A
This commit is contained in:
@@ -2701,18 +2701,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
String baseCalled = resolveCalledMethod(node);
|
String baseCalled = resolveCalledMethod(node);
|
||||||
if (baseCalled == null) return Collections.emptyList();
|
if (baseCalled == null) return Collections.emptyList();
|
||||||
|
|
||||||
if (node.getExpression() == null) {
|
if (!baseCalled.contains(".")) {
|
||||||
return Collections.singletonList(baseCalled);
|
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);
|
return Collections.singletonList(baseCalled);
|
||||||
}
|
}
|
||||||
|
|
||||||
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
|
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
|
||||||
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1);
|
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1);
|
||||||
TypeDeclaration td = context.getTypeDeclaration(className);
|
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||||
boolean isImplicitThis = node.getExpression() instanceof ThisExpression;
|
|
||||||
String cacheKey = baseCalled + "#" + isImplicitThis;
|
String cacheKey = baseCalled + "#" + isImplicitThis;
|
||||||
List<String> cached = polymorphicCallCache.get(cacheKey);
|
List<String> cached = polymorphicCallCache.get(cacheKey);
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
@@ -2727,7 +2731,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
shouldExpand = false;
|
shouldExpand = false;
|
||||||
} else if (td == null) {
|
} else if (td == null) {
|
||||||
shouldExpand = false;
|
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;
|
shouldExpand = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2757,6 +2767,32 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
return frozen;
|
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.
|
* 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.
|
* {@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