GitNexus/gitnexus/test/unit/scope-resolution/run-progress.test.ts
azizur100389 4f16bd8023
fix(impact): report scope extraction omissions (#3071)
* fix(impact): surface scope extraction omissions

* fix(impact): preserve complete index fixtures

* fix(impact): preserve scope completeness evidence

* test(analyze): model successful scope extraction in harnesses

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-29 08:38:20 +01:00

154 lines
5.2 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import type { ParsedFile, ScopeId, Scope } from 'gitnexus-shared';
import {
runScopeResolution,
type ScopeResolutionSubPhase,
} from '../../../src/core/ingestion/scope-resolution/pipeline/run.js';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
import type { ScopeResolver } from '../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js';
import type { LanguageProvider } from '../../../src/core/ingestion/language-provider.js';
const mkScope = (id: ScopeId, filePath: string): Scope => ({
id,
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 10, endCol: 0 },
filePath,
bindings: new Map(),
ownedDefs: [],
imports: [],
typeBindings: new Map(),
});
const mkFile = (filePath: string): ParsedFile => ({
filePath,
moduleScope: `scope:${filePath}#module`,
scopes: [mkScope(`scope:${filePath}#module`, filePath)],
parsedImports: [],
localDefs: [],
referenceSites: [],
});
const stubProvider = {
language: 'python' as const,
languageProvider: {} as ScopeResolver['languageProvider'],
importEdgeReason: 'test',
populateOwners: () => {},
resolveImportTarget: () => null,
mergeBindings: (existing: unknown) => existing,
buildMro: () => new Map(),
propagatesReturnTypesAcrossImports: false,
} as unknown as ScopeResolver;
const providerWithEmitter = (emitScopeCaptures: LanguageProvider['emitScopeCaptures']) =>
({
...stubProvider,
languageProvider: { emitScopeCaptures } as LanguageProvider,
}) as ScopeResolver;
describe('runScopeResolution onProgress', () => {
it('emits sub-phases in order for a 3-file input', () => {
const files = [
{ path: 'a.py', content: '' },
{ path: 'b.py', content: '' },
{ path: 'c.py', content: '' },
];
const preExtracted = new Map<string, ParsedFile>();
for (const f of files) preExtracted.set(f.path, mkFile(f.path));
const calls: { subPhase: ScopeResolutionSubPhase; current: number; total: number }[] = [];
const onProgress = (subPhase: ScopeResolutionSubPhase, current: number, total: number) => {
calls.push({ subPhase, current, total });
};
runScopeResolution(
{
graph: createKnowledgeGraph(),
model: createSemanticModel(),
files,
preExtractedParsedFiles: preExtracted,
onProgress,
},
stubProvider,
);
const subPhases = calls.map((c) => c.subPhase);
expect(subPhases).toContain('extracting');
expect(subPhases).toContain('analyzing types');
expect(subPhases).toContain('resolving references');
expect(subPhases).toContain('linking symbols');
const extractCalls = calls.filter((c) => c.subPhase === 'extracting');
expect(extractCalls.length).toBeGreaterThan(0);
expect(extractCalls[0].total).toBe(3);
expect(extractCalls[0].current).toBe(0);
expect(extractCalls[extractCalls.length - 1].current).toBe(3);
const analyzeIdx = subPhases.indexOf('analyzing types');
const resolveIdx = subPhases.indexOf('resolving references');
const linkIdx = subPhases.indexOf('linking symbols');
expect(analyzeIdx).toBeLessThan(resolveIdx);
expect(resolveIdx).toBeLessThan(linkIdx);
});
it('emits only extracting (0, 0) then returns early for 0-file input', () => {
const calls: { subPhase: ScopeResolutionSubPhase; current: number; total: number }[] = [];
const onProgress = (subPhase: ScopeResolutionSubPhase, current: number, total: number) => {
calls.push({ subPhase, current, total });
};
const stats = runScopeResolution(
{
graph: createKnowledgeGraph(),
model: createSemanticModel(),
files: [],
onProgress,
},
stubProvider,
);
expect(stats.filesProcessed).toBe(0);
expect(calls).toEqual([{ subPhase: 'extracting', current: 0, total: 0 }]);
});
it('counts empty migrated files as skipped without recording extraction failures', () => {
const stats = runScopeResolution(
{
graph: createKnowledgeGraph(),
model: createSemanticModel(),
files: [
{ path: 'empty.py', content: '' },
{ path: 'whitespace.py', content: ' \n\t' },
],
},
providerWithEmitter(() => {
throw new Error('empty files must short-circuit before capture');
}),
);
expect(stats.filesProcessed).toBe(0);
expect(stats.filesSkipped).toBe(2);
expect(stats.scopeExtractionFailedPaths).toEqual([]);
});
it('records a warned emitter failure as a final extraction omission', () => {
const warnings: string[] = [];
const stats = runScopeResolution(
{
graph: createKnowledgeGraph(),
model: createSemanticModel(),
files: [{ path: 'broken.py', content: 'value = 1' }],
onWarn: (warning) => warnings.push(warning),
},
providerWithEmitter(() => {
throw new Error('capture failed');
}),
);
expect(stats.filesProcessed).toBe(0);
expect(stats.filesSkipped).toBe(1);
expect(stats.scopeExtractionFailedPaths).toEqual(['broken.py']);
expect(warnings).toEqual([expect.stringContaining('capture failed')]);
});
});