GitNexus/gitnexus/test/unit/binding-accumulator.test.ts
Copilot ab956f113c
feat(SM-15): Wire BindingAccumulator into processCallsFromExtracted for cross-file return type propagation (#763)
* Initial plan

* Initial setup - Phase 9 BindingAccumulator cross-file return type wiring

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7cee6490-090d-4714-8cb5-a704168ff47a

* feat(SM-15): wire BindingAccumulator into processCallsFromExtracted for Phase 9 cross-file return type propagation

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7cee6490-090d-4714-8cb5-a704168ff47a

* fix(SM-15): address all PR #763 review findings

Performance (R1)
- Changed _fileScopeByFile from Map<string, [string,string][]> to
  Map<string, Map<string,string>>. fileScopeGet(filePath, name) is
  now O(1) — replaces the O(n) linear scan + defensive-copy alloc
  that ran once per ConstructorBinding entry. fileScopeEntries()
  reconstructs tuples from Map.entries() for backward compat.
- Updated finalize() dev-mode invariant to compare deduplicated Map
  size rather than raw array length (Map.set deduplicates same-name).

Lifecycle (R2)
- Documented that Phase 9 intentionally reads pre-finalize because
  finalize() cannot move before both the worker consumer (line 984)
  AND the sequential-path writer (line 1061). Pre-finalize reads are
  safe because finalize() is write-lock-only with no side effects.
  Replaced the ambiguous "populated but not yet finalized" comment
  with the full lifecycle ordering explanation.

Sequential-path parity (R3)
- Wired bindingAccumulator into processCalls at line 797 (sequential
  path) so verifyConstructorBindings gets the Phase 9 fallback.
- Added bindingAccumulator parameter to processAssignmentsFromExtracted
  signature and wired it at the pipeline.ts call site (line 1026).
- Both paths now produce identical Phase 9 behavior for the same code.

Tracking comments (R4)
- Added "Overlapping mechanism (N of 3)" cross-references at:
  1. buildImportedReturnTypes (~line 109)
  2. collectExportedBindings (~line 168)
  3. Phase 9 fallback in verifyConstructorBindings (~line 563)
  Each links to the other two and notes future unification.

Language coverage (R5)
- Added 5 new Phase 9 integration test suites in cross-file-binding.test.ts:
  JavaScript, C++, C#, PHP, Ruby. Each uses the existing fixture
  directories and asserts getUser() → User → user.save() resolves.
  Total cross-file binding tests: 52 (was 37).

Quality asymmetry (R6)
- Added inline comment at the Phase 9 fallback noting worker-path
  entries are Tier 0/1 only and that binding accuracy is structurally
  lower for large repos where the worker path dominates.

Tests (+21 new)
- 6 fileScopeGet unit tests (happy path, unknown file/name, mixed
  scopes, post-dispose, duplicate varName last-write-wins)
- 15 integration tests across 5 new language suites

Verification
- tsc --noEmit clean
- 3147 unit tests pass (+6 new)
- 52 cross-file binding integration tests pass (+15 new)
- 1766 resolver integration tests pass
- Zero regressions

Plan: docs/plans/2026-04-10-001-fix-sm15-review-findings-plan.md
Review: https://github.com/abhigyanpatwari/GitNexus/pull/763#issuecomment-4220354242

* fix(SM-15): gate accumulator fallback on resolution tier and fix sequential file-order dependency

Two Codex adversarial reviews identified medium-severity bugs in the Phase 9
BindingAccumulator fallback:

1. Local-first violation: the fallback fired regardless of whether ctx.resolve()
   found same-file candidates, letting an imported callee shadow a local one
   and produce false CALLS edges. Fixed by gating on tiered.tier !== 'same-file'
   and callableDefs.length <= 1.

2. Sequential file-order dependency: processCalls flushed and verified per-file,
   so consumer files processed before their providers missed accumulator bindings.
   Fixed by splitting into a flush pre-pass (all files) then a resolution loop,
   mirroring the worker path's "all appends before any reads" pattern.

Also adds 11 consumer-before-provider integration test fixtures (one per
supported language) and 4 unit tests for tier gating edge cases.

* refactor(SM-15): eliminate duplicated prepare logic in processCalls two-pass split

Replace the duplicated pre-pass + legacy-path code (parse → query → heritage
→ TypeEnv → exports) with a single preparation loop followed by a resolution
loop. Both paths now share the same preparation code — the only conditional
is the accumulator flush.

Side benefit: globalParentMap is now fully populated before any resolution
runs, improving cross-file isSubclassOf accuracy regardless of file order.

Net -118 lines (226 removed, 108 added).

* fix(SM-15): address PR #763 third-pass review findings

1. Update stale dispose() JSDoc — remove forward-reference to Phase 9
   wiring that is now complete; document actual consumers.

2. Add processAssignmentsFromExtracted Phase 9 unit test — verifies the
   accumulator fallback produces ACCESSES write edges when the SymbolTable
   has no returnType for the callee.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-10 10:29:31 +01:00

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('works before finalize() — accumulator behaves like a fresh one after dispose', () => {
const acc = new BindingAccumulator();
acc.appendFile('src/a.ts', [{ scope: '', varName: 'x', typeName: 'X' }]);
acc.dispose();
// Not finalized, so appends still work post-dispose.
expect(() =>
acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'Y' }]),
).not.toThrow();
expect(acc.fileCount).toBe(1);
expect(acc.totalBindings).toBe(1);
expect(acc.getFile('src/b.ts')).toHaveLength(1);
expect(acc.getFile('src/a.ts')).toBeUndefined();
});
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, so appends throw even post-dispose.
expect(() =>
acc.appendFile('src/b.ts', [{ scope: '', varName: 'y', typeName: 'Y' }]),
).toThrow(/finalize/);
// But reads 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');
});
});
});