From d3d4fa31bb6bc017e20cdb714b0ad4622320f187 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:31:56 +0100 Subject: [PATCH] fix(scope-resolution): gate C#/Kotlin free calls by instance ownership (#2563) (#2654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/ci-tests.yml | 1 + gitnexus-shared/src/scope-resolution/types.ts | 5 + gitnexus/bench/scope-capture/baselines.json | 10 +- .../languages/csharp/namespace-siblings.ts | 6 +- .../languages/csharp/scope-resolver.ts | 1 + .../languages/kotlin/scope-resolver.ts | 1 + .../passes/free-call-fallback.ts | 137 +++++++++++++----- .../scope-resolution/scope/walkers.ts | 63 ++++++++ gitnexus/src/storage/repo-manager.ts | 6 +- .../expected-captures.json | 8 + .../App/NamespaceOwnerCollision.cs | 18 +++ .../csharp-using-static/App/SameFileCases.cs | 68 +++++++++ .../kotlin-instance-ownership/src/App.kt | 43 ++++++ ...tance-ownership-pipeline-benchmark.test.ts | 129 +++++++++++++++++ .../test/integration/resolvers/csharp.test.ts | 48 ++++++ .../test/integration/resolvers/kotlin.test.ts | 29 ++++ .../unit/call-summary-schema-version.test.ts | 9 +- .../walkers-augmentations.test.ts | 20 +++ 18 files changed, 553 insertions(+), 49 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-using-static/App/NamespaceOwnerCollision.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/csharp-using-static/App/SameFileCases.cs create mode 100644 gitnexus/test/fixtures/lang-resolution/kotlin-instance-ownership/src/App.kt create mode 100644 gitnexus/test/integration/instance-ownership-pipeline-benchmark.test.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 664ade48a..dd6eed93c 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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 diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 0956c3db4..ff9e07a05 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.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 ─────────────────────────────────────────────────────────── diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index be6cd6563..38cec9153 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -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." } } diff --git a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts index 5dab83afe..10aefb45e 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts @@ -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', + }); } } } diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index 19e4ae8e2..29b1a98ed 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index e8009fee1..7d3a78611 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -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) => { diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 2f0262410..4e32a4c4e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -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() : undefined; + const enclosingInstanceOwnerByScope = + options.freeCallsRequireInstanceOwnership === true + ? new Map() + : undefined; + const reachableInstanceOwnersByOwner = + options.freeCallsRequireInstanceOwnership === true + ? new Map>() + : 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([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>() + : 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, diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 1d371b925..d022092cc 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -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(); + 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(); + 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`. diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index e875415eb..e61baab49 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -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; diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json index dc05e7cdf..38ac8dd71 100644 --- a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -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" diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-using-static/App/NamespaceOwnerCollision.cs b/gitnexus/test/fixtures/lang-resolution/csharp-using-static/App/NamespaceOwnerCollision.cs new file mode 100644 index 000000000..a99850906 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-using-static/App/NamespaceOwnerCollision.cs @@ -0,0 +1,18 @@ +namespace First +{ + public class NamespaceTwin + { + public void RejectOtherNamespaceOwner() + { + NamespaceCollision(); + } + } +} + +namespace Second +{ + public class NamespaceTwin + { + public void NamespaceCollision() { } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/csharp-using-static/App/SameFileCases.cs b/gitnexus/test/fixtures/lang-resolution/csharp-using-static/App/SameFileCases.cs new file mode 100644 index 000000000..01293ff8e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/csharp-using-static/App/SameFileCases.cs @@ -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 lambda = () => LocalOnly(2); + } +} + +public partial class SameFilePartial +{ + public void CallAcrossFragment() + { + AcrossFragment(); + } +} + +public partial class SameFilePartial +{ + private void AcrossFragment() { } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-instance-ownership/src/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-instance-ownership/src/App.kt new file mode 100644 index 000000000..f60657757 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-instance-ownership/src/App.kt @@ -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() + } +} diff --git a/gitnexus/test/integration/instance-ownership-pipeline-benchmark.test.ts b/gitnexus/test/integration/instance-ownership-pipeline-benchmark.test.ts new file mode 100644 index 000000000..456b92858 --- /dev/null +++ b/gitnexus/test/integration/instance-ownership-pipeline-benchmark.test.ts @@ -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 { + 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); + } +}); diff --git a/gitnexus/test/integration/resolvers/csharp.test.ts b/gitnexus/test/integration/resolvers/csharp.test.ts index b4e49af0d..91945cc92 100644 --- a/gitnexus/test/integration/resolvers/csharp.test.ts +++ b/gitnexus/test/integration/resolvers/csharp.test.ts @@ -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', + ]); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 942bc374d..a0aa3cd90 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 163cd5c75..87256a2b2 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -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); }); }); diff --git a/gitnexus/test/unit/scope-resolution/walkers-augmentations.test.ts b/gitnexus/test/unit/scope-resolution/walkers-augmentations.test.ts index 21d48fc49..a4dbb0e5b 100644 --- a/gitnexus/test/unit/scope-resolution/walkers-augmentations.test.ts +++ b/gitnexus/test/unit/scope-resolution/walkers-augmentations.test.ts @@ -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;