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
+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;