From 3111f4dcd31472e26e9e6994f2ae1f1dcfbdeecb Mon Sep 17 00:00:00 2001 From: abhigyanpatwari Date: Thu, 9 Apr 2026 02:10:28 +0530 Subject: [PATCH] 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) --- .../src/core/ingestion/binding-accumulator.ts | 106 +++++++++++++ .../test/unit/binding-accumulator.test.ts | 150 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 gitnexus/src/core/ingestion/binding-accumulator.ts create mode 100644 gitnexus/test/unit/binding-accumulator.test.ts diff --git a/gitnexus/src/core/ingestion/binding-accumulator.ts b/gitnexus/src/core/ingestion/binding-accumulator.ts new file mode 100644 index 000000000..78a60acf7 --- /dev/null +++ b/gitnexus/src/core/ingestion/binding-accumulator.ts @@ -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(); + 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 { + 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; + } +} diff --git a/gitnexus/test/unit/binding-accumulator.test.ts b/gitnexus/test/unit/binding-accumulator.test.ts new file mode 100644 index 000000000..3dcb9d6fb --- /dev/null +++ b/gitnexus/test/unit/binding-accumulator.test.ts @@ -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); + }); + }); +});