A
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
""");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user