mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
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:
parent
862fdf9185
commit
a6a1004e82
14 changed files with 691 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
8
gitnexus/test/fixtures/cross-file-binding/ts-circular/src/a.ts
vendored
Normal file
8
gitnexus/test/fixtures/cross-file-binding/ts-circular/src/a.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { getB } from './b';
|
||||
export class A {
|
||||
doA(): void {}
|
||||
}
|
||||
export function processA() {
|
||||
const b = getB();
|
||||
b.doB();
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/ts-circular/src/b.ts
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/ts-circular/src/b.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { A } from './a';
|
||||
export class B {
|
||||
doB(): void {}
|
||||
}
|
||||
export function getB(): B {
|
||||
return new B();
|
||||
}
|
||||
5
gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/app.ts
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/app.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { getConfig } from './index';
|
||||
export function init() {
|
||||
const config = getConfig();
|
||||
config.validate();
|
||||
}
|
||||
6
gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/core.ts
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/core.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export class Config {
|
||||
validate(): boolean { return true; }
|
||||
}
|
||||
export function getConfig(): Config {
|
||||
return new Config();
|
||||
}
|
||||
1
gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/index.ts
vendored
Normal file
1
gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/index.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { getConfig } from './core';
|
||||
5
gitnexus/test/fixtures/cross-file-binding/ts-simple/src/app.ts
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/ts-simple/src/app.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { user } from './service';
|
||||
export function main() {
|
||||
user.save();
|
||||
user.getName();
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/ts-simple/src/models.ts
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/ts-simple/src/models.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export class User {
|
||||
save(): void {}
|
||||
getName(): string { return ''; }
|
||||
}
|
||||
export function getUser(): User {
|
||||
return new User();
|
||||
}
|
||||
2
gitnexus/test/fixtures/cross-file-binding/ts-simple/src/service.ts
vendored
Normal file
2
gitnexus/test/fixtures/cross-file-binding/ts-simple/src/service.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { getUser } from './models';
|
||||
export const user = getUser();
|
||||
180
gitnexus/test/integration/cross-file-binding.test.ts
Normal file
180
gitnexus/test/integration/cross-file-binding.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
150
gitnexus/test/unit/topological-sort.test.ts
Normal file
150
gitnexus/test/unit/topological-sort.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue