Skip to content
Merged
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 @@ -277,6 +277,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, a call whose callee is not a name, like an immediately invoked function `(() => { … })()`, a curried call such as `connect(mapState)(View)`, `handlers[type]()` or `(0, fn)()`, no longer stores the callee's whole source text as the name of a reference that nothing in the project can answer. A single immediately invoked function could leave its entire body in the index that way. The calls inside such a callee, and the inner call of a curried one, are still recorded. A call through parentheses, a cast or a non-null assertion, like `(fn)()`, `(fn as F)()` or `fn!()`, now links to `fn` exactly as `fn()` does, so a parameter named `fn` no longer links to a same-named property elsewhere in the project. In TypeScript, a call with type arguments behind an operator, like `!settings.get<boolean>('X')` or `void apiRequest<Status>()`, now links to what it calls, as `await load<T>()` already did.
- In JavaScript, TypeScript, Python, Go, Rust, Java and Kotlin, a function named in the value of a top-level constant or variable, or of a class field, like `const routes = { home }`, `HANDLERS = {"home": home}`, a Go `map[string]func()` table, a Rust `static` list of functions, `static handlers = { click: onClick }`, a Java `List.of(Registry::home)` field or a Kotlin `listOf(::home)` property, is now referenced once, by the constant, variable, field or property that holds it. The file or class around it no longer also shows up as a second reference from the same place, so a function's callers and impact list each table once.
- `codegraph sync` and the file watcher now follow an edit that changes which declaration a module exports as its default, like `export default A` becoming `export default B` while `A` stays, or the module dropping its default. References through `import Y from './x'`, `{ default as Y }`, `require('./x').default`, a barrel that passes the default on or a route that renders it as a layout, and calls on a value typed by it, now link to the new default, or stay unlinked, as a fresh index has them. Before, they kept pointing at the old declaration until the importing file changed or the project was indexed again.
- `codegraph sync` and the file watcher now relink a named import that a name match had linked to a declaration elsewhere while its module didn't export the name, once an edit makes the module export it, like `import { EmptyState } from './layouts/shared'` after the barrel gains `export { EmptyState } from './EmptyState'`. An import a name match had linked to a class the module exported only as its default comes unlinked once the module stops exporting it. Before, the import, the references through it and calls on values typed by it kept the old link until the importing file changed or the project was indexed again.

## [1.6.2] - 2026-10-03

Expand Down
167 changes: 167 additions & 0 deletions __tests__/sync-name-matched-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* A sync re-links an import a name match took instead of its module.
*
* `import { Widget } from '../lib'` resolves through `lib/index.ts`. While the
* barrel does not pass `Widget` on, the import fails there and a name match
* links the binding, and every reference through it, to a `Widget` declared
* somewhere else. An edit that gives the barrel `export { Widget } from
* './widget'` declares nothing: no definition delta for the rebind (CG-33),
* no added file for the walks below a barrel (#2512, #2560, #2665), and the
* binding never failed for the retries (#1240, #2744). The name match stayed,
* where a full index links the binding through the barrel (#2744's
* "retargets"). A name match into the edited module itself, `import { Widget }
* from '../x'` where `x.ts` exports `Widget` only as its default, stayed too
* once the edit dropped that default, where a full index finds nothing.
*
* Each case indexes the first tree, edits it, syncs, and compares edges and
* unresolved references with a full index of the final tree in a folder of
* its own (#2411).
*/
import { describe, it, expect, afterEach, vi } 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';
import { QueryBuilder } from '../src/db/queries';

type Files = Record<string, string>;

const run = '{ run(): number { return 1; } }';
const reader = (from: string, name = 'Widget'): string =>
`import { ${name} } from '${from}';\nexport function make(): unknown { return new ${name}(); }\nexport function call(w: ${name}): number { return w.run(); }\n`;

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

afterEach(() => {
vi.restoreAllMocks();
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-sync-name-matched-'));
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(root: string): Promise<CodeGraph> {
const graph = await CodeGraph.init(root, { index: true });
graphs.push(graph);
return graph;
}

/** 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();
}
}

/** Where each reference to `refName` landed and how: `<source> -> <target file>::<qualified name> (<resolvedBy>)`. */
function targetsOf(graph: CodeGraph, refName: string): string[] {
const ids = graph.getFiles().flatMap((file) => graph.getNodesInFile(file.path)).map((n) => n.id);
return graph.getOutgoingEdgesFrom(ids)
.filter((e) => e.metadata?.refName === refName)
.map((e) => {
const target = graph.getNode(e.target)!;
return `${graph.getNode(e.source)!.name} -> ${target.filePath}::${target.qualifiedName} (${e.metadata?.resolvedBy})`;
})
.sort();
}

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

describe('sync re-links an import a name match took instead of its module', () => {
it('when an edit gives the barrel a re-export under another name', async () => {
const before: Files = {
'src/lib/index.ts': 'export const version = 1;\n',
'src/lib/impl.ts': `export default class Impl ${run}\n`,
'src/other/widget.ts': `export class Widget ${run}\n`,
'src/app/y.ts': reader('../lib'),
};
const after: Files = { 'src/lib/index.ts': "export const version = 1;\nexport { default as Widget } from './impl';\n" };
const { root, graph, full } = await syncEdit(before, after);
expect(targetsOf(graph, 'Widget')).toEqual([
'call -> src/lib/impl.ts::Impl (import)',
'make -> src/lib/impl.ts::Impl (import)',
'y.ts -> src/lib/impl.ts::Impl (import)',
]);
// A value typed by the binding reaches the member of what it binds now.
expect(targetsOf(graph, 'w.run')).toEqual(['call -> src/lib/impl.ts::Impl::run (instance-method)']);
expect(stateOf(root, graph)).toEqual(full);
expect(graph.getPendingReferenceCount()).toBe(0);
}, 120_000);

it('when an edit gives the barrel the re-export of a declaration the name match also found', async () => {
const before: Files = {
'src/lib/index.ts': 'export const version = 1;\n',
'src/lib/widget.ts': `export class Widget ${run}\n`,
'src/other/widget.ts': `export class Widget ${run}\n`,
'src/app/y.ts': reader('../lib'),
};
const after: Files = { 'src/lib/index.ts': "export const version = 1;\nexport { Widget } from './widget';\n" };
const { root, graph, full } = await syncEdit(before, after);
expect(targetsOf(graph, 'Widget').every((t) => t.endsWith('src/lib/widget.ts::Widget (import)'))).toBe(true);
expect(stateOf(root, graph)).toEqual(full);
}, 120_000);

it('when an edit drops the default a named import was matched to', async () => {
const before: Files = {
'src/x.ts': `export const VERSION = 1;\nclass Widget ${run}\nexport default Widget;\n`,
'src/app/y.ts': reader('../x'),
};
const after: Files = { 'src/x.ts': `export const VERSION = 1;\nclass Widget ${run}\n` };
const { root, graph, full } = await syncEdit(before, after);
expect(targetsOf(graph, 'Widget')).toEqual([]);
expect(stateOf(root, graph)).toEqual(full);
}, 120_000);

it('and puts nothing back when the module still lacks the name', async () => {
const reopened = vi.spyOn(QueryBuilder.prototype, 'replaceResolutionEdgesWithUnresolvedRefs');
const before: Files = {
'src/lib/index.ts': 'export const version = 1;\n',
'src/other/widget.ts': `export class Widget ${run}\n`,
'src/app/y.ts': reader('../lib'),
};
const after: Files = { 'src/lib/index.ts': 'export const version = 2;\n' };
const { root, graph, full } = await syncEdit(before, after);
expect(targetsOf(graph, 'Widget').every((t) => t.includes('src/other/widget.ts::Widget'))).toBe(true);
expect(reopened).not.toHaveBeenCalled();
expect(stateOf(root, graph)).toEqual(full);
}, 120_000);
});
42 changes: 42 additions & 0 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3163,6 +3163,48 @@ export class QueryBuilder {
}));
}

/**
* The import bindings of `filePaths` that resolved some other way than
* through their import — `imports` edges out of each file's node named for
* the binding, whose `resolvedBy` is a name match or a framework's — with
* the source file and language a resurrection needs and the file of each
* target. Synthesized edges are left out. The binding edges of a file are
* an indexed range (idx_edges_source_kind); few of them resolve by name.
*/
getImportBindingsResolvedElsewhere(
filePaths: readonly string[]
): Array<Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language; targetFilePath: string }> {
const out: Array<Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language; targetFilePath: string }> = [];
const unique = [...new Set(filePaths)].map((filePath) => `file:${filePath}`);
for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const rows = this.db
.prepare(
`SELECT e.*, src.file_path AS source_file_path, src.language AS source_language, tgt.file_path AS target_file_path
FROM edges e
JOIN nodes src ON src.id = e.source
JOIN nodes tgt ON tgt.id = e.target
WHERE e.source IN (${placeholders})
AND e.kind = 'imports'
AND (e.provenance IS NULL OR e.provenance != 'heuristic')
AND json_valid(e.metadata)
AND json_extract(e.metadata, '$.resolvedBy') != 'import'`
)
.all(...chunk) as Array<EdgeRow & { source_file_path: string; source_language: Language; target_file_path: string }>;
for (const row of rows) {
out.push({
...rowToEdge(row),
edgeId: row.id,
sourceFilePath: row.source_file_path,
sourceLanguage: row.source_language,
targetFilePath: row.target_file_path,
});
}
}
return out;
}

/**
* The resolution edges into `targetIds` — every edge into one of them but
* `contains` and synthesized (`provenance='heuristic'`) ones — with the
Expand Down
20 changes: 19 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import {
ResolutionResult,
} from './resolution';
import { hasSynthesisPattern, isJsxChildCandidate } from './resolution/callback-synthesizer';
import { createImportReader, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels, reopenReferencesThroughChangedDefaults, reopenReferencesThroughDeclaredPackages } from './resolution/module-importers';
import { createImportReader, reopenNameMatchedImports, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels, reopenReferencesThroughChangedDefaults, reopenReferencesThroughDeclaredPackages } from './resolution/module-importers';
import { GraphTraverser, GraphQueryManager } from './graph';
import { findNamedCopybooks, type NamedCopybook } from './graph/cobol-copybooks';
import { ContextBuilder, createContextBuilder } from './context';
Expand Down Expand Up @@ -1198,6 +1198,24 @@ export class CodeGraph {
const throughDefaults = movedDefaults.length === 0 ? 0
: reopenReferencesThroughChangedDefaults(this.queries, importReader, movedDefaults, edgesBeforeResolution);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-default-exports: ${Date.now() - tDefaults}ms (${movedDefaults.length} defaults moved, ${throughDefaults} refs re-opened)`);

// An import a name match took while its module did not export the
// name, once the binding resolves another way: `import {
// EmptyState } from './layouts/shared'` kept a namesake after the
// barrel's edit gave it the re-export, which declares nothing. And
// a name match into the edited module itself, which the edit can
// undo.
const tNamed = Date.now();
const editedModules = modified.flatMap((filePath) => {
const file = this.queries.getFileByPath(filePath);
return file ? [{ path: filePath, language: file.language }] : [];
});
const nameMatched = reopenNameMatchedImports(
this.queries, importReader, editedModules, edgesBeforeResolution,
(specifier, fromFile, language) => this.resolver.resolveModuleFile(specifier, fromFile, language),
(ref) => this.resolver.resolveNow(ref)
);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-name-matched-imports: ${Date.now() - tNamed}ms (${nameMatched} refs re-opened)`);
} else {
// No git info — use batched resolution to avoid OOM
const unresolvedCount = this.queries.getUnresolvedReferencesCount();
Expand Down
11 changes: 11 additions & 0 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2819,6 +2819,17 @@ export class ReferenceResolver {
return resolveImportPath(specifier, fromFile, language, this.context);
}

/**
* What `ref` resolves to against the graph as it is now, writing nothing:
* its target and how it was found, or null. A sync compares it with the
* edge the reference made before, to re-open only an edge an edit moved.
*/
resolveNow(ref: UnresolvedReference): { targetNodeId: string; resolvedBy: string } | null {
if (!ref.filePath || !ref.language) return null;
const resolved = this.resolveOne({ ...ref, filePath: ref.filePath, language: ref.language });
return resolved ? { targetNodeId: resolved.targetNodeId, resolvedBy: resolved.resolvedBy } : null;
}

/** True when a detected router binds navigation calls to its routes (see {@link reopenNavigationsFor}). */
hasNavigationRouters(): boolean {
return this.frameworks.some((f) => f.navigation !== undefined);
Expand Down
Loading