fix(sync): a default import follows the module's default when an edit moves it (#2749)

An edit that changes which declaration a module exports as its default
(`export default A` becoming `export default B` while A stays, or the
default dropped) left every reference through a default import of it on
the old declaration. A sync moves other files' edges into a changed file
onto the node that replaces each target, and an `export default` statement
is no node, so no definition delta, failed-ref retry or importer walk saw
the change. A fresh index links the new default, or nothing.

- ReferenceResolver.changedDefaultExports: for each modified JS/TS module
  that unchanged files import by default or as a namespace (an imports edge
  into its file node named for the binding), compare what the whole-name
  references through those bindings resolved to with the module's default
  now (moduleDefaultExport, the lookup resolveViaImport makes). No file is
  read; a module forwarding its default samples 8 importers' edges.
- reopenReferencesThroughChangedDefaults (module-importers.ts): when one
  moved, the edges bound before this sync resolved anything go back for
  the sweep: through every binding that reads the default (importersOf
  seeded with `default`: default imports, { default as Y },
  require('./x').default, modules passing it on), the routers'
  `layout:<binding>` references, and into the old default's own members
  (typed receivers, inherited members).

Regression test sync-default-export-change.test.ts: 6 cases comparing the
synced graph with a fresh index of the final tree; all fail on main.
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 14:22:36 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3cfd24a46d
commit fc293f0081
7 changed files with 420 additions and 6 deletions
+1
View File
@@ -274,6 +274,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, an import written from the app's root with a leading slash, like bigbluebutton's `import Icon from '/imports/ui/components/common/icon/component'`, now links to the file it names when the app's `tsconfig.json` or `jsconfig.json` maps it, as `"paths": { "/*": ["*"] }` does. Before, such an import was taken for a package, so the module looked unused and every name it imports linked by name instead, often to the wrong one of several components of the same name, or to nothing. Re-index projects that import this way after upgrading.
- 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.
## [1.6.2] - 2026-10-03
@@ -0,0 +1,207 @@
/**
* A sync re-links what a default import reads when the module's default changes.
*
* `import Y from './x'; new Y()` resolves to whatever `x` exports as its
* default. When an edit makes that another declaration (`export default A`
* becomes `export default B`) or stops exporting one, while the old default
* stays declared, a sync moved the edges other files made into `x` onto the
* node that replaces each old target. That is still `A`, so `new Y()` kept
* linking `A` where a full index links `B` (or nothing). An `export default`
* statement is no node, so neither the definition delta (CG-33) nor the
* failed-reference retry (#1240, #2450) saw it change.
*
* Each case indexes the first tree, edits it, syncs, and compares the graph
* 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';
import { ReferenceResolver } from '../src/resolution';
type Files = Record<string, string>;
const run = '{ static create() { return 1; } run(): number { return 1; } }';
/** Every way another file reaches `src/x.ts`'s default export, and a named import that must stay put. */
const READERS: Files = {
'src/y.ts': "import Y from './x';\nexport function make(): unknown { return new Y(); }\nexport function build(): unknown { return Y.create(); }\nexport let held: Y | undefined;\n",
'src/w.ts': "import { default as W } from './x';\nexport function wrap(): unknown { return new W(); }\n",
'src/r.ts': "export function load(): unknown { const R = require('./x').default; return new R(); }\n",
'src/n.ts': "import { A } from './x';\nexport function named(): unknown { return new A(); }\n",
// A value typed by the default, and a subclass of it, reach its members by name only.
'src/t.ts': "import T from './x';\nexport function typed(v: T): number { return v.run(); }\n",
'src/s.ts': "import S from './x';\nexport class Sub extends S { go(): number { return this.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-default-'));
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: `<source> -> <target kind> <file>::<qualified name>`. */
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 && e.kind !== 'imports')
.map((e) => {
const target = graph.getNode(e.target)!;
return `${graph.getNode(e.source)!.name} -> ${target.kind} ${target.filePath}::${target.qualifiedName}`;
})
.sort();
}
/** 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[]; modified: number }> {
const root = tempRoot();
write(root, before);
const graph = await index(root);
write(root, after);
const result = await graph.sync();
const fullRoot = tempRoot();
write(fullRoot, { ...before, ...after });
const full = stateOf(fullRoot, await index(fullRoot));
return { root, graph, full, modified: result.filesModified };
}
describe('sync re-links default-import readers when the module default changes', () => {
it('when `export default` names another declaration', async () => {
const reopened = vi.spyOn(QueryBuilder.prototype, 'replaceResolutionEdgesWithUnresolvedRefs');
const before = { ...READERS, 'src/x.ts': `export class A ${run}\nexport class B ${run}\nexport default A;\n` };
const after = { 'src/x.ts': `export class A ${run}\nexport class B ${run}\nexport default B;\n` };
const { root, graph, full, modified } = await syncEdit(before, after);
expect(modified).toBe(1);
// What reads the default went back, and what reaches the old default's
// members; not the named import, which reads A as before.
expect(reopened.mock.calls.flatMap(([, refs]) => refs.map((ref) => ref.referenceName)).sort())
.toEqual(['R', 'S', 'T', 'W', 'Y', 'Y', 'Y.create', 'run', 'v.run']);
expect(targetsOf(graph, 'Y')).toEqual(['held -> class src/x.ts::B', 'make -> class src/x.ts::B']);
expect(targetsOf(graph, 'Y.create')).toEqual(['build -> method src/x.ts::B::create']);
expect(targetsOf(graph, 'W')).toEqual(['wrap -> class src/x.ts::B']);
expect(targetsOf(graph, 'R')).toEqual(['load -> class src/x.ts::B']);
expect(targetsOf(graph, 'v.run')).toEqual(['typed -> method src/x.ts::B::run']);
expect(targetsOf(graph, 'S')).toEqual(['Sub -> class src/x.ts::B']);
// The named import still reads A.
expect(targetsOf(graph, 'A')).toEqual(['named -> class src/x.ts::A']);
expect(stateOf(root, graph)).toEqual(full);
expect(graph.getPendingReferenceCount()).toBe(0);
}, 120_000);
it('when a route renders the default as its layout', async () => {
const root = (dflt: string): Files => ({
'src/root.tsx': `export function AppRoot() { return null; }\nexport function Shell() { return null; }\nexport default ${dflt};\n`,
});
const before = {
'package.json': JSON.stringify({ name: 'app', dependencies: { react: '^18', 'react-router-dom': '^6' } }),
'src/router.tsx': "import { createBrowserRouter } from 'react-router-dom';\nimport { default as AppRoot } from './root';\nimport { One } from './one';\n" +
"export const router = createBrowserRouter([\n {\n path: '/app',\n element: <AppRoot />,\n children: [{ path: 'one', element: <One /> }],\n },\n]);\n",
'src/one.tsx': 'export function One() { return null; }\n',
...root('AppRoot'),
};
const { root: dir, graph, full } = await syncEdit(before, root('Shell'));
expect(targetsOf(graph, 'layout:AppRoot').every((t) => t.endsWith('::Shell'))).toBe(true);
expect(stateOf(dir, graph)).toEqual(full);
}, 120_000);
it('when the default declaration moves to another class', async () => {
const before = { ...READERS, 'src/x.ts': `export default class A ${run}\nexport class B ${run}\n` };
const after = { 'src/x.ts': `export default class B ${run}\nexport class A ${run}\n` };
const { root, graph, full } = await syncEdit(before, after);
expect(targetsOf(graph, 'Y')).toEqual(['held -> class src/x.ts::B', 'make -> class src/x.ts::B']);
expect(stateOf(root, graph)).toEqual(full);
}, 120_000);
it('when the module stops exporting a default', async () => {
// Without `src/n.ts`: `{ A }` names a class exported only as the default,
// which no import of it reaches, so only a name match ever linked it.
const defaultReaders = Object.fromEntries(Object.entries(READERS).filter(([file]) => file !== 'src/n.ts'));
const before = { ...defaultReaders, 'src/x.ts': `export const VERSION = 1;\nclass A ${run}\nexport default A;\n` };
const after = { 'src/x.ts': `export const VERSION = 1;\nclass A ${run}\n` };
const { root, graph, full } = await syncEdit(before, after);
expect(targetsOf(graph, 'Y')).toEqual([]);
expect(stateOf(root, graph)).toEqual(full);
}, 120_000);
it('when a module forwards another module\'s default', async () => {
const lib = (from: string): Files => ({ 'src/lib/index.ts': `export { default } from './${from}';\n` });
const before = {
'src/lib/a.ts': `export default class A ${run}\n`,
'src/lib/b.ts': `export default class B ${run}\n`,
'src/y.ts': "import Y from './lib';\nexport function make(): unknown { return new Y(); }\n",
...lib('a'),
};
const { root, graph, full } = await syncEdit(before, lib('b'));
expect(targetsOf(graph, 'Y')).toEqual(['make -> class src/lib/b.ts::B']);
expect(stateOf(root, graph)).toEqual(full);
}, 120_000);
it('and puts nothing back when an edit leaves the default alone', async () => {
const moved = vi.spyOn(ReferenceResolver.prototype, 'changedDefaultExports');
const before = {
...READERS,
'src/x.ts': `export class A ${run}\nexport class B ${run}\nexport default A;\n`,
// A default that is an alias: a call through it links the function it names.
'src/alias.ts': 'function impl(): number { return 1; }\nconst go = impl;\nexport default go;\n',
'src/use-alias.ts': "import start from './alias';\nexport function call(): number { return start(); }\n",
};
const after = {
'src/x.ts': `export class A { static create() { return 2; } run(): number { return 2; } }\nexport class B ${run}\nexport default A;\n`,
'src/alias.ts': 'function impl(): number { return 2; }\nconst go = impl;\nexport default go;\n',
};
const { root, graph, full } = await syncEdit(before, after);
expect(targetsOf(graph, 'start')).toEqual(['call -> function src/alias.ts::impl']);
expect(moved).toHaveBeenCalledTimes(1);
expect(moved.mock.results[0]!.value).toEqual([]);
expect(targetsOf(graph, 'Y')).toEqual(['held -> class src/x.ts::A', 'make -> class src/x.ts::A']);
expect(stateOf(root, graph)).toEqual(full);
}, 120_000);
});
+39 -1
View File
@@ -3106,7 +3106,7 @@ export class QueryBuilder {
*/
getCrossFileIncomingEdgesWithTarget(
filePath: string
): Array<Edge & { targetName: string; targetKind: NodeKind; sourceFilePath: string; sourceLanguage: Language }> {
): Array<Edge & { edgeId: number; targetName: string; targetKind: NodeKind; sourceFilePath: string; sourceLanguage: Language }> {
const sql = `SELECT e.*, tgt.name AS target_name, tgt.kind AS target_kind,
src.file_path AS source_file_path, src.language AS source_language
FROM edges e
@@ -3120,6 +3120,7 @@ export class QueryBuilder {
>;
return rows.map(row => ({
...rowToEdge(row),
edgeId: row.id,
targetName: row.target_name,
targetKind: row.target_kind,
sourceFilePath: row.source_file_path,
@@ -3162,6 +3163,43 @@ export class QueryBuilder {
}));
}
/**
* The resolution edges into `targetIds` — every edge into one of them but
* `contains` and synthesized (`provenance='heuristic'`) ones — with the
* source file and language a resurrection needs and the file of each target.
*/
getResolutionEdgesInto(
targetIds: 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(targetIds)];
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.target IN (${placeholders})
AND e.kind != 'contains'
AND (e.provenance IS NULL OR e.provenance != 'heuristic')`
)
.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 row ids of the failed references of `filePath` that import one of
* `modules` or are written through one of `names` (see {@link THROUGH_NAMES}).
+13 -1
View File
@@ -54,7 +54,7 @@ import {
ResolutionResult,
} from './resolution';
import { hasSynthesisPattern, isJsxChildCandidate } from './resolution/callback-synthesizer';
import { createImportReader, reopenReferencesThroughAddedFiles, reopenReferencesThroughBarrels, reopenReferencesThroughDeclaredPackages } from './resolution/module-importers';
import { createImportReader, 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';
@@ -1186,6 +1186,18 @@ export class CodeGraph {
this.queries, importReader, this.projectRoot, result.changedFilePaths, edgesBeforeResolution
);
if (process.env.CODEGRAPH_SYNTH_TIMINGS && declared > 0) console.error(`[phase-timing] sync-declared-packages: ${declared} refs re-opened`);
// An edited module whose default export moved to another
// declaration, or went away, while the old one stayed declared:
// `new Y()` after `import Y from './x'` kept the old one, the node
// that replaced its target, and no name this sync's files carry
// changed. Read off the edges into the edited modules; an edit
// that leaves the default alone re-opens nothing.
const tDefaults = Date.now();
const movedDefaults = this.resolver.changedDefaultExports(modified, result.changedFilePaths, edgesBeforeResolution);
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)`);
} else {
// No git info — use batched resolution to avoid OOM
const unresolvedCount = this.queries.getUnresolvedReferencesCount();
+25
View File
@@ -4430,6 +4430,31 @@ function localTypeBinding(receiver: string, ref: UnresolvedRef, context: Resolut
return mappings.find((m) => m.localName === head);
}
/**
* The declaration a default import of `filePath` written in `language` binds
* — `X` in `import X from './x'` or `{ default as X }` — followed through the
* modules the file forwards its default from, or undefined when it has none.
* The lookup resolveViaImport makes for that binding, memoized with it.
*/
export function moduleDefaultExport(filePath: string, language: Language, context: ResolutionContext): Node | undefined {
return findExportedSymbol(filePath, { isDefault: true, isNamespace: false, exportedName: 'default', memberName: null }, language, context, new Set());
}
/** A `.js`/`.ts`-family file, the modules whose default an `export default` statement or the first exported declaration names. */
export function isJsFamilyFile(filePath: string): boolean {
return JS_FAMILY_FILE.test(filePath);
}
/**
* Whether the name of an `imports` reference into a module's own file node is
* a binding the importing file holds on the module — `Y` of `import Y from
* './x'`, `{ default as Y }` or `import * as Y` — rather than the path it
* imports the module by.
*/
export function isModuleBindingName(name: string): boolean {
return IDENTIFIER.test(name);
}
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
/** The receiver of a direct member reference: `store` of `store.get`, none of `store.get().then` or `a.b.c`. */
const MEMBER_RECEIVER = /^([A-Za-z_$][\w$]*)\.[A-Za-z_$][\w$]*$/;
+74 -1
View File
@@ -35,7 +35,7 @@ import { clearCppIncluderMemos } from './cpp-includers';
import { clearCFeatureVariantMemos } from './c-feature-variants';
import { clearAmbientModuleMemos, ambientModuleDeclarations } from './ambient-modules';
import { matchShopifyThemeFile } from './shopify-themes';
import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveTsQualifiedTypeRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, reachesAmbientModule, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters } from './import-resolver';
import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveTsQualifiedTypeRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, reachesAmbientModule, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters, moduleDefaultExport, isJsFamilyFile, isModuleBindingName } from './import-resolver';
import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool';
import { resolveAliasBinding } from './alias-binding';
import { detectFrameworks, getResolvingFrameworks } from './frameworks';
@@ -89,6 +89,13 @@ const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/;
/** PHP `$this->prop->method()` encoded as `this->prop.method` — no `()`, so CHAIN_SHAPE misses it. */
const PHP_PROP_SHAPE = /^this->\w+\.\w+$/;
/**
* How many files that import a changed module by default a sync reads the
* edges of, when the module forwards its default from another file, to see
* whether they still land on it (changedDefaultExports).
*/
const DEFAULT_READER_SAMPLE_FILES = 8;
/**
* Cache size limits. Each per-resolver cache is bounded so memory
* stays flat on large codebases (20k+ files). Sizes were chosen to
@@ -2897,6 +2904,72 @@ export class ReferenceResolver {
return this.queries.reopenFailedReferences(rowIds) + refs.length;
}
/**
* The edited modules among `modifiedFilePaths` whose default export moved:
* an edit made `export default A` into `export default B`, or took the
* default away, while `A` stayed declared. A sync moves the edges other
* files made into a changed file onto the node that replaces each target, so
* `new Y()` after `import Y from './x'` kept `A` where a full index links
* `B`. An `export default` statement is no node, so neither the definition
* delta nor a failed-reference retry sees it change.
*
* The default a module had is read off the edges into it, with no file read:
* a default or namespace import of it is an `imports` edge into its file
* node named for the binding, and a whole-name reference through that
* binding (`Y`, not `Y.run`) that resolved through the import names exactly
* its default (a namespace binding's `NS` names the file). Only edges bound
* before this sync resolved anything count (up to `resolvedUpToEdgeId`), and
* only from files it left alone. A module that forwards its default from
* another file (`export { default } from './impl'`) has those edges there,
* so a few of its importers' are read instead. An edit that leaves the
* default alone, nearly every one, finds them all still on it. Each module
* comes with the declarations its default was (`was`), as those edges say.
*/
changedDefaultExports(
modifiedFilePaths: readonly string[],
changedFilePaths: readonly string[],
resolvedUpToEdgeId: number,
): Array<{ path: string; language: Language; was: string[] }> {
const fresh = new Set(changedFilePaths);
const moved: Array<{ path: string; language: Language; was: string[] }> = [];
for (const filePath of modifiedFilePaths) {
if (!isJsFamilyFile(filePath)) continue;
const bindings = new Map<string, Set<string>>();
for (const i of this.queries.getImportsInto(`file:${filePath}`, filePath)) {
if (i.refName === null || fresh.has(i.filePath) || !isModuleBindingName(i.refName)) continue;
const names = bindings.get(i.filePath) ?? new Set<string>();
names.add(i.refName);
bindings.set(i.filePath, names);
}
if (bindings.size === 0) continue;
const language = this.queries.getFileByPath(filePath)?.language;
if (!language) continue;
const readsDefault = (e: Edge & { edgeId: number; sourceFilePath: string }): boolean => {
const name = e.metadata?.refName;
return typeof name === 'string' && e.metadata?.resolvedBy === 'import' && e.edgeId <= resolvedUpToEdgeId &&
!e.target.startsWith('file:') && bindings.get(e.sourceFilePath)?.has(name) === true;
};
let readers: Array<Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language }> =
this.queries.getCrossFileIncomingEdgesWithTarget(filePath).filter(readsDefault);
if (readers.length === 0) {
const now = moduleDefaultExport(filePath, language, this.context);
if (!now || now.filePath === filePath) continue;
readers = [...bindings].slice(0, DEFAULT_READER_SAMPLE_FILES)
.flatMap(([file, names]) => this.queries.getResolutionEdgesThrough(file, [...names]))
.filter(readsDefault);
}
const was = new Set<string>();
for (const e of readers) {
const now = moduleDefaultExport(filePath, e.sourceLanguage, this.context);
// A call that lands on an alias binding goes on to the callable it names (resolveOne).
if (now && (e.target === now.id || (e.kind === 'calls' && resolveAliasBinding(now, null, this.context)?.id === e.target))) continue;
was.add(e.target);
}
if (was.size > 0) moved.push({ path: filePath, language, was: [...was] });
}
return moved;
}
/**
* Whether a failed import could name one of `files`, the files a sync just
* added: the test the import retry puts a crowded key's imports to
+61 -3
View File
@@ -237,6 +237,41 @@ export function reopenReferencesThroughDeclaredPackages(
return reopenEdges(queries, bindings, (e) => e.edgeId <= resolvedUpToEdgeId) + queries.reopenFailedReferences(failed);
}
/**
* Re-open, for the sync's sweep, what reads the default of any of `files`
* when it moved: an edit made `export default A` into `export default B`, or
* took the default away, while `A` stayed declared (`was`, see
* ReferenceResolver.changedDefaultExports). `new Y()` after `import Y from
* './x'` kept `A`, the node that replaced its old target. The edges bound
* before this sync resolved anything (up to `resolvedUpToEdgeId`) go back:
* - those through every binding that reads the default: a default import,
* `{ default as Y }`, `require('./x').default`, the names a module passes it
* on as (`export { default as Y } from './x'`), and a route rendering one
* as its layout (`layout:Y`). A named import reads what it did, and a
* default import's own edge links the module's file, which stays;
* - those into the members `A` declares, wherever they are written: a value
* typed by the binding reaches them by their own name (`document.delete()`
* with `document: Document`, a subclass's `this.add`). `A` itself is reached
* through a binding, the default's or a named import's, which reads it still.
* Returns the number re-opened.
*/
export function reopenReferencesThroughChangedDefaults(
queries: QueryBuilder,
reader: ImportReader,
files: ReadonlyArray<{ path: string; language: Language; was: readonly string[] }>,
resolvedUpToEdgeId: number,
): number {
const stays = (e: Edge & { edgeId: number }): boolean => e.edgeId <= resolvedUpToEdgeId && !e.target.startsWith('file:');
const bindings = bindingsOf(importersOf(queries, files, reader, [], new Set(['default'])), reader);
// React, Vue and Angular routers write a layout a route renders as `layout:<name>`.
for (const entry of bindings.values()) entry.names.push(...entry.names.map((name) => `layout:${name}`));
const reopened = reopenEdges(queries, bindings, stays);
const was = [...new Set(files.flatMap((file) => file.was))];
const members = was.length === 0 ? [] : queries.getOutgoingEdgesFrom(was, ['contains']).map((edge) => edge.target);
if (members.length === 0) return reopened;
return reopened + reopenEdgeList(queries, queries.getResolutionEdgesInto(members).filter(stays));
}
/** 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']);
@@ -386,18 +421,20 @@ function reopenModuleEdges(queries: QueryBuilder, outranked: readonly OutrankedI
* The files with an import statement naming one of `files`, or one of the
* modules re-exporting from them, other than `files` themselves (going away,
* or just resolved with the others in place); `outranked` imports name the
* added file they now resolve to.
* added file they now resolve to. `names` limits what of `files` counts: all
* of it (null), or what they export under these names.
*/
function importersOf(
queries: QueryBuilder,
files: ReadonlyArray<{ path: string; language: Language }>,
reader: ImportReader,
outranked: readonly OutrankedImport[] = [],
names: Set<string> | null = null,
): Importers {
const importers: Importers = new Map();
const changed = new Set(files.map((file) => file.path));
const seen = new Set(changed);
let level: Module[] = files.filter((file) => BINDING_LANGUAGES.has(file.language)).map((file) => ({ ...file, passes: null }));
let level: Module[] = files.filter((file) => BINDING_LANGUAGES.has(file.language)).map((file) => ({ ...file, passes: names }));
for (let depth = 1; level.length > 0; depth++) {
// Per importer found here, its statements naming each module of the level.
const naming = new Map<string, { language: Language; statements: Array<{ source: string; module: Module; onlyName?: string }> }>();
@@ -596,7 +633,7 @@ function bindingsOf(importers: Importers, reader: ImportReader): Map<string, { m
function reopenEdges(
queries: QueryBuilder,
bindings: Map<string, { names: string[] }>,
keep: (e: { edgeId: number; targetFilePath: string }) => boolean,
keep: (e: Edge & { edgeId: number; targetFilePath: string }) => boolean,
): number {
const edgeIds: number[] = [];
const refs: UnresolvedReference[] = [];
@@ -614,6 +651,27 @@ function reopenEdges(
return refs.length;
}
/**
* Swap `edges` for their references, one per reference: a conditional's arms
* are edges of one call. Returns how many references went back.
*/
function reopenEdgeList(queries: QueryBuilder, edges: ReadonlyArray<Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language }>): number {
const edgeIds: number[] = [];
const refs: UnresolvedReference[] = [];
const sites = new Set<string>();
for (const edge of edges) {
const ref = resurrectRefFromDroppedEdge(edge);
if (!ref) continue;
edgeIds.push(edge.edgeId);
const site = `${ref.fromNodeId}\0${ref.referenceName}\0${ref.line}\0${ref.column}`;
if (sites.has(site)) continue;
sites.add(site);
refs.push(ref);
}
if (edgeIds.length > 0) queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs);
return refs.length;
}
/** A Python file's module as a path: `shop/models.py` → 'shop/models', `shop/__init__.py` → 'shop'. Null for a top-level `__init__.py`. */
function pythonModuleOfFile(filePath: string): string | null {
if (!filePath.endsWith('.py') || filePath === '__init__.py') return null;