fix(SM-15): gate accumulator fallback on resolution tier and fix sequential file-order dependency

Two Codex adversarial reviews identified medium-severity bugs in the Phase 9
BindingAccumulator fallback:

1. Local-first violation: the fallback fired regardless of whether ctx.resolve()
   found same-file candidates, letting an imported callee shadow a local one
   and produce false CALLS edges. Fixed by gating on tiered.tier !== 'same-file'
   and callableDefs.length <= 1.

2. Sequential file-order dependency: processCalls flushed and verified per-file,
   so consumer files processed before their providers missed accumulator bindings.
   Fixed by splitting into a flush pre-pass (all files) then a resolution loop,
   mirroring the worker path's "all appends before any reads" pattern.

Also adds 11 consumer-before-provider integration test fixtures (one per
supported language) and 4 unit tests for tier gating edge cases.
This commit is contained in:
Gergo Magyar 2026-04-10 09:10:16 +01:00
parent 273cd3ada7
commit ddb86b35cb
33 changed files with 974 additions and 95 deletions

View file

@ -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<string, number>() : 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<typeof getProvider>;
tree: ReturnType<typeof parser.parse>;
matches: ReturnType<Parser.Query['matches']>;
parentMap: ReadonlyMap<string, readonly string[]>;
typeEnv: ReturnType<typeof buildTypeEnv>;
}
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<string, string[]>();
for (const match of matches) {
const captureMap: Record<string, any> = {};
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<string, readonly string[]> = 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<typeof getProvider>;
let tree: ReturnType<typeof parser.parse>;
let matches: ReturnType<Parser.Query['matches']>;
let typeEnv: ReturnType<typeof buildTypeEnv>;
let parentMap: ReadonlyMap<string, readonly string[]>;
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<string, string[]>();
for (const match of matches) {
const captureMap: Record<string, any> = {};
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<string, readonly string[]> = 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<string, string[]>();
for (const match of matches) {
const captureMap: Record<string, any> = {};
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 =

View file

@ -0,0 +1,6 @@
#include "../b_provider/provider.h"
void process() {
User user = get_user();
user.save();
}

View file

@ -0,0 +1,7 @@
#include "provider.h"
void User::save() {}
User get_user() {
return User();
}

View file

@ -0,0 +1,8 @@
#pragma once
class User {
public:
void save();
};
User get_user();

View file

@ -0,0 +1,13 @@
using static ConsumerBeforeProvider.BProvider.UserFactory;
namespace ConsumerBeforeProvider.AConsumer
{
public class Program
{
public void Run()
{
var u = GetUser();
u.Save();
}
}
}

View file

@ -0,0 +1,7 @@
namespace ConsumerBeforeProvider.BProvider
{
public class User
{
public void Save() {}
}
}

View file

@ -0,0 +1,7 @@
namespace ConsumerBeforeProvider.BProvider
{
public static class UserFactory
{
public static User GetUser() { return new User(); }
}
}

View file

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>ConsumerBeforeProvider</RootNamespace>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,8 @@
package main
import "go-consumer-before-provider/models"
func main() {
user := models.GetUser()
user.Save()
}

View file

@ -0,0 +1,3 @@
module go-consumer-before-provider
go 1.21

View file

@ -0,0 +1,9 @@
package models
type User struct{}
func (u User) Save() {}
func GetUser() User {
return User{}
}

View file

@ -0,0 +1,10 @@
package app;
import static models.BProvider.getUser;
public class AConsumer {
public void run() {
var u = getUser();
u.save();
}
}

View file

@ -0,0 +1,7 @@
package models;
public class BProvider {
public static User getUser() {
return new User();
}
}

View file

@ -0,0 +1,5 @@
package models;
public class User {
public void save() {}
}

View file

@ -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();
}

View file

@ -0,0 +1,7 @@
export class User {
save() {}
}
export function getUser() {
return new User();
}

View file

@ -0,0 +1,10 @@
package app
import models.getUser
class AConsumer {
fun run() {
val u = getUser()
u.save()
}
}

View file

@ -0,0 +1,7 @@
package models
class User {
fun save() {}
}
fun getUser(): User = User()

View file

@ -0,0 +1,12 @@
<?php
namespace App;
use function App\Models\getUser;
class AConsumer {
public function run(): void {
$u = getUser();
$u->save();
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Models;
class User {
public function save(): void {}
}
function getUser(): User {
return new User();
}

View file

@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}

View file

@ -0,0 +1,5 @@
from b_provider import get_user
def main():
u = get_user()
u.save()

View file

@ -0,0 +1,6 @@
class User:
def save(self):
pass
def get_user() -> User:
return User()

View file

@ -0,0 +1,6 @@
require_relative 'models/b_user_factory'
def process
user = UserFactory.get_user
user.save
end

View file

@ -0,0 +1,4 @@
class User
def save
end
end

View file

@ -0,0 +1,7 @@
require_relative 'b_user'
class UserFactory
def self.get_user
User.new
end
end

View file

@ -0,0 +1,6 @@
use crate::b_provider::get_user;
pub fn process() {
let u = get_user();
u.save();
}

View file

@ -0,0 +1,9 @@
pub struct User;
impl User {
pub fn save(&self) {}
}
pub fn get_user() -> User {
User
}

View file

@ -0,0 +1,2 @@
mod a_consumer;
mod b_provider;

View file

@ -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();
}

View file

@ -0,0 +1,7 @@
export class User {
save(): void {}
}
export function getUser(): User {
return new User();
}

View file

@ -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();
});
});

View file

@ -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 () => {