Skip to content

Commit 7d19cc5

Browse files
aksOpsclaude
andcommitted
feat(detector/class-hierarchy): consume ctx.resolved() for RESOLVED EXTENDS/IMPLEMENTS edges
Phase 6 task 24-style migration applied to ClassHierarchyDetector. Class hierarchy is high-leverage for resolution: simple-name superclass references like "extends Service" are routine across unrelated codebases, and EXTENDS / IMPLEMENTS edges are downstream-load-bearing for blast-radius / dead-code / cycle / topology analysis. Pinning the target FQN turns "Service-named-something" into a stable cross-file reference. When ctx.resolved() carries a JavaResolved, the detector now: 1. Uses the resolver-parsed CompilationUnit (symbol solver attached). 2. For each parent type in extendedTypes / implementedTypes, calls a single new helper addHierarchyEdge() that: - tries to resolve the type via the symbol solver - on success, attaches target_fqn to edge properties + stamps Confidence.RESOLVED + source = "java.class_hierarchy" - on failure (and always when ctx.resolved() is empty), emits the existing simple-name placeholder edge with raw default confidence (orchestrator stamps SYNTACTIC at the boundary). 3. The 4 prior in-line edge-emission blocks (class-extends, interface-extends, class-implements, enum-implements) collapse to two-line iterations through addHierarchyEdge — net is fewer LOC plus the new resolution capability. Existing 30 ClassHierarchyDetectorExtended tests pass unchanged — node emission, regex fallback, the property shapes, and the simple-name edge IDs / target placeholders are all preserved. 5 new tests in ClassHierarchyDetectorResolvedTest: - resolvedModeStampsResolvedTierOnExtendsEdge — two BaseService in different packages; imported one wins on edge.target_fqn. - resolvedModeStampsResolvedTierOnImplementsEdge — same shape for interface implements. - fallbackModeMatchesPreSpecBaseline — EmptyResolved → no FQN, no RESOLVED stamp. - fallbackModeWhenContextHasNoResolvedAtAll — Optional.empty() also safe. - mixedModeFallsBackForUnreachableType — class extends a known type, implements an unknown one: EXTENDS is RESOLVED, IMPLEMENTS falls back gracefully. This brings the migrated-detector count to 4 (JpaEntityDetector, RepositoryDetector, SpringRestDetector, ClassHierarchyDetector) — at the lower bound of the plan's "4-6 Java detectors migrated as proof of value". Plan: docs/plans/2026-04-27-sub-project-1-resolver-spi-and-java-pilot.md (spirit of tasks 24-29 — using the actual detectors that exist in this repo, not the plan's hypothetical names). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d9d0678 commit 7d19cc5

2 files changed

Lines changed: 294 additions & 37 deletions

File tree

src/main/java/io/github/randomcodespace/iq/detector/jvm/java/ClassHierarchyDetector.java

Lines changed: 71 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@
55
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
66
import com.github.javaparser.ast.body.EnumDeclaration;
77
import com.github.javaparser.ast.type.ClassOrInterfaceType;
8+
import com.github.javaparser.resolution.types.ResolvedType;
89
import io.github.randomcodespace.iq.detector.DetectorContext;
910
import io.github.randomcodespace.iq.detector.DetectorResult;
11+
import io.github.randomcodespace.iq.intelligence.resolver.Resolved;
12+
import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved;
1013
import io.github.randomcodespace.iq.model.CodeEdge;
1114
import io.github.randomcodespace.iq.model.CodeNode;
15+
import io.github.randomcodespace.iq.model.Confidence;
1216
import io.github.randomcodespace.iq.model.EdgeKind;
1317
import io.github.randomcodespace.iq.model.NodeKind;
1418
import org.springframework.stereotype.Component;
@@ -71,16 +75,28 @@ public DetectorResult detect(DetectorContext ctx) {
7175
String text = ctx.content();
7276
if (text == null || text.isEmpty()) return DetectorResult.empty();
7377

74-
Optional<CompilationUnit> cu = parse(ctx);
78+
// Prefer the resolver-parsed CU when ctx.resolved() carries a
79+
// JavaResolved — class hierarchy benefits a lot from FQN resolution
80+
// because superclass / interface refs are routinely simple-named in
81+
// source ("extends Service" not "extends com.example.Service") and
82+
// EXTENDS/IMPLEMENTS edges are downstream-load-bearing.
83+
Optional<JavaResolved> resolved = ctx.resolved()
84+
.filter(Resolved::isAvailable)
85+
.filter(JavaResolved.class::isInstance)
86+
.map(JavaResolved.class::cast);
87+
88+
Optional<CompilationUnit> cu = resolved.map(JavaResolved::cu).or(() -> parse(ctx));
7589
if (cu.isPresent()) {
76-
return detectWithAst(cu.get(), ctx);
90+
return detectWithAst(cu.get(), ctx, resolved);
7791
}
7892
return detectWithRegex(ctx);
7993
}
8094

8195
// ==================== AST-based detection ====================
8296

83-
private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) {
97+
private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx,
98+
Optional<JavaResolved> resolved) {
99+
boolean canResolve = resolved.isPresent();
84100
List<CodeNode> nodes = new ArrayList<>();
85101
List<CodeEdge> edges = new ArrayList<>();
86102

@@ -144,36 +160,17 @@ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) {
144160
node.setProperties(props);
145161
nodes.add(node);
146162

147-
// EXTENDS edges
148-
if (!isInterface) {
149-
for (String superclass : extendedTypes) {
150-
CodeEdge edge = new CodeEdge();
151-
edge.setId(nodeId + "->extends->*:" + superclass);
152-
edge.setKind(EdgeKind.EXTENDS);
153-
edge.setSourceId(nodeId);
154-
edge.setTarget(new CodeNode("*:" + superclass, NodeKind.CLASS, superclass));
155-
edges.add(edge);
156-
}
157-
} else {
158-
// Interfaces extend other interfaces
159-
for (String ext : extendedTypes) {
160-
CodeEdge edge = new CodeEdge();
161-
edge.setId(nodeId + "->extends->*:" + ext);
162-
edge.setKind(EdgeKind.EXTENDS);
163-
edge.setSourceId(nodeId);
164-
edge.setTarget(new CodeNode("*:" + ext, NodeKind.INTERFACE, ext));
165-
edges.add(edge);
166-
}
163+
// EXTENDS edges — iterate the typed AST nodes (not the simple-name
164+
// strings) so we can attempt FQN resolution per-type when ctx
165+
// carries a JavaResolved.
166+
NodeKind extendsTargetKind = isInterface ? NodeKind.INTERFACE : NodeKind.CLASS;
167+
for (ClassOrInterfaceType ext : decl.getExtendedTypes()) {
168+
addHierarchyEdge(nodeId, ext, EdgeKind.EXTENDS, extendsTargetKind, canResolve, edges);
167169
}
168170

169171
// IMPLEMENTS edges
170-
for (String iface : implementedTypes) {
171-
CodeEdge edge = new CodeEdge();
172-
edge.setId(nodeId + "->implements->*:" + iface);
173-
edge.setKind(EdgeKind.IMPLEMENTS);
174-
edge.setSourceId(nodeId);
175-
edge.setTarget(new CodeNode("*:" + iface, NodeKind.INTERFACE, iface));
176-
edges.add(edge);
172+
for (ClassOrInterfaceType impl : decl.getImplementedTypes()) {
173+
addHierarchyEdge(nodeId, impl, EdgeKind.IMPLEMENTS, NodeKind.INTERFACE, canResolve, edges);
177174
}
178175
});
179176

@@ -212,13 +209,8 @@ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) {
212209
node.setProperties(props);
213210
nodes.add(node);
214211

215-
for (String iface : interfaces) {
216-
CodeEdge edge = new CodeEdge();
217-
edge.setId(nodeId + "->implements->*:" + iface);
218-
edge.setKind(EdgeKind.IMPLEMENTS);
219-
edge.setSourceId(nodeId);
220-
edge.setTarget(new CodeNode("*:" + iface, NodeKind.INTERFACE, iface));
221-
edges.add(edge);
212+
for (ClassOrInterfaceType impl : decl.getImplementedTypes()) {
213+
addHierarchyEdge(nodeId, impl, EdgeKind.IMPLEMENTS, NodeKind.INTERFACE, canResolve, edges);
222214
}
223215
});
224216

@@ -435,4 +427,46 @@ private List<String> parseTypeList(String typeList) {
435427
}
436428
return result;
437429
}
430+
431+
/**
432+
* Emit an EXTENDS or IMPLEMENTS edge for a single type reference. When
433+
* {@code canResolve} is true the helper attempts FQN resolution via the
434+
* symbol solver and, on success, attaches {@code target_fqn} +
435+
* {@link Confidence#RESOLVED} + source. The simple-name placeholder
436+
* target is unchanged so EntityLinker / ClassHierarchyLinker post-passes
437+
* are unaffected on the surface — they can opt to use {@code target_fqn}
438+
* when present.
439+
*/
440+
private void addHierarchyEdge(String sourceId, ClassOrInterfaceType target,
441+
EdgeKind edgeKind, NodeKind targetKind,
442+
boolean canResolve, List<CodeEdge> edges) {
443+
String simpleName = target.getNameAsString();
444+
Optional<String> fqn = canResolve ? tryResolveFqn(target) : Optional.empty();
445+
446+
CodeEdge edge = new CodeEdge();
447+
edge.setId(sourceId + "->" + edgeKind.getValue() + "->*:" + simpleName);
448+
edge.setKind(edgeKind);
449+
edge.setSourceId(sourceId);
450+
edge.setTarget(new CodeNode("*:" + simpleName, targetKind, simpleName));
451+
if (fqn.isPresent()) {
452+
Map<String, Object> props = new LinkedHashMap<>();
453+
props.put("target_fqn", fqn.get());
454+
edge.setProperties(props);
455+
edge.setConfidence(Confidence.RESOLVED);
456+
edge.setSource(getName());
457+
}
458+
edges.add(edge);
459+
}
460+
461+
private static Optional<String> tryResolveFqn(ClassOrInterfaceType type) {
462+
try {
463+
ResolvedType rt = type.resolve();
464+
if (rt.isReferenceType()) {
465+
return Optional.of(rt.asReferenceType().getQualifiedName());
466+
}
467+
return Optional.of(rt.describe());
468+
} catch (RuntimeException e) {
469+
return Optional.empty();
470+
}
471+
}
438472
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package io.github.randomcodespace.iq.detector.jvm.java;
2+
3+
import io.github.randomcodespace.iq.analyzer.DiscoveredFile;
4+
import io.github.randomcodespace.iq.detector.DetectorContext;
5+
import io.github.randomcodespace.iq.detector.DetectorResult;
6+
import io.github.randomcodespace.iq.intelligence.resolver.EmptyResolved;
7+
import io.github.randomcodespace.iq.intelligence.resolver.ResolutionException;
8+
import io.github.randomcodespace.iq.intelligence.resolver.Resolved;
9+
import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved;
10+
import io.github.randomcodespace.iq.intelligence.resolver.java.JavaSourceRootDiscovery;
11+
import io.github.randomcodespace.iq.intelligence.resolver.java.JavaSymbolResolver;
12+
import io.github.randomcodespace.iq.model.CodeEdge;
13+
import io.github.randomcodespace.iq.model.Confidence;
14+
import io.github.randomcodespace.iq.model.EdgeKind;
15+
import org.junit.jupiter.api.BeforeEach;
16+
import org.junit.jupiter.api.Test;
17+
import org.junit.jupiter.api.io.TempDir;
18+
19+
import java.io.IOException;
20+
import java.nio.file.Files;
21+
import java.nio.file.Path;
22+
import java.util.List;
23+
24+
import static org.junit.jupiter.api.Assertions.*;
25+
26+
/**
27+
* Phase 6 — ClassHierarchyDetector migration to consume {@code ctx.resolved()}
28+
* and stamp EXTENDS / IMPLEMENTS edges as RESOLVED with stable FQN targets
29+
* when the symbol solver can pin them.
30+
*
31+
* <p>Class hierarchy resolution is high-leverage: the simple name "Service"
32+
* appears in dozens of unrelated codebases at once and EXTENDS / IMPLEMENTS
33+
* edges are downstream-load-bearing for blast-radius / dead-code / cycle
34+
* analysis. Pinning the FQN turns the edge from "Service-named-something"
35+
* into "this exact superclass".
36+
*
37+
* <p>Three contract tests:
38+
* <ol>
39+
* <li><b>resolvedModeStampsResolvedTierOnExtendsEdge</b> — two
40+
* {@code BaseService} classes in different packages; resolution picks
41+
* the imported one for the EXTENDS edge.</li>
42+
* <li><b>fallbackModeMatchesPreSpecBaseline</b> — EmptyResolved → simple-
43+
* name target, no target_fqn, no RESOLVED stamp.</li>
44+
* <li><b>mixedModeUsesResolverWhereAvailable</b> — a class that extends a
45+
* resolvable type and implements an unresolvable one: EXTENDS is
46+
* RESOLVED, IMPLEMENTS falls back.</li>
47+
* </ol>
48+
*/
49+
class ClassHierarchyDetectorResolvedTest {
50+
51+
@TempDir Path repoRoot;
52+
53+
private final ClassHierarchyDetector detector = new ClassHierarchyDetector();
54+
private JavaSymbolResolver resolver;
55+
56+
@BeforeEach
57+
void setUp() throws IOException {
58+
Files.createDirectories(repoRoot.resolve("src/main/java/com/example/a"));
59+
Files.createDirectories(repoRoot.resolve("src/main/java/com/example/b"));
60+
Files.writeString(repoRoot.resolve("pom.xml"), "<project/>");
61+
62+
Files.writeString(repoRoot.resolve("src/main/java/com/example/a/BaseService.java"),
63+
"""
64+
package com.example.a;
65+
public class BaseService {}
66+
""");
67+
Files.writeString(repoRoot.resolve("src/main/java/com/example/b/BaseService.java"),
68+
"""
69+
package com.example.b;
70+
public class BaseService {}
71+
""");
72+
Files.writeString(repoRoot.resolve("src/main/java/com/example/a/Auditable.java"),
73+
"""
74+
package com.example.a;
75+
public interface Auditable {}
76+
""");
77+
78+
resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery());
79+
}
80+
81+
// ── (1) Resolved mode ────────────────────────────────────────────────────
82+
83+
@Test
84+
void resolvedModeStampsResolvedTierOnExtendsEdge() throws Exception {
85+
// Pet extends BaseService — two BaseService classes in different
86+
// packages, only the imported one wins.
87+
String petPath = "src/main/java/com/example/PetService.java";
88+
Path absPet = repoRoot.resolve(petPath);
89+
Files.createDirectories(absPet.getParent());
90+
String content = """
91+
package com.example;
92+
import com.example.a.BaseService;
93+
public class PetService extends BaseService {}
94+
""";
95+
Files.writeString(absPet, content);
96+
97+
Resolved resolved = bootstrapAndResolve(petPath, content);
98+
assertInstanceOf(JavaResolved.class, resolved);
99+
100+
DetectorContext ctx = ctxFor(petPath, content).withResolved(resolved);
101+
DetectorResult result = detector.detect(ctx);
102+
103+
CodeEdge extendsEdge = onlyEdge(result, EdgeKind.EXTENDS);
104+
assertEquals("com.example.a.BaseService", extendsEdge.getProperties().get("target_fqn"));
105+
assertEquals(Confidence.RESOLVED, extendsEdge.getConfidence());
106+
assertEquals(detector.getName(), extendsEdge.getSource());
107+
}
108+
109+
@Test
110+
void resolvedModeStampsResolvedTierOnImplementsEdge() throws Exception {
111+
String petPath = "src/main/java/com/example/PetService.java";
112+
Path absPet = repoRoot.resolve(petPath);
113+
Files.createDirectories(absPet.getParent());
114+
String content = """
115+
package com.example;
116+
import com.example.a.Auditable;
117+
public class PetService implements Auditable {}
118+
""";
119+
Files.writeString(absPet, content);
120+
121+
Resolved resolved = bootstrapAndResolve(petPath, content);
122+
DetectorContext ctx = ctxFor(petPath, content).withResolved(resolved);
123+
DetectorResult result = detector.detect(ctx);
124+
125+
CodeEdge implementsEdge = onlyEdge(result, EdgeKind.IMPLEMENTS);
126+
assertEquals("com.example.a.Auditable", implementsEdge.getProperties().get("target_fqn"));
127+
assertEquals(Confidence.RESOLVED, implementsEdge.getConfidence());
128+
}
129+
130+
// ── (2) Fallback mode ────────────────────────────────────────────────────
131+
132+
@Test
133+
void fallbackModeMatchesPreSpecBaseline() throws Exception {
134+
String petPath = "src/main/java/com/example/PetService.java";
135+
Path absPet = repoRoot.resolve(petPath);
136+
Files.createDirectories(absPet.getParent());
137+
String content = """
138+
package com.example;
139+
import com.example.a.BaseService;
140+
public class PetService extends BaseService {}
141+
""";
142+
Files.writeString(absPet, content);
143+
144+
DetectorContext ctx = ctxFor(petPath, content).withResolved(EmptyResolved.INSTANCE);
145+
DetectorResult result = detector.detect(ctx);
146+
147+
CodeEdge extendsEdge = onlyEdge(result, EdgeKind.EXTENDS);
148+
assertNull(extendsEdge.getProperties().get("target_fqn"),
149+
"EmptyResolved → no FQN attempt, no target_fqn");
150+
assertNotEquals(Confidence.RESOLVED, extendsEdge.getConfidence());
151+
assertNull(extendsEdge.getSource());
152+
}
153+
154+
@Test
155+
void fallbackModeWhenContextHasNoResolvedAtAll() throws Exception {
156+
String petPath = "src/main/java/com/example/PetService.java";
157+
Path absPet = repoRoot.resolve(petPath);
158+
Files.createDirectories(absPet.getParent());
159+
String content = """
160+
package com.example;
161+
public class PetService extends BaseService {}
162+
""";
163+
Files.writeString(absPet, content);
164+
165+
DetectorContext ctx = ctxFor(petPath, content);
166+
DetectorResult result = detector.detect(ctx);
167+
168+
CodeEdge extendsEdge = onlyEdge(result, EdgeKind.EXTENDS);
169+
assertNull(extendsEdge.getProperties().get("target_fqn"));
170+
assertNotEquals(Confidence.RESOLVED, extendsEdge.getConfidence());
171+
}
172+
173+
// ── (3) Mixed mode ───────────────────────────────────────────────────────
174+
175+
@Test
176+
void mixedModeFallsBackForUnreachableType() throws Exception {
177+
// Class extends a known type and implements an unknown one.
178+
// Expect: EXTENDS edge gets RESOLVED, IMPLEMENTS edge falls back.
179+
String petPath = "src/main/java/com/example/PetService.java";
180+
Path absPet = repoRoot.resolve(petPath);
181+
Files.createDirectories(absPet.getParent());
182+
String content = """
183+
package com.example;
184+
import com.example.a.BaseService;
185+
public class PetService extends BaseService implements MysteryAware {}
186+
""";
187+
Files.writeString(absPet, content);
188+
189+
Resolved resolved = bootstrapAndResolve(petPath, content);
190+
DetectorContext ctx = ctxFor(petPath, content).withResolved(resolved);
191+
DetectorResult result = detector.detect(ctx);
192+
193+
CodeEdge extendsEdge = onlyEdge(result, EdgeKind.EXTENDS);
194+
assertEquals("com.example.a.BaseService", extendsEdge.getProperties().get("target_fqn"));
195+
assertEquals(Confidence.RESOLVED, extendsEdge.getConfidence());
196+
197+
CodeEdge implementsEdge = onlyEdge(result, EdgeKind.IMPLEMENTS);
198+
assertNull(implementsEdge.getProperties().get("target_fqn"),
199+
"MysteryAware has no source — solver fails — fallback");
200+
assertNotEquals(Confidence.RESOLVED, implementsEdge.getConfidence());
201+
}
202+
203+
// ── Helpers ──────────────────────────────────────────────────────────────
204+
205+
private Resolved bootstrapAndResolve(String relPath, String content) throws ResolutionException {
206+
resolver.bootstrap(repoRoot);
207+
DiscoveredFile file = new DiscoveredFile(Path.of(relPath), "java", content.length());
208+
return resolver.resolve(file, content);
209+
}
210+
211+
private DetectorContext ctxFor(String relPath, String content) {
212+
return new DetectorContext(relPath, "java", content, null, null);
213+
}
214+
215+
private static CodeEdge onlyEdge(DetectorResult result, EdgeKind kind) {
216+
List<CodeEdge> matching = result.edges().stream()
217+
.filter(e -> e.getKind() == kind)
218+
.toList();
219+
assertEquals(1, matching.size(),
220+
"expected exactly one " + kind + " edge, got " + matching.size());
221+
return matching.get(0);
222+
}
223+
}

0 commit comments

Comments
 (0)