diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 285bc7f79..08e6b6a04 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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>; + +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> }, + filePath: string, + symbolTable: { lookupExact(filePath: string, name: string): string | undefined }, + graph: { getNode(id: string): { properties?: { isExported?: boolean } } | undefined }, +): Map | null { + const fileScope = typeEnv.env.get(''); + if (!fileScope || fileScope.size === 0) return null; + + const exported = new Map(); + 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), 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 => { 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 diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index dc2d4658b..0d761cc22 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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>, +): readonly IndependentFileGroup[] { + // Build in-degree map and reverse dependency map + const inDegree = new Map(); + const reverseDeps = new Map(); + + 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(); + 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(); + 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; diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 9cda7390f..4b0ecc3a9 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -647,6 +647,26 @@ const resolveFixpointBindings = ( export interface BuildTypeEnvOptions { symbolTable?: SymbolTable; parentMap?: ReadonlyMap; + /** 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; +} + +/** 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, +): 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): diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-circular/src/a.ts b/gitnexus/test/fixtures/cross-file-binding/ts-circular/src/a.ts new file mode 100644 index 000000000..54adda1f2 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-circular/src/a.ts @@ -0,0 +1,8 @@ +import { getB } from './b'; +export class A { + doA(): void {} +} +export function processA() { + const b = getB(); + b.doB(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-circular/src/b.ts b/gitnexus/test/fixtures/cross-file-binding/ts-circular/src/b.ts new file mode 100644 index 000000000..f15cfe792 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-circular/src/b.ts @@ -0,0 +1,7 @@ +import { A } from './a'; +export class B { + doB(): void {} +} +export function getB(): B { + return new B(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/app.ts b/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/app.ts new file mode 100644 index 000000000..97b48527f --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/app.ts @@ -0,0 +1,5 @@ +import { getConfig } from './index'; +export function init() { + const config = getConfig(); + config.validate(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/core.ts b/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/core.ts new file mode 100644 index 000000000..93000e716 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/core.ts @@ -0,0 +1,6 @@ +export class Config { + validate(): boolean { return true; } +} +export function getConfig(): Config { + return new Config(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/index.ts b/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/index.ts new file mode 100644 index 000000000..c7d471009 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-reexport/src/index.ts @@ -0,0 +1 @@ +export { getConfig } from './core'; diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/app.ts b/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/app.ts new file mode 100644 index 000000000..d9d0f0f16 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/app.ts @@ -0,0 +1,5 @@ +import { user } from './service'; +export function main() { + user.save(); + user.getName(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/models.ts b/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/models.ts new file mode 100644 index 000000000..b2115f9f2 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/models.ts @@ -0,0 +1,7 @@ +export class User { + save(): void {} + getName(): string { return ''; } +} +export function getUser(): User { + return new User(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/service.ts b/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/service.ts new file mode 100644 index 000000000..e0fb9dd7b --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-simple/src/service.ts @@ -0,0 +1,2 @@ +import { getUser } from './models'; +export const user = getUser(); diff --git a/gitnexus/test/integration/cross-file-binding.test.ts b/gitnexus/test/integration/cross-file-binding.test.ts new file mode 100644 index 000000000..60180b1ac --- /dev/null +++ b/gitnexus/test/integration/cross-file-binding.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/topological-sort.test.ts b/gitnexus/test/unit/topological-sort.test.ts new file mode 100644 index 000000000..f5d4d8175 --- /dev/null +++ b/gitnexus/test/unit/topological-sort.test.ts @@ -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>(); + const levels = topologicalLevelSort(importMap); + expect(levels).toEqual([]); + }); + + it('returns single level for files with no imports', () => { + const importMap = new Map>([ + ['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>([ + ['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>([ + ['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>([ + ['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>([ + ['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>([ + ['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>([ + ['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>([ + ['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>([ + ['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>([ + ['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); + }); +}); diff --git a/gitnexus/test/unit/type-env.test.ts b/gitnexus/test/unit/type-env.test.ts index 5ec9c05c3..567170efe 100644 --- a/gitnexus/test/unit/type-env.test.ts +++ b/gitnexus/test/unit/type-env.test.ts @@ -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'); + }); + }); + });