mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-23 06:50:54 +08:00
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:
co-authored by
Claude Opus 5.5
parent
3cfd24a46d
commit
fc293f0081
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user