diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a861a68e..c9f22f046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -534,6 +534,18 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of ``. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading. - In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div` followed by its CSS, or `styled(Base)`, is now indexed as a component, as one written without a type argument already was. Before, it was indexed as a plain constant, so `codegraph callers` and impact found none of the code that renders it, and its tags could link to another file's component of the same name instead: in outline/outline, `NudeButton` and `HStack` had no callers, and `` tags linked to a different `Text` than the one each file imports. Re-index TypeScript React projects after upgrading. + +- The call paths `codegraph context` prints now say what bridges each dynamic-dispatch hop, in the same words `codegraph_explore` uses: a call through an interface reads `interface → impl`, and a C++ override, a Redux thunk or a Gin middleware chain is named as one. Before, every hop of a kind it didn't know was labeled an "event", so a call through an interface looked like an event being emitted. + +- In C++, a call on a range-based `for` loop's variable, like `suite->SetVerbose(verbose)` inside `for (ConformanceTestSuite *suite : suites)`, now links to the method of the type the loop declares. Before, the loop's declaration was never read, so the call was matched by the variable's name alone: in RocksDB, `log->file()` on a `log::Writer` went to `BlobLogWriter::file`, and a loop variable named like a member of its class was taken for that member. The variable counts only inside its loop, and loops over `auto` elements resolve as before. Re-index C++ projects after upgrading. + +- In C++, a call made with `->` on a variable, parameter or member declared as a `std::unique_ptr`, `std::shared_ptr` or `std::optional`, like `iter->Valid()` on a `std::unique_ptr iter`, now links to that method of the type it holds, or of a class that type inherits from, with the type looked up the way C++ does it, so a class of the same name nested elsewhere is not mistaken for it. Before, the call went to whichever project method shared its name: in facebook/rocksdb, `iter->Valid()` reached `ArenaWrappedDBIter::Valid` and `fs->GetFileSize(…)` on a `std::shared_ptr` reached a test's stand-in file system, and calls like `it->Next()` were often not linked at all. When what it holds is a template parameter or a standard-library type, like `std::unique_ptr>`, the call no longer links to a project method. Re-index C++ projects after upgrading. + +- `codegraph sync` and the file watcher now move a React Router route to its page when the router file loads that page lazily into a value of its own, like `const Docs = lazy(() => import('./pages/Docs'))` rendered by `} />`, and the page's file is added after the router was indexed, comes back after being deleted, or gets or changes its default export in a later edit. Before, the route kept pointing at the `Docs` declaration, or at the page's old component, until the router file itself changed or the project was re-indexed, unless the page's component happened to share the declaration's name. The same now holds for a `lazy: () => import('./pages/Docs')` route whose page switches its default export to another component. Re-index React projects after upgrading to correct routes an earlier version left behind. + +- `codegraph sync` and the file watcher now link a component to the JSX that renders it, like ``, when the component is added in a file with no JSX of its own, as in `export default function Team() { return null; }`. Before, the link waited for an unrelated change to a file with JSX, or for a re-index, so the new component looked unused. + +- In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. Re-index JavaScript and TypeScript projects after upgrading. #### MCP / indexing - Full indexes of large projects no longer sometimes run two to three times slower than usual. A timing clash between two background database maintenance steps could switch reference resolution from several threads to one partway through. diff --git a/README.md b/README.md index a6011a56a..128ddb1ee 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. ## About this fork -This is **bompus/codegraph**, a fork of [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Its default branch, `fork/consolidated`, contains upstream `main` through [`b635dd46`](https://github.com/colbymchenry/codegraph/commit/b635dd467f0578926a9c01a37b9d28d2b26689f1) (after v1.6.2) plus the fork's own work, and it takes upstream changes as they land. Changes that suit upstream are also offered there as pull requests. +This is **bompus/codegraph**, a fork of [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Its default branch, `fork/consolidated`, contains upstream `main` through [`c0f45c7d`](https://github.com/colbymchenry/codegraph/commit/c0f45c7d) (after v1.6.2) plus the fork's own work, and it takes upstream changes as they land. Changes that suit upstream are also offered there as pull requests. The fork publishes no releases. The install scripts, npm package, badges and `codegraph upgrade` further down this page install **upstream's** releases. To run the fork, build it from source (below). @@ -80,7 +80,7 @@ Session search uses `.codegraph/sessions-v2.db`. The first session search import ### What the fork adds -Compared with upstream `main` at `b635dd46` (after v1.6.2). Each item here and in the dispatch and framework lists below was checked against upstream's tree at that commit. +Compared with upstream `main` at `c0f45c7d` (after v1.6.2). Each item here and in the dispatch and framework lists below was checked against upstream's tree at that commit. | Feature | Upstream | Fork | What it does | |---|:-:|:-:|---| @@ -150,6 +150,7 @@ Call resolution the fork adds: - Kotlin chains on instance and extension receivers use the declared return type, including nested and multiline calls; upstream covers class and companion-factory chains. An imported return-type guess for an unknown receiver keeps confidence at most 0.7, and so does a Rust chain match without a proved receiver type. - Kotlin multi-dollar strings keep their interpolation threshold. - C++ typedef and using aliases follow their declaration scope, including nested owners and inherited aliases. Pointer, array and function members are not supertypes. Visible class-scoped aliases and declared complex receivers keep their method owners; unsubstituted template parameters and ambiguous owners stay unresolved. +- C++ `->` calls on a `std::unique_ptr`, `std::shared_ptr` or `std::optional` reach the type it holds, looked up through the caller's enclosing classes and their bases, its namespaces, `using` directives and aliases. A held type the project does not declare leaves the call unresolved, as a plain pointer to one does; upstream guesses a callee from the receiver's name there. A partial class template specialization is indexed as an anonymous class, so a call to a member only it declares stays unresolved. - C++ namespace aliases and declarations in macro-opened namespaces are looked up only in the caller's include closure; upstream pools them across all files. - C# namespace `using` directives and `using` aliases apply only inside their enclosing namespace, not to sibling namespaces in the same file (`using static` is still file-wide, as upstream). - A Java field declared with a qualified type (`outside.Repository`) keeps its qualifier, so calls through it never resolve to an unrelated project class with the same simple name. diff --git a/__tests__/cpp-range-for-receiver.test.ts b/__tests__/cpp-range-for-receiver.test.ts new file mode 100644 index 000000000..67da5c2f9 --- /dev/null +++ b/__tests__/cpp-range-for-receiver.test.ts @@ -0,0 +1,446 @@ +/** + * A C++ range-based for loop declares the receiver its body calls through. + * + * protocolbuffers/protobuf's conformance runner loops `for + * (ConformanceTestSuite *suite : suites)` and calls `suite->SetVerbose(…)`, + * `suite->RunSuite(…)` and `suite->GetFailureListFlagName()` in the body. + * Receiver inference reads a declaration only when the declared name is + * followed by `;`, `=`, `,`, `)`, `[`, `{` or `(`, so it never read a loop's + * `Type name :` and the calls fell through to a guess by the receiver's name. + * A guess can still land on the right method by a shared word, so these tests + * check how the edge was made (the typed path's confidence), not only where + * it goes. + * + * The loop's variable is in scope in the loop's body only: after the loop, or + * in another function, a receiver of the same name is some other variable. + * A `for` in a comment or a string literal declares nothing, and a colon + * outside a loop's header never ends a declaration: not a bit-field's + * (`unsigned car : 1;`), nor the scope operator's (`void parts::Install()`). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-range-for-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +function callable(cg: CodeGraph, qualifiedName: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** `calls` callees of a function or method, as `qualifiedName @confidence`, one per call site. */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(callable(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => `${r.node.qualifiedName} @${(r.edge.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +/** protocolbuffers/protobuf's conformance runner, trimmed; `FlagParser` is a decoy `SetVerbose`. */ +const CONFORMANCE = { + 'conformance/suite.h': [ + '#include ', + 'namespace google {', + 'namespace protobuf {', + 'class ConformanceTestRunner {', + ' public:', + ' virtual ~ConformanceTestRunner() = default;', + '};', + 'class ConformanceTestSuite {', + ' public:', + ' void SetVerbose(bool verbose) { verbose_ = verbose; }', + ' std::string GetFailureListFlagName() { return failure_list_flag_name_; }', + ' bool RunSuite(ConformanceTestRunner* runner, std::string* output,', + ' const std::string& filename);', + ' private:', + ' bool verbose_ = false;', + ' std::string failure_list_flag_name_;', + '};', + 'class FlagParser {', + ' public:', + ' void SetVerbose(bool verbose) {}', + '};', + '} // namespace protobuf', + '} // namespace google', + '', + ].join('\n'), + 'conformance/suite.cc': [ + '#include "conformance/suite.h"', + 'namespace google {', + 'namespace protobuf {', + 'bool ConformanceTestSuite::RunSuite(ConformanceTestRunner* runner, std::string* output,', + ' const std::string& filename) {', + ' return true;', + '}', + '} // namespace protobuf', + '} // namespace google', + '', + ].join('\n'), + 'conformance/runner.cc': [ + '#include ', + '#include ', + '#include "conformance/suite.h"', + 'namespace google {', + 'namespace protobuf {', + 'int ForkPipeRunnerMain(int argc, char* argv[], const std::vector& suites) {', + ' bool verbose = false;', + ' for (ConformanceTestSuite *suite : suites) {', + ' std::string failure_list_filename;', + ' for (int arg = 1; arg < argc; ++arg) {', + ' if (strcmp(argv[arg], suite->GetFailureListFlagName().c_str()) == 0) {', + ' failure_list_filename = argv[arg];', + ' }', + ' }', + ' suite->SetVerbose(verbose);', + ' std::string output;', + ' ConformanceTestRunner* runner = nullptr;', + ' verbose = verbose && suite->RunSuite(runner, &output,', + ' failure_list_filename);', + ' }', + ' return 0;', + '}', + '} // namespace protobuf', + '} // namespace google', + '', + ].join('\n'), +}; + +/** Two `Start`s no receiver name below points at, and a class whose member is named like a loop variable. */ +const GARAGE = { + 'garage/vehicle.h': [ + '#include ', + 'namespace garage {', + 'class Engine {', + ' public:', + ' void Start();', + ' int Torque() const;', + '};', + 'class Car {', + ' public:', + ' void Start();', + '};', + 'namespace parts {', + 'class Part {', + ' public:', + ' void Fit();', + '};', + 'void Install();', + '} // namespace parts', + '} // namespace garage', + '', + ].join('\n'), + 'garage/vehicle.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Engine::Start() {}', + 'int Engine::Torque() const { return 0; }', + 'void Car::Start() {}', + 'void parts::Part::Fit() {}', + '} // namespace garage', + '', + ].join('\n'), + 'garage/garage.h': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'class Garage {', + ' public:', + ' void Inspect(const std::vector& engines);', + ' void Service();', + ' private:', + ' Car* car;', + '};', + '} // namespace garage', + '', + ].join('\n'), + 'garage/garage.cc': [ + '#include "garage/garage.h"', + 'namespace garage {', + 'void Garage::Inspect(const std::vector& engines) {', + ' for (Engine* car : engines) car->Start();', + '}', + 'void Garage::Service() {', + ' car->Start();', + '}', + '} // namespace garage', + '', + ].join('\n'), +}; + +describe('a C++ range-based for declares its loop variable', () => { + it('reproduction: protobuf\'s conformance runner calls the suite\'s methods through the typed path', async () => { + const cg = await indexed(CONFORMANCE); + try { + // The guess by the receiver's name lands on the same methods (0.65 / + // 0.7); the loop's declared type makes them certain. + expect(calls(cg, 'google::protobuf::ForkPipeRunnerMain')).toEqual([ + 'google::protobuf::ConformanceTestSuite::GetFailureListFlagName @0.9', + 'google::protobuf::ConformanceTestSuite::RunSuite @0.9', + 'google::protobuf::ConformanceTestSuite::SetVerbose @0.9', + ]); + } finally { + cg.close(); + } + }); + + it('a loop variable that hides a member is the element, not the member', async () => { + const cg = await indexed(GARAGE); + try { + // Reading on past the loop reached the member `Car* car;` in the header. + expect(calls(cg, 'garage::Garage::Inspect')).toEqual(['garage::Engine::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('after the loop, and in another function, a same-named receiver is not the loop\'s variable', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/fleet.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Drive(Car* engine, const std::vector& engines) {', + ' for (Engine* engine : engines) {', + ' engine->Start();', + ' }', + ' engine->Start();', + '}', + 'void Wash(Car* engine, const std::vector& engines) {', + ' for (Engine* engine : engines) engine->Start();', + ' engine->Start();', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::Drive')).toEqual(['garage::Car::Start @0.9', 'garage::Engine::Start @0.9']); + // A loop without braces ends with its one statement. + expect(calls(cg, 'garage::Wash')).toEqual(['garage::Car::Start @0.9', 'garage::Engine::Start @0.9']); + // Garage::Service's `car` is the member, though Inspect's loop above it names one `car`. + expect(calls(cg, 'garage::Garage::Service')).toEqual(['garage::Car::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('reads references, pointers, qualified and global-scope types, and a header split over lines', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/shop.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void ByReference(std::vector& engines) {', + ' for (Engine& e : engines) e.Start();', + '}', + 'void ByConstReference(const std::vector& engines) {', + ' for (const Engine& e : engines) {', + ' if (e.Torque() > 0) {}', + ' }', + '}', + 'void StarOnName(const std::vector& engines) {', + ' for (Engine *e : engines)', + ' e->Start();', + '}', + 'void Qualified(const std::vector& list) {', + ' for (::garage::parts::Part* p : list) p->Fit();', + '}', + 'void SplitHeader(const std::vector& engines) {', + ' for (Engine* first_engine_in_the_list :', + ' engines) {', + ' first_engine_in_the_list->Start();', + ' }', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::ByReference')).toEqual(['garage::Engine::Start @0.9']); + expect(calls(cg, 'garage::ByConstReference')).toEqual(['garage::Engine::Torque @0.9']); + expect(calls(cg, 'garage::StarOnName')).toEqual(['garage::Engine::Start @0.9']); + expect(calls(cg, 'garage::Qualified')).toEqual(['garage::parts::Part::Fit @0.9']); + expect(calls(cg, 'garage::SplitHeader')).toEqual(['garage::Engine::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('a brace in a comment or a string literal does not end the loop', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/braces.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Braces(const std::vector& engines) {', + ' for (Engine* e : engines) {', + ' // A comment\'s } is not the loop\'s.', + ' const char* close = "}";', + ' /* nor is } this one', + ' } or this */', + ' char brace = \'}\';', + ' e->Start();', + ' }', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::Braces')).toEqual(['garage::Engine::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('a nested loop\'s call is inside the outer loop\'s body', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/nested.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Nested(const std::vector& list) {', + ' for (parts::Part* p : list) {', + ' for (int i = 0; i < 2; ++i) {', + ' if (i) { p->Fit(); }', + ' }', + ' }', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::Nested')).toEqual(['garage::parts::Part::Fit @0.9']); + } finally { + cg.close(); + } + }); +}); + +describe('what declares no loop variable', () => { + it('a `for` in a comment or a string literal', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/notes.cc': [ + '#include ', + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Commented(Car* car) {', + ' // for (Engine* car : engines) {', + ' car->Start();', + '}', + 'void Inline(Car* car) {', + ' /* for (Engine* car : engines) { */ car->Start();', + '}', + 'std::string Template(Car* car) {', + ' std::string code = "for (Engine* car : engines) {";', + ' car->Start();', + ' return code + "}";', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::Commented')).toEqual(['garage::Car::Start @0.9']); + expect(calls(cg, 'garage::Inline')).toEqual(['garage::Car::Start @0.9']); + expect(calls(cg, 'garage::Template')).toEqual(['garage::Car::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('a bit-field', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/lot.h': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'struct Slot {', + ' unsigned car : 1;', + '};', + 'class Lot {', + ' public:', + ' void Park();', + ' private:', + ' Car* car;', + '};', + '} // namespace garage', + '', + ].join('\n'), + 'garage/lot.cc': [ + '#include "garage/lot.h"', + 'namespace garage {', + 'void Lot::Park() {', + ' car->Start();', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + // The member `Car* car;`, not Slot's `unsigned car : 1;` above it. + expect(calls(cg, 'garage::Lot::Park')).toEqual(['garage::Car::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('a name followed by the scope operator', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/bay.h': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'class Bay {', + ' public:', + ' void Open();', + ' void Fit(const std::vector& list);', + ' private:', + ' Car* parts;', + '};', + '} // namespace garage', + '', + ].join('\n'), + 'garage/bay.cc': [ + '#include "garage/bay.h"', + 'namespace garage {', + 'void parts::Install() {}', + 'void Bay::Open() {', + ' parts->Start();', + '}', + 'void Bay::Fit(const std::vector& list) {', + ' for (parts::Part* part : list) parts->Start();', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + // `void parts::Install()` declares no `parts`; the member does. + expect(calls(cg, 'garage::Bay::Open')).toEqual(['garage::Car::Start @0.9']); + // `parts::Part` declares the loop's `part`; the receiver `parts` is still the member. + expect(calls(cg, 'garage::Bay::Fit')).toEqual(['garage::Car::Start @0.9']); + } finally { + cg.close(); + } + }); +}); diff --git a/__tests__/cpp-smart-pointer-receiver.test.ts b/__tests__/cpp-smart-pointer-receiver.test.ts new file mode 100644 index 000000000..fddbf8370 --- /dev/null +++ b/__tests__/cpp-smart-pointer-receiver.test.ts @@ -0,0 +1,457 @@ +/** + * A C++ `->` call on a receiver declared as a standard smart pointer or + * optional — `std::unique_ptr iter; iter->Valid()` — calls a member + * of the type it holds. + * + * facebook/rocksdb has about 14,000 of these calls on a declaration in the + * calling function or its class. Receiver inference read the declared type as + * `unique_ptr`, which has no project methods, so the call fell through to a + * guess by the method's name and the receiver's words: `iter->Valid()` went + * to `ArenaWrappedDBIter::Valid`, `db_copy->Get()` to a test's `Get`. + * + * The held type is looked up as C++ looks a name up where the call is + * written — the scopes around the call, the file's `using`s, aliases + * followed — and the method is taken from that class or the classes it + * derives from. Its last name alone is not enough: rocksdb's `Iterator` is + * also the name of `MemTableRep::Iterator` and other nested classes, while the + * `Iterator` the call means inherits `Valid` from `IteratorBase`. A template + * parameter, a library type and an alias of a type outside the project hold + * no project class, so those calls get no edge; neither does a call the held + * class has no method for (unless a class template's specialization may + * declare it). A held type the project doesn't declare — a generated message + * — leaves the call as it was. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-held-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +function callable(cg: CodeGraph, qualifiedName: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** `calls` callees of a function or method, as `qualifiedName`. */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(callable(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => r.node.qualifiedName) + .sort(); +} + +const lines = (...l: string[]): string => [...l, ''].join('\n'); + +/** rocksdb's iterator family, trimmed: the public Iterator, a nested namesake, and an implementation a guess reaches. */ +const ROCKSDB = { + 'include/rocksdb/iterator_base.h': lines( + 'namespace rocksdb {', + 'class IteratorBase {', + ' public:', + ' virtual ~IteratorBase() {}', + ' virtual bool Valid() const = 0;', + ' virtual void Next() = 0;', + '};', + '} // namespace rocksdb', + ), + 'include/rocksdb/iterator.h': lines( + '#include "include/rocksdb/iterator_base.h"', + 'namespace rocksdb {', + 'class Iterator : public IteratorBase {', + ' public:', + ' virtual int value() const = 0;', + '};', + '} // namespace rocksdb', + ), + 'include/rocksdb/memtablerep.h': lines( + 'namespace rocksdb {', + 'class MemTableRep {', + ' public:', + ' class Iterator {', + ' public:', + ' virtual bool Valid() const = 0;', + ' virtual void Next() = 0;', + ' };', + '};', + '} // namespace rocksdb', + ), + 'db/arena_wrapped_db_iter.h': lines( + '#include "include/rocksdb/iterator.h"', + 'namespace rocksdb {', + 'class ArenaWrappedDBIter : public Iterator {', + ' public:', + ' bool Valid() const override { return true; }', + ' void Next() override {}', + ' int value() const override { return 0; }', + '};', + '} // namespace rocksdb', + ), + 'include/rocksdb/db.h': lines( + '#include "include/rocksdb/iterator.h"', + 'namespace rocksdb {', + 'class DB {', + ' public:', + ' virtual int Get(int key) = 0;', + ' virtual Iterator* NewIterator() = 0;', + '};', + 'class DBGetBatch {', + ' public:', + ' int Get(int key) { return key; }', + '};', + '} // namespace rocksdb', + ), +}; + +describe('a `->` call on a standard smart pointer or optional reaches the type it holds', () => { + it('reproduction: `std::unique_ptr iter; iter->Valid()` is the Iterator the scope sees, not a namesake', async () => { + const cg = await indexed({ + ...ROCKSDB, + 'db/db_impl.cc': lines( + '#include ', + '#include "include/rocksdb/db.h"', + '#include "db/arena_wrapped_db_iter.h"', + 'namespace rocksdb {', + 'int CountKeys(DB* db) {', + ' std::unique_ptr iter(db->NewIterator());', + ' int n = 0;', + ' for (; iter->Valid(); iter->Next()) n += iter->value();', + ' return n;', + '}', + '} // namespace rocksdb', + ), + }); + try { + // `Valid` and `Next` are IteratorBase's, which Iterator derives from. + expect(calls(cg, 'rocksdb::CountKeys')).toEqual([ + 'rocksdb::DB::NewIterator', + 'rocksdb::Iterator::value', + 'rocksdb::IteratorBase::Next', + 'rocksdb::IteratorBase::Valid', + ]); + } finally { + cg.close(); + } + }); + + it('`std::shared_ptr`, `std::optional`, a parameter, and a member the class declares in its header', async () => { + const cg = await indexed({ + ...ROCKSDB, + // Classes the receivers' names point a guess at. + 'table/table_db.h': lines( + 'namespace rocksdb {', + 'class TableDB {', + ' public:', + ' int Get(int key) { return key; }', + '};', + 'class CuckooTableDB {', + ' public:', + ' int Get(int key) { return key; }', + '};', + '} // namespace rocksdb', + ), + 'db/db_impl.h': lines( + '#include ', + '#include "include/rocksdb/db.h"', + 'namespace rocksdb {', + 'class DBImpl {', + ' public:', + ' int Lookup(int key);', + ' private:', + ' std::shared_ptr table_db_;', + '};', + '} // namespace rocksdb', + ), + 'db/db_impl.cc': lines( + '#include ', + '#include "db/db_impl.h"', + '#include "table/table_db.h"', + 'namespace rocksdb {', + 'int DBImpl::Lookup(int key) {', + ' return table_db_->Get(key);', + '}', + 'int Probe(const std::shared_ptr& cuckoo_db) {', + ' std::optional batch;', + ' return cuckoo_db->Get(1) + batch->Get(2);', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::DBImpl::Lookup')).toEqual(['rocksdb::DB::Get']); + expect(calls(cg, 'rocksdb::Probe')).toEqual(['rocksdb::DB::Get', 'rocksdb::DBGetBatch::Get']); + } finally { + cg.close(); + } + }); + + it('a nested class is found first from inside the class that declares it', async () => { + const cg = await indexed({ + ...ROCKSDB, + 'memtable/skiplistrep.cc': lines( + '#include ', + '#include "include/rocksdb/iterator.h"', + '#include "include/rocksdb/memtablerep.h"', + 'namespace rocksdb {', + 'class SkipListRep : public MemTableRep {', + ' public:', + ' bool Check();', + ' private:', + ' std::unique_ptr iter_;', + '};', + 'bool SkipListRep::Check() { return iter_->Valid(); }', + '} // namespace rocksdb', + ), + }); + try { + // MemTableRep's own `Iterator`, which SkipListRep inherits, hides rocksdb::Iterator. + expect(calls(cg, 'rocksdb::SkipListRep::Check')).toEqual(['rocksdb::MemTableRep::Iterator::Valid']); + } finally { + cg.close(); + } + }); + + it('a held type named through an alias, a `using namespace`, and a class local to the function', async () => { + const cg = await indexed({ + ...ROCKSDB, + 'table/internal_iterator.h': lines( + 'namespace rocksdb {', + 'template ', + 'class InternalIteratorBase {', + ' public:', + ' virtual void SeekToFirst() = 0;', + '};', + 'using InternalIterator = InternalIteratorBase;', + 'class MergingIterator : public InternalIterator {', + ' public:', + ' void SeekToFirst() override {}', + '};', + '} // namespace rocksdb', + ), + 'table/scan.cc': lines( + '#include ', + '#include "table/internal_iterator.h"', + 'namespace rocksdb {', + 'void ScanTable(InternalIterator* raw) {', + ' std::unique_ptr it(raw);', + ' it->SeekToFirst();', + '}', + 'class EventListener {', + ' public:', + ' void OnFlushCompleted() {}', + '};', + 'void Notify() {', + ' class FlushListener {', + ' public:', + ' void OnFlushCompleted() {}', + ' };', + ' std::shared_ptr listener;', + ' listener->OnFlushCompleted();', + '}', + '} // namespace rocksdb', + ), + 'tools/dump.cc': lines( + '#include ', + '#include "include/rocksdb/db.h"', + '#include "include/rocksdb/memtablerep.h"', + 'using namespace rocksdb;', + 'int Dump(DB* db) {', + ' std::unique_ptr it(db->NewIterator());', + ' return it->Valid() ? 1 : 0;', + '}', + ), + }); + try { + expect(calls(cg, 'rocksdb::ScanTable')).toEqual(['rocksdb::InternalIteratorBase::SeekToFirst']); + expect(calls(cg, 'rocksdb::Notify')).toEqual(['rocksdb::Notify::FlushListener::OnFlushCompleted']); + expect(calls(cg, 'Dump')).toEqual(['rocksdb::DB::NewIterator', 'rocksdb::IteratorBase::Valid']); + } finally { + cg.close(); + } + }); +}); + +describe('a held type that is no class of the project\'s gets no guess', () => { + it('a template parameter, a library type, and an alias of one', async () => { + const cg = await indexed({ + ...ROCKSDB, + 'table/block.h': lines( + 'namespace rocksdb {', + 'class Block {', + ' public:', + ' unsigned size() const { return 0; }', + ' int front() const { return 0; }', + '};', + '} // namespace rocksdb', + ), + 'db_stress_tool/stress.cc': lines( + '#include ', + '#include ', + '#include ', + '#include "db/arena_wrapped_db_iter.h"', + '#include "table/block.h"', + 'namespace rocksdb {', + 'template ', + 'void TestIterate(IterType* raw) {', + ' std::unique_ptr iter(raw);', + ' iter->Next();', + '}', + 'using BlockList = std::vector;', + 'class BlockBasedTableIterator {', + ' public:', + ' int First();', + ' private:', + ' std::unique_ptr> block_handles_;', + ' std::shared_ptr block_list_;', + '};', + 'int BlockBasedTableIterator::First() {', + ' return block_handles_->front() + static_cast(block_list_->size());', + '}', + '} // namespace rocksdb', + ), + }); + try { + // Not ArenaWrappedDBIter::Next, Block::front or Block::size. + expect(calls(cg, 'rocksdb::TestIterate')).toEqual([]); + expect(calls(cg, 'rocksdb::BlockBasedTableIterator::First')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('a class that has no such method, nor does any class it derives from', async () => { + const cg = await indexed({ + ...ROCKSDB, + 'db/compaction.cc': lines( + '#include ', + '#include "include/rocksdb/db.h"', + '#include "db/arena_wrapped_db_iter.h"', + 'namespace rocksdb {', + 'class CompactionIterator {', + ' public:', + ' void Release() {}', + '};', + 'void Compact(DB* raw) {', + ' std::unique_ptr db(raw);', + ' db->Release();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Compact')).toEqual([]); + } finally { + cg.close(); + } + }); +}); + +describe('what keeps the call as it was', () => { + it('a type the project does not declare, such as a generated message, and a class template\'s specialization', async () => { + // protocolbuffers/protobuf's tests hold generated messages, which derive + // from Message; facebook/rocksdb's order-maintenance tree has members only + // a specialization declares. + const cg = await indexed({ + 'src/google/protobuf/message.h': lines( + 'namespace google {', + 'namespace protobuf {', + 'class Message {', + ' public:', + ' unsigned SpaceUsedLong() const { return 0; }', + '};', + '} // namespace protobuf', + '} // namespace google', + ), + 'src/google/protobuf/extension_set_unittest.cc': lines( + '#include ', + '#include "src/google/protobuf/message.h"', + 'namespace google {', + 'namespace protobuf {', + 'unsigned SpaceUsed() {', + ' std::unique_ptr message;', + ' return message->SpaceUsedLong();', + '}', + '} // namespace protobuf', + '} // namespace google', + ), + 'util/omt.h': lines( + '#include ', + 'namespace toku {', + 'template ', + 'class omt_node {', + ' public:', + ' void clear_stolen_bits() {}', + '};', + 'template ', + 'class omt_node {', + ' public:', + ' bool get_marked() const { return false; }', + '};', + 'bool HasMarks() {', + ' std::unique_ptr> node;', + ' return node->get_marked();', + '}', + '} // namespace toku', + ), + }); + try { + // The fork's resolver guesses no callee by the receiver's name when the + // receiver's declared type is one the project doesn't declare (a plain + // `unittest::TestAllExtensions* message` gets none either), so the held + // type's call stays as unresolved as the pointer's. + expect(calls(cg, 'google::protobuf::SpaceUsed')).toEqual([]); + // A partial specialization is extracted as an anonymous class here, so its + // `get_marked` is not a member of `omt_node` and the call stays unresolved; + // what matters is that it does not reach `omt_node`'s other methods. + expect(cg.getCallees(callable(cg, 'toku::HasMarks').id).filter((r) => r.edge.kind === 'calls').map((r) => r.node.name)).toEqual([]); + } finally { + cg.close(); + } + }); + + it('`->` through a raw pointer to a smart pointer is the smart pointer\'s own member', async () => { + const cg = await indexed({ + ...ROCKSDB, + 'cache/cache.h': lines( + 'namespace rocksdb {', + 'class Cache {', + ' public:', + ' void reset() {}', + '};', + '} // namespace rocksdb', + ), + 'db/version_set.cc': lines( + '#include ', + '#include "include/rocksdb/iterator.h"', + '#include "cache/cache.h"', + 'namespace rocksdb {', + 'void Drop(std::unique_ptr* iter_cache) {', + ' iter_cache->reset();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::Drop')).toEqual([]); + } finally { + cg.close(); + } + }); +}); diff --git a/__tests__/default-export-declaration.test.ts b/__tests__/default-export-declaration.test.ts new file mode 100644 index 000000000..ab39db1c2 --- /dev/null +++ b/__tests__/default-export-declaration.test.ts @@ -0,0 +1,274 @@ +/** + * A default import is the declaration the module's `export default` statement + * names, wherever it sits in the file — not the first exported function. + * + * A React Router 6.4+ data-router page exports its loader or action above the + * page component (`export function loader() {…}` then `export default function + * Vans() {…}`), and `import Vans, { loader as vansLoader } from './Vans'` bound + * `Vans` to `loader`: the route `/vans` rendered the loader, and every call + * through the default import went there too. A component file that exports a + * styled or memo component lost its default function or binding to that + * component, and an anonymous default (`export default function () {…}`, + * `export default () => …`) took whatever exported function came first — even + * one nested inside the default itself. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +const files: Record = { + 'package.json': JSON.stringify({ name: 'vanlife', private: true, dependencies: { react: '^18', 'react-router-dom': '^6.4' } }), + 'src/index.jsx': `import { RouterProvider, createBrowserRouter, createRoutesFromElements, Route } from "react-router-dom" +import Vans, { loader as vansLoader } from "./pages/Vans/Vans" +import HostVans, { loader as hostVansLoader } from "./pages/Host/HostVans" +import Login, { action as loginAction } from "./pages/Login" + +const router = createBrowserRouter(createRoutesFromElements( + + } action={loginAction} /> + } loader={vansLoader} /> + } loader={hostVansLoader} /> + +)) + +export default function App() { + return +} +`, + 'src/api.js': `export function getVans() { + return fetch("/api/vans") +} +`, + 'src/pages/Vans/Vans.jsx': `import { useLoaderData, defer } from "react-router-dom" +import { getVans } from "../../api" + +export function loader() { + return defer({ vans: getVans() }) +} + +export default function Vans() { + const dataPromise = useLoaderData() + function renderVanElements(vans) { + return vans.map((van) =>

{van.name}

) + } + return
{renderVanElements(dataPromise.vans)}
+} +`, + 'src/pages/Host/HostVans.jsx': `import { useLoaderData } from "react-router-dom" +import { getVans } from "../../api" + +export const loader = async () => getVans() + +export default function HostVans() { + return
    {useLoaderData().length}
+} +`, + 'src/pages/Login.jsx': `import { useActionData } from "react-router-dom" + +export async function action({ request }) { + return request.formData() +} + +export default function Login() { + const data = useActionData() + return
{data}
+} +`, + // A Next.js API route: a helper exported above the default handler. + 'src/api/vans.ts': `export function listVans() { + return [] +} + +export default async function handler(req: unknown, res: { json(v: unknown): void }) { + res.json(listVans()) +} +`, + 'src/store.ts': `export function createStore() { + return {} +} + +export default class Store { + read() { + return 1 + } +} +`, + // A regex with a backtick inside a template literal's interpolation + // (outline's headingToSlug): a string masker loses step there and blanked + // the statement below it. + 'src/slug.ts': `export function escapeHtml(text: string) { + return text +} + +function safeSlugify(text: string) { + return \`h-\${escapeHtml(text).replace(/[\`]/g, "")}\` +} + +export default function headingToSlug(text: string, index = 0) { + return index === 0 ? safeSlugify(text) : \`\${safeSlugify(text)}-\${index}\` +} +`, + // A line break between the statement and the declaration it writes. + 'src/stream.js': `export function emit() { + return 1 +} + +export default +function* stream() { + yield emit() +} +`, + // An exported styled component above the default function, or the binding. + 'src/components/Title.tsx': `import styled from "styled-components" + +export const Heading = styled.h1\` + font-size: 2rem; +\` + +export default function Title() { + return Vans +} +`, + 'src/components/Banner.tsx': `import styled from "styled-components" + +export const Strip = styled.div\` + display: flex; +\` + +const Banner = () => + +export default Banner +`, + // Anonymous defaults name no declaration of their own. + 'src/anonymous.js': `export function helper() { + return 1 +} + +export default function () { + function inner() { + return helper() + } + return inner() +} +`, + 'src/arrow.jsx': `export function useThing() { + return 1 +} + +export default () => { + const handle = () => useThing() + return