diff --git a/gitnexus/src/core/ingestion/binding-accumulator.ts b/gitnexus/src/core/ingestion/binding-accumulator.ts index 3d0c3708f..ff3c389a3 100644 --- a/gitnexus/src/core/ingestion/binding-accumulator.ts +++ b/gitnexus/src/core/ingestion/binding-accumulator.ts @@ -174,6 +174,19 @@ export class BindingAccumulator { return this._finalized; } + /** + * Whether the accumulator has been disposed. Exposed for symmetry with + * `finalized` so debug tooling and future Phase 9 consumers can detect a + * disposed accumulator without inspecting empty state heuristically. + * + * Disposal and finalization are orthogonal: a disposed accumulator may or + * may not be finalized, and vice versa. See `dispose()` for the full + * lifecycle contract. + */ + get disposed(): boolean { + return this._disposed; + } + /** * Rough memory estimate in bytes (intentionally pessimistic). * Formula: sum of (ENTRY_OVERHEAD + char bytes of scope+varName+typeName) per entry diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 393c11c68..08ff92193 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -355,7 +355,14 @@ const processParsingSequential = async ( continue; } - // Build per-file type environment for FieldExtractor context (lightweight — skipped if no fieldExtractor) + // Build per-file type environment for FieldExtractor context (lightweight — skipped if no fieldExtractor). + // + // Note: this TypeEnv is intentionally NOT flushed into the BindingAccumulator. + // The accumulator feed happens later in `call-processor.ts` via its own + // `typeEnv.flush(accumulator)` call. Flushing here would double-count + // file-scope bindings and break the single-use invariant of `flush()`. + // See PR #743 (SM-14) and plan `docs/plans/2026-04-09-005-*.md` for the + // full accumulator lifecycle and flush-site ownership rules. const typeEnv = provider.fieldExtractor ? buildTypeEnv(tree, language, { enclosingFunctionFinder: provider.enclosingFunctionFinder, diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 9f3c0c8e8..3e9d6d2ca 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1083,6 +1083,16 @@ async function runChunkedParseAndResolve( ); } + // ── Finalize the accumulator before the read phase begins. All worker-path + // appends (line ~934) and sequential-path flushes (via `processCalls` → + // `typeEnv.flush()` earlier in this function) have completed by here, + // so the finalize-write-lock is correct at this seam. Making the + // lifecycle contract explicit — `append → finalize → consume → dispose` + // — was a Codex adversarial review follow-up (plan 2026-04-09-005). + // Previously `finalize()` was called much later in `runPipelineFromRepo` + // after the enrichment loop had already read the mutable accumulator. + bindingAccumulator.finalize(); + // ── Worker path quality enrichment: merge file-scope bindings into ExportedTypeMap ── if (bindingAccumulator.fileCount > 0) { let enriched = 0; @@ -1711,8 +1721,12 @@ export const runPipelineFromRepo = async ( processORMQueries(graph, allORMQueries, isDev); } - // Finalize — no more appends allowed - bindingAccumulator.finalize(); + // `bindingAccumulator.finalize()` was moved inside `runChunkedParseAndResolve` + // to immediately precede the enrichment loop — see the comment there for + // the PR #743 Codex adversarial review rationale. By the time execution + // reaches this point, the accumulator has already been finalized, consumed + // by the enrichment loop, and is ready for dispose() below after the dev + // telemetry log captures peak state. if (isDev && bindingAccumulator.totalBindings > 0) { const memKB = Math.round(bindingAccumulator.estimateMemoryBytes() / 1024); diff --git a/gitnexus/test/unit/binding-accumulator.test.ts b/gitnexus/test/unit/binding-accumulator.test.ts index 2df30aa82..8a4bf66e3 100644 --- a/gitnexus/test/unit/binding-accumulator.test.ts +++ b/gitnexus/test/unit/binding-accumulator.test.ts @@ -403,7 +403,13 @@ describe('BindingAccumulator', () => { fileMap = new Map(); exportedTypeMap.set(filePath, fileMap); } - fileMap.set(name, type); + // Tier 0 priority guard: if the SymbolTable already populated an + // entry for this name, don't overwrite it. Mirrors + // `pipeline.ts:1104-1108` — without this, a worker-path binding + // could clobber a higher-quality Tier 0 SymbolTable entry. + if (!fileMap.has(name)) { + fileMap.set(name, type); + } } } } @@ -497,6 +503,44 @@ describe('BindingAccumulator', () => { expect(() => runEnrichmentLoop(acc, nodesById, exportedTypeMap)).not.toThrow(); 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 loop must NOT + // overwrite it with a (lower-quality) worker-path binding. Guards + // against `pipeline.ts:1104-1108` regressing the priority check. + 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>([ + ['src/utils.ts', new Map([['helper', 'SymbolTableAuthoritativeType']])], + ]); + + const nodesById = new Map([ + [ + 'Function:src/utils.ts:helper', + { + id: 'Function:src/utils.ts:helper', + label: 'Function', + name: 'helper', + filePath: 'src/utils.ts', + isExported: true, + }, + ], + ]); + + runEnrichmentLoop(acc, nodesById, exportedTypeMap); + + // Tier 0 wins — the authoritative SymbolTable type survives. + expect(exportedTypeMap.get('src/utils.ts')?.get('helper')).toBe( + 'SymbolTableAuthoritativeType', + ); + }); }); // -------------------------------------------------------------------------