mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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
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:
parent
450cebc268
commit
d3d4fa31bb
18 changed files with 553 additions and 49 deletions
1
.github/workflows/ci-tests.yml
vendored
1
.github/workflows/ci-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
18
gitnexus/test/fixtures/lang-resolution/csharp-using-static/App/NamespaceOwnerCollision.cs
vendored
Normal file
18
gitnexus/test/fixtures/lang-resolution/csharp-using-static/App/NamespaceOwnerCollision.cs
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
namespace First
|
||||
{
|
||||
public class NamespaceTwin
|
||||
{
|
||||
public void RejectOtherNamespaceOwner()
|
||||
{
|
||||
NamespaceCollision();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace Second
|
||||
{
|
||||
public class NamespaceTwin
|
||||
{
|
||||
public void NamespaceCollision() { }
|
||||
}
|
||||
}
|
||||
68
gitnexus/test/fixtures/lang-resolution/csharp-using-static/App/SameFileCases.cs
vendored
Normal file
68
gitnexus/test/fixtures/lang-resolution/csharp-using-static/App/SameFileCases.cs
vendored
Normal 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() { }
|
||||
}
|
||||
43
gitnexus/test/fixtures/lang-resolution/kotlin-instance-ownership/src/App.kt
vendored
Normal file
43
gitnexus/test/fixtures/lang-resolution/kotlin-instance-ownership/src/App.kt
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue