Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `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 `<Route path="/docs" element={<Docs />} />`, 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 `<Team />`, 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. A module loaded with `require('./x').default` is read the same way. Re-index JavaScript and TypeScript projects after upgrading.
- In JavaScript and TypeScript, an import now links to what its module passes on when the module exports an import of its own, like outline's `server/presenters/index.ts`, which imports each presenter and lists it in `export { presentUser, presentPolicies, … }`, or an `index.ts` written `import Footer from './FooterCenter'; export { Footer }` or `export default Avatar`, and `export { createThing as default }` is read too. A default export written as an expression now links as well: `export default new Logger()` to the methods of that `Logger` class, and a wrapper like `export default connect(mapState)(Profile)`, `observer(DocumentNew)` or `traceFunction({ … })(accountProvisioner)` to the component or function it wraps. Before, such an import was matched by its name alone, so it could land on a namesake elsewhere in the project, as when outline's server logging calls linked to the browser app's `Logger` and excalidraw's public `Footer` to an internal component of that name, or on whichever function the module happened to export first. Re-index JavaScript and TypeScript projects after upgrading.
- In React and Vue, a tag for a component the file imports from a package, like `<Button>` from antd, `<Typography>` from `@mui/material/Typography`, `<Link>` from react-router-dom or `<Card>` from ant-design-vue, no longer links to a component, function or class in your repository that only shares its name. Before, the tag linked to whatever the repository had by that name, so `codegraph_explore`, callers and impact followed renders that never happen: in SigNoz, antd's `<Button>` tags showed up as callers of a styled link on its 404 page, and in Material UI dashboards like mantis and berry, `<Typography>` and `<Button>` tags showed up as callers of the theme's style overrides. A package here is one that a `package.json` above the file lists and that no file in the repository answers to. A tag imported through a path alias like `components/Modal`, from a workspace package of the repository, or under a name no `package.json` lists still links as before. Re-index React and Vue projects after upgrading.
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.
Expand Down
193 changes: 193 additions & 0 deletions __tests__/import-forwarding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* An import reaches the declaration its module exports, when the module
* exports an import of its own or writes its default export as an expression.
*
* A module that hands on what it imports — outline's `presenters/index.ts`
* is `import presentUser from './user'; … export { presentUser, … }`, and an
* `index.ts` beside a component is `import Avatar from './Avatar'; export
* default Avatar` — declares nothing under the exported name, so the import
* fell through to a name match, which took any namesake in the project. A
* default written as an expression had only the first-exported-function guess
* behind it: `export default new Logger()` sent a server's logging calls to
* the browser app's `Logger` (outline makes one in each), and a function a
* wrapper hands on (`traceFunction({…})(accountProvisioner)`) or one `export
* { createThing as default }` names was replaced by whatever the module
* exported first.
*
* The sync cases compare the synced graph with a full index of the final tree
* in a folder of its own (#2411).
*/
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/index';
import { DatabaseConnection, getDatabasePath } from '../src/db';

type Files = Record<string, string>;

let roots: string[] = [];
let graphs: CodeGraph[] = [];

afterEach(() => {
for (const graph of graphs) graph.close();
graphs = [];
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});

function tempRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-forwarding-'));
roots.push(root);
return root;
}

function write(root: string, files: Files): void {
for (const [file, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
fs.writeFileSync(path.join(root, file), text);
}
}

async function index(files: Files, root = tempRoot()): Promise<CodeGraph> {
write(root, files);
const graph = await CodeGraph.init(root, { index: true });
graphs.push(graph);
return graph;
}

/** Where the calls out of `source` landed: `<kind> <file>::<qualified name>`. */
function callsOf(graph: CodeGraph, source: string): string[] {
const nodes = graph.getFiles().flatMap((file) => graph.getNodesInFile(file.path));
const from = nodes.filter((n) => n.name === source).map((n) => n.id);
return graph.getOutgoingEdgesFrom(from)
.filter((e) => e.kind === 'calls')
.map((e) => {
const target = graph.getNode(e.target)!;
return `${target.kind} ${target.filePath}::${target.qualifiedName}`;
})
.sort();
}

/** Every edge by its ends' natural keys, and every reference left unresolved. */
function stateOf(root: string, graph: CodeGraph): string[] {
const keys = new Map(
graph.getFiles()
.flatMap((file) => graph.getNodesInFile(file.path))
.map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const)
);
const edges = graph.getOutgoingEdgesFrom([...keys.keys()])
.map((e) => `${e.kind} ${keys.get(e.source)} -> ${keys.get(e.target) ?? e.target} @${e.line}:${e.column} ${JSON.stringify(e.metadata ?? {})}`);
const db = DatabaseConnection.open(getDatabasePath(root));
try {
const refs = (db.getDb()
.prepare('SELECT reference_name, reference_kind, file_path, line, col, status, name_tail FROM unresolved_refs')
.all() as Array<Record<string, unknown>>)
.map((r) => `ref ${r.file_path}:${r.line}:${r.col} ${r.reference_kind} ${r.reference_name} ${r.status} ${r.name_tail}`);
return [...edges, ...refs].sort();
} finally {
db.close();
}
}

/** Index `before`, write `after` over it, sync; return the graph and what a full index of `after` holds. */
async function syncEdit(before: Files, after: Files): Promise<{ root: string; graph: CodeGraph; full: string[] }> {
const root = tempRoot();
const graph = await index(before, root);
write(root, after);
await graph.sync();
const fullRoot = tempRoot();
const full = stateOf(fullRoot, await index({ ...before, ...after }, fullRoot));
return { root, graph, full };
}

/** A function of the decoy app, named like every one the tests import. */
const decoy = (...names: string[]): Files => ({
'a-decoy/lib.ts': names.map((n) => `export function ${n}() { return 'decoy'; }\n`).join(''),
});

describe('an import of a module that exports an import of its own', () => {
it('reaches what the module imports, under its own name or another', async () => {
const graph = await index({
...decoy('presentUser', 'presentTeam'),
'presenters/user.ts': "export default function presentUser() { return 'user'; }\n",
'presenters/team.ts': "export function presentTeam() { return 'team'; }\n",
'presenters/index.ts': "import presentUser from './user';\nimport { presentTeam } from './team';\nexport { presentUser, presentTeam as team };\n",
'api/users.ts': "import { presentUser, team } from '../presenters';\nexport function show() { return presentUser() + team(); }\n",
'api/teams.ts': "import * as presenters from '../presenters';\nexport function list() { return presenters.presentUser(); }\n",
});
expect(callsOf(graph, 'show')).toEqual([
'function presenters/team.ts::presentTeam',
'function presenters/user.ts::presentUser',
]);
expect(callsOf(graph, 'list')).toEqual(['function presenters/user.ts::presentUser']);
});

it('reaches the default export a module imports and exports as its own', async () => {
const graph = await index({
...decoy('makeAvatar', 'Marker'),
'lib/avatar/makeAvatar.ts': "export default function makeAvatar() { return 'avatar'; }\n",
'lib/avatar/index.ts': "import makeAvatar from './makeAvatar';\nexport default makeAvatar;\n",
'lib/map/marker.ts': "export function Marker() { return 'marker'; }\n",
'lib/map/index.ts': "import { Marker } from './marker';\nexport { Marker as default };\n",
'main.ts': "import makeAvatar from './lib/avatar';\nimport placeMarker from './lib/map';\nexport function start() { return makeAvatar() + placeMarker(); }\n",
});
expect(callsOf(graph, 'start')).toEqual([
'function lib/avatar/makeAvatar.ts::makeAvatar',
'function lib/map/marker.ts::Marker',
]);
});

it('follows the module when a sync changes the import it exports as its default', async () => {
const lib = (from: string): Files => ({ 'lib/index.ts': `import impl from './${from}';\nexport default impl;\n` });
const before = {
'lib/a.ts': "export default function runA() { return 'a'; }\n",
'lib/b.ts': "export default function runB() { return 'b'; }\n",
'main.ts': "import run from './lib';\nexport function start() { return run(); }\n",
...lib('a'),
};
const { root, graph, full } = await syncEdit(before, lib('b'));
expect(callsOf(graph, 'start')).toEqual(['function lib/b.ts::runB']);
expect(stateOf(root, graph)).toEqual(full);
}, 120_000);
});

describe('a default export written as an expression', () => {
it('is an instance of the class it makes, whose methods its importers call', async () => {
const logger = 'class Logger {\n info(m: string) { return m; }\n warn(m: string) { return m; }\n}\nexport default new Logger();\n';
const graph = await index({
'app/logger.ts': `export function format(m: string) { return m; }\n${logger}`,
'server/logger.ts': logger,
'server/jobs.ts': "import Logger from './logger';\nexport function work() { Logger.info('start'); return Logger.warn('done'); }\n",
});
expect(callsOf(graph, 'work')).toEqual(['method server/logger.ts::Logger::info', 'method server/logger.ts::Logger::warn']);
});

it('is what a wrapper call hands on, or the declaration a clause names', async () => {
const graph = await index({
...decoy('provision', 'make'),
'tracing.ts': 'export function traceFunction(opts: object) { return <T>(fn: T) => fn; }\nexport function withRetry<T>(fn: T) { return fn; }\n',
'provisioner.ts': "import { traceFunction } from './tracing';\nexport function helperFirst() { return 1; }\nasync function accountProvisioner() { return { ok: true }; }\nexport default traceFunction({ spanName: 'accountProvisioner' })(accountProvisioner);\n",
'job.ts': "import { withRetry } from './tracing';\nexport function helperFirst() { return 1; }\nfunction SyncJob() { return 2; }\nexport default withRetry(SyncJob);\n",
'svc.ts': 'export function first() { return 1; }\nfunction createThing() { return 2; }\nexport { createThing as default };\n',
'main.ts': "import provision from './provisioner';\nimport job from './job';\nimport make from './svc';\nexport async function start() { await provision(); job(); return make(); }\n",
});
expect(callsOf(graph, 'start')).toEqual([
'function job.ts::SyncJob',
'function provisioner.ts::accountProvisioner',
'function svc.ts::createThing',
]);
});

it('is not the argument of a factory, nor anything past the call it ends with', async () => {
const graph = await index({
'factory.ts': 'export function createStore(r: (s: unknown) => unknown) { return { dispatch(a: unknown) { return r(a); } }; }\n',
'store.ts': "import { createStore } from './factory';\nfunction reducer(state: unknown) { return state; }\nexport default createStore(reducer);\n",
'client.ts': 'class Client { configure() { return this; } send() { return 1; } }\nexport function connect() { return 0; }\nexport default new Client()\n .configure();\n',
'main.ts': "import store from './store';\nimport client from './client';\nexport function start() { client.send(); return store.dispatch({ type: 'go' }); }\n",
});
const calls = callsOf(graph, 'start');
expect(calls.some((c) => c.endsWith('::reducer'))).toBe(false);
expect(calls.some((c) => c.startsWith('method client.ts::Client::'))).toBe(false);
});
});
Loading