A
This commit is contained in:
@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
|
||||
@@ -268,7 +269,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
final boolean debug = log.isDebugEnabled();
|
||||
try {
|
||||
String event = tp.getEvent();
|
||||
event = unwrapConverterMethods(event);
|
||||
String triggerScope = path.isEmpty() ? null : path.get(path.size() - 1);
|
||||
event = unwrapConverterMethods(event, triggerScope);
|
||||
String currentParamName = event;
|
||||
String resolvedValue = event;
|
||||
String methodSuffix = "";
|
||||
@@ -360,7 +362,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (paramIndex < edge.getArguments().size()) {
|
||||
String arg = edge.getArguments().get(paramIndex);
|
||||
if (arg != null) {
|
||||
arg = unwrapConverterMethods(arg);
|
||||
arg = unwrapConverterMethods(arg, caller);
|
||||
String receiver = edge.getReceiver();
|
||||
String actualReceiver = null;
|
||||
if ("this".equals(arg) || arg.startsWith("this.") || "super".equals(arg) || arg.startsWith("super.")) {
|
||||
@@ -1041,16 +1043,36 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
&& (fallbackMi.getExpression() == null || fallbackMi.getExpression() instanceof ThisExpression)) {
|
||||
TypeDeclaration enclosingType = findEnclosingType(fallbackMi);
|
||||
CompilationUnit fallbackCu = fallbackMi.getRoot() instanceof CompilationUnit cu ? cu : null;
|
||||
List<String> ownerCandidates = new ArrayList<>();
|
||||
if (enclosingType != null) {
|
||||
List<String> switchConstants = resolveLocalSwitchMethodConstants(enclosingType, fallbackMi);
|
||||
for (String switchConstant : switchConstants) {
|
||||
if (switchConstant != null && !switchConstant.startsWith("<SYMBOLIC:")
|
||||
&& !polymorphicEvents.contains(switchConstant)) {
|
||||
polymorphicEvents.add(switchConstant);
|
||||
ownerCandidates.add(context.getFqn(enclosingType));
|
||||
}
|
||||
// String-parsed expressions have no enclosing AST type — walk the call path instead.
|
||||
for (String methodFqn : path) {
|
||||
if (methodFqn != null && methodFqn.contains(".")) {
|
||||
String owner = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
if (!ownerCandidates.contains(owner)) {
|
||||
ownerCandidates.add(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String ownerFqn : ownerCandidates) {
|
||||
TypeDeclaration ownerTd = context.getTypeDeclaration(ownerFqn);
|
||||
if (ownerTd == null
|
||||
|| context.findMethodDeclaration(ownerTd, fallbackMi.getName().getIdentifier(), true) == null) {
|
||||
continue;
|
||||
}
|
||||
if (enclosingType != null && ownerFqn.equals(context.getFqn(enclosingType))) {
|
||||
List<String> switchConstants = resolveLocalSwitchMethodConstants(enclosingType, fallbackMi);
|
||||
for (String switchConstant : switchConstants) {
|
||||
if (switchConstant != null && !switchConstant.startsWith("<SYMBOLIC:")
|
||||
&& !polymorphicEvents.contains(switchConstant)) {
|
||||
polymorphicEvents.add(switchConstant);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> methodReturns = constantExtractor.resolveMethodReturnConstant(
|
||||
context.getFqn(enclosingType),
|
||||
ownerFqn,
|
||||
fallbackMi.getName().getIdentifier(),
|
||||
0,
|
||||
new HashSet<>(),
|
||||
@@ -1061,6 +1083,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
polymorphicEvents.add(methodReturn);
|
||||
}
|
||||
}
|
||||
if (!polymorphicEvents.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2732,37 +2757,74 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
|
||||
/**
|
||||
* Peels enum factory wrappers while tracing call-path arguments, e.g.
|
||||
* Peels enum factory / library wrappers while tracing call-path arguments, e.g.
|
||||
* {@code OrderEvent.valueOf(action)} → {@code action} so the next hop can resolve the parameter.
|
||||
* <p>
|
||||
* Does <em>not</em> peel implicit-{@code this} domain helpers ({@code toEvent(msg)}, {@code resolve(x)}).
|
||||
* Those must stay intact so {@link ConstantExtractor} / {@link AccessorResolver} can widen to
|
||||
* subclass implementations and walk overridden return statements. Pure passthrough helpers that
|
||||
* literally {@code return arg;} are still peeled via {@link MethodInvocationUnwrapper}.
|
||||
* <p>
|
||||
* Map/collection lookups ({@code ROUTES.get(key)}) are intentionally <em>not</em> peeled — they are
|
||||
* routing tables, not enum converters. See {@link ExpressionAccessClassifier}.
|
||||
*/
|
||||
private String unwrapConverterMethods(String arg) {
|
||||
private String unwrapConverterMethods(String arg, String scopeMethodFqn) {
|
||||
if (arg == null) return null;
|
||||
String current = arg;
|
||||
while (current.contains("(") && !current.startsWith("new ")) {
|
||||
org.eclipse.jdt.core.dom.ASTNode argNode = parseExpressionString(current);
|
||||
if (argNode instanceof org.eclipse.jdt.core.dom.MethodInvocation miArg && !miArg.arguments().isEmpty()) {
|
||||
if (expressionAccessClassifier.isKeyedLookup(miArg, null)) {
|
||||
break;
|
||||
ASTNode argNode = parseExpressionString(current);
|
||||
if (!(argNode instanceof MethodInvocation miArg) || miArg.arguments().isEmpty()) {
|
||||
break;
|
||||
}
|
||||
if (expressionAccessClassifier.isKeyedLookup(miArg, scopeMethodFqn)) {
|
||||
break;
|
||||
}
|
||||
String methodName = miArg.getName().getIdentifier();
|
||||
Expression recv = miArg.getExpression();
|
||||
|
||||
// Type.valueOf(x) / Enum.valueOf(Class, x) — peel to the name argument.
|
||||
if ("valueOf".equals(methodName) && isTypeLikeReceiver(recv)) {
|
||||
Expression innerArg = (Expression) miArg.arguments().get(0);
|
||||
if (miArg.arguments().size() == 2) {
|
||||
innerArg = (Expression) miArg.arguments().get(1);
|
||||
}
|
||||
boolean shouldUnpack = false;
|
||||
org.eclipse.jdt.core.dom.Expression recv = miArg.getExpression();
|
||||
if (recv == null || recv instanceof org.eclipse.jdt.core.dom.ThisExpression) {
|
||||
shouldUnpack = true;
|
||||
} else if (recv instanceof org.eclipse.jdt.core.dom.SimpleName sn) {
|
||||
shouldUnpack = !Character.isLowerCase(sn.getIdentifier().charAt(0));
|
||||
} else if (recv instanceof org.eclipse.jdt.core.dom.QualifiedName qn) {
|
||||
shouldUnpack = !Character.isLowerCase(qn.getName().getIdentifier().charAt(0));
|
||||
}
|
||||
if (shouldUnpack) {
|
||||
org.eclipse.jdt.core.dom.Expression innerArg = (org.eclipse.jdt.core.dom.Expression) miArg.arguments().get(0);
|
||||
if (miArg.getName().getIdentifier().equals("valueOf") && miArg.arguments().size() == 2) {
|
||||
innerArg = (org.eclipse.jdt.core.dom.Expression) miArg.arguments().get(1);
|
||||
current = innerArg.toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Optional.of / Mono.just / MessageBuilder.withPayload — peel library factories only.
|
||||
if (recv != null
|
||||
&& LibraryUnwrapRegistry.isUnwrapReceiverType(recv.toString())
|
||||
&& LibraryUnwrapRegistry.isUnwrapMethodName(methodName)
|
||||
&& !LibraryUnwrapRegistry.shouldStopUnwrap(methodName)) {
|
||||
current = ((Expression) miArg.arguments().get(0)).toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Implicit-this / this.helper(arg): only peel pure passthroughs (return the same parameter).
|
||||
// Domain converters that return enum constants must remain for override/return walking.
|
||||
if (recv == null || recv instanceof ThisExpression) {
|
||||
Expression peeled = MethodInvocationUnwrapper.unwrap(
|
||||
miArg,
|
||||
0,
|
||||
scopeMethodFqn,
|
||||
context,
|
||||
mi -> {
|
||||
if (scopeMethodFqn == null || !scopeMethodFqn.contains(".")) {
|
||||
return null;
|
||||
}
|
||||
String currentClass = scopeMethodFqn.substring(0, scopeMethodFqn.lastIndexOf('.'));
|
||||
if (mi.getExpression() == null || mi.getExpression() instanceof ThisExpression) {
|
||||
return currentClass + "." + mi.getName().getIdentifier();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (peeled != null && peeled != miArg) {
|
||||
String next = peeled.toString();
|
||||
if (!next.equals(current)) {
|
||||
current = next;
|
||||
continue;
|
||||
}
|
||||
current = innerArg.toString();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -2770,6 +2832,18 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return current;
|
||||
}
|
||||
|
||||
private static boolean isTypeLikeReceiver(Expression recv) {
|
||||
if (recv instanceof SimpleName sn) {
|
||||
String name = sn.getIdentifier();
|
||||
return name != null && !name.isEmpty() && !Character.isLowerCase(name.charAt(0));
|
||||
}
|
||||
if (recv instanceof QualifiedName qn) {
|
||||
String name = qn.getName().getIdentifier();
|
||||
return name != null && !name.isEmpty() && !Character.isLowerCase(name.charAt(0));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean shouldDropBarePolymorphicCandidate(String candidate, String expectedType, CodebaseContext context) {
|
||||
if (candidate == null || candidate.contains(".")
|
||||
|| candidate.startsWith("<SYMBOLIC:") || candidate.startsWith("ENUM_SET:")) {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Implicit-{@code this} helpers that return enum constants must not be peeled by
|
||||
* {@code unwrapConverterMethods} — otherwise ConstantExtractor never widens to subclass overrides.
|
||||
*/
|
||||
class ImplicitThisHelperEventResolutionTest {
|
||||
|
||||
@Test
|
||||
void shouldResolveEventFromImplicitThisHelperOverrides(@TempDir Path tempDir) throws Exception {
|
||||
Path javaRoot = tempDir.resolve("src/main/java/com/example");
|
||||
Files.createDirectories(javaRoot);
|
||||
Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }");
|
||||
Files.writeString(javaRoot.resolve("OrderEvent.java"), """
|
||||
package com.example;
|
||||
public enum OrderEvent { PAY, SHIP, LOG }
|
||||
""");
|
||||
Files.writeString(javaRoot.resolve("StateMachine.java"), """
|
||||
package com.example;
|
||||
public class StateMachine {
|
||||
void sendEvent(OrderEvent event) {}
|
||||
}
|
||||
""");
|
||||
Files.writeString(javaRoot.resolve("AbstractDispatcher.java"), """
|
||||
package com.example;
|
||||
public abstract class AbstractDispatcher {
|
||||
private final StateMachine sm = new StateMachine();
|
||||
protected abstract OrderEvent toEvent(String message);
|
||||
public void handle(String message) {
|
||||
sm.sendEvent(toEvent(message));
|
||||
}
|
||||
}
|
||||
""");
|
||||
Files.writeString(javaRoot.resolve("PayDispatcher.java"), """
|
||||
package com.example;
|
||||
public class PayDispatcher extends AbstractDispatcher {
|
||||
@Override
|
||||
protected OrderEvent toEvent(String message) {
|
||||
return OrderEvent.PAY;
|
||||
}
|
||||
}
|
||||
""");
|
||||
Files.writeString(javaRoot.resolve("ShipDispatcher.java"), """
|
||||
package com.example;
|
||||
public class ShipDispatcher extends AbstractDispatcher {
|
||||
@Override
|
||||
protected OrderEvent toEvent(String message) {
|
||||
return OrderEvent.SHIP;
|
||||
}
|
||||
}
|
||||
""");
|
||||
Files.writeString(javaRoot.resolve("OrderController.java"), """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
private final AbstractDispatcher dispatcher;
|
||||
public OrderController(AbstractDispatcher dispatcher) {
|
||||
this.dispatcher = dispatcher;
|
||||
}
|
||||
public void onCommand(String message) {
|
||||
dispatcher.handle(message);
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString()));
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
List<CallChain> chains = engine.findChains(
|
||||
List.of(EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /commands")
|
||||
.className("com.example.OrderController")
|
||||
.methodName("onCommand")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("message")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build()),
|
||||
List.of(TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("event")
|
||||
.build()));
|
||||
|
||||
assertThat(chains).isNotEmpty();
|
||||
List<String> poly = chains.stream()
|
||||
.flatMap(chain -> {
|
||||
List<String> events = chain.getTriggerPoint().getPolymorphicEvents();
|
||||
return events == null ? java.util.stream.Stream.<String>empty() : events.stream();
|
||||
})
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
assertThat(poly)
|
||||
.as("toEvent(message) must stay intact so override returns are collected")
|
||||
.anyMatch(e -> e.endsWith(".PAY") || e.equals("PAY") || e.endsWith("OrderEvent.PAY"))
|
||||
.anyMatch(e -> e.endsWith(".SHIP") || e.equals("SHIP") || e.endsWith("OrderEvent.SHIP"));
|
||||
assertThat(poly)
|
||||
.as("must not collapse to the raw message parameter after destructive unwrap")
|
||||
.noneMatch(e -> "message".equals(e));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStillPeelValueOfForParameterTracing(@TempDir Path tempDir) throws Exception {
|
||||
Path javaRoot = tempDir.resolve("src/main/java/com/example");
|
||||
Files.createDirectories(javaRoot);
|
||||
Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }");
|
||||
Files.writeString(javaRoot.resolve("OrderEvent.java"), """
|
||||
package com.example;
|
||||
public enum OrderEvent { PAY, SHIP }
|
||||
""");
|
||||
Files.writeString(javaRoot.resolve("StateMachine.java"), """
|
||||
package com.example;
|
||||
public class StateMachine {
|
||||
void sendEvent(OrderEvent event) {}
|
||||
}
|
||||
""");
|
||||
Files.writeString(javaRoot.resolve("OrderController.java"), """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
private final StateMachine sm = new StateMachine();
|
||||
public void onCommand(String eventStr) {
|
||||
sm.sendEvent(OrderEvent.valueOf(eventStr));
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString()));
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
List<CallChain> chains = engine.findChains(
|
||||
List.of(EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /commands/{eventStr}")
|
||||
.className("com.example.OrderController")
|
||||
.methodName("onCommand")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("eventStr")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build()),
|
||||
List.of(TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("event")
|
||||
.build()));
|
||||
|
||||
assertThat(chains).isNotEmpty();
|
||||
// valueOf peel must still allow tracing back to the path variable name.
|
||||
assertThat(chains.stream()
|
||||
.map(c -> c.getTriggerPoint().getEvent())
|
||||
.anyMatch(e -> e != null && (e.contains("eventStr") || e.contains("valueOf"))))
|
||||
.isTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user