mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
273cd3ada7
commit
ddb86b35cb
33 changed files with 974 additions and 95 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
6
gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include "../b_provider/provider.h"
|
||||
|
||||
void process() {
|
||||
User user = get_user();
|
||||
user.save();
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#include "provider.h"
|
||||
|
||||
void User::save() {}
|
||||
|
||||
User get_user() {
|
||||
return User();
|
||||
}
|
||||
8
gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h
vendored
Normal file
8
gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#pragma once
|
||||
|
||||
class User {
|
||||
public:
|
||||
void save();
|
||||
};
|
||||
|
||||
User get_user();
|
||||
13
gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs
vendored
Normal file
13
gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using static ConsumerBeforeProvider.BProvider.UserFactory;
|
||||
|
||||
namespace ConsumerBeforeProvider.AConsumer
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var u = GetUser();
|
||||
u.Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace ConsumerBeforeProvider.BProvider
|
||||
{
|
||||
public class User
|
||||
{
|
||||
public void Save() {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace ConsumerBeforeProvider.BProvider
|
||||
{
|
||||
public static class UserFactory
|
||||
{
|
||||
public static User GetUser() { return new User(); }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>ConsumerBeforeProvider</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
8
gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go
vendored
Normal file
8
gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package main
|
||||
|
||||
import "go-consumer-before-provider/models"
|
||||
|
||||
func main() {
|
||||
user := models.GetUser()
|
||||
user.Save()
|
||||
}
|
||||
3
gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module go-consumer-before-provider
|
||||
|
||||
go 1.21
|
||||
9
gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go
vendored
Normal file
9
gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package models
|
||||
|
||||
type User struct{}
|
||||
|
||||
func (u User) Save() {}
|
||||
|
||||
func GetUser() User {
|
||||
return User{}
|
||||
}
|
||||
10
gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java
vendored
Normal file
10
gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package app;
|
||||
|
||||
import static models.BProvider.getUser;
|
||||
|
||||
public class AConsumer {
|
||||
public void run() {
|
||||
var u = getUser();
|
||||
u.save();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package models;
|
||||
|
||||
public class BProvider {
|
||||
public static User getUser() {
|
||||
return new User();
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models;
|
||||
|
||||
public class User {
|
||||
public void save() {}
|
||||
}
|
||||
10
gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js
vendored
Normal file
10
gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js
vendored
Normal 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();
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export class User {
|
||||
save() {}
|
||||
}
|
||||
|
||||
export function getUser() {
|
||||
return new User();
|
||||
}
|
||||
10
gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt
vendored
Normal file
10
gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package app
|
||||
|
||||
import models.getUser
|
||||
|
||||
class AConsumer {
|
||||
fun run() {
|
||||
val u = getUser()
|
||||
u.save()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package models
|
||||
|
||||
class User {
|
||||
fun save() {}
|
||||
}
|
||||
|
||||
fun getUser(): User = User()
|
||||
12
gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php
vendored
Normal file
12
gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use function App\Models\getUser;
|
||||
|
||||
class AConsumer {
|
||||
public function run(): void {
|
||||
$u = getUser();
|
||||
$u->save();
|
||||
}
|
||||
}
|
||||
11
gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php
vendored
Normal file
11
gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class User {
|
||||
public function save(): void {}
|
||||
}
|
||||
|
||||
function getUser(): User {
|
||||
return new User();
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/composer.json
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/composer.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/cross-file-binding/py-consumer-before-provider/src/a_consumer.py
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/py-consumer-before-provider/src/a_consumer.py
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from b_provider import get_user
|
||||
|
||||
def main():
|
||||
u = get_user()
|
||||
u.save()
|
||||
6
gitnexus/test/fixtures/cross-file-binding/py-consumer-before-provider/src/b_provider.py
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/py-consumer-before-provider/src/b_provider.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class User:
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
def get_user() -> User:
|
||||
return User()
|
||||
6
gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
require_relative 'models/b_user_factory'
|
||||
|
||||
def process
|
||||
user = UserFactory.get_user
|
||||
user.save
|
||||
end
|
||||
4
gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb
vendored
Normal file
4
gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
class User
|
||||
def save
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
require_relative 'b_user'
|
||||
|
||||
class UserFactory
|
||||
def self.get_user
|
||||
User.new
|
||||
end
|
||||
end
|
||||
6
gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
use crate::b_provider::get_user;
|
||||
|
||||
pub fn process() {
|
||||
let u = get_user();
|
||||
u.save();
|
||||
}
|
||||
9
gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs
vendored
Normal file
9
gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
pub struct User;
|
||||
|
||||
impl User {
|
||||
pub fn save(&self) {}
|
||||
}
|
||||
|
||||
pub fn get_user() -> User {
|
||||
User
|
||||
}
|
||||
2
gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs
vendored
Normal file
2
gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod a_consumer;
|
||||
mod b_provider;
|
||||
10
gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts
vendored
Normal file
10
gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts
vendored
Normal 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();
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export class User {
|
||||
save(): void {}
|
||||
}
|
||||
|
||||
export function getUser(): User {
|
||||
return new User();
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue