From b97f41a8686c9a015cc191b6b14f2168faad9726 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 21 Oct 2026 09:11:34 -0500 Subject: [PATCH] fix(sync): a default import follows the module's default when an edit moves it 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:` 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. --- CHANGELOG.md | 1 + __tests__/sync-default-export-change.test.ts | 207 +++++++++++++++++++ src/db/queries.ts | 40 +++- src/index.ts | 14 +- src/resolution/import-resolver.ts | 25 +++ src/resolution/index.ts | 75 ++++++- src/resolution/module-importers.ts | 64 +++++- 7 files changed, 420 insertions(+), 6 deletions(-) create mode 100644 __tests__/sync-default-export-change.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 361f7efe8..363afd45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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('X')` or `void apiRequest()`, now links to what it calls, as `await load()` 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 diff --git a/__tests__/sync-default-export-change.test.ts b/__tests__/sync-default-export-change.test.ts new file mode 100644 index 000000000..4837a43f7 --- /dev/null +++ b/__tests__/sync-default-export-change.test.ts @@ -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; + +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 { + 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>) + .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: ` -> ::`. */ +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: ,\n children: [{ path: 'one', element: }],\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); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index fe2f73d9e..35c729ba5 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -3106,7 +3106,7 @@ export class QueryBuilder { */ getCrossFileIncomingEdgesWithTarget( filePath: string - ): Array { + ): Array { 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 { + const out: Array = []; + 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; + 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}). diff --git a/src/index.ts b/src/index.ts index 524ca04e1..90a5a0114 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index faf19f25b..d7e844771 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -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$]*$/; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 126c75f05..75c12d103 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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>(); + 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(); + 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 = + 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(); + 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 diff --git a/src/resolution/module-importers.ts b/src/resolution/module-importers.ts index 3d7252468..bb63ab788 100644 --- a/src/resolution/module-importers.ts +++ b/src/resolution/module-importers.ts @@ -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:`. + 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(['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 | 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 }>(); @@ -596,7 +633,7 @@ function bindingsOf(importers: Importers, reader: ImportReader): Map, - 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): number { + const edgeIds: number[] = []; + const refs: UnresolvedReference[] = []; + const sites = new Set(); + 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;