Fix CIC accessor resolution, concrete overrides, and reactive flatMap tracing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 07:06:55 +02:00
parent c2eff6d7bb
commit cc35546d01
12 changed files with 1176 additions and 38 deletions

View File

@@ -0,0 +1,607 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
import click.kamil.springstatemachineexporter.analysis.service.HeuristicCallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.DataFlowModel;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class InheritanceAndNestedResolutionTest {
@TempDir
Path tempDir;
CodebaseContext context;
ConstantExtractor constantExtractor;
ConstructorAnalyzer constructorAnalyzer;
AccessorResolver accessorResolver;
VariableTracer variableTracer;
@BeforeEach
void setUp() throws IOException {
context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
ConstantResolver constantResolver = context.getConstantResolver();
constantExtractor = new ConstantExtractor(context, constantResolver, mi -> null);
constructorAnalyzer = new ConstructorAnalyzer(context, constantResolver);
variableTracer = new VariableTracer(context, constantResolver);
constantExtractor.setVariableTracer(variableTracer);
constantExtractor.setConstructorAnalyzer(constructorAnalyzer);
constructorAnalyzer.setVariableTracer(variableTracer);
constructorAnalyzer.setConstantExtractor(constantExtractor);
accessorResolver = new AccessorResolver(context, constantExtractor, constructorAnalyzer, constantResolver);
constantExtractor.setAccessorResolver(accessorResolver);
}
private void writeJava(String relativePath, String source) throws IOException {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, source);
}
private void scan() throws IOException {
context.scan(tempDir);
}
@Nested
class InterfaceDefaultMethod {
@Test
void accessorResolverShouldIncludeDefaultMethodAndAllImpls() throws IOException {
writeJava("com/example/Code.java", """
package com.example;
public enum Code { DEFAULT, PAY, SHIP }
""");
writeJava("com/example/EventSource.java", """
package com.example;
public interface EventSource {
default Code getCode() { return Code.DEFAULT; }
}
""");
writeJava("com/example/PayEvent.java", """
package com.example;
public class PayEvent implements EventSource {
public Code getCode() { return Code.PAY; }
}
""");
writeJava("com/example/ShipEvent.java", """
package com.example;
public class ShipEvent implements EventSource {
public Code getCode() { return Code.SHIP; }
}
""");
scan();
List<String> resolved = accessorResolver.resolve(
"com.example.EventSource",
"getCode",
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration("com.example.EventSource"),
null, null, false, null),
ResolutionBudget.defaults());
assertThat(resolved).containsExactlyInAnyOrder("Code.DEFAULT", "Code.PAY", "Code.SHIP");
}
@Test
void accessorResolverShouldMergeDefaultMethodWithConcreteOverrides() throws IOException {
writeJava("com/example/EventSource.java", """
package com.example;
public interface EventSource {
default String getCode() { return "DEFAULT"; }
}
""");
writeJava("com/example/PayEvent.java", """
package com.example;
public class PayEvent implements EventSource {
public String getCode() { return "PAY"; }
}
""");
writeJava("com/example/ShipEvent.java", """
package com.example;
public class ShipEvent implements EventSource {
public String getCode() { return "SHIP"; }
}
""");
scan();
List<String> resolved = accessorResolver.resolve(
"com.example.EventSource",
"getCode",
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration("com.example.EventSource"),
null, null, false, null),
ResolutionBudget.defaults());
assertThat(resolved).containsExactlyInAnyOrder("DEFAULT", "PAY", "SHIP");
}
@Test
void accessorResolverShouldPreferConcreteCicOverInterfaceDefault() throws IOException {
writeJava("com/example/Code.java", """
package com.example;
public enum Code { DEFAULT, PAY, SHIP }
""");
writeJava("com/example/EventSource.java", """
package com.example;
public interface EventSource {
default Code getCode() { return Code.DEFAULT; }
}
""");
writeJava("com/example/PayEvent.java", """
package com.example;
public class PayEvent implements EventSource {
public Code getCode() { return Code.PAY; }
}
""");
writeJava("com/example/Runner.java", """
package com.example;
public class Runner {
void run() {
EventSource src = new PayEvent();
}
}
""");
scan();
org.eclipse.jdt.core.dom.TypeDeclaration runner = context.getTypeDeclaration("com.example.Runner");
org.eclipse.jdt.core.dom.CompilationUnit cu = (org.eclipse.jdt.core.dom.CompilationUnit) runner.getRoot();
final org.eclipse.jdt.core.dom.ClassInstanceCreation[] payEventCic =
new org.eclipse.jdt.core.dom.ClassInstanceCreation[1];
cu.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(org.eclipse.jdt.core.dom.ClassInstanceCreation node) {
if ("PayEvent".equals(node.getType().toString())) {
payEventCic[0] = node;
}
return super.visit(node);
}
});
List<String> resolved = accessorResolver.resolve(
"com.example.EventSource",
"getCode",
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration("com.example.EventSource"),
payEventCic[0],
cu,
false,
null),
ResolutionBudget.defaults());
assertThat(resolved).containsExactly("Code.PAY");
}
}
@Nested
class NonIndexedConcreteOverride {
@Test
void dataFlowShouldResolveNonTrivialConcreteOverride() throws IOException {
writeJava("com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Payload payload = new CustomPayload();
String event = payload.getEvent();
}
}
interface Payload {
String getEvent();
}
class CustomPayload implements Payload {
public String getEvent() {
return sideEffect();
}
private String sideEffect() { return "CUSTOM"; }
}
""");
scan();
TypeDeclaration runner = context.getTypeDeclaration("com.example.Runner");
MethodDeclaration run = context.findMethodDeclaration(runner, "run", true);
final MethodInvocation[] getterCall = new MethodInvocation[1];
run.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getInitializer() instanceof MethodInvocation mi) {
getterCall[0] = mi;
}
return super.visit(node);
}
});
DataFlowModel model = new JdtDataFlowModel(context);
String resolved = model.resolveValue(getterCall[0], context);
assertThat(resolved).isEqualTo("CUSTOM");
}
}
@Nested
class GetterChainThroughInterface {
@Test
void callGraphShouldResolveDeepChainWithInterfaceMiddleHop() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
Outer outer;
public void pay() {
dispatcher.fire(outer.getInner().getPayload().getEvent());
}
}
class CentralDispatcher {
public void fire(OrderEvent event) {}
}
interface Inner {
Payload getPayload();
}
class InnerImpl implements Inner {
private Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
class Outer {
public Inner getInner() { return new InnerImpl(); }
}
class Payload {
private OrderEvent event = OrderEvent.PAY;
public OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("pay")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.CentralDispatcher")
.methodName("fire")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvent.PAY");
}
}
@Nested
class ConcreteSubtypeTrigger {
@Test
void callGraphShouldPreferConcreteSubtypeFieldOverAbstractDefault() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
OrderService service;
public void ship() {
service.fire(new ShipEvent());
}
}
class OrderService {
StateMachine sm;
public void fire(BaseEvent event) {
sm.sendEvent(event.getCode());
}
}
enum Code { PAY, SHIP }
abstract class BaseEvent {
protected Code code = Code.PAY;
public Code getCode() { return code; }
}
class ShipEvent extends BaseEvent {
public ShipEvent() { this.code = Code.SHIP; }
}
class StateMachine { void sendEvent(Code event) {} }
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("ship")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("Code.SHIP");
}
}
@Nested
class ConcreteSubtypeTriggerViaFire {
@Test
void callGraphShouldResolveSubtypeWhenPassedToBaseTypedFire() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
OrderService service;
public void ship() {
service.fire(new ShipEvent());
}
}
class OrderService {
public void fire(BaseEvent event) {}
}
enum Code { PAY, SHIP }
abstract class BaseEvent {
protected Code code = Code.PAY;
public Code getCode() { return code; }
}
class ShipEvent extends BaseEvent {
public ShipEvent() { this.code = Code.SHIP; }
}
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("ship")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.OrderService")
.methodName("fire")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getMethodChain())
.containsExactly(
"com.example.ApiController.ship",
"com.example.OrderService.fire");
assertThat(chains.get(0).getTriggerPoint().getEvent())
.contains("ShipEvent");
}
}
@Nested
class UnresolvedTypeWidening {
@Test
void constantExtractorShouldWidenWhenTypeDeclarationMissing() throws IOException {
writeJava("com/example/Code.java", """
package com.example;
public enum Code { A, B }
""");
writeJava("com/example/EventA.java", """
package com.example;
public class EventA implements RichEvent {
public Code getCode() { return Code.A; }
}
""");
writeJava("com/example/EventB.java", """
package com.example;
public class EventB implements RichEvent {
public Code getCode() { return Code.B; }
}
""");
writeJava("com/example/RichEvent.java", """
package com.example;
public interface RichEvent {
Code getCode();
}
""");
scan();
List<String> resolved = constantExtractor.resolveMethodReturnConstant(
"RichEvent", "getCode", 0, new HashSet<>(), null);
assertThat(resolved).containsExactlyInAnyOrder("Code.A", "Code.B");
}
}
@Nested
class DeterministicImplementations {
@Test
void getImplementationsShouldReturnSortedStableOrder() throws IOException {
writeJava("com/example/EventSource.java", """
package com.example;
public interface EventSource {
String getCode();
}
""");
writeJava("com/example/ZebraEvent.java", """
package com.example;
public class ZebraEvent implements EventSource {
public String getCode() { return "Z"; }
}
""");
writeJava("com/example/AlphaEvent.java", """
package com.example;
public class AlphaEvent implements EventSource {
public String getCode() { return "A"; }
}
""");
scan();
List<String> first = context.getImplementations("com.example.EventSource");
List<String> second = context.getImplementations("com.example.EventSource");
assertThat(first).containsExactly(
"com.example.AlphaEvent",
"com.example.ZebraEvent");
assertThat(second).isEqualTo(first);
}
}
@Nested
class ReactiveFlatMapLambdaSendEvent {
@Test
void callGraphShouldResolveGetterInsideFlatMapLambda() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
StateMachine sm;
Payload payload;
public void handle() {
Mono.just(payload).flatMap(p -> sm.sendEvent(p.getEvent()));
}
}
class Payload {
OrderEvent event = OrderEvent.PAY;
OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono flatMap(Object fn) { return this; }
}
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("handle")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderEvent.PAY");
}
@Test
void callGraphShouldResolveGetterInsideFlatMapBlockBody() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
StateMachine sm;
Payload payload;
public void handle() {
Mono.just(payload).flatMap(p -> {
sm.sendEvent(p.getEvent());
return Mono.empty();
});
}
}
class Payload {
OrderEvent event = OrderEvent.PAY;
OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono flatMap(Object fn) { return this; }
static Mono empty() { return new Mono(); }
}
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("handle")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderEvent.PAY");
}
@Test
void callGraphShouldResolveGetterThroughNestedFlatMap() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
StateMachine sm;
Payload payload;
public void handle() {
Mono.just(payload)
.flatMap(x -> Mono.just(x.getInner()))
.flatMap(p -> sm.sendEvent(p.getEvent()));
}
}
class Payload {
Inner inner = new Inner();
Inner getInner() { return inner; }
}
class Inner {
OrderEvent event = OrderEvent.SHIP;
OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono flatMap(Object fn) { return this; }
}
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("handle")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderEvent.SHIP");
}
}
}

View File

@@ -0,0 +1,99 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class ReactiveExpressionSupportTest {
@Test
void shouldMapLambdaParameterToOuterJustPayload(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class Runner {
Payload payload;
void run() {
Mono.just(payload).flatMap(p -> Mono.just(p.getEvent()));
}
}
class Payload { String getEvent() { return "X"; } }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono flatMap(Object fn) { return this; }
}
""";
Path file = tempDir.resolve("Runner.java");
Files.writeString(file, source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
org.eclipse.jdt.core.dom.TypeDeclaration runner = context.getTypeDeclaration("com.example.Runner");
CompilationUnit cu = (CompilationUnit) runner.getRoot();
final MethodInvocation[] flatMapCall = new MethodInvocation[1];
cu.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if ("flatMap".equals(node.getName().getIdentifier()) && flatMapCall[0] == null) {
flatMapCall[0] = node;
}
return super.visit(node);
}
});
String payload = ReactiveExpressionSupport.extractPayload(
flatMapCall[0], context.getConstantResolver(), context);
assertThat(payload).isEqualTo("payload.getEvent()");
}
@Test
void shouldRemapLambdaParameterGetter(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class Runner {
Payload payload;
StateMachine sm;
void run() {
Mono.just(payload).flatMap(p -> sm.sendEvent(p.getEvent()));
}
}
class Payload { String getEvent() { return "X"; } }
class StateMachine { void sendEvent(String e) {} }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono flatMap(Object fn) { return this; }
}
""";
Path file = tempDir.resolve("Runner.java");
Files.writeString(file, source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
org.eclipse.jdt.core.dom.TypeDeclaration runner = context.getTypeDeclaration("com.example.Runner");
CompilationUnit cu = (CompilationUnit) runner.getRoot();
final MethodInvocation[] sendEventCall = new MethodInvocation[1];
cu.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if ("sendEvent".equals(node.getName().getIdentifier()) && sendEventCall[0] == null) {
sendEventCall[0] = node;
}
return super.visit(node);
}
});
Expression eventExpr = (Expression) sendEventCall[0].arguments().get(0);
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetter(
eventExpr, context.getConstantResolver(), context);
assertThat(remapped).isEqualTo("payload.getEvent()");
}
}

View File

@@ -0,0 +1,33 @@
package click.kamil.springstatemachineexporter.analysis.service;
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 static org.assertj.core.api.Assertions.assertThat;
class TypeResolverTest {
@Test
void shouldMapGenericEventPlaceholderToSingleParameterIndex(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("Machine.java"), """
package com.example;
class StateMachine {
void sendEvent(OrderEvent event) {}
}
enum OrderEvent { PAY }
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeResolver resolver = new TypeResolver(context);
assertThat(resolver.getParameterIndex("com.example.StateMachine.sendEvent", "event")).isZero();
assertThat(resolver.getParameterIndex("com.example.StateMachine.sendEvent", "e", true)).isZero();
assertThat(resolver.getParameterIndex("com.example.StateMachine.sendEvent", "e")).isEqualTo(-1);
assertThat(resolver.getParameterIndex("com.example.StateMachine.sendEvent", "payload")).isEqualTo(-1);
}
}