This commit is contained in:
2026-07-16 22:13:21 +02:00
parent 2fc5ad53d5
commit 5300fdb881
2 changed files with 229 additions and 4 deletions

View File

@@ -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<String> 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.

View File

@@ -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<String, List<CallEdge>> graph = engine.buildCallGraph();
List<CallEdge> 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<CallEdge> 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<CallChain> 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<String> events = chain.getTriggerPoint().getPolymorphicEvents();
return events == null ? java.util.stream.Stream.<String>empty() : events.stream();
}))
.anyMatch(e -> e.contains("PAY") || e.endsWith(".PAY"));
List<CallChain> 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<String> events = chain.getTriggerPoint().getPolymorphicEvents();
return events == null ? java.util.stream.Stream.<String>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<String, List<CallEdge>> 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 }
""");
}
}