diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index db28376b5..8ac9645be 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -572,6 +572,14 @@ const verifyConstructorBindings = ( // namedImportMap tells us which source file exported the callee so we // can look up its file-scope binding via the O(1) fileScopeGet method. // + // Tier gating: only fall back to the accumulator when resolution is + // unambiguously import-scoped or global. When tiered.tier is 'same-file', + // the local definition is authoritative even without a return type + // annotation — using the accumulator here would let an imported callee + // with the same name shadow the local one, producing false CALLS edges. + // When multiple callable candidates exist, the accumulator would pick + // arbitrarily — skip to avoid fabricated edges. + // // Quality note: worker-path accumulator entries are Tier 0/1 only // (annotation-declared + same-file constructor inference) — see the // BindingAccumulator class JSDoc. For large repos where the worker @@ -587,7 +595,9 @@ const verifyConstructorBindings = ( // — TypeEnv + graph isExported flag // 3. This fallback — namedImportMap + BindingAccumulator // A future cleanup should merge these into a single resolution pass. - if (!typeName && bindingAccumulator) { + const shouldFallback = + tiered?.tier !== 'same-file' && (!callableDefs || callableDefs.length <= 1); + if (!typeName && bindingAccumulator && shouldFallback) { const namedImports = ctx.namedImportMap.get(filePath); const importBinding = namedImports?.get(calleeName); if (importBinding) { @@ -690,117 +700,262 @@ export const processCalls = async ( const logSkipped = isVerboseIngestionEnabled(); const skippedByLang = logSkipped ? new Map() : null; - for (let i = 0; i < files.length; i++) { - const file = files[i]; + // ── Two-pass split for accumulator ordering correctness ── + // When bindingAccumulator is present, the Phase 9 fallback in + // verifyConstructorBindings reads from the accumulator. If we flush and + // verify in the same per-file iteration, consumer files processed before + // their provider files won't see the provider's bindings — causing silent + // misses. Fix: pre-pass flushes ALL files' TypeEnv bindings into the + // accumulator before the main loop runs verifyConstructorBindings. + // This mirrors the worker path where all appendFile calls complete before + // processCallsFromExtracted runs. For the sequential path (<15 files), + // buffering per-file state is negligible. + // + // When bindingAccumulator is absent (legacy/Phase 14 path), the existing + // single-pass behavior is preserved — no pre-pass, no buffering. + interface PrePassState { + file: { path: string; content: string }; + language: SupportedLanguages; + provider: ReturnType; + tree: ReturnType; + matches: ReturnType; + parentMap: ReadonlyMap; + typeEnv: ReturnType; + } + const prePassStates: PrePassState[] | undefined = bindingAccumulator ? [] : undefined; + + if (bindingAccumulator) { + // ── Pre-pass: build TypeEnv, flush to accumulator, buffer state ── + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (i % 20 === 0) await yieldToEventLoop(); + + const language = getLanguageFromFilename(file.path); + if (!language) continue; + if (!isLanguageAvailable(language)) { + if (skippedByLang) { + skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1); + } + continue; + } + + const provider = getProvider(language); + const queryStr = provider.treeSitterQueries; + if (!queryStr) continue; + + await loadLanguage(language, file.path); + + let tree = astCache.get(file.path); + if (!tree) { + try { + tree = parser.parse(file.content, undefined, { + bufferSize: getTreeSitterBufferSize(file.content.length), + }); + } catch (parseError) { + continue; + } + astCache.set(file.path, tree); + } + + let matches; + try { + const lang = parser.getLanguage(); + const query = new Parser.Query(lang, queryStr); + matches = query.matches(tree.rootNode); + } catch (queryError) { + console.warn(`Query error for ${file.path}:`, queryError); + continue; + } + + // Extract heritage for parentMap (same as main loop) + const fileParentMap = new Map(); + for (const match of matches) { + const captureMap: Record = {}; + match.captures.forEach((c) => (captureMap[c.name] = c.node)); + if (captureMap['heritage.class'] && captureMap['heritage.extends']) { + const className: string = captureMap['heritage.class'].text; + const parentName: string = captureMap['heritage.extends'].text; + const extendsNode = captureMap['heritage.extends']; + const fieldDecl = extendsNode.parent; + if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) + continue; + let parents = fileParentMap.get(className); + if (!parents) { + parents = []; + fileParentMap.set(className, parents); + } + if (!parents.includes(parentName)) parents.push(parentName); + } + } + const parentMap: ReadonlyMap = fileParentMap; + for (const [cls, parents] of fileParentMap) { + let global = globalParentMap.get(cls); + let seen = globalParentSeen.get(cls); + if (!global) { + global = []; + globalParentMap.set(cls, global); + } + if (!seen) { + seen = new Set(); + globalParentSeen.set(cls, seen); + } + for (const p of parents) { + if (!seen.has(p)) { + seen.add(p); + global.push(p); + } + } + } + + const importedBindings = importedBindingsMap?.get(file.path); + const importedReturnTypes = importedReturnTypesMap?.get(file.path); + const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path); + const typeEnv = buildTypeEnv(tree, language, { + symbolTable: ctx.symbols, + parentMap, + importedBindings, + importedReturnTypes, + importedRawReturnTypes, + enclosingFunctionFinder: provider?.enclosingFunctionFinder, + extractFunctionName: provider?.methodExtractor?.extractFunctionName, + }); + if (typeEnv && exportedTypeMap) { + const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph); + if (fileExports) exportedTypeMap.set(file.path, fileExports); + } + typeEnv.flush(file.path, bindingAccumulator); + + prePassStates!.push({ file, language, provider, tree, matches, parentMap, typeEnv }); + } + } + + // ── Main loop: resolve calls (and optionally build TypeEnv if no pre-pass) ── + const loopSource = prePassStates ?? files; + for (let i = 0; i < loopSource.length; i++) { + const isPrePassed = prePassStates !== undefined; + const entry = loopSource[i]; + const file = isPrePassed + ? (entry as PrePassState).file + : (entry as { path: string; content: string }); + enclosingFnExtractCache.clear(); onProgress?.(i + 1, files.length); if (i % 20 === 0) await yieldToEventLoop(); - const language = getLanguageFromFilename(file.path); - if (!language) continue; - if (!isLanguageAvailable(language)) { - if (skippedByLang) { - skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1); - } - continue; - } + let language: SupportedLanguages; + let provider: ReturnType; + let tree: ReturnType; + let matches: ReturnType; + let typeEnv: ReturnType; + let parentMap: ReadonlyMap; - const provider = getProvider(language); - const queryStr = provider.treeSitterQueries; - if (!queryStr) continue; - - await loadLanguage(language, file.path); - - let tree = astCache.get(file.path); - if (!tree) { - try { - tree = parser.parse(file.content, undefined, { - bufferSize: getTreeSitterBufferSize(file.content.length), - }); - } catch (parseError) { + if (isPrePassed) { + // Reuse state from pre-pass — TypeEnv and flush already done + const state = entry as PrePassState; + language = state.language; + provider = state.provider; + tree = state.tree; + matches = state.matches; + typeEnv = state.typeEnv; + parentMap = state.parentMap; + } else { + // Legacy single-pass path (no bindingAccumulator) + const lang = getLanguageFromFilename(file.path); + if (!lang) continue; + if (!isLanguageAvailable(lang)) { + if (skippedByLang) { + skippedByLang.set(lang, (skippedByLang.get(lang) ?? 0) + 1); + } continue; } - astCache.set(file.path, tree); - } + language = lang; - let query; - let matches; - try { - const language = parser.getLanguage(); - query = new Parser.Query(language, queryStr); - matches = query.matches(tree.rootNode); - } catch (queryError) { - console.warn(`Query error for ${file.path}:`, queryError); - continue; - } + provider = getProvider(language); + const queryStr = provider.treeSitterQueries; + if (!queryStr) continue; - // Pre-pass: extract heritage from query matches to build parentMap for buildTypeEnv. - // Heritage-processor runs in PARALLEL, so graph edges don't exist when buildTypeEnv runs. - const fileParentMap = new Map(); - for (const match of matches) { - const captureMap: Record = {}; - match.captures.forEach((c) => (captureMap[c.name] = c.node)); - if (captureMap['heritage.class'] && captureMap['heritage.extends']) { - const className: string = captureMap['heritage.class'].text; - const parentName: string = captureMap['heritage.extends'].text; - const extendsNode = captureMap['heritage.extends']; - const fieldDecl = extendsNode.parent; - if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) + await loadLanguage(language, file.path); + + tree = astCache.get(file.path)!; + if (!tree) { + try { + tree = parser.parse(file.content, undefined, { + bufferSize: getTreeSitterBufferSize(file.content.length), + }); + } catch (parseError) { continue; - let parents = fileParentMap.get(className); - if (!parents) { - parents = []; - fileParentMap.set(className, parents); } - if (!parents.includes(parentName)) parents.push(parentName); + astCache.set(file.path, tree); } - } - const parentMap: ReadonlyMap = fileParentMap; - // Merge per-file heritage into globalParentMap for cross-file isSubclassOf lookups. - // Uses a parallel Set (globalParentSeen) for O(1) deduplication instead of O(n) includes(). - for (const [cls, parents] of fileParentMap) { - let global = globalParentMap.get(cls); - let seen = globalParentSeen.get(cls); - if (!global) { - global = []; - globalParentMap.set(cls, global); + + try { + const parseLang = parser.getLanguage(); + const query = new Parser.Query(parseLang, queryStr); + matches = query.matches(tree.rootNode); + } catch (queryError) { + console.warn(`Query error for ${file.path}:`, queryError); + continue; } - if (!seen) { - seen = new Set(); - globalParentSeen.set(cls, seen); - } - for (const p of parents) { - if (!seen.has(p)) { - seen.add(p); - global.push(p); + + // Heritage extraction (same as pre-pass, only runs in legacy path) + const fileParentMap = new Map(); + for (const match of matches) { + const captureMap: Record = {}; + match.captures.forEach((c) => (captureMap[c.name] = c.node)); + if (captureMap['heritage.class'] && captureMap['heritage.extends']) { + const className: string = captureMap['heritage.class'].text; + const parentName: string = captureMap['heritage.extends'].text; + const extendsNode = captureMap['heritage.extends']; + const fieldDecl = extendsNode.parent; + if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) + continue; + let parents = fileParentMap.get(className); + if (!parents) { + parents = []; + fileParentMap.set(className, parents); + } + if (!parents.includes(parentName)) parents.push(parentName); } } + for (const [cls, parents] of fileParentMap) { + let global = globalParentMap.get(cls); + let seen = globalParentSeen.get(cls); + if (!global) { + global = []; + globalParentMap.set(cls, global); + } + if (!seen) { + seen = new Set(); + globalParentSeen.set(cls, seen); + } + for (const p of parents) { + if (!seen.has(p)) { + seen.add(p); + global.push(p); + } + } + } + + parentMap = fileParentMap; + + const importedBindings = importedBindingsMap?.get(file.path); + const importedReturnTypes = importedReturnTypesMap?.get(file.path); + const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path); + typeEnv = buildTypeEnv(tree, language, { + symbolTable: ctx.symbols, + parentMap, + importedBindings, + importedReturnTypes, + importedRawReturnTypes, + enclosingFunctionFinder: provider?.enclosingFunctionFinder, + extractFunctionName: provider?.methodExtractor?.extractFunctionName, + }); + if (typeEnv && exportedTypeMap) { + const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph); + if (fileExports) exportedTypeMap.set(file.path, fileExports); + } } - const importedBindings = importedBindingsMap?.get(file.path); - const importedReturnTypes = importedReturnTypesMap?.get(file.path); - const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path); - const typeEnv = buildTypeEnv(tree, language, { - symbolTable: ctx.symbols, - parentMap, - importedBindings, - importedReturnTypes, - importedRawReturnTypes, - enclosingFunctionFinder: provider?.enclosingFunctionFinder, - extractFunctionName: provider?.methodExtractor?.extractFunctionName, - }); - if (typeEnv && exportedTypeMap) { - const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph); - if (fileExports) exportedTypeMap.set(file.path, fileExports); - } - // Flush file-scope bindings into the accumulator. `flush()` is narrowed - // to iterate only FILE_SCOPE entries (type-env.ts) — function-scope - // bindings are dropped at the flush boundary until a Phase 9 consumer - // lands. See type-env.ts::flush() JSDoc for the dual-site reversion - // checklist (this sequential path + the worker path in parse-worker.ts). - if (bindingAccumulator) { - typeEnv.flush(file.path, bindingAccumulator); - } const callRouter = provider.callRouter; const verifiedReceivers = diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp new file mode 100644 index 000000000..56d13b285 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp @@ -0,0 +1,6 @@ +#include "../b_provider/provider.h" + +void process() { + User user = get_user(); + user.save(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.cpp b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.cpp new file mode 100644 index 000000000..155d05003 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.cpp @@ -0,0 +1,7 @@ +#include "provider.h" + +void User::save() {} + +User get_user() { + return User(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h new file mode 100644 index 000000000..1d434b7f5 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h @@ -0,0 +1,8 @@ +#pragma once + +class User { +public: + void save(); +}; + +User get_user(); diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs new file mode 100644 index 000000000..c03a9605a --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs @@ -0,0 +1,13 @@ +using static ConsumerBeforeProvider.BProvider.UserFactory; + +namespace ConsumerBeforeProvider.AConsumer +{ + public class Program + { + public void Run() + { + var u = GetUser(); + u.Save(); + } + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs new file mode 100644 index 000000000..4ed71631e --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs @@ -0,0 +1,7 @@ +namespace ConsumerBeforeProvider.BProvider +{ + public class User + { + public void Save() {} + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/UserFactory.cs b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/UserFactory.cs new file mode 100644 index 000000000..3c66c511a --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/UserFactory.cs @@ -0,0 +1,7 @@ +namespace ConsumerBeforeProvider.BProvider +{ + public static class UserFactory + { + public static User GetUser() { return new User(); } + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/ConsumerBeforeProvider.csproj b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/ConsumerBeforeProvider.csproj new file mode 100644 index 000000000..75251fdad --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/ConsumerBeforeProvider.csproj @@ -0,0 +1,5 @@ + + + ConsumerBeforeProvider + + diff --git a/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go new file mode 100644 index 000000000..0951c398d --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go @@ -0,0 +1,8 @@ +package main + +import "go-consumer-before-provider/models" + +func main() { + user := models.GetUser() + user.Save() +} diff --git a/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod new file mode 100644 index 000000000..00cc50274 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod @@ -0,0 +1,3 @@ +module go-consumer-before-provider + +go 1.21 diff --git a/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go new file mode 100644 index 000000000..e17ca9635 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go @@ -0,0 +1,9 @@ +package models + +type User struct{} + +func (u User) Save() {} + +func GetUser() User { + return User{} +} diff --git a/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java new file mode 100644 index 000000000..37a8d8fd1 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java @@ -0,0 +1,10 @@ +package app; + +import static models.BProvider.getUser; + +public class AConsumer { + public void run() { + var u = getUser(); + u.save(); + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/BProvider.java b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/BProvider.java new file mode 100644 index 000000000..5a8ac7d73 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/BProvider.java @@ -0,0 +1,7 @@ +package models; + +public class BProvider { + public static User getUser() { + return new User(); + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java new file mode 100644 index 000000000..4bb729993 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java @@ -0,0 +1,5 @@ +package models; + +public class User { + public void save() {} +} diff --git a/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js new file mode 100644 index 000000000..14b939ba1 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js @@ -0,0 +1,10 @@ +// File starts with 'a-' to sort alphabetically before 'b-provider.js'. +// In the sequential path, this file is processed first. Without the +// two-pass fix, the accumulator wouldn't have b-provider's bindings +// when this file's verifyConstructorBindings runs. +import { getUser } from './b-provider'; + +export function main() { + const u = getUser(); + u.save(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js new file mode 100644 index 000000000..99c85948a --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js @@ -0,0 +1,7 @@ +export class User { + save() {} +} + +export function getUser() { + return new User(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt b/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt new file mode 100644 index 000000000..9f82882a8 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt @@ -0,0 +1,10 @@ +package app + +import models.getUser + +class AConsumer { + fun run() { + val u = getUser() + u.save() + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/models/BProvider.kt b/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/models/BProvider.kt new file mode 100644 index 000000000..c343056fd --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/models/BProvider.kt @@ -0,0 +1,7 @@ +package models + +class User { + fun save() {} +} + +fun getUser(): User = User() diff --git a/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php new file mode 100644 index 000000000..dd44bf692 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php @@ -0,0 +1,12 @@ +save(); + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php new file mode 100644 index 000000000..9eadcb83c --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php @@ -0,0 +1,11 @@ + User: + return User() diff --git a/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb new file mode 100644 index 000000000..ed92cf60c --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb @@ -0,0 +1,6 @@ +require_relative 'models/b_user_factory' + +def process + user = UserFactory.get_user + user.save +end diff --git a/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb new file mode 100644 index 000000000..a139ede47 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb @@ -0,0 +1,4 @@ +class User + def save + end +end diff --git a/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user_factory.rb b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user_factory.rb new file mode 100644 index 000000000..e7c80feff --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user_factory.rb @@ -0,0 +1,7 @@ +require_relative 'b_user' + +class UserFactory + def self.get_user + User.new + end +end diff --git a/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs new file mode 100644 index 000000000..aa6cd1809 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs @@ -0,0 +1,6 @@ +use crate::b_provider::get_user; + +pub fn process() { + let u = get_user(); + u.save(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs new file mode 100644 index 000000000..7b20e072e --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs @@ -0,0 +1,9 @@ +pub struct User; + +impl User { + pub fn save(&self) {} +} + +pub fn get_user() -> User { + User +} diff --git a/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs new file mode 100644 index 000000000..eac6f55f1 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs @@ -0,0 +1,2 @@ +mod a_consumer; +mod b_provider; diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts b/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts new file mode 100644 index 000000000..d6d5f86c1 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts @@ -0,0 +1,10 @@ +// File starts with 'a-' to sort alphabetically before 'b-provider.ts'. +// In the sequential path, this file is processed first. Without the +// two-pass fix, the accumulator wouldn't have b-provider's bindings +// when this file's verifyConstructorBindings runs. +import { getUser } from './b-provider'; + +export function main() { + const x = getUser(); + x.save(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts b/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts new file mode 100644 index 000000000..58beea790 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts @@ -0,0 +1,7 @@ +export class User { + save(): void {} +} + +export function getUser(): User { + return new User(); +} diff --git a/gitnexus/test/integration/cross-file-binding.test.ts b/gitnexus/test/integration/cross-file-binding.test.ts index 7e1d3b656..33063f94f 100644 --- a/gitnexus/test/integration/cross-file-binding.test.ts +++ b/gitnexus/test/integration/cross-file-binding.test.ts @@ -517,3 +517,274 @@ describe('Phase 9 — Cross-File Call-Result Binding: Ruby', () => { expect(saveCall).toBeDefined(); }); }); + +// --------------------------------------------------------------------------- +// Note: shadowed import tier gating is tested at the unit level +// (call-processor.test.ts "Phase 9 tier gating" tests) because the scenario +// requires invalid TypeScript (same name imported and locally defined). +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Regression: consumer file processed before provider in sequential path +// a-consumer.ts (alphabetically first) imports getUser from b-provider.ts. +// Without the two-pass flush fix, the accumulator wouldn't have b-provider's +// bindings when a-consumer's verifyConstructorBindings runs. +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Consumer-before-provider regression tests (sequential ordering fix) +// +// Each language fixture has a consumer file that sorts alphabetically before +// the provider file. In the sequential path, the consumer is processed first. +// The two-pass flush ensures the accumulator has provider bindings before +// verifyConstructorBindings runs for the consumer. +// --------------------------------------------------------------------------- + +describe('Consumer-Before-Provider: TypeScript', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'ts-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method from provider', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves x.save() to User#save despite consumer sorted before provider', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('b-provider'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: JavaScript', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'js-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves u.save() in main() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('b-provider'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Python', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'py-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + // Python tree-sitter captures all function_definitions as Function, including methods + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); + + it('resolves u.save() in main() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('b_provider'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Java', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'java-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves user.save() in run() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Go', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'go-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User struct and Save method', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + }); + + it('resolves user.Save() in main() to User#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'Save' && c.source === 'main'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: C++', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'cpp-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves user.save() in process() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: C#', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'csharp-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and Save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + }); + + it('resolves u.Save() in Run() to User#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'Save' && c.source === 'Run'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Kotlin', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'kotlin-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves u.save() in run() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: PHP', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'php-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves $u->save() in run() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Ruby', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'rb-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves user.save in process() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Rust', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'rs-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User struct and save function', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + // Rust tree-sitter captures impl fns as Function nodes + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); + + it('resolves u.save() in process() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index 4c3d641a2..840dcd0ea 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -880,6 +880,238 @@ describe('processCallsFromExtracted', () => { expect(rels[0].targetId).toBe('Method:src/models.ts:save'); }); + // ---- Phase 9: Tier gating — accumulator fallback respects resolution tiers ---- + + it('Phase 9 tier gating: same-file callable shadows imported callee — fallback skipped', async () => { + // consumer.ts defines a local getUser() AND imports getUser from api.ts. + // The local definition has no returnType annotation. The accumulator has + // getUser → User from api.ts. The fallback must NOT fire because the + // same-file definition is authoritative (tier: 'same-file'). + ctx.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function'); + ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + // Place User and save in non-imported files so import-scoped member-call resolution + // can't resolve save without a receiver type. + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ownerId: 'Class:src/other.ts:OtherClass', + }); + // Only import api.ts — NOT models.ts, so save can't be found via import scope. + ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts'])); + ctx.namedImportMap.set( + 'src/consumer.ts', + new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]), + ); + + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + // Fallback must NOT fire — local getUser shadows imported getUser (tier: same-file). + // Without a receiver type, member-call 'save' is ambiguous globally → no edge. + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(0); + }); + + it('Phase 9 tier gating: multiple callable candidates — fallback skipped', async () => { + // Two functions named getUser in different imported files — resolution is ambiguous + // (multiple candidates at 'import-scoped' tier). The accumulator carries a WRONG type + // (BadType). If the fallback fires, x gets typed as BadType and x.save() looks for + // BadType.save — which doesn't exist → 0 edges. If the fallback is correctly blocked, + // x has no receiver type at all, and save is ambiguous (two owners) → 0 edges. + // Either way, no CALLS edge. But we verify the accumulator's wrong type did NOT leak + // by checking that no ACCESSES edge to BadType is created. + ctx.symbols.add('src/api-v1.ts', 'getUser', 'Function:src/api-v1.ts:getUser', 'Function'); + ctx.symbols.add('src/api-v2.ts', 'getUser', 'Function:src/api-v2.ts:getUser', 'Function'); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ownerId: 'Class:src/other.ts:OtherClass', + }); + // BadType has no methods — if the accumulator wrongly types x as BadType, + // the receiver type is set but save won't resolve at all. + ctx.symbols.add('src/bad.ts', 'BadType', 'Class:src/bad.ts:BadType', 'Class'); + ctx.importMap.set( + 'src/consumer.ts', + new Set(['src/api-v1.ts', 'src/api-v2.ts', 'src/models.ts']), + ); + ctx.namedImportMap.set( + 'src/consumer.ts', + new Map([['getUser', { sourcePath: 'src/api-v1.ts', exportedName: 'getUser' }]]), + ); + + // Accumulator carries WRONG type — proves gating blocks the fallback + const acc = new BindingAccumulator(); + acc.appendFile('src/api-v1.ts', [{ scope: '', varName: 'getUser', typeName: 'BadType' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + // If gating works: x has no receiver type, save may or may not resolve via + // import scope (separate mechanism). Key assertion: BadType never appears + // as an ACCESSES target — proving the accumulator's wrong type did not leak. + const accesses = graph.relationships.filter( + (r) => r.type === 'ACCESSES' && r.targetId === 'Class:src/bad.ts:BadType', + ); + expect(accesses).toHaveLength(0); + }); + + it('Phase 9 tier gating: no callable candidates but named import — fallback fires', async () => { + // getUser is not in the SymbolTable at all (e.g. definition not parsed). + // namedImportMap has the import, accumulator has the type. Fallback should fire. + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts'])); + ctx.namedImportMap.set( + 'src/consumer.ts', + new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]), + ); + + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + // No SymbolTable entry at all → tiered is null, fallback fires via accumulator. + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(1); + expect(rels[0].targetId).toBe('Method:src/models.ts:save'); + }); + + it('Phase 9 tier gating: single same-file callable without returnType — fallback skipped', async () => { + // consumer.ts has a local getUser() without returnType annotation. + // No import of getUser exists. The accumulator has getUser → User from api.ts. + // Tier is 'same-file' so fallback must NOT fire. + ctx.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function'); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + // Add a second 'save' so fuzzy lookup is ambiguous without receiver type + ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ownerId: 'Class:src/other.ts:OtherClass', + }); + + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + + const constructorBindings: FileConstructorBindings[] = [ + { + filePath: 'src/consumer.ts', + bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }], + }, + ]; + + const calls: ExtractedCall[] = [ + { + filePath: 'src/consumer.ts', + calledName: 'save', + sourceId: 'Function:src/consumer.ts:main', + receiverName: 'x', + callForm: 'member', + }, + ]; + + await processCallsFromExtracted( + graph, + calls, + ctx, + undefined, + constructorBindings, + undefined, + acc, + ); + + // Same-file callable — local is authoritative even without annotation. + // Fuzzy 'save' lookup is ambiguous → no edge. + const rels = graph.relationships.filter((r) => r.type === 'CALLS'); + expect(rels).toHaveLength(0); + }); + // ---- Scope-aware constructor bindings (Phase 3) ---- it('receiverKey collision: same method name in different classes does not collide', async () => {