From cdc98a9cf8f098aae5a217e273bc0af092d7f21d Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Thu, 13 Aug 2026 07:04:00 +0100 Subject: [PATCH 1/7] fix(java): capture enum interface heritage (#2935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(java): capture enum interface heritage * fix(java): harden enum heritage dispatch * test(java): refresh synthetic capture baselines --------- Co-authored-by: Gergő Magyar --- .../src/scope-resolution/symbol-definition.ts | 4 + gitnexus/bench/scope-capture/baselines.json | 6 +- .../languages/java/analysis-features.ts | 7 + .../core/ingestion/languages/java/captures.ts | 39 +++-- .../src/core/ingestion/scope-extractor.ts | 2 + .../passes/receiver-bound-calls.ts | 101 +++++++++--- gitnexus/src/core/run-analyze.ts | 6 +- gitnexus/src/storage/parse-cache.ts | 12 +- .../test/integration/resolvers/java.test.ts | 145 ++++++++++++++++++ gitnexus/test/unit/analysis-features.test.ts | 7 +- .../unit/incremental-orchestration.test.ts | 89 ++++++++++- .../test/unit/incremental-parse-cache.test.ts | 25 +-- gitnexus/test/unit/parsedfile-store.test.ts | 2 + .../java/java-captures.test.ts | 45 +++++- .../scope-resolution/scope-extractor.test.ts | 16 ++ 15 files changed, 448 insertions(+), 58 deletions(-) diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index 896b0dc04..e90b0be85 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -107,6 +107,10 @@ export interface SymbolDefinition { * Unavailable callables still participate in overload selection, but a * selected unavailable target must suppress edge emission. */ isDeleted?: boolean; + /** True when the declaration identity was synthesized rather than written in + * source (for example an anonymous class). Consumers may use this only as a + * conservative priority hint; it does not change graph-node identity. */ + isSynthetic?: boolean; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; /** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`) diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 724a6a29a..817029c5c 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -133,8 +133,9 @@ "_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." }, "java": { - "fingerprint": "36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5", + "fingerprint": "2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a", "scaling_budget": 1.5, + "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: synthesized Java anonymous classes and bodied enum constants now carry the presence-only @declaration.is-synthetic sidecar used to preserve source-written dispatch targets at the fanout cap. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE: the tag is attached to existing synthetic declaration matches; capture groups and fixture count remain 5755/18405, 3512, and 206. Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a; CI scaling 0.971 < 1.5.", "_rebaselined_2900_record_heritage": "#2900 review follow-up: the Java scale unit now includes a record implementing Marker, so the record-declaration @reference.inherits path is fingerprinted and exercised at scale. Prior b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663 -> 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5; scaling 1.042 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.", @@ -154,8 +155,9 @@ "fixture_count": 206 }, "java-local-types": { - "fingerprint": "8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633", + "fingerprint": "560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97", "scaling_budget": 1.5, + "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: the local-type stress corpus includes synthesized anonymous declarations, which now carry the presence-only @declaration.is-synthetic sidecar. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97; CI scaling 1.002 < 1.5.", "_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633." diff --git a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts index b85616602..17b153ab1 100644 --- a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts +++ b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts @@ -17,3 +17,10 @@ export const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor = { (filePath) => filePath.toLowerCase().endsWith('.java') || isSpringApplicationConfig(filePath), ), }; + +/** Durable completeness contract for Java heritage captures. */ +export const JAVA_ENUM_INTERFACE_HERITAGE_FEATURE: AnalysisFeatureDescriptor = { + id: 'java.heritage-captures', + version: 1, + appliesTo: (filePaths) => filePaths.some((filePath) => filePath.toLowerCase().endsWith('.java')), +}; diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index b71775534..3a4c131f2 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -424,6 +424,7 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture out.push({ '@declaration.class': nodeToCapture('@declaration.class', body), '@declaration.name': syntheticCapture('@declaration.name', body, identity.name), + '@declaration.is-synthetic': syntheticCapture('@declaration.is-synthetic', body, 'true'), }); // Inheritance: the anonymous class extends/implements its constructed @@ -485,6 +486,11 @@ function synthesizeJavaAnonymousClassDeclarations(rootNode: SyntaxNode): Capture out.push({ '@declaration.class': nodeToCapture('@declaration.class', bodyNode), '@declaration.name': syntheticCapture('@declaration.name', bodyNode, bodiedIdentity.name), + '@declaration.is-synthetic': syntheticCapture( + '@declaration.is-synthetic', + bodyNode, + 'true', + ), }); if (hostEnum !== undefined) { out.push({ @@ -636,12 +642,14 @@ function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null { * `emitCppInheritanceCaptures`). * * Scope covers `class_declaration` (`superclass` extends + `interfaces` - * implements clauses), `record_declaration` (`interfaces` implements clauses), - * and `interface_declaration` (`extends_interfaces` clauses). Interface + * implements clauses), `record_declaration` and `enum_declaration` + * (`interfaces` implements clauses), and `interface_declaration` + * (`extends_interfaces` clauses). Interface * inheritance was restored for registry-primary resolution in #1951. Record * graph nodes became canonical link targets in #2801 / PR #2871, so their - * `implements` clauses must participate for interface dispatch (#2900). Java - * enum interface heritage remains a separately tracked gap (#2918). + * `implements` clauses must participate for interface dispatch (#2900). + * Enums use the same tree-sitter `interfaces` field and participate as + * class-like `Enum` graph nodes (#2918). * * Generic bases (`extends Box`, `implements IFoo`) and qualified bases * (`a.b.Base`, `a.b.Box`, `a.b.IFoo`) are normalized to their simple @@ -664,9 +672,13 @@ function synthesizeJavaInheritanceReferences(root: SyntaxNode): CaptureMatch[] { for (const base of superclass.namedChildren) emitJavaInheritanceBase(out, base); } } - if (node.type === 'class_declaration' || node.type === 'record_declaration') { - // Records cannot declare a superclass; they share only the class - // `interfaces` arm. + if ( + node.type === 'class_declaration' || + node.type === 'record_declaration' || + node.type === 'enum_declaration' + ) { + // Records and enums cannot declare a superclass; all three declarations + // expose implemented interfaces through the same tree-sitter field. const interfaces = node.childForFieldName('interfaces'); if (interfaces !== null) { for (const typeList of interfaces.namedChildren) { @@ -724,15 +736,22 @@ function javaBaseSimpleNameOf(typeNode: SyntaxNode): string | undefined { function javaBaseLookupNameNode(node: SyntaxNode): SyntaxNode | null { switch (node.type) { case 'type_identifier': - return node; - case 'scoped_type_identifier': + return node.isMissing || node.text.length === 0 ? null : node; + case 'scoped_type_identifier': { // `java.io.Serializable` → trailing `type_identifier` (`Serializable`). - return node.lastNamedChild; + const tail = node.lastNamedChild; + return tail === null ? null : javaBaseLookupNameNode(tail); + } case 'generic_type': { // `Box` → recurse into the base type (`Box`). const first = node.firstNamedChild; return first === null ? null : javaBaseLookupNameNode(first); } + case 'annotated_type': { + // The final named child is the base type; preceding children are annotations. + const type = node.lastNamedChild; + return type === null ? null : javaBaseLookupNameNode(type); + } default: return null; } diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index fb9f07697..ab82342e8 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -701,6 +701,7 @@ function buildDefFromDeclarationMatch( const typeParameters = parseTypeParameterList(match['@declaration.type-parameters']?.text ?? ''); const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']); const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']); + const isSynthetic = parseBooleanCapture(match['@declaration.is-synthetic']); return { nodeId: makeDefId(filePath, anchor.range, type, nameCap.text), @@ -718,6 +719,7 @@ function buildDefFromDeclarationMatch( ...(templateConstraints !== undefined ? { templateConstraints } : {}), ...(isExplicit === true ? { isExplicit: true } : {}), ...(isDeleted === true ? { isDeleted: true } : {}), + ...(isSynthetic === true ? { isSynthetic: true } : {}), }; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index d6ea81b3c..8c34e8088 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -467,35 +467,85 @@ export function emitReceiverBoundCalls( if (subtypesBySupertypeDefId.get(ownerDef.nodeId) === undefined) return 0; // Collect concrete targets across the closure first, so the cap below counts - // real dispatch targets rather than types visited. - const targets: SymbolDefinition[] = []; - const seenTypes = new Set([ownerDef.nodeId]); - const queue: string[] = [ownerDef.nodeId]; - while (queue.length > 0) { - const superId = queue.shift() as string; - for (const subDef of subtypesBySupertypeDefId.get(superId) ?? []) { - if (seenTypes.has(subDef.nodeId)) continue; - seenTypes.add(subDef.nodeId); - queue.push(subDef.nodeId); - const implMember = pickOverload(subDef.nodeId, memberName, site, model, provider); + // real dispatch targets rather than types visited. Source-written owners + // rank ahead of synthesized owners so a large anonymous implementation + // family cannot consume the whole budget. Within each group, the priority + // counts concrete implementations already encountered on the path: the + // first implementation under an abstract branch ranks ahead of deeper + // overrides. Carrying that count through this existing walk avoids a reverse + // traversal per target at every call site. + type DispatchTarget = { + readonly member: SymbolDefinition; + readonly syntheticOwnerPriority: number; + readonly ancestorImplementationCount: number; + readonly discoveryOrder: number; + }; + type DispatchTraversal = { + readonly typeId: string; + readonly ancestorImplementationCount: number; + }; + const targetByMemberId = new Map(); + const bestIncomingCount = new Map([[ownerDef.nodeId, 0]]); + const queue: DispatchTraversal[] = [ + { typeId: ownerDef.nodeId, ancestorImplementationCount: 0 }, + ]; + let head = 0; + let discoveryOrder = 0; + while (head < queue.length) { + const current = queue[head++]!; + for (const subDef of subtypesBySupertypeDefId.get(current.typeId) ?? []) { + const previousIncomingCount = bestIncomingCount.get(subDef.nodeId); if ( - implMember === undefined || - implMember === OVERLOAD_AMBIGUOUS || - implMember.isDeleted === true + previousIncomingCount !== undefined && + previousIncomingCount <= current.ancestorImplementationCount ) { continue; } - if (implMember.nodeId === primaryMemberDef.nodeId) continue; - // A re-declared interface method or an `abstract` override is not an - // implementation — keep descending past it rather than emitting to it. - if (isDeclarationOnly(implMember)) continue; - // Nor is a static member: no instance-typed receiver can reach one, so - // an edge to it is a target dispatch cannot produce (#2842 review). - if (isUnreachableByInstanceDispatch(implMember)) continue; - targets.push(implMember); + bestIncomingCount.set(subDef.nodeId, current.ancestorImplementationCount); + + const implMember = pickOverload(subDef.nodeId, memberName, site, model, provider); + let descendantImplementationCount = current.ancestorImplementationCount; + if ( + implMember !== undefined && + implMember !== OVERLOAD_AMBIGUOUS && + implMember.isDeleted !== true && + implMember.nodeId !== primaryMemberDef.nodeId && + !isDeclarationOnly(implMember) && + !isUnreachableByInstanceDispatch(implMember) + ) { + const existing = targetByMemberId.get(implMember.nodeId); + const syntheticOwnerPriority = subDef.isSynthetic === true ? 1 : 0; + if ( + existing === undefined || + syntheticOwnerPriority < existing.syntheticOwnerPriority || + (syntheticOwnerPriority === existing.syntheticOwnerPriority && + current.ancestorImplementationCount < existing.ancestorImplementationCount) + ) { + targetByMemberId.set(implMember.nodeId, { + member: implMember, + syntheticOwnerPriority, + ancestorImplementationCount: current.ancestorImplementationCount, + discoveryOrder: existing?.discoveryOrder ?? discoveryOrder++, + }); + } + descendantImplementationCount++; + } + queue.push({ + typeId: subDef.nodeId, + ancestorImplementationCount: descendantImplementationCount, + }); } } + const targets = [...targetByMemberId.values()] + .sort( + (left, right) => + left.syntheticOwnerPriority - right.syntheticOwnerPriority || + left.ancestorImplementationCount - right.ancestorImplementationCount || + left.discoveryOrder - right.discoveryOrder, + ) + .map((target) => target.member); + // Bounded, and NEVER silently (#2829). An interface with a very large // implementor set multiplies edges by every call site — Go, TypeScript and // Kotlin do not set `collapseMemberCallsByCallerTarget`, so the product is @@ -505,8 +555,13 @@ export function emitReceiverBoundCalls( if (targets.length > MAX_INTERFACE_DISPATCH_FANOUT) { dispatchFanoutSkipped += targets.length - MAX_INTERFACE_DISPATCH_FANOUT; if (dispatchFanoutSkippedNames.length < MAX_REPORTED_SKIPPED_INTERFACES) { + const dropped = targets + .slice(MAX_INTERFACE_DISPATCH_FANOUT, MAX_INTERFACE_DISPATCH_FANOUT + 5) + .map((target) => target.qualifiedName ?? target.nodeId); + const omitted = targets.length - MAX_INTERFACE_DISPATCH_FANOUT - dropped.length; dispatchFanoutSkippedNames.push( - `${ownerDef.qualifiedName ?? ownerDef.nodeId}.${memberName} (${targets.length} targets)`, + `${ownerDef.qualifiedName ?? ownerDef.nodeId}.${memberName} (${targets.length} targets; ` + + `dropped: ${dropped.join(', ')}${omitted > 0 ? `, +${omitted} more` : ''})`, ); } targets.length = MAX_INTERFACE_DISPATCH_FANOUT; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index aae283798..2f64aa81a 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -172,7 +172,10 @@ import { SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, } from './ingestion/frameworks/spring/analysis-features.js'; -import { SPRING_CONFIG_BINDINGS_FEATURE } from './ingestion/languages/java/analysis-features.js'; +import { + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + SPRING_CONFIG_BINDINGS_FEATURE, +} from './ingestion/languages/java/analysis-features.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, findAnalysisFeatureMismatches, @@ -225,6 +228,7 @@ const ANALYSIS_FEATURES = [ SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, ] as const; interface PersistedFrameworkAnnotationRow { diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index f0df5a881..98dc0bf7b 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -467,7 +467,6 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // // Still open at this commit: #2891 also claims 59, which main now holds. That is // a live exact clash for #2891 to renumber, not for this branch. -// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. // // 60 -> 62 for the two optional `ParsedImport` fields the cycle-checker fix // adds: `typeOnly` (TS `import type`) and `runsOnlyWhenCalled` (an import @@ -503,10 +502,15 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // one, and the accessor definitions it materializes are not in this branch's // ParsedFile shape at all. // -// Note for whoever merges next: #2935 and #2840 BOTH claim 61, independently of -// this branch. That clash is still live and is theirs to resolve. +// 63 -> 64 for Java enum heritage plus annotated class, record, interface, +// enum, and explicit-super base names emitting corrected captures (#2918). +// Warm v63 ParsedFiles lack those captures and must be re-extracted. +// 64 -> 66 adds the synthetic-declaration sidecar used to keep anonymous class +// implementations from evicting ordinary implementors at the dispatch cap. +// This PR already published a v64 head, while #2936 uses 65 for its independent +// record accessor shape, so 66 keeps all three cached shapes distinct. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. -const SCHEMA_BUMP = 63; +const SCHEMA_BUMP = 66; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 44bfbc46f..9f84a1d10 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import path from 'path'; import fs from 'node:fs'; import os from 'node:os'; +import { _captureLogger, type PinoLogRecord } from '../../../src/core/logger.js'; import { FIXTURES, CROSS_FILE_FIXTURES, @@ -1341,6 +1342,150 @@ describe('Java record method resolution (#2564)', () => { }, 60000); }); +describe('Java enum interface heritage (#2918)', () => { + it('links and dispatches an Enum interface method (#2918)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-enum-heritage-')); + try { + writeFixtureRepo(root, { + 'EnumHeritage.java': `import java.lang.annotation.ElementType; + import java.lang.annotation.Target; + @Target(ElementType.TYPE_USE) @interface Marker {} + interface Named { String label(); } + enum Status implements @Marker Named { + ACTIVE; + public String label() { return "active"; } + } + class Reader { + String read(Named value) { return value.label(); } + }`, + }); + + const linked = await runPipelineFromRepo(root, () => {}); + const implementsEdges = getRelationships(linked, 'IMPLEMENTS').filter( + (edge) => edge.source === 'Status' && edge.target === 'Named', + ); + const fanout = getRelationships(linked, 'CALLS').filter( + (edge) => + edge.source === 'read' && + edge.target === 'label' && + edge.rel.reason === 'interface-dispatch', + ); + + expect(implementsEdges).toHaveLength(1); + expect(implementsEdges[0]?.sourceLabel).toBe('Enum'); + expect(implementsEdges[0]?.targetLabel).toBe('Interface'); + expect(fanout.map((edge) => edge.rel.targetId).sort()).toEqual([ + 'Method:EnumHeritage.java:Status.label#0', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + + it('keeps enum constant-body methods distinct while preserving enum heritage (#2918)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-enum-constant-body-')); + try { + writeFixtureRepo(root, { + 'EnumConstantBody.java': `interface Named { String label(); } + enum Status implements Named { + ACTIVE { public String label() { return "active"; } }, + INACTIVE; + public String label() { return "inactive"; } + } + class Reader { + String read(Named value) { return value.label(); } + }`, + }); + + const linked = await runPipelineFromRepo(root, () => {}); + const implementsEdges = getRelationships(linked, 'IMPLEMENTS').filter( + (edge) => edge.source === 'Status' && edge.target === 'Named', + ); + + expect(implementsEdges).toHaveLength(1); + expect(implementsEdges[0]?.sourceLabel).toBe('Enum'); + expect(getNodesByLabel(linked, 'Method').filter((name) => name === 'label')).toHaveLength(3); + const fanout = getRelationships(linked, 'CALLS').filter( + (edge) => + edge.source === 'read' && + edge.target === 'label' && + edge.rel.reason === 'interface-dispatch', + ); + expect(fanout.map((edge) => edge.rel.targetId).sort()).toEqual([ + 'Method:EnumConstantBody.java:Status$1.label#0', + 'Method:EnumConstantBody.java:Status.label#0', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + + it('keeps non-synthetic implementations ahead of abstract enum constant bodies at the cap', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-enum-fanout-cap-')); + try { + const constants = Array.from( + { length: 40 }, + (_, index) => `A${index} { public String label() { return "enum-${index}"; } }`, + ).join(',\n'); + const classes = Array.from( + { length: 30 }, + (_, index) => + `class ZImpl${index} extends ZBase { public String label() { return "class-${index}"; } }`, + ).join('\n'); + writeFixtureRepo(root, { + 'Fanout.java': `interface Named { String label(); } + enum AaaBig implements Named { + ${constants}; + public abstract String label(); + } + abstract class ZBase implements Named { public abstract String label(); } + ${classes} + class Reader { String read(Named value) { return value.label(); } }`, + }); + + const loggerCapture = _captureLogger(); + let linked: PipelineResult; + let logRecords: PinoLogRecord[]; + try { + linked = await runPipelineFromRepo(root, () => {}); + logRecords = loggerCapture.records(); + } finally { + loggerCapture.restore(); + } + const fanoutIds = getRelationships(linked, 'CALLS') + .filter( + (edge) => + edge.source === 'read' && + edge.target === 'label' && + edge.rel.reason === 'interface-dispatch', + ) + .map((edge) => edge.rel.targetId); + + expect(fanoutIds).toHaveLength(32); + for (let index = 0; index < 30; index++) { + expect(fanoutIds).toContain(`Method:Fanout.java:ZImpl${index}.label#0`); + } + expect(fanoutIds).toContain('Method:Fanout.java:AaaBig$1.label#0'); + expect(fanoutIds).toContain('Method:Fanout.java:AaaBig$2.label#0'); + + const warning = logRecords.find( + (record) => + record.msg === + 'interface-dispatch: members over the fan-out cap dropped implementors (their CALLS edges were not emitted)', + ); + expect(warning).toMatchObject({ + dispatchFanoutSkipped: 38, + fanoutCap: 32, + dispatchFanoutSkippedNames: [ + 'Named.label (70 targets; dropped: AaaBig$3.label, AaaBig$4.label, AaaBig$5.label, AaaBig$6.label, AaaBig$7.label, +33 more)', + ], + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); +}); + // --------------------------------------------------------------------------- // Java 16+ instanceof pattern variable: `if (obj instanceof User user)` // Phase 5.2: extractPatternBinding on instanceof_expression binds user → User. diff --git a/gitnexus/test/unit/analysis-features.test.ts b/gitnexus/test/unit/analysis-features.test.ts index 5e77834f4..7a91ee023 100644 --- a/gitnexus/test/unit/analysis-features.test.ts +++ b/gitnexus/test/unit/analysis-features.test.ts @@ -10,7 +10,10 @@ import { SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; -import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js'; +import { + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + SPRING_CONFIG_BINDINGS_FEATURE, +} from '../../src/core/ingestion/languages/java/analysis-features.js'; const FEATURES = [ CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, @@ -18,6 +21,7 @@ const FEATURES = [ SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, ] as const; describe('analysis feature versions', () => { @@ -27,6 +31,7 @@ describe('analysis feature versions', () => { }); expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({ 'graph.class-framework-annotations': 1, + 'java.heritage-captures': 1, 'spring.aop-advice': 1, 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index 466d30700..964b9420d 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -53,7 +53,10 @@ import { SPRING_AOP_EVIDENCE_ID_PREFIX, } from '../../src/core/ingestion/frameworks/spring/aop.js'; import { SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX } from '../../src/core/ingestion/frameworks/spring/auto-configuration.js'; -import { SPRING_CONFIG_BINDINGS_FEATURE } from '../../src/core/ingestion/languages/java/analysis-features.js'; +import { + JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + SPRING_CONFIG_BINDINGS_FEATURE, +} from '../../src/core/ingestion/languages/java/analysis-features.js'; const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-orch-'); @@ -96,6 +99,24 @@ async function setupSpringBeanIncrementalRepo() { return repo; } +async function setupJavaEnumHeritageIncrementalRepo() { + const repo = await createTempDir('gitnexus-incr-java-enum-heritage-'); + const src = path.join(repo.dbPath, 'src'); + await mkdir(src, { recursive: true }); + await writeFile( + path.join(src, 'Status.java'), + 'interface Named { String label(); }\n' + + 'enum Status implements Named {\n' + + ' ACTIVE;\n' + + ' public String label() { return "active"; }\n' + + '}\n', + 'utf-8', + ); + execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' }); + gitCommitAll(repo.dbPath, 'initial Java enum heritage'); + return repo; +} + async function setupKotlinSpringBeanIncrementalRepo() { const repo = await createTempDir('gitnexus-incr-spring-bean-kotlin-'); const src = path.join(repo.dbPath, 'src', 'com', 'other'); @@ -224,6 +245,35 @@ async function readSpringConfigPropertyNames(repoPath: string): Promise { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const { lbugPath } = getStoragePaths(repoPath); + await adapter.initLbug(lbugPath); + try { + const rows = (await adapter.executeQuery( + "MATCH (e:Enum {name: 'Status'})-[r:CodeRelation]->(i:Interface {name: 'Named'}) " + + "WHERE r.type = 'IMPLEMENTS' RETURN count(r) AS c", + )) as Array<{ c: number | bigint }>; + return Number(rows[0]?.c ?? 0); + } finally { + await adapter.closeLbug(); + } +} + +async function deleteStatusImplementsNamed(repoPath: string): Promise { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const { lbugPath } = getStoragePaths(repoPath); + await adapter.initLbug(lbugPath); + try { + await adapter.executeQuery( + "MATCH (e:Enum {name: 'Status'})-[r:CodeRelation]->(i:Interface {name: 'Named'}) " + + "WHERE r.type = 'IMPLEMENTS' DELETE r", + ); + } finally { + await adapter.closeLbug(); + } +} + /** * Direct count over INJECTS CodeRelation rows — mirrors pdg-mode-flip's * countBasicBlocks: reopen the repo DB, count, close (runFullAnalysis closes @@ -465,6 +515,43 @@ describe('runFullAnalysis — incremental orchestration', () => { } }, 300_000); + it('a Java index missing enum heritage evidence rebuilds before the fast path (#2918)', async () => { + const repo = await setupJavaEnumHeritageIncrementalRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + const { storagePath } = getStoragePaths(repo.dbPath); + const meta = await loadMeta(storagePath); + expect(meta!.analysisFeatures).toMatchObject({ + [JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id]: JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.version, + }); + expect(await countStatusImplementsNamed(repo.dbPath)).toBe(1); + + await deleteStatusImplementsNamed(repo.dbPath); + expect(await countStatusImplementsNamed(repo.dbPath)).toBe(0); + + await saveMeta( + storagePath, + withoutAnalysisFeature(meta!, JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id), + ); + const logs: string[] = []; + const reanalyzed = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true }, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(reanalyzed.alreadyUpToDate).toBeUndefined(); + expect(logs.join('\n')).toContain(`missing:${JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id}`); + expect((await loadMeta(storagePath))!.analysisFeatures).toMatchObject({ + [JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id]: JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.version, + }); + expect(await countStatusImplementsNamed(repo.dbPath)).toBe(1); + } finally { + await repo.cleanup(); + } + }, 300_000); + it('a same-commit index with NO fingerprint (pre-#2798) rebuilds once, not grandfathered', async () => { const repo = await setupMiniRepo(); try { diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 1901b1df2..e23114379 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -204,24 +204,25 @@ describe('PARSE_CACHE_VERSION', () => { // would take the untagged path, and `check --cycles` would keep reporting the // erased and deferred imports the branch exists to stop reporting: a silent // no-op on incremental analyze while every cold-run test passes. - // 63 rather than 62 or 61: main holds 60, #2935 claims 61, and #2936 claims 62 - // — the next free value above every in-flight MAXIMUM, not above origin/main. - // This branch staged 62 first and was correct when written; #2936 opened four - // hours later, re-checked against main rather than the in-flight claims, and - // took 62 as well. Moving instead of standing on seniority, because 63 is - // right whichever of the two merges first. - it('pins SCHEMA_BUMP to 63 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(63); + // Main subsequently advanced through 63. Values above it must remain distinct + // from both published branch heads and every active in-flight claim. + // Moved 63 -> 64 for Java enum and annotated heritage captures (#2918), + // then 64 -> 66 for the synthetic-declaration sidecar. #2936 uses 65 for + // its independent record-component accessor cache shape. + it('pins SCHEMA_BUMP to 66 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(66); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly // while being wrong, which is exactly how the 37/38 exact clashes landed. - // Every live neighbour is named: 60 is what origin/main holds, so a rebase - // that drops this branch's bump lands there; 61 is claimed by BOTH #2935 and - // #2840 (a live clash of their own); and 62 is #2936's claim, which is what - // this value moved off. + // Every nearby historical value is rejected: origin/main advanced through + // 63, while this branch already published 64 and #2936 uses 65. Pinning 66 + // and rejecting all prior values makes an accidental conflict resolution loud. expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(60); expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(61); expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(62); + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(63); + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(64); + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(65); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/parsedfile-store.test.ts b/gitnexus/test/unit/parsedfile-store.test.ts index 6c95f6ffd..6ce5cd252 100644 --- a/gitnexus/test/unit/parsedfile-store.test.ts +++ b/gitnexus/test/unit/parsedfile-store.test.ts @@ -406,6 +406,7 @@ describe('parsedfile-store', () => { filePath: 'a.c', type: 'Function', qualifiedName: 'fn', + isSynthetic: true, }; const pf = { filePath: 'a.c', @@ -445,6 +446,7 @@ describe('parsedfile-store', () => { filePath: 'a.c', type: 'Function', qualifiedName: 'fn', + isSynthetic: true, }); } finally { await rm(dir, { recursive: true, force: true }); diff --git a/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts index 84880fb6f..4576f8f24 100644 --- a/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts @@ -90,7 +90,7 @@ describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', () }); }); -describe('emitJavaScopeCaptures — record interface heritage (#2900)', () => { +describe('emitJavaScopeCaptures — record and enum interface heritage (#2900, #2918)', () => { it('captures simple, generic, and qualified record interfaces by lookup name', () => { const refs = inheritanceRefs( 'record User(int id) implements Named, Comparable, audit.Auditable {}', @@ -99,9 +99,41 @@ describe('emitJavaScopeCaptures — record interface heritage (#2900)', () => { expect(refs).toEqual(['Auditable', 'Comparable', 'Named']); }); - it('does not yet emit enum heritage (#2918)', () => { - // Delete this characterization when #2918 adds enum interface heritage. - expect(inheritanceRefs('enum Status implements Named { ACTIVE }')).toEqual([]); + it('captures simple, generic, and qualified enum interfaces by lookup name', () => { + const refs = inheritanceRefs( + 'enum Status implements Named, Tagged, audit.Auditable { ACTIVE }', + ); + + expect(refs).toEqual(['Auditable', 'Named', 'Tagged']); + }); + + it('unwraps type-use annotations on enum interface names', () => { + const refs = inheritanceRefs( + 'enum Status implements @Marker Named, @Marker Tagged, audit.@Marker Auditable { ACTIVE }', + ); + + expect(refs).toEqual(['Auditable', 'Named', 'Tagged']); + }); + + it.each([ + ['class extends', 'class Child extends @Marker Base {}', ['Base']], + ['class implements', 'class Child implements @Marker Named {}', ['Named']], + ['record implements', 'record Child(int id) implements @Marker Named {}', ['Named']], + ['interface extends', 'interface Child extends @Marker Named {}', ['Named']], + ])('unwraps type-use annotations for %s', (_label, source, expected) => { + expect(inheritanceRefs(source)).toEqual(expected); + }); + + it('does not emit an empty inheritance name from a torn annotated base', () => { + expect(inheritanceRefs('enum Status implements @Marker {')).toEqual([]); + }); + + it('preserves the enum constant-body link while adding enum interface heritage', () => { + expect( + inheritanceRefs( + 'enum Status implements Named { ACTIVE { public String label() { return "active"; } } }', + ), + ).toEqual(['Named', 'Status']); }); }); @@ -120,6 +152,11 @@ describe('emitJavaScopeCaptures — explicit constructor invocations (F38 #1928) expect(refs.some((r) => r.name === 'Box' && r.arity === '0')).toBe(true); }); + it('unwraps an annotated superclass for explicit `super(...)`', () => { + const refs = ctorRefs('class C extends @Marker Base { C() { super(); } }'); + expect(refs.some((r) => r.name === 'Base' && r.arity === '0')).toBe(true); + }); + it('captures `this(...)` as a constructor ref to the enclosing class name', () => { const src = 'class C { C() { this(1); } C(int x) {} }'; const refs = ctorRefs(src); diff --git a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts index c76ac6932..9e2ac3c4e 100644 --- a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts +++ b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts @@ -261,6 +261,22 @@ describe('Pass 2: declarations + local bindings', () => { expect(result.localDefs[0]!.type).toBe('Function'); }); + it('preserves a synthetic declaration marker on the definition', () => { + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + declMatch('class', 'Worker$1', 5, 0, 10, 0, { + '@declaration.is-synthetic': cap('@declaration.is-synthetic', 5, 0, 10, 0, 'true'), + }), + ], + 'a.ts', + mockProvider(), + ); + + expect(result.localDefs).toHaveLength(1); + expect(result.localDefs[0]!.isSynthetic).toBe(true); + }); + it('honors `provider.bindingScopeFor` to hoist a binding to an outer scope', () => { // Treat every declaration as hoisted to the module scope. const result = extract( From 56d9003fe3a8126705f2e61582615d0ac62d7949 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:15:26 +0100 Subject: [PATCH 2/7] chore(deps)(deps): bump react-dom and @types/react-dom in /gitnexus-web (#2944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom). These dependencies needed to be updated together. Updates `react-dom` from 19.2.7 to 19.2.8 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.2.8/packages/react-dom) Updates `@types/react-dom` from 19.2.3 to 19.2.4 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom) --- updated-dependencies: - dependency-name: react-dom dependency-version: 19.2.8 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: "@types/react-dom" dependency-version: 19.2.4 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus-web/package-lock.json | 24 ++++++++++++------------ gitnexus-web/package.json | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index b39351123..bec73f6f3 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -35,7 +35,7 @@ "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", "react": "^19.2.5", - "react-dom": "^19.2.7", + "react-dom": "^19.2.8", "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", @@ -55,7 +55,7 @@ "@types/dompurify": "^3.2.0", "@types/node": "^26.0.1", "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", + "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.8.23", "@vitejs/plugin-react": "^6.0.5", @@ -2589,9 +2589,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7393,24 +7393,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-i18next": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 1b4c721fc..60c32b7f4 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -45,7 +45,7 @@ "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", "react": "^19.2.5", - "react-dom": "^19.2.7", + "react-dom": "^19.2.8", "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", @@ -65,7 +65,7 @@ "@types/dompurify": "^3.2.0", "@types/node": "^26.0.1", "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", + "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.8.23", "@vitejs/plugin-react": "^6.0.5", From e679502b845207f9611753841cce25f605e40562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 13 Aug 2026 07:28:25 +0100 Subject: [PATCH 3/7] chore(deps): update brace-expansion and js-yaml versions in package-lock.json (#2952) Co-authored-by: Gergo Magyar --- package-lock.json | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7ea1b6692..99d1e2c4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -330,9 +330,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -411,9 +411,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -943,16 +943,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -1386,9 +1386,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1868,9 +1868,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { From 9fa39bad53ff69503f61b89cb1427370fe8c1343 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:28:40 +0100 Subject: [PATCH 4/7] chore(deps)(deps): bump lucide-react in /gitnexus-web (#2946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.23.0 to 1.28.0. - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.28.0/packages/lucide-react) --- updated-dependencies: - dependency-name: lucide-react dependency-version: 1.28.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus-web/package-lock.json | 8 ++++---- gitnexus-web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index bec73f6f3..a877b7be0 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -30,7 +30,7 @@ "i18next-browser-languagedetector": "^8.2.1", "langchain": "^1.5.4", "lru-cache": "^11.5.2", - "lucide-react": "^1.23.0", + "lucide-react": "^1.28.0", "mermaid": "^11.16.1", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", @@ -5715,9 +5715,9 @@ } }, "node_modules/lucide-react": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", - "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz", + "integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 60c32b7f4..493546aee 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -40,7 +40,7 @@ "i18next-browser-languagedetector": "^8.2.1", "langchain": "^1.5.4", "lru-cache": "^11.5.2", - "lucide-react": "^1.23.0", + "lucide-react": "^1.28.0", "mermaid": "^11.16.1", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", From 3d4a95360d04a5f49fbc5d49c0bae68383248557 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Thu, 13 Aug 2026 09:43:11 +0100 Subject: [PATCH 5/7] fix(java): materialize record component accessors (#2936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(java): materialize record component accessors * fix(java): ignore receiver params in record accessor arity * fix(java): address record-accessor review findings (#2917) Five findings from the tri-review of #2936. P1 — a synthesized callable evicted a source-written one from the method map. `getMethodInfo` keyed its per-class map by `name:line`, but a callable that is SYNTHESIZED at a position that is not its own declaration shares its owner's line: a record's implicit accessor is minted at the component, and a C# 12 primary constructor at the owner's `parameter_list`. Both are appended last by their extractor, so on a single line the synthesized entry overwrote the explicit method's MethodInfo and both definitions collapsed onto one id — `record P(int x, int y) { int x(int s) {...} }` lost `P.x#1` and rebound the arity-1 call to the zero-argument accessor. Adds a required `MethodInfo.column` and keys the map by `name:line:column` through a single `methodInfoKey` helper. Required, not optional: an absent column would key an entry no lookup could reach — a silent, whole-language loss of enrichment instead of a compile error. All three lookup sites move together; the file's own lockstep docblock warns that a half-applied change loses caller edges silently rather than dangling. This also fixes the same collision in C#, which never touched record code. Degenerate component names no longer mint a node. tree-sitter's zero-width MISSING recovery token satisfies `name: (identifier)`, so `record M(int x, y) {}` minted an empty-named Method whose returnType was the neighbouring `y`; and the grammar admits `underscore_pattern` in the same field, which the query rejected but the scope path accepted, so `record R(int _) {}` left a scope declaration with no node behind it. One `isRecordComponentName` predicate now gates all three emitters — query suppression, scope synthesis, and the method extractor — so they cannot drift apart again. Component annotations reach the implicit accessor (JLS 8.10.3 / 9.7.4) by reusing the shared `extractAnnotations` helper. Deliberately over-approximate and commented as such: `@Target` lives in another file and parsing is per-file. `explicitZeroArgAccessorNames` is memoised per record node. It was rebuilt on every component capture — O(components x body members) for one record, measured at ~4x per 2x input — while the scope path already hoisted the identical call. Docs: the `java-local-types` baseline now stores the `capture_groups_fp` its own note cites, the SCHEMA_BUMP ledger no longer claims a v65 that nothing holds, and `shouldSkipDefinitionCapture` documents that `defaultLabel` may be ignored. Scope-capture fingerprints are unchanged (`measure.mjs --check` PASS, 15 languages): the bench corpus contains no degenerate components, so the new predicate is inert on it. SCHEMA_BUMP stays 67 — this branch's existing claim already covers the changed worker output; re-check it against origin/main before merging. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * docs(ingestion): reunite the overload-suffix JSDoc with typeTagForId The block describing the `~type1,type2` same-arity discriminator was stranded above `buildCollisionGroups` when that function was inserted between it and the `typeTagForId` it documents (#658). Adding `methodInfoKey` in this branch parked it directly above yet another unrelated function, which gitnexus-check flagged. Moves the comment down to the function it describes. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB --------- Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 15 +- .../src/core/ingestion/language-provider.ts | 14 ++ gitnexus/src/core/ingestion/languages/java.ts | 9 +- .../languages/java/analysis-features.ts | 7 + .../core/ingestion/languages/java/captures.ts | 2 + .../languages/java/record-components.ts | 233 ++++++++++++++++++ .../method-extractors/configs/csharp.ts | 1 + .../ingestion/method-extractors/generic.ts | 1 + gitnexus/src/core/ingestion/method-types.ts | 16 ++ .../src/core/ingestion/tree-sitter-queries.ts | 11 + .../src/core/ingestion/utils/method-props.ts | 34 ++- .../core/ingestion/workers/parse-worker.ts | 25 +- gitnexus/src/core/run-analyze.ts | 2 + gitnexus/src/storage/parse-cache.ts | 15 +- .../test/integration/resolvers/java.test.ts | 85 ++++++- gitnexus/test/unit/analysis-features.test.ts | 3 + .../test/unit/incremental-parse-cache.test.ts | 16 +- gitnexus/test/unit/method-extraction.test.ts | 122 ++++++++- gitnexus/test/unit/method-props.test.ts | 1 + .../java/java-captures.test.ts | 89 +++++++ 20 files changed, 662 insertions(+), 39 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/java/record-components.ts diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 817029c5c..9ad4261db 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -133,9 +133,10 @@ "_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." }, "java": { - "fingerprint": "2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a", + "fingerprint": "79dafc369eaeb7183ee8cc1149b1a6c21ad672c7e5b806fe8b0060e5a952c79a", "scaling_budget": 1.5, "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: synthesized Java anonymous classes and bodied enum constants now carry the presence-only @declaration.is-synthetic sidecar used to preserve source-written dispatch targets at the fanout cap. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE: the tag is attached to existing synthetic declaration matches; capture groups and fixture count remain 5755/18405, 3512, and 206. Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a; CI scaling 0.971 < 1.5.", + "_rebaselined_2917_record_component_accessors": "#2917: every implicit Java record-component accessor now emits a component-bounded @scope.function plus @declaration.method/name/zero-arity/return-type metadata. The scope boundary prevents subsequent record-body references from being attributed to the accessor. Java was the only general language fingerprint to move; capture groups scale by exactly two per generated record component (small 5755 -> 6255, large 18405 -> 20005). Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 901a66c7dc0f071eeef9e4864b2519e5b58a1a141a1f9a7817ea42f7ff70eafb; scaling 0.961 < 1.5. Re-measured after merging origin/main, which carries #2935's is-synthetic sidecar on top of the same corpus: 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a -> 79dafc369eaeb7183ee8cc1149b1a6c21ad672c7e5b806fe8b0060e5a952c79a; scaling 1.085 < 1.5, capture groups 6255/20005, capture_groups_fp 3560, fixture_count 206 (unchanged by the merge).", "_rebaselined_2900_record_heritage": "#2900 review follow-up: the Java scale unit now includes a record implementing Marker, so the record-declaration @reference.inherits path is fingerprinted and exercised at scale. Prior b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663 -> 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5; scaling 1.042 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.", @@ -149,18 +150,20 @@ "_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.", "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9.", - "capture_groups_small": 5755, - "capture_groups_large": 18405, - "capture_groups_fp": 3512, + "capture_groups_small": 6255, + "capture_groups_large": 20005, + "capture_groups_fp": 3560, "fixture_count": 206 }, "java-local-types": { - "fingerprint": "560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97", + "fingerprint": "bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e", "scaling_budget": 1.5, "_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: the local-type stress corpus includes synthesized anonymous declarations, which now carry the presence-only @declaration.is-synthetic sidecar. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97; CI scaling 1.002 < 1.5.", + "_rebaselined_2917_record_component_accessors": "#2917: the focused local-type fixture corpus contains local records, so their implicit component accessors add the same bounded scope/declaration captures as the general Java corpus. No local-type naming logic changed. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 3e22f368a4ee139be7cb91ff4fb77ddadf60c55efe8d66955ec81f366a46e460; scaling 1.032 < 1.5, capture_groups_fp 680. Re-measured on top of #2935's is-synthetic sidecar after merging origin/main: 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97 -> bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e; scaling 0.997 < 1.5, capture_groups_fp 680.", "_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.", - "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633." + "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633.", + "capture_groups_fp": 680 }, "typescript": { "fingerprint": "f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f", diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index fdfc75839..109a2ea94 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -214,6 +214,20 @@ interface LanguageProviderConfig { * Default: undefined (standard label assignment). */ readonly labelOverride?: (functionNode: SyntaxNode, defaultLabel: NodeLabel) => NodeLabel | null; + /** + * Suppress a definition query match after its default label is known. + * Languages use this for syntax that represents an implicit declaration + * unless an explicit declaration with the same semantics is present. + * + * `defaultLabel` is supplied so an implementation can scope itself to one + * kind of definition; implementations whose capture map alone decides the + * question may ignore it. + */ + readonly shouldSkipDefinitionCapture?: ( + captureMap: CaptureMap, + defaultLabel: NodeLabel, + ) => boolean; + // ── MRO ─────────────────────────────────────────────────────────── /** MRO strategy for multiple inheritance resolution. * Default: 'first-wins'. */ diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 047a87791..317a853ac 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -23,14 +23,16 @@ import { createCallExtractor } from '../call-extractors/generic.js'; import { javaCallConfig } from '../call-extractors/configs/jvm.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { javaConfig } from '../field-extractors/configs/jvm.js'; -import { createMethodExtractor } from '../method-extractors/generic.js'; -import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { javaVariableConfig } from '../variable-extractors/configs/jvm.js'; import { createJavaCfgVisitor } from '../cfg/visitors/java.js'; import { assertCloneable } from '../workers/clone-safety.js'; import { collectJavaCaptureSideChannel } from './java/capture-side-channel.js'; import type { SymbolDefinition } from 'gitnexus-shared'; +import { + javaRecordMethodExtractor, + shouldSkipJavaRecordComponentDefinition, +} from './java/record-components.js'; import { emitJavaScopeCaptures, interpretJavaImport, @@ -186,7 +188,8 @@ export const javaProvider = defineLanguage({ mroStrategy: 'implements-split', callExtractor: createCallExtractor(javaCallConfig), fieldExtractor: createFieldExtractor(javaConfig), - methodExtractor: createMethodExtractor(javaMethodConfig), + methodExtractor: javaRecordMethodExtractor, + shouldSkipDefinitionCapture: shouldSkipJavaRecordComponentDefinition, variableExtractor: createVariableExtractor(javaVariableConfig), classExtractor: createClassExtractor(javaClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts index 17b153ab1..73969670d 100644 --- a/gitnexus/src/core/ingestion/languages/java/analysis-features.ts +++ b/gitnexus/src/core/ingestion/languages/java/analysis-features.ts @@ -18,6 +18,13 @@ export const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor = { ), }; +/** Durable completeness contract for implicit Java record-component accessors. */ +export const JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE: AnalysisFeatureDescriptor = { + id: 'java.record-component-accessors', + version: 1, + appliesTo: (filePaths) => filePaths.some((filePath) => filePath.toLowerCase().endsWith('.java')), +}; + /** Durable completeness contract for Java heritage captures. */ export const JAVA_ENUM_INTERFACE_HERITAGE_FEATURE: AnalysisFeatureDescriptor = { id: 'java.heritage-captures', diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 3a4c131f2..47c694b37 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -50,6 +50,7 @@ import { captureJavaSpringConditionalFacts, type JavaSpringConditionalFact, } from './spring-conditionals.js'; +import { synthesizeJavaRecordComponentAccessorCaptures } from './record-components.js'; /** Declaration anchors that carry function-like arity metadata. */ const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const; @@ -397,6 +398,7 @@ export function emitJavaScopeCaptures( ...synthesizeJavaInheritanceReferences(tree.rootNode), ...synthesizeJavaExplicitConstructorReferences(tree.rootNode), ...synthesizeJavaAnonymousClassDeclarations(tree.rootNode), + ...synthesizeJavaRecordComponentAccessorCaptures(tree.rootNode), ...synthesizeCallableFlowCaptures(tree.rootNode, JAVA_CALLABLE_CAPTURE_OPTIONS), ]; } diff --git a/gitnexus/src/core/ingestion/languages/java/record-components.ts b/gitnexus/src/core/ingestion/languages/java/record-components.ts new file mode 100644 index 000000000..51053510c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/java/record-components.ts @@ -0,0 +1,233 @@ +import { SupportedLanguages, type CaptureMatch } from 'gitnexus-shared'; +import type { CaptureMap } from '../../language-provider.js'; +import { createMethodExtractor } from '../../method-extractors/generic.js'; +import { javaMethodConfig } from '../../method-extractors/configs/jvm.js'; +import { extractAnnotations } from '../../field-extractors/configs/helpers.js'; +import type { + ExtractedMethods, + MethodExtractor, + MethodExtractorContext, + MethodInfo, +} from '../../method-types.js'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +const javaExplicitMethodExtractor = createMethodExtractor(javaMethodConfig); + +function recordComponents(recordNode: SyntaxNode): SyntaxNode[] { + const parameters = recordNode.childForFieldName('parameters'); + if (parameters === null) return []; + return parameters.namedChildren.filter( + (node): node is SyntaxNode => + node !== null && (node.type === 'formal_parameter' || node.type === 'spread_parameter'), + ); +} + +/** + * A record component is named by a real `identifier` and nothing else. + * + * Two node shapes reach this that are not one, and both would mint a graph node + * for source that does not compile: + * + * - `record M(int x, y) {}` — a dropped type. tree-sitter recovers by + * synthesizing `name: (MISSING identifier)`, a zero-width node whose text is + * `''`. It still satisfies the query's `name: (identifier)`, so testing the + * node TYPE alone does not reject it. + * - `record R(int _) {}` — the grammar declares both `formal_parameter.name` + * and `variable_declarator.name` as `identifier | underscore_pattern`, and + * `_` parses with no error at all. `_` is illegal as a component name, and + * admitting it here while the query rejects it is what let the structure and + * scope paths disagree. + * + * Same degenerate-node shape as `javaBaseLookupNameNode` in captures.ts (#2935). + */ +function isRecordComponentName(node: SyntaxNode | null | undefined): node is SyntaxNode { + return ( + node !== null && + node !== undefined && + node.type === 'identifier' && + !node.isMissing && + node.text.length > 0 + ); +} + +function recordComponentNameNode(component: SyntaxNode): SyntaxNode | null { + const name = + component.type === 'formal_parameter' + ? component.childForFieldName('name') + : (component.namedChildren + .find((node) => node?.type === 'variable_declarator') + ?.childForFieldName('name') ?? null); + return isRecordComponentName(name) ? name : null; +} + +/** + * Memoised per record node. `shouldSkipJavaRecordComponentDefinition` is called + * once per component capture, so recomputing this would rescan the whole record + * body per component — O(components x body members) for a single record. The + * scope-capture path hoists the call out of its own loop instead; this cache is + * what gives the structure path the same cost. Keyed weakly on the AST node, so + * it drops with the tree at the end of the file's parse. + */ +const explicitZeroArgAccessorNamesCache = new WeakMap>(); + +function explicitZeroArgAccessorNames(recordNode: SyntaxNode): Set { + const memoized = explicitZeroArgAccessorNamesCache.get(recordNode); + if (memoized !== undefined) return memoized; + const names = computeExplicitZeroArgAccessorNames(recordNode); + explicitZeroArgAccessorNamesCache.set(recordNode, names); + return names; +} + +function computeExplicitZeroArgAccessorNames(recordNode: SyntaxNode): Set { + const names = new Set(); + const body = recordNode.childForFieldName('body'); + if (body === null) return names; + + for (const node of body.namedChildren) { + if (node === null || node.type !== 'method_declaration') continue; + const name = node.childForFieldName('name')?.text; + const parameters = node.childForFieldName('parameters'); + const parameterCount = + parameters?.namedChildren.filter( + (parameter) => + parameter !== null && + (parameter.type === 'formal_parameter' || parameter.type === 'spread_parameter'), + ).length ?? 0; + if (name !== undefined && parameterCount === 0) names.add(name); + } + return names; +} + +function recordComponentReturnType(component: SyntaxNode): string | null { + const typeNode = + component.childForFieldName('type') ?? + (component.type === 'spread_parameter' + ? component.namedChildren.find( + (node) => node?.type !== 'modifiers' && node?.type !== 'variable_declarator', + ) + : undefined); + const type = typeNode?.text; + if (type === undefined) return null; + return component.type === 'spread_parameter' ? `${type}[]` : type; +} + +function implicitAccessorInfo( + component: SyntaxNode, + context: MethodExtractorContext, +): MethodInfo | null { + const name = recordComponentNameNode(component)?.text; + if (name === undefined) return null; + + return { + name, + receiverType: null, + returnType: recordComponentReturnType(component), + parameters: [], + visibility: 'public', + isStatic: false, + isAbstract: false, + isFinal: false, + // JLS 8.10.3 / 9.7.4: a component annotation reaches the generated accessor + // when its @Target admits METHOD (or TYPE_USE, in the return-type position). + // ponytail: over-approximate — we propagate every component annotation, + // because @Target lives in another file and parsing is per-file, so the + // target set is not knowable here. Nothing reads Method annotations today: + // `annotations` is not a column in METHOD_SCHEMA/FUNCTION_SCHEMA + // (src/core/lbug/schema.ts), so it lives only in the in-memory graph for one + // analyze run, and the sole in-memory reader (springDiFieldMatcher) is gated + // to `Property` nodes. If that column is ever added, revisit this: the set + // would then become an agent-visible claim that may over-state the target. + annotations: extractAnnotations(component, 'modifiers'), + sourceFile: context.filePath, + line: component.startPosition.row + 1, + column: component.startPosition.column, + }; +} + +/** Java records synthesize one public, zero-argument accessor per component. */ +export const javaRecordMethodExtractor: MethodExtractor = { + ...javaExplicitMethodExtractor, + language: SupportedLanguages.Java, + extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null { + const extracted = javaExplicitMethodExtractor.extract(node, context); + if (extracted === null || node.type !== 'record_declaration') return extracted; + + const explicitAccessors = explicitZeroArgAccessorNames(node); + const implicitAccessors = recordComponents(node) + .filter((component) => { + const name = recordComponentNameNode(component)?.text; + return name !== undefined && !explicitAccessors.has(name); + }) + .map((component) => implicitAccessorInfo(component, context)) + .filter((method): method is MethodInfo => method !== null); + + return { ...extracted, methods: [...extracted.methods, ...implicitAccessors] }; + }, +}; + +/** Scope declarations matching the structure-phase synthetic accessor nodes. */ +export function synthesizeJavaRecordComponentAccessorCaptures( + rootNode: SyntaxNode, +): CaptureMatch[] { + const captures: CaptureMatch[] = []; + for (const recordNode of rootNode.descendantsOfType('record_declaration')) { + const explicitAccessors = explicitZeroArgAccessorNames(recordNode); + for (const component of recordComponents(recordNode)) { + const nameNode = recordComponentNameNode(component); + const returnType = recordComponentReturnType(component); + if (nameNode === null || returnType === null || explicitAccessors.has(nameNode.text)) + continue; + + captures.push({ + '@scope.function': nodeToCapture('@scope.function', component), + }); + captures.push({ + '@declaration.method': nodeToCapture('@declaration.method', component), + '@declaration.name': nodeToCapture('@declaration.name', nameNode), + '@declaration.parameter-count': syntheticCapture( + '@declaration.parameter-count', + component, + '0', + ), + '@declaration.required-parameter-count': syntheticCapture( + '@declaration.required-parameter-count', + component, + '0', + ), + '@declaration.return-type': syntheticCapture( + '@declaration.return-type', + component, + returnType, + ), + }); + } + } + return captures; +} + +/** + * The structure query sees every record component. Suppress that synthetic + * definition when the record body provides the canonical zero-argument + * accessor explicitly, leaving the explicit method as the single authority. + */ +export function shouldSkipJavaRecordComponentDefinition(captureMap: CaptureMap): boolean { + const component = captureMap['definition.method']; + if (component?.type !== 'formal_parameter' && component?.type !== 'spread_parameter') { + return false; + } + + const parameters = component.parent; + const recordNode = parameters?.parent; + if (parameters?.type !== 'formal_parameters' || recordNode?.type !== 'record_declaration') { + return false; + } + + // Same predicate the scope path applies, so the two can never disagree about + // which components have an accessor. The query's `name: (identifier)` is + // satisfied by tree-sitter's zero-width MISSING recovery token, so the + // structure path has to re-check what the query cannot express. + const nameNode = captureMap['name']; + if (!isRecordComponentName(nameNode)) return true; + + return explicitZeroArgAccessorNames(recordNode).has(nameNode.text); +} diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts b/gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts index 4e87ddd89..c6550050f 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts @@ -316,6 +316,7 @@ export const csharpMethodConfig: MethodExtractionConfig = { annotations: [], // C# has no syntax for attributes on primary constructors sourceFile: context.filePath, line: paramList.startPosition.row + 1, + column: paramList.startPosition.column, }; }, }; diff --git a/gitnexus/src/core/ingestion/method-extractors/generic.ts b/gitnexus/src/core/ingestion/method-extractors/generic.ts index ecb25d939..e1a10f825 100644 --- a/gitnexus/src/core/ingestion/method-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/method-extractors/generic.ts @@ -256,5 +256,6 @@ function buildMethod( annotations: config.extractAnnotations?.(node) ?? [], sourceFile: context.filePath, line: node.startPosition.row + 1, + column: node.startPosition.column, }; } diff --git a/gitnexus/src/core/ingestion/method-types.ts b/gitnexus/src/core/ingestion/method-types.ts index d263fd5db..05659db02 100644 --- a/gitnexus/src/core/ingestion/method-types.ts +++ b/gitnexus/src/core/ingestion/method-types.ts @@ -37,6 +37,22 @@ export interface MethodInfo { annotations: string[]; sourceFile: string; line: number; + /** + * 0-based `startPosition.column` of the node `line` was derived from. + * + * `line` alone does NOT identify a callable. A callable that is SYNTHESIZED + * at a position that is not its own declaration shares its owner's line: a + * Java record's implicit component accessor is minted at the COMPONENT, and a + * C# 12 primary constructor at the owner's `parameter_list`. So both + * `record P(int x, int y) { int x(int s) {…} }` and + * `class Point(int x, int y) { public Point(int x) : this(x, 0) {} }` give two + * different callables the same (name, line) (#2936). + * + * Required, not optional: the per-class map in parse-worker keys on it, and + * an absent column would key an entry no lookup could ever reach — a silent, + * whole-language loss of method enrichment rather than a compile error. + */ + column: number; } export interface MethodExtractorContext { diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 0e56f7c7b..bd0ae6700 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1205,6 +1205,17 @@ export const JAVA_QUERIES = ` (record_declaration name: (identifier) @name) @definition.record (annotation_type_declaration name: (identifier) @name) @definition.annotation +; Canonical record-component accessors are implicit public zero-argument methods. +(record_declaration + parameters: (formal_parameters + (formal_parameter + name: (identifier) @name) @definition.method)) +(record_declaration + parameters: (formal_parameters + (spread_parameter + (variable_declarator + name: (identifier) @name)) @definition.method)) + ; Anonymous class bodies: new Runnable() { ... } — no @name capture; the ; class extractor synthesizes the javac-style Worker$N name (#2550) (object_creation_expression (class_body)) @definition.class diff --git a/gitnexus/src/core/ingestion/utils/method-props.ts b/gitnexus/src/core/ingestion/utils/method-props.ts index 5f8fa1a6b..4ade74c8a 100644 --- a/gitnexus/src/core/ingestion/utils/method-props.ts +++ b/gitnexus/src/core/ingestion/utils/method-props.ts @@ -17,11 +17,31 @@ export function arityForIdFromInfo(info: MethodInfo): number | undefined { } /** - * Compute a type-based discriminator suffix for same-arity overloads. - * Returns `~type1,type2` when the current method collides with another method - * in the same class that has the same name and arity but different parameter types. - * Returns `''` when there is no collision or types are unavailable. + * Key for the per-class method map built by `getMethodInfo` (parse-worker). + * + * `name:line` is NOT unique. Two callables can start on the same line with the + * same name whenever one of them is SYNTHESIZED at a position that is not its + * own declaration: a Java record's implicit component accessor is minted at the + * component (`record P(int x, int y) { int x(int s) {…} }`), and a C# 12 primary + * constructor at the owner's `parameter_list` + * (`class Point(int x, int y) { public Point(int x) : this(x, 0) {} }`). Both are + * appended LAST by their extractor, so under a `name:line` key the synthesized + * entry silently destroyed the source-written method's MethodInfo and the two + * ids collapsed onto one node (#2936). + * + * `line` is 1-based and `column` is 0-based — deliberately, because this is a + * join key rather than a displayed location and both sides derive it from the + * same node. Do not "normalize" one half; the join is the only contract. + * + * The KEY is never parsed by any consumer — `buildCollisionGroups`, + * `typeTagForId` and `constTagForId` all iterate `.values()`. Keep it that way, + * and never insert one MethodInfo under two keys: those consumers would then + * count it twice and turn every singleton into a false collision group. */ +export function methodInfoKey(name: string, line: number, column: number): string { + return `${name}:${line}:${column}`; +} + /** * Build collision groups from a method map — groups methods by `name#arity`. * Call once per class, then pass to typeTagForId/constTagForId to avoid O(N²) scans. @@ -43,6 +63,12 @@ export function buildCollisionGroups( return groups; } +/** + * Compute a type-based discriminator suffix for same-arity overloads. + * Returns `~type1,type2` when the current method collides with another method + * in the same class that has the same name and arity but different parameter types. + * Returns `''` when there is no collision or types are unavailable. + */ export function typeTagForId( methodMap: Map, methodName: string, diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index f139c2be1..d9fb0fd67 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -133,6 +133,7 @@ import { constTagForId, buildCollisionGroups, parameterShapeIdTag, + methodInfoKey, } from '../utils/method-props.js'; import { extractTemplateArguments, @@ -739,9 +740,12 @@ const methodInfoCache = new Map>(); /** * Get (or extract and cache) method info for a class node. - * Returns a "name:line" → MethodInfo map, or undefined if the provider has no method extractor - * or the class yielded no methods. - * Keyed by name:line (not name alone) to support overloaded methods in Java/Kotlin. + * Returns a "name:line:column" → MethodInfo map, or undefined if the provider has no method + * extractor or the class yielded no methods. + * Keyed by name:line:column (not name, and not name:line) to support overloaded methods in + * Java/Kotlin AND to keep a callable SYNTHESIZED at another node's position from evicting the + * source-written one that starts on the same line (#2936). Every lookup site MUST pass the + * column of the SAME node it takes the line from — see `methodInfoKey`. */ function getMethodInfo( classNode: SyntaxNode, @@ -759,7 +763,7 @@ function getMethodInfo( cached = new Map(); for (const method of result.methods) { - cached.set(`${method.name}:${method.line}`, method); + cached.set(methodInfoKey(method.name, method.line, method.column), method); } methodInfoCache.set(cacheKey, cached); return cached; @@ -996,7 +1000,9 @@ const findEnclosingFunctionId = ( language: encLang, }); const defLine = current.startPosition.row + 1; - const info = methodMap?.get(`${funcName}:${defLine}`); + const info = methodMap?.get( + methodInfoKey(funcName, defLine, current.startPosition.column), + ); if (info) { arity = info.parameters.some((p) => p.isVariadic) ? undefined @@ -1062,7 +1068,9 @@ const findEnclosingFunctionId = ( language: encLang2, }); const defLine2 = sigNode.startPosition.row + 1; - const info2 = methodMap2?.get(`${customResult.funcName}:${defLine2}`); + const info2 = methodMap2?.get( + methodInfoKey(customResult.funcName, defLine2, sigNode.startPosition.column), + ); if (info2) { arity2 = info2.parameters.some((p) => p.isVariadic) ? undefined @@ -2112,6 +2120,7 @@ const processFileGroup = ( const definitionNode = getDefinitionNodeFromCaptures(captureMap); const defaultNodeLabel = getLabelFromCaptures(captureMap, provider); if (!defaultNodeLabel) continue; + if (provider.shouldSkipDefinitionCapture?.(captureMap, defaultNodeLabel) === true) continue; const nameNode = captureMap['name']; const extractedClassSymbol = @@ -2562,7 +2571,9 @@ const processFileGroup = ( language, }); const defLine = definitionNode.startPosition.row + 1; - const info = methodMap?.get(`${nodeName}:${defLine}`); + const info = methodMap?.get( + methodInfoKey(nodeName, defLine, definitionNode.startPosition.column), + ); if (info) { enrichedByMethodExtractor = true; arityForId = arityForIdFromInfo(info); diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 2f64aa81a..d60c731fd 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -174,6 +174,7 @@ import { } from './ingestion/frameworks/spring/analysis-features.js'; import { JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, } from './ingestion/languages/java/analysis-features.js'; import { @@ -229,6 +230,7 @@ const ANALYSIS_FEATURES = [ SPRING_CONDITIONALS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, ] as const; interface PersistedFrameworkAnnotationRow { diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 98dc0bf7b..4548385ef 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -507,10 +507,19 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // Warm v63 ParsedFiles lack those captures and must be re-extracted. // 64 -> 66 adds the synthetic-declaration sidecar used to keep anonymous class // implementations from evicting ordinary implementors at the dispatch cap. -// This PR already published a v64 head, while #2936 uses 65 for its independent -// record accessor shape, so 66 keeps all three cached shapes distinct. +// That PR published a v64 head first, so 66 kept all the shapes in flight at the +// time distinct. (It also named a v65 claim from this branch; that claim was +// superseded before either landed — see the 66 -> 67 entry below. Nothing holds +// 65 now.) +// +// 66 -> 67 for #2917's implicit Java record-component accessor definitions and +// scope declarations. A warm cache would otherwise replay ParsedFiles without +// the synthesized accessors. This branch staged 65 before #2918's 66 landed on +// main; 67 is the next free value above every in-flight claim (main 66, #2939's +// 64), which is the ledger rule above — re-check against the claims, not just +// against main. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. -const SCHEMA_BUMP = 66; +const SCHEMA_BUMP = 67; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 9f84a1d10..5fada9ce6 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -1247,6 +1247,31 @@ describe('Java record method resolution (#2564)', () => { expect(sumCall).toBeDefined(); }); + // #2936: the implicit accessor is minted at the COMPONENT's position, so on a + // single line it shares (name, line) with an explicit overload. The worker's + // per-class method map keyed on that pair, so the appended implicit entry + // evicted the source-written method and both definitions collapsed onto + // `Scaled.x#0` — the arity-1 call then bound to a zero-argument target. + it('keeps a same-line explicit overload distinct from the implicit accessor (#2936)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-sameline-')); + try { + writeFixtureRepo(root, { + 'Scaled.java': + 'package probe;\npublic record Scaled(int x, int y) { int x(int factor) { return x * factor; } }\n', + }); + + const linked = await runPipelineFromRepo(root, () => {}); + const arities = getNodesByLabelFull(linked, 'Method') + .filter((node) => node.name === 'x') + .map((node) => Number(node.properties.parameterCount)) + .sort((left, right) => left - right); + + expect(arities).toEqual([0, 1]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, 60000); + it('uses the Record node as a caller source and constructor-call target (#2801)', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-link-')); try { @@ -1309,15 +1334,19 @@ describe('Java record method resolution (#2564)', () => { } }, 60000); - it('documents missing dispatch to an implicit Record component accessor (#2917)', async () => { + it('materializes implicit accessors and dispatches them through a Record interface (#2917)', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-accessor-')); try { writeFixtureRepo(root, { - 'RecordAccessor.java': `interface Named { String name(); } - record User(String name) implements Named {} - class Reader { - String read(Named value) { return value.name(); } - }`, + 'Named.java': 'interface Named { String name(); }', + 'User.java': 'record User(String name, java.util.List tags) implements Named {}', + 'Explicit.java': `record Explicit(String name) implements Named { + public String name() { return name.toUpperCase(); } + }`, + 'Reader.java': `class Reader { + String read(Named value) { return value.name(); } + java.util.List directTags(User value) { return value.tags(); } + }`, }); const linked = await runPipelineFromRepo(root, () => {}); @@ -1331,11 +1360,49 @@ describe('Java record method resolution (#2564)', () => { edge.rel.reason === 'interface-dispatch', ); + const methods = getNodesByLabelFull(linked, 'Method'); + const userName = methods.find( + (method) => method.name === 'name' && method.properties.filePath.endsWith('User.java'), + ); + const userTags = methods.find( + (method) => method.name === 'tags' && method.properties.filePath.endsWith('User.java'), + ); + const explicitNames = methods.filter( + (method) => method.name === 'name' && method.properties.filePath.endsWith('Explicit.java'), + ); + const userHasMethod = getRelationships(linked, 'HAS_METHOD').filter( + (edge) => edge.source === 'User' && (edge.target === 'name' || edge.target === 'tags'), + ); + const methodImplements = getRelationships(linked, 'METHOD_IMPLEMENTS').filter( + (edge) => edge.source === 'name' && edge.target === 'name', + ); + const directTags = getRelationships(linked, 'CALLS').find( + (edge) => edge.source === 'directTags' && edge.target === 'tags', + ); + expect(implementsEdge?.sourceLabel).toBe('Record'); expect(implementsEdge?.targetLabel).toBe('Interface'); - // TODO(#2917): implicit component accessors are not Method nodes yet. - // Replace this characterization with the expected User.name target. - expect(fanout).toEqual([]); + expect(userName?.properties).toMatchObject({ + parameterCount: 0, + returnType: 'String', + visibility: 'public', + }); + expect(userTags?.properties).toMatchObject({ + parameterCount: 0, + returnType: 'java.util.List', + visibility: 'public', + }); + expect(explicitNames).toHaveLength(1); + expect(userHasMethod.map((edge) => edge.target).sort()).toEqual(['name', 'tags']); + expect(methodImplements.map((edge) => edge.sourceFilePath).sort()).toEqual([ + expect.stringContaining('Explicit.java'), + expect.stringContaining('User.java'), + ]); + expect(fanout.map((edge) => edge.targetFilePath).sort()).toEqual([ + expect.stringContaining('Explicit.java'), + expect.stringContaining('User.java'), + ]); + expect(directTags?.targetFilePath).toContain('User.java'); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/gitnexus/test/unit/analysis-features.test.ts b/gitnexus/test/unit/analysis-features.test.ts index 7a91ee023..43e36d5de 100644 --- a/gitnexus/test/unit/analysis-features.test.ts +++ b/gitnexus/test/unit/analysis-features.test.ts @@ -12,6 +12,7 @@ import { } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; import { JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, } from '../../src/core/ingestion/languages/java/analysis-features.js'; @@ -22,6 +23,7 @@ const FEATURES = [ SPRING_CONDITIONALS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, + JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, ] as const; describe('analysis feature versions', () => { @@ -32,6 +34,7 @@ describe('analysis feature versions', () => { expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({ 'graph.class-framework-annotations': 1, 'java.heritage-captures': 1, + 'java.record-component-accessors': 1, 'spring.aop-advice': 1, 'spring.bean-inventory': 2, 'spring.conditionals-auto-configuration': 1, diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index e23114379..ac5223a59 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -207,22 +207,26 @@ describe('PARSE_CACHE_VERSION', () => { // Main subsequently advanced through 63. Values above it must remain distinct // from both published branch heads and every active in-flight claim. // Moved 63 -> 64 for Java enum and annotated heritage captures (#2918), - // then 64 -> 66 for the synthetic-declaration sidecar. #2936 uses 65 for - // its independent record-component accessor cache shape. - it('pins SCHEMA_BUMP to 66 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(66); + // then 64 -> 66 for the synthetic-declaration sidecar, both now on main. + // Moved 66 -> 67 for #2917's implicit Java record-component accessor + // definitions and scope declarations. This branch staged 65 before #2918's 66 + // landed; 67 is the next free value above every in-flight claim (main 66, + // #2939's 64), re-checked against the claims rather than against main alone. + it('pins SCHEMA_BUMP to 67 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(67); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly // while being wrong, which is exactly how the 37/38 exact clashes landed. // Every nearby historical value is rejected: origin/main advanced through - // 63, while this branch already published 64 and #2936 uses 65. Pinning 66 - // and rejecting all prior values makes an accidental conflict resolution loud. + // 66, and this branch previously published 65. Pinning 67 and rejecting all + // prior values makes an accidental conflict resolution loud. expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(60); expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(61); expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(62); expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(63); expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(64); expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(65); + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(66); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/method-extraction.test.ts b/gitnexus/test/unit/method-extraction.test.ts index 06711a958..7f9a83c43 100644 --- a/gitnexus/test/unit/method-extraction.test.ts +++ b/gitnexus/test/unit/method-extraction.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { createMethodExtractor } from '../../src/core/ingestion/method-extractors/generic.js'; +import { javaRecordMethodExtractor } from '../../src/core/ingestion/languages/java/record-components.js'; import { javaMethodConfig, kotlinMethodConfig, @@ -18,6 +19,7 @@ import { phpMethodConfig } from '../../src/core/ingestion/method-extractors/conf import { swiftMethodConfig } from '../../src/core/ingestion/method-extractors/configs/swift.js'; import { goMethodConfig } from '../../src/core/ingestion/method-extractors/configs/go.js'; import type { MethodExtractorContext } from '../../src/core/ingestion/method-types.js'; +import { methodInfoKey } from '../../src/core/ingestion/utils/method-props.js'; import Parser from 'tree-sitter'; import Java from 'tree-sitter-java'; import Go from 'tree-sitter-go'; @@ -98,7 +100,7 @@ const csharpCtx: MethodExtractorContext = { // --------------------------------------------------------------------------- describe('Java MethodExtractor', () => { - const extractor = createMethodExtractor(javaMethodConfig); + const extractor = javaRecordMethodExtractor; describe('isTypeDeclaration', () => { it('recognizes class_declaration', () => { @@ -404,6 +406,124 @@ describe('Java MethodExtractor', () => { expect(ctor!.parameters[0].name).toBe('x'); expect(ctor!.parameters[1].name).toBe('y'); }); + + it('synthesizes public zero-argument accessors with full component return types', () => { + const tree = parseJava('public record User(String name, java.util.List tags) {}'); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + + expect(result!.methods).toHaveLength(2); + expect(result!.methods).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'name', + returnType: 'String', + parameters: [], + visibility: 'public', + }), + expect.objectContaining({ + name: 'tags', + returnType: 'java.util.List', + parameters: [], + visibility: 'public', + }), + ]), + ); + }); + + it('exposes a varargs component through its array-typed accessor', () => { + const tree = parseJava('public record Samples(String... values) {}'); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + + expect(result!.methods).toContainEqual( + expect.objectContaining({ + name: 'values', + returnType: 'String[]', + parameters: [], + }), + ); + }); + + it('keeps an explicit canonical accessor as the single definition', () => { + const tree = parseJava(` + public record User(String name) { + public String name(/* canonical accessor */) { return name.toUpperCase(); } + } + `); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + const accessors = result!.methods.filter((method) => method.name === 'name'); + + expect(accessors).toHaveLength(1); + expect(accessors[0].line).toBe(3); + }); + + it('does not count an explicit accessor receiver parameter toward arity', () => { + const tree = parseJava(` + public record User(String name) { + public String name(User this) { return name.toUpperCase(); } + } + `); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + const accessors = result!.methods.filter((method) => method.name === 'name'); + + expect(accessors).toHaveLength(1); + expect(accessors[0].parameters).toEqual([]); + expect(accessors[0].line).toBe(3); + }); + + it('retains an explicit overload alongside the implicit accessor', () => { + const tree = parseJava(` + public record User(String name) { + public String name(int repeat) { return name.repeat(repeat); } + } + `); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + const accessors = result!.methods.filter((method) => method.name === 'name'); + + expect(accessors).toHaveLength(2); + expect(accessors.map((method) => method.parameters.length).sort()).toEqual([0, 1]); + }); + + // #2936: the accessor is minted at the COMPONENT's position, so on a single + // line it shares (name, line) with an explicit overload. The worker's + // per-class map keys on that pair, so before `column` the appended implicit + // entry evicted the source-written method and both ids collapsed to `x#0`. + it('gives a same-line implicit accessor and explicit overload distinct map keys', () => { + const tree = parseJava( + 'public record User(String name) { public String name(int repeat) { return name.repeat(repeat); } }', + ); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + const keys = result!.methods + .filter((method) => method.name === 'name') + .map((method) => methodInfoKey(method.name, method.line, method.column)) + .sort(); + + expect(keys).toEqual(['name:1:19', 'name:1:34']); + }); + + it.each([ + ['a dropped component type', 'record M(int x, y) {}', ['x']], + ['a nameless varargs component', 'record W(int... ) {}', []], + ['an underscore component', 'record R(int _) {}', []], + ['an underscore varargs component', 'record S(int... _) {}', []], + ])('synthesizes no accessor for %s', (_label, source, expected) => { + const tree = parseJava(source); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + + expect(result!.methods.map((method) => method.name)).toEqual(expected); + }); + + it.each([ + ['a marker annotation', 'record U(@Marker String name) {}', ['@Marker']], + ['an annotation with arguments', 'record U(@Marker("x") String name) {}', ['@Marker']], + ['several annotations', 'record U(@A @B String name) {}', ['@A', '@B']], + ['an annotated varargs component', 'record U(@A String... xs) {}', ['@A']], + ['no annotation', 'record U(String name) {}', []], + ])('propagates %s to the implicit accessor', (_label, source, expected) => { + const tree = parseJava(source); + const result = extractor.extract(tree.rootNode.child(0)!, javaCtx); + + expect(result!.methods[0]!.annotations).toEqual(expected); + }); }); describe('extract primitive varargs', () => { diff --git a/gitnexus/test/unit/method-props.test.ts b/gitnexus/test/unit/method-props.test.ts index cf2fb11ec..cd1be05e0 100644 --- a/gitnexus/test/unit/method-props.test.ts +++ b/gitnexus/test/unit/method-props.test.ts @@ -30,6 +30,7 @@ function makeMethodInfo( annotations: [], sourceFile: 'test.java', line: 1, + column: 0, ...overrides, }; } diff --git a/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts index 4576f8f24..990ea69de 100644 --- a/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts @@ -37,6 +37,17 @@ function inheritanceRefs(src: string): string[] { .sort(); } +function recordAccessorDeclarations(src: string) { + return emitJavaScopeCaptures(src, 'C.java') + .filter((m) => m['@declaration.method'] !== undefined) + .map((m) => ({ + name: m['@declaration.name']?.text, + arity: m['@declaration.parameter-count']?.text, + requiredArity: m['@declaration.required-parameter-count']?.text, + returnType: m['@declaration.return-type']?.text, + })); +} + describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', () => { it('binds the simple name for an unqualified `new User()`', () => { const refs = ctorRefs(wrapExpr('new User()')); @@ -137,6 +148,84 @@ describe('emitJavaScopeCaptures — record and enum interface heritage (#2900, # }); }); +describe('emitJavaScopeCaptures — record component accessors (#2917)', () => { + it('synthesizes zero-argument declarations with full generic return types', () => { + expect( + recordAccessorDeclarations('record User(String name, java.util.List tags) {}'), + ).toEqual([ + { name: 'name', arity: '0', requiredArity: '0', returnType: 'String' }, + { + name: 'tags', + arity: '0', + requiredArity: '0', + returnType: 'java.util.List', + }, + ]); + }); + + it('uses the array return type for a varargs component accessor', () => { + expect(recordAccessorDeclarations('record Samples(String... values) {}')).toEqual([ + { name: 'values', arity: '0', requiredArity: '0', returnType: 'String[]' }, + ]); + }); + + it('does not duplicate an explicit canonical accessor', () => { + const declarations = recordAccessorDeclarations( + 'record User(String name) { public String name(/* canonical */) { return name; } }', + ); + + expect(declarations.filter((declaration) => declaration.name === 'name')).toHaveLength(1); + }); + + it('does not count an explicit accessor receiver parameter toward arity', () => { + const declarations = recordAccessorDeclarations( + 'record User(String name) { public String name(User this) { return name; } }', + ); + + expect(declarations.filter((declaration) => declaration.name === 'name')).toEqual([ + expect.objectContaining({ arity: '0', requiredArity: '0' }), + ]); + }); + + it('keeps an overload alongside the implicit zero-argument accessor', () => { + const declarations = recordAccessorDeclarations( + 'record User(String name) { public String name(int repeat) { return name; } }', + ).filter((declaration) => declaration.name === 'name'); + + expect(declarations.map((declaration) => declaration.arity).sort()).toEqual(['0', '1']); + }); + + // A component is named by a real `identifier` and nothing else. tree-sitter's + // zero-width MISSING recovery token satisfies `name: (identifier)`, and the + // grammar admits `underscore_pattern` in the same field, so both would mint an + // accessor for source that does not compile. + it.each([ + ['a dropped component type', 'record M(int x, y) {}', ['x']], + ['a nameless varargs component', 'record W(int... ) {}', []], + ['an underscore component', 'record R(int _) {}', []], + ['an underscore varargs component', 'record S(int... _) {}', []], + ])('emits no accessor declaration for %s', (_label, source, expected) => { + expect(recordAccessorDeclarations(source).map((declaration) => declaration.name)).toEqual( + expected, + ); + }); + + it('emits no accessor scope for a component with no usable name', () => { + const scopes = emitJavaScopeCaptures('record M(int x, y) {}', 'C.java') + .filter((m) => m['@scope.function'] !== undefined) + .map((m) => m['@scope.function']?.text); + + expect(scopes).toEqual(['int x']); + }); + + it('is unaffected for a valid record', () => { + expect(recordAccessorDeclarations('record P(int x, String... ys) {}')).toEqual([ + { name: 'x', arity: '0', requiredArity: '0', returnType: 'int' }, + { name: 'ys', arity: '0', requiredArity: '0', returnType: 'String[]' }, + ]); + }); +}); + describe('emitJavaScopeCaptures — explicit constructor invocations (F38 #1928)', () => { it('captures `super(...)` as a constructor ref to the superclass simple name', () => { const src = 'class C extends pkg.Base { C() { super(1, 2); } }'; From 77360e1043e71ad9f31a5a625bb7695539709732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 13 Aug 2026 14:50:53 +0100 Subject: [PATCH 6/7] fix(scope-resolution): make interface dispatch generic-instantiation aware (#2912) (#2939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scope-resolution): make interface dispatch generic-instantiation aware (#2912) Interface-dispatch fan-out walked the subtype closure with generic arguments erased, so `IValidator` and `IValidator` — one declaration, one subtype list — were indistinguishable and a call through the first reached `IntValidator.Check(int)`, a target no runtime dispatch can produce. The arguments were already in the capture, unread: every language anchors `@reference.inherits` on the whole base node while `@reference.name` keeps the erased base. `ReferenceSite.typeArguments` is therefore derived generically in `scope-extractor.ts` from the anchor's own spelling — no per-language query changed — covering C#, Java, TypeScript, Kotlin, Go (`Base[int]` embedding), Python (`Base[User]`) and Swift; Rust and Dart anchor on the bare name and get nothing, which reads as "unknown". `preEmitInheritanceEdges` is the only code that pairs a heritage site with a resolved (subtype, supertype), so it records the instantiation there and hands it to the dispatch pass. The closure is then walked carrying a substitution, as a type checker would: `Wrapper : IValidator` binds T to the receiver's argument and stays reachable from every instantiation, while its own subtypes are matched against that binding. An incompatible hop is skipped without being marked seen, so a type reachable by a second, compatible path still gets its edge, and without descending, since its subtypes inherit the mismatch. Pruning happens only on positive evidence that two instantiations differ. Unknown arguments on either side, an arity that does not line up, an unresolved qualified spelling of the same simple name, or an argument that might be a type variable the language never captured all keep the target. Telling an uncaptured type VARIABLE from a concrete type is the crux: `typeParameters` is absent both for a non-generic declaration and for every declaration in a language whose query omits `@declaration.type-parameters`, so the pass reads the evidence in front of it — one run resolves one language, so a single generic declaration anywhere in it proves the captures record parameters. A language recording neither arguments nor parameters keeps exactly its pre-#2912 fan-out. Type arguments are compared as resolved declarations rather than spellings, so `Models.User` and an imported `User` are one type; the new optional `ScopeResolver.normalizeTypeArgument` hook canonicalizes a language's predefined aliases, implemented for C# (`string` ≡ `String`) where mixing the spellings would otherwise delete a real implementor. Fan-out cap, skipped-target reporting, overload selection and non-generic closure behaviour are unchanged. SCHEMA_BUMP 60 -> 64: the heritage arguments are a parse-time capture, so a warm cache would replay pre-fix sites and leave the filter silently inert on unchanged files (61/62/63 are claimed by open PRs). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef * fix(scope-resolution): close the two generic-dispatch gaps (#2912) The first commit left two shapes on the pre-#2912 fan-out. Both are now covered, and the second one turned out to need a route the pipeline did not have at all. **Folded receivers (Cases 0 and 3b).** `this._validator.Check(x)` is typed by the compound fold, and the fold answers with a CLASS — which is exactly what loses the instantiation, since `IValidator` and `IValidator` fold to one declaration. The fold now reports the SPELLING it typed each receiver position from, through a pure side channel (`recordReceiverType`) added to the one helper every declared-type route already shares plus the two return-type routes; resolution is unchanged whether or not a caller passes it. The reader keeps the last report and uses it only when it names the class the fold returned, so an intermediate position cannot lend its arguments to another class. This covers the dependency-injection shape the issue is really about — a field-held generic interface — and multi-hop chains, where it is the last hop's spelling that types the receiver. **Rust and Dart heritage.** Neither recorded arguments, for two different reasons, so both routes exist now: - Rust's `@reference.inherits` anchor is the trait identifier INSIDE a `generic_type`. Widening the anchor would move the site's range, and that range is part of every inheritance edge's id, so the arguments arrive through a new `@reference.type-arguments` sub-tag instead. - Dart's `implements` / `with` never become reference sites at all: they travel as heritage MARKERS and their edges are emitted by the language hook. The arguments ride the marker payload as an optional fourth field (dropped, not encoded, when the spelling contains the marker delimiter), and `ScopeResolver.emitHeritageEdges` now receives the same sink `preEmitInheritanceEdges` writes to, so whichever pass emits an edge records that edge's instantiation. Dart also gained the `@declaration.type-parameters` capture, without which its own type VARIABLES are indistinguishable from concrete arguments and `class Box implements Validator` would be pruned from every instantiation. Note this makes Rust and Dart record their instantiations; it does not make them fan out. Interface dispatch still fires only for a receiver whose folded type is an `Interface` symbol, so a Rust `Trait` or a Dart abstract `Class` receiver has no secondary targets to filter. Widening that gate emits new edges for several languages and belongs to its own issue. **Two matcher rules the wider coverage exposed.** A WILDCARD names a set of types rather than one — `Repo` holds a `Repo`, and Kotlin's `Repo<*>` / `Repo` say the same — so a position with one on either side is unknown; nullable spellings trip the same test, which costs a little precision in the safe direction. And insignificant whitespace inside a nested spelling (`Map` vs `Map`) is no longer a difference. One expectation changed in the #2833 field-receiver matrix: a `Repo>` receiver no longer reaches `UserRepo implements Repo`. That edge is precisely the false positive this issue is about, and the primary edge to the interface's own declaration — which is what the matrix row exists to prove — is untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef * refactor(scope-resolution): apply the quality pass to the #2912 change Three cleanups, no behaviour change. **One balanced-list scanner, not two.** `erasedTypeApplication` and `typeApplicationArguments` each carried a copy of the same fiddly scan — one bracket list, balanced, closing on the last character, non-empty — differing only in what they did with the result. Both now call `balancedTailList`; the rule that rejects `User[][]` and `Repo?` lives in one place instead of being free to drift between two. **The receiver's arguments are parsed after the gates, not before them.** `emitInterfaceDispatchFor` takes the receiver's declared SPELLING and parses it itself, once the owner is known to be an Interface with subtypes. Every one of the five cases calls it unconditionally and the overwhelming majority of receivers are concrete classes that return at the first line, so the parse was running per resolved receiver site to be discarded immediately. Case 4 and Case 6 now hand over the string they already hold, and the folded-receiver helper returns the recorded spelling rather than parsing it. **One question gates the whole instantiation apparatus.** Inside the closure walk, the graph-id lookups now hang off "is the supertype's instantiation known?" — false for every non-generic receiver and for every language that captures no heritage arguments, which is what makes those walks cost exactly what they cost before #2912. Also lifted the argument-route choice in `pass5CollectReferences` out of a nested ternary into a named `heritageTypeArguments`, where the reason the explicit sub-tag wins over the anchor text can be stated once. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01StNKYi7Qxv5DnSURZuFBef * test(scope-resolution): cover generic interface dispatch in Kotlin and Go (#2912) Extends the #2912 dispatch coverage past C#/Java/TypeScript. No production code changes — the derivation is language-agnostic by construction (`heritageTypeArguments` reads the heritage anchor's own spelling), so the question was only which languages actually reach the filter. Kotlin rides the shared heritage pre-pass; Go reaches the same filter from the other side, matching implementors structurally while the receiver's `Validator[string]` spelling carries the instantiation. Both are confirmed to prune the mismatched implementor. Each language gets a NON-GENERIC control asserting the fan-out still reaches every implementor. Without it the `not.toContain` assertion passes just as well when a language emits no dispatch edge at all — which is what Dart, Python and Rust were measured doing for this receiver shape, generic or not. They are deliberately not asserted on here: a "filtered correctly" test over a path that never fans out measures nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * test(bench): re-baseline the Rust and Dart capture fingerprints for #2912 The Rust trait-impl and Dart heritage capture changes this branch makes are additive TEXT on existing matches — each carries the instantiation the clause was written with — so they drift the scope-capture digest without adding or removing a match. The baselines were never re-measured when those captures landed, which left `measure.mjs --check` red on this branch independently of the merge. Re-measured rather than hand-edited. Rust's capture_groups_fp (3556) and fixture_count (202) are unchanged across the move, which is the evidence that this is digest drift and not a capture-set regression. The other 13 languages are byte-identical; 15/15 pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * refactor(scope-resolution): quality pass over the #2912 change Cleanup only — no behavior change. Findings from a four-angle review (reuse, simplification, efficiency, altitude), applied where they were verified. Reuse / duplication: * `stripTrailingCallSuffix` was a second copy of `matchingOpenParen`'s backward balanced-paren scan. Both now live in `template-arguments.ts` beside `balancedTailList`, for the reason that helper was shared in the first place: two copies of a scan this fiddly are free to disagree. * The two call-return arms of the compound fold repeated the same four-part expression character for character; they share `classOfReturnType` now, the return-type twin of `classOfDeclaredType`, which keeps the "look up by rawName, report the erased application" pairing in one place. * `pipeline/run.ts` implemented first-writer-wins twice — once in the pre-pass and once in the provider sink. One store, one sink, one rule; the pass keeps its `Set` return and the callable-flow-only arm stops building an empty map to satisfy a widened return shape. Simplification: * `subtypeParametersComplete` dropped a disjunct that could never decide: every `subDef` reaching it comes out of the same loop that sets `languageCapturesTypeParameters`, from exactly those defs. * The heritage-argument lookup asked "is the supertype's instantiation known?" three times; `superGraphId` now gates the block once. * `TypeArgumentResolver` and `HeritageInstantiationResult` un-exported — no consumer outside their module. Efficiency (all on the per-site dispatch walk): * `resolveSupertypeArgument` captures only the site, so it is built once per site instead of once per subtype visited; the subtype's scope id is looked up once per subtype instead of once per argument position. * `erasedTypeApplication` no longer runs on every fold hop through a call — the spelling is built only once the lookup has found a class, since it is discarded otherwise. * `normalize`+`compact` computed once per side rather than twice. * Regex literals and the identity `normalize` fallback hoisted to module scope. * C# `System.` prefix stripped with `startsWith`/`slice` instead of a regex. Verified: tsc clean, build clean, 1994 scope-resolution unit tests, 171 generic-dispatch + generic-field-receiver integration tests, 15/15 capture bench fingerprints unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * style: apply Prettier to the two files the quality pass reformatted Whitespace only — `quality / format` (npx prettier --check .) was red. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * fix(scope-resolution): close the generic-dispatch review findings (#2912) Addresses the gitnexus-check review on #2939. A repeated type variable was rebound rather than unified: `class C : Pair` accepted a `Pair` receiver, with `T = int` silently replacing `T = string` and the bogus substitution carried to the next hop. It now unifies, and prunes only on the same positive evidence the concrete path demands — an undecidable repeat keeps the target with no binding. A type PARAMETER of the declaration enclosing either side is now recognised and never compared. `subtypeParametersComplete` is evidence about the SUBTYPE's parameter list and says nothing about a `T` written at the call site, so `void Run(IValidator v) { v.Check(x); }` pruned every implementor: unbounded, `T` grounds to nothing; bounded, it grounds to its BOUND. Both read as a difference of type. That is the missing-edge failure this filter is built to avoid, and it is the common dependency-injection shape in C#, Java and Kotlin. Making that recognition reliable is why generic METHODS now capture `@declaration.type-parameters` in C#, Java and Kotlin — TypeScript already did, which is why its generic functions never had the defect. The capture feeds the existing `bindsTypeParameter` guard, so a method-level `T` also stops resolving to a same-named class in every other lookup. C# alias normalization additionally strips the `global::` qualifier, which `import-decomposer` already unwraps elsewhere: `global::System.String` read as unequal to `string` and pruned a live implementor. The C# captures golden fixture is regenerated for the new capture; the extractor reads `@declaration.type-parameters` generically, so no reader changed. SCHEMA_BUMP 64 already covers these capture changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * fix(scope-resolution): close the two remaining gitnexus-check findings (#2912) `balancedTailList` counted ONE bracket family, so a crossed pair slipped through: scanning `Foo` it never sees the `]`, reaches the final `>` at depth zero, and reports `Bar]` as a balanced argument list — which `typeApplicationArguments` then splits and `erasedTypeApplication` rebuilds a spelling from. It now tracks a stack of expected closers, so every closer must match the opener it actually closes and a crossed pair declines to `undefined`, the "unknown" both callers already fail open on. Well-formed mixed nesting (`List`) is unaffected. C# `normalizeTypeArgument` stripped `System.` from every qualified spelling, so `System.Custom` answered `Custom` and compared equal to an unrelated `Custom` elsewhere in the workspace. The strip is now earned: a keyword answers from the alias table first, and the qualifier is dropped only when what remains IS a predefined type. `System.Custom` is returned as written and goes to the identity comparison instead — the step that can actually tell two declarations apart. `global::System.String` still meets `string`. Both are pinned by unit tests, including the well-formed mixed nesting and the `global::`-qualified ordinary type that must keep its qualifier. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * docs(csharp): record why a shadowed `String` keeps its implementor (#2912) Answers a review finding rather than changing behavior. A workspace may declare its own type named `String`, shadowing the BCL simple name, and the alias table then reads `IValidator` as the `string` instantiation and keeps that implementor. That is the SAFE direction, not an oversight: pruning instead would rest on the belief that two spellings differ, which is the missing-edge failure `generic-instantiation.ts` exists to avoid. Resolving rather than normalizing cannot settle it either — the identity comparison needs a `definitionId` from both sides, and a built-in name carries none, so "built-in versus workspace-declared implies different" would be a new prune with no positive evidence behind it. The cost is one surplus edge for that pair, which is exactly the pre-#2912 fan-out and no worse. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB * refactor(scope-resolution): pair the receiver spelling with the class structurally (#2912) The fan-out needs the spelling a receiver position was typed from, because the class the fold returns has lost the generic arguments. That was carried by a PASS-LEVEL mutable holder, written by every declared-type lookup anywhere in the fold and read back through a def-id coincidence check, with the holder cleared by hand before each call site. Three things were load-bearing and none were enforced: * the reset had to be remembered at every call site. It was not: the Case 3b retry (`rawName` then `rawName + '()'`) reset once, BEFORE the first attempt, so a spelling reported by the attempt that failed could be attributed to the one that succeeded. * the holder outlived every resolution, so a site that resolved through a route reporting nothing could read the previous site's spelling if the def ids happened to line up. * the pairing itself was inferred from "whichever lookup reported last", not from the fold's own bookkeeping — losing branches (an MRO walk that moved on, a step later folded past) report too. `foldReceiverChain` already had the answer and threw it away: its final `FoldState` holds `def` and `declaredType` produced by the SAME step. It now reports that pairing last, so the structural route is the one that stands. `resolveCompoundReceiverTyped` returns `{def, declaredSpelling}` and owns a sink created and read within the single call, which is what removes the reset discipline — a local cannot be forgotten, and each of the two retry attempts carries its own. The def-id guard stays as the check that a report names the class actually returned. Behavior is unchanged: 1975 scope-resolution unit tests, 177 generic-dispatch and generic-field-receiver integration tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) --- ARCHITECTURE.md | 6 + .../src/scope-resolution/reference-site.ts | 22 + gitnexus/bench/scope-capture/baselines.json | 6 +- .../core/ingestion/languages/csharp/query.ts | 8 +- .../languages/csharp/scope-resolver.ts | 76 +++ .../core/ingestion/languages/dart/captures.ts | 27 +- .../core/ingestion/languages/dart/query.ts | 10 +- .../languages/dart/scope-resolver.ts | 18 +- .../core/ingestion/languages/java/query.ts | 6 + .../core/ingestion/languages/kotlin/query.ts | 6 + .../core/ingestion/languages/rust/captures.ts | 13 + .../languages/rust/scope-resolver.ts | 14 +- .../src/core/ingestion/scope-extractor.ts | 72 ++- .../contract/scope-resolver.ts | 31 ++ .../passes/compound-receiver.ts | 177 ++++++- .../passes/receiver-bound-calls.ts | 191 ++++++- .../scope-resolution/pipeline/run.ts | 51 +- .../utils/generic-instantiation.ts | 345 ++++++++++++ .../ingestion/utils/template-arguments.ts | 144 ++++- gitnexus/src/storage/parse-cache.ts | 16 +- .../expected-captures.json | 46 +- .../expected-captures.json | 2 +- .../generic-field-receiver-matrix.test.ts | 8 +- .../generic-interface-dispatch.test.ts | 492 ++++++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 22 +- .../generic-instantiation.test.ts | 367 +++++++++++++ .../heritage-type-arguments.test.ts | 177 +++++++ 27 files changed, 2252 insertions(+), 101 deletions(-) create mode 100644 gitnexus/src/core/ingestion/scope-resolution/utils/generic-instantiation.ts create mode 100644 gitnexus/test/integration/resolvers/generic-interface-dispatch.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/generic-instantiation.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/heritage-type-arguments.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 03e3730b3..138530d24 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -277,6 +277,12 @@ The solver is flow-insensitive but bounded: dependency-indexed work items rerun Property-key dispatch remains a separate conservative fallback. Its per-key fan-out cap is 32; capped keys synthesize no partial calls and are reported at warning level with language, skipped-key count, dropped key names (bounded), and cap; the count also travels in `RunScopeResolutionStats.propertyDispatchSkippedKeys`. +Interface-dispatch fan-out walks the subtype closure of the receiver's interface and is **generic-instantiation aware** (#2912): a call through `IValidator` must not reach an implementor of `IValidator`, which shares its declaration and therefore its subtype list. Each heritage clause's arguments reach resolution by one of three routes — read off the `@reference.inherits` anchor's own spelling where that anchor spans the whole base (most languages, no query change), through the `@reference.type-arguments` sub-tag where the anchor is the bare name and moving it would renumber inheritance edge ids (Rust `impl T for S`, Dart `extends`), or on a heritage MARKER payload for clauses that never become reference sites (Dart `implements`/`with`). Whichever pass emits the edge records the pair through one sink: `preEmitInheritanceEdges` for heritage clauses, `ScopeResolver.emitHeritageEdges` for the rest. + +The walk then carries a substitution: a subtype's own type parameters bind to the receiver's arguments, so `class Wrapper : IValidator` stays reachable from every instantiation while `class IntValidator : IValidator` is pruned from the `string` one. Receiver arguments come from the declared type (Case 4), a class-level field's declared type (Case 6), or — for a compound receiver such as `this._repo` — the spelling the compound fold typed that position from, reported back through `recordReceiverType` and accepted only when it names the class the fold returned. + +The filter prunes only on positive evidence: an unknown instantiation on either side, an argument list whose arity does not line up, a name that may be a type variable the language's captures never recorded, or an unresolved spelling whose simple name matches all keep the target. A type parameter of the declaration ENCLOSING either side is recognised as such and never compared — `void Run(IValidator v)` writes a receiver with no known instantiation, so it keeps the unfiltered fan-out. That recognition is what generic METHODS now carry `@declaration.type-parameters` for in C#, Java and Kotlin (TypeScript already did): without it an unbounded `T` grounds to nothing and a bounded one grounds to its BOUND, and both compare unequal to an implementor's concrete argument. Languages that capture neither type arguments nor type parameters therefore emit exactly the pre-#2912 fan-out. The fan-out cap (32, `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT`) and its skipped-target reporting are unchanged and apply after filtering. Note the fan-out itself still fires only for a receiver whose folded type is an `Interface` symbol, so a Rust `Trait` or a Dart abstract `Class` receiver emits no secondary targets to filter in the first place. + Standalone (regex-based) providers such as COBOL participate via `ScopeResolver.scopeResolutionEdgeMode: 'callable-flow-only'`: `runScopeResolution` runs for them, but every ordinary emission path — heritage, interface implementations, receiver-bound, free-call fallback, reference/import edges, post-resolution hooks — is gated off, so their legacy phase (e.g. `cobolPhase`) remains the sole owner of structural edges and the callable solver's `CALLS` are purely additive. A callable-flow-only provider whose files emitted no callable facts exits early, before finalize, keeping the opt-in proportional to source scanning. ### Receiver chains and the drop census (#2766) diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index 6629dacd3..b559d32e3 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -82,6 +82,28 @@ export interface ReferenceSite { * otherwise, in which case resolution is unchanged. */ readonly rawQualifiedName?: string; + /** + * Top-level generic/template arguments the source wrote ON this reference — + * `class UserValidator : IValidator` yields `['string']` on the + * `inherits` site whose `name` is `IValidator`. + * + * `name` is the BASE name and stays that way: every lookup in resolution is + * keyed by it, and one declaration answers for every instantiation of itself. + * This records what the erasure threw away, so a consumer that needs the + * INSTANTIATION — receiver-bound interface dispatch, which must not fan a + * `IValidator` receiver out to an `IValidator` implementor + * (#2912) — can ask for it without re-parsing the source. + * + * Derived generically from the anchor capture's own text (see + * `collectReferenceSites`), so no language query change is needed: an emitter + * whose `@reference.inherits` anchor spans the whole base gets this for free, + * and one whose anchor is the bare name simply leaves it absent. + * + * ABSENT MEANS UNKNOWN, never "not generic" — the two are indistinguishable + * here, and only the first is safe to act on. Consumers must fail OPEN on + * absence (keep the target), matching `SymbolDefinition.typeParameters`. + */ + readonly typeArguments?: readonly string[]; /** Source-text range of this reference. */ readonly atRange: Range; /** diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 9ad4261db..b010fa30a 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -72,8 +72,9 @@ "fixture_count": 178 }, "rust": { - "fingerprint": "116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9", + "fingerprint": "e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7", "scaling_budget": 1.5, + "_rebaselined_generic_instantiation_2912": "#2912: RUST_SCOPE_QUERY tags trait-impl heritage with the instantiation the impl was written with (`impl Validator for V`), so interface dispatch can prune implementors of an instantiation the receiver cannot hold. Additive capture text on existing impl matches — the same matches are minted, carrying one more field — so this is digest drift, not a capture-set change: capture_groups_fp (3556) and fixture_count (202) are both unchanged, which is the check that no match appeared or vanished. Prior 116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9 -> e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7; scaling 1.018 < 1.5. Only rust and dart move; the other 13 languages are byte-identical.", "_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged — verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.", "_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.", @@ -123,8 +124,9 @@ "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5." }, "dart": { - "fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73", + "fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687", "scaling_budget": 1.5, + "_rebaselined_generic_instantiation_2912": "#2912: the Dart heritage marker carries a fourth field — the type arguments the clause was written with (`implements Validator`) — so interface dispatch can prune implementors of a mismatched instantiation. Additive marker text on existing heritage matches rather than a new match, so this is digest drift only; a marker from a pre-#2912 cache simply has no fourth field and reads as unknown. Prior ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73 -> 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687; scaling 1.027 < 1.5.", "_rebaselined_2538": "#2538: Dart extension type headers are preprocessed into normal extension declarations before scope capture, so extension type symbols and their methods are now emitted. Intentional Dart-only capture fingerprint drift; CI measured scaling 1.042 < 1.5.", "_rebaselined_2538_implements": "#2538 tri-review follow-up: Dart extension type implements clauses now emit heritage markers and fixture coverage asserts IMPLEMENTS edges, including multi-arg generic interfaces. Prior committed baseline 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3 -> ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73; scaling 0.945 < 1.5.", "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8 -> 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3; scaling 1.054 < 1.5.", diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts index 615da3c21..18c37ba2b 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/query.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -93,8 +93,14 @@ const CSHARP_SCOPE_QUERY = ` name: (identifier) @declaration.name) @declaration.enum ;; Declarations — methods / constructors / properties +;; +;; A generic METHOD's parameters are read for the same reason a generic type's +;; are (#2912 review): \`void Run(IValidator v)\` writes a receiver whose +;; argument is a type VARIABLE, and a pass that cannot tell that from a concrete +;; type prunes every implementor of \`IValidator\` from the call's fan-out. (method_declaration - name: (identifier) @declaration.name) @declaration.method + name: (identifier) @declaration.name + (type_parameter_list)? @declaration.type-parameters) @declaration.method (constructor_declaration name: (identifier) @declaration.name) @declaration.constructor diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index eb1d3b10d..4b50efc67 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -102,6 +102,82 @@ const csharpScopeResolver: ScopeResolver = { // files. The compound-receiver walker needs to walk up from the // class scope to find them; see the contract field for rationale. hoistTypeBindingsToModule: true, + + // `IValidator` and `IValidator` are one instantiation, so the + // dispatch fan-out must not read them as two (#2912). See the alias table. + normalizeTypeArgument: normalizeCsharpTypeArgument, }; +/** + * C# predefined type aliases — the 15 keywords the language defines as exact + * synonyms for `System` types (`string` ≡ `System.String`), plus `nint`/`nuint`. + * A codebase mixing the spellings is common enough that StyleCop ships a rule + * about it (SA1121), so the two forms genuinely meet across files. + * + * Keyword → BCL simple name; anything else is returned unchanged, including the + * BCL names themselves (already canonical) and any qualified spelling, which is + * compared as written. + * + * A workspace may legally declare its OWN type named `String`, which shadows the + * BCL simple name; this table then reads `IValidator` as the `string` + * instantiation and KEEPS that implementor in the fan-out. Deliberate, and the + * safe direction: the alternative is pruning on the belief that two spellings + * differ, which is the missing-edge failure `generic-instantiation.ts` is built + * to avoid. Resolving instead of normalizing cannot settle it either — the + * identity comparison needs a `definitionId` from BOTH sides, and a built-in + * name has none, so "built-in versus workspace-declared" would be a new prune + * with no positive evidence behind it. The result is one surplus edge in a + * shape that is rare on its own terms, i.e. exactly the pre-#2912 fan-out for + * that pair and no worse. + */ +const CSHARP_PREDEFINED_TYPE_ALIASES: ReadonlyMap = new Map([ + ['bool', 'Boolean'], + ['byte', 'Byte'], + ['sbyte', 'SByte'], + ['char', 'Char'], + ['decimal', 'Decimal'], + ['double', 'Double'], + ['float', 'Single'], + ['int', 'Int32'], + ['uint', 'UInt32'], + ['long', 'Int64'], + ['ulong', 'UInt64'], + ['short', 'Int16'], + ['ushort', 'UInt16'], + ['nint', 'IntPtr'], + ['nuint', 'UIntPtr'], + ['object', 'Object'], + ['string', 'String'], +]); + +/** The BCL simple names the keywords alias. A spelling that reduces to one of + * these IS the predefined type; anything else that merely happens to sit in + * `System` is an ordinary type and keeps its qualifier. */ +const CSHARP_PREDEFINED_TYPE_NAMES: ReadonlySet = new Set( + CSHARP_PREDEFINED_TYPE_ALIASES.values(), +); + +const CSHARP_SYSTEM_QUALIFIER = /^(?:global::)?System\./; + +function normalizeCsharpTypeArgument(name: string): string { + const named = name.trim(); + // A keyword answers immediately: `string` → `String`. + const aliased = CSHARP_PREDEFINED_TYPE_ALIASES.get(named); + if (aliased !== undefined) return aliased; + // Otherwise the `System.` qualifier is dropped so the fully-qualified + // spelling of a predefined type meets that keyword: `System.String` → + // `String` ≡ `string` → `String`. The optional `global::` alias qualifier goes + // with it — `import-decomposer` already unwraps that spelling elsewhere, and + // leaving it on would make `global::System.String` unequal to `string` and + // prune a live implementor. + // + // ONLY when what remains is a predefined type. `System.Custom` is an ordinary + // type that happens to live in `System`, and answering `Custom` for it would + // equate it with an unrelated `Custom` elsewhere in the workspace. Returned as + // written instead, which sends it to the identity comparison — the step that + // can actually tell two declarations apart. + const bare = named.replace(CSHARP_SYSTEM_QUALIFIER, ''); + return bare !== named && CSHARP_PREDEFINED_TYPE_NAMES.has(bare) ? bare : named; +} + export { csharpScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index 351eaee7d..a6c5ef773 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -1069,9 +1069,15 @@ function emitHeritage(classNode: SyntaxNode, out: CaptureMatch[]): void { for (let i = 0; i < superclass.namedChildCount; i++) { const c = superclass.namedChild(i); if (c !== null && c.type === 'type_identifier') { + // `extends Base` spells the arguments in a SIBLING node, so the + // anchor's own text cannot carry them; the sub-tag does (#2912). + const args = typeArgumentsAfter(superclass, i); out.push({ '@reference.inherits': nodeToCapture('@reference.inherits', c), '@reference.name': nodeToCapture('@reference.name', c), + ...(args === null + ? {} + : { '@reference.type-arguments': nodeToCapture('@reference.type-arguments', args) }), }); break; } @@ -1144,7 +1150,26 @@ function emitHeritageMarkers( for (let i = 0; i < container.namedChildCount; i++) { const c = container.namedChild(i); if (c === null || c.type !== 'type_identifier') continue; - const payload = encodeMarker('heritage', [kind, c.text, className]); + // `implements Validator` / `with M`: the arguments ride the + // marker payload, because this heritage never becomes a reference SITE — + // `emitDartHeritageEdges` reads the marker and emits the edge (#2912). + // Dropped rather than encoded when the spelling contains the marker's own + // ':' delimiter, which `encodeMarker` rejects outright; absence is the + // fail-open value everywhere this is read. + const args = typeArgumentsAfter(container, i)?.text; + const fields = + args === undefined || args.includes(':') + ? [kind, c.text, className] + : [kind, c.text, className, args]; + const payload = encodeMarker('heritage', fields); out.push({ '@import.heritage': syntheticCapture('@import.heritage', c, payload) }); } } + +/** The `type_arguments` node written immediately after `container`'s named + * child at `index` — the arguments of the type that child names — or `null` + * when that type was written without any. */ +function typeArgumentsAfter(container: SyntaxNode, index: number): SyntaxNode | null { + const next = container.namedChild(index + 1); + return next !== null && next.type === 'type_arguments' ? next : null; +} diff --git a/gitnexus/src/core/ingestion/languages/dart/query.ts b/gitnexus/src/core/ingestion/languages/dart/query.ts index 38496f2ec..fb93f5fb8 100644 --- a/gitnexus/src/core/ingestion/languages/dart/query.ts +++ b/gitnexus/src/core/ingestion/languages/dart/query.ts @@ -42,7 +42,15 @@ const DART_SCOPE_QUERY = ` (enum_declaration) @scope.class ; ── Declarations — types ───────────────────────────────────────────────────── -(class_definition name: (identifier) @declaration.name) @declaration.class +; The type-parameter list is matched as an UNNAMED optional child: the Dart +; grammar hangs \`type_parameters\` off \`class_definition\` without a field name. +; Recording it is what lets instantiation-aware interface dispatch tell a type +; VARIABLE (\`class Box implements Validator\`) from a concrete argument +; (\`class V implements Validator\`) — see #2912; absent parameters are +; indistinguishable from a language that captures none, and read as unknown. +(class_definition + name: (identifier) @declaration.name + (type_parameters)? @declaration.type-parameters) @declaration.class (mixin_declaration (identifier) @declaration.name) @declaration.trait (extension_declaration name: (identifier) @declaration.name) @declaration.class (enum_declaration name: (identifier) @declaration.name) @declaration.enum diff --git a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts index 22e1171d1..76bec5d74 100644 --- a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts @@ -38,6 +38,8 @@ import { generateId } from '../../../../lib/utils.js'; import { dartProvider } from '../dart.js'; import { dartArityCompatibility, dartMergeBindings, resolveDartImportTarget } from './index.js'; import { decodeMarker } from '../../utils/heritage-marker.js'; +import { typeApplicationArguments } from '../../utils/template-arguments.js'; +import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js'; import { expandDartWildcardNames } from './expand-wildcards.js'; interface ClassDefRef { @@ -77,6 +79,7 @@ function emitDartHeritageEdges( graph: KnowledgeGraph, parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, + recordTypeArguments?: HeritageTypeArgumentSink, ): void { const defsByName = new Map(); for (const parsed of parsedFiles) { @@ -110,10 +113,19 @@ function emitDartHeritageEdges( if (decoded?.kind !== 'heritage') continue; const parts = decoded.fields; if (parts.length < 3) continue; - const [kind, baseName, childName] = parts; + const [kind, baseName, childName, rawTypeArguments] = parts; const childId = pickClassByName(childName!, parsed.filePath, defsByName); const baseId = pickClassByName(baseName!, parsed.filePath, defsByName); if (childId === undefined || baseId === undefined || childId === baseId) continue; + // The instantiation this clause was written with — `implements + // Validator` (#2912). Recorded before the dedup below, since the + // FIRST writer wins on both sides and an edge deduped here still needs + // its arguments. A marker from a pre-#2912 cache has no fourth field, + // which reads as unknown. + if (rawTypeArguments !== undefined) { + const typeArguments = typeApplicationArguments(rawTypeArguments); + if (typeArguments !== undefined) recordTypeArguments?.(childId, baseId, typeArguments); + } const key = `${childId}->${baseId}:${kind}`; if (emitted.has(key)) continue; emitted.add(key); @@ -211,8 +223,8 @@ export const dartScopeResolver: ScopeResolver = { // `implements` / `with` IMPLEMENTS edges (extends rides the generic // inherits pre-pass; these need an explicit, kind-independent edge type). - emitHeritageEdges: (graph, parsedFiles, nodeLookup) => - emitDartHeritageEdges(graph, parsedFiles, nodeLookup), + emitHeritageEdges: (graph, parsedFiles, nodeLookup, _scopes, recordTypeArguments) => + emitDartHeritageEdges(graph, parsedFiles, nodeLookup, recordTypeArguments), // Dart is statically typed — the field-fallback heuristic over-connects. fieldFallbackOnMethodLookup: false, diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts index 99c72dc09..507d3ce79 100644 --- a/gitnexus/src/core/ingestion/languages/java/query.ts +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -89,7 +89,13 @@ const JAVA_SCOPE_QUERY = ` ])) @class-annotation.class ;; Declarations — methods / constructors +;; +;; A generic METHOD's parameters are read for the same reason a generic type's +;; are (#2912 review): \` boolean runAny(Validator v)\` writes a receiver +;; whose argument is a type VARIABLE, and a pass that cannot tell that from a +;; concrete type prunes every implementor from the call's dispatch fan-out. (method_declaration + type_parameters: (type_parameters)? @declaration.type-parameters name: (identifier) @declaration.name) @declaration.method (constructor_declaration diff --git a/gitnexus/src/core/ingestion/languages/kotlin/query.ts b/gitnexus/src/core/ingestion/languages/kotlin/query.ts index f442a2b37..94dadc59e 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/query.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/query.ts @@ -121,7 +121,13 @@ const KOTLIN_SCOPE_QUERY = ` ])) @class-annotation.class ;; Declarations — functions / methods / properties +;; +;; A generic FUNCTION's parameters are read for the same reason a generic type's +;; are (#2912 review): \`fun runAny(v: Validator)\` writes a receiver whose +;; argument is a type VARIABLE, and a pass that cannot tell that from a concrete +;; type prunes every implementor from the call's dispatch fan-out. (function_declaration + (type_parameters)? @declaration.type-parameters (simple_identifier) @declaration.name) @declaration.function ;; Lambda bound to a val/var: val handler = { x: Int -> target(x) } diff --git a/gitnexus/src/core/ingestion/languages/rust/captures.ts b/gitnexus/src/core/ingestion/languages/rust/captures.ts index ae15c99e2..d6685dde3 100644 --- a/gitnexus/src/core/ingestion/languages/rust/captures.ts +++ b/gitnexus/src/core/ingestion/languages/rust/captures.ts @@ -1,5 +1,6 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { + findChild, nodeIfType, nodeToCapture, syntheticCapture, @@ -252,10 +253,22 @@ function synthesizeRustInheritanceReferences(root: SyntaxNode): CaptureMatch[] { const traitName = bareTypeIdentifier(traitField); const structName = bareTypeIdentifier(typeField); if (traitName === null || structName === null) return; + // The trait's generic ARGUMENTS (`impl Validator for V`), so + // interface dispatch can tell one instantiation of a trait from another + // (#2912). Emitted as a sub-tag rather than by widening the anchor: the + // anchor is the bare `type_identifier` inside the `generic_type`, and its + // range is part of the inheritance edge's id. + const traitArguments = + traitField.type === 'generic_type' ? findChild(traitField, 'type_arguments') : null; out.push({ '@reference.inherits': nodeToCapture('@reference.inherits', traitName), '@reference.name': nodeToCapture('@reference.name', traitName), '@reference.receiver': syntheticCapture('@reference.receiver', structName, structName.text), + ...(traitArguments === null + ? {} + : { + '@reference.type-arguments': nodeToCapture('@reference.type-arguments', traitArguments), + }), }); }); return out; diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index 5fd0f1570..bea53d9fd 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -16,6 +16,7 @@ import { import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; @@ -54,6 +55,7 @@ function emitRustTraitImplEdges( parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, scopes: ScopeResolutionIndexes | undefined, + recordTypeArguments?: HeritageTypeArgumentSink, ): void { if (scopes === undefined) return; @@ -83,6 +85,14 @@ function emitRustTraitImplEdges( const traitGraphId = resolveDefGraphId(traitDef.filePath, traitDef, nodeLookup); if (structGraphId === undefined || traitGraphId === undefined) continue; + // The instantiation the impl was written with — `impl Validator + // for V` (#2912). Recorded against THIS edge's ids, not the pre-pass's: + // the pre-pass sources its edge from the enclosing def, and interface + // dispatch crosses the corrected one emitted here. + if (site.typeArguments !== undefined) { + recordTypeArguments?.(structGraphId, traitGraphId, site.typeArguments); + } + const edgeKey = `${structGraphId}->${traitGraphId}`; if (emitted.has(edgeKey)) continue; emitted.add(edgeKey); @@ -159,8 +169,8 @@ export const rustScopeResolver: ScopeResolver = { buildMro: (graph, parsedFiles, nodeLookup) => buildRustMro(graph, parsedFiles, nodeLookup), - emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes) => - emitRustTraitImplEdges(graph, parsedFiles, nodeLookup, scopes), + emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes, recordTypeArguments) => + emitRustTraitImplEdges(graph, parsedFiles, nodeLookup, scopes, recordTypeArguments), populateOwners: (parsed: ParsedFile) => populateRustOwners(parsed), diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index ab82342e8..39b8667ae 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -80,6 +80,7 @@ import type { CallableFlowOperand, CallableFlowPassingMode, CallableFlowSite, + Capture, CaptureMatch, ImportEdge, ParameterTypeClass, @@ -97,7 +98,11 @@ import type { import { buildPositionIndex, buildScopeTree, canParentScope, makeScopeId } from 'gitnexus-shared'; import type { LanguageProvider } from './language-provider.js'; import { isValidReceiverChain } from './utils/receiver-chain-codec.js'; -import { extractTemplateArguments } from './utils/template-arguments.js'; +import { + extractTemplateArguments, + stripTrailingCallSuffix, + typeApplicationArguments, +} from './utils/template-arguments.js'; import { parseTypeParameterList } from './utils/type-parameters.js'; // ─── Narrow hook surface the extractor actually uses ─────────────────────── @@ -1255,6 +1260,12 @@ function pass5CollectReferences( // sibling via the full-path QualifiedNameIndex before the simple-tail walk // (#1982). Absent for unqualified references — resolution stays unchanged. const qualifiedCap = match['@reference.qualified-name']; + // Generic ARGUMENTS written on a heritage reference (`: IValidator`); + // `inherits` only, because a call/read/write anchor spans the whole call + // expression, whose `<…>` would be an argument list, a comparison, or + // nothing at all — widening the kind would mint confident nonsense (#2912). + const typeArguments = + kind === 'inherits' ? heritageTypeArguments(match, anchor, nameCap) : undefined; const inScopeId = positionIndex.atPosition( filePath, anchor.range.startLine, @@ -1306,6 +1317,7 @@ function pass5CollectReferences( ...(qualifiedCap?.text !== undefined && qualifiedCap.text.length > 0 ? { rawQualifiedName: qualifiedCap.text } : {}), + ...(typeArguments !== undefined ? { typeArguments } : {}), ...(propertyKeyCap?.text !== undefined && propertyKeyCap.text.length > 0 ? { propertyKey: propertyKeyCap.text } : {}), @@ -1322,6 +1334,60 @@ function pass5CollectReferences( } } +/** + * The generic arguments a heritage reference was written with, by whichever of + * the two routes this emitter uses (#2912). + * + * `@reference.type-arguments` is the explicit route, for an emitter whose anchor + * is the bare NAME node (Rust's `impl Trait for S` anchors on the trait + * identifier inside a `generic_type`). It wins where present: moving such an + * anchor to cover the arguments would change the site's range, and that range is + * part of every inheritance EDGE ID — a spelling detail must not renumber the + * graph. Every other emitter already anchors on the whole base, so its spelling + * is read directly and no query changed. + */ +function heritageTypeArguments( + match: CaptureMatch, + anchor: Capture, + nameCap: Capture, +): readonly string[] | undefined { + const explicit = match['@reference.type-arguments']?.text; + return explicit !== undefined + ? typeApplicationArguments(explicit) + : referenceTypeArguments(anchor.text, nameCap.text); +} + +/** + * Type arguments written on a heritage reference, read from the anchor's own + * spelling — `IValidator` → `['string']` (#2912). + * + * Two shapes are handled before the spelling is read as an application: + * + * - A trailing CONSTRUCTOR INVOCATION is dropped. `record R : Base(x)` + * and Kotlin `class C : Bar()` write a call in the heritage position; + * the call is not part of the type, and leaving it attached would make the + * list fail to close at the end and lose the arguments entirely. + * - The application's base must BE the referenced name (`Other::Inner` + * ends with `Inner`). An anchor that spans more than the base type is not + * read at all rather than read wrongly. + * + * `undefined` for a non-generic base and for every spelling that is not exactly + * one balanced argument list — absence is the "unknown" value that consumers + * fail open on, so declining is always safe here. + */ +function referenceTypeArguments( + anchorText: string, + baseName: string, +): readonly string[] | undefined { + const text = stripTrailingCallSuffix(anchorText.trim()); + const opener = text.search(OPENING_BRACKET); + if (opener === -1) return undefined; + if (!text.slice(0, opener).trimEnd().endsWith(baseName)) return undefined; + return typeApplicationArguments(text); +} + +const OPENING_BRACKET = /[<[]/; + function referenceKindFromAnchor(name: string): ReferenceKind | undefined { const suffix = name.slice('@reference.'.length); // Strip sub-tag after the kind (`@reference.call.member` → `call`). @@ -1720,6 +1786,10 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@type-binding.type', '@reference.name', '@reference.qualified-name', + // The generic arguments a heritage base was written with, when the emitter's + // anchor is the bare name and cannot carry them (#2912). A sub-tag for the + // usual reason: it spans a sibling node of the anchor, never the site itself. + '@reference.type-arguments', '@reference.property-key', '@reference.callee-position', '@reference.embedded-pointer', diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 1e871cc8e..016aa1a29 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -297,6 +297,7 @@ import { LanguageProvider } from '../../language-provider.js'; import { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { SemanticModel } from '../../model/semantic-model.js'; import type { ConversionRankFn } from '../passes/overload-narrowing.js'; +import type { HeritageTypeArgumentSink } from '../utils/generic-instantiation.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; /** A LinearizeStrategy receives the full ancestor map so C3-style @@ -586,6 +587,16 @@ export interface ScopeResolver { * shape. Must be idempotent (the orchestrator may call it more than once * during re-resolution). * + * `recordTypeArguments` is the same sink `preEmitInheritanceEdges` writes to: + * the generic INSTANTIATION a heritage clause was written with, so + * interface-dispatch fan-out can refuse an implementor of an incompatible one + * (#2912). An implementation that emits an edge for a generic base + * (`impl Validator for V`, `class V implements Validator`) + * should call it with the same (source, target) graph ids it just used; + * anything not recorded reads as "unknown" and keeps the pre-#2912 fan-out. + * Ignoring it entirely is correct for a language whose heritage carries no + * type arguments (Ruby `include`). + * * Default: undefined (no extra heritage edges needed). */ readonly emitHeritageEdges?: ( @@ -593,6 +604,7 @@ export interface ScopeResolver { parsedFiles: readonly ParsedFile[], nodeLookup: GraphNodeLookup, scopes?: ScopeResolutionIndexes, + recordTypeArguments?: HeritageTypeArgumentSink, ) => void; /** @@ -1006,6 +1018,25 @@ export interface ScopeResolver { */ readonly isStaticOnly?: (def: SymbolDefinition) => boolean; + /** + * Optional canonicalizer for a written GENERIC TYPE ARGUMENT, so two + * spellings of one type compare equal during interface-dispatch + * instantiation matching (#2912). + * + * The case it exists for is a language with predefined ALIASES: C# `string` + * and `String` are the same type, so `IValidator` must still fan out + * to `class V : IValidator`. Without the hook the two spellings look + * like two instantiations and the implementor is pruned — a missing edge, + * which is the failure direction #2912 is most concerned to avoid. + * + * Called ONLY on the two sides of one argument comparison, never on a name + * used for lookup, so it may map to whatever canonical form the language + * prefers (`string` → `String`, or the reverse) as long as it is consistent. + * Languages whose types have one spelling each leave it undefined and the + * comparison stays exact. + */ + readonly normalizeTypeArgument?: (name: string) => string; + /** * Optional predicate to gate free-call fallback emission by caller-side * visibility. When provided, `pickUniqueGlobalCallable` rejects candidates diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 5a82ec455..66453dc3e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -24,7 +24,11 @@ import type { ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; import type { ElementAccessRoute, ScopeResolver } from '../contract/scope-resolver.js'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; -import { erasedTypeApplication, stripTemplateArguments } from '../../utils/template-arguments.js'; +import { + erasedTypeApplication, + matchingOpenParen, + stripTemplateArguments, +} from '../../utils/template-arguments.js'; import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js'; import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js'; import type { DecorationStripper } from '../scope/walkers.js'; @@ -75,7 +79,22 @@ function parseMapTupleSentinel(text: string): { tupleIdx: number; rhs: string } return { tupleIdx: Number(idxStr), rhs }; } +/** + * Notified with the spelling a receiver position was typed from and the class + * it resolved to — see {@link noteReceiverType}. Pure side channel: this file + * never reads it back, and resolution is identical whether or not it is set. + */ +type ReceiverTypeRecorder = (spelling: string, defId: string) => void; + interface ResolveCompoundReceiverOptions { + /** + * Optional sink for the DECLARED TYPE SPELLINGS this fold typed receiver + * positions from (#2912). The fold returns a class, and a class has lost the + * generic arguments that decide which implementations an interface-typed + * receiver can dispatch to; the caller keeps the last report whose def id + * matches the returned class and reads the arguments off that spelling. + */ + readonly recordReceiverType?: ReceiverTypeRecorder; /** When true (default), if method lookup fails on the receiver's * class, walk its fields and try the lookup on each field's class. * Phase-9C "unified fixpoint" — Python-shaped heuristic. */ @@ -348,17 +367,65 @@ function classOfDeclaredType( typeRef: TypeRef, scopes: ScopeResolutionIndexes, stripDecoration?: DecorationStripper, + recordReceiverType?: ReceiverTypeRecorder, ): SymbolDefinition | undefined { // `declaredAtScope`, never a scope the caller chose: all five sites passed // exactly this `TypeRef`'s own anchor, and taking it as a parameter is what // would let a sixth quietly not — which is the hole this helper exists to // close, one level up. - return resolveClassBindingForName( + const spelling = erasedTypeApplication(typeRef) ?? typeRef.rawName; + const def = resolveClassBindingForName( typeRef.declaredAtScope, - erasedTypeApplication(typeRef) ?? typeRef.rawName, + spelling, scopes, stripDecoration, ); + return noteReceiverType(recordReceiverType, spelling, def); +} + +/** + * Report the SPELLING a receiver position was typed from, alongside the class + * it resolved to (#2912). + * + * The fold answers "which class", which is all dispatch needed until generic + * instantiation mattered: `IValidator` and `IValidator` fold to + * the same declaration. The spelling is the only place the arguments survive, + * and it exists at every one of these lookups already — reporting it costs a + * function call and changes no resolution. + * + * Pairing it with the def id is what makes it usable: the caller keeps the LAST + * report and uses it only if it names the class the fold ultimately returned, + * so a route that typed an intermediate position, or a later route that + * answered differently, cannot lend its arguments to another class. + */ +function noteReceiverType( + record: ReceiverTypeRecorder | undefined, + spelling: string, + def: SymbolDefinition | undefined, +): SymbolDefinition | undefined { + if (def !== undefined) record?.(spelling, def.nodeId); + return def; +} + +/** + * The class a CALL's return type names, reported to the receiver-type side + * channel — the return-type twin of {@link classOfDeclaredType}. + * + * The pairing it exists to keep in one place: the lookup goes through + * `rawName`, while the SPELLING reported alongside it is the erased type + * application, so an `IValidator` return is reported with its + * arguments intact. The spelling is built only once the lookup has actually + * found a class, because it is discarded otherwise — and every fold hop + * through a call reaches this, generic or not. + */ +function classOfReturnType( + retType: TypeRef, + scopes: ScopeResolutionIndexes, + record: ReceiverTypeRecorder | undefined, +): SymbolDefinition | undefined { + const def = findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); + if (def === undefined || record === undefined) return def; + return noteReceiverType(record, erasedTypeApplication(retType) ?? retType.rawName, def); } function typeOfMemberOnClass( @@ -374,7 +441,12 @@ function typeOfMemberOnClass( const classScope = classScopeByDefId.get(ownerId); const memberType = classScope?.typeBindings.get(memberName); if (memberType !== undefined) { - const def = classOfDeclaredType(memberType, scopes, options.stripTypePreservingDecoration); + const def = classOfDeclaredType( + memberType, + scopes, + options.stripTypePreservingDecoration, + options.recordReceiverType, + ); // The declared type is reported even when it resolved to no class: // `Promise` and `[]Repo` name nothing in the workspace, and an // await or index step unwrapping them is exactly how they become @@ -404,7 +476,12 @@ function typeOfMemberOnClass( // Same stripper the primary branch above passes. Omitting it here // meant a decorated declared type (`*Host`) resolved on one branch and // not the other, for the same member of the same class. - const def = classOfDeclaredType(hoisted, scopes, options.stripTypePreservingDecoration); + const def = classOfDeclaredType( + hoisted, + scopes, + options.stripTypePreservingDecoration, + options.recordReceiverType, + ); // Identical to the primary branch: a declared type that named no // class is still a usable position when the next step unwraps it. // Returning `undefined` here made `svc.getMap()['k'].run()` decline @@ -569,9 +646,68 @@ export function foldReceiverChain( } // A chain that ended without a class returns undefined naturally — no // separate guard, because `def` IS the signal. + // + // The receiver-type report is made HERE, from the final `FoldState`, because + // that record pairs the class with the spelling that produced it BY + // CONSTRUCTION — same step, same lookup. The individual `classOfDeclaredType` + // calls inside the fold also report, including from steps that were later + // folded past, so the last of those is not reliably about the class the fold + // returns. Reporting the final state last makes it the one that stands. + if (current.def !== undefined && current.declaredType !== undefined) { + options.recordReceiverType?.(current.declaredType, current.def.nodeId); + } return current.def; } +/** A resolved compound receiver, together with the declared spelling that typed + * the position it came from — see {@link resolveCompoundReceiverTyped}. */ +export interface TypedCompoundReceiver { + readonly def: SymbolDefinition; + /** + * The receiver's declared type AS WRITTEN (`IValidator`), or + * `undefined` where the route that answered had no declared type to report — a + * construction expression, a namespace target, a static class receiver. The + * fan-out reads its generic arguments off this and restores the unfiltered + * behaviour when it is absent, so declining is always safe (#2912). + */ + readonly declaredSpelling: string | undefined; +} + +/** + * {@link resolveCompoundReceiverClass}, paired with the spelling that typed the + * position (#2912). + * + * The sink is created and read HERE, per call, which is the whole point: a + * recorder that outlives one resolution has to be reset by hand before every + * call, and the retry shapes in this pass make two calls in a row — a reset + * missed at one of them silently attributes the previous receiver's spelling to + * this one. A local cannot be forgotten. + * + * The def-id guard is the second half. Lookups that lost — an MRO walk that + * moved on, a fold step later folded past — report too, so a report counts only + * when it names the class actually returned. `foldReceiverChain` reports its + * final state last for exactly this reason, so the structural route wins. + */ +export function resolveCompoundReceiverTyped( + receiverText: string, + inScope: ScopeId, + scopes: ScopeResolutionIndexes, + index: WorkspaceResolutionIndex, + options: ResolveCompoundReceiverOptions = {}, +): TypedCompoundReceiver | undefined { + let spelling: string | undefined; + let spellingDefId: string | undefined; + const def = resolveCompoundReceiverClass(receiverText, inScope, scopes, index, { + ...options, + recordReceiverType: (reported, defId) => { + spelling = reported; + spellingDefId = defId; + }, + }); + if (def === undefined) return undefined; + return { def, declaredSpelling: spellingDefId === def.nodeId ? spelling : undefined }; +} + export function resolveCompoundReceiverClass( receiverText: string, inScope: ScopeId, @@ -676,7 +812,12 @@ export function resolveCompoundReceiverClass( return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes); } - const viaTb = classOfDeclaredType(tb, scopes, options.stripTypePreservingDecoration); + const viaTb = classOfDeclaredType( + tb, + scopes, + options.stripTypePreservingDecoration, + options.recordReceiverType, + ); if (viaTb !== undefined) return viaTb; // Member-alias / call-result shapes store the RHS path on rawName @@ -769,7 +910,7 @@ export function resolveCompoundReceiverClass( const viaReturn = retType === undefined ? undefined - : findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); + : classOfReturnType(retType, scopes, options.recordReceiverType); if (viaReturn !== undefined) return viaReturn; } // Inline construction — `Service(db).m()` / `new Service(db).m()`. @@ -891,7 +1032,7 @@ export function resolveCompoundReceiverClass( } if (retType === undefined) return undefined; - return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes); + return classOfReturnType(retType, scopes, options.recordReceiverType); } // Mixed dotted + call chain: `obj.field.method().field.method()…`. @@ -967,7 +1108,7 @@ export function resolveCompoundReceiverClass( // two had a fixture. See `classOfDeclaredType` for why this cannot change a // `TypeRef` that was never reduced. let currentClass: SymbolDefinition | undefined = headType - ? classOfDeclaredType(headType, scopes) + ? classOfDeclaredType(headType, scopes, undefined, options.recordReceiverType) : findClassBindingInScope(inScope, headMemberName, scopes); // Whether the walk currently sits on the CLASS ITSELF rather than on a // value of that class. Seeded true only when the head resolved straight to @@ -1097,7 +1238,7 @@ export function resolveCompoundReceiverClass( // grounds fell through here — a declined fold is documented as "no answer", // never a veto — and this walk re-minted `other.py:Mapped` from the // workspace index. Same rule, same lookup, so the two routes now agree. - let nextClass = classOfDeclaredType(memberType, scopes); + let nextClass = classOfDeclaredType(memberType, scopes, undefined, options.recordReceiverType); if (nextClass === undefined) { const fromMap = unwrapMapValueToClass(memberType, scopes); if (fromMap !== undefined) nextClass = fromMap; @@ -1167,22 +1308,6 @@ function isInitializerContext(startScope: ScopeId, scopes: ScopeResolutionIndexe return false; } -/** Find the index of the `(` that matches the trailing `)` of a - * call-expression text. Returns -1 if unbalanced. */ -function matchingOpenParen(text: string): number { - if (!text.endsWith(')')) return -1; - let depth = 0; - for (let i = text.length - 1; i >= 0; i--) { - const ch = text[i]; - if (ch === ')') depth++; - else if (ch === '(') { - depth--; - if (depth === 0) return i; - } - } - return -1; -} - /** Max peel iterations for `stripCastWrappers`. Real cast nesting — * including decompiler output like `((Target)((Object)expr))` — * is a handful of levels, and each cast level costs at most two diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 8c34e8088..0000b2451 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -68,6 +68,7 @@ import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; import { collectNamespaceTargets } from '../scope/namespace-targets.js'; import { + bindsTypeParameter, findClassBindingInScope, findEnclosingClassDef, isReceiverOwnedButUnbound, @@ -86,8 +87,17 @@ import { type CalleeIdCaptureCtx, } from '../graph-bridge/edges.js'; import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js'; -import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js'; -import { erasedTypeApplication } from '../../utils/template-arguments.js'; +import { + resolveCompoundReceiverClass, + resolveCompoundReceiverTyped, +} from '../passes/compound-receiver.js'; +import { erasedTypeApplication, typeApplicationArguments } from '../../utils/template-arguments.js'; +import { + heritageTypeArgumentsKey, + stepHeritageInstantiation, + type GroundedTypeArgument, + type HeritageTypeArguments, +} from '../utils/generic-instantiation.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; import { narrowOverloadCandidates, @@ -124,6 +134,7 @@ type ReceiverBoundProviderSubset = Pick< | 'conversionOnlyArgTypePrefixes' | 'constraintCompatibility' | 'isStaticOnly' + | 'normalizeTypeArgument' >; /** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */ @@ -298,6 +309,14 @@ export function emitReceiverBoundCalls( * degrades a drop's label to `unknown` (the safe direction) and changes no * edge. */ readonly isBuiltInName?: (name: string) => boolean; + /** The generic arguments each heritage clause instantiated its base with, + * from the passes that emitted those heritage edges — the inheritance + * pre-pass, and the language resolvers that emit their own (Rust `impl T + * for S`, Dart `implements` / `with`) (#2912). Read + * ONLY by the interface-dispatch fan-out, to refuse an implementor of an + * incompatible instantiation. Absent ⇒ every heritage instantiation reads + * as unknown ⇒ the pre-#2912 fan-out, unchanged. */ + readonly heritageTypeArguments?: HeritageTypeArguments; } = {}, ): ReceiverBoundResult { let emitted = 0; @@ -331,6 +350,26 @@ export function emitReceiverBoundCalls( // DefIds, and `pickOverload` keys member lookup by those DefIds. Preserving // every part makes dispatch independent of declaration order. const graphIdToClassDefs = new Map(); + // The same correspondence read the other way, so the dispatch walk can name a + // heritage EDGE (which is keyed by graph ids) from the two DEFS it holds. + const classGraphIdByDefId = new Map(); + /** + * Does THIS language record generic type parameters (#2912)? + * + * `SymbolDefinition.typeParameters` is absent both for a non-generic + * declaration and for every declaration in a language whose captures do not + * emit `@declaration.type-parameters`, and instantiation filtering needs the + * two told apart: in the second case a heritage argument `T` is a type + * VARIABLE that would otherwise read as a concrete type named "T", and + * `class Box : IValidator` would be pruned out of every instantiation. + * + * Evidence rather than a declared capability, because the evidence is exactly + * as good and costs nothing: one run resolves one language (`phase.ts` loops + * per language), so a single generic declaration anywhere in it proves the + * captures record parameters. A run where none exists cannot be harmed by the + * answer — with no generic declaration there is no type variable to mistake. + */ + let languageCapturesTypeParameters = false; for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { if (!isClassLike(def.type)) continue; @@ -342,6 +381,10 @@ export function emitReceiverBoundCalls( graphIdToClassDefs.set(graphId, defs); } defs.push(def); + classGraphIdByDefId.set(def.nodeId, graphId); + if (def.typeParameters !== undefined && def.typeParameters.length > 0) { + languageCapturesTypeParameters = true; + } } } // Direct subtypes of a type, keyed by the SUPERtype's def id. @@ -433,6 +476,34 @@ export function emitReceiverBoundCalls( return graph.getNode(graphId)?.properties.isStatic === true; }; + /** + * What does this written type argument NAME, as seen from `scopeId` (#2912)? + * + * The scope is load-bearing: a heritage argument is resolved from the + * declaring class's own scope and a receiver argument from the call site's, + * because a name means what it means where it was WRITTEN. Resolving both + * makes `Models.User` and an imported `User` one type, which a string + * comparison could only get wrong. + * + * Neither answer is an error: a name that binds nothing and is not built in + * comes back ungrounded, which the matcher reads as "unknown" and keeps. + * + * A TYPE PARAMETER is reported as such rather than left to the ungrounded + * path, because `resolveClassBindingForName` answers a bounded one with its + * BOUND's declaration — grounded, and the wrong thing to compare. + */ + const groundTypeArgument = (name: string, scopeId: string | undefined): GroundedTypeArgument => { + const def = + scopeId === undefined ? undefined : resolveClassBindingForName(scopeId, name, scopes); + return { + ...(def !== undefined ? { definitionId: def.nodeId } : {}), + builtIn: options.isBuiltInName?.(name) === true, + ...(scopeId !== undefined && bindsTypeParameter(scopeId, name, scopes) + ? { typeVariable: true } + : {}), + }; + }; + /** * Emit secondary CALLS edges with reason='interface-dispatch' when the primary * receiver-typed edge targeted an Interface's method. @@ -454,6 +525,17 @@ export function emitReceiverBoundCalls( * override further down is an equally real runtime target — dispatch is an * over-approximation by design, and stopping early would silently prefer the * base. + * + * The closure is walked carrying the receiver's generic INSTANTIATION (#2912). + * `IValidator` and `IValidator` are one declaration and therefore + * one subtype list, so without the substitution a `IValidator` call + * reaches `IntValidator.Check(int)` — a target no dispatch can produce. Each + * hop unifies the arguments the subtype wrote against the ones the supertype + * is known to hold; an incompatible hop is skipped BEFORE the visit is + * recorded, so a type reachable by a second, compatible path still gets its + * edge, and skipped WITHOUT descending, because its own subtypes inherit the + * mismatch. + * Every uncertainty keeps the target — see `generic-instantiation.ts`. */ const emitInterfaceDispatchFor = ( ownerDef: SymbolDefinition, @@ -462,9 +544,26 @@ export function emitReceiverBoundCalls( site: ParsedFile['referenceSites'][number], confidence: number, calleeCapture: CalleeIdCaptureCtx | undefined, + /** The receiver's declared type AS WRITTEN (`IValidator`), or + * `undefined` where the case could not recover it — which restores the + * unfiltered fan-out for that site rather than guessing. + * + * The SPELLING rather than the parsed arguments, so the parse happens after + * the two gates below rather than at every resolved receiver site: all five + * cases call this unconditionally, and the overwhelming majority of + * receivers are concrete classes that return at the first line. */ + receiverTypeSpelling: string | undefined, ): number => { if (ownerDef.type !== 'Interface') return 0; if (subtypesBySupertypeDefId.get(ownerDef.nodeId) === undefined) return 0; + const receiverTypeArguments = + receiverTypeSpelling === undefined + ? undefined + : typeApplicationArguments(receiverTypeSpelling); + // Captures only `site`, so it is built once per SITE rather than once per + // subtype visited. Its partner below cannot be: it is keyed by the subtype. + const resolveSupertypeArgument = (name: string): GroundedTypeArgument => + groundTypeArgument(name, site.inScope); // Collect concrete targets across the closure first, so the cap below counts // real dispatch targets rather than types visited. Source-written owners @@ -483,16 +582,31 @@ export function emitReceiverBoundCalls( type DispatchTraversal = { readonly typeId: string; readonly ancestorImplementationCount: number; + /** The instantiation this type is known to hold ON THIS PATH (#2912), or + * `undefined` where it is not known — which restores the unfiltered + * fan-out for the subtree below it rather than guessing. */ + readonly typeArguments: readonly string[] | undefined; }; const targetByMemberId = new Map(); const bestIncomingCount = new Map([[ownerDef.nodeId, 0]]); const queue: DispatchTraversal[] = [ - { typeId: ownerDef.nodeId, ancestorImplementationCount: 0 }, + { + typeId: ownerDef.nodeId, + ancestorImplementationCount: 0, + typeArguments: receiverTypeArguments, + }, ]; let head = 0; let discoveryOrder = 0; while (head < queue.length) { const current = queue[head++]!; + // The whole instantiation apparatus hangs off ONE question: is the + // supertype's own instantiation known? It is not for a non-generic + // receiver, nor for any language that captures no heritage arguments, so + // those walks skip every lookup below and emit exactly what they did + // before #2912. + const superGraphId = + current.typeArguments === undefined ? undefined : classGraphIdByDefId.get(current.typeId); for (const subDef of subtypesBySupertypeDefId.get(current.typeId) ?? []) { const previousIncomingCount = bestIncomingCount.get(subDef.nodeId); if ( @@ -501,6 +615,42 @@ export function emitReceiverBoundCalls( ) { continue; } + + // What THIS heritage clause instantiated its base with. `superGraphId` + // already answers "is the supertype's instantiation known?", so it gates + // the whole lookup once instead of being re-asked at each step below. + let subtypeArguments: readonly string[] | undefined; + if (superGraphId !== undefined) { + const subGraphId = classGraphIdByDefId.get(subDef.nodeId); + const heritageArguments = + subGraphId === undefined + ? undefined + : options.heritageTypeArguments?.get( + heritageTypeArgumentsKey(subGraphId, superGraphId), + ); + if (heritageArguments !== undefined) { + const subtypeScopeId = index.classScopeByDefId.get(subDef.nodeId)?.id; + const step = stepHeritageInstantiation({ + supertypeArguments: current.typeArguments, + heritageArguments, + subtypeParameters: subDef.typeParameters, + // The "this subtype declares parameters" disjunct an earlier + // revision carried here could never decide: `subDef` comes out of + // the same loop that sets this flag, from exactly these defs, so a + // subtype with parameters has already set it. + subtypeParametersComplete: languageCapturesTypeParameters, + resolveSupertypeArgument, + resolveHeritageArgument: (name) => groundTypeArgument(name, subtypeScopeId), + normalize: provider.normalizeTypeArgument, + }); + // Skipped BEFORE the visit is recorded, so a type reachable by a + // second, compatible path still gets its edge; and without + // descending, because its own subtypes inherit the mismatch. + if (!step.compatible) continue; + subtypeArguments = step.subtypeArguments; + } + } + bestIncomingCount.set(subDef.nodeId, current.ancestorImplementationCount); const implMember = pickOverload(subDef.nodeId, memberName, site, model, provider); @@ -533,6 +683,7 @@ export function emitReceiverBoundCalls( queue.push({ typeId: subDef.nodeId, ancestorImplementationCount: descendantImplementationCount, + typeArguments: subtypeArguments, }); } } @@ -789,7 +940,7 @@ export function emitReceiverBoundCalls( receiverName.includes('(') || site.receiverChain !== undefined ) { - const currentClass = resolveCompoundReceiverClass( + const resolved = resolveCompoundReceiverTyped( receiverName, site.inScope, scopes, @@ -798,8 +949,9 @@ export function emitReceiverBoundCalls( // captured chain describes it and the structural fold applies. { ...fileCompoundOpts, receiverChain: site.receiverChain }, ); + const currentClass = resolved?.def; compoundReceiverUnresolved = currentClass === undefined; - if (currentClass !== undefined) { + if (resolved !== undefined && currentClass !== undefined) { const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)]; let memberDef: SymbolDefinition | undefined; let ambiguousOwnerId: string | undefined; @@ -902,6 +1054,10 @@ export function emitReceiverBoundCalls( // Deliberately not "fixed" here: changing Case 0's primary // confidence is a separate behavioural change affecting every // language, and is out of scope for #2813. + // + // The instantiation the FOLD typed this receiver from — the + // declared spelling of `this.repo` / `svc.get().repo`, which the + // folded class alone no longer carries (#2912). emitted += emitInterfaceDispatchFor( currentClass, memberName, @@ -909,6 +1065,7 @@ export function emitReceiverBoundCalls( site, 0.85, calleeCapture, + resolved.declaredSpelling, ); // Always mark handled when the site was resolved, even // if the edge was deduplicated (collapse mode), so @@ -1379,15 +1536,18 @@ export function emitReceiverBoundCalls( // already contain `()` (Ruby member-call-return captures), // pass through directly — the compound resolver handles the // full expression including the call syntax. - let ownerDef = resolveCompoundReceiverClass( + // Each attempt carries its OWN spelling: the retry below used to reuse a + // recorder reset once, before the first call, so a spelling reported by + // the attempt that FAILED could be read as the retry's. + let resolved = resolveCompoundReceiverTyped( typeRef.rawName, typeRef.declaredAtScope, scopes, index, fileCompoundOpts, ); - if (ownerDef === undefined && !typeRef.rawName.includes('(')) { - ownerDef = resolveCompoundReceiverClass( + if (resolved === undefined && !typeRef.rawName.includes('(')) { + resolved = resolveCompoundReceiverTyped( typeRef.rawName + '()', typeRef.declaredAtScope, scopes, @@ -1395,7 +1555,8 @@ export function emitReceiverBoundCalls( fileCompoundOpts, ); } - if (ownerDef !== undefined) { + const ownerDef = resolved?.def; + if (resolved !== undefined && ownerDef !== undefined) { const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; let memberDef: SymbolDefinition | undefined; let ambiguousOwnerId: string | undefined; @@ -1496,6 +1657,7 @@ export function emitReceiverBoundCalls( // value instead because ITS primary varies that way; Case 3b's // primary, like Case 0's, does not, so there is no 1.0 arm here to // mirror. + // Same fold, same recovered spelling as Case 0. emitted += emitInterfaceDispatchFor( ownerDef, memberName, @@ -1503,6 +1665,7 @@ export function emitReceiverBoundCalls( site, 0.85, calleeCapture, + resolved.declaredSpelling, ); // Always mark handled when the site was resolved, even // if the edge was deduplicated (collapse mode), so @@ -1776,6 +1939,12 @@ export function emitReceiverBoundCalls( // Interface dispatch: when the primary owner is an // Interface, emit secondary CALLS edges to every // implementing class's same-named method. + // + // This case is the one that KNOWS the instantiation: the receiver + // has a declared type, and `typeApplication` is that type restored + // to its written `Base` spelling (`rawName` is the erasure). + // A language whose `rawName` was never erased carries the arguments + // itself, so both spellings are read (#2912). emitted += emitInterfaceDispatchFor( ownerDef, memberName, @@ -1783,6 +1952,7 @@ export function emitReceiverBoundCalls( site, confidence, calleeCapture, + typeApplication ?? typeRef.rawName, ); // Always mark handled when the site was resolved, even // if the edge was deduplicated (collapse mode), so @@ -2054,6 +2224,8 @@ export function emitReceiverBoundCalls( // way. Omitting it would make the static spelling emit fewer // targets than the identical instance field, which is the very // spelling-dependence #2829/#2842 closed elsewhere. + // The field's DECLARED type is the spelling the source wrote, so + // its arguments are available here exactly as in Case 4 (#2912). emitted += emitInterfaceDispatchFor( receiverClass, memberName, @@ -2061,6 +2233,7 @@ export function emitReceiverBoundCalls( site, confidence, calleeCapture, + fieldDeclaredType, ); handledSites.add(siteKey); continue; diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 78da450fc..fcc456d66 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -95,6 +95,10 @@ import { } from '../passes/callable-value-flow.js'; import type { ScopeResolver, UndecidedSatisfaction } from '../contract/scope-resolver.js'; import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.js'; +import { + heritageTypeArgumentsKey, + type HeritageTypeArgumentSink, +} from '../utils/generic-instantiation.js'; import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js'; import { logHeapProbe } from '../../utils/heap-probe.js'; @@ -149,11 +153,22 @@ function emitInheritanceEdgeDirect( * reference-edge bridge from re-emitting the same sites later. * * @returns Site keys to seed the downstream handled-site skip set. + * + * The generic INSTANTIATION each heritage edge was written with (#2912) goes to + * `recordTypeArguments` rather than out through the return, because the caller + * shares that sink with the language heritage hook — see + * `HeritageTypeArguments` in `utils/generic-instantiation.ts`. This pass is + * where the pairing exists at all: the site carries the arguments and this is + * the only code that resolves the site to a (subtype, supertype) pair, so + * recording it here costs one map write per generic heritage edge, while + * recovering it downstream would mean redoing the resolution against a graph + * edge that no longer carries the spelling. */ function preEmitInheritanceEdges( graph: KnowledgeGraph, scopes: ReturnType, nodeLookup: ReturnType, + recordTypeArguments: HeritageTypeArgumentSink, ): Set { const handledSites = new Set(); const seen = new Set(); @@ -207,6 +222,12 @@ function preEmitInheritanceEdges( const edgeType: 'EXTENDS' | 'IMPLEMENTS' = targetDef.type === 'Interface' || targetDef.type === 'Trait' ? 'IMPLEMENTS' : 'EXTENDS'; emitInheritanceEdgeDirect(graph, seen, existing, callerGraphId, targetGraphId, edgeType, site); + // The instantiation this heritage clause wrote (`: IValidator`), + // keyed by the same graph-id pair the edge itself carries. Only generic + // bases produce an entry; the sink owns the first-writer-wins rule. + if (site.typeArguments !== undefined) { + recordTypeArguments(callerGraphId, targetGraphId, site.typeArguments); + } } return handledSites; @@ -704,16 +725,38 @@ export function runScopeResolution( }, }); logHeapProbe('sr-post-finalize', `lang=${provider.language}`); + // One store and ONE writer rule for heritage instantiations (#2912), shared by + // the pre-pass below and by the language hook further down — a heritage shape + // the pre-pass cannot express (Rust `impl T for S`, Dart `implements`) records + // through the same sink. FIRST writer wins: a repeated (sub, super) pair is a + // partial declaration or a re-listed base, and letting a later entry overwrite + // the first would make dispatch depend on file order. + const heritageTypeArguments = new Map(); + const recordHeritageTypeArguments: HeritageTypeArgumentSink = ( + subtypeGraphId, + supertypeGraphId, + typeArguments, + ) => { + if (typeArguments.length === 0) return; + const key = heritageTypeArgumentsKey(subtypeGraphId, supertypeGraphId); + if (!heritageTypeArguments.has(key)) heritageTypeArguments.set(key, typeArguments); + }; const preEmittedInheritanceSites = callableFlowOnly ? new Set() - : preEmitInheritanceEdges(graph, finalized, nodeLookup); + : preEmitInheritanceEdges(graph, finalized, nodeLookup, recordHeritageTypeArguments); // Call-based heritage hook (e.g., Ruby include/extend/prepend) — emits // IMPLEMENTS edges that `preEmitInheritanceEdges` cannot produce because // the heritage declarations are syntactic method calls, not grammar-level // heritage clauses. Must run BEFORE `buildMro` so MRO construction sees // the freshly-emitted IMPLEMENTS edges. if (!callableFlowOnly) { - provider.emitHeritageEdges?.(graph, parsedFiles, nodeLookup, finalized); + provider.emitHeritageEdges?.( + graph, + parsedFiles, + nodeLookup, + finalized, + recordHeritageTypeArguments, + ); } // Implicit IMPORTS-edge hook — for languages whose files have compiler- // implicit cross-file visibility (no syntactic import statement). The @@ -980,6 +1023,10 @@ export function runScopeResolution( // receiver (`console.log`, `fetch(...)`). Same hook, same spelling as // the `emitFreeCallFallback` wiring below. isBuiltInName: provider.languageProvider.isBuiltInName, + // What each heritage clause instantiated its base with, so the + // interface-dispatch fan-out can refuse an incompatible instantiation + // (#2912). Empty under `callableFlowOnly`, which emits no dispatch. + heritageTypeArguments, }, ); const receiverExtras = receiverBound.emitted; diff --git a/gitnexus/src/core/ingestion/scope-resolution/utils/generic-instantiation.ts b/gitnexus/src/core/ingestion/scope-resolution/utils/generic-instantiation.ts new file mode 100644 index 000000000..b7dce0a72 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/utils/generic-instantiation.ts @@ -0,0 +1,345 @@ +/** + * Generic-instantiation compatibility for interface-dispatch fan-out (#2912). + * + * ── THE PROBLEM ────────────────────────────────────────────────────────────── + * + * Heritage edges are stored between DECLARATIONS, and a declaration answers for + * every instantiation of itself: `class UserValidator : IValidator` and + * `class IntValidator : IValidator` both land in `IValidator`'s subtype + * list, indistinguishable once the arguments are erased. A call through an + * `IValidator` receiver then fans out to `IntValidator.Check(int)` — a + * target no runtime dispatch can produce, because the two instantiations are + * unrelated types. + * + * ── THE MODEL ──────────────────────────────────────────────────────────────── + * + * The subtype closure is walked carrying a SUBSTITUTION, exactly as a type + * checker would. Each hop takes the arguments the supertype is currently known + * to be instantiated with and the arguments the subtype WROTE on that supertype, + * and unifies them positionally: + * + * receiver `IValidator` → super args ['string'] + * `UserValidator : IValidator` ['string'] ≡ ['string'] → keep + * `IntValidator : IValidator` ['int'] ✗ → prune + * `Wrapper : IValidator` ['T'] binds T = string → keep, + * and the next hop sees `Wrapper` instantiated with ['string'], so + * `IntWrapper : Wrapper` prunes and `StrWrapper : Wrapper` + * survives. + * + * ── WHY EVERY UNCERTAINTY FAILS OPEN ───────────────────────────────────────── + * + * Dispatch fan-out is an over-approximation by design: a missing edge is a + * silently wrong answer to "what can this call reach", while a surplus edge is + * the pre-existing, documented imprecision. So this only ever prunes on POSITIVE + * evidence that two instantiations differ, and returns `compatible` for every + * shape it cannot decide — unknown arguments on either side, an arity it cannot + * line up, or an argument that might be a type variable this pipeline did not + * capture. `SymbolDefinition.typeParameters` and `ReferenceSite.typeArguments` + * are both absent for languages whose captures do not populate them, and absence + * means "unknown", never "not generic"; a language that captures neither is + * therefore left with exactly the pre-#2912 fan-out. + * + * That is also why the arguments are RESOLVED rather than string-compared. Two + * spellings that differ are only certainly different types when both bind to + * something this pipeline can see — an imported `User` and a `Models.User` are + * one type, and a lone `T` may be a type variable the capture layer never + * recorded. The caller supplies the evidence (scope lookup + built-in names); + * anything it cannot ground keeps the target. + */ + +import type { TypeParameter } from 'gitnexus-shared'; + +/** + * What a written type argument turned out to name, as far as the pipeline can + * tell from where it was written. + * + * A spelling is GROUNDED when either field answers: it bound to a declaration, + * or the language calls the name built in. Two grounded arguments that are not + * the same type are the only evidence that licenses a prune. An ungrounded + * spelling is `unknown` — it may be an external type, but it may equally be a + * TYPE VARIABLE in a language whose captures do not record type parameters, and + * pruning on that would delete `class Box : IValidator` from every + * instantiation's fan-out. + */ +export interface GroundedTypeArgument { + /** Identity of the declaration this spelling bound to, when it bound to one. + * Comparing identities rather than spellings is what makes `Models.User` and + * an imported `User` one type. */ + readonly definitionId?: string; + /** The language declares this name built in (`string`, `int`). */ + readonly builtIn: boolean; + /** + * The name is a TYPE PARAMETER of a declaration enclosing where it was + * written — the `T` of `void Run(IValidator v)` at the call site, or of + * an outer class around a nested one's heritage clause. + * + * It stands for a different type at every instantiation, so it cannot be + * compared with anything, and it must be recognised SEPARATELY from + * ungrounded: a bounded `T extends User` grounds to its bound's declaration, + * and comparing that bound against a concrete argument would prune every + * implementor of a call written through `IValidator`. + */ + readonly typeVariable?: boolean; +} + +/** Resolve a written type argument from the scope it was written in. */ +type TypeArgumentResolver = (name: string) => GroundedTypeArgument; + +/** + * Generic arguments written on a heritage clause, keyed by the GRAPH-ID pair of + * the edge they were written on — see {@link heritageTypeArgumentsKey}. + * + * Graph ids rather than def ids because that is the identity the heritage edge + * itself carries, and because same-file partial declarations share one node: a + * base listed on any part is the base of the whole type. Absent for every + * non-generic base, for every language whose captures do not record arguments, + * and for heritage that never passes through the inheritance pre-pass (Ruby's + * `include`, Go's structural implements) — all of which read as "unknown". + */ +export type HeritageTypeArguments = ReadonlyMap; + +/** + * Records one heritage edge's instantiation, from whichever pass emitted that + * edge — the generic inheritance pre-pass, or a language's own + * `ScopeResolver.emitHeritageEdges` for heritage the pre-pass cannot express + * (Rust `impl Trait for S`, Dart's `implements` markers). + * + * The ids MUST be the same pair the emitted edge carries, because the dispatch + * walk looks the instantiation up by the edge it is crossing. Recording nothing + * is always safe: absence reads as "unknown" and keeps every target. + */ +export type HeritageTypeArgumentSink = ( + subtypeGraphId: string, + supertypeGraphId: string, + typeArguments: readonly string[], +) => void; + +/** Key for {@link HeritageTypeArguments}. NUL-separated because a graph id + * embeds a file path, and a path may legally contain every other separator a + * reader would reach for first — `:`, `|`, even a space. */ +export function heritageTypeArgumentsKey(subtypeGraphId: string, supertypeGraphId: string): string { + return `${subtypeGraphId}\u0000${supertypeGraphId}`; +} + +/** One hop of the subtype closure, expressed as a substitution problem. */ +export interface HeritageInstantiationStep { + /** + * Arguments the SUPERTYPE is currently known to be instantiated with, in + * declaration order — `['string']` for a receiver typed `IValidator`. + * `undefined` when the instantiation is unknown, which keeps every subtype. + */ + readonly supertypeArguments: readonly string[] | undefined; + /** + * Arguments the SUBTYPE wrote on the supertype in its own heritage clause — + * `['string']` for `: IValidator`, `['T']` for `: IValidator`. + * `undefined` when the subtype named the supertype without arguments, or when + * the language's captures did not record them. + */ + readonly heritageArguments: readonly string[] | undefined; + /** The SUBTYPE's own declared type parameters, in declaration order. */ + readonly subtypeParameters: readonly TypeParameter[] | undefined; + /** + * Does an EMPTY `subtypeParameters` mean "this declaration is not generic"? + * + * The distinction decides whether an unresolvable argument may be pruned on. + * `SymbolDefinition.typeParameters` is absent both for a plain `class C : + * IValidator` and for every declaration in a language whose captures + * record no parameters at all — and the two demand opposite answers, because + * in the second case the `T` of `class Box : IValidator` is also absent + * and would be read as a concrete type named "T". + * + * True when the caller has evidence the parameters ARE recorded: this + * declaration itself lists some, or some declaration in the same language run + * does. False leaves an unresolvable argument unusable as evidence, which is + * the pre-#2912 fan-out for that language. + */ + readonly subtypeParametersComplete: boolean; + /** Ground a supertype argument — resolved from the RECEIVER's scope. */ + readonly resolveSupertypeArgument: TypeArgumentResolver; + /** Ground a heritage argument — resolved from where the HERITAGE was written, + * a different scope from the call site and usually a different file. */ + readonly resolveHeritageArgument: TypeArgumentResolver; + /** Optional language normalization applied to both sides before they are + * compared, for aliases that denote one type (C# `string` / `String`). */ + readonly normalize?: (name: string) => string; +} + +interface HeritageInstantiationResult { + /** False ONLY when the two instantiations are provably different types. */ + readonly compatible: boolean; + /** + * What the SUBTYPE is instantiated with, for the next hop of the walk: + * its own type parameters resolved through this step's bindings. `undefined` + * whenever any parameter stayed unbound — a partially known list would have to + * be tracked per slot, and the whole-list unknown is the fail-open reading. + */ + readonly subtypeArguments: readonly string[] | undefined; +} + +const UNKNOWN: HeritageInstantiationResult = { compatible: true, subtypeArguments: undefined }; + +/** Stand-in for a language that declares no `normalizeTypeArgument`. Module + * level so the 15 that do not are not charged a closure per hop. */ +const identity = (name: string): string => name; + +/** A resolved declaration, or a name the language calls built in. Anything else + * might be a type variable nobody captured. */ +function grounded(type: GroundedTypeArgument): boolean { + return type.definitionId !== undefined || type.builtIn; +} + +/** + * Does this spelling name a SET of types rather than one — a Java wildcard + * (`?`, `? extends User`, `? super User`), a Kotlin star projection (`*`) or + * use-site variance (`out User`, `in User`)? + * + * Nullable decoration (`User?`, `string?`) matches the `?` test too. Keeping it + * in is deliberate: an argument that may or may not be null is still the same + * type for dispatch purposes, so the only cost is declining to prune a position + * that could have been pruned — the direction every other uncertainty here + * takes. + */ +function isWildcard(name: string): boolean { + return WILDCARD_MARK.test(name) || USE_SITE_VARIANCE.test(name); +} + +const WILDCARD_MARK = /[?*]/; +/** Leading whitespace is matched rather than trimmed off, so a spelling that + * carries none — the overwhelming majority — costs no allocation. */ +const USE_SITE_VARIANCE = /^\s*(?:out|in)\s/; + +/** Drop insignificant whitespace so two spellings of one instantiation compare + * equal: `Map` and `Map` are the same type, and + * which one a capture produced depends on how the source was written. */ +function compact(name: string): string { + return name.replace(INSIGNIFICANT_WHITESPACE, ''); +} + +const INSIGNIFICANT_WHITESPACE = /\s+/g; + +/** Last segment of a qualified spelling: `java.lang.String` → `String`, + * `System::Text::Encoding` → `Encoding`. Used only when a name did not + * resolve, so the qualifier is exactly the part nothing can check. */ +function simpleName(name: string): string { + const cut = Math.max(name.lastIndexOf('.'), name.lastIndexOf(':')); + return cut === -1 ? name : name.slice(cut + 1); +} + +/** + * Unify one heritage hop and carry the substitution to the subtype. + * + * Pure and total: no lookups of its own, no throwing, and every branch it cannot + * decide answers {@link UNKNOWN} — compatible, with an unknown instantiation. + */ +export function stepHeritageInstantiation( + step: HeritageInstantiationStep, +): HeritageInstantiationResult { + const { supertypeArguments, heritageArguments, subtypeParameters } = step; + if (supertypeArguments === undefined || heritageArguments === undefined) return UNKNOWN; + // An arity that does not line up means one of the two lists is not what this + // code thinks it is (a spelling the argument splitter read differently, a + // partial specialization, a variadic parameter pack). Nothing positive can be + // concluded from a mismatched pairing, so nothing is. + if (supertypeArguments.length !== heritageArguments.length) return UNKNOWN; + + const normalize = step.normalize ?? identity; + const bindings = new Map(); + for (let i = 0; i < heritageArguments.length; i++) { + const written = heritageArguments[i] as string; + const actual = supertypeArguments[i] as string; + // A type VARIABLE of the subtype binds rather than compares: `Wrapper : + // IValidator` under an `IValidator` receiver means T = string. + if (subtypeParameters?.some((p) => p.name === written) === true) { + const previous = bindings.get(written); + if (previous !== undefined) { + // The SAME variable in a second position must receive the same type: + // `class C : Pair` cannot be a `Pair`, and + // overwriting the first binding would both accept that and hand the + // next hop a substitution the subtype never had. Unify instead — but + // prune only on the evidence the concrete path below demands, since two + // spellings that differ are not yet two types. + const first = step.resolveSupertypeArgument(previous); + const second = step.resolveSupertypeArgument(actual); + if ( + isWildcard(previous) || + isWildcard(actual) || + first.typeVariable === true || + second.typeVariable === true + ) { + return UNKNOWN; + } + if (compact(normalize(previous)) === compact(normalize(actual))) continue; + if (first.definitionId !== undefined && second.definitionId !== undefined) { + if (first.definitionId === second.definitionId) continue; + return { compatible: false, subtypeArguments: undefined }; + } + if (grounded(first) && grounded(second)) { + return { compatible: false, subtypeArguments: undefined }; + } + // One side names something this pipeline cannot see. The position is + // undecided, and so is the binding it would have carried onward. + return UNKNOWN; + } + bindings.set(written, actual); + continue; + } + // A WILDCARD names a set of types, not one: `Repo` holds a + // `Repo` perfectly well, and Kotlin's `Repo<*>` or `Repo` + // say the same thing in their own spelling. Comparing one against a + // concrete argument answers a question neither spelling asked, so the + // position is simply unknown. Nullable decoration (`User?`, `string?`) trips + // the same test, which costs a little precision in the safe direction. + if (isWildcard(written) || isWildcard(actual)) continue; + // Normalized once and reused by the simple-name compare below, so both + // comparisons are visibly made on the same normalization. + const writtenKey = compact(normalize(written)); + const actualKey = compact(normalize(actual)); + if (writtenKey === actualKey) continue; + // Differing spellings, which is not yet a difference of TYPE. Resolve both + // where each was written and compare what they bound to: an imported `User` + // and a `Models.User` are one declaration, and a declaration is what the + // instantiation is actually about. + const heritageType = step.resolveHeritageArgument(written); + const supertypeType = step.resolveSupertypeArgument(actual); + // A type PARAMETER in scope where it was written stands for a different type + // at every instantiation, so it is not comparable with anything — and + // `subtypeParametersComplete` says nothing about it, because that flag is + // evidence about the SUBTYPE's parameter list while this `T` belongs to the + // enclosing generic method or class at the other end. Without this branch a + // call written `void Run(IValidator v) { v.Check(x); }` prunes every + // implementor: `T` is unbounded, so it grounds to nothing, and a bounded one + // grounds to its BOUND and compares unequal to the concrete argument. + if (heritageType.typeVariable === true || supertypeType.typeVariable === true) return UNKNOWN; + if (heritageType.definitionId !== undefined && supertypeType.definitionId !== undefined) { + if (heritageType.definitionId === supertypeType.definitionId) continue; + return { compatible: false, subtypeArguments: undefined }; + } + // At least one side names something outside this workspace — `String`, + // `HttpClient`, a generated type. That is the COMMON case for a generic + // argument, so refusing to decide here would make the whole filter inert; + // what is compared instead is the simple name, which cannot tell + // `a.User` from `b.User` (kept, the over-approximating direction) but does + // tell `String` from `Integer`. + if (simpleName(writtenKey) === simpleName(actualKey)) continue; + // The one thing a spelling difference must not be read as: a TYPE VARIABLE + // this pipeline never captured. Where the subtype's parameter list is not + // known to be complete, only a pair of grounded names — resolved or built + // in — is safe to prune on. A variable that IS captured never reaches here: + // the subtype's own bind above, and any other declaration's through the + // `typeVariable` test, which is why that test has to be reliable — see the + // type-parameter captures on generic METHODS. + if (!step.subtypeParametersComplete && !(grounded(heritageType) && grounded(supertypeType))) { + return UNKNOWN; + } + return { compatible: false, subtypeArguments: undefined }; + } + + if (subtypeParameters === undefined || subtypeParameters.length === 0) return UNKNOWN; + const subtypeArguments: string[] = []; + for (const parameter of subtypeParameters) { + const bound = bindings.get(parameter.name); + if (bound === undefined) return UNKNOWN; + subtypeArguments.push(bound); + } + return { compatible: true, subtypeArguments }; +} diff --git a/gitnexus/src/core/ingestion/utils/template-arguments.ts b/gitnexus/src/core/ingestion/utils/template-arguments.ts index 9d1f25e8f..6ed710eb7 100644 --- a/gitnexus/src/core/ingestion/utils/template-arguments.ts +++ b/gitnexus/src/core/ingestion/utils/template-arguments.ts @@ -47,6 +47,129 @@ export function extractTemplateArguments(text: string): string[] | undefined { return args.length > 0 ? args : undefined; } +/** + * The type ARGUMENTS a reference applies to its base, read from the reference's + * own source spelling: `IValidator` → `['string']`, `Base[User]` → + * `['User']`, `Repository` → `undefined`. + * + * The inverse direction of {@link erasedTypeApplication}, which rebuilds the + * `Base` SPELLING so a lookup can stay grounded; this returns the + * ARGUMENTS so a consumer that has already resolved the base can ask which + * instantiation it was (#2912). + * + * Both bracket families count, because both spell type application in a + * heritage position — `class C : IValidator` and Go's `struct { Base[int] }` + * / Python's `class C(Base[User])`. What is NOT accepted is anything that fails + * to be exactly one balanced, non-empty list closing at the very end: + * + * - `Base(args)` — a C# primary-constructor base, not an application. + * - `Foo[]` — an empty list is an array spelling, not arguments. + * - `(Int) -> Unit` — a Kotlin function type, whose `>` closes nothing. + * + * Declining is the safe outcome for all of them: absence reads as "unknown" + * and every consumer of this fails open on it. + */ +export function typeApplicationArguments(spelling: string): string[] | undefined { + const text = spelling.trim(); + const inner = balancedTailList(text, text.search(OPENING_BRACKET)); + if (inner === undefined) return undefined; + const args = splitTopLevelArguments(inner); + return args.length > 0 ? args : undefined; +} + +const OPENING_BRACKET = /[<[]/; + +/** + * The contents of the ONE balanced bracket list that opens at `start` and closes + * on the LAST character of `text` — `Repo` from index 4 yields `User`. + * + * `undefined` for everything else, which is what both callers need: a list that + * closes early (`User[][]`, `Repo?`), one that never closes + * (`Map Unit>`), an empty one (`User[]`), one whose brackets + * cross families (`Foo`), or no bracket at all (`start === -1`). Shared + * because the rule is one rule — `erasedTypeApplication` rebuilds the spelling + * from it and `typeApplicationArguments` splits it, and two copies of a scan + * this fiddly would be free to disagree about `User[][]`. + */ +function balancedTailList(text: string, start: number): string | undefined { + const opener = text[start]; + if (opener !== '<' && opener !== '[') return undefined; + // A STACK of expected closers rather than one counter for one family: a + // counter scanning `Foo` never sees the `]`, reaches the final `>` at + // depth zero and reports `Bar]` as a balanced argument list. Every closer must + // now match the opener it actually closes, so a crossed pair declines — which + // is what the contract above says and what both callers read as "unknown". + const expected: string[] = []; + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (ch === '<' || ch === '[') { + expected.push(ch === '<' ? '>' : ']'); + continue; + } + if (ch !== '>' && ch !== ']') continue; + if (expected.pop() !== ch) return undefined; + if (expected.length === 0) { + return i === text.length - 1 && i > start + 1 ? text.slice(start + 1, i) : undefined; + } + } + return undefined; +} + +/** Split `string, Map` on the commas that are not inside a nested + * list. Tracks BOTH bracket families so a mixed spelling (`List`) + * does not split inside the inner one. */ +function splitTopLevelArguments(inner: string): string[] { + const args: string[] = []; + let depth = 0; + let tokenStart = 0; + const push = (end: number): void => { + const token = inner.slice(tokenStart, end).trim(); + if (token.length > 0) args.push(token); + }; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (ch === '<' || ch === '[') depth++; + else if (ch === '>' || ch === ']') depth--; + else if (ch === ',' && depth === 0) { + push(i); + tokenStart = i + 1; + } + } + push(inner.length); + return args; +} + +/** + * Index of the `(` that matches the trailing `)` of `text`, or -1 when the text + * does not end in a balanced call suffix. + * + * Shared for the same reason as {@link balancedTailList}: this scan is fiddly + * enough that two copies would be free to disagree, and it has two unrelated + * readers — splitting a receiver chain at its call, and stripping a base's + * constructor invocation off a heritage spelling. + */ +export function matchingOpenParen(text: string): number { + if (!text.endsWith(')')) return -1; + let depth = 0; + for (let i = text.length - 1; i >= 0; i--) { + const ch = text[i]; + if (ch === ')') depth++; + else if (ch === '(') { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +/** Drop a balanced `(...)` that ENDS the text — the argument list of a base's + * constructor invocation, as in `record R : Base(x)` or Kotlin + * `class C : Bar()`. Anything else is returned unchanged. */ +export function stripTrailingCallSuffix(text: string): string { + const open = matchingOpenParen(text); + return open === -1 ? text : text.slice(0, open).trimEnd(); +} + export function stripTemplateArguments(text: string): string { const start = text.indexOf('<'); if (start === -1) return text; @@ -151,21 +274,8 @@ export function erasedTypeApplication(typeRef: TypeRef): string | undefined { if (spelling === undefined) return undefined; const base = typeRef.rawName.trim(); if (base.length === 0 || !spelling.startsWith(base)) return undefined; - const rest = spelling.slice(base.length).trimStart(); - const opener = rest[0]; - if (opener !== '<' && opener !== '[') return undefined; - const closer = opener === '<' ? '>' : ']'; - let depth = 0; - for (let i = 0; i < rest.length; i++) { - if (rest[i] === opener) depth++; - else if (rest[i] === closer) { - depth--; - // The list the spelling opened must close on the LAST character, and must - // have held something: `Repo[User]` yes, `User[]` no, `User[][]` no. - if (depth === 0) { - return i === rest.length - 1 && i > 1 ? `${base}<${rest.slice(1, i)}>` : undefined; - } - } - } - return undefined; + // The list must open immediately after the base and close on the LAST + // character, holding something: `Repo[User]` yes, `User[]` no, `User[][]` no. + const inner = balancedTailList(spelling.slice(base.length).trimStart(), 0); + return inner === undefined ? undefined : `${base}<${inner}>`; } diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 4548385ef..270e2c0ad 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -518,8 +518,22 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // main; 67 is the next free value above every in-flight claim (main 66, #2939's // 64), which is the ledger rule above — re-check against the claims, not just // against main. +// +// 67 -> 68 for #2912's `ReferenceSite.typeArguments`: the generic arguments a +// heritage reference was written with (`: IValidator`), derived at +// EXTRACTION time from the anchor's spelling. A warm cache replays `inherits` +// sites with the field absent, absence is the fail-open "unknown", and +// generic-instantiation filtering therefore degrades to the pre-fix fan-out on +// exactly the unchanged files — silent, and passing every cold-run test. +// +// This branch staged 64 when main held 60 and #2935/#2936/#2934 claimed 61/62/63. +// All three have since landed and cascaded main to 67, burying 64 inside main's +// own ledger — the EIGHTH time the re-check moved a number, and the reason the +// re-check is a merge step rather than a one-time choice. 68 is the next free +// value above every in-flight claim at this merge (main 67, #2891's 59, #1616's +// stale 2), which is the rule above: above every claim, not above origin/main. // RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. -const SCHEMA_BUMP = 67; +const SCHEMA_BUMP = 68; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json index ed093716c..86dc818ce 100644 --- a/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/csharp-captures-golden/expected-captures.json @@ -41,7 +41,7 @@ }, "csharp-assignment-chain/Program.cs": { "captureGroups": 32, - "digest": "7698bdabe97661a2a2539a13cf9d886cd7efb2f4924b17a15597ce63cec5126a" + "digest": "6b8eddb5525ef0358276dc18fba264ee0443a8adf5036a3300891507b99c36ab" }, "csharp-async-binding/Order.cs": { "captureGroups": 9, @@ -49,11 +49,11 @@ }, "csharp-async-binding/OrderService.cs": { "captureGroups": 14, - "digest": "712fb5f3a791581ab56c37df58d8245a17a674a6d2f7bd25b2bc8d1632f751c3" + "digest": "492b2ceffaeae03b6a9d673f961dc5386bf08496ef4a8ae70dae68a604801c2f" }, "csharp-async-binding/Program.cs": { "captureGroups": 37, - "digest": "73735c3910ed4db423302d9575cec86156d420e9961c592c34e0436301cac7ce" + "digest": "fc6bb5b9887e5193c90c687248d890873f5eb40f6a8e5b597729311dd3cc9f0a" }, "csharp-async-binding/User.cs": { "captureGroups": 9, @@ -61,11 +61,11 @@ }, "csharp-async-binding/UserService.cs": { "captureGroups": 14, - "digest": "a4e6b093fa23a86313bc468f8b6a89a96d5c90b1d16f166417745bd36dfbd10f" + "digest": "e68423fbb601a61a100d01ef070e0d37f817be4a4c6555500b56fa2ed290adce" }, "csharp-call-result-binding/App.cs": { "captureGroups": 27, - "digest": "3d8c7dc0b7f5bd60c74d6a595bd4b49bdb13b62522fb7f0c1434495e10e1171b" + "digest": "7439cd5fada77ae186fb76594404a8650e21b4892ff268dfbcad6c3ec741c478" }, "csharp-calls/Services/UserService.cs": { "captureGroups": 11, @@ -93,7 +93,7 @@ }, "csharp-chain-call/Services/UserService.cs": { "captureGroups": 11, - "digest": "9833795eeb79a08ef9c8afb66a58b789ab314e48c1ae3193fe87fec279c55374" + "digest": "36a822100dccd931041f95be155b426d87f8e3d1dd1e2268f152b6f8fbffc6d5" }, "csharp-child-extends-parent/src/App.cs": { "captureGroups": 13, @@ -125,11 +125,11 @@ }, "csharp-deep-field-chain/Service.cs": { "captureGroups": 14, - "digest": "30a18501a48916294ef08b2694d297bd46c72ad1d16bb654968c4293b9c1cd14" + "digest": "daeb42918323c3f79de9b72ffc72d2c61bb085a65ffb1f67ca3a0d9a78ec06e8" }, "csharp-dictionary-keys-values/App.cs": { "captureGroups": 21, - "digest": "ee8eb9c569b71d7050f292bc7fbf89b68cdbb60b4bcd557f2523c86831891543" + "digest": "1f6dfccf8eef881d22795dc6aa80bd267f0ee6f12538c7cabf5cac71db6a7f57" }, "csharp-dictionary-keys-values/Repo.cs": { "captureGroups": 7, @@ -153,7 +153,7 @@ }, "csharp-field-types/Service.cs": { "captureGroups": 11, - "digest": "c38e3db8241460f2c3c295536c760a2452c0f1bc0ee084f7c76e63979ad84b51" + "digest": "4cb300e33d2dcc08f869b6752c4655a34b67aebeb4baa44f37411117486d5f1d" }, "csharp-foreach/Models/Repo.cs": { "captureGroups": 8, @@ -165,7 +165,7 @@ }, "csharp-foreach/Program.cs": { "captureGroups": 20, - "digest": "810f0f65e956343cf6817918dc5b28ce7bc1f1f755f89e008a6e8b05864ff469" + "digest": "0f71f4a62926fb335d32b456d5778812519c0eb2bf85528e2e9073db1b976749" }, "csharp-frozen-binding-collision/App/Program.cs": { "captureGroups": 18, @@ -189,11 +189,11 @@ }, "csharp-generic-type-refs/Program.cs": { "captureGroups": 25, - "digest": "e0cd6ea7dc08f66b651027f964f7a36fd3c4efb7a4584df5f14935b93faace5a" + "digest": "28246dd1c88ecfe07fcee84ba314dbd9e39ccd0c30f20b8fb4633ec71e48e375" }, "csharp-grandparent-resolution/Models/A.cs": { "captureGroups": 10, - "digest": "3cd545b2cbec5fee82e9e3d09f2d2ff7ff940e3bf4b597d7c9080fcd8b526675" + "digest": "159a52b959e021b1a34cc0dfbf1ba1a0748a0f29c84634948e2e85afc75db003" }, "csharp-grandparent-resolution/Models/B.cs": { "captureGroups": 6, @@ -221,7 +221,7 @@ }, "csharp-inline-constructor-receiver/src/Svc.cs": { "captureGroups": 19, - "digest": "c2ec7f452c244d7fda931e32c16e46cc7c59e15f404a4413d18f522ed6c492bb" + "digest": "cfbc783ca38a1149913b71f5dead7fd7a7048ca313cfaa7806c68ac9f1867e1f" }, "csharp-interface-default-method/App.cs": { "captureGroups": 12, @@ -321,7 +321,7 @@ }, "csharp-method-chain-binding/App.cs": { "captureGroups": 60, - "digest": "2b4761e1dfe2d48ac25cfda7ccce95175607926b47db43726f38ad2a16acc6f1" + "digest": "4cdccde81efbe41cac6bc82e33b365ccd79481a440e65012f78cb543421c9873" }, "csharp-method-enrichment/Animal.cs": { "captureGroups": 18, @@ -393,7 +393,7 @@ }, "csharp-null-check-narrowing/Services/App.cs": { "captureGroups": 36, - "digest": "5d840c524610b6a84a2f09981c15327e9f7ea5c2c4b11b09e959d880ac6b9bc9" + "digest": "6c7fa1daf5d12a60403a63d5d29c1b42b99370d12f4be53c8e56bb31e5b09d8a" }, "csharp-null-conditional/App.cs": { "captureGroups": 17, @@ -425,7 +425,7 @@ }, "csharp-overload-interface/App/Caller.cs": { "captureGroups": 15, - "digest": "f1ea2e564c46dab5fb93fb19e81ab3411f458b172820b5dc0bdc57f49ffa88e0" + "digest": "5769b1eda360cd588192335e47035683dae751b9f67e0528cccc066b1e4ed887" }, "csharp-overload-interface/App/Logger.cs": { "captureGroups": 14, @@ -445,7 +445,7 @@ }, "csharp-overload-param-types/Models/UserService.cs": { "captureGroups": 30, - "digest": "178e1a7dd5b07ba3361e1eb28ce73ca6a6075fa8ecb8f2ca40e8e87a410de552" + "digest": "ba08bc90619c578582465cb9f640fd0bf0640a5a6a84fdfed0be987802086bb3" }, "csharp-parent-resolution/src/Models/BaseModel.cs": { "captureGroups": 8, @@ -465,7 +465,7 @@ }, "csharp-pattern-matching/Services/AnimalService.cs": { "captureGroups": 13, - "digest": "2623dcd94520473dc3dd830cfc21675349b6981185b779db3f5a47200b2f44a3" + "digest": "6f308b4411f9ad397e2789d7f85eaaaed78f7879dd16f4d7b8d8e7639335d302" }, "csharp-primary-ctor-heritage/src/BaseEntity.cs": { "captureGroups": 6, @@ -581,7 +581,7 @@ }, "csharp-return-type/Models/User.cs": { "captureGroups": 23, - "digest": "6681e6830c71c25908e50273e4babb41611bc229395d1dbab70ac9f219b68ca8" + "digest": "8ca5e28d14a29fb1f29ca6f19300b26fd99d20bc99bd9f537787a41cd9fe6196" }, "csharp-return-type/Services/App.cs": { "captureGroups": 16, @@ -621,7 +621,7 @@ }, "csharp-spurious-edges-no-csproj/Services/OrderService.cs": { "captureGroups": 15, - "digest": "2574c61dc312d531301a6d08c828ac743b1198e32946980c0f44bc115ec9bcdc" + "digest": "77a89bc40ea022b9e795adcc1bf5e8a1bc67d8d1c5061c2fe990ec3d72248de0" }, "csharp-spurious-edges/Legacy/Tasks.cs": { "captureGroups": 8, @@ -633,7 +633,7 @@ }, "csharp-spurious-edges/Services/OrderService.cs": { "captureGroups": 15, - "digest": "2574c61dc312d531301a6d08c828ac743b1198e32946980c0f44bc115ec9bcdc" + "digest": "77a89bc40ea022b9e795adcc1bf5e8a1bc67d8d1c5061c2fe990ec3d72248de0" }, "csharp-struct-overloads/src/Calc.cs": { "captureGroups": 19, @@ -689,7 +689,7 @@ }, "csharp-var-foreach/Program.cs": { "captureGroups": 32, - "digest": "a9c7bf1f2425cece1ecb698abc0c6fa5e7a3bb24e3cc7d2dba50ef7d0651badc" + "digest": "58052d12af8b6e4f34924d59bd965773ce6083cb557adebe28eb1d3edcd41b7a" }, "csharp-variadic-resolution/Services/App.cs": { "captureGroups": 10, @@ -705,7 +705,7 @@ }, "csharp-write-access/Service.cs": { "captureGroups": 11, - "digest": "aa6d8a61ac39db413df10a6bc8b9bad3305327dcdce09e01cf01eff31f945537" + "digest": "f97be4b109be6bdaa583e2e7b3268b2fb90ac1ce3cfaf0291a7873f09103a2f9" }, "synthetic:dao-20": { "captureGroups": 263, diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 1505032d6..793451b4a 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -661,7 +661,7 @@ }, "rust-qualified-trait/src/widget.rs": { "captureGroups": 23, - "digest": "ee34385539f7e9398123c056738c6a662a80dac41fc038db96fab0da5c84c8ac" + "digest": "f131767bf717a065166a5d8b6bdd969e8ea31c8725eecbedeac694b2e2aaeea5" }, "rust-receiver-resolution/src/main.rs": { "captureGroups": 25, diff --git a/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts b/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts index d3473d034..8b4da76d9 100644 --- a/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts +++ b/gitnexus/test/integration/resolvers/generic-field-receiver-matrix.test.ts @@ -1332,7 +1332,13 @@ class PyMultiSvc: rows: [ { caller: 'runTsNested', - targets: ['Method:a.ts:Repo.save#1', 'Method:a.ts:UserRepo.save#1'], + // No `UserRepo.save`, and that is the #2912 filter doing its job rather + // than the receiver failing to resolve: `UserRepo implements Repo` + // is an implementor of a DIFFERENT instantiation from this receiver's + // `Repo>`, so no dispatch through it can reach `UserRepo`. + // The primary edge to the interface's own declaration is unaffected, + // which is what still proves the receiver typed correctly here. + targets: ['Method:a.ts:Repo.save#1'], note: 'DISCRIMINATING nested generic: TypeScript reaches the shared lookup, unlike the Java/Kotlin/Rust spelling rows above', }, { diff --git a/gitnexus/test/integration/resolvers/generic-interface-dispatch.test.ts b/gitnexus/test/integration/resolvers/generic-interface-dispatch.test.ts new file mode 100644 index 000000000..845f5aed6 --- /dev/null +++ b/gitnexus/test/integration/resolvers/generic-interface-dispatch.test.ts @@ -0,0 +1,492 @@ +/** + * Interface-dispatch fan-out is generic-instantiation aware (#2912). + * + * `IValidator` and `IValidator` are one DECLARATION and therefore + * one subtype list, so an erased fan-out reaches implementors of instantiations + * the receiver can never hold. Each language here declares two incompatible + * instantiations of one interface with the SAME method name — the shape the + * issue was filed with — plus the cases the filter must not break: a generic + * pass-through implementor, a non-generic interface, and (C#) the predefined + * alias spellings of one type. + * + * Both ways a receiver gets its type are covered, because they reach the + * instantiation by different routes: a DECLARED receiver (`Validator v`) + * carries it on the type binding, while a FOLDED one (`this._validator`, + * `this._holder.Validator`) is typed by the compound fold, which answers with a + * class and reports the spelling separately. + * + * Every implementor lives in its own file so a dispatch target can be named by + * `targetFilePath`: the two `Check` methods are otherwise indistinguishable by + * node name alone. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + getRelationships, + runPipelineFromRepo, + writeFixtureRepo, + type PipelineResult, +} from './helpers.js'; + +/** Files a dispatch edge out of `caller` landed in, deduped and sorted. */ +function dispatchTargetFiles(result: PipelineResult, caller: string, member: string): string[] { + const files = getRelationships(result, 'CALLS') + .filter( + (edge) => + edge.source === caller && + edge.target === member && + edge.rel.reason === 'interface-dispatch', + ) + .map((edge) => path.basename(edge.targetFilePath)); + return [...new Set(files)].sort(); +} + +/** Files ANY resolved call out of `caller` landed in — primary edges included. */ +function calledFiles(result: PipelineResult, caller: string, member: string): string[] { + const files = getRelationships(result, 'CALLS') + .filter((edge) => edge.source === caller && edge.target === member) + .map((edge) => path.basename(edge.targetFilePath)); + return [...new Set(files)].sort(); +} + +describe('C# generic interface dispatch (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-generic-dispatch-')); + writeFixtureRepo(root, { + 'IValidator.cs': `namespace Probe; + public interface IValidator { bool Check(T item); }`, + 'UserValidator.cs': `namespace Probe; + public record UserValidator : IValidator { public bool Check(string item) => true; }`, + 'IntValidator.cs': `namespace Probe; + public record IntValidator : IValidator { public bool Check(int item) => true; }`, + 'AliasValidator.cs': `namespace Probe; + public class AliasValidator : IValidator { public bool Check(String item) => true; }`, + 'GlobalAliasValidator.cs': `namespace Probe; + public class GlobalAliasValidator : IValidator { public bool Check(String item) => true; }`, + 'Wrapper.cs': `namespace Probe; + public class Wrapper : IValidator { public bool Check(T item) => true; }`, + 'Runner.cs': `namespace Probe; + public class Runner { + public bool Run(IValidator v) => v.Check("x"); + public bool RunInt(IValidator v) => v.Check(1); + public bool RunAny(IValidator v, TItem item) => v.Check(item); + }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('does not fan a string-instantiated receiver out to the int implementor', () => { + expect(dispatchTargetFiles(result, 'Run', 'Check')).not.toContain('IntValidator.cs'); + }); + + it('still reaches the implementor of the matching instantiation', () => { + expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('UserValidator.cs'); + }); + + it('mirrors the filter for the other instantiation', () => { + const intTargets = dispatchTargetFiles(result, 'RunInt', 'Check'); + expect(intTargets).toContain('IntValidator.cs'); + expect(intTargets).not.toContain('UserValidator.cs'); + }); + + it('keeps a generic pass-through implementor for BOTH instantiations', () => { + // `Wrapper : IValidator` is an implementor of every instantiation — + // T binds to the receiver's argument rather than clashing with it. + expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('Wrapper.cs'); + expect(dispatchTargetFiles(result, 'RunInt', 'Check')).toContain('Wrapper.cs'); + }); + + it('treats the predefined alias spelling as the same instantiation', () => { + // `IValidator` ≡ `IValidator`: C# defines the keyword as an + // alias, so pruning on the spelling would delete a real dispatch target. + expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('AliasValidator.cs'); + expect(dispatchTargetFiles(result, 'RunInt', 'Check')).not.toContain('AliasValidator.cs'); + }); + + it('treats the `global::`-qualified spelling as that same instantiation', () => { + expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('GlobalAliasValidator.cs'); + expect(dispatchTargetFiles(result, 'RunInt', 'Check')).not.toContain('GlobalAliasValidator.cs'); + }); + + it('keeps every implementor when the receiver is typed by a CALLER type variable', () => { + // `RunAny(IValidator v)` knows no instantiation, so the filter + // has nothing to prune on and must restore the unfiltered fan-out. `TItem` + // is a type parameter of the calling METHOD, which the subtype's own + // parameter-list evidence says nothing about. + const targets = dispatchTargetFiles(result, 'RunAny', 'Check'); + expect(targets).toContain('UserValidator.cs'); + expect(targets).toContain('IntValidator.cs'); + }); + + it('still emits the primary edge to the interface declaration', () => { + expect(calledFiles(result, 'Run', 'Check')).toContain('IValidator.cs'); + }); +}); + +describe('C# generic dispatch through a FOLDED receiver (#2912)', () => { + // The dependency-injection shape: the receiver is a field reached through a + // dot, so it is typed by the compound fold rather than by a type binding. + // The fold answers with a CLASS, which no longer carries the instantiation — + // the spelling it typed the position from is what does. + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-folded-dispatch-')); + writeFixtureRepo(root, { + 'IValidator.cs': `namespace Probe; + public interface IValidator { bool Check(T item); }`, + 'UserValidator.cs': `namespace Probe; + public class UserValidator : IValidator { public bool Check(string item) => true; }`, + 'IntValidator.cs': `namespace Probe; + public class IntValidator : IValidator { public bool Check(int item) => true; }`, + 'Service.cs': `namespace Probe; + public class Service { + private readonly IValidator _validator; + public Service(IValidator validator) { _validator = validator; } + public bool Run() => this._validator.Check("x"); + }`, + 'Holder.cs': `namespace Probe; + public class Holder { + public IValidator Validator { get; set; } + }`, + 'ChainRunner.cs': `namespace Probe; + public class ChainRunner { + private readonly Holder _holder; + public ChainRunner(Holder holder) { _holder = holder; } + public bool RunChain() => this._holder.Validator.Check(1); + }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('filters a field-typed receiver by its own instantiation', () => { + const targets = dispatchTargetFiles(result, 'Run', 'Check'); + expect(targets).toContain('UserValidator.cs'); + expect(targets).not.toContain('IntValidator.cs'); + }); + + it("filters a two-hop chain by the LAST hop's instantiation", () => { + // `this._holder.Validator` — the fold walks two members, and it is the + // second one's declared spelling that types the receiver. + const targets = dispatchTargetFiles(result, 'RunChain', 'Check'); + expect(targets).toContain('IntValidator.cs'); + expect(targets).not.toContain('UserValidator.cs'); + }); +}); + +describe('C# non-generic interface dispatch is unaffected (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-plain-dispatch-')); + writeFixtureRepo(root, { + 'IGreeter.cs': `namespace Probe; + public interface IGreeter { string Greet(); }`, + 'Loud.cs': `namespace Probe; + public class Loud : IGreeter { public string Greet() => "HI"; }`, + 'Quiet.cs': `namespace Probe; + public class Quiet : IGreeter { public string Greet() => "hi"; }`, + 'Runner.cs': `namespace Probe; + public class Runner { public string Run(IGreeter g) => g.Greet(); }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('fans out to every implementor when no generics are involved', () => { + expect(dispatchTargetFiles(result, 'Run', 'Greet')).toEqual(['Loud.cs', 'Quiet.cs']); + }); +}); + +describe('Java generic interface dispatch (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-generic-dispatch-')); + writeFixtureRepo(root, { + 'Validator.java': `package probe; + public interface Validator { boolean check(T item); }`, + 'StringValidator.java': `package probe; + public class StringValidator implements Validator { + public boolean check(String item) { return true; } + }`, + 'NumberValidator.java': `package probe; + public class NumberValidator implements Validator { + public boolean check(Integer item) { return true; } + }`, + 'Runner.java': `package probe; + public class Runner { + public boolean run(Validator v) { return v.check("x"); } + public boolean runAny(Validator v, T item) { return v.check(item); } + }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'run', 'check'); + expect(targets).toContain('StringValidator.java'); + expect(targets).not.toContain('NumberValidator.java'); + }); + + it('keeps every implementor when the receiver is typed by a CALLER type variable', () => { + const targets = dispatchTargetFiles(result, 'runAny', 'check'); + expect(targets).toContain('StringValidator.java'); + expect(targets).toContain('NumberValidator.java'); + }); +}); + +describe('Kotlin generic interface dispatch (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-generic-dispatch-')); + writeFixtureRepo(root, { + 'Validator.kt': `package probe +interface Validator { fun check(item: T): Boolean }`, + 'StringValidator.kt': `package probe +class StringValidator : Validator { override fun check(item: String): Boolean = true }`, + 'IntValidator.kt': `package probe +class IntValidator : Validator { override fun check(item: Int): Boolean = true }`, + 'Runner.kt': `package probe +class Runner { + fun run(v: Validator): Boolean = v.check("x") + fun runAny(v: Validator, item: T): Boolean = v.check(item) +}`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'run', 'check'); + expect(targets).toContain('StringValidator.kt'); + expect(targets).not.toContain('IntValidator.kt'); + }); + + it('keeps every implementor when the receiver is typed by a CALLER type variable', () => { + const targets = dispatchTargetFiles(result, 'runAny', 'check'); + expect(targets).toContain('StringValidator.kt'); + expect(targets).toContain('IntValidator.kt'); + }); +}); + +describe('TypeScript generic interface dispatch (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ts-generic-dispatch-')); + writeFixtureRepo(root, { + 'validator.ts': `export interface Validator { check(item: T): boolean; }`, + 'string-validator.ts': `import type { Validator } from './validator.js'; + export class StringValidator implements Validator { + check(item: string): boolean { return true; } + }`, + 'number-validator.ts': `import type { Validator } from './validator.js'; + export class NumberValidator implements Validator { + check(item: number): boolean { return true; } + }`, + 'runner.ts': `import type { Validator } from './validator.js'; + export function run(v: Validator): boolean { return v.check('x'); } + export function runAny(v: Validator, item: T): boolean { return v.check(item); }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'run', 'check'); + expect(targets).toContain('string-validator.ts'); + expect(targets).not.toContain('number-validator.ts'); + }); + + it('keeps every implementor when the receiver is typed by a CALLER type variable', () => { + const targets = dispatchTargetFiles(result, 'runAny', 'check'); + expect(targets).toContain('string-validator.ts'); + expect(targets).toContain('number-validator.ts'); + }); +}); + +describe('Kotlin generic interface dispatch (#2912)', () => { + // Kotlin needs no per-language wiring: it emits heritage through the shared + // pre-pass, so the arguments are read off the clause's own spelling. The + // `class C : Bar()` shape — a base with a constructor invocation — is + // the one `stripTrailingCallSuffix` exists for, and is covered here by the + // supertype being an interface (no call suffix) plus the unit tests on that + // helper. + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-generic-dispatch-')); + writeFixtureRepo(root, { + 'Validator.kt': `package probe + interface Validator { fun check(item: T): Boolean }`, + 'StringValidator.kt': `package probe + class StringValidator : Validator { + override fun check(item: String): Boolean = true + }`, + 'NumberValidator.kt': `package probe + class NumberValidator : Validator { + override fun check(item: Int): Boolean = true + }`, + 'Runner.kt': `package probe + class Runner { fun run(v: Validator): Boolean = v.check("x") }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'run', 'check'); + expect(targets).toContain('StringValidator.kt'); + expect(targets).not.toContain('NumberValidator.kt'); + }); +}); + +describe('Kotlin non-generic interface dispatch is unaffected (#2912)', () => { + // The CONTROL for the case above. Without it, the `not.toContain` there + // passes just as well when Kotlin emits no dispatch edge at all — which is + // exactly what Dart, Python and Rust turned out to do for this receiver + // shape, and why they are not asserted on in this file. + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-plain-dispatch-')); + writeFixtureRepo(root, { + 'Greeter.kt': `package probe + interface Greeter { fun greet(): String }`, + 'Loud.kt': `package probe + class Loud : Greeter { override fun greet(): String = "HI" }`, + 'Quiet.kt': `package probe + class Quiet : Greeter { override fun greet(): String = "hi" }`, + 'Runner.kt': `package probe + class Runner { fun run(g: Greeter): String = g.greet() }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('fans out to every implementor when no generics are involved', () => { + expect(dispatchTargetFiles(result, 'run', 'greet')).toEqual(['Loud.kt', 'Quiet.kt']); + }); +}); + +describe('Go generic interface dispatch (#2912)', () => { + // Go reaches the same filter by a different route: implementors are matched + // STRUCTURALLY rather than by a heritage clause, and the receiver's own + // `Validator[string]` spelling is what carries the instantiation. + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-go-generic-dispatch-')); + writeFixtureRepo(root, { + 'validator.go': `package probe + +type Validator[T any] interface { + Check(item T) bool +}`, + 'string_validator.go': `package probe + +type StringValidator struct{} + +func (s StringValidator) Check(item string) bool { return true }`, + 'number_validator.go': `package probe + +type NumberValidator struct{} + +func (n NumberValidator) Check(item int) bool { return true }`, + 'runner.go': `package probe + +func Run(v Validator[string]) bool { return v.Check("x") }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reaches only the implementor of the receiver instantiation', () => { + const targets = dispatchTargetFiles(result, 'Run', 'Check'); + expect(targets).toContain('string_validator.go'); + expect(targets).not.toContain('number_validator.go'); + }); +}); + +describe('Go non-generic interface dispatch is unaffected (#2912)', () => { + let result: PipelineResult; + let root: string; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-go-plain-dispatch-')); + writeFixtureRepo(root, { + 'greeter.go': `package probe + +type Greeter interface { + Greet() string +}`, + 'loud.go': `package probe + +type Loud struct{} + +func (l Loud) Greet() string { return "HI" }`, + 'quiet.go': `package probe + +type Quiet struct{} + +func (q Quiet) Greet() string { return "hi" }`, + 'runner.go': `package probe + +func Run(g Greeter) string { return g.Greet() }`, + }); + result = await runPipelineFromRepo(root, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('fans out to every implementor when no generics are involved', () => { + expect(dispatchTargetFiles(result, 'Run', 'Greet')).toEqual(['loud.go', 'quiet.go']); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index ac5223a59..4373ebf2c 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -212,21 +212,23 @@ describe('PARSE_CACHE_VERSION', () => { // definitions and scope declarations. This branch staged 65 before #2918's 66 // landed; 67 is the next free value above every in-flight claim (main 66, // #2939's 64), re-checked against the claims rather than against main alone. - it('pins SCHEMA_BUMP to 67 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(67); + // Moved 67 -> 68 for #2912's `ReferenceSite.typeArguments` — heritage generic + // arguments derived at extraction time, so a warm cache replays `inherits` + // sites without them and instantiation-aware dispatch degrades silently to + // the pre-fix fan-out. This branch staged 64 above the claims live at the + // time (61, 62, 63); all three landed and cascaded main to 67, so 68 is the + // next free value above every claim at merge — the rule, re-applied. + it('pins SCHEMA_BUMP to 68 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(68); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly // while being wrong, which is exactly how the 37/38 exact clashes landed. // Every nearby historical value is rejected: origin/main advanced through - // 66, and this branch previously published 65. Pinning 67 and rejecting all + // 67, and this branch previously published 64. Pinning 68 and rejecting all // prior values makes an accidental conflict resolution loud. - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(60); - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(61); - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(62); - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(63); - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(64); - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(65); - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(66); + for (const taken of [60, 61, 62, 63, 64, 65, 66, 67]) { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); + } }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => { diff --git a/gitnexus/test/unit/scope-resolution/generic-instantiation.test.ts b/gitnexus/test/unit/scope-resolution/generic-instantiation.test.ts new file mode 100644 index 000000000..360146886 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/generic-instantiation.test.ts @@ -0,0 +1,367 @@ +/** + * Unit tests for the generic-instantiation matcher behind interface-dispatch + * fan-out (#2912) and for the spelling reader that feeds it. + * + * The integration suite proves the filter reaches real graphs; these pin the + * decisions the filter is MADE of, and above all the fail-open ones — an + * unknown that starts pruning is a silently missing edge, which is the failure + * mode this design is built to avoid. + */ +import { describe, it, expect } from 'vitest'; +import { + heritageTypeArgumentsKey, + stepHeritageInstantiation, + type HeritageInstantiationStep, +} from '../../../src/core/ingestion/scope-resolution/utils/generic-instantiation.js'; +import { typeApplicationArguments } from '../../../src/core/ingestion/utils/template-arguments.js'; +import { csharpScopeResolver } from '../../../src/core/ingestion/languages/csharp/scope-resolver.js'; + +/** A step with everything unresolvable and no parameters — the pessimistic + * baseline each test overrides only what it is about. */ +function step(overrides: Partial): HeritageInstantiationStep { + return { + supertypeArguments: undefined, + heritageArguments: undefined, + subtypeParameters: undefined, + subtypeParametersComplete: true, + resolveSupertypeArgument: () => ({ builtIn: false }), + resolveHeritageArgument: () => ({ builtIn: false }), + ...overrides, + }; +} + +describe('stepHeritageInstantiation — pruning on positive evidence', () => { + it('prunes an implementor of a different instantiation', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['string'], heritageArguments: ['int'] }), + ); + expect(result.compatible).toBe(false); + }); + + it('keeps an implementor of the same instantiation', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['string'], heritageArguments: ['string'] }), + ); + expect(result.compatible).toBe(true); + }); + + it('prunes on a difference in any position, not just the first', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['string', 'User'], heritageArguments: ['string', 'Admin'] }), + ); + expect(result.compatible).toBe(false); + }); + + it('compares what the names RESOLVED to, so a qualifier is not a difference', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['User'], + heritageArguments: ['Models.User'], + resolveSupertypeArgument: () => ({ definitionId: 'def:User', builtIn: false }), + resolveHeritageArgument: () => ({ definitionId: 'def:User', builtIn: false }), + }), + ); + expect(result.compatible).toBe(true); + }); + + it('prunes two names that resolved to different declarations', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['User'], + heritageArguments: ['Admin'], + resolveSupertypeArgument: () => ({ definitionId: 'def:User', builtIn: false }), + resolveHeritageArgument: () => ({ definitionId: 'def:Admin', builtIn: false }), + }), + ); + expect(result.compatible).toBe(false); + }); + + it('applies the language normalizer to both sides before comparing', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['String'], + normalize: (name) => (name === 'string' ? 'String' : name), + }), + ); + expect(result.compatible).toBe(true); + }); + + it('keeps an unresolved qualified spelling of the same simple name', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['String'], heritageArguments: ['java.lang.String'] }), + ); + expect(result.compatible).toBe(true); + }); +}); + +describe('stepHeritageInstantiation — substitution', () => { + it('binds a type variable instead of comparing it', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['T'], + subtypeParameters: [{ name: 'T' }], + }), + ); + expect(result.compatible).toBe(true); + expect(result.subtypeArguments).toEqual(['string']); + }); + + it('carries the binding in the subtype’s own parameter order', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string', 'int'], + heritageArguments: ['V', 'K'], + subtypeParameters: [{ name: 'K' }, { name: 'V' }], + }), + ); + expect(result.subtypeArguments).toEqual(['int', 'string']); + }); + + it('reports an unknown instantiation when a parameter stayed unbound', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['T'], + subtypeParameters: [{ name: 'T' }, { name: 'U' }], + }), + ); + expect(result.compatible).toBe(true); + expect(result.subtypeArguments).toBeUndefined(); + }); + + it('prunes a repeated variable the two positions disagree about', () => { + // `class C : Pair` is not a `Pair` at any + // instantiation; the second position must not overwrite the first. + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string', 'int'], + heritageArguments: ['T', 'T'], + subtypeParameters: [{ name: 'T' }], + resolveSupertypeArgument: () => ({ builtIn: true }), + }), + ); + expect(result.compatible).toBe(false); + }); + + it('keeps a repeated variable both positions agree about', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string', 'string'], + heritageArguments: ['T', 'T'], + subtypeParameters: [{ name: 'T' }], + }), + ); + expect(result.compatible).toBe(true); + expect(result.subtypeArguments).toEqual(['string']); + }); + + it('keeps, without a binding, when a repeated variable cannot be decided', () => { + // `ExternalA` and `ExternalB` are both unresolvable, so the disagreement is + // not proven — and the binding the next hop would inherit is not either. + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['ExternalA', 'ExternalB'], + heritageArguments: ['T', 'T'], + subtypeParameters: [{ name: 'T' }], + }), + ); + expect(result.compatible).toBe(true); + expect(result.subtypeArguments).toBeUndefined(); + }); +}); + +describe('stepHeritageInstantiation — every uncertainty keeps the target', () => { + it('keeps when the receiver instantiation is unknown', () => { + const result = stepHeritageInstantiation(step({ heritageArguments: ['int'] })); + expect(result.compatible).toBe(true); + }); + + it('keeps when the heritage clause recorded no arguments', () => { + const result = stepHeritageInstantiation(step({ supertypeArguments: ['string'] })); + expect(result.compatible).toBe(true); + }); + + it('keeps when the two argument lists have different lengths', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['string'], heritageArguments: ['string', 'int'] }), + ); + expect(result.compatible).toBe(true); + }); + + it('keeps a wildcard receiver argument, which names a SET of types', () => { + // `Repo` genuinely holds a `Repo`; so do Kotlin's + // `Repo<*>` and `Repo`. + for (const wildcard of ['? extends User', '?', '* ', 'out User', 'in User']) { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: [wildcard], + heritageArguments: ['User'], + resolveSupertypeArgument: () => ({ builtIn: true }), + resolveHeritageArgument: () => ({ definitionId: 'def:User', builtIn: false }), + }), + ); + expect(result.compatible).toBe(true); + } + }); + + it('keeps a nullable spelling of the same argument', () => { + const result = stepHeritageInstantiation( + step({ supertypeArguments: ['User?'], heritageArguments: ['User'] }), + ); + expect(result.compatible).toBe(true); + }); + + it('ignores whitespace when comparing nested spellings', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['Map'], + heritageArguments: ['Map'], + }), + ); + expect(result.compatible).toBe(true); + }); + + it('keeps every implementor for a receiver typed with a CALLER type variable', () => { + // `void Run(IValidator v) { v.Check(x); }`. `T` belongs to the calling + // method, not to the subtype, so `subtypeParametersComplete` — which is + // evidence about the SUBTYPE's list — says nothing about it. An unbounded + // `T` grounds to nothing and a bounded one grounds to its BOUND; both would + // otherwise compare unequal to the implementor's concrete argument. + for (const receiverType of [ + { builtIn: false, typeVariable: true }, + { definitionId: 'def:User', builtIn: false, typeVariable: true }, + ]) { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['T'], + heritageArguments: ['Admin'], + subtypeParametersComplete: true, + resolveSupertypeArgument: () => receiverType, + resolveHeritageArgument: () => ({ definitionId: 'def:Admin', builtIn: false }), + }), + ); + expect(result.compatible).toBe(true); + } + }); + + it('keeps a heritage argument that is a type variable of an ENCLOSING declaration', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['T'], + subtypeParametersComplete: true, + resolveSupertypeArgument: () => ({ builtIn: true }), + resolveHeritageArgument: () => ({ builtIn: false, typeVariable: true }), + }), + ); + expect(result.compatible).toBe(true); + }); + + it('keeps an unresolvable argument when the parameter list may be incomplete', () => { + // The `T` of `class Box : IValidator` in a language that captures no + // type parameters: indistinguishable from a concrete type named T, so it + // must not be pruned on. + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['T'], + subtypeParametersComplete: false, + }), + ); + expect(result.compatible).toBe(true); + }); + + it('prunes the same pair once BOTH names are grounded', () => { + const result = stepHeritageInstantiation( + step({ + supertypeArguments: ['string'], + heritageArguments: ['int'], + subtypeParametersComplete: false, + resolveSupertypeArgument: () => ({ builtIn: true }), + resolveHeritageArgument: () => ({ builtIn: true }), + }), + ); + expect(result.compatible).toBe(false); + }); +}); + +describe('heritageTypeArgumentsKey', () => { + it('keeps a pair distinct from the same ids in the other order', () => { + expect(heritageTypeArgumentsKey('a', 'b')).not.toBe(heritageTypeArgumentsKey('b', 'a')); + }); + + it('separates on a character a file path cannot contain', () => { + // `Class:a b.cs:A` + `Class:c.cs:C` must not be spellable two ways. + expect(heritageTypeArgumentsKey('Class:a b.cs:A', 'Class:c.cs:C')).not.toBe( + heritageTypeArgumentsKey('Class:a', 'b.cs:A Class:c.cs:C'), + ); + }); +}); + +describe('typeApplicationArguments', () => { + it('reads angle-bracket arguments', () => { + expect(typeApplicationArguments('IValidator')).toEqual(['string']); + }); + + it('reads bracket arguments (Go embedding, Python bases)', () => { + expect(typeApplicationArguments('Base[User]')).toEqual(['User']); + }); + + it('splits only at top level', () => { + expect(typeApplicationArguments('Map>')).toEqual(['string', 'List']); + expect(typeApplicationArguments('Cache')).toEqual([ + 'Dict[str, int]', + 'bool', + ]); + }); + + it('declines a plain name, an array spelling, and a constructor call', () => { + expect(typeApplicationArguments('Repository')).toBeUndefined(); + expect(typeApplicationArguments('User[]')).toBeUndefined(); + expect(typeApplicationArguments('Base(args)')).toBeUndefined(); + }); + + it('declines a list that does not close at the end', () => { + expect(typeApplicationArguments('Repo by delegate')).toBeUndefined(); + expect(typeApplicationArguments('(Int) -> Unit')).toBeUndefined(); + }); + + it('declines brackets that cross families', () => { + // A one-family counter never sees the `]`, reaches the final `>` at depth + // zero and reports `Bar]` as a balanced argument list. + expect(typeApplicationArguments('Foo')).toBeUndefined(); + expect(typeApplicationArguments('Foo[Bar>]')).toBeUndefined(); + expect(typeApplicationArguments('Map]')).toBeUndefined(); + // The well-formed mixed nesting it must NOT start declining. + expect(typeApplicationArguments('List')).toEqual(['Dict[a, b]']); + }); +}); + +describe('C# normalizeTypeArgument', () => { + const normalize = csharpScopeResolver.normalizeTypeArgument as (name: string) => string; + + it('makes every spelling of a predefined type one name', () => { + // Including the `global::` alias qualifier, which this repository already + // unwraps when decomposing imports. + for (const spelling of ['string', 'String', 'System.String', 'global::System.String']) { + expect(normalize(spelling)).toBe('String'); + } + expect(normalize('int')).toBe('Int32'); + }); + + it('leaves an unrelated qualified name as written', () => { + expect(normalize('Foo.String')).toBe('Foo.String'); + expect(normalize('Models.User')).toBe('Models.User'); + }); + + it('keeps the qualifier on an ordinary type that merely lives in System', () => { + // Stripping `System.` unconditionally would answer `Custom` here, equating + // this with an unrelated `Custom` elsewhere in the workspace. Only a + // spelling that reduces to a PREDEFINED type earns the strip. + expect(normalize('System.Custom')).toBe('System.Custom'); + expect(normalize('global::System.Custom')).toBe('global::System.Custom'); + expect(normalize('System.Collections.Generic.List')).toBe('System.Collections.Generic.List'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/heritage-type-arguments.test.ts b/gitnexus/test/unit/scope-resolution/heritage-type-arguments.test.ts new file mode 100644 index 000000000..d5683e915 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/heritage-type-arguments.test.ts @@ -0,0 +1,177 @@ +/** + * Heritage generic ARGUMENTS reach resolution, across languages (#2912). + * + * Three routes exist, and every language uses exactly one of them: + * + * 1. The `@reference.inherits` ANCHOR already spans the whole base, so the + * spelling is read straight off it and no query changed (C#, Java, + * TypeScript, Kotlin, Go, Python, Swift). + * 2. The anchor is the bare NAME node — widening it would move the site's + * range, which is part of every inheritance edge's id — so the arguments + * arrive through the `@reference.type-arguments` sub-tag (Rust, Dart + * `extends`). + * 3. The clause never becomes a reference site at all, and rides a heritage + * MARKER payload instead (Dart `implements` / `with`). + * + * Each is pinned here because instantiation filtering degrades SILENTLY to the + * pre-#2912 fan-out when a capture stops arriving: no error, no failing edge + * count, just an interface reaching implementors of the wrong instantiation + * again. + */ +import { describe, it, expect } from 'vitest'; +import type { ParsedFile } from 'gitnexus-shared'; +import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; +import type { LanguageProvider } from '../../../src/core/ingestion/language-provider.js'; +import { csharpProvider } from '../../../src/core/ingestion/languages/csharp.js'; +import { javaProvider } from '../../../src/core/ingestion/languages/java.js'; +import { typescriptProvider } from '../../../src/core/ingestion/languages/typescript.js'; +import { kotlinProvider } from '../../../src/core/ingestion/languages/kotlin.js'; +import { goProvider } from '../../../src/core/ingestion/languages/go.js'; +import { pythonProvider } from '../../../src/core/ingestion/languages/python.js'; +import { swiftProvider } from '../../../src/core/ingestion/languages/swift.js'; +import { rustProvider } from '../../../src/core/ingestion/languages/rust.js'; +import { dartProvider } from '../../../src/core/ingestion/languages/dart.js'; +import { decodeMarker } from '../../../src/core/ingestion/utils/heritage-marker.js'; + +function inheritsSites( + provider: LanguageProvider, + source: string, + filePath: string, +): Array<{ name: string; typeArguments?: readonly string[] }> { + const parsed: ParsedFile | undefined = extractParsedFile(provider, source, filePath); + return (parsed?.referenceSites ?? []) + .filter((site) => site.kind === 'inherits') + .map((site) => ({ name: site.name, typeArguments: site.typeArguments })); +} + +describe('heritage type arguments are captured', () => { + it('C# base list', () => { + expect( + inheritsSites( + csharpProvider, + 'namespace P;\npublic record V : IValidator { }', + 'V.cs', + ), + ).toEqual([{ name: 'IValidator', typeArguments: ['string'] }]); + }); + + it('C# record with a primary-constructor base', () => { + // `Base(x)` writes a CALL in the heritage position; the call is not + // part of the type and must not stop the arguments being read. + expect( + inheritsSites( + csharpProvider, + 'namespace P;\npublic record R(int x) : Base(x) { }', + 'R.cs', + ), + ).toEqual([{ name: 'Base', typeArguments: ['int'] }]); + }); + + it('Java implements clause', () => { + expect( + inheritsSites( + javaProvider, + 'package p;\npublic class V implements Validator { }', + 'V.java', + ), + ).toEqual([{ name: 'Validator', typeArguments: ['String'] }]); + }); + + it('TypeScript implements clause', () => { + expect( + inheritsSites(typescriptProvider, 'export class V implements Validator { }', 'v.ts'), + ).toEqual([{ name: 'Validator', typeArguments: ['string'] }]); + }); + + it('Kotlin delegation specifier, with and without a constructor call', () => { + expect(inheritsSites(kotlinProvider, 'class V : Validator() { }', 'v.kt')).toEqual([ + { name: 'Validator', typeArguments: ['String'] }, + ]); + expect(inheritsSites(kotlinProvider, 'class V : Validator { }', 'v2.kt')).toEqual([ + { name: 'Validator', typeArguments: ['String'] }, + ]); + }); + + it('Go generic struct embedding (bracket application)', () => { + expect(inheritsSites(goProvider, 'package p\ntype S struct { Base[int] }', 's.go')).toEqual([ + { name: 'Base', typeArguments: ['int'] }, + ]); + }); + + it('Python subscripted base (bracket application)', () => { + expect(inheritsSites(pythonProvider, 'class Repo(Base[User]):\n pass\n', 'r.py')).toEqual([ + { name: 'Base', typeArguments: ['User'] }, + ]); + }); + + it('Swift inheritance clause', () => { + expect(inheritsSites(swiftProvider, 'class Repo: Base { }', 'r.swift')).toEqual([ + { name: 'Base', typeArguments: ['User'] }, + ]); + }); +}); + +describe('emitters whose anchor is the bare name use the explicit sub-tag', () => { + it('Rust trait impl', () => { + // The anchor is the trait NAME node inside a `generic_type`, and its range + // is part of the inheritance edge's id — so the arguments arrive through + // `@reference.type-arguments` rather than by widening the anchor. + expect(inheritsSites(rustProvider, 'impl Validator for V { }', 'v.rs')).toEqual([ + { name: 'Validator', typeArguments: ['String'] }, + ]); + }); + + it('Rust trait impl without arguments records none', () => { + expect(inheritsSites(rustProvider, 'impl Validator for V { }', 'v2.rs')).toEqual([ + { name: 'Validator', typeArguments: undefined }, + ]); + }); + + it('Dart extends clause', () => { + expect(inheritsSites(dartProvider, 'class Repo extends Base { }', 'r.dart')).toEqual([ + { name: 'Base', typeArguments: ['User'] }, + ]); + }); +}); + +describe('heritage that never becomes a reference site', () => { + // Dart's `implements` / `with` travel as heritage MARKERS on parsed imports, + // not as `inherits` sites: `emitDartHeritageEdges` reads the marker and emits + // the edge, so the instantiation has to ride the payload to reach the same + // sink the generic pre-pass writes to (#2912). + function heritageMarkers(source: string, filePath: string): Array { + const parsed = extractParsedFile(dartProvider, source, filePath); + return (parsed?.parsedImports ?? []) + .map((imported) => decodeMarker(String(imported.targetRaw))) + .filter( + (marker): marker is { kind: 'heritage'; fields: string[] } => marker?.kind === 'heritage', + ) + .map((marker) => marker.fields); + } + + it('carries the arguments of a Dart `implements` clause', () => { + expect(heritageMarkers('class V implements Validator { }', 'v.dart')).toEqual([ + ['implements', 'Validator', 'V', ''], + ]); + }); + + it('carries the arguments of a Dart `with` clause', () => { + expect(heritageMarkers('class V extends Base with M { }', 'v2.dart')).toEqual([ + ['with', 'M', 'V', ''], + ]); + }); + + it('omits the field for a non-generic clause, so old payloads stay readable', () => { + expect(heritageMarkers('class V implements Validator { }', 'v3.dart')).toEqual([ + ['implements', 'Validator', 'V'], + ]); + }); +}); + +describe('non-generic heritage stays byte-identical', () => { + it('records no arguments for a plain base', () => { + expect( + inheritsSites(csharpProvider, 'namespace P;\npublic class C : Base { }', 'C.cs'), + ).toEqual([{ name: 'Base', typeArguments: undefined }]); + }); +}); From ac5626b160032cfa69d9be00f7be45c93893b399 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:52:01 +0100 Subject: [PATCH 7/7] chore(deps)(deps-dev): bump tsx from 4.23.11 to 4.23.12 in /gitnexus (#2958) Bumps [tsx](https://github.com/privatenumber/tsx) from 4.23.11 to 4.23.12. - [Release notes](https://github.com/privatenumber/tsx/releases) - [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs) - [Commits](https://github.com/privatenumber/tsx/compare/v4.23.11...v4.23.12) --- updated-dependencies: - dependency-name: tsx dependency-version: 4.23.12 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 790dbc010..4f5fa40ad 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -79,7 +79,7 @@ "version": "1.0.0", "dev": true, "devDependencies": { - "typescript": "^6.0.3" + "typescript": "^7.0.2" } }, "node_modules/@babel/code-frame": { @@ -5430,9 +5430,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.11", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz", - "integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==", + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", "dev": true, "license": "MIT", "dependencies": {