feat(sm-14): add BindingAccumulator class with unit tests

Read-append-only accumulator that collects (filePath, scope, varName) -> typeName
bindings from TypeEnv outputs across all files. Supports finalization, file-scope
filtering, iteration, and memory estimation.

Part of #679.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
abhigyanpatwari 2026-04-09 02:10:28 +05:30
parent c19e76a4a3
commit 3111f4dcd3
2 changed files with 256 additions and 0 deletions

View file

@ -0,0 +1,106 @@
/**
* BindingAccumulator read-append-only accumulator that collects TypeEnv
* bindings across all files in the GitNexus analyzer pipeline.
*/
export interface BindingEntry {
readonly scope: string; // '' for file-level, 'funcName@startIndex' for function-local
readonly varName: string;
readonly typeName: string;
}
const ENTRY_OVERHEAD = 64; // bytes per entry (object overhead + property refs)
const MAP_ENTRY_OVERHEAD = 80; // bytes per file entry in the map
export class BindingAccumulator {
private readonly _map = new Map<string, BindingEntry[]>();
private _totalBindings = 0;
private _finalized = false;
/**
* Append bindings for a file. Safe to call multiple times for the same file.
* Throws if the accumulator has been finalized. Skips if entries is empty.
*/
appendFile(filePath: string, entries: BindingEntry[]): void {
if (this._finalized) {
throw new Error('BindingAccumulator is finalized — no further appends allowed');
}
if (entries.length === 0) {
return;
}
const existing = this._map.get(filePath);
if (existing !== undefined) {
for (const e of entries) {
existing.push(e);
}
} else {
this._map.set(filePath, entries.slice());
}
this._totalBindings += entries.length;
}
/** Lock the accumulator — no further appends. Idempotent. */
finalize(): void {
this._finalized = true;
}
/** Get all bindings for a file, or undefined if the file is unknown. */
getFile(filePath: string): readonly BindingEntry[] | undefined {
return this._map.get(filePath);
}
/**
* Get only scope='' (file-level) entries as [varName, typeName] tuples.
* Backward-compatible with the old workerTypeEnvBindings pattern.
* Returns an empty array for an unknown file.
*/
fileScopeEntries(filePath: string): [string, string][] {
const entries = this._map.get(filePath);
if (entries === undefined) {
return [];
}
const result: [string, string][] = [];
for (const e of entries) {
if (e.scope === '') {
result.push([e.varName, e.typeName]);
}
}
return result;
}
/** Iterate over all file paths in insertion order. */
files(): IterableIterator<string> {
return this._map.keys();
}
/** Number of distinct files with at least one binding. */
get fileCount(): number {
return this._map.size;
}
/** Total number of binding entries across all files. */
get totalBindings(): number {
return this._totalBindings;
}
/** Whether the accumulator has been finalized. */
get finalized(): boolean {
return this._finalized;
}
/**
* Rough memory estimate in bytes.
* Formula: sum of (ENTRY_OVERHEAD + char bytes of scope+varName+typeName) per entry
* + MAP_ENTRY_OVERHEAD + char bytes of filePath per file.
*/
estimateMemoryBytes(): number {
let total = 0;
for (const [filePath, entries] of this._map) {
total += MAP_ENTRY_OVERHEAD + filePath.length * 2;
for (const e of entries) {
total += ENTRY_OVERHEAD + (e.scope.length + e.varName.length + e.typeName.length) * 2;
}
}
return total;
}
}

View file

@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest';
import {
BindingAccumulator,
type BindingEntry,
} 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(/finalized/);
});
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);
});
});
});