fix(ts): a re-export imports its module, and sync follows barrels of barrels (#2526)

An `export … from './x'` statement emitted one `imports` reference per
re-exported name and nothing for the module itself; `export * from` emitted
nothing at all. So a barrel made only of `export *` lines had no dependency
on what it re-exports (no dependent for `affected`), and sync could not find
an `index.ts` that re-exports another barrel: when the inner one was deleted
or added, the outer one's importers kept their stale or name-matched edges.

A re-export now imports its module the way `import … from` does, in both
extractors (TS and the Rust kernel): an import node named by the specifier
and a module reference that links the barrel to the file it names. Sync's
importer discovery follows such modules to their own importers, carrying
the names each passes on (`export *`, a named or renamed re-export, an
`export * as ns`), as deep as the resolver chases a re-export; an ES
namespace import reads whatever its module passes on. A re-export's import
node does not count as a symbol for the "parse produced no symbols" warning,
so a barrel with an unparseable line still reports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-12 16:56:50 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7ba8eee4a7
commit 041db10146
7 changed files with 108 additions and 39 deletions
+18 -8
View File
@@ -564,6 +564,8 @@ export class TreeSitterExtractor {
private fileScopeValueCounts = new Map<string, number>(); // file-scope nodes per name (conditional-def detection)
private valueRefScopes: Array<{ id: string; node: SyntaxNode; name: string }> = [];
private errors: ExtractionError[] = [];
// Import nodes made for `export … from` statements: no symbols of the file's own.
private reExportImports = 0;
private extractor: LanguageExtractor | null = null;
private nodeStack: string[] = []; // Stack of parent node IDs
// C/C++ enclosing `namespace ns { … }` names, prepended to every contained
@@ -733,7 +735,9 @@ export class TreeSitterExtractor {
this.unresolvedReferences.push(...commonJsRequireRefs(this.filePath, this.source, this.language));
// hasError is routine for several grammars; warn only when no symbols survived.
const symbolCount = this.nodes.filter((n) => n.kind !== 'file').length;
// (A re-export's import of its module is none: a barrel whose other lines
// failed to parse has lost what it passes on.)
const symbolCount = this.nodes.filter((n) => n.kind !== 'file').length - this.reExportImports;
if (this.tree?.rootNode.hasError && symbolCount === 0) {
this.errors.push({
message:
@@ -1577,13 +1581,16 @@ export class TreeSitterExtractor {
else if (this.extractor.importTypes.includes(nodeType)) {
this.extractImport(node);
}
// Re-export from another module — `export { X } from './y'` (TS/JS). A
// re-export is a dependency on the source module just like an import, but
// the export_statement is otherwise only descended into (no declaration to
// extract), so a barrel that ONLY re-exports produced zero edges and showed
// 0 dependents. Link each re-exported name to its definition. Children are
// still visited (a non-re-export `export const X = …` has no `source` and
// falls through to its normal declaration extraction).
// Re-export from another module — `export { X } from './y'`, `export * from
// './y'` (TS/JS). A re-export is a dependency on the source module just like
// an import, but the export_statement is otherwise only descended into (no
// declaration to extract), so a barrel that ONLY re-exports produced zero
// edges and showed 0 dependents. It imports the module as `import … from`
// does — an import node and a reference to the module, which links the
// barrel to the file it names (so a barrel of barrels can be followed) —
// and links each re-exported name to its definition. Children are still
// visited (a non-re-export `export const X = …` has no `source` and falls
// through to its normal declaration extraction).
else if (
nodeType === 'export_statement' &&
(this.language === 'typescript' || this.language === 'tsx' ||
@@ -1591,6 +1598,9 @@ export class TreeSitterExtractor {
this.language === 'arkts') &&
getChildByField(node, 'source')
) {
const before = this.nodes.length;
this.extractImport(node);
this.reExportImports += this.nodes.length - before;
const parentId = this.nodeStack[this.nodeStack.length - 1];
if (parentId) this.emitReExportRefs(node, parentId);
}
+1 -1
View File
@@ -2732,7 +2732,7 @@ function findGoPackageMember(
/** Recursive depth cap for re-export chain following. Real codebases
* rarely chain barrels more than 2–3 deep; 8 is a generous safety
* net that still bounds worst-case work. */
const REEXPORT_MAX_DEPTH = 8;
export const REEXPORT_MAX_DEPTH = 8;
/**
* Find an exported symbol in `filePath`, following `export { x } from
+45 -28
View File
@@ -11,20 +11,18 @@
* binds them through the module when it is there, and another way (by name)
* or not at all when it is not.
*
* A Python module that re-exports another one (`from .shop import *`, or a
* package's `__init__.py` importing names from it) is the way in for its own
* importers, so theirs are followed too, as deep as the resolver chases a
* name through such modules. An ES module's `export … from` leaves no import
* edge or node to find it by, so an `index.ts` re-exporting another is not
* followed.
* A module that re-exports another one (`export * from './components'` in an
* `index.ts`, `from .shop import *` or a package's `__init__.py` importing
* names from it) is the way in for its own importers, so theirs are followed
* too, as deep as the resolver chases a name through such modules.
*/
import * as fs from 'fs';
import * as path from 'path';
import type { QueryBuilder } from '../db/queries';
import type { Language, UnresolvedReference } from '../types';
import type { ImportMapping } from './types';
import { extractImportMappings, pythonModulePath } from './import-resolver';
import type { ImportMapping, ReExport } from './types';
import { extractImportMappings, extractReExports, pythonModulePath, REEXPORT_MAX_DEPTH } from './import-resolver';
import { resurrectRefFromDroppedEdge } from './resurrect-ref';
/**
@@ -37,11 +35,12 @@ const BINDING_LANGUAGES = new Set<Language>([
]);
/**
* How many re-exporting Python modules deep the importers of a file are
* followed: as deep as the resolver chases a name through star imports and
* re-exports (`pythonModuleSymbol`).
* How many re-exporting modules deep the importers of a file are followed: as
* deep as the resolver chases a name through them, three for Python's star
* imports and re-exports (`pythonModuleSymbol`), `REEXPORT_MAX_DEPTH` for
* ES-module `export … from`.
*/
const PYTHON_REEXPORT_DEPTH = 3;
const reExportDepth = (language: Language): number => (language === 'python' ? 3 : REEXPORT_MAX_DEPTH);
/** A Python star import, read the way the resolver reads one. */
const PYTHON_STAR_IMPORT = /^\s*from\s+([\w.]+)\s+import\s+\*/gm;
@@ -72,12 +71,14 @@ type AddImporter = (importer: string, language: Language, source: string, module
export interface ImportReader {
/** Its import mappings. */
mappings(file: string, language: Language): ImportMapping[];
/** Its ES-module `export … from` statements, which have no import mapping. */
reExports(file: string, language: Language): ReExport[];
/** The modules a Python file star-imports, which have no import mapping. */
starImports(file: string, language: Language): string[];
}
export function createImportReader(rootDir: string): ImportReader {
const memo = new Map<string, { mappings: ImportMapping[]; starImports: string[] }>();
const memo = new Map<string, { mappings: ImportMapping[]; reExports: ReExport[]; starImports: string[] }>();
const read = (file: string, language: Language) => {
let entry = memo.get(file);
if (!entry) {
@@ -89,6 +90,7 @@ export function createImportReader(rootDir: string): ImportReader {
}
entry = {
mappings: content ? extractImportMappings(file, content, language) : [],
reExports: content ? extractReExports(content, language) : [],
starImports: language === 'python' ? [...content.matchAll(PYTHON_STAR_IMPORT)].map((m) => m[1]!) : [],
};
memo.set(file, entry);
@@ -97,6 +99,7 @@ export function createImportReader(rootDir: string): ImportReader {
};
return {
mappings: (file, language) => read(file, language).mappings,
reExports: (file, language) => read(file, language).reExports,
starImports: (file, language) => read(file, language).starImports,
};
}
@@ -177,12 +180,11 @@ function importersOf(
}
const python = level.filter((module) => module.language === 'python');
if (python.length > 0) addPythonImporters(queries, python, reader, add);
// The next level: the Python importers that pass on what they import from these.
if (depth > PYTHON_REEXPORT_DEPTH) break;
// The next level: the importers that pass on what they import from these.
const next: Module[] = [];
for (const [importer, { language, statements }] of naming) {
if (language !== 'python' || seen.has(importer)) continue;
const passes = pythonPassedOn(importer, statements, reader);
if (seen.has(importer) || depth > reExportDepth(language)) continue;
const passes = passedOn(importer, language, statements, reader);
if (passes === undefined) continue;
seen.add(importer);
next.push({ path: importer, language, passes });
@@ -193,12 +195,14 @@ function importersOf(
}
/**
* What the Python module `importer` passes on of what its `statements`
* import, or undefined when the resolver reads no name through it: what it
* star-imports, and for a package's `__init__.py`, the names it imports too.
* What `importer` passes on of what its `statements` import, or undefined
* when the resolver reads no name through it: an ES module what it `export …
* from`s; a Python module what it star-imports, and a package's `__init__.py`
* the names it imports too.
*/
function pythonPassedOn(
function passedOn(
importer: string,
language: Language,
statements: ReadonlyArray<{ source: string; module: Module; onlyName?: string }>,
reader: ImportReader,
): Set<string> | null | undefined {
@@ -206,15 +210,25 @@ function pythonPassedOn(
const pass = (names: Set<string> | null): void => {
passes = passes === null || names === null ? null : new Set([...(passes ?? []), ...names]);
};
const stars = new Set(reader.starImports(importer, 'python'));
const passing = (module: Module, name: string): boolean => module.passes === null || module.passes.has(name);
if (language !== 'python') {
for (const rex of reader.reExports(importer, language)) {
for (const { source, module } of statements) {
if (source !== rex.source) continue;
if (rex.kind === 'wildcard') pass(module.passes);
else if (rex.kind === 'namespace') pass(new Set([rex.exportedName]));
else if (passing(module, rex.originalName)) pass(new Set([rex.exportedName]));
}
}
return passes;
}
const stars = new Set(reader.starImports(importer, language));
const isPackage = importer === '__init__.py' || importer.endsWith('/__init__.py');
for (const { source, module, onlyName } of statements) {
if (onlyName === undefined && stars.has(source)) pass(module.passes);
if (!isPackage) continue;
const names = reader.mappings(importer, 'python')
.filter((m) => m.source === source && (onlyName !== undefined
? m.exportedName === onlyName
: module.passes === null || module.passes.has(m.exportedName)))
const names = reader.mappings(importer, language)
.filter((m) => m.source === source && (onlyName !== undefined ? m.exportedName === onlyName : passing(module, m.exportedName)))
.map((m) => m.localName);
if (names.length > 0) pass(new Set(names));
}
@@ -288,7 +302,8 @@ function addPythonImporters(
/**
* Per importer, what its statements naming the modules import: the modules
* named whole (not the package around one), and the local names bound.
* named whole (not the package around one), and the local names bound. An ES
* namespace import (`import * as z`) reads whatever its module passes on.
*/
function bindingsOf(importers: Importers, reader: ImportReader): Map<string, { modules: string[]; names: string[] }> {
const bindings = new Map<string, { modules: string[]; names: string[] }>();
@@ -297,7 +312,9 @@ function bindingsOf(importers: Importers, reader: ImportReader): Map<string, { m
reader.mappings(file, language)
.filter((m) => {
const only = sources.get(m.source);
return only === null || (only !== undefined && only.has(m.exportedName));
if (only === null) return true;
if (only === undefined) return false;
return only.has(m.exportedName) || (m.isNamespace && language !== 'python' && only.size > 0);
})
.map((m) => m.localName)
);