feat(type-resolution): Phase 14 — cross-file binding propagation

Add ExportedTypeMap infrastructure to propagate resolved type bindings
across file boundaries. When file A exports `const user = getUser()`
(resolved to `User`), file B importing `user` now gets seeded with
`user → User`, enabling `user.save()` to produce CALLS edges.

Key components:
- `importedBindings` option on BuildTypeEnvOptions with scopeEnv seeding
  AFTER walk() to respect first-writer-wins (local declarations win)
- `collectExportedBindings()` in call-processor using graph node
  isExported flag (no SymbolDefinition changes needed)
- Inline Kahn's algorithm topological sort with level grouping for
  parallel-safe file ordering and cycle detection
- Re-resolution pass in pipeline.ts: topological order, 3% skip
  threshold, path validation, per-file export caps (500)
- 32 new tests: 11 topological sort, 6 seeding, 15 integration
  (simple cross-file, re-export chain, circular imports)

All 3454 tests pass (32 net new, 0 regressions).
This commit is contained in:
Gergo Magyar 2026-03-20 09:31:20 +00:00
parent 862fdf9185
commit a6a1004e82
14 changed files with 691 additions and 3 deletions

View file

@ -33,6 +33,38 @@ import { typeConfigs } from './type-extractors/index.js';
import type { LiteralTypeInferrer } from './type-extractors/types.js';
import type { SyntaxNode } from './utils.js';
/** Per-file resolved type bindings for exported symbols.
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
export type ExportedTypeMap = Map<string, Map<string, string>>;
const MAX_EXPORTS_PER_FILE = 500;
const MAX_TYPE_NAME_LENGTH = 256;
/** Collect resolved type bindings for exported file-scope symbols.
* Uses graph node isExported flag — does NOT require isExported on SymbolDefinition. */
function collectExportedBindings(
typeEnv: { readonly env: ReadonlyMap<string, ReadonlyMap<string, string>> },
filePath: string,
symbolTable: { lookupExact(filePath: string, name: string): string | undefined },
graph: { getNode(id: string): { properties?: { isExported?: boolean } } | undefined },
): Map<string, string> | null {
const fileScope = typeEnv.env.get('');
if (!fileScope || fileScope.size === 0) return null;
const exported = new Map<string, string>();
for (const [varName, typeName] of fileScope) {
if (exported.size >= MAX_EXPORTS_PER_FILE) break;
if (!typeName || typeName.length > MAX_TYPE_NAME_LENGTH) continue;
const nodeId = symbolTable.lookupExact(filePath, varName);
if (!nodeId) continue;
const node = graph.getNode(nodeId);
if (node?.properties?.isExported) {
exported.set(varName, typeName);
}
}
return exported.size > 0 ? exported : null;
}
// Stdlib methods that preserve the receiver's type identity. When TypeEnv already
// strips nullable wrappers (Option<User> → User), these chain steps are no-ops
// for type resolution — the current type passes through unchanged.
@ -138,6 +170,7 @@ export const processCalls = async (
astCache: ASTCache,
ctx: ResolutionContext,
onProgress?: (current: number, total: number) => void,
exportedTypeMap?: ExportedTypeMap,
): Promise<ExtractedHeritage[]> => {
const parser = await loadParser();
const collectedHeritage: ExtractedHeritage[] = [];
@ -224,6 +257,10 @@ export const processCalls = async (
}
const typeEnv = lang ? buildTypeEnv(tree, lang, { symbolTable: ctx.symbols, parentMap }) : null;
if (typeEnv && exportedTypeMap) {
const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph);
if (fileExports) exportedTypeMap.set(file.path, fileExports);
}
const callRouter = callRouters[language];
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0
@ -964,6 +1001,7 @@ export const processCallsFromExtracted = async (
ctx: ResolutionContext,
onProgress?: (current: number, total: number) => void,
constructorBindings?: FileConstructorBindings[],
exportedTypeMap?: ExportedTypeMap,
) => {
// Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName.
// The scope dimension prevents collisions when two functions in the same file

View file

@ -6,17 +6,19 @@ import {
processImportsFromExtracted,
buildImportResolutionContext
} from './import-processor.js';
import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted } from './call-processor.js';
import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted, type ExportedTypeMap } from './call-processor.js';
import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
import { computeMRO } from './mro-processor.js';
import { processCommunities } from './community-processor.js';
import { processProcesses } from './process-processor.js';
import { createResolutionContext } from './resolution-context.js';
import { buildTypeEnv } from './type-env.js';
import { createASTCache } from './ast-cache.js';
import { PipelineProgress, PipelineResult } from '../../types/pipeline.js';
import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js';
import { getLanguageFromFilename } from './utils.js';
import { isLanguageAvailable } from '../tree-sitter/parser-loader.js';
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { getTreeSitterBufferSize } from './constants.js';
import { createWorkerPool, WorkerPool } from './workers/worker-pool.js';
import fs from 'node:fs';
import path from 'node:path';
@ -24,6 +26,62 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
const isDev = process.env.NODE_ENV === 'development';
/** A group of files with no mutual dependencies, safe to process in parallel. */
type IndependentFileGroup = readonly string[];
/** Kahn's algorithm: returns files grouped by topological level.
* Files in the same level have no mutual dependencies — safe to process in parallel.
* Files in cycles are returned as a final group (no cross-cycle propagation). */
export function topologicalLevelSort(
importMap: ReadonlyMap<string, ReadonlySet<string>>,
): readonly IndependentFileGroup[] {
// Build in-degree map and reverse dependency map
const inDegree = new Map<string, number>();
const reverseDeps = new Map<string, string[]>();
for (const [file, deps] of importMap) {
if (!inDegree.has(file)) inDegree.set(file, 0);
for (const dep of deps) {
if (!inDegree.has(dep)) inDegree.set(dep, 0);
// file imports dep, so dep must be processed before file
// In Kahn's terms: dep → file (dep is a prerequisite of file)
inDegree.set(file, (inDegree.get(file) ?? 0) + 1);
let rev = reverseDeps.get(dep);
if (!rev) { rev = []; reverseDeps.set(dep, rev); }
rev.push(file);
}
}
// BFS from zero-in-degree nodes, grouping by level
const levels: string[][] = [];
let currentLevel = [...inDegree.entries()]
.filter(([, d]) => d === 0)
.map(([f]) => f);
while (currentLevel.length > 0) {
levels.push(currentLevel);
const nextLevel: string[] = [];
for (const file of currentLevel) {
for (const dependent of reverseDeps.get(file) ?? []) {
const newDeg = (inDegree.get(dependent) ?? 1) - 1;
inDegree.set(dependent, newDeg);
if (newDeg === 0) nextLevel.push(dependent);
}
}
currentLevel = nextLevel;
}
// Files still with positive in-degree are in cycles — add as final group
const cycleFiles = [...inDegree.entries()]
.filter(([, d]) => d > 0)
.map(([f]) => f);
if (cycleFiles.length > 0) {
levels.push(cycleFiles);
}
return levels;
}
/** Max bytes of source content to load per parse chunk. Each chunk's source +
* parsed ASTs + extracted records + worker serialization overhead all live in
* memory simultaneously, so this must be conservative. 20MB source ≈ 200-400MB
@ -201,6 +259,8 @@ export const runPipelineFromRepo = async (
// are already registered). This trades ~5% cross-chunk resolution accuracy for
// 200-400MB less memory — critical for Linux-kernel-scale repos.
const sequentialChunkPaths: string[][] = [];
// Phase 14: Collect exported type bindings for cross-file propagation
const exportedTypeMap: ExportedTypeMap = new Map();
try {
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
@ -260,6 +320,7 @@ export const runPipelineFromRepo = async (
});
},
chunkWorkerData.constructorBindings,
exportedTypeMap,
),
processHeritageFromExtracted(
graph,
@ -316,7 +377,7 @@ export const runPipelineFromRepo = async (
.filter(p => chunkContents.has(p))
.map(p => ({ path: p, content: chunkContents.get(p)! }));
astCache = createASTCache(chunkFiles.length);
const rubyHeritage = await processCalls(graph, chunkFiles, astCache, ctx);
const rubyHeritage = await processCalls(graph, chunkFiles, astCache, ctx, undefined, exportedTypeMap);
await processHeritage(graph, chunkFiles, astCache, ctx);
if (rubyHeritage.length > 0) {
await processHeritageFromExtracted(graph, rubyHeritage, ctx);
@ -332,6 +393,114 @@ export const runPipelineFromRepo = async (
console.log(`🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`);
}
// ── Phase 14: Cross-file binding propagation ──────────────────────
// Seed downstream files with resolved type bindings from upstream files.
// Uses namedImportMap (populated during import processing) to determine
// which exported bindings each file needs. Files processed in topological
// import order so upstream bindings are available when downstream runs.
if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0) {
const allPathSet = new Set(allPaths);
const levels = topologicalLevelSort(ctx.importMap);
// Count files that would benefit from cross-file seeding
let filesWithGaps = 0;
for (const level of levels) {
for (const filePath of level) {
const imports = ctx.namedImportMap.get(filePath);
if (!imports) continue;
for (const [, binding] of imports) {
if (exportedTypeMap.has(binding.sourcePath)) { filesWithGaps++; break; }
}
}
}
const totalProcessed = exportedTypeMap.size + filesWithGaps;
const gapRatio = totalProcessed > 0 ? filesWithGaps / totalProcessed : 0;
const CROSS_FILE_SKIP_THRESHOLD = 0.03;
if (gapRatio < CROSS_FILE_SKIP_THRESHOLD) {
if (isDev) {
console.log(`⏭️ Cross-file re-resolution skipped (${filesWithGaps} files, ${(gapRatio * 100).toFixed(1)}% < ${CROSS_FILE_SKIP_THRESHOLD * 100}% threshold)`);
}
} else {
onProgress({
phase: 'parsing',
percent: 82,
message: `Cross-file type propagation (${filesWithGaps} files)...`,
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
});
let crossFileResolved = 0;
const crossFileStart = Date.now();
astCache = createASTCache(AST_CACHE_CAP);
for (const level of levels) {
for (const filePath of level) {
const imports = ctx.namedImportMap.get(filePath);
if (!imports) continue;
// Build seeded bindings from upstream ExportedTypeMap
const seeded = new Map<string, string>();
for (const [localName, binding] of imports) {
const upstream = exportedTypeMap.get(binding.sourcePath);
if (upstream) {
const type = upstream.get(binding.exportedName);
if (type) seeded.set(localName, type);
}
}
if (seeded.size === 0) continue;
// Validate path before re-reading (defense-in-depth)
if (!allPathSet.has(filePath)) continue;
// Re-read, re-parse, and re-resolve with seeded bindings
const lang = getLanguageFromFilename(filePath);
if (!lang || !isLanguageAvailable(lang)) continue;
const contentMap = await readFileContents(repoPath, [filePath]);
const content = contentMap.get(filePath);
if (!content) continue;
const parser = await loadParser();
await loadLanguage(lang, filePath);
const bufferSize = getTreeSitterBufferSize(content.length);
if (bufferSize) (parser as any).setBufferSize?.(bufferSize);
const tree = parser.parse(content);
const typeEnv = buildTypeEnv(tree, lang, {
symbolTable: ctx.symbols,
importedBindings: seeded,
});
// Collect updated exports for downstream propagation
const fileScope = typeEnv.env.get('');
if (fileScope) {
const updated = new Map<string, string>();
for (const [varName, typeName] of fileScope) {
if (updated.size >= 500) break;
if (!typeName || typeName.length > 256) continue;
const nodeId = ctx.symbols.lookupExact(filePath, varName);
if (!nodeId) continue;
const node = graph.getNode(nodeId);
if (node?.properties?.isExported) {
updated.set(varName, typeName);
}
}
if (updated.size > 0) exportedTypeMap.set(filePath, updated);
}
crossFileResolved++;
}
astCache.clear();
}
if (isDev) {
const elapsed = Date.now() - crossFileStart;
console.log(`🔗 Cross-file re-resolution: ${crossFileResolved} files re-processed in ${elapsed}ms`);
}
}
}
// Free import resolution context — suffix index + resolve cache no longer needed
// (allPathObjects and importCtx hold ~94MB+ for large repos)
allPathObjects.length = 0;

View file

@ -647,6 +647,26 @@ const resolveFixpointBindings = (
export interface BuildTypeEnvOptions {
symbolTable?: SymbolTable;
parentMap?: ReadonlyMap<string, readonly string[]>;
/** Pre-resolved bindings from upstream files (Phase 14).
* Seeded into FILE_SCOPE after walk() for names with no local binding.
* Local declarations always take precedence (first-writer-wins). */
importedBindings?: ReadonlyMap<string, string>;
}
/** Seed cross-file type bindings into the file scope.
* MUST be called AFTER walk() completes so that local declarations
* (Tier 0/1) always take precedence over imported bindings (first-writer-wins). */
function seedImportedBindings(
env: TypeEnv,
importedBindings: ReadonlyMap<string, string>,
): void {
let fileEnv = env.get(FILE_SCOPE);
if (!fileEnv) { fileEnv = new Map(); env.set(FILE_SCOPE, fileEnv); }
for (const [name, type] of importedBindings) {
if (!fileEnv.has(name)) {
fileEnv.set(name, type);
}
}
}
export const buildTypeEnv = (
@ -996,6 +1016,12 @@ export const buildTypeEnv = (
walk(tree.rootNode, FILE_SCOPE);
// Phase 14: Seed cross-file bindings from upstream files AFTER walk
// (local declarations from walk() take precedence — first-writer-wins)
if (options?.importedBindings && options.importedBindings.size > 0) {
seedImportedBindings(env, options.importedBindings);
}
resolveFixpointBindings(pendingItems, env, returnTypeLookup, symbolTable, parentMap);
// Post-fixpoint for-loop replay (Phase 10 / ex-9B loop-fixpoint bridge):

View file

@ -0,0 +1,8 @@
import { getB } from './b';
export class A {
doA(): void {}
}
export function processA() {
const b = getB();
b.doB();
}

View file

@ -0,0 +1,7 @@
import { A } from './a';
export class B {
doB(): void {}
}
export function getB(): B {
return new B();
}

View file

@ -0,0 +1,5 @@
import { getConfig } from './index';
export function init() {
const config = getConfig();
config.validate();
}

View file

@ -0,0 +1,6 @@
export class Config {
validate(): boolean { return true; }
}
export function getConfig(): Config {
return new Config();
}

View file

@ -0,0 +1 @@
export { getConfig } from './core';

View file

@ -0,0 +1,5 @@
import { user } from './service';
export function main() {
user.save();
user.getName();
}

View file

@ -0,0 +1,7 @@
export class User {
save(): void {}
getName(): string { return ''; }
}
export function getUser(): User {
return new User();
}

View file

@ -0,0 +1,2 @@
import { getUser } from './models';
export const user = getUser();

View file

@ -0,0 +1,180 @@
/**
* Phase 14: Cross-file type binding propagation
*
* When file A exports `const user = getUser()` (resolved to type User), and
* file B imports `user`, Phase 14 seeds `user → User` into file B's type
* environment, enabling `user.save()` in file B to produce a CALLS edge to
* User#save.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
getRelationships,
getNodesByLabel,
runPipelineFromRepo,
type PipelineResult,
} from './resolvers/helpers.js';
const CROSS_FILE_FIXTURES = path.resolve(__dirname, '..', 'fixtures', 'cross-file-binding');
// ---------------------------------------------------------------------------
// Simple cross-file: models → service → app
// models.ts exports getUser(): User
// service.ts exports const user = getUser() (user → User via call-result)
// app.ts imports user from service → seeds user → User → resolves user.save()
// ---------------------------------------------------------------------------
describe('Cross-File Binding Propagation: TypeScript simple cross-file', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'ts-simple'),
() => {},
);
}, 60000);
it('detects User class with save and getName methods', () => {
expect(getNodesByLabel(result, 'Class')).toContain('User');
expect(getNodesByLabel(result, 'Method')).toContain('save');
expect(getNodesByLabel(result, 'Method')).toContain('getName');
});
it('detects getUser function and main function', () => {
expect(getNodesByLabel(result, 'Function')).toContain('getUser');
expect(getNodesByLabel(result, 'Function')).toContain('main');
});
it('resolves user.save() in main() to User#save via cross-file binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(c =>
c.target === 'save' &&
c.source === 'main' &&
c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves user.getName() in main() to User#getName via cross-file binding', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(c =>
c.target === 'getName' &&
c.source === 'main' &&
c.targetFilePath.includes('models'),
);
expect(getNameCall).toBeDefined();
});
it('emits HAS_METHOD edges linking save and getName to User', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const saveEdge = hasMethod.find(e => e.source === 'User' && e.target === 'save');
const getNameEdge = hasMethod.find(e => e.source === 'User' && e.target === 'getName');
expect(saveEdge).toBeDefined();
expect(getNameEdge).toBeDefined();
});
it('emits IMPORTS edges across all three files', () => {
const imports = getRelationships(result, 'IMPORTS');
// service.ts → models.ts and app.ts → service.ts
expect(imports.length).toBeGreaterThanOrEqual(2);
const paths = imports.map(e => `${e.sourceFilePath} → ${e.targetFilePath}`);
expect(paths.some(p => p.includes('service') && p.includes('models'))).toBe(true);
expect(paths.some(p => p.includes('app') && p.includes('service'))).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Re-export chain: core → index (barrel) → app
// core.ts exports getConfig(): Config
// index.ts re-exports getConfig from core (no new bindings)
// app.ts imports getConfig from index, creates local const config = getConfig()
// → config.validate() resolves to Config#validate via local call-result binding
// ---------------------------------------------------------------------------
describe('Cross-File Binding Propagation: TypeScript re-export chain', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'ts-reexport'),
() => {},
);
}, 60000);
it('detects Config class with validate method', () => {
expect(getNodesByLabel(result, 'Class')).toContain('Config');
expect(getNodesByLabel(result, 'Method')).toContain('validate');
});
it('detects getConfig function and init function', () => {
expect(getNodesByLabel(result, 'Function')).toContain('getConfig');
expect(getNodesByLabel(result, 'Function')).toContain('init');
});
it('resolves config.validate() in init() to Config#validate', () => {
const calls = getRelationships(result, 'CALLS');
const validateCall = calls.find(c =>
c.target === 'validate' &&
c.source === 'init' &&
c.targetFilePath.includes('core'),
);
expect(validateCall).toBeDefined();
});
it('emits HAS_METHOD edge from Config to validate', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const edge = hasMethod.find(e => e.source === 'Config' && e.target === 'validate');
expect(edge).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Circular imports: a.ts ↔ b.ts
// a.ts imports getB from b.ts; b.ts imports A from a.ts
// Conservative expectation: pipeline completes without error.
// Cross-file binding propagation across cycles is not guaranteed.
// ---------------------------------------------------------------------------
describe('Cross-File Binding Propagation: TypeScript circular imports', () => {
let result: PipelineResult;
let pipelineError: unknown;
beforeAll(async () => {
try {
result = await runPipelineFromRepo(
path.join(CROSS_FILE_FIXTURES, 'ts-circular'),
() => {},
);
} catch (err) {
pipelineError = err;
}
}, 60000);
it('pipeline completes without throwing on circular imports', () => {
expect(pipelineError).toBeUndefined();
});
it('detects both class A and class B', () => {
expect(getNodesByLabel(result, 'Class')).toContain('A');
expect(getNodesByLabel(result, 'Class')).toContain('B');
});
it('detects doA and doB methods', () => {
expect(getNodesByLabel(result, 'Method')).toContain('doA');
expect(getNodesByLabel(result, 'Method')).toContain('doB');
});
it('detects processA and getB functions', () => {
expect(getNodesByLabel(result, 'Function')).toContain('processA');
expect(getNodesByLabel(result, 'Function')).toContain('getB');
});
it('emits IMPORTS edges reflecting the circular dependency', () => {
const imports = getRelationships(result, 'IMPORTS');
const paths = imports.map(e => `${e.sourceFilePath} → ${e.targetFilePath}`);
// a.ts imports from b.ts
expect(paths.some(p => p.includes('a.ts') && p.includes('b.ts'))).toBe(true);
// b.ts imports from a.ts
expect(paths.some(p => p.includes('b.ts') && p.includes('a.ts'))).toBe(true);
});
});

View file

@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest';
import { topologicalLevelSort } from '../../src/core/ingestion/pipeline.js';
describe('topologicalLevelSort', () => {
it('returns empty levels for empty graph', () => {
const importMap = new Map<string, Set<string>>();
const levels = topologicalLevelSort(importMap);
expect(levels).toEqual([]);
});
it('returns single level for files with no imports', () => {
const importMap = new Map<string, Set<string>>([
['a.ts', new Set()],
['b.ts', new Set()],
]);
const levels = topologicalLevelSort(importMap);
expect(levels).toHaveLength(1);
expect(levels[0]).toContain('a.ts');
expect(levels[0]).toContain('b.ts');
});
it('orders files by dependency depth', () => {
// b imports a, c imports b → a first, then b, then c
const importMap = new Map<string, Set<string>>([
['a.ts', new Set()],
['b.ts', new Set(['a.ts'])],
['c.ts', new Set(['b.ts'])],
]);
const levels = topologicalLevelSort(importMap);
expect(levels).toHaveLength(3);
expect(levels[0]).toContain('a.ts');
expect(levels[1]).toContain('b.ts');
expect(levels[2]).toContain('c.ts');
});
it('groups independent files at the same level', () => {
// b and c both import a → a at level 0, b and c at level 1
const importMap = new Map<string, Set<string>>([
['a.ts', new Set()],
['b.ts', new Set(['a.ts'])],
['c.ts', new Set(['a.ts'])],
]);
const levels = topologicalLevelSort(importMap);
expect(levels).toHaveLength(2);
expect(levels[0]).toContain('a.ts');
expect(levels[1]).toContain('b.ts');
expect(levels[1]).toContain('c.ts');
});
it('handles cycles by grouping them in a final level', () => {
// a imports b, b imports a — circular
const importMap = new Map<string, Set<string>>([
['a.ts', new Set(['b.ts'])],
['b.ts', new Set(['a.ts'])],
]);
const levels = topologicalLevelSort(importMap);
// Both should appear (in a cycle group)
const allFiles = levels.flat();
expect(allFiles).toContain('a.ts');
expect(allFiles).toContain('b.ts');
});
it('handles disconnected components', () => {
// Two independent groups with no cross-links
const importMap = new Map<string, Set<string>>([
['a.ts', new Set()],
['b.ts', new Set(['a.ts'])],
['x.ts', new Set()],
['y.ts', new Set(['x.ts'])],
]);
const levels = topologicalLevelSort(importMap);
// Level 0 has both roots, level 1 has both dependents
expect(levels[0]).toContain('a.ts');
expect(levels[0]).toContain('x.ts');
expect(levels[1]).toContain('b.ts');
expect(levels[1]).toContain('y.ts');
});
it('handles diamond dependencies', () => {
// d imports b and c; b and c both import a
const importMap = new Map<string, Set<string>>([
['a.ts', new Set()],
['b.ts', new Set(['a.ts'])],
['c.ts', new Set(['a.ts'])],
['d.ts', new Set(['b.ts', 'c.ts'])],
]);
const levels = topologicalLevelSort(importMap);
expect(levels).toHaveLength(3);
expect(levels[0]).toContain('a.ts');
expect(levels[1]).toContain('b.ts');
expect(levels[1]).toContain('c.ts');
expect(levels[2]).toContain('d.ts');
});
it('handles a single file with no imports', () => {
const importMap = new Map<string, Set<string>>([
['only.ts', new Set()],
]);
const levels = topologicalLevelSort(importMap);
expect(levels).toHaveLength(1);
expect(levels[0]).toContain('only.ts');
});
it('handles a dependency on a file not explicitly in the map', () => {
// b imports external.ts which is not itself a key in importMap
const importMap = new Map<string, Set<string>>([
['b.ts', new Set(['external.ts'])],
]);
const levels = topologicalLevelSort(importMap);
// external.ts has in-degree 0 (no one depends on it as a key), appears first
// b.ts depends on external.ts so appears after
const allFiles = levels.flat();
expect(allFiles).toContain('external.ts');
expect(allFiles).toContain('b.ts');
const externalLevel = levels.findIndex(l => l.includes('external.ts'));
const bLevel = levels.findIndex(l => l.includes('b.ts'));
expect(externalLevel).toBeLessThan(bLevel);
});
it('handles a cycle mixed with an acyclic dependent', () => {
// a and b are cyclic; c depends on b (and ends up in cycle group too
// because b never reaches in-degree 0)
const importMap = new Map<string, Set<string>>([
['a.ts', new Set(['b.ts'])],
['b.ts', new Set(['a.ts'])],
['c.ts', new Set(['b.ts'])],
]);
const levels = topologicalLevelSort(importMap);
// No file has in-degree 0 to start: a needs b, b needs a, c needs b
// All end up in the cycle group
const allFiles = levels.flat();
expect(allFiles).toContain('a.ts');
expect(allFiles).toContain('b.ts');
expect(allFiles).toContain('c.ts');
});
it('all files appear exactly once across all levels', () => {
const importMap = new Map<string, Set<string>>([
['a.ts', new Set()],
['b.ts', new Set(['a.ts'])],
['c.ts', new Set(['a.ts'])],
['d.ts', new Set(['b.ts', 'c.ts'])],
]);
const levels = topologicalLevelSort(importMap);
const allFiles = levels.flat();
const uniqueFiles = new Set(allFiles);
expect(uniqueFiles.size).toBe(allFiles.length);
expect(uniqueFiles.size).toBe(4);
});
});

View file

@ -4044,4 +4044,88 @@ class App {
});
});
describe('Phase 14: importedBindings seeding', () => {
it('seeds imported bindings into file scope for unbound names', () => {
// Source has no local declaration of 'config', so the imported binding wins
const tree = parse('', TypeScript.typescript);
const { env } = buildTypeEnv(tree, 'typescript', {
importedBindings: new Map([['config', 'Config']]),
});
const fileScope = env.get('');
expect(fileScope?.get('config')).toBe('Config');
});
it('local declarations take precedence over imported bindings', () => {
// Source declares config: AppConfig — imported binding for 'config' must not overwrite it
const tree = parse('const config: AppConfig = getConfig();', TypeScript.typescript);
const { env } = buildTypeEnv(tree, 'typescript', {
importedBindings: new Map([['config', 'Config']]),
});
const fileScope = env.get('');
// AppConfig from the local annotation wins over the imported 'Config'
expect(fileScope?.get('config')).toBe('AppConfig');
});
it('does nothing when importedBindings is empty', () => {
const tree = parse('const user: User = getUser();', TypeScript.typescript);
const { env: envWithout } = buildTypeEnv(tree, 'typescript');
const { env: envWith } = buildTypeEnv(tree, 'typescript', {
importedBindings: new Map(),
});
// Both envs should produce the same file-scope content
const scopeWithout = envWithout.get('');
const scopeWith = envWith.get('');
expect(scopeWith?.get('user')).toBe(scopeWithout?.get('user'));
expect(scopeWith?.size).toBe(scopeWithout?.size);
});
it('seeds multiple bindings', () => {
const tree = parse('', TypeScript.typescript);
const { env } = buildTypeEnv(tree, 'typescript', {
importedBindings: new Map([
['user', 'User'],
['config', 'Config'],
]),
});
const fileScope = env.get('');
expect(fileScope?.get('user')).toBe('User');
expect(fileScope?.get('config')).toBe('Config');
});
it('seeded bindings are reachable via lookup from a nested call node', () => {
// A call inside a function should still be able to look up a file-scope seeded binding
const code = `
function process() {
config.validate();
}`;
const tree = parse(code, TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript', {
importedBindings: new Map([['config', 'Config']]),
});
const calls: any[] = [];
function findCalls(node: any) {
if (node.type === 'call_expression') calls.push(node);
for (let i = 0; i < node.childCount; i++) findCalls(node.child(i));
}
findCalls(tree.rootNode);
// config.validate() — lookup 'config' from inside the process function scope
expect(typeEnv.lookup('config', calls[0])).toBe('Config');
});
it('seeds bindings with no conflict when local file has unrelated declarations', () => {
// File has 'user' declared locally; 'config' comes from importedBindings
const tree = parse('const user: User = getUser();', TypeScript.typescript);
const { env } = buildTypeEnv(tree, 'typescript', {
importedBindings: new Map([['config', 'Config']]),
});
const fileScope = env.get('');
// Local binding preserved
expect(fileScope?.get('user')).toBe('User');
// Imported binding added for the name that had no local declaration
expect(fileScope?.get('config')).toBe('Config');
});
});
});