mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* Initial plan * refactor: move language-specific container node logic into LanguageProvider - Add resolveEnclosingOwner hook to LanguageProviderConfig - Add staticOwnerTypes to MethodExtractionConfig - Implement Ruby resolveEnclosingOwner (singleton_class → class/module) - Replace hardcoded STATIC_OWNER_TYPES with config.staticOwnerTypes - Move Ruby static types to rubyMethodConfig - Move Kotlin static types to kotlinMethodConfig - Remove Ruby singleton_class branch from findEnclosingClassInfo - Collapse seqFindEnclosingClassNode/seqFindRawEnclosingContainerNode into single provider-aware seqFindEnclosingOwnerNode - Update worker path to pass provider.resolveEnclosingOwner Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bc9f9d4d-f749-4872-9ff2-17fc86e08787 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: add regression tests for config-driven staticOwnerTypes and resolveEnclosingOwner hook Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bc9f9d4d-f749-4872-9ff2-17fc86e08787 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refactor: implement DAG-based pipeline architecture with phase extraction Restructure the ingestion pipeline from a ~1800-line monolithic orchestrator into a DAG (Directed Acyclic Graph) of named phases with explicit dependencies. New files under pipeline-phases/: - types.ts: PipelinePhase, PipelineContext, PhaseResult contracts - runner.ts: DAG runner with topological sort validation - scan.ts, structure.ts, markdown.ts, cobol.ts: early phases - parse.ts + parse-impl.ts: chunked parse + resolve (the core) - routes.ts, tools.ts, orm.ts: post-parse enrichment phases - cross-file.ts + cross-file-impl.ts: cross-file binding propagation - mro.ts, communities.ts, processes.ts: graph analysis phases - index.ts: barrel export pipeline.ts reduced from ~1960 lines to ~184 lines: - DAG phase array declaration - runPipelineFromRepo as thin orchestrator - topologicalLevelSort retained for backward compat Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136bf9c3-2f4f-449b-9fff-001332c8371c * test: add DAG runner unit tests, update ARCHITECTURE.md with phase DAG docs Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136bf9c3-2f4f-449b-9fff-001332c8371c * fix: address code review - pass resolutionContext through parse output, fix worker URL path Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136bf9c3-2f4f-449b-9fff-001332c8371c * fix: declare transitive parse dependency explicitly in mro/communities/processes phases Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136bf9c3-2f4f-449b-9fff-001332c8371c * refactor: improve pipeline-phases clean code and folder structure - Extract synthesizeWildcardImportBindings to wildcard-synthesis.ts - Extract extractORMQueriesInline to orm-extraction.ts - Create shared constants.ts for AST_CACHE_CAP - Fix inline type import in orm.ts (use proper top-level import) - Add comprehensive JSDoc to getPhaseOutput explaining type safety - Move isDev to module level in cross-file.ts (consistency) - Improve module-level documentation across files - Organize barrel exports in index.ts with section comments Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2bd6d4aa-6271-4009-8dd2-332ea8ec73ab Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * address review feedback: fix circular dep, allFetchCalls mutation, progress bugs, remove DAG naming, extract isDev, fix _item naming, fix O(n²) line calc Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6cf53c9b-d55d-4c6f-bf3d-7bfb82d512b6 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * improve JSDoc on lineNumberAtOffset binary search Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6cf53c9b-d55d-4c6f-bf3d-7bfb82d512b6 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * address review: filter deps in runner, move totalFiles to ctx, fix cycle JSDoc, centralize isDev, remove DAG naming Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b388424f-b939-4a94-97de-3855f9465564 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix doc consistency in graph-sort.ts module-level and function-level JSDoc Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b388424f-b939-4a94-97de-3855f9465564 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix(pipeline): wrap phase errors with phase name and emit terminal error progress event Restores phase diagnostics at CLI/MCP boundary. runPipeline now wraps phase.execute() in try/catch and rethrows with 'Phase <name> failed: ...' preserving the original via { cause }. Also emits a terminal { phase: 'error' } progress event so subscribers see the failure before the rejection propagates. Handler errors during error reporting are swallowed to keep the original cause authoritative. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U1) * fix(pipeline): move bindingAccumulator dispose into crossFile try/finally; make single-use crossFile.execute() now wraps its body in try/finally so the accumulator is released on both the happy path and when runCrossFileBindingPropagation throws. Dev-mode telemetry stays inside the try block before dispose (all three counters return 0 after dispose clears internal maps). BindingAccumulator becomes single-use: appendFile after dispose now throws 'BindingAccumulator: use after dispose' instead of silently re-animating via the old _disposed auto-clear. Docs updated; the only production construction site (parse-impl) always creates a fresh instance per run, so no caller relied on the re-use contract. Residual risk documented in crossFile module JSDoc: a future phase inserted between parse and crossFile that throws would still leak the accumulator. Any such phase must manage accumulator lifetime explicitly. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U2) * docs(pipeline): explain why importCtx teardown is safe before crossFile Investigation (plan U3) confirms: `importCtx` (ImportResolutionContext) is a scratch workspace with no downstream consumer after parse. `resolutionContext` (returned to crossFile) is a distinct object that owns importMap / namedImportMap / packageMap / moduleAliasMap / model, and never closes over importCtx. cross-file-impl consumes only that ctx via processCalls. The two confusingly-similar "context" names were the root of the adversarial reviewer's concern — comment locks in the invariant so the next reader sees it. No behavioral change. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U3) * refactor(pipeline): remove ctx.totalFiles side-channel; promote to ParseOutput totalFiles was a hidden mutable field on PipelineContext written by parse and read by mro/communities/processes — five reviewers flagged this as a violation of the immutable-context invariant. Removed from PipelineContext, which is now fully readonly, and made the implicit temporal dep explicit: mro/communities/processes now declare 'parse' as a dep and read totalFiles via getPhaseOutput<ParseOutput>(...). No behavior change. Topo-sort unchanged because parse was already a transitive dep through crossFile. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U4) * feat(method-extractor): runtime staticOwnerTypes guard at factory chokepoint createMethodExtractor now rejects MethodExtractionConfigs that list companion_object / singleton_class / object_declaration in typeDeclarationNodes but omit the matching entry from staticOwnerTypes. Fails loudly at provider construction time instead of producing silent isStatic=false on the 50000th file analyzed. Opt-out convention preserved: an explicit `new Set()` (empty Set) signals intentional exclusion and passes the guard (memory obs #30588). All 13 existing language configs pass the guard; the new negative test fails without it. Test-first. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U5) * fix(pipeline): wrap sequential-fallback in try/finally so cleanup survives throws The sequential-fallback block in runChunkedParseAndResolve now runs inside a try/finally that guarantees astCache.clear(), accumulator finalize, and enrichExportedTypeMap execute even if readFileContents or processCalls throws mid-fallback. Cleanup failures are caught inside the finally so they can't mask the original error. Accumulator disposal ownership remains with crossFile (U2) — U6 only adds astCache cleanup and preserves finalize ordering on the error path. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U6) * test(pipeline): direct unit coverage for wildcard-synthesis and cross-file-impl Both modules previously had zero direct unit coverage — branches were exercised only through integration tests' happy paths. wildcard-synthesis.test.ts covers: Go graph-IMPORTS fallback, Python moduleAliasMap build, MAX_SYNTHETIC_BINDINGS_PER_FILE cap, dedup against existing namedImportMap entries, and empty-exportedSymbols early return. cross-file-impl.test.ts covers: gapRatio below threshold no-op, MAX_CROSS_FILE_REPROCESS cap, graph-only exportedTypeMap fallback, and empty namedImportMap short-circuit. Tests assert current behavior — any future regression flips them. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U7) * test(pipeline): golden-file graph-parity regression guard on mini-repo fixture Pins the current post-P1/P2 graph output (57 symbols, 92 relationships, 4 processes, deterministic edge digest) so future silent refactors cannot drift behavior unnoticed. If any count changes or any edge rewires, the test fails with a readable diff listing what changed and a copy-pasteable UPDATE_GOLDEN=1 regen command. Edge digest keyed by symbolic (label, name, filePath) triples rather than raw generateId output — stays meaningful across id-encoding refactors while still catching real semantic rewiring. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U8) * fix(pipeline): minimal cycle reporting + resolveEnclosingOwner loop safeguards U9: runner cycle detection now reports only the SCC members via DFS back-edge trace ('Cycle detected: A -> B -> C -> A') rather than everything with inDegree > 0 (which mixed cycle members with blocked dependents). Also emits the 'error' progress event for graph- validation failures, symmetric with U1's runtime-error path. U16: findEnclosingClassInfo now defends against language-provider hooks that return non-container nodes — visitedContainers Set breaks repeat-visit loops, MAX_ENCLOSING_WALK_ITERATIONS is belt-and-braces. Documented the hook contract invariant so future provider authors know the walk-continues-upward expectation. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U9, U16) * refactor(pipeline): type hygiene, dead code cleanup, shared allPathSet, graph-sort naming Bundles plan units U10, U11, U12, U14, U15: U10 — Type hygiene: readonly ParseOutput arrays (allExtractedRoutes, allDecoratorRoutes, allToolDefs, allORMQueries, allPaths); removed redundant 'as string[] | undefined' cast in routes.ts and 'as URL' in parse-impl.ts; WorkerPool is now 'import type'. Readonly contract propagated into processORMQueries (only iterates). U11 — Dead code & shims: deleted constants.ts shim (AST_CACHE_CAP inlined into its sole real consumer cross-file-impl.ts; isDev consumers now import directly from ../utils/env.js). Removed internal utility re-exports from pipeline-phases/index.ts (no external consumers). Removed topologicalLevelSort re-export from pipeline.ts; updated topological-sort.test.ts to import from the canonical utils/graph-sort.js. Stripped 'Phase 3+4:' stale JSDoc from parse-impl.ts. U12 — Perf: StructureOutput now carries allPathSet (ReadonlySet<string>) built once; cobol, markdown, and cross-file-impl consume the shared set instead of allocating their own. Parse forwards it via ParseOutput.allPathSet; processCobol/processMarkdown widened to ReadonlySet<string>. U14 — graph-sort.ts: renamed local 'inDegree' to 'pendingImportsPerFile' with expanded JSDoc explaining the reverse- graph Kahn's formulation and warning future maintainers not to 'correct' it to standard in-degree semantics. Added self-edge test. U15 — Unconditional worker-fallback logging: removed isDev guard on the worker-pool-creation-failure console.warn so operators can diagnose perf degradations in production. No behavior change. U8 golden-file test confirms pipeline output is byte-identical. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U10, U11, U12, U14, U15) * docs: fix ARCHITECTURE.md table integrity; bump AGENTS.md/CLAUDE.md to 1.3.0 U13 — documentation fixes: ARCHITECTURE.md: the prior insertion of the 'Pipeline Phase DAG' section orphaned 7 rows from the 'Where to change what' header. Moved those 7 rows back up under their header so the table reads contiguously; DAG section now follows the completed table. AGENTS.md + CLAUDE.md: bumped version 1.2.0 -> 1.3.0, updated Last reviewed to 2026-04-13, added matching Changelog row documenting the GitNexus index stats refresh after the DAG refactor. Stat bumps (symbols/relationships/execution flows) that were sitting uncommitted in the working tree are now landed under a proper changelog entry per each file's own documented schema. Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U13) * refactor(pipeline): drop spurious parse deps, true-readonly ParseOutput.exportedTypeMap, skip redundant wildcard synth - mro/communities/processes: switch redundant `parse` dep to `structure` — totalFiles originates in structure, so depending on parse for it was a spurious data dep that obscured the real DAG. - ParseOutput.exportedTypeMap: typed as truly ReadonlyMap<...,ReadonlyMap>>; graph→exports enrichment moved into parse-impl so the snapshot is fully populated at parse return. crossFile builds its own local mutable working copy for per-file re-resolution writes — no cast at the boundary. - parse-impl: hasSynthesized flag guards the unconditional final synthesizeWildcardImportBindings call when per-chunk/fallback synthesis already ran (graph-global + idempotent across chunks). - cross-file-impl: documented the intentional `phase: 'parsing'` progress label so telemetry bucketing stays consistent with the parse phase. - cross-file-impl test: replaced the now-moved fallback-enrichment assertion with a stronger one — crossFile must not mutate the parse-supplied map. Addresses PR #809 review pass 5 carry-overs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
701 lines
28 KiB
TypeScript
701 lines
28 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
BindingAccumulator,
|
|
enrichExportedTypeMap,
|
|
type BindingEntry,
|
|
type EnrichmentGraphLookup,
|
|
type EnrichmentGraphNode,
|
|
} from '../../src/core/ingestion/binding-accumulator.js';
|
|
|
|
describe('BindingAccumulator', () => {
|
|
describe('append + read', () => {
|
|
it('returns entries for a single file', () => {
|
|
const acc = new BindingAccumulator();
|
|
const entries: BindingEntry[] = [
|
|
{ scope: '', varName: 'x', typeName: 'number' },
|
|
{ scope: 'foo@10', varName: 'y', typeName: 'string' },
|
|
];
|
|
acc.appendFile('src/a.ts', entries);
|
|
expect(acc.getFile('src/a.ts')).toEqual(entries);
|
|
});
|
|
|
|
it('returns entries for multiple files', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [{ scope: '', varName: 'a', typeName: 'number' }]);
|
|
acc.appendFile('src/b.ts', [{ scope: '', varName: 'b', typeName: 'string' }]);
|
|
expect(acc.getFile('src/a.ts')).toHaveLength(1);
|
|
expect(acc.getFile('src/b.ts')).toHaveLength(1);
|
|
expect(acc.fileCount).toBe(2);
|
|
});
|
|
|
|
it('returns undefined for unknown file', () => {
|
|
const acc = new BindingAccumulator();
|
|
expect(acc.getFile('nonexistent.ts')).toBeUndefined();
|
|
});
|
|
|
|
it('accumulates entries across multiple calls for the same file', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'number' }]);
|
|
acc.appendFile('src/a.ts', [{ scope: 'fn@5', varName: 'y', typeName: 'boolean' }]);
|
|
const entries = acc.getFile('src/a.ts');
|
|
expect(entries).toHaveLength(2);
|
|
expect(entries![0].varName).toBe('x');
|
|
expect(entries![1].varName).toBe('y');
|
|
});
|
|
|
|
it('skips append when entries is empty', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', []);
|
|
expect(acc.getFile('src/a.ts')).toBeUndefined();
|
|
expect(acc.fileCount).toBe(0);
|
|
});
|
|
|
|
it('tracks totalBindings correctly', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [
|
|
{ scope: '', varName: 'x', typeName: 'number' },
|
|
{ scope: '', varName: 'y', typeName: 'string' },
|
|
]);
|
|
acc.appendFile('src/b.ts', [{ scope: '', varName: 'z', typeName: 'boolean' }]);
|
|
expect(acc.totalBindings).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('finalize + immutability', () => {
|
|
it('finalize prevents further appends', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'number' }]);
|
|
acc.finalize();
|
|
expect(() =>
|
|
acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'string' }]),
|
|
).toThrow(/finalize/);
|
|
});
|
|
|
|
it('finalized getter returns true after finalize', () => {
|
|
const acc = new BindingAccumulator();
|
|
expect(acc.finalized).toBe(false);
|
|
acc.finalize();
|
|
expect(acc.finalized).toBe(true);
|
|
});
|
|
|
|
it('getFile works after finalize', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'number' }]);
|
|
acc.finalize();
|
|
expect(acc.getFile('src/a.ts')).toHaveLength(1);
|
|
});
|
|
|
|
it('finalize is idempotent', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.finalize();
|
|
expect(() => acc.finalize()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('fileScopeEntries', () => {
|
|
it('returns only scope="" entries as [varName, typeName] tuples', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [
|
|
{ scope: '', varName: 'x', typeName: 'number' },
|
|
{ scope: 'foo@10', varName: 'y', typeName: 'string' },
|
|
{ scope: '', varName: 'z', typeName: 'boolean' },
|
|
]);
|
|
const tuples = acc.fileScopeEntries('src/a.ts');
|
|
expect(tuples).toEqual([
|
|
['x', 'number'],
|
|
['z', 'boolean'],
|
|
]);
|
|
});
|
|
|
|
it('returns empty array for unknown file', () => {
|
|
const acc = new BindingAccumulator();
|
|
expect(acc.fileScopeEntries('nonexistent.ts')).toEqual([]);
|
|
});
|
|
|
|
it('returns empty array when file has no file-scope entries', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [{ scope: 'fn@1', varName: 'x', typeName: 'number' }]);
|
|
expect(acc.fileScopeEntries('src/a.ts')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('iteration', () => {
|
|
it('files() yields all file paths', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'number' }]);
|
|
acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'string' }]);
|
|
acc.appendFile('src/c.ts', [{ scope: '', varName: 'z', typeName: 'boolean' }]);
|
|
const paths = [...acc.files()];
|
|
expect(paths.sort()).toEqual(['src/a.ts', 'src/b.ts', 'src/c.ts']);
|
|
});
|
|
|
|
it('files() returns empty iterator when no files added', () => {
|
|
const acc = new BindingAccumulator();
|
|
expect([...acc.files()]).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('memory estimate', () => {
|
|
it('returns a reasonable estimate for 1000 files x 2 entries', () => {
|
|
const acc = new BindingAccumulator();
|
|
for (let i = 0; i < 1000; i++) {
|
|
acc.appendFile(`src/file${i}.ts`, [
|
|
{ scope: '', varName: `var${i}a`, typeName: 'string' },
|
|
{ scope: `fn${i}@0`, varName: `var${i}b`, typeName: 'number' },
|
|
]);
|
|
}
|
|
const bytes = acc.estimateMemoryBytes();
|
|
// Should be between 50KB and 2MB
|
|
expect(bytes).toBeGreaterThan(50 * 1024);
|
|
expect(bytes).toBeLessThan(2 * 1024 * 1024);
|
|
});
|
|
});
|
|
|
|
describe('pipeline integration (simulated)', () => {
|
|
it('deserializes allScopeBindings from worker into accumulator', () => {
|
|
const acc = new BindingAccumulator();
|
|
|
|
// Simulated worker output:
|
|
// After narrowing the worker IPC payload to file-scope only, the
|
|
// emitted tuple shape is [varName, typeName]. Function-scope entries
|
|
// are stripped at the parse-worker boundary; the sequential path's
|
|
// flush() still writes all scopes via its own code path.
|
|
const workerBindings = [
|
|
{
|
|
filePath: 'src/service.ts',
|
|
bindings: [['config', 'Config'] as [string, string]],
|
|
},
|
|
{
|
|
filePath: 'src/utils.ts',
|
|
bindings: [['logger', 'Logger'] as [string, string]],
|
|
},
|
|
];
|
|
|
|
// Pipeline deserialization logic (mirrors pipeline.ts adapter):
|
|
// two-element tuples → BindingEntry with hard-coded scope: ''.
|
|
for (const { filePath, bindings } of workerBindings) {
|
|
const entries: BindingEntry[] = bindings.map(([varName, typeName]) => ({
|
|
scope: '',
|
|
varName,
|
|
typeName,
|
|
}));
|
|
acc.appendFile(filePath, entries);
|
|
}
|
|
acc.finalize();
|
|
|
|
expect(acc.fileCount).toBe(2);
|
|
expect(acc.totalBindings).toBe(2);
|
|
|
|
// fileScopeEntries — what the ExportedTypeMap enrichment loop uses.
|
|
expect(acc.fileScopeEntries('src/service.ts')).toEqual([['config', 'Config']]);
|
|
expect(acc.fileScopeEntries('src/utils.ts')).toEqual([['logger', 'Logger']]);
|
|
|
|
// Every entry produced by the worker path has scope === '' after the
|
|
// IPC narrowing — locks the contract in place.
|
|
const serviceEntries = acc.getFile('src/service.ts');
|
|
expect(serviceEntries).toHaveLength(1);
|
|
expect(serviceEntries![0]).toEqual({
|
|
scope: '',
|
|
varName: 'config',
|
|
typeName: 'Config',
|
|
});
|
|
});
|
|
|
|
it('worker IPC payload contains ONLY file-scope entries (narrowing guard)', () => {
|
|
// Function-scope bindings were being
|
|
// serialized over worker IPC with no consumer, costing ~4.9 MB. The
|
|
// worker now uses typeEnv.fileScope() instead of typeEnv.allScopes(),
|
|
// so `handleRequest@15 → db: Database` never crosses the IPC boundary.
|
|
//
|
|
// This test simulates a TypeEnvironment that HAD both file-scope and
|
|
// function-scope bindings (as would be produced by a realistic file),
|
|
// then asserts the worker IPC payload contains only the file-scope
|
|
// ones. If a future change accidentally re-broadens the worker loop
|
|
// to `allScopes()`, this assertion fires.
|
|
const simulatedFileScope = new Map<string, string>([
|
|
['config', 'Config'],
|
|
['db', 'Database'],
|
|
]);
|
|
// Function-scope entries that must NOT appear in the worker payload.
|
|
const simulatedFunctionScope = new Map<string, string>([
|
|
['localRequest', 'Request'],
|
|
['localUser', 'User'],
|
|
]);
|
|
|
|
// Mirror the parse-worker loop (post-narrowing shape):
|
|
// const fileScope = typeEnv.fileScope();
|
|
// for (const [varName, typeName] of fileScope) {
|
|
// scopeBindings.push([varName, typeName]);
|
|
// }
|
|
const workerPayload: [string, string][] = [];
|
|
for (const [varName, typeName] of simulatedFileScope) {
|
|
workerPayload.push([varName, typeName]);
|
|
}
|
|
|
|
// Verify: the simulated function-scope variables are never pushed.
|
|
const allVarNames = workerPayload.map(([v]) => v);
|
|
expect(allVarNames).toEqual(['config', 'db']);
|
|
expect(allVarNames).not.toContain('localRequest');
|
|
expect(allVarNames).not.toContain('localUser');
|
|
|
|
// Sanity: simulatedFunctionScope exists so the test is not trivially
|
|
// vacuous — it documents what the old allScopes() path would have
|
|
// emitted and what the new fileScope() path deliberately excludes.
|
|
expect(simulatedFunctionScope.size).toBe(2);
|
|
|
|
// Round-trip through the accumulator with the pipeline adapter shape.
|
|
const acc = new BindingAccumulator();
|
|
const entries: BindingEntry[] = workerPayload.map(([varName, typeName]) => ({
|
|
scope: '',
|
|
varName,
|
|
typeName,
|
|
}));
|
|
acc.appendFile('src/service.ts', entries);
|
|
acc.finalize();
|
|
|
|
const stored = acc.getFile('src/service.ts');
|
|
expect(stored).toHaveLength(2);
|
|
// All accumulator entries from the worker path have scope === ''.
|
|
for (const entry of stored!) {
|
|
expect(entry.scope).toBe('');
|
|
}
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// fileScopeEntries() must be O(n_file_scope),
|
|
// not O(n_total). Storage is split into _allByFile + _fileScopeByFile so
|
|
// reads skip function-scope entries entirely.
|
|
// -------------------------------------------------------------------------
|
|
|
|
describe('storage split (fast-path fileScopeEntries)', () => {
|
|
it('mixed file-scope and function-scope input: fileScopeEntries ignores function-scope', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [
|
|
{ scope: '', varName: 'file1', typeName: 'T1' },
|
|
{ scope: 'fn@10', varName: 'local1', typeName: 'L1' },
|
|
{ scope: '', varName: 'file2', typeName: 'T2' },
|
|
{ scope: 'fn@20', varName: 'local2', typeName: 'L2' },
|
|
{ scope: 'fn@30', varName: 'local3', typeName: 'L3' },
|
|
]);
|
|
|
|
// fileScopeEntries returns exactly the two file-scope entries,
|
|
// preserving insertion order.
|
|
expect(acc.fileScopeEntries('src/a.ts')).toEqual([
|
|
['file1', 'T1'],
|
|
['file2', 'T2'],
|
|
]);
|
|
|
|
// getFile still returns all 5 entries (mixed scopes preserved).
|
|
expect(acc.getFile('src/a.ts')).toHaveLength(5);
|
|
});
|
|
|
|
it('only-function-scope file: fileScopeEntries returns [] but files() still lists it', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/only-fn.ts', [
|
|
{ scope: 'fn@5', varName: 'x', typeName: 'X' },
|
|
{ scope: 'fn@10', varName: 'y', typeName: 'Y' },
|
|
]);
|
|
|
|
expect(acc.fileScopeEntries('src/only-fn.ts')).toEqual([]);
|
|
expect(acc.getFile('src/only-fn.ts')).toHaveLength(2);
|
|
expect([...acc.files()]).toContain('src/only-fn.ts');
|
|
expect(acc.fileCount).toBe(1);
|
|
});
|
|
|
|
it('multiple appends accumulate in both maps consistently', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [
|
|
{ scope: '', varName: 'x', typeName: 'X' },
|
|
{ scope: 'fn@1', varName: 'y', typeName: 'Y' },
|
|
]);
|
|
acc.appendFile('src/a.ts', [
|
|
{ scope: '', varName: 'z', typeName: 'Z' },
|
|
{ scope: 'fn@2', varName: 'w', typeName: 'W' },
|
|
]);
|
|
|
|
expect(acc.fileScopeEntries('src/a.ts')).toEqual([
|
|
['x', 'X'],
|
|
['z', 'Z'],
|
|
]);
|
|
expect(acc.getFile('src/a.ts')).toHaveLength(4);
|
|
expect(acc.totalBindings).toBe(4);
|
|
});
|
|
|
|
it('performance guard: fileScopeEntries does not walk function-scope entries', () => {
|
|
const acc = new BindingAccumulator();
|
|
// 1 file-scope entry + 1000 function-scope entries.
|
|
const entries: BindingEntry[] = [{ scope: '', varName: 'shared', typeName: 'Shared' }];
|
|
for (let i = 0; i < 1000; i++) {
|
|
entries.push({
|
|
scope: `fn${i}@${i * 10}`,
|
|
varName: `local${i}`,
|
|
typeName: 'Local',
|
|
});
|
|
}
|
|
acc.appendFile('src/big.ts', entries);
|
|
|
|
// fileScopeEntries returns the single file-scope pair without
|
|
// iterating the 1000 function-scope entries — this is the O(1) cache
|
|
// lookup behavior guaranteed by the storage split.
|
|
const result = acc.fileScopeEntries('src/big.ts');
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toEqual(['shared', 'Shared']);
|
|
// Sanity: getFile still sees everything.
|
|
expect(acc.getFile('src/big.ts')).toHaveLength(1001);
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Integration coverage for the sequential
|
|
// path → accumulator → ExportedTypeMap enrichment loop at pipeline.ts
|
|
// lines 1082-1110. This test mirrors that loop inline with a minimal
|
|
// KnowledgeGraph-shaped mock, locking in the node-ID format contract
|
|
// (Function:{filePath}:{name}, Variable:..., Const:...). If the ID format
|
|
// drifts for any language, this test fires.
|
|
// -------------------------------------------------------------------------
|
|
|
|
describe('ExportedTypeMap enrichment (integration)', () => {
|
|
/**
|
|
* Minimal graph backing for `enrichExportedTypeMap`. Matches the
|
|
* `EnrichmentGraphNode` shape from binding-accumulator.ts — which in
|
|
* turn matches the real `GraphNode.properties.isExported` access path
|
|
* used by the production `KnowledgeGraph`. Using this shape (rather
|
|
* than a flat `isExported` field) means a refactor of the graph's
|
|
* `properties` layout will fail this test, not silently pass.
|
|
*/
|
|
function makeGraphLookup(
|
|
nodes: Array<{ id: string; isExported: boolean }>,
|
|
): EnrichmentGraphLookup {
|
|
const byId = new Map<string, EnrichmentGraphNode>();
|
|
for (const n of nodes) {
|
|
byId.set(n.id, { id: n.id, properties: { isExported: n.isExported } });
|
|
}
|
|
return { getNode: (id) => byId.get(id) };
|
|
}
|
|
|
|
it('enriches exportedTypeMap with an exported Function node', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/utils.ts', [
|
|
{ scope: '', varName: 'helper', typeName: '(arg: string) => User' },
|
|
]);
|
|
acc.finalize();
|
|
|
|
const graph = makeGraphLookup([{ id: 'Function:src/utils.ts:helper', isExported: true }]);
|
|
const exportedTypeMap = new Map<string, Map<string, string>>();
|
|
|
|
const enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap);
|
|
|
|
expect(enriched).toBe(1);
|
|
expect(exportedTypeMap.get('src/utils.ts')?.get('helper')).toBe('(arg: string) => User');
|
|
});
|
|
|
|
it('skips non-exported Variable nodes', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/app.ts', [{ scope: '', varName: 'dbClient', typeName: 'Database' }]);
|
|
acc.finalize();
|
|
|
|
const graph = makeGraphLookup([{ id: 'Variable:src/app.ts:dbClient', isExported: false }]);
|
|
const exportedTypeMap = new Map<string, Map<string, string>>();
|
|
|
|
const enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap);
|
|
|
|
expect(enriched).toBe(0);
|
|
expect(exportedTypeMap.has('src/app.ts')).toBe(false);
|
|
});
|
|
|
|
it('enriches exportedTypeMap with an exported Const node', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/config.ts', [{ scope: '', varName: 'API_URL', typeName: 'string' }]);
|
|
acc.finalize();
|
|
|
|
const graph = makeGraphLookup([{ id: 'Const:src/config.ts:API_URL', isExported: true }]);
|
|
const exportedTypeMap = new Map<string, Map<string, string>>();
|
|
|
|
const enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap);
|
|
|
|
expect(enriched).toBe(1);
|
|
expect(exportedTypeMap.get('src/config.ts')?.get('API_URL')).toBe('string');
|
|
});
|
|
|
|
it('silently skips accumulator entries with no matching graph node', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/missing.ts', [{ scope: '', varName: 'ghost', typeName: 'Ghost' }]);
|
|
acc.finalize();
|
|
|
|
// Empty graph — no nodes at any of the candidate IDs.
|
|
const graph = makeGraphLookup([]);
|
|
const exportedTypeMap = new Map<string, Map<string, string>>();
|
|
|
|
// Must not throw; enrichment's `continue` path fires for every
|
|
// unmatched entry.
|
|
let enriched = -1;
|
|
expect(() => {
|
|
enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap);
|
|
}).not.toThrow();
|
|
expect(enriched).toBe(0);
|
|
expect(exportedTypeMap.has('src/missing.ts')).toBe(false);
|
|
});
|
|
|
|
it('does not overwrite existing SymbolTable entry (Tier 0 priority)', () => {
|
|
// When the SymbolTable's tier-0 extraction pass has already populated
|
|
// an entry for a name, the accumulator enrichment must NOT overwrite
|
|
// it with a (lower-quality) worker-path binding.
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/utils.ts', [
|
|
{ scope: '', varName: 'helper', typeName: 'WorkerInferredType' },
|
|
]);
|
|
acc.finalize();
|
|
|
|
// Pre-populate exportedTypeMap to simulate what SymbolTable would
|
|
// have written in the tier-0 pass.
|
|
const exportedTypeMap = new Map<string, Map<string, string>>([
|
|
['src/utils.ts', new Map([['helper', 'SymbolTableAuthoritativeType']])],
|
|
]);
|
|
|
|
const graph = makeGraphLookup([{ id: 'Function:src/utils.ts:helper', isExported: true }]);
|
|
|
|
const enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap);
|
|
|
|
// Tier 0 wins — the authoritative SymbolTable type survives.
|
|
expect(enriched).toBe(0);
|
|
expect(exportedTypeMap.get('src/utils.ts')?.get('helper')).toBe(
|
|
'SymbolTableAuthoritativeType',
|
|
);
|
|
});
|
|
|
|
it('handles nodes whose properties object is undefined (production shape)', () => {
|
|
// Regression guard: the real KnowledgeGraph stores isExported under
|
|
// `node.properties.isExported` and properties may be undefined for
|
|
// some node kinds. The enrichment guard `!node?.properties?.isExported`
|
|
// must treat an undefined properties object as non-exported.
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/edge.ts', [{ scope: '', varName: 'helper', typeName: 'Helper' }]);
|
|
acc.finalize();
|
|
|
|
const graph: EnrichmentGraphLookup = {
|
|
getNode: (id) =>
|
|
id === 'Function:src/edge.ts:helper'
|
|
? ({ id, properties: undefined } satisfies EnrichmentGraphNode)
|
|
: undefined,
|
|
};
|
|
const exportedTypeMap = new Map<string, Map<string, string>>();
|
|
|
|
const enriched = enrichExportedTypeMap(acc, graph, exportedTypeMap);
|
|
|
|
expect(enriched).toBe(0);
|
|
expect(exportedTypeMap.has('src/edge.ts')).toBe(false);
|
|
});
|
|
|
|
it('returns 0 and leaves exportedTypeMap untouched when accumulator is empty', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.finalize();
|
|
|
|
const graph = makeGraphLookup([{ id: 'Function:src/utils.ts:helper', isExported: true }]);
|
|
const existingMap = new Map<string, Map<string, string>>([
|
|
['src/existing.ts', new Map([['keep', 'Type']])],
|
|
]);
|
|
|
|
const enriched = enrichExportedTypeMap(acc, graph, existingMap);
|
|
|
|
expect(enriched).toBe(0);
|
|
expect(existingMap.size).toBe(1);
|
|
expect(existingMap.get('src/existing.ts')?.get('keep')).toBe('Type');
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// BindingAccumulator.dispose() releases the accumulator's heap footprint
|
|
// after the enrichment loop has consumed everything it needs. Post-dispose
|
|
// reads return empty/undefined without throwing, matching "never-appended"
|
|
// state. Idempotent and orthogonal to finalize().
|
|
// -------------------------------------------------------------------------
|
|
|
|
describe('fileScopeGet (O(1) point lookup)', () => {
|
|
it('returns the typeName for a known file-scope binding', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/api.ts', [
|
|
{ scope: '', varName: 'getUser', typeName: 'User' },
|
|
{ scope: '', varName: 'getPost', typeName: 'Post' },
|
|
]);
|
|
expect(acc.fileScopeGet('src/api.ts', 'getUser')).toBe('User');
|
|
expect(acc.fileScopeGet('src/api.ts', 'getPost')).toBe('Post');
|
|
});
|
|
|
|
it('returns undefined for an unknown file', () => {
|
|
const acc = new BindingAccumulator();
|
|
expect(acc.fileScopeGet('nonexistent.ts', 'x')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for an unknown name in a known file', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]);
|
|
expect(acc.fileScopeGet('src/api.ts', 'missing')).toBeUndefined();
|
|
});
|
|
|
|
it('ignores function-scope entries', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/service.ts', [
|
|
{ scope: 'handler@10', varName: 'localDb', typeName: 'Database' },
|
|
{ scope: '', varName: 'config', typeName: 'Config' },
|
|
]);
|
|
// Only file-scope entries are indexed by fileScopeGet.
|
|
expect(acc.fileScopeGet('src/service.ts', 'config')).toBe('Config');
|
|
expect(acc.fileScopeGet('src/service.ts', 'localDb')).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined after dispose', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]);
|
|
acc.dispose();
|
|
expect(acc.fileScopeGet('src/api.ts', 'getUser')).toBeUndefined();
|
|
});
|
|
|
|
it('last-write-wins for duplicate varNames in the same file', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'OldType' }]);
|
|
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'NewType' }]);
|
|
expect(acc.fileScopeGet('src/api.ts', 'getUser')).toBe('NewType');
|
|
});
|
|
});
|
|
|
|
describe('dispose', () => {
|
|
it('empties all read methods after dispose', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [
|
|
{ scope: '', varName: 'x', typeName: 'X' },
|
|
{ scope: 'fn@10', varName: 'y', typeName: 'Y' },
|
|
]);
|
|
acc.appendFile('src/b.ts', [{ scope: '', varName: 'z', typeName: 'Z' }]);
|
|
|
|
// Sanity: pre-dispose state is populated.
|
|
expect(acc.fileCount).toBe(2);
|
|
expect(acc.totalBindings).toBe(3);
|
|
|
|
acc.dispose();
|
|
|
|
// Post-dispose state: all read methods return empty/undefined.
|
|
expect(acc.fileCount).toBe(0);
|
|
expect(acc.totalBindings).toBe(0);
|
|
expect([...acc.files()]).toEqual([]);
|
|
expect(acc.getFile('src/a.ts')).toBeUndefined();
|
|
expect(acc.getFile('src/b.ts')).toBeUndefined();
|
|
expect(acc.fileScopeEntries('src/a.ts')).toEqual([]);
|
|
expect(acc.fileScopeEntries('src/b.ts')).toEqual([]);
|
|
});
|
|
|
|
it('is idempotent — calling twice is a no-op', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]);
|
|
acc.dispose();
|
|
expect(() => acc.dispose()).not.toThrow();
|
|
expect(acc.fileCount).toBe(0);
|
|
expect(acc.totalBindings).toBe(0);
|
|
});
|
|
|
|
it('appendFile after dispose throws with the expected message', () => {
|
|
// Single-use lifecycle: dispose is terminal. Any subsequent append is
|
|
// a programming error (the consumer is treating a released accumulator
|
|
// as if it were live). Convert the silent failure into a loud one.
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]);
|
|
acc.dispose();
|
|
expect(() =>
|
|
acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'Y' }]),
|
|
).toThrow('BindingAccumulator: use after dispose');
|
|
});
|
|
|
|
it('works after finalize() — append still throws, reads return empty', () => {
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]);
|
|
acc.finalize();
|
|
acc.dispose();
|
|
// Finalized takes precedence — the finalize check runs first in
|
|
// appendFile, so the error is the "finalize" one, not the
|
|
// "use after dispose" one.
|
|
expect(() =>
|
|
acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'Y' }]),
|
|
).toThrow(/finalize/);
|
|
// Reads still return empty.
|
|
expect(acc.fileCount).toBe(0);
|
|
expect(acc.totalBindings).toBe(0);
|
|
expect(acc.getFile('src/a.ts')).toBeUndefined();
|
|
});
|
|
|
|
it('estimateMemoryBytes drops to zero after dispose', () => {
|
|
const acc = new BindingAccumulator();
|
|
// Populate a large batch to give the estimate a non-trivial baseline.
|
|
for (let i = 0; i < 100; i++) {
|
|
acc.appendFile(`src/file${i}.ts`, [
|
|
{ scope: '', varName: `var${i}a`, typeName: 'string' },
|
|
{ scope: '', varName: `var${i}b`, typeName: 'number' },
|
|
]);
|
|
}
|
|
const preDisposeBytes = acc.estimateMemoryBytes();
|
|
expect(preDisposeBytes).toBeGreaterThan(0);
|
|
|
|
acc.dispose();
|
|
|
|
// After dispose, the iteration over `_allByFile` in estimateMemoryBytes
|
|
// has zero files to walk, so the returned value is exactly 0.
|
|
expect(acc.estimateMemoryBytes()).toBe(0);
|
|
});
|
|
|
|
it('disposed getter reflects dispose state', () => {
|
|
// Locks in the `get disposed()` contract for API symmetry with
|
|
// `get finalized()`. Without this test, a trivial wrong impl like
|
|
// `get disposed() { return this._finalized; }` passes everything.
|
|
const acc = new BindingAccumulator();
|
|
expect(acc.disposed).toBe(false);
|
|
acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]);
|
|
expect(acc.disposed).toBe(false);
|
|
acc.dispose();
|
|
expect(acc.disposed).toBe(true);
|
|
acc.dispose(); // idempotent
|
|
expect(acc.disposed).toBe(true);
|
|
});
|
|
|
|
it('dispose then finalize: appends throw, state is consistent', () => {
|
|
// Orthogonality check: dispose() and finalize() are independent
|
|
// lifecycle dimensions. dispose → finalize → appendFile should throw
|
|
// the finalized error (because finalize was called), and the
|
|
// accumulator should report both flags as true.
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]);
|
|
acc.dispose();
|
|
acc.finalize();
|
|
expect(acc.disposed).toBe(true);
|
|
expect(acc.finalized).toBe(true);
|
|
expect(() =>
|
|
acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'Y' }]),
|
|
).toThrow(/finalize/);
|
|
});
|
|
|
|
it('fileScopeEntries returns a defensive copy — mutation does not corrupt state', () => {
|
|
// Encapsulation guard: the cached internal array must not be exposed
|
|
// by reference. Mutating the returned array should not affect
|
|
// subsequent reads.
|
|
const acc = new BindingAccumulator();
|
|
acc.appendFile('src/a.ts', [
|
|
{ scope: '', varName: 'x', typeName: 'X' },
|
|
{ scope: '', varName: 'y', typeName: 'Y' },
|
|
]);
|
|
|
|
const firstRead = acc.fileScopeEntries('src/a.ts');
|
|
expect(firstRead).toHaveLength(2);
|
|
|
|
// Try to corrupt internal state via the returned array. The
|
|
// `readonly` return type is compile-time only; cast to mutable at
|
|
// runtime to simulate a consumer that bypasses TypeScript.
|
|
const mutableView = firstRead as unknown as [string, string][];
|
|
mutableView.push(['corrupted', 'Corrupt']);
|
|
mutableView.length = 0;
|
|
|
|
// Subsequent reads are unaffected by the mutation attempt.
|
|
const secondRead = acc.fileScopeEntries('src/a.ts');
|
|
expect(secondRead).toHaveLength(2);
|
|
expect(secondRead[0][0]).toBe('x');
|
|
expect(secondRead[1][0]).toBe('y');
|
|
});
|
|
});
|
|
});
|