mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-20 00:11:37 +00:00
269 lines
9.6 KiB
TypeScript
269 lines
9.6 KiB
TypeScript
/**
|
|
* Unit tests for `finalize-orchestrator` (RFC #909 Ring 2 PKG #921).
|
|
*
|
|
* Covers empty-input, single-file, multi-file-with-imports, and the
|
|
* `MutableSemanticModel.attachScopeIndexes` one-shot contract.
|
|
*
|
|
* Builds synthetic `ParsedFile` inputs directly — the orchestrator is
|
|
* below the extraction layer and independent of tree-sitter, so the
|
|
* tests don't need a real parser.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import type {
|
|
BindingRef,
|
|
ParsedFile,
|
|
ParsedImport,
|
|
Scope,
|
|
ScopeId,
|
|
SymbolDefinition,
|
|
} from 'gitnexus-shared';
|
|
import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js';
|
|
import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
|
|
import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js';
|
|
|
|
// ─── Fixture helpers ────────────────────────────────────────────────────────
|
|
|
|
const mkScope = (
|
|
id: ScopeId,
|
|
parent: ScopeId | null,
|
|
filePath: string,
|
|
bindings: Record<string, readonly BindingRef[]> = {},
|
|
): Scope => ({
|
|
id,
|
|
parent,
|
|
kind: parent === null ? 'Module' : 'Class',
|
|
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
|
|
filePath,
|
|
bindings: new Map(Object.entries(bindings)),
|
|
ownedDefs: [],
|
|
imports: [],
|
|
typeBindings: new Map(),
|
|
});
|
|
|
|
const mkFile = (filePath: string, overrides: Partial<ParsedFile> = {}): ParsedFile => ({
|
|
filePath,
|
|
moduleScope: `scope:${filePath}#module`,
|
|
scopes: overrides.scopes ?? [mkScope(`scope:${filePath}#module`, null, filePath)],
|
|
parsedImports: overrides.parsedImports ?? [],
|
|
localDefs: overrides.localDefs ?? [],
|
|
referenceSites: overrides.referenceSites ?? [],
|
|
});
|
|
|
|
const mkDef = (nodeId: string, filePath: string, qname: string): SymbolDefinition => ({
|
|
nodeId,
|
|
filePath,
|
|
type: 'Class',
|
|
qualifiedName: qname,
|
|
});
|
|
|
|
// ─── Empty input ───────────────────────────────────────────────────────────
|
|
|
|
describe('finalizeScopeModel: empty input', () => {
|
|
it('produces a valid but empty bundle for zero parsedFiles', () => {
|
|
const out = finalizeScopeModel([]);
|
|
expect(out.scopeTree.size).toBe(0);
|
|
expect(out.defs.size).toBe(0);
|
|
expect(out.qualifiedNames.size).toBe(0);
|
|
expect(out.moduleScopes.size).toBe(0);
|
|
expect(out.methodDispatch.mroByOwnerDefId.size).toBe(0);
|
|
expect(out.imports.size).toBe(0);
|
|
expect(out.bindings.size).toBe(0);
|
|
expect(out.referenceSites).toEqual([]);
|
|
expect(out.sccs).toEqual([]);
|
|
expect(out.stats.totalFiles).toBe(0);
|
|
expect(out.stats.totalEdges).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Single file ───────────────────────────────────────────────────────────
|
|
|
|
describe('finalizeScopeModel: single file', () => {
|
|
it('builds all per-file indexes from a single ParsedFile', () => {
|
|
const userClass = mkDef('def:User', 'models.ts', 'models.User');
|
|
const file = mkFile('models.ts', {
|
|
localDefs: [userClass],
|
|
});
|
|
const out = finalizeScopeModel([file]);
|
|
|
|
expect(out.scopeTree.size).toBe(1);
|
|
expect(out.defs.get('def:User')).toBe(userClass);
|
|
expect(out.qualifiedNames.get('models.User')).toEqual(['def:User']);
|
|
expect(out.moduleScopes.get('models.ts')).toBe(file.moduleScope);
|
|
expect(out.stats.totalFiles).toBe(1);
|
|
});
|
|
|
|
it('forwards per-file referenceSites into the aggregated list', () => {
|
|
const file = mkFile('a.ts', {
|
|
referenceSites: [
|
|
{
|
|
name: 'save',
|
|
atRange: { startLine: 5, startCol: 0, endLine: 5, endCol: 4 },
|
|
inScope: 'scope:a.ts#module',
|
|
kind: 'call',
|
|
},
|
|
],
|
|
});
|
|
const out = finalizeScopeModel([file]);
|
|
expect(out.referenceSites).toHaveLength(1);
|
|
expect(out.referenceSites[0]!.name).toBe('save');
|
|
});
|
|
|
|
it('aggregates large referenceSites without variadic push stack overflow', () => {
|
|
const referenceSites: ParsedFile['referenceSites'] = Array.from(
|
|
{ length: 200_000 },
|
|
(_, i) => ({
|
|
name: `ref${i}`,
|
|
atRange: { startLine: i + 1, startCol: 0, endLine: i + 1, endCol: 1 },
|
|
inScope: 'scope:large.ts#module',
|
|
kind: 'call' as const,
|
|
}),
|
|
);
|
|
|
|
const file = mkFile('large.ts', { referenceSites });
|
|
const out = finalizeScopeModel([file]);
|
|
|
|
expect(out.referenceSites).toHaveLength(referenceSites.length);
|
|
expect(out.referenceSites[0]).toBe(referenceSites[0]);
|
|
expect(out.referenceSites[referenceSites.length - 1]).toBe(
|
|
referenceSites[referenceSites.length - 1],
|
|
);
|
|
expect(Object.isFrozen(out.referenceSites)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── Multi-file with cross-file imports ────────────────────────────────────
|
|
|
|
describe('finalizeScopeModel: cross-file imports', () => {
|
|
it('links a named import when the caller provides resolveImportTarget', () => {
|
|
const userClass = mkDef('def:User', 'models.ts', 'models.User');
|
|
const modelsFile = mkFile('models.ts', { localDefs: [userClass] });
|
|
|
|
const importOfUser: ParsedImport = {
|
|
kind: 'named',
|
|
localName: 'User',
|
|
importedName: 'User',
|
|
targetRaw: 'models.ts',
|
|
};
|
|
const appFile = mkFile('app.ts', { parsedImports: [importOfUser] });
|
|
|
|
const out = finalizeScopeModel([appFile, modelsFile], {
|
|
hooks: {
|
|
resolveImportTarget: (targetRaw) => (targetRaw === 'models.ts' ? 'models.ts' : null),
|
|
},
|
|
});
|
|
|
|
const appImports = out.imports.get(appFile.moduleScope) ?? [];
|
|
expect(appImports).toHaveLength(1);
|
|
expect(appImports[0]!.linkStatus).toBeUndefined();
|
|
expect(appImports[0]!.targetFile).toBe('models.ts');
|
|
expect(appImports[0]!.targetDefId).toBe('def:User');
|
|
});
|
|
|
|
it('finalizes a provider-classified module symbol as a namespace edge', () => {
|
|
const modelsFile = mkFile('pkg/models.py', {
|
|
localDefs: [mkDef('def:User', 'pkg/models.py', 'pkg.models.User')],
|
|
});
|
|
const appFile = mkFile('pkg/app.py', {
|
|
parsedImports: [
|
|
{
|
|
kind: 'named',
|
|
localName: 'models',
|
|
importedName: 'models',
|
|
targetRaw: 'pkg',
|
|
},
|
|
],
|
|
});
|
|
|
|
const out = finalizeScopeModel([appFile, modelsFile], {
|
|
hooks: {
|
|
resolveImportTarget: () => 'pkg/models.py',
|
|
isNamespaceImport: () => true,
|
|
},
|
|
});
|
|
|
|
const edge = out.imports.get(appFile.moduleScope)?.[0];
|
|
expect(edge?.kind).toBe('namespace');
|
|
expect(edge?.linkStatus).toBeUndefined();
|
|
expect(edge?.targetModuleScope).toBe(modelsFile.moduleScope);
|
|
expect(out.stats.unresolvedEdges).toBe(0);
|
|
});
|
|
it('leaves imports unresolved when no resolveImportTarget is supplied (default hook)', () => {
|
|
// Default `resolveImportTarget: () => null` — every import ends up
|
|
// with `linkStatus: 'unresolved'`. This is the zero-provider case
|
|
// today; behavior is well-defined, not a crash.
|
|
const importOfUser: ParsedImport = {
|
|
kind: 'named',
|
|
localName: 'User',
|
|
importedName: 'User',
|
|
targetRaw: 'models.ts',
|
|
};
|
|
const appFile = mkFile('app.ts', { parsedImports: [importOfUser] });
|
|
|
|
const out = finalizeScopeModel([appFile]);
|
|
const appImports = out.imports.get(appFile.moduleScope) ?? [];
|
|
expect(appImports).toHaveLength(1);
|
|
expect(appImports[0]!.linkStatus).toBe('unresolved');
|
|
});
|
|
|
|
it('surfaces FinalizeStats for observability', () => {
|
|
const userClass = mkDef('def:User', 'models.ts', 'models.User');
|
|
const modelsFile = mkFile('models.ts', { localDefs: [userClass] });
|
|
const appFile = mkFile('app.ts', {
|
|
parsedImports: [
|
|
{
|
|
kind: 'named',
|
|
localName: 'User',
|
|
importedName: 'User',
|
|
targetRaw: 'models.ts',
|
|
},
|
|
],
|
|
});
|
|
const out = finalizeScopeModel([appFile, modelsFile], {
|
|
hooks: { resolveImportTarget: () => 'models.ts' },
|
|
});
|
|
expect(out.stats.totalFiles).toBe(2);
|
|
expect(out.stats.totalEdges).toBe(1);
|
|
expect(out.stats.linkedEdges).toBe(1);
|
|
expect(out.stats.unresolvedEdges).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── Integration with MutableSemanticModel ─────────────────────────────────
|
|
|
|
describe('MutableSemanticModel.attachScopeIndexes', () => {
|
|
it('starts as undefined and accepts a one-shot attach', () => {
|
|
const model = createSemanticModel();
|
|
expect(model.scopes).toBeUndefined();
|
|
|
|
const indexes = finalizeScopeModel([]);
|
|
model.attachScopeIndexes(indexes);
|
|
|
|
expect(model.scopes).toBe(indexes);
|
|
expect(model.scopes!.stats.totalFiles).toBe(0);
|
|
});
|
|
|
|
it('freezes the attached bundle (callers cannot mutate after attach)', () => {
|
|
const model = createSemanticModel();
|
|
const indexes: ScopeResolutionIndexes = finalizeScopeModel([]);
|
|
model.attachScopeIndexes(indexes);
|
|
|
|
expect(Object.isFrozen(model.scopes)).toBe(true);
|
|
});
|
|
|
|
it('throws on a second attach without clear()', () => {
|
|
const model = createSemanticModel();
|
|
model.attachScopeIndexes(finalizeScopeModel([]));
|
|
expect(() => model.attachScopeIndexes(finalizeScopeModel([]))).toThrowError(/already attached/);
|
|
});
|
|
|
|
it('clear() resets the bundle, enabling re-attach', () => {
|
|
const model = createSemanticModel();
|
|
model.attachScopeIndexes(finalizeScopeModel([]));
|
|
model.clear();
|
|
expect(model.scopes).toBeUndefined();
|
|
// Second attach now succeeds.
|
|
model.attachScopeIndexes(finalizeScopeModel([]));
|
|
expect(model.scopes).toBeDefined();
|
|
});
|
|
});
|