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 96a65a0..d8638b5 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 @@ -2701,18 +2701,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String baseCalled = resolveCalledMethod(node); if (baseCalled == null) return Collections.emptyList(); - if (node.getExpression() == null) { + if (!baseCalled.contains(".")) { return Collections.singletonList(baseCalled); } - if (!baseCalled.contains(".")) { + // Plain doX(m) and this.doX(m) are the same virtual dispatch on the enclosing + // instance. Returning early on a null expression skipped subclass expansion for the + // classic template-method pattern (abstract base calls doPMessage without "this."). + boolean isImplicitThis = + node.getExpression() == null || node.getExpression() instanceof ThisExpression; + if (isImplicitThis && isStaticResolvedMethod(baseCalled)) { return Collections.singletonList(baseCalled); } String className = baseCalled.substring(0, baseCalled.lastIndexOf('.')); String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1); TypeDeclaration td = context.getTypeDeclaration(className); - boolean isImplicitThis = node.getExpression() instanceof ThisExpression; String cacheKey = baseCalled + "#" + isImplicitThis; List cached = polymorphicCallCache.get(cacheKey); if (cached != null) { @@ -2727,7 +2731,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { shouldExpand = false; } else if (td == null) { shouldExpand = false; - } else if (!td.isInterface() && !Modifier.isAbstract(td.getModifiers()) && isImplicitThis) { + } else if (isImplicitThis) { + // Implicit-this / plain calls: only expand abstract/interface hooks. Concrete + // methods on an abstract class (the template method itself) keep a single edge to + // the declaring body — expanding them invents ClassA.processMessage / ClassB... + // keys that do not exist and cross-wires unrelated subclasses. + shouldExpand = isAbstractOrInterfaceMethod(td, methodName); + } else if (!td.isInterface() && !Modifier.isAbstract(td.getModifiers())) { shouldExpand = false; } @@ -2757,6 +2767,32 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return frozen; } + private boolean isAbstractOrInterfaceMethod(TypeDeclaration declaringType, String methodName) { + if (declaringType == null || methodName == null) { + return false; + } + if (declaringType.isInterface()) { + return true; + } + MethodDeclaration md = context.findMethodDeclaration(declaringType, methodName, false); + return md != null && Modifier.isAbstract(md.getModifiers()); + } + + private boolean isStaticResolvedMethod(String methodFqn) { + int lastDot = methodFqn.lastIndexOf('.'); + if (lastDot <= 0) { + return false; + } + String className = methodFqn.substring(0, lastDot); + String methodName = methodFqn.substring(lastDot + 1); + TypeDeclaration td = context.getTypeDeclaration(className); + if (td == null) { + return false; + } + MethodDeclaration md = context.findMethodDeclaration(td, methodName, false); + return md != null && Modifier.isStatic(md.getModifiers()); + } + /** * 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. diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ImplicitThisTemplateMethodCallGraphTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ImplicitThisTemplateMethodCallGraphTest.java new file mode 100644 index 0000000..3388c98 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ImplicitThisTemplateMethodCallGraphTest.java @@ -0,0 +1,189 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +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 java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Template-method bases call abstract hooks as plain {@code doPMessage(m)} (no {@code this.}). + * That null-receiver form must still expand to concrete subclass overrides in the call graph — + * otherwise JMS listeners that share the base never reach subclass triggers. + */ +class ImplicitThisTemplateMethodCallGraphTest { + + @Test + void plainHookCallWithoutThisExpandsToSubclassOverrides(@TempDir Path tempDir) throws Exception { + writeTemplateMethodFixture(tempDir); + + CodebaseContext context = scan(tempDir); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + List fromProcess = graph.get("com.example.AbstractListener.processMessage"); + assertThat(fromProcess) + .extracting(CallEdge::getTargetMethod) + .as("doPMessage(m) must expand like this.doPMessage(m) for abstract hooks") + .contains( + "com.example.OrderListener.doPMessage", + "com.example.DocumentListener.doPMessage") + .doesNotContain("com.example.AbstractListener.doPMessage"); + + List fromOnMessage = graph.get("com.example.OrderListener.onMessage"); + assertThat(fromOnMessage) + .extracting(CallEdge::getTargetMethod) + .as("concrete template method processMessage must not invent subclass keys") + .containsExactly("com.example.AbstractListener.processMessage"); + } + + @Test + void jmsEntryReachesSubclassTriggerThroughPlainTemplateHook(@TempDir Path tempDir) throws Exception { + writeTemplateMethodFixture(tempDir); + + CodebaseContext context = scan(tempDir); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + + List orderChains = engine.findChains( + List.of(EntryPoint.builder() + .type(EntryPoint.Type.JMS) + .name("JMS: orders") + .className("com.example.OrderListener") + .methodName("onMessage") + .build()), + List.of(TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("event") + .build())); + + assertThat(orderChains) + .as("OrderListener.onMessage -> inherited processMessage -> OrderListener.doPMessage -> sendEvent") + .isNotEmpty(); + assertThat(orderChains) + .as("at least one path must go through this listener's hook, not only sibling subclasses") + .anyMatch(chain -> chain.getMethodChain().stream() + .anyMatch(hop -> hop.contains("OrderListener.doPMessage"))); + assertThat(orderChains.stream() + .filter(chain -> chain.getMethodChain().stream() + .anyMatch(hop -> hop.contains("OrderListener.doPMessage"))) + .flatMap(chain -> { + List events = chain.getTriggerPoint().getPolymorphicEvents(); + return events == null ? java.util.stream.Stream.empty() : events.stream(); + })) + .anyMatch(e -> e.contains("PAY") || e.endsWith(".PAY")); + + List documentChains = engine.findChains( + List.of(EntryPoint.builder() + .type(EntryPoint.Type.JMS) + .name("JMS: documents") + .className("com.example.DocumentListener") + .methodName("onMessage") + .build()), + List.of(TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("event") + .build())); + + assertThat(documentChains).isNotEmpty(); + assertThat(documentChains) + .anyMatch(chain -> chain.getMethodChain().stream() + .anyMatch(hop -> hop.contains("DocumentListener.doPMessage"))); + assertThat(documentChains.stream() + .filter(chain -> chain.getMethodChain().stream() + .anyMatch(hop -> hop.contains("DocumentListener.doPMessage"))) + .flatMap(chain -> { + List events = chain.getTriggerPoint().getPolymorphicEvents(); + return events == null ? java.util.stream.Stream.empty() : events.stream(); + })) + .anyMatch(e -> e.contains("SUBMIT") || e.endsWith(".SUBMIT")); + } + + @Test + void explicitThisHookCallStillExpandsTheSameWay(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("Listener.java"), """ + package com.example; + abstract class AbstractListener { + void processMessage(String m) { + this.doPMessage(m); + } + abstract void doPMessage(String m); + } + class OrderListener extends AbstractListener { + StateMachine sm = new StateMachine(); + void onMessage(String m) { processMessage(m); } + @Override + void doPMessage(String m) { sm.sendEvent(OrderEvent.PAY); } + } + class StateMachine { void sendEvent(OrderEvent event) {} } + enum OrderEvent { PAY } + """); + + CodebaseContext context = scan(tempDir); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + assertThat(graph.get("com.example.AbstractListener.processMessage")) + .extracting(CallEdge::getTargetMethod) + .containsExactly("com.example.OrderListener.doPMessage"); + } + + private static CodebaseContext scan(Path tempDir) throws Exception { + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + return context; + } + + private static void writeTemplateMethodFixture(Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("Listeners.java"), """ + package com.example; + + abstract class AbstractListener { + /** Template method: hook invoked without an explicit this. receiver. */ + void processMessage(String m) { + doPMessage(m); + } + abstract void doPMessage(String m); + } + + class OrderListener extends AbstractListener { + private final StateMachine sm = new StateMachine(); + void onMessage(String m) { + processMessage(m); + } + @Override + void doPMessage(String m) { + sm.sendEvent(OrderEvent.PAY); + } + } + + class DocumentListener extends AbstractListener { + private final StateMachine sm = new StateMachine(); + void onMessage(String m) { + processMessage(m); + } + @Override + void doPMessage(String m) { + sm.sendEvent(DocumentEvent.SUBMIT); + } + } + + class StateMachine { + void sendEvent(Object event) {} + } + enum OrderEvent { PAY } + enum DocumentEvent { SUBMIT } + """); + } +}