From 4ad32915b927ec9fd2c8e4b9f5bcaf541dff9c16 Mon Sep 17 00:00:00 2001 From: abhigyantrumio Date: Tue, 12 May 2026 17:21:30 +0530 Subject: [PATCH] fix(call-processor): key fieldInfoCache by filePath:startIndex, not raw byte offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude's review of 255bdf6 caught a real collision in the FieldInfoCache I added: keying by `classNode.startIndex` alone is a per-file byte offset, so two files that both begin with a class at byte 0 — extremely common in Ruby / Python, where files frequently open with `class Foo`, `module Foo` — collide on the same cache entry. The second file's `getFieldInfo` then returns the first file's FieldInfo map, producing wrong `declaredType` / `visibility` / `isReadonly` on its properties. Same shape as the bug that already exists in parse-worker.ts:377 (also keyed by `classNode.startIndex` in a module-level map, persistent across files processed by the same worker). Fixing the symmetric pre-existing leak in parse-worker.ts is a separate, scoped follow-up — left out of this commit to keep the fix minimal and reviewable. Cache map and key are now both string-typed. Composite key `${context.filePath}:${classNode.startIndex}` keeps the within-file hit rate (one FieldExtractor.extract() per class regardless of how many `attr_accessor` lines it has) while eliminating cross-file aliasing. Verification on the patched HEAD: * `tsc --noEmit`: 0 errors * test/unit (call-processor, call-routing, field-extraction, ruby-self-call): 224 passing * test/integration (ruby, ruby-sequential-mixin, pipeline-graph-golden): 137 passing Co-Authored-By: Claude Opus 4.7 (1M context) --- gitnexus/src/core/ingestion/call-processor.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index ac5b6c9c3..b45478f7a 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -112,15 +112,19 @@ const NOOP_SYMBOL_TABLE: SymbolTableReader = { * stays scoped to a single `processCalls` invocation rather than leaking * across analyze runs (worker uses module-level caching because each worker * process is short-lived; the main thread is not). + * + * Cache key is `${filePath}:${classNode.startIndex}` — startIndex alone is a + * per-file byte offset, so almost every Ruby/Python file's leading class lands + * at byte 0 and would collide across files in the shared map. */ const getFieldInfo = ( classNode: SyntaxNode, provider: LanguageProvider, context: FieldExtractorContext, - cache: Map>, + cache: Map>, ): Map | undefined => { if (!provider.fieldExtractor) return undefined; - const cacheKey = classNode.startIndex; + const cacheKey = `${context.filePath}:${classNode.startIndex}`; const cached = cache.get(cacheKey); if (cached) return cached; const result = provider.fieldExtractor.extract(classNode, context); @@ -922,7 +926,7 @@ export const processCalls = async ( // worker-path block in parse-worker.ts (kind === 'properties') — any // divergence between the two paths breaks the `incremental ≡ --force` // invariant once a repo crosses the worker threshold between runs. - const fieldInfoCache = new Map>(); + const fieldInfoCache = new Map>(); for (const { file, language, provider, matches, typeEnv } of prepared) { const callRouter = provider.callRouter; if (!callRouter) continue;