fix(scope-resolution): gate C#/Kotlin free calls by instance ownership (#2563) (#2654)
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

* Initial plan

* fix(scope-resolution): gate C# and Kotlin free calls

* fix(scope-resolution): keep Kotlin ownership gate safe

* Apply remaining changes

* perf(scope-resolution): benchmark and cache ownership gates

* test(scope-resolution): simplify benchmark scaling loop

* refactor(scope-resolution): encapsulate ownership cache

* test(scope-resolution): enforce subquadratic ownership scaling

* fix(scope-resolution): address ownership review findings

* test(csharp): regenerate capture golden for #2563 fixtures

The committed expected-captures.json was missing the new
NamespaceOwnerCollision.cs entry and carried a stale SameFileCases.cs
digest/count (56 → 67), so csharp-captures-golden.test.ts was the sole
red check on the PR. Regenerate with UPDATE_GOLDEN=1 to match the
fixtures the bench fingerprint already reflects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Copilot 2026-07-24 13:31:56 +01:00 committed by GitHub
parent 450cebc268
commit d3d4fa31bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 553 additions and 49 deletions

View file

@ -523,6 +523,7 @@ jobs:
npx vitest run --no-file-parallelism
test/integration/cobol-pipeline-benchmark.test.ts
test/integration/csharp-pipeline-benchmark.test.ts
test/integration/instance-ownership-pipeline-benchmark.test.ts
test/integration/rust-pipeline-benchmark.test.ts
test/integration/php-pipeline-benchmark.test.ts
test/integration/ruby-pipeline-benchmark.test.ts

View file

@ -351,6 +351,11 @@ export interface BindingRef {
readonly origin: 'local' | 'import' | 'namespace' | 'wildcard' | 'reexport';
/** Non-null for non-local origins; carries the `ImportEdge` that brought the name into this scope. */
readonly via?: ImportEdge;
/**
* Optional semantic visibility evidence supplied by a language hook.
* Shared resolution consumes this without inspecting language syntax.
*/
readonly visibility?: 'static-member-import';
}
// ─── §2.5 TypeRef ───────────────────────────────────────────────────────────

View file

@ -39,11 +39,12 @@
},
"csharp": {
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
"fingerprint": "75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1",
"fingerprint": "e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.",
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11)."
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).",
"_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5."
},
"rust": {
"fingerprint": "655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db",
@ -132,7 +133,7 @@
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5."
},
"kotlin": {
"fingerprint": "a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091",
"fingerprint": "9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.",
@ -140,6 +141,7 @@
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.",
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).",
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.",
"_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5."
"_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5.",
"_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5."
}
}

View file

@ -615,7 +615,11 @@ export function populateCsharpNamespaceSiblings(
}
if (seen.has(memberDef.nodeId)) continue;
seen.add(memberDef.nodeId);
bucketArr.push({ def: memberDef, origin: 'import' });
bucketArr.push({
def: memberDef,
origin: 'import',
visibility: 'static-member-import',
});
}
}
}

View file

@ -93,6 +93,7 @@ const csharpScopeResolver: ScopeResolver = {
// `(caller, target)` — multiple `g.Greet(...)` sites from Main
// yield ONE edge, not one per site.
collapseMemberCallsByCallerTarget: true,
freeCallsRequireInstanceOwnership: true,
// C# hoists method return-type bindings to the enclosing Module
// scope so `propagateImportedReturnTypes` can mirror them across

View file

@ -121,6 +121,7 @@ export const kotlinScopeResolver: ScopeResolver = {
propagatesReturnTypesAcrossImports: true,
collapseMemberCallsByCallerTarget: false,
hoistTypeBindingsToModule: true,
freeCallsRequireInstanceOwnership: true,
postExtractSourceTextPolicy: 'uncached-files',
populateNamespaceSiblings: populateKotlinPackageSiblings,
emitPostResolutionEdges: (graph, parsedFiles, nodeLookup, indexes) => {

View file

@ -18,6 +18,7 @@
*/
import type {
DefId,
ParameterTypeClass,
ParsedFile,
Reference,
@ -37,11 +38,13 @@ import type {
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
import {
findAllCallableBindingCandidatesInScope,
findAllCallableBindingsInScope,
findCallableBindingInScope,
findCallableBindingsAndAdlBlocker,
findEnclosingClassDef,
resolveInheritanceBaseInScope,
type CallableBindingCandidate,
} from '../scope/walkers.js';
import {
isOverloadAmbiguousAfterNormalization,
@ -131,8 +134,46 @@ export function emitFreeCallFallback(
options.isCallableVisibleFromCaller === undefined
? new Map<string, readonly SymbolDefinition[]>()
: undefined;
const enclosingInstanceOwnerByScope =
options.freeCallsRequireInstanceOwnership === true
? new Map<ScopeId, SymbolDefinition | null>()
: undefined;
const reachableInstanceOwnersByOwner =
options.freeCallsRequireInstanceOwnership === true
? new Map<string, ReadonlySet<string>>()
: undefined;
const instanceOwnerKey = (ownerId: string): string => {
const owner = scopes.defs.get(ownerId as DefId);
const qualifiedName = owner?.qualifiedName;
if (qualifiedName === undefined || qualifiedName === '') return ownerId;
const namespacePrefix = owner.namespacePrefix ?? '';
return `${namespacePrefix.length}:${namespacePrefix}:${qualifiedName}`;
};
const isReachableInstanceOwner = (scopeId: ScopeId, ownerId: string): boolean => {
let enclosing = enclosingInstanceOwnerByScope?.get(scopeId);
if (enclosing === undefined) {
enclosing = findEnclosingClassDef(scopeId, scopes) ?? null;
enclosingInstanceOwnerByScope?.set(scopeId, enclosing);
}
if (enclosing === null) return false;
let owners = reachableInstanceOwnersByOwner?.get(enclosing.nodeId);
if (owners === undefined) {
const mutableOwners = new Set<string>([instanceOwnerKey(enclosing.nodeId)]);
for (const inheritedOwnerId of scopes.methodDispatch.mroFor(enclosing.nodeId)) {
mutableOwners.add(instanceOwnerKey(inheritedOwnerId));
}
owners = mutableOwners;
reachableInstanceOwnersByOwner?.set(enclosing.nodeId, owners);
}
return owners.has(instanceOwnerKey(ownerId));
};
for (const parsed of parsedFiles) {
const bindingCandidatesByScope =
options.freeCallsRequireInstanceOwnership === true
? new Map<ScopeId, Map<string, readonly CallableBindingCandidate[]>>()
: undefined;
for (const site of parsed.referenceSites) {
if (site.kind !== 'call') continue;
if (site.explicitReceiver !== undefined) continue;
@ -202,11 +243,61 @@ export function emitFreeCallFallback(
// (local shadows import). When a conversion-rank function is
// available AND the binding scope contains multiple overloads,
// refine with narrowOverloadCandidates (#1578).
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
let bindingCandidates: readonly CallableBindingCandidate[] | undefined;
if (bindingCandidatesByScope !== undefined) {
let byName = bindingCandidatesByScope.get(site.inScope);
if (byName === undefined) {
byName = new Map();
bindingCandidatesByScope.set(site.inScope, byName);
}
bindingCandidates = byName.get(site.name);
if (bindingCandidates === undefined) {
bindingCandidates = findAllCallableBindingCandidatesInScope(
site.inScope,
site.name,
scopes,
);
byName.set(site.name, bindingCandidates);
}
}
let eligibleBindingCandidates: readonly CallableBindingCandidate[] | undefined;
if (bindingCandidates === undefined) {
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
} else {
eligibleBindingCandidates = bindingCandidates.filter((candidate) => {
const def = candidate.def;
if (
def.type !== 'Method' ||
def.ownerId === undefined ||
def.filePath !== parsed.filePath
) {
return true;
}
const ownerReachable = isReachableInstanceOwner(site.inScope, def.ownerId);
const staticallyImported = candidate.bindings.some(
(binding) => binding.visibility === 'static-member-import',
);
return ownerReachable || staticallyImported;
});
fnDef = eligibleBindingCandidates[0]?.def;
if (fnDef === undefined && bindingCandidates.length > 0) {
recordSuppressedOutcome(options.recordResolutionOutcome, {
phase: 'free-call-fallback',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
reason: 'free-call-instance-ownership',
candidates: bindingCandidates.map((candidate) => candidate.def),
});
}
}
if (
fnDef !== undefined &&
options.isBuiltInName?.(site.name) === true &&
fnDef.filePath === parsed.filePath &&
eligibleBindingCandidates?.some((candidate) =>
candidate.bindings.some((binding) => binding.visibility === 'static-member-import'),
) !== true &&
!hasGenuineLexicalBinding(site.inScope, site.name, scopes)
) {
// A platform/language built-in (e.g. `fetch`, `setTimeout`)
@ -234,48 +325,14 @@ export function emitFreeCallFallback(
// stopped resolving (verified via a scratch probe fixture).
fnDef = undefined;
}
// Instance-ownership gate (#2550). Placement matters: after the
// scope-chain lookup, BEFORE overload narrowing -- a suppressed
// candidate must not participate in overload selection. The
// legitimate same-class bare call already resolved earlier via
// `pickImplicitThisOverload`; an inherited bare call passes the
// MRO arm here; what remains is the finalize-bucket leak (an
// unrelated same-file method matched by bare name).
//
// Same-file only (mirrors the #2545 guard's load-bearing
// condition): the `materializeBindings` bucket is per-file, so
// the leak is ALWAYS same-file. A cross-file Method match here
// came through a genuine import channel -- e.g. the arity-
// narrowing parity fixtures resolve a bare `writeAudit(u)` to
// an imported class's method, which must keep working
// (suppressing it broke `java.test.ts`'s arity-filtering suite,
// verified empirically).
if (
fnDef !== undefined &&
options.freeCallsRequireInstanceOwnership === true &&
fnDef.type === 'Method' &&
fnDef.ownerId !== undefined &&
fnDef.filePath === parsed.filePath
(options.conversionRankFn !== undefined || bindingCandidates !== undefined)
) {
const enclosing = findEnclosingClassDef(site.inScope, scopes);
const ownerReachable =
enclosing !== undefined &&
(enclosing.nodeId === fnDef.ownerId ||
scopes.methodDispatch.mroFor(enclosing.nodeId).includes(fnDef.ownerId));
if (!ownerReachable) {
recordSuppressedOutcome(options.recordResolutionOutcome, {
phase: 'free-call-fallback',
filePath: parsed.filePath,
name: site.name,
range: site.atRange,
reason: 'free-call-instance-ownership',
candidates: [fnDef],
});
fnDef = undefined;
}
}
if (fnDef !== undefined && options.conversionRankFn !== undefined) {
const allCallables = findAllCallableBindingsInScope(site.inScope, site.name, scopes);
const allCallables =
eligibleBindingCandidates === undefined
? findAllCallableBindingsInScope(site.inScope, site.name, scopes)
: eligibleBindingCandidates.map((candidate) => candidate.def);
if (allCallables.length > 1) {
const narrowed = narrowOverloadCandidates(
allCallables,

View file

@ -668,6 +668,69 @@ export function findCallableBindingInScope(
return findAllCallableBindingsInScope(startScope, callableName, scopes)[0];
}
export interface CallableBindingCandidate {
readonly def: SymbolDefinition;
/** Every visibility path for this definition, in binding precedence order. */
readonly bindings: readonly BindingRef[];
}
function collectCallableBindingCandidates(
sources: readonly (readonly BindingRef[] | undefined)[],
): readonly CallableBindingCandidate[] {
const byNodeId = new Map<string, { def: SymbolDefinition; bindings: BindingRef[] }>();
for (const source of sources) {
if (source === undefined) continue;
for (const binding of source) {
const def = binding.def;
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
const existing = byNodeId.get(def.nodeId);
if (existing === undefined) {
byNodeId.set(def.nodeId, { def, bindings: [binding] });
} else {
existing.bindings.push(binding);
}
}
}
return [...byNodeId.values()];
}
/**
* Binding-aware callable lookup for consumers that need visibility evidence.
* Unlike `lookupBindingsAt`, duplicate definitions retain every binding path,
* so a weaker augmentation can contribute provenance even when a finalized
* binding remains the candidate's canonical definition.
*/
export function findAllCallableBindingCandidatesInScope(
startScope: ScopeId,
callableName: string,
scopes: ScopeResolutionIndexes,
): readonly CallableBindingCandidate[] {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return [];
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return [];
if (scope.kind !== 'Object') {
const lexical = collectCallableBindingCandidates([scope.bindings.get(callableName)]);
if (lexical.length > 0) return lexical;
const candidates = collectCallableBindingCandidates([
scopes.bindings.get(currentId)?.get(callableName),
scopes.bindingAugmentations.get(currentId)?.get(callableName),
collectNamespaceFqnBindings(currentId, callableName, scopes),
scopes.workspaceFqnBindings?.get(callableName),
]);
if (candidates.length > 0) return candidates;
}
currentId = scope.parent;
}
return [];
}
/**
* Look up all callable bindings (Function/Method/Constructor) by name
* from the nearest scope in the chain that binds `callableName`.

View file

@ -463,8 +463,12 @@ export interface RepoMeta {
* separate sequence for anonymous types. Existing type/member ids, lexical
* bindings, and ownership edges must not be mixed with newly named unchanged
* Java files; force a full re-analyze.
* v14: C# and Kotlin free-call fallback now rejects same-file methods whose
* instance owner is outside the caller's enclosing class/MRO (#2563). The
* incremental write set would otherwise retain those stale CALLS edges on
* every unchanged C# and Kotlin file; force a full re-analyze instead.
*/
export const INCREMENTAL_SCHEMA_VERSION = 13;
export const INCREMENTAL_SCHEMA_VERSION = 14;
export interface IndexedRepo {
repoPath: string;

View file

@ -659,6 +659,14 @@
"captureGroups": 12,
"digest": "b6f9dd906e1309338f21633d71e663cdfd95a8707d38b9a8bf74813415ee5d13"
},
"csharp-using-static/App/NamespaceOwnerCollision.cs": {
"captureGroups": 16,
"digest": "d78082d240d14417ad3f502ef1e96e8e3575dd4e9cec00cfc15c7230c596ca53"
},
"csharp-using-static/App/SameFileCases.cs": {
"captureGroups": 67,
"digest": "50f41faf386131ecbf6cf9c22b3594cbafee8291f385252aad7c10202881d27e"
},
"csharp-using-static/Helpers/MathUtils.cs": {
"captureGroups": 8,
"digest": "32c174cbaade4e2d6e0aa7e95a2b5addd138441deb43ced286c9cf5cd30750aa"

View file

@ -0,0 +1,18 @@
namespace First
{
public class NamespaceTwin
{
public void RejectOtherNamespaceOwner()
{
NamespaceCollision();
}
}
}
namespace Second
{
public class NamespaceTwin
{
public void NamespaceCollision() { }
}
}

View file

@ -0,0 +1,68 @@
using System;
using static App.SameFileStatics;
namespace App;
public static class SameFileStatics
{
public static void ImportedOnly() { }
public static string Select(string value, int count)
{
return value;
}
public static int Select(int value)
{
return value;
}
}
public class SameFileIntruder
{
public void LeakedOnly() { }
public string Select(string value, int count)
{
return value;
}
}
public class SameFileBase
{
protected void InheritedOnly() { }
}
public class SameFileConsumer : SameFileBase
{
private void OwnOnly() { }
public void Exercise()
{
LeakedOnly();
ImportedOnly();
OwnOnly();
InheritedOnly();
Select("value", 1);
int LocalOnly(int value)
{
return value;
}
Func<int> lambda = () => LocalOnly(2);
}
}
public partial class SameFilePartial
{
public void CallAcrossFragment()
{
AcrossFragment();
}
}
public partial class SameFilePartial
{
private void AcrossFragment() { }
}

View file

@ -0,0 +1,43 @@
open class Base {
fun inherited() {}
}
class Owner : Base() {
fun own() {}
fun callOwn() {
own()
}
fun callInherited() {
inherited()
}
}
class Unrelated {
fun collide() {}
}
class Caller {
fun run() {
collide()
}
}
class Outer {
fun outerMethod() {}
inner class Inner {
fun callOuter() {
outerMethod()
}
}
}
val handler = object {
fun sibling() {}
fun callSibling() {
sibling()
}
}

View file

@ -0,0 +1,129 @@
/**
* Instance-ownership free-call gate benchmark.
*
* Generates C# and Kotlin projects where every file contains a caller and an
* unrelated class method with the same receiver-less call name. Repeated calls
* stress the ownership gate that prevents the same-file fallback from linking
* those unrelated methods.
*
* Run:
* cd gitnexus && GITNEXUS_BENCH=1 npx vitest run test/integration/instance-ownership-pipeline-benchmark.test.ts
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
const CALLS_PER_FILE = 24;
// time growth divided by file growth: quadratic work reaches 2 on a doubling.
const NORMALIZED_SCALING_LIMIT = 2;
interface BenchResult {
fileCount: number;
callCount: number;
elapsedMs: number;
peakHeapMB: number;
}
interface LanguageCase {
readonly label: string;
readonly extension: string;
source(fileIndex: number): string;
}
const LANGUAGES: readonly LanguageCase[] = [
{
label: 'C#',
extension: 'cs',
source: (fileIndex) => `namespace Bench${fileIndex};
public class Caller${fileIndex}
{
${Array.from(
{ length: CALLS_PER_FILE },
(_, callIndex) => ` public void Run${callIndex}() { Foreign(); }`,
).join('\n')}
}
public class Unrelated${fileIndex}
{
public void Foreign() {}
}
`,
},
{
label: 'Kotlin',
extension: 'kt',
source: (fileIndex) => `package bench${fileIndex}
class Caller${fileIndex} {
${Array.from(
{ length: CALLS_PER_FILE },
(_, callIndex) => ` fun run${callIndex}() { foreign() }`,
).join('\n')}
}
class Unrelated${fileIndex} {
fun foreign() {}
}
`,
},
];
function generateFixture(language: LanguageCase, fileCount: number): string {
const dir = fs.mkdtempSync(
path.join(os.tmpdir(), `instance-ownership-${language.extension}-${fileCount}-`),
);
for (let i = 0; i < fileCount; i++) {
fs.writeFileSync(path.join(dir, `Case${i}.${language.extension}`), language.source(i));
}
return dir;
}
async function runBenchmark(language: LanguageCase, fileCount: number): Promise<BenchResult> {
const dir = generateFixture(language, fileCount);
let peakHeapMB = 0;
const heapSampler = setInterval(() => {
peakHeapMB = Math.max(peakHeapMB, process.memoryUsage().heapUsed / 1024 / 1024);
}, 25);
try {
const start = performance.now();
await runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true });
return {
fileCount,
callCount: fileCount * CALLS_PER_FILE,
elapsedMs: Math.round(performance.now() - start),
peakHeapMB: Math.round(peakHeapMB),
};
} finally {
clearInterval(heapSampler);
fs.rmSync(dir, { recursive: true, force: true });
}
}
describe.skipIf(!BENCH_ENABLED)('instance-ownership free-call gate benchmark', () => {
for (const language of LANGUAGES) {
it(`${language.label} scales sub-quadratically with ownership-gated calls`, async () => {
// Wide enough steps to expose quadratic growth without making this
// opt-in benchmark impractical on contributor machines.
let previous: BenchResult | undefined;
for (const fileCount of [100, 250, 500]) {
const result = await runBenchmark(language, fileCount);
console.log(
`${language.label}: ${result.fileCount} files / ${result.callCount} calls: ` +
`${result.elapsedMs}ms, ${result.peakHeapMB}MB heap`,
);
if (previous !== undefined) {
const fileRatio = result.fileCount / previous.fileCount;
const timeRatio = result.elapsedMs / previous.elapsedMs;
expect(timeRatio / fileRatio).toBeLessThan(NORMALIZED_SCALING_LIMIT);
}
previous = result;
}
}, 600_000);
}
});

View file

@ -241,6 +241,54 @@ describe('C# using static member injection', () => {
expect(sqCall!.targetFilePath).toBe('Helpers/MathUtils.cs');
expect(['import-resolved', 'global']).toContain(sqCall!.rel.reason);
});
it("does not resolve an unrelated same-file class's bare method", () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.find((c) => c.source === 'Exercise' && c.target === 'LeakedOnly')).toBeUndefined();
expect(
calls.find(
(c) => c.source === 'RejectOtherNamespaceOwner' && c.target === 'NamespaceCollision',
),
).toBeUndefined();
});
it('preserves same-file using-static visibility when finalize masks its provenance', () => {
const calls = getRelationships(result, 'CALLS');
const imported = calls.find((c) => c.source === 'Exercise' && c.target === 'ImportedOnly');
expect(imported).toBeDefined();
expect(imported!.rel.targetId).toContain('SameFileStatics.ImportedOnly');
});
it('preserves own-instance and inherited bare calls', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.find((c) => c.source === 'Exercise' && c.target === 'OwnOnly')).toBeDefined();
expect(
calls.find((c) => c.source === 'Exercise' && c.target === 'InheritedOnly'),
).toBeDefined();
});
it('preserves bare calls across same-file partial-class fragments', () => {
const calls = getRelationships(result, 'CALLS');
expect(
calls.find((c) => c.source === 'CallAcrossFragment' && c.target === 'AcrossFragment'),
).toBeDefined();
});
it('resolves a local function called from a lambda body', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.find((c) => c.source === 'Exercise' && c.target === 'LocalOnly')).toBeDefined();
});
it('narrows static-import overloads after rejecting a leaked same-file method', () => {
const calls = getRelationships(result, 'CALLS');
const selectCalls = calls.filter((c) => c.source === 'Exercise' && c.target === 'Select');
expect(selectCalls).toHaveLength(1);
expect(selectCalls[0]!.rel.targetId).toContain('SameFileStatics.Select');
expect(result.graph.getNode(selectCalls[0]!.rel.targetId)?.properties.parameterTypes).toEqual([
'string',
'int',
]);
});
});
// ---------------------------------------------------------------------------

View file

@ -2958,3 +2958,32 @@ describe('Kotlin anonymous object-expression method scoping (#2545)', () => {
expect(getNodesByLabel(result, 'Method')).toContain('println');
});
});
describe('Kotlin instance-ownership free-call gate (#2563)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-instance-ownership'), () => {});
}, 60000);
it("does not resolve a bare call to an unrelated same-file class's method", () => {
const leaked = getRelationships(result, 'CALLS').find(
(call) => call.source === 'run' && call.target === 'collide',
);
expect(leaked).toBeUndefined();
});
it('preserves own, inherited, outer-instance, and anonymous-object sibling calls', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.find((call) => call.source === 'callOwn' && call.target === 'own')).toBeDefined();
expect(
calls.find((call) => call.source === 'callInherited' && call.target === 'inherited'),
).toBeDefined();
expect(
calls.find((call) => call.source === 'callSibling' && call.target === 'sibling'),
).toBeDefined();
expect(
calls.find((call) => call.source === 'callOuter' && call.target === 'outerMethod'),
).toBeDefined();
});
});

View file

@ -73,8 +73,8 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
});
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
it('INCREMENTAL_SCHEMA_VERSION is 13 (Java local-type identity migration, #2562)', () => {
expect(INCREMENTAL_SCHEMA_VERSION).toBe(13);
it('INCREMENTAL_SCHEMA_VERSION is bumped to 14 (C#/Kotlin instance-ownership free-call gate, #2563)', () => {
expect(INCREMENTAL_SCHEMA_VERSION).toBe(14);
});
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
@ -125,7 +125,10 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
// identities and lexical visibility scopes (#2562), so unchanged
// simple-name-keyed type/member ids must not survive.
expect(passesReuseGate(12)).toBe(false);
// A pre-v14 (v13) index predates the C#/Kotlin instance-ownership gate,
// so unchanged files may retain spurious same-file CALLS edges.
expect(passesReuseGate(13)).toBe(false);
// A current-version stamp passes the gate (incremental top-up eligible).
expect(passesReuseGate(13)).toBe(true);
expect(passesReuseGate(14)).toBe(true);
});
});

View file

@ -12,6 +12,7 @@
import { describe, it, expect } from 'vitest';
import {
findAllCallableBindingCandidatesInScope,
findCallableBindingInScope,
findClassBindingInScope,
findExportedDefByName,
@ -215,6 +216,25 @@ describe('walker helpers read bindingAugmentations', () => {
expect(findCallableBindingInScope(SCOPE, 'callMe', indexes)?.nodeId).toBe('callMe');
});
it('preserves augmentation provenance masked by a finalized binding', () => {
const moduleScope = scope(SCOPE);
const callable = def('callMe');
const finalized = { def: callable, origin: 'local' } as BindingRef;
const staticImport = {
def: callable,
origin: 'import',
visibility: 'static-member-import',
} as BindingRef;
const indexes = indexesForScopeLookup(moduleScope, new Map([['callMe', [staticImport]]]));
indexes.bindings.set(SCOPE, new Map([['callMe', [finalized]]]));
const candidates = findAllCallableBindingCandidatesInScope(SCOPE, 'callMe', indexes);
expect(candidates).toHaveLength(1);
expect(candidates[0]!.def).toBe(callable);
expect(candidates[0]!.bindings).toEqual([finalized, staticImport]);
});
it('findExportedDefByName finds callable refs that exist only in augmentations', () => {
const moduleScope = scope(SCOPE);
const callableRef = { def: def('fromAugmentation'), origin: 'import' } as BindingRef;