diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index a4bf9af..730cefe 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -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 ownerCandidates = new ArrayList<>(); if (enclosingType != null) { - List switchConstants = resolveLocalSwitchMethodConstants(enclosingType, fallbackMi); - for (String switchConstant : switchConstants) { - if (switchConstant != null && !switchConstant.startsWith(" switchConstants = resolveLocalSwitchMethodConstants(enclosingType, fallbackMi); + for (String switchConstant : switchConstants) { + if (switchConstant != null && !switchConstant.startsWith(" 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. *

+ * Does not 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}. + *

* Map/collection lookups ({@code ROUTES.get(key)}) are intentionally not 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(" 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 poly = chains.stream() + .flatMap(chain -> { + List events = chain.getTriggerPoint().getPolymorphicEvents(); + return events == null ? java.util.stream.Stream.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 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(); + } +}