fix(sync): an import a name match took follows its module once it resolves another way (#2752)

A named import that a name match linked to a declaration elsewhere while
its module did not export the name kept that link after an edit gave the
module the name. A barrel's `export { EmptyState } from './EmptyState'`
declares nothing, so no definition delta, failed-ref retry or barrel walk
saw it (the "retargets" PR #2744 left). A name match into the module
itself, a class exported only as its default and imported by name, kept
its link after the default was dropped, where a fresh index finds nothing.

reopenNameMatchedImports (module-importers.ts): for each module the sync
modified, its importers' binding edges that resolved some other way than
their import (getImportBindingsResolvedElsewhere, an indexed range per
importer), whose import statement names this module, are resolved again
against the graph as it is (ReferenceResolver.resolveNow, writing
nothing). When the answer differs, the importer's edges through the name
and into the members of what the name match took go back for the sweep.

Regression test sync-name-matched-imports.test.ts: 4 cases comparing the
synced graph with a fresh index of the final tree. Fresh indexes are
byte-identical on 8 repos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 15:23:05 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bbf987e13b
commit 553d5d101d
6 changed files with 301 additions and 1 deletions
+1
View File
@@ -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
+167
View File
@@ -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
View File
@@ -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
+19 -1
View File
@@ -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';
@@ -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();
+11
View File
@@ -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);
+61
View File
@@ -272,6 +272,67 @@ export function reopenReferencesThroughChangedDefaults(
return reopened + reopenEdgeList(queries, queries.getResolutionEdgesInto(members).filter(stays));
}
/**
* Re-open, for the sync's sweep, the imports of `files` that a name match
* took instead of the import, when the binding now resolves another way.
* `import { EmptyState } from './layouts/shared'`, linked by name to a
* namesake while the barrel did not pass `EmptyState` on, kept the namesake
* after an edit gave the barrel `export { EmptyState } from './EmptyState'`:
* that edit declares nothing, so neither the rebind nor the walk below a
* barrel sees it, and a fresh index links the binding through the module. A
* name match into the module itself (`import { Card } from './card'` where
* `card.ts` exports `Card` only as its default) can come undone the same way.
* Each importer's edges bound before this sync resolved anything (up to
* `resolvedUpToEdgeId`) go back: those through the name, and those into the
* members of what the name match took, which a value typed by the binding
* reached (`w.run()` with `w: Widget`). Only the importers' bindings that
* resolved some other way than their import are read, an indexed range per
* importer, then only those importers' import statements, and `resolveNow`
* says what each binding of `files` resolves to against the graph as it is:
* one that resolves as it did, such as a name match a fresh index makes too,
* stays. Returns the number re-opened.
*/
export function reopenNameMatchedImports(
queries: QueryBuilder,
reader: ImportReader,
files: ReadonlyArray<{ path: string; language: Language }>,
resolvedUpToEdgeId: number,
resolveModule: ModuleResolver,
resolveNow: (ref: UnresolvedReference) => { targetNodeId: string; resolvedBy: string } | null,
): number {
const bindings = new Map<string, { names: string[] }>();
// What each importer's name matches took, whose members a typed value reached.
const took = new Set<string>();
for (const file of files) {
if (!BARREL_LANGUAGES.has(file.language)) continue;
const importers = new Set<string>();
for (const i of queries.getImportsInto(`file:${file.path}`, file.path)) {
if (i.language !== 'python' && BINDING_LANGUAGES.has(i.language)) importers.add(i.filePath);
}
if (importers.size === 0) continue;
for (const edge of queries.getImportBindingsResolvedElsewhere([...importers])) {
const name = edge.metadata?.refName;
if (typeof name !== 'string' || edge.edgeId > resolvedUpToEdgeId) continue;
const binding = reader.mappings(edge.sourceFilePath, edge.sourceLanguage).find((m) => m.localName === name);
if (!binding || binding.isNamespace) continue;
if (resolveModule(binding.source, edge.sourceFilePath, edge.sourceLanguage) !== file.path) continue;
const ref = resurrectRefFromDroppedEdge(edge);
const now = ref ? resolveNow(ref) : null;
if (!ref || (now && now.targetNodeId === edge.target && now.resolvedBy === edge.metadata?.resolvedBy)) continue;
const entry = bindings.get(edge.sourceFilePath) ?? { names: [] };
if (!entry.names.includes(name)) entry.names.push(name);
bindings.set(edge.sourceFilePath, entry);
took.add(edge.target);
}
}
const stays = (e: { edgeId: number }): boolean => e.edgeId <= resolvedUpToEdgeId;
const reopened = reopenEdges(queries, bindings, stays);
if (took.size === 0) return reopened;
const members = queries.getOutgoingEdgesFrom([...took], ['contains']).map((edge) => edge.target);
if (members.length === 0) return reopened;
return reopened + reopenEdgeList(queries, queries.getResolutionEdgesInto(members).filter((e) => bindings.has(e.sourceFilePath) && stays(e)));
}
/** The languages a barrel a lazy route reads through is written in: the ES-module family. */
const BARREL_LANGUAGES = new Set<Language>(['typescript', 'tsx', 'javascript', 'jsx']);