mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-09 22:33:39 +00:00
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:
parent
c19e76a4a3
commit
3111f4dcd3
2 changed files with 256 additions and 0 deletions
106
gitnexus/src/core/ingestion/binding-accumulator.ts
Normal file
106
gitnexus/src/core/ingestion/binding-accumulator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
150
gitnexus/test/unit/binding-accumulator.test.ts
Normal file
150
gitnexus/test/unit/binding-accumulator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue