mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3ab768d9-3993-4882-9d8f-17f7fcbd086e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
/**
|
||
* Coverage tests for cross-file-impl.ts — `runCrossFileBindingPropagation`.
|
||
*
|
||
* Scenarios aimed at branches the integration tests exercise only on the
|
||
* happy path:
|
||
* 1. gapRatio < CROSS_FILE_SKIP_THRESHOLD → returns 0 without reprocess.
|
||
* 2. MAX_CROSS_FILE_REPROCESS cap → outer level loop breaks.
|
||
* 3. parse-supplied exportedTypeMap is NEVER mutated by crossFile (cross
|
||
* file builds its own local working copy for re-resolution writes).
|
||
* 4. namedImportMap.size === 0 → returns 0 immediately.
|
||
*
|
||
* Note: `processCalls`, `readFileContents`, and `isLanguageAvailable` are
|
||
* mocked so the test doesn't require tree-sitter or filesystem access.
|
||
* `buildImportedReturnTypes` and `buildImportedRawReturnTypes` are preserved
|
||
* via `importOriginal`. The graph-fallback enrichment that used to live here
|
||
* was moved into parse-impl's `runChunkedParseAndResolve` so the parse phase
|
||
* hands crossFile a fully-populated, truly read-only map.
|
||
*/
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
||
vi.mock('../../src/core/ingestion/call-processor.js', async (importOriginal) => {
|
||
const actual =
|
||
await importOriginal<typeof import('../../src/core/ingestion/call-processor.js')>();
|
||
return {
|
||
...actual,
|
||
processCalls: vi.fn(async () => {}),
|
||
};
|
||
});
|
||
|
||
vi.mock('../../src/core/ingestion/filesystem-walker.js', async (importOriginal) => {
|
||
const actual =
|
||
await importOriginal<typeof import('../../src/core/ingestion/filesystem-walker.js')>();
|
||
return {
|
||
...actual,
|
||
readFileContents: vi.fn(async (_repo: string, paths: string[]) => {
|
||
const m = new Map<string, string>();
|
||
for (const p of paths) m.set(p, '// stub');
|
||
return m;
|
||
}),
|
||
};
|
||
});
|
||
|
||
vi.mock('../../src/core/tree-sitter/parser-loader.js', async (importOriginal) => {
|
||
const actual =
|
||
await importOriginal<typeof import('../../src/core/tree-sitter/parser-loader.js')>();
|
||
return {
|
||
...actual,
|
||
isLanguageAvailable: vi.fn(() => true),
|
||
};
|
||
});
|
||
|
||
// Default to non-registry-primary so existing tests (which use .ts files) are
|
||
// not affected by the isRegistryPrimary guard added in cross-file-impl. Tests
|
||
// that verify the skip behavior can override this with mockReturnValue(true).
|
||
vi.mock('../../src/core/ingestion/registry-primary-flag.js', () => ({
|
||
isRegistryPrimary: vi.fn(() => false),
|
||
}));
|
||
|
||
import { runCrossFileBindingPropagation } from '../../src/core/ingestion/pipeline-phases/cross-file-impl.js';
|
||
import { processCalls } from '../../src/core/ingestion/call-processor.js';
|
||
import { isRegistryPrimary } from '../../src/core/ingestion/registry-primary-flag.js';
|
||
import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js';
|
||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||
import type { ExportedTypeMap } from '../../src/core/ingestion/call-processor.js';
|
||
|
||
const processCallsMock = vi.mocked(processCalls);
|
||
const isRegistryPrimaryMock = vi.mocked(isRegistryPrimary);
|
||
|
||
/**
|
||
* Index of the `compiledQueryCache` parameter in the `processCalls` signature.
|
||
* graph(0), files(1), astCache(2), ctx(3), onProgress?(4), exportedTypeMap?(5),
|
||
* importedBindingsMap?(6), importedReturnTypesMap?(7),
|
||
* importedRawReturnTypesMap?(8), heritageMap?(9), bindingAccumulator?(10),
|
||
* compiledQueryCache?(11).
|
||
*/
|
||
const COMPILED_QUERY_CACHE_ARG_INDEX = 11;
|
||
|
||
describe('runCrossFileBindingPropagation', () => {
|
||
beforeEach(() => {
|
||
processCallsMock.mockClear();
|
||
isRegistryPrimaryMock.mockReturnValue(false); // reset to non-primary before each test
|
||
});
|
||
|
||
it('returns 0 immediately when namedImportMap is empty', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
const ctx = createResolutionContext();
|
||
const exportedTypeMap: ExportedTypeMap = new Map([
|
||
['upstream.ts', new Map([['User', 'User']])],
|
||
]);
|
||
|
||
const result = await runCrossFileBindingPropagation(
|
||
graph,
|
||
ctx,
|
||
exportedTypeMap,
|
||
new Set(['upstream.ts']),
|
||
1,
|
||
'/repo',
|
||
Date.now(),
|
||
() => {},
|
||
);
|
||
|
||
expect(result).toBe(0);
|
||
expect(processCallsMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('returns 0 when gapRatio < CROSS_FILE_SKIP_THRESHOLD', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
const ctx = createResolutionContext();
|
||
|
||
// 100 files total; exportedTypeMap has an export but no downstream
|
||
// namedImportMap entry references a matching name → zero gaps.
|
||
const exportedTypeMap: ExportedTypeMap = new Map([
|
||
['upstream.ts', new Map([['User', 'User']])],
|
||
]);
|
||
|
||
// One downstream importer whose binding points at a symbol NOT in
|
||
// exportedTypeMap and NOT in ctx.model.symbols → no gap-filling seed
|
||
// available, so filesWithGaps stays at 0.
|
||
const downstreamBindings = new Map();
|
||
downstreamBindings.set('Missing', {
|
||
sourcePath: 'upstream.ts',
|
||
exportedName: 'Missing',
|
||
});
|
||
ctx.namedImportMap.set('downstream.ts', downstreamBindings);
|
||
|
||
const totalFiles = 100; // threshold = ceil(100 * 0.03) = 3
|
||
|
||
const result = await runCrossFileBindingPropagation(
|
||
graph,
|
||
ctx,
|
||
exportedTypeMap,
|
||
new Set(['downstream.ts', 'upstream.ts']),
|
||
totalFiles,
|
||
'/repo',
|
||
Date.now(),
|
||
() => {},
|
||
);
|
||
|
||
expect(result).toBe(0);
|
||
expect(processCallsMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('does not mutate the parse-supplied exportedTypeMap (works on a local copy)', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
const ctx = createResolutionContext();
|
||
|
||
// Seed a single upstream export and a downstream importer so the gap
|
||
// ratio crosses the skip threshold and processCalls (mocked) is invoked.
|
||
const parseExports: ExportedTypeMap = new Map([['upstream.ts', new Map([['User', 'User']])]]);
|
||
const parseExportsSnapshot = new Map(
|
||
Array.from(parseExports, ([k, v]) => [k, new Map(v)] as const),
|
||
);
|
||
|
||
const bindings = new Map();
|
||
bindings.set('User', { sourcePath: 'upstream.ts', exportedName: 'User' });
|
||
ctx.namedImportMap.set('downstream.ts', bindings);
|
||
ctx.importMap.set('upstream.ts', new Set());
|
||
ctx.importMap.set('downstream.ts', new Set(['upstream.ts']));
|
||
|
||
await runCrossFileBindingPropagation(
|
||
graph,
|
||
ctx,
|
||
parseExports,
|
||
new Set(['downstream.ts', 'upstream.ts']),
|
||
10,
|
||
'/repo',
|
||
Date.now(),
|
||
() => {},
|
||
);
|
||
|
||
// Outer map identity preserved, sizes unchanged, inner Maps unchanged —
|
||
// crossFile must operate on its own working copy.
|
||
expect(parseExports.size).toBe(parseExportsSnapshot.size);
|
||
for (const [k, v] of parseExportsSnapshot) {
|
||
const after = parseExports.get(k);
|
||
expect(after).toBeDefined();
|
||
expect(after!.size).toBe(v.size);
|
||
for (const [innerK, innerV] of v) {
|
||
expect(after!.get(innerK)).toBe(innerV);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('passes the same compiledQueryCache Map instance to every processCalls call', async () => {
|
||
// Verifies that the O(N)→O(1) query-cache fix is correctly wired: the
|
||
// `compiledQueryCache` created in runCrossFileBindingPropagation is shared
|
||
// across all processCalls invocations so each language's Parser.Query is
|
||
// compiled exactly once, not once per file.
|
||
const graph = createKnowledgeGraph();
|
||
const ctx = createResolutionContext();
|
||
|
||
const exportedTypeMap: ExportedTypeMap = new Map([
|
||
['upstream.ts', new Map([['User', 'User']])],
|
||
]);
|
||
ctx.importMap.set('upstream.ts', new Set());
|
||
|
||
const allPaths = ['upstream.ts'];
|
||
for (let i = 0; i < 3; i++) {
|
||
const file = `downstream${i}.ts`;
|
||
allPaths.push(file);
|
||
const bindings = new Map();
|
||
bindings.set('User', { sourcePath: 'upstream.ts', exportedName: 'User' });
|
||
ctx.namedImportMap.set(file, bindings);
|
||
ctx.importMap.set(file, new Set(['upstream.ts']));
|
||
}
|
||
|
||
await runCrossFileBindingPropagation(
|
||
graph,
|
||
ctx,
|
||
exportedTypeMap,
|
||
new Set(allPaths),
|
||
allPaths.length,
|
||
'/repo',
|
||
Date.now(),
|
||
() => {},
|
||
);
|
||
|
||
expect(processCallsMock).toHaveBeenCalledTimes(3);
|
||
|
||
// Argument index 11 is compiledQueryCache — see COMPILED_QUERY_CACHE_ARG_INDEX.
|
||
const caches = processCallsMock.mock.calls.map((call) => call[COMPILED_QUERY_CACHE_ARG_INDEX]);
|
||
// Every call must receive a non-null Map (not undefined).
|
||
for (const cache of caches) {
|
||
expect(cache).toBeDefined();
|
||
expect(cache).toBeInstanceOf(Map);
|
||
}
|
||
// All calls share the SAME instance — the whole point of the cache.
|
||
expect(caches[1]).toBe(caches[0]);
|
||
expect(caches[2]).toBe(caches[0]);
|
||
});
|
||
|
||
it('emits live onProgress events every 25 files with N/M format', async () => {
|
||
// Verifies that the frozen-progress-display fix is correctly wired:
|
||
// onProgress must be called multiple times from the processing loop,
|
||
// not just once at phase start, so large repos show real movement in
|
||
// the UI instead of a frozen percentage bar.
|
||
const graph = createKnowledgeGraph();
|
||
const ctx = createResolutionContext();
|
||
|
||
const exportedTypeMap: ExportedTypeMap = new Map([
|
||
['upstream.ts', new Map([['User', 'User']])],
|
||
]);
|
||
ctx.importMap.set('upstream.ts', new Set());
|
||
|
||
const allPaths = ['upstream.ts'];
|
||
for (let i = 0; i < 50; i++) {
|
||
const file = `downstream${i}.ts`;
|
||
allPaths.push(file);
|
||
const bindings = new Map();
|
||
bindings.set('User', { sourcePath: 'upstream.ts', exportedName: 'User' });
|
||
ctx.namedImportMap.set(file, bindings);
|
||
ctx.importMap.set(file, new Set(['upstream.ts']));
|
||
}
|
||
|
||
const progressMessages: string[] = [];
|
||
const onProgress = vi.fn((p: { phase: string; percent: number; message: string }) => {
|
||
progressMessages.push(p.message);
|
||
});
|
||
|
||
await runCrossFileBindingPropagation(
|
||
graph,
|
||
ctx,
|
||
exportedTypeMap,
|
||
new Set(allPaths),
|
||
allPaths.length,
|
||
'/repo',
|
||
Date.now(),
|
||
onProgress,
|
||
);
|
||
|
||
// 1 initial call at phase start + 2 loop calls (at 25 and 50 files).
|
||
expect(onProgress).toHaveBeenCalledTimes(3);
|
||
|
||
// Loop messages must carry the "N/M files" format so the UI is informative.
|
||
const loopMessages = progressMessages.filter((m) => m.match(/\(\d+\/\d+ files\)/));
|
||
expect(loopMessages).toHaveLength(2);
|
||
expect(loopMessages[0]).toContain('(25/50 files)');
|
||
expect(loopMessages[1]).toContain('(50/50 files)');
|
||
});
|
||
|
||
it('caps processing at MAX_CROSS_FILE_REPROCESS (2000)', async () => {
|
||
const graph = createKnowledgeGraph();
|
||
const ctx = createResolutionContext();
|
||
|
||
// Seed one upstream export reused by every downstream file.
|
||
const exportedTypeMap: ExportedTypeMap = new Map([
|
||
['upstream.ts', new Map([['User', 'User']])],
|
||
]);
|
||
|
||
const allPaths: string[] = ['upstream.ts'];
|
||
// Create 2100 downstream importers — each will qualify as a candidate
|
||
// (seeded.size === 1 because upstream.ts has the export we bind to).
|
||
// Populate ctx.importMap so topologicalLevelSort returns real levels.
|
||
ctx.importMap.set('upstream.ts', new Set());
|
||
for (let i = 0; i < 2100; i++) {
|
||
const file = `downstream${i}.ts`;
|
||
allPaths.push(file);
|
||
const bindings = new Map();
|
||
bindings.set('User', { sourcePath: 'upstream.ts', exportedName: 'User' });
|
||
ctx.namedImportMap.set(file, bindings);
|
||
ctx.importMap.set(file, new Set(['upstream.ts']));
|
||
}
|
||
|
||
const totalFiles = allPaths.length;
|
||
|
||
const result = await runCrossFileBindingPropagation(
|
||
graph,
|
||
ctx,
|
||
exportedTypeMap,
|
||
new Set(allPaths),
|
||
totalFiles,
|
||
'/repo',
|
||
Date.now(),
|
||
() => {},
|
||
);
|
||
|
||
// Hard cap is 2000. The function returns `crossFileResolved`, which
|
||
// equals MAX_CROSS_FILE_REPROCESS once the cap is hit.
|
||
expect(result).toBe(2000);
|
||
expect(processCallsMock).toHaveBeenCalledTimes(2000);
|
||
});
|
||
|
||
it('skips registry-primary language files without calling processCalls', async () => {
|
||
// Finding 3: on large TypeScript/C++ repos (registry-primary since v1.6.4+)
|
||
// cross-file-impl was calling processCalls 595× per candidate only for
|
||
// processCalls to immediately return (isRegistryPrimary guard inside).
|
||
// Now cross-file-impl filters them out BEFORE readFileContents so we avoid
|
||
// the I/O cost and map-building overhead entirely.
|
||
const graph = createKnowledgeGraph();
|
||
const ctx = createResolutionContext();
|
||
|
||
const exportedTypeMap: ExportedTypeMap = new Map([
|
||
['upstream.ts', new Map([['User', 'User']])],
|
||
]);
|
||
ctx.importMap.set('upstream.ts', new Set());
|
||
|
||
const allPaths = ['upstream.ts'];
|
||
for (let i = 0; i < 5; i++) {
|
||
const file = `downstream${i}.ts`;
|
||
allPaths.push(file);
|
||
const bindings = new Map();
|
||
bindings.set('User', { sourcePath: 'upstream.ts', exportedName: 'User' });
|
||
ctx.namedImportMap.set(file, bindings);
|
||
ctx.importMap.set(file, new Set(['upstream.ts']));
|
||
}
|
||
|
||
// Simulate all files being registry-primary (e.g. TypeScript on main branch).
|
||
isRegistryPrimaryMock.mockReturnValue(true);
|
||
|
||
const result = await runCrossFileBindingPropagation(
|
||
graph,
|
||
ctx,
|
||
exportedTypeMap,
|
||
new Set(allPaths),
|
||
allPaths.length,
|
||
'/repo',
|
||
Date.now(),
|
||
() => {},
|
||
);
|
||
|
||
// No files are candidates; no processCalls invocations.
|
||
expect(result).toBe(0);
|
||
expect(processCallsMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|