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(