Skip to content

Commit d9d0678

Browse files
aksOpsclaude
andcommitted
feat(detector/spring-rest): emit RESOLVED MAPS_TO edges for @RequestBody DTOs
Phase 6 task 29 (SpringRestDetector migration). Per the plan: "Resolves @RequestBody UserDto dto and @PathVariable types. Edge: MAPS_TO from endpoint node to the resolved DTO class." When ctx.resolved() carries a JavaResolved, the detector now: 1. Uses the resolver-parsed CompilationUnit (symbol solver attached) instead of the local ThreadLocal-pool parse — Type.resolve() works inside the AST walk for parameter type resolution. 2. After emitting each ENDPOINT node + its EXPOSES edge, scans the method's parameters for @RequestBody. For each binding parameter whose type is a class/interface, attempts to resolve to a stable fully-qualified name via the symbol solver. 3. On success, emits a MAPS_TO edge: endpoint --MAPS_TO--> *:<simpleName> stamped with target_fqn / parameter_kind=request_body / parameter_name properties + Confidence.RESOLVED + source = "spring_rest". Target node uses NodeKind.CLASS so EntityLinker can resolve the FQN to a concrete class node post-pass. 4. On failure (primitive type, classpath gap, generic variable, etc.), no MAPS_TO edge is emitted — endpoint extraction itself is unaffected. The endpoint's `parameters` property still records the simple type name for the lexical / SYNTACTIC tier. This is purely additive: when ctx.resolved() is empty / EmptyResolved, the detector behaves identically to before the migration. The 27 existing SpringRestDetectorExtended tests pass unchanged. 4 new tests in SpringRestDetectorResolvedTest cover: - resolvedModeProducesResolvedMapsToEdge — two UserDto in different packages; imported FQN wins on edge.target_fqn + RESOLVED stamp + parameter_name property. - fallbackModeProducesNoMapsToEdge — EmptyResolved → endpoint still emitted, but no MAPS_TO (additive contract). - fallbackModeWhenContextHasNoResolvedAtAll — Optional.empty() also produces no MAPS_TO. - mixedModeFallsBackForUnreachableType — endpoint with one resolvable DTO + one unresolvable: only the resolvable one gets MAPS_TO. Plan: docs/plans/2026-04-27-sub-project-1-resolver-spi-and-java-pilot.md (task 29). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 473508e commit d9d0678

2 files changed

Lines changed: 310 additions & 3 deletions

File tree

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

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
import com.github.javaparser.ast.CompilationUnit;
44
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
55
import com.github.javaparser.ast.body.MethodDeclaration;
6+
import com.github.javaparser.ast.body.Parameter;
67
import com.github.javaparser.ast.expr.*;
8+
import com.github.javaparser.ast.type.Type;
9+
import com.github.javaparser.resolution.types.ResolvedType;
710
import io.github.randomcodespace.iq.detector.DetectorContext;
811
import io.github.randomcodespace.iq.detector.DetectorResult;
12+
import io.github.randomcodespace.iq.intelligence.resolver.Resolved;
13+
import io.github.randomcodespace.iq.intelligence.resolver.java.JavaResolved;
914
import io.github.randomcodespace.iq.model.CodeEdge;
1015
import io.github.randomcodespace.iq.model.CodeNode;
16+
import io.github.randomcodespace.iq.model.Confidence;
1117
import io.github.randomcodespace.iq.model.EdgeKind;
1218
import io.github.randomcodespace.iq.model.NodeKind;
1319
import org.springframework.stereotype.Component;
@@ -93,16 +99,25 @@ public DetectorResult detect(DetectorContext ctx) {
9399
String text = ctx.content();
94100
if (text == null || text.isEmpty()) return DetectorResult.empty();
95101

96-
Optional<CompilationUnit> cu = parse(ctx);
102+
// Prefer the resolver-parsed CU when available — it has the symbol
103+
// solver attached, so Type.resolve() works inside the AST walk for
104+
// @RequestBody / @PathVariable type lifting.
105+
Optional<JavaResolved> resolved = ctx.resolved()
106+
.filter(Resolved::isAvailable)
107+
.filter(JavaResolved.class::isInstance)
108+
.map(JavaResolved.class::cast);
109+
110+
Optional<CompilationUnit> cu = resolved.map(JavaResolved::cu).or(() -> parse(ctx));
97111
if (cu.isPresent()) {
98-
return detectWithAst(cu.get(), ctx);
112+
return detectWithAst(cu.get(), ctx, resolved);
99113
}
100114
return detectWithRegex(ctx);
101115
}
102116

103117
// ==================== AST-based detection ====================
104118

105-
private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) {
119+
private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx,
120+
Optional<JavaResolved> resolved) {
106121
List<CodeNode> nodes = new ArrayList<>();
107122
List<CodeEdge> edges = new ArrayList<>();
108123

@@ -200,6 +215,15 @@ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) {
200215
edge.setSourceId(classNodeId);
201216
edge.setTarget(node);
202217
edges.add(edge);
218+
219+
// RESOLVED tier: emit MAPS_TO edges for @RequestBody (and
220+
// @PathVariable / @RequestParam reference types) when the
221+
// symbol solver can pin a fully-qualified DTO. These ride
222+
// alongside the existing endpoint metadata so the SPA + MCP
223+
// can navigate from endpoint → DTO without a string-match
224+
// round trip through EntityLinker.
225+
resolved.ifPresent(jr ->
226+
addRequestBodyMapsToEdges(method, endpointId, edges));
203227
}
204228
}
205229
});
@@ -210,6 +234,63 @@ private DetectorResult detectWithAst(CompilationUnit cu, DetectorContext ctx) {
210234
return DetectorResult.of(nodes, edges);
211235
}
212236

237+
/**
238+
* For each parameter annotated with {@code @RequestBody} (and the few
239+
* other reference-type binding annotations), try to resolve the parameter
240+
* type via the symbol solver. On success, emit a {@link EdgeKind#MAPS_TO}
241+
* edge from {@code endpointId} → {@code "*:" + simpleName} stamped with
242+
* {@code Confidence.RESOLVED} and a {@code target_fqn} property.
243+
*
244+
* <p>Resolution failures are silent — the request body type might be a
245+
* primitive (no edge needed), a third-party class missing from the
246+
* classpath (genuinely unresolvable), or a generic type variable. The
247+
* existing {@code parameters} property on the endpoint node still carries
248+
* the simple name for the lexical / regex tier.
249+
*/
250+
private void addRequestBodyMapsToEdges(MethodDeclaration method, String endpointId,
251+
List<CodeEdge> edges) {
252+
for (Parameter param : method.getParameters()) {
253+
boolean isBindable = param.getAnnotations().stream().anyMatch(a ->
254+
"RequestBody".equals(a.getNameAsString()));
255+
if (!isBindable) continue;
256+
// Only emit MAPS_TO when the parameter type is a class/interface
257+
// — primitive types (int, long) have no FQN and no DTO target.
258+
Type paramType = param.getType();
259+
if (!paramType.isClassOrInterfaceType()) continue;
260+
261+
Optional<String> fqn = tryResolveFqn(paramType);
262+
if (fqn.isEmpty()) continue;
263+
264+
String simpleName = paramType.asClassOrInterfaceType().getNameAsString();
265+
Map<String, Object> edgeProps = new LinkedHashMap<>();
266+
edgeProps.put("target_fqn", fqn.get());
267+
edgeProps.put("parameter_kind", "request_body");
268+
edgeProps.put("parameter_name", param.getNameAsString());
269+
270+
CodeEdge mapsTo = new CodeEdge();
271+
mapsTo.setId(endpointId + "->maps_to->*:" + fqn.get());
272+
mapsTo.setKind(EdgeKind.MAPS_TO);
273+
mapsTo.setSourceId(endpointId);
274+
mapsTo.setTarget(new CodeNode("*:" + simpleName, NodeKind.CLASS, simpleName));
275+
mapsTo.setProperties(edgeProps);
276+
mapsTo.setConfidence(Confidence.RESOLVED);
277+
mapsTo.setSource(getName());
278+
edges.add(mapsTo);
279+
}
280+
}
281+
282+
private static Optional<String> tryResolveFqn(Type type) {
283+
try {
284+
ResolvedType rt = type.resolve();
285+
if (rt.isReferenceType()) {
286+
return Optional.of(rt.asReferenceType().getQualifiedName());
287+
}
288+
return Optional.of(rt.describe());
289+
} catch (RuntimeException e) {
290+
return Optional.empty();
291+
}
292+
}
293+
213294
/**
214295
* Extract path from a mapping annotation (value or path attribute, or bare string).
215296
*/
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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 — SpringRestDetector migration to consume {@code ctx.resolved()}
28+
* and emit RESOLVED-tier MAPS_TO edges from endpoints to their {@code
29+
* @RequestBody} DTO classes.
30+
*
31+
* <p>Three contract tests per the plan:
32+
* <ol>
33+
* <li><b>resolvedModeProducesResolvedMapsToEdge</b> — {@code @RequestBody
34+
* UserDto} with two {@code UserDto} classes in different packages;
35+
* resolution picks the imported FQN and stamps the edge RESOLVED.</li>
36+
* <li><b>fallbackModeMatchesPreSpecBaseline</b> — EmptyResolved → no
37+
* MAPS_TO edge from endpoint → DTO (existing pre-migration shape).</li>
38+
* <li><b>mixedModeUsesResolverWhereAvailable</b> — endpoint with one
39+
* resolvable DTO and one unresolvable type: only the resolvable case
40+
* gets a MAPS_TO edge.</li>
41+
* </ol>
42+
*/
43+
class SpringRestDetectorResolvedTest {
44+
45+
@TempDir Path repoRoot;
46+
47+
private final SpringRestDetector detector = new SpringRestDetector();
48+
private JavaSymbolResolver resolver;
49+
50+
@BeforeEach
51+
void setUp() throws IOException {
52+
Files.createDirectories(repoRoot.resolve("src/main/java/com/example/a"));
53+
Files.createDirectories(repoRoot.resolve("src/main/java/com/example/b"));
54+
Files.writeString(repoRoot.resolve("pom.xml"), "<project/>");
55+
56+
Files.writeString(repoRoot.resolve("src/main/java/com/example/a/UserDto.java"),
57+
"""
58+
package com.example.a;
59+
public class UserDto {}
60+
""");
61+
Files.writeString(repoRoot.resolve("src/main/java/com/example/b/UserDto.java"),
62+
"""
63+
package com.example.b;
64+
public class UserDto {}
65+
""");
66+
67+
resolver = new JavaSymbolResolver(new JavaSourceRootDiscovery());
68+
}
69+
70+
// ── (1) Resolved mode ────────────────────────────────────────────────────
71+
72+
@Test
73+
void resolvedModeProducesResolvedMapsToEdge() throws Exception {
74+
// Two UserDto classes in different packages; controller imports one.
75+
// With resolution, MAPS_TO target_fqn pins the imported one.
76+
String controllerPath = "src/main/java/com/example/UserController.java";
77+
Path absController = repoRoot.resolve(controllerPath);
78+
Files.createDirectories(absController.getParent());
79+
String content = """
80+
package com.example;
81+
import com.example.a.UserDto;
82+
public class UserController {
83+
public String createUser(@RequestBody UserDto dto) {
84+
return "ok";
85+
}
86+
@PostMapping("/users")
87+
public String postUser(@RequestBody UserDto body) {
88+
return "ok";
89+
}
90+
}
91+
""";
92+
Files.writeString(absController, content);
93+
94+
Resolved resolved = bootstrapAndResolve(controllerPath, content);
95+
assertInstanceOf(JavaResolved.class, resolved);
96+
97+
DetectorContext ctx = ctxFor(controllerPath, content).withResolved(resolved);
98+
DetectorResult result = detector.detect(ctx);
99+
100+
// Only the @PostMapping-annotated method actually creates an endpoint —
101+
// the un-mapped createUser is filtered out. So one MAPS_TO is expected.
102+
List<CodeEdge> mapsTo = mapsToEdges(result);
103+
assertEquals(1, mapsTo.size(),
104+
"exactly one @RequestBody parameter on a real endpoint → one MAPS_TO");
105+
106+
CodeEdge edge = mapsTo.get(0);
107+
assertEquals("com.example.a.UserDto", edge.getProperties().get("target_fqn"),
108+
"imported package wins — not the b/ DTO");
109+
assertEquals("request_body", edge.getProperties().get("parameter_kind"));
110+
assertEquals("body", edge.getProperties().get("parameter_name"),
111+
"parameter name rides as metadata for downstream consumers");
112+
assertEquals(Confidence.RESOLVED, edge.getConfidence());
113+
assertEquals(detector.getName(), edge.getSource());
114+
}
115+
116+
// ── (2) Fallback mode ────────────────────────────────────────────────────
117+
118+
@Test
119+
void fallbackModeProducesNoMapsToEdge() throws Exception {
120+
// Without ctx.resolved(), the detector emits its existing endpoint
121+
// node + EXPOSES edge, but no MAPS_TO — that's the migration's
122+
// additive contract. Existing 27 SpringRestDetectorExtendedTest cases
123+
// already cover endpoint extraction itself.
124+
String controllerPath = "src/main/java/com/example/UserController.java";
125+
Path absController = repoRoot.resolve(controllerPath);
126+
Files.createDirectories(absController.getParent());
127+
String content = """
128+
package com.example;
129+
import com.example.a.UserDto;
130+
public class UserController {
131+
@PostMapping("/users")
132+
public String postUser(@RequestBody UserDto body) {
133+
return "ok";
134+
}
135+
}
136+
""";
137+
Files.writeString(absController, content);
138+
139+
DetectorContext ctx = ctxFor(controllerPath, content).withResolved(EmptyResolved.INSTANCE);
140+
DetectorResult result = detector.detect(ctx);
141+
142+
assertTrue(mapsToEdges(result).isEmpty(),
143+
"no JavaResolved → no MAPS_TO edges (additive contract)");
144+
// The endpoint itself still gets emitted — sanity check.
145+
assertFalse(result.nodes().isEmpty(),
146+
"endpoint detection still runs in fallback mode");
147+
}
148+
149+
@Test
150+
void fallbackModeWhenContextHasNoResolvedAtAll() throws Exception {
151+
String controllerPath = "src/main/java/com/example/UserController.java";
152+
Path absController = repoRoot.resolve(controllerPath);
153+
Files.createDirectories(absController.getParent());
154+
String content = """
155+
package com.example;
156+
import com.example.a.UserDto;
157+
public class UserController {
158+
@PostMapping("/users")
159+
public String postUser(@RequestBody UserDto body) {
160+
return "ok";
161+
}
162+
}
163+
""";
164+
Files.writeString(absController, content);
165+
166+
// No withResolved at all — ctx.resolved() is Optional.empty().
167+
DetectorContext ctx = ctxFor(controllerPath, content);
168+
DetectorResult result = detector.detect(ctx);
169+
assertTrue(mapsToEdges(result).isEmpty());
170+
}
171+
172+
// ── (3) Mixed mode ───────────────────────────────────────────────────────
173+
174+
@Test
175+
void mixedModeFallsBackForUnreachableType() throws Exception {
176+
// Two endpoints — one body type is reachable (UserDto from
177+
// com.example.a), the other (MysteryDto) has no source on the
178+
// project. Resolved one gets MAPS_TO, unreachable one doesn't.
179+
String controllerPath = "src/main/java/com/example/UserController.java";
180+
Path absController = repoRoot.resolve(controllerPath);
181+
Files.createDirectories(absController.getParent());
182+
String content = """
183+
package com.example;
184+
import com.example.a.UserDto;
185+
public class UserController {
186+
@PostMapping("/users")
187+
public String createUser(@RequestBody UserDto dto) {
188+
return "ok";
189+
}
190+
@PostMapping("/mystery")
191+
public String mystery(@RequestBody MysteryDto dto) {
192+
return "ok";
193+
}
194+
}
195+
""";
196+
Files.writeString(absController, content);
197+
198+
Resolved resolved = bootstrapAndResolve(controllerPath, content);
199+
DetectorContext ctx = ctxFor(controllerPath, content).withResolved(resolved);
200+
DetectorResult result = detector.detect(ctx);
201+
202+
List<CodeEdge> mapsTo = mapsToEdges(result);
203+
assertEquals(1, mapsTo.size(),
204+
"only the resolvable DTO produces a MAPS_TO edge");
205+
assertEquals("com.example.a.UserDto", mapsTo.get(0).getProperties().get("target_fqn"));
206+
assertEquals(Confidence.RESOLVED, mapsTo.get(0).getConfidence());
207+
}
208+
209+
// ── Helpers ──────────────────────────────────────────────────────────────
210+
211+
private Resolved bootstrapAndResolve(String relPath, String content) throws ResolutionException {
212+
resolver.bootstrap(repoRoot);
213+
DiscoveredFile file = new DiscoveredFile(Path.of(relPath), "java", content.length());
214+
return resolver.resolve(file, content);
215+
}
216+
217+
private DetectorContext ctxFor(String relPath, String content) {
218+
return new DetectorContext(relPath, "java", content, null, null);
219+
}
220+
221+
private static List<CodeEdge> mapsToEdges(DetectorResult result) {
222+
return result.edges().stream()
223+
.filter(e -> e.getKind() == EdgeKind.MAPS_TO)
224+
.toList();
225+
}
226+
}

0 commit comments

Comments
 (0)