mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 04:19:33 +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
@@ -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