mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix(java): capture enum interface heritage (#2935)
* fix(java): capture enum interface heritage * fix(java): harden enum heritage dispatch * test(java): refresh synthetic capture baselines --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
parent
25e51eac96
commit
cdc98a9cf8
15 changed files with 448 additions and 58 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<T>`, `implements IFoo<T>`) and qualified bases
|
||||
* (`a.b.Base`, `a.b.Box<T>`, `a.b.IFoo<T>`) 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<String>` → 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>([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<string, DispatchTarget>();
|
||||
const bestIncomingCount = new Map<string, number>([[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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string[]
|
|||
}
|
||||
}
|
||||
|
||||
async function countStatusImplementsNamed(repoPath: string): Promise<number> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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<User>, 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<Status>, 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<Status>, 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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue