diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 85b2efd58..e896c2383 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -57,9 +57,10 @@ "_rebaselined": "#1970 review + tri-review follow-ups: constructor-call retag, cascade calls, built-in suppression, enum scope, #1926 F24/F25, named-ctor dedup (crash fix), container-name binding suppression; heritage file-affinity resolution. Fixtures: member-call-contexts, constructor-body, named-constructor-body, heritage-name-collision, construct-cascade." }, "java": { - "fingerprint": "d5cf68e9faf92fffd928c1ee6e584c72cc65918d1f1b5078abb3bfe09ac699bf", + "fingerprint": "9b29cafe32873b4902bda311bd089ffc04efe08f13557b966d29544be514080a", "scaling_budget": 1.5, - "_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged." + "_rebaselined": "#1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.", + "_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06)." }, "typescript": { "fingerprint": "3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9", diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts index 85c5a9106..cd4463459 100644 --- a/gitnexus/src/core/ingestion/languages/java/captures.ts +++ b/gitnexus/src/core/ingestion/languages/java/captures.ts @@ -216,7 +216,104 @@ export function emitJavaScopeCaptures( out.push(grouped); } - return [...resolveVarTypeBindings(out), ...synthesizeJavaInheritanceReferences(tree.rootNode)]; + return [ + ...resolveVarTypeBindings(out), + ...synthesizeJavaInheritanceReferences(tree.rootNode), + ...synthesizeJavaExplicitConstructorReferences(tree.rootNode), + ]; +} + +/** + * Synthesize `@reference.call.constructor` captures for explicit constructor + * invocations — `super(...)` and `this(...)` (F38 #1928). tree-sitter-java + * models these as `explicit_constructor_invocation` nodes, which the scope + * query does not match, so the chained-constructor CALLS edges (subclass ctor → + * superclass ctor; ctor → sibling overload) were silently dropped. + * + * The grammar gives no constructor *name* at the call site (the child is a bare + * `(super)` / `(this)` token), so the target name is resolved structurally: + * - `this(...)` → the enclosing type's own simple name (constructor symbols + * are keyed by the declaring class name). + * - `super(...)` → the enclosing class's superclass simple-name tail (reusing + * `javaBaseLookupNameNode` so qualified/generic supers reduce + * to the bare class name, matching the EXTENDS synth). An + * implicit `Object` super (no `superclass` field) has no + * in-graph symbol, so it is skipped rather than emitting a + * dangling reference. + * Arity is attached so overloaded constructors disambiguate downstream, mirroring + * the call-site arity synthesized for `new X(...)`. + */ +function synthesizeJavaExplicitConstructorReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === 'explicit_constructor_invocation') { + emitJavaExplicitConstructorRef(out, node); + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child !== null) stack.push(child); + } + } + return out; +} + +const TYPE_DECL_NODE_TYPES = new Set([ + 'class_declaration', + 'enum_declaration', + 'record_declaration', +]); + +function emitJavaExplicitConstructorRef(out: CaptureMatch[], node: SyntaxNode): void { + const ctor = node.childForFieldName('constructor'); + if (ctor === null) return; + + const enclosingType = findEnclosingTypeDeclaration(node); + if (enclosingType === null) return; + + let targetNameNode: SyntaxNode | null = null; + if (ctor.type === 'this') { + targetNameNode = enclosingType.childForFieldName('name'); + } else if (ctor.type === 'super') { + // Only class_declaration carries a `superclass` field; enum/record cannot + // declare an explicit superclass, so `super(...)` there has no resolvable + // target symbol. + const superclass = enclosingType.childForFieldName('superclass'); + if (superclass === null) return; + for (const base of superclass.namedChildren) { + if (base === null) continue; + const nameNode = javaBaseLookupNameNode(base); + if (nameNode !== null) { + targetNameNode = nameNode; + break; + } + } + } + if (targetNameNode === null) return; + + const argList = node.childForFieldName('arguments'); + const args = + argList === null + ? [] + : argList.namedChildren.filter( + (c) => c !== null && c.type !== 'block_comment' && c.type !== 'line_comment', + ); + + out.push({ + '@reference.call.constructor': nodeToCapture('@reference.call.constructor', node), + '@reference.name': nodeToCapture('@reference.name', targetNameNode), + '@reference.arity': syntheticCapture('@reference.arity', node, String(args.length)), + }); +} + +function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null { + let cur: SyntaxNode | null = node.parent; + while (cur !== null) { + if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur; + cur = cur.parent; + } + return null; } /** diff --git a/gitnexus/src/core/ingestion/languages/java/interpret.ts b/gitnexus/src/core/ingestion/languages/java/interpret.ts index 87da580b6..09c911aa9 100644 --- a/gitnexus/src/core/ingestion/languages/java/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/java/interpret.ts @@ -74,10 +74,14 @@ export function interpretJavaTypeBinding(captures: CaptureMatch): ParsedTypeBind const typeCap = captures['@type-binding.type']; if (nameCap === undefined || typeCap === undefined) return null; - // Strip qualifier first so that `com.example.BaseModel` becomes - // `BaseModel` before stripGeneric — the JVM-erasure fallback pattern - // requires an unqualified identifier at the start of the string. - const rawType = stripGeneric(stripQualifier(typeCap.text.trim())); + // Strip generics BEFORE the qualifier (F41 #1928). Stripping the qualifier + // first uses `lastIndexOf('.')`, which for a qualified *type argument* + // (`Map`) cuts inside the generic and yields a + // corrupted `User>`. Unwrapping generics first reduces the string to a single + // (possibly qualified) class name, then the qualifier strip leaves the bare + // simple name. `stripGeneric`'s erasure fallback is qualifier-tolerant so a + // qualified generic base (`com.example.BaseModel`) still reduces correctly. + const rawType = stripQualifier(stripGeneric(typeCap.text.trim())); // Skip `var` — tree-sitter-java parses `var` as type_identifier with // text "var". When used without a constructor initializer, there's no @@ -127,8 +131,10 @@ function stripGeneric(text: string): string { // `BaseModel` → `BaseModel`, `Builder` → `Builder`. // This mirrors JVM type erasure — the raw class name is the resolvable symbol. // The pattern matches up to the first `<` to handle nested generics safely - // (e.g. `BaseModel>` → `BaseModel`). - const fallback = text.match(/^([A-Za-z_$][A-Za-z0-9_$]*)<.+>$/s); + // (e.g. `BaseModel>` → `BaseModel`). The base is allowed to be + // qualified (`com.example.BaseModel` → `com.example.BaseModel`) since the + // caller strips the qualifier afterwards (F41 #1928). + const fallback = text.match(/^((?:[A-Za-z_$][A-Za-z0-9_$]*\.)*[A-Za-z_$][A-Za-z0-9_$]*)<.+>$/s); if (fallback !== null) return fallback[1].trim(); return text; diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts index e1e581ad5..4821e9703 100644 --- a/gitnexus/src/core/ingestion/languages/java/query.ts +++ b/gitnexus/src/core/ingestion/languages/java/query.ts @@ -214,8 +214,23 @@ const JAVA_SCOPE_QUERY = ` type: (generic_type (type_identifier) @reference.name)) @reference.call.constructor +;; References — qualified constructor calls: new pkg.Foo(), new a.b.Foo() (F35 #1928) +;; tree-sitter-java parses \`pkg.Foo\` as a scoped_type_identifier whose final +;; child is the simple type. Bind that tail as @reference.name (trailing \`.\` +;; anchor = last child) so resolution targets \`Foo\`, not the raw \`pkg.Foo\` text. +;; Mirrors the TS/JS new-expression qualified-constructor capture. (object_creation_expression - type: (scoped_type_identifier) @reference.call.constructor.qualified) @reference.call.constructor + type: (scoped_type_identifier + (type_identifier) @reference.name .) @reference.call.constructor.qualified) @reference.call.constructor + +;; References — qualified + generic constructor calls: new pkg.Box() (F35 #1928) +;; The base is a generic_type whose first child is a scoped_type_identifier, so +;; neither the simple-generic nor the plain-scoped arm above matches it. Bind the +;; scoped tail as @reference.name. +(object_creation_expression + type: (generic_type + (scoped_type_identifier + (type_identifier) @reference.name .) @reference.call.constructor.qualified)) @reference.call.constructor ;; References — method references: User::getName, obj::method (method_reference diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 92f3f2d8b..441bb3e4b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -54,6 +54,16 @@ function isCallerAnchorLabel(label: NodeLabel): boolean { ); } +/** + * Callables whose same-name overloads occupy distinct graph nodes keyed by + * parameter types / shape. Must mirror `isOverloadableCallable` in + * `node-lookup.ts` so registration and lookup agree (Constructor included — + * #1928 F38). + */ +function isOverloadableCallable(label: NodeLabel | undefined): boolean { + return label === 'Function' || label === 'Method' || label === 'Constructor'; +} + /** * Look up a `SymbolDefinition` in the graph node lookup. * @@ -107,7 +117,7 @@ export function resolveDefGraphId( if (cHit !== undefined) return cHit; } if ( - (def.type === 'Function' || def.type === 'Method') && + isOverloadableCallable(def.type) && def.parameterTypes !== undefined && def.parameterTypeClasses !== undefined ) { @@ -120,9 +130,12 @@ export function resolveDefGraphId( } // Overload disambiguation: when the def carries parameter types, // try the parameter-typed key first so same-name same-arity - // overloads route to their distinct graph nodes. + // overloads route to their distinct graph nodes. Constructors are + // included so a `this(int)`/`super(int)` chain or `new Foo(int)` + // resolves to the matching ctor overload instead of first-wins + // collapsing onto another `Foo` ctor (a self-loop) — #1928 F38. if ( - (def.type === 'Function' || def.type === 'Method') && + isOverloadableCallable(def.type) && def.parameterTypes !== undefined && def.parameterTypes.length > 0 ) { diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index 80bc5dedd..d7da8a56f 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -99,11 +99,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { // a parameter-types-suffixed key so resolveDefGraphId can find // the right overload by matching its def's parameterTypes. const pTypes = (props as { parameterTypes?: readonly string[] }).parameterTypes; - if ( - pTypes !== undefined && - pTypes.length > 0 && - (node.label === 'Function' || node.label === 'Method') - ) { + if (pTypes !== undefined && pTypes.length > 0 && isOverloadableCallable(node.label)) { const pKey = qualifiedKey( props.filePath, node.label, @@ -115,7 +111,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { const pClasses = (props as { parameterTypeClasses?: readonly ParameterTypeClass[] }) .parameterTypeClasses; const shapeTag = parameterShapeIdTag(pTypes, pClasses); - if (shapeTag !== '' && (node.label === 'Function' || node.label === 'Method')) { + if (shapeTag !== '' && isOverloadableCallable(node.label)) { const shapeKey = qualifiedKey(props.filePath, node.label, `${keyQualified}${shapeTag}`); if (!lookup.has(shapeKey)) lookup.set(shapeKey, node.id); } @@ -162,6 +158,18 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { return lookup; } +/** + * Callables whose same-name overloads must route to distinct graph nodes via + * the parameter-types / shape key. Constructors belong here too: a class with + * `Foo()` and `Foo(int)` mints distinct `#0`/`#1` Constructor nodes, and a + * `this(...)`/`super(...)` edge (or any `new Foo(args)`) must reach the right + * one. Without the overload key both ctor nodes collapse onto the first-wins + * qualified/simple key, turning a `this()` chain into a self-loop (#1928 F38). + */ +function isOverloadableCallable(label: NodeLabel): boolean { + return label === 'Function' || label === 'Method' || label === 'Constructor'; +} + export function isLinkableLabel(label: NodeLabel): boolean { return ( label === 'Function' || diff --git a/gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Base.java b/gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Base.java new file mode 100644 index 000000000..2c9334f2e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Base.java @@ -0,0 +1,5 @@ +package models; + +public class Base { + public Base(int x) {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Child.java b/gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Child.java new file mode 100644 index 000000000..14de7ea12 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-explicit-constructor/models/Child.java @@ -0,0 +1,11 @@ +package models; + +public class Child extends Base { + public Child() { + super(1); + } + + public Child(int x) { + this(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/App.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/App.java new file mode 100644 index 000000000..c6147a221 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/App.java @@ -0,0 +1,6 @@ +public class App { + public void make() { + pkg.Foo f = new pkg.Foo(); + new pkg.Box(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Box.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Box.java new file mode 100644 index 000000000..05c940e71 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Box.java @@ -0,0 +1,5 @@ +package pkg; + +public class Box { + public Box() {} +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Foo.java b/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Foo.java new file mode 100644 index 000000000..87bcc7cd0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-qualified-constructor/pkg/Foo.java @@ -0,0 +1,7 @@ +package pkg; + +public class Foo { + public Foo() {} + + public void run() {} +} diff --git a/gitnexus/test/integration/resolvers/java-1928.test.ts b/gitnexus/test/integration/resolvers/java-1928.test.ts new file mode 100644 index 000000000..ce739cc42 --- /dev/null +++ b/gitnexus/test/integration/resolvers/java-1928.test.ts @@ -0,0 +1,76 @@ +/** + * Java parsing-layer coverage gaps (#1928) — end-to-end resolution. + * + * - F35: qualified / qualified-generic constructor calls (`new pkg.Foo()`, + * `new pkg.Box()`) resolve to the target constructor instead of + * dropping the edge on a corrupted `pkg.Foo` reference name. + * - F38: `super(...)` / `this(...)` explicit constructor invocations emit CALLS + * edges to the superclass / sibling constructor. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'path'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; + +describe('Java qualified constructor resolution (F35 #1928)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-qualified-constructor'), () => {}); + }, 60000); + + it('resolves `new pkg.Foo()` to the Foo constructor', () => { + const calls = getRelationships(result, 'CALLS'); + const fooCtor = calls.find((c) => c.target === 'Foo' && c.source === 'make'); + expect(fooCtor).toBeDefined(); + expect(fooCtor!.targetLabel).toBe('Constructor'); + expect(fooCtor!.targetFilePath).toBe('pkg/Foo.java'); + }); + + it('resolves `new pkg.Box()` to the Box constructor', () => { + const calls = getRelationships(result, 'CALLS'); + const boxCtor = calls.find((c) => c.target === 'Box' && c.source === 'make'); + expect(boxCtor).toBeDefined(); + expect(boxCtor!.targetLabel).toBe('Constructor'); + expect(boxCtor!.targetFilePath).toBe('pkg/Box.java'); + }); + + it('does not emit a CALLS edge to a corrupted `pkg.Foo` / `pkg.Box` name', () => { + const calls = getRelationships(result, 'CALLS'); + expect(calls.some((c) => c.target === 'pkg.Foo' || c.target === 'pkg.Box')).toBe(false); + }); +}); + +describe('Java explicit constructor invocation resolution (F38 #1928)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-explicit-constructor'), () => {}); + }, 60000); + + it('resolves `super(1)` in Child() to the Base constructor', () => { + const calls = getRelationships(result, 'CALLS'); + const superCall = calls.find((c) => c.target === 'Base' && c.targetLabel === 'Constructor'); + expect(superCall).toBeDefined(); + expect(superCall!.source).toBe('Child'); + expect(superCall!.targetFilePath).toBe('models/Base.java'); + // Source is the arity-0 `Child()`, where `super(1)` lives. + expect(superCall!.rel.sourceId).toContain('Child.Child#0'); + expect(superCall!.rel.targetId).toContain('Base.Base#1'); + }); + + it('resolves `this()` in Child(int) to a DISTINCT Child constructor (no self-loop)', () => { + const calls = getRelationships(result, 'CALLS'); + const thisCall = calls.find( + (c) => c.target === 'Child' && c.targetLabel === 'Constructor' && c.source === 'Child', + ); + expect(thisCall).toBeDefined(); + expect(thisCall!.targetFilePath).toBe('models/Child.java'); + // The edge must connect DISTINCT constructors: the caller `Child(int)` (#1) + // chains to `Child()` (#0). A self-loop (`#0 → #0`) — the bug this PR's + // review caught (#1928 F38: ctor overload keys missing in node-lookup) — + // satisfies the name-only match above but must NOT pass here. + expect(thisCall!.rel.sourceId).not.toBe(thisCall!.rel.targetId); + expect(thisCall!.rel.sourceId).toContain('Child.Child#1'); + expect(thisCall!.rel.targetId).toContain('Child.Child#0'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts new file mode 100644 index 000000000..f4d5d0251 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/java/java-captures.test.ts @@ -0,0 +1,119 @@ +/** + * Low-level coverage for the Java scope-captures orchestrator + * (`emitJavaScopeCaptures`), focused on the #1928 parsing-layer fixes: + * + * - F35: qualified / qualified-generic constructor calls bind the simple-name + * tail as @reference.name (not the raw `pkg.Foo` text). + * - F38: `super(...)` / `this(...)` explicit constructor invocations are + * captured as @reference.call.constructor references with arity. + * + * Runs against the installed tree-sitter-java grammar so it catches grammar + * drift before the integration parity gate. + */ + +import { describe, it, expect } from 'vitest'; +import { emitJavaScopeCaptures } from '../../../../src/core/ingestion/languages/java/captures.js'; + +function wrapExpr(expr: string): string { + return `class C { void m() { ${expr}; } }`; +} + +/** All constructor-call matches in `src`, as `{ name, qualified, arity }`. */ +function ctorRefs(src: string) { + return emitJavaScopeCaptures(src, 'C.java') + .filter((m) => m['@reference.call.constructor'] !== undefined) + .map((m) => ({ + name: m['@reference.name']?.text, + qualified: m['@reference.call.constructor.qualified']?.text, + arity: m['@reference.arity']?.text, + })); +} + +describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', () => { + it('binds the simple name for an unqualified `new User()`', () => { + const refs = ctorRefs(wrapExpr('new User()')); + expect(refs).toContainEqual({ name: 'User', qualified: undefined, arity: '0' }); + }); + + it('binds the simple-name tail for a qualified `new pkg.Foo()`', () => { + const refs = ctorRefs(wrapExpr('new pkg.Foo()')); + const foo = refs.find((r) => r.name === 'Foo'); + expect(foo).toBeDefined(); + expect(foo!.qualified).toBe('pkg.Foo'); + // The name must be the bare tail, never the raw scoped text. + expect(refs.some((r) => r.name === 'pkg.Foo')).toBe(false); + }); + + it('binds the simple-name tail for a deeply-nested `new a.b.Foo()`', () => { + const refs = ctorRefs(wrapExpr('new a.b.Foo()')); + const foo = refs.find((r) => r.name === 'Foo'); + expect(foo).toBeDefined(); + expect(foo!.qualified).toBe('a.b.Foo'); + expect(refs.some((r) => r.name === 'a' || r.name === 'b')).toBe(false); + }); + + it('binds the simple name for a simple-generic `new Box()`', () => { + const refs = ctorRefs(wrapExpr('new Box()')); + const box = refs.find((r) => r.name === 'Box'); + expect(box).toBeDefined(); + expect(box!.qualified).toBeUndefined(); + }); + + it('binds the simple-name tail for a qualified-generic `new pkg.Box()`', () => { + const refs = ctorRefs(wrapExpr('new pkg.Box()')); + const box = refs.find((r) => r.name === 'Box'); + expect(box).toBeDefined(); + expect(box!.qualified).toBe('pkg.Box'); + expect(refs.some((r) => r.name === 'pkg.Box' || r.name === 'String')).toBe(false); + }); + + it('carries the argument arity on a qualified constructor call', () => { + const refs = ctorRefs(wrapExpr('new pkg.Foo(1, 2, 3)')); + const foo = refs.find((r) => r.name === 'Foo'); + expect(foo!.arity).toBe('3'); + }); + + it('emits exactly one constructor reference per `new` expression', () => { + // Regression guard: the qualified + qualified-generic arms must not + // double-match the plain/generic arms. + expect(ctorRefs(wrapExpr('new pkg.Foo()')).length).toBe(1); + expect(ctorRefs(wrapExpr('new pkg.Box()')).length).toBe(1); + expect(ctorRefs(wrapExpr('new a.b.Foo()')).length).toBe(1); + }); +}); + +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); } }'; + const refs = ctorRefs(src); + const sup = refs.find((r) => r.name === 'Base'); + expect(sup).toBeDefined(); + expect(sup!.arity).toBe('2'); + }); + + it('reduces a generic superclass `super(...)` target to the bare name', () => { + const src = 'class C extends Box { C() { super(); } }'; + const refs = ctorRefs(src); + expect(refs.some((r) => r.name === 'Box' && 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); + const self = refs.find((r) => r.name === 'C' && r.arity === '1'); + expect(self).toBeDefined(); + }); + + it('does NOT synthesize a super ref when there is no explicit superclass', () => { + // Implicit `Object` super — no in-graph symbol, so no reference is emitted. + const src = 'class C { C() { super(); } }'; + const refs = ctorRefs(src); + expect(refs.length).toBe(0); + }); + + it('captures `this(...)` inside an enum constructor', () => { + const src = 'enum E { A; E() { this(1); } E(int x) {} }'; + const refs = ctorRefs(src); + expect(refs.some((r) => r.name === 'E' && r.arity === '1')).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/java/java-interpret.test.ts b/gitnexus/test/unit/scope-resolution/java/java-interpret.test.ts new file mode 100644 index 000000000..989a94edd --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/java/java-interpret.test.ts @@ -0,0 +1,86 @@ +/** + * Coverage for `interpretJavaTypeBinding` type-name normalization, focused on + * F41 (#1928): generics must be stripped BEFORE the qualifier so a qualified + * generic *type argument* (`Map`) is not corrupted + * into `User>` by an early `lastIndexOf('.')` cut. + */ + +import { describe, it, expect } from 'vitest'; +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { interpretJavaTypeBinding } from '../../../../src/core/ingestion/languages/java/interpret.js'; + +const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const; +const cap = (name: string, text: string): Capture => ({ name, text, range: ZERO_RANGE }); + +/** Build an annotation-source type binding (`Type name;`). */ +function binding(typeText: string, sourceTag = '@type-binding.annotation'): CaptureMatch { + return { + '@type-binding.name': cap('@type-binding.name', 'x'), + '@type-binding.type': cap('@type-binding.type', typeText), + [sourceTag]: cap(sourceTag, typeText), + }; +} + +/** The normalized `rawTypeName` for a given raw type string. */ +function raw(typeText: string): string | undefined { + return interpretJavaTypeBinding(binding(typeText))?.rawTypeName; +} + +describe('interpretJavaTypeBinding — type normalization (F41 #1928)', () => { + it('strips a qualifier from a plain qualified type', () => { + expect(raw('com.example.User')).toBe('User'); + }); + + it('strips generics from an unqualified generic base', () => { + expect(raw('BaseModel')).toBe('BaseModel'); + }); + + it('strips generics AND qualifier from a qualified generic base', () => { + expect(raw('com.example.BaseModel')).toBe('BaseModel'); + }); + + it('does not corrupt a qualified generic TYPE ARGUMENT (the F41 bug)', () => { + // Before the fix: stripQualifier ran first → `User>` (trailing bracket). + expect(raw('Map')).toBe('User'); + }); + + it('extracts the element type from a single-arg container with qualified arg', () => { + expect(raw('List')).toBe('User'); + }); + + it('extracts the element type from a qualified container', () => { + expect(raw('java.util.List')).toBe('User'); + }); + + it('extracts the element type from a simple container', () => { + expect(raw('List')).toBe('User'); + expect(raw('Optional')).toBe('User'); + }); + + it('extracts the value type from a two-arg map', () => { + expect(raw('Map')).toBe('User'); + }); + + it('passes through a plain simple type', () => { + expect(raw('User')).toBe('User'); + }); + + it('falls back to the raw class name for an unrecognized nested generic', () => { + // Nested generic args defeat the single/two-arg element extraction; the + // erasure fallback keeps the outer class name (pre-existing behavior). + expect(raw('List>')).toBe('List'); + }); + + it('keeps the outer class for a QUALIFIED nested generic element (guards the strip order)', () => { + // Unlike `List>` (no dot inside the args, so it yields + // `List` under both strip orders), this input has a qualified nested + // element: the OLD order (stripQualifier first) cut inside the generic and + // produced a corrupted `Foo>`; only generics-first yields `List`. + // This is the case that actually fails if the F41 reorder regresses. + expect(raw('List>')).toBe('List'); + }); + + it('returns null for a bare `var` with no concrete type', () => { + expect(interpretJavaTypeBinding(binding('var'))).toBeNull(); + }); +});