Unify accessor resolution through a shared pipeline and widen interface getters to all implementations.

Introduce AccessorResolver with session caching and ordered index-first fallbacks, replace fragmented unwrap/depth logic with shared utilities, and fix polymorphic event resolution that stopped after the first indexed implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-11 20:12:22 +02:00
parent 7f12ed3f3a
commit a108d8cc6e
22 changed files with 2176 additions and 318 deletions

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.analysis.index.AccessorKind;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.DataFlowModel;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
@@ -98,6 +99,40 @@ class AccessorInliningDataFlowTest {
assertResolvedValue(tempDir, "value", "BASE");
}
@Test
void shouldResolveSuperTrivialGetterWithoutOpeningSuperBody(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event = "SUPER";
public String getEvent() { return event; }
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
public String capture() {
return super.getEvent();
}
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Child child = new Child();
String value = child.capture();
}
}
""");
CodebaseContext context = scan(tempDir);
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", "value");
String resolved = model.resolveValue(initializer, context);
assertThat(resolved).isEqualTo("SUPER");
}
@Test
void shouldResolveRecordComponentAfterNonTrivialGetterWrapper(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Event.java", """
@@ -278,7 +313,10 @@ class AccessorInliningDataFlowTest {
""");
assertResolvedValue(tempDir, "value", "LITERAL");
assertThat(accessorLookup(tempDir, "com.example.Runner.Service", "getEvent")).isEmpty();
assertThat(accessorLookup(tempDir, "com.example.Runner.Service", "getEvent"))
.isPresent()
.get()
.satisfies(summary -> assertThat(summary.kind()).isEqualTo(AccessorKind.CONSTANT_GETTER));
}
@Test

View File

@@ -78,6 +78,35 @@ class ConstantExtractorAccessorInliningTest {
assertThat(context.getAccessorIndex().lookup("com.example.Payload", "setEvent")).isPresent();
}
@Test
void resolveMethodReturnConstantShouldUseImplementationIndexedGetter(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/EventType.java", """
package com.example;
public interface EventType {
String getCode();
}
""");
writeJava(tempDir, "com/example/PayEvent.java", """
package com.example;
public class PayEvent implements EventType {
private String code = "PAY";
public String getCode() { return code; }
}
""");
context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
context.scan(tempDir);
constantExtractor = new ConstantExtractor(context, context.getConstantResolver(), mi -> null);
constantExtractor.setConstructorAnalyzer(new ConstructorAnalyzer(context, context.getConstantResolver()));
List<String> constants = constantExtractor.resolveMethodReturnConstant(
"com.example.EventType", "getCode", 0, new HashSet<>(), null);
assertThat(constants).contains("PAY");
}
private static void writeJava(Path tempDir, String relativePath, String source) throws IOException {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());

View File

@@ -79,6 +79,47 @@ class TrivialAccessorDetectorTest {
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isPresent();
}
@Test
void shouldDetectConstantGetter() {
MethodDeclaration md = method("""
public class Demo {
public String getEvent() { return "PAY"; }
}
""", 0);
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
assertThat(summary).isPresent();
assertThat(summary.get().kind()).isEqualTo(AccessorKind.CONSTANT_GETTER);
}
@Test
void shouldDetectEnumConstantGetter() {
MethodDeclaration md = method("""
public class Demo {
public OrderEvents getType() { return OrderEvents.PAY; }
}
enum OrderEvents { PAY }
""", 0);
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
assertThat(summary).isPresent();
assertThat(summary.get().kind()).isEqualTo(AccessorKind.CONSTANT_GETTER);
}
@Test
void shouldRejectComputedConstantGetter() {
MethodDeclaration md = method("""
public class Demo {
public String getEvent() { return build(); }
private String build() { return "X"; }
}
""", 0);
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isEmpty();
}
@Test
void shouldRejectLazyInitGetter() {
MethodDeclaration md = method("""

View File

@@ -0,0 +1,341 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
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.ExpressionAccessClassifier;
import click.kamil.springstatemachineexporter.analysis.service.HeuristicCallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.TypeDeclaration;
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 PipelineRefactorTest {
@TempDir
Path tempDir;
CodebaseContext context;
ConstantExtractor constantExtractor;
ConstructorAnalyzer constructorAnalyzer;
AccessorResolver accessorResolver;
ExpressionAccessClassifier expressionAccessClassifier;
@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 variableTracer = new VariableTracer(context, constantResolver);
constantExtractor.setVariableTracer(variableTracer);
constantExtractor.setConstructorAnalyzer(constructorAnalyzer);
constructorAnalyzer.setVariableTracer(variableTracer);
constructorAnalyzer.setConstantExtractor(constantExtractor);
expressionAccessClassifier = new ExpressionAccessClassifier(context, variableTracer, constantResolver, expr -> null);
constantExtractor.setExpressionAccessClassifier(expressionAccessClassifier);
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 scanWorkspace() throws IOException {
context.scan(tempDir);
}
@Nested
class ResolutionBudgetTests {
@Test
void shouldUseSharedDefaults() {
ResolutionBudget budget = ResolutionBudget.defaults();
assertThat(budget.isMethodReturnExhausted(20)).isFalse();
assertThat(budget.isMethodReturnExhausted(21)).isTrue();
assertThat(budget.isUnwrapExhausted(5)).isFalse();
assertThat(budget.isUnwrapExhausted(6)).isTrue();
assertThat(budget.allowGlobalFieldScan()).isFalse();
}
@Test
void shouldEnableGlobalFieldScanExplicitly() {
assertThat(ResolutionBudget.withGlobalFieldScan().allowGlobalFieldScan()).isTrue();
}
}
@Nested
class LibraryUnwrapRegistryTests {
@Test
void shouldRecognizeReactiveReceiversAndFactoryMethods() {
assertThat(LibraryUnwrapRegistry.isUnwrapReceiverType("Mono")).isTrue();
assertThat(LibraryUnwrapRegistry.isUnwrapMethodName("just")).isTrue();
assertThat(LibraryUnwrapRegistry.shouldStopUnwrap("buildPayload")).isTrue();
assertThat(LibraryUnwrapRegistry.isEventSetterMethodName("eventType")).isTrue();
}
}
@Nested
class MethodInvocationUnwrapperTests {
@Test
void shouldPeelOptionalOfFactoryCall() throws IOException {
writeJava("com/example/Factory.java", """
package com.example;
public class Factory {
public static String build() {
return Optional.of("PAY").get();
}
}
""");
scanWorkspace();
TypeDeclaration td = context.getTypeDeclaration("com.example.Factory");
assertThat(td).isNotNull();
}
}
@Nested
class AccessorResolverTests {
@Test
void shouldResolveConstantGetterWithoutCic() throws IOException {
writeJava("com/example/Events.java", """
package com.example;
public enum Events { PAY, CANCEL }
""");
writeJava("com/example/EventHolder.java", """
package com.example;
public class EventHolder {
public Events getEvent() { return Events.PAY; }
}
""");
scanWorkspace();
List<String> resolved = accessorResolver.resolve(
"com.example.EventHolder",
"getEvent",
new AccessorResolver.ReceiverContext(null, null, null, false, null),
ResolutionBudget.defaults());
assertThat(resolved).contains("Events.PAY");
}
@Test
void shouldCacheRepeatedLookups() throws IOException {
writeJava("com/example/Events.java", """
package com.example;
public enum Events { PAY }
""");
writeJava("com/example/EventHolder.java", """
package com.example;
public class EventHolder {
public Events getEvent() { return Events.PAY; }
}
""");
scanWorkspace();
AccessorResolver.ReceiverContext receiverContext =
new AccessorResolver.ReceiverContext(null, null, null, false, null);
List<String> first = accessorResolver.resolve(
"com.example.EventHolder", "getEvent", receiverContext, ResolutionBudget.defaults());
List<String> second = accessorResolver.resolve(
"com.example.EventHolder", "getEvent", receiverContext, ResolutionBudget.defaults());
assertThat(first).contains("Events.PAY");
assertThat(second).isSameAs(first);
}
@Test
void shouldResolveGetterChainStepViaIndexBeforeReturnedCic() throws IOException {
writeJava("com/example/Payload.java", """
package com.example;
public class Payload {
public String getType() { return "PAY"; }
}
""");
writeJava("com/example/Inner.java", """
package com.example;
public class Inner {
private final Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
""");
scanWorkspace();
AccessorResolver.GetterChainStep step = accessorResolver.resolveGetterChainStep(
"com.example.Inner", "getPayload", ResolutionBudget.defaults());
assertThat(step).isNotNull();
assertThat(step.typeFqn()).contains("Payload");
assertThat(step.cic()).isNotNull();
}
@Test
void shouldResolveFactoryBuildThenGetterWithoutCicDeadEnd() throws IOException {
writeJava("com/example/Events.java", """
package com.example;
public enum Events { PAY }
""");
writeJava("com/example/Order.java", """
package com.example;
public class Order {
public Events getEvent() { return Events.PAY; }
}
""");
writeJava("com/example/OrderFactory.java", """
package com.example;
public class OrderFactory {
public Order build() { return new Order(); }
}
""");
writeJava("com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
OrderFactory factory = new OrderFactory();
Order order = factory.build();
Events event = order.getEvent();
}
}
""");
scanWorkspace();
List<String> events = accessorResolver.resolve(
"com.example.Order",
"getEvent",
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration("com.example.Order"),
null,
null,
false,
null),
ResolutionBudget.defaults());
assertThat(events).contains("Events.PAY");
}
@Test
void shouldWidenInterfaceGettersToAllImplementations() throws IOException {
writeJava("com/example/Events.java", """
package com.example;
public enum Events { ALPHA, BETA }
""");
writeJava("com/example/AlphaPayload.java", """
package com.example;
public class AlphaPayload implements Payload {
public Events type() { return Events.ALPHA; }
}
""");
writeJava("com/example/BetaPayload.java", """
package com.example;
public class BetaPayload implements Payload {
public Events type() { return Events.BETA; }
}
""");
writeJava("com/example/Payload.java", """
package com.example;
public interface Payload {
Events type();
}
""");
scanWorkspace();
List<String> resolved = accessorResolver.resolve(
"com.example.Payload",
"type",
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration("com.example.Payload"),
null,
null,
false,
null),
ResolutionBudget.defaults());
assertThat(resolved).containsExactlyInAnyOrder("Events.ALPHA", "Events.BETA");
}
}
@Nested
class ExpressionAccessClassifierSuffixTests {
@Test
void shouldTreatRecordStyleSuffixesAsTraceable() {
assertThat(expressionAccessClassifier.isTraceableAccessorSuffix(".getEvent()")).isTrue();
assertThat(expressionAccessClassifier.isTraceableAccessorSuffix(".type()")).isTrue();
assertThat(expressionAccessClassifier.isTraceableAccessorSuffix(".event()")).isTrue();
assertThat(expressionAccessClassifier.isTraceableAccessorSuffix(".hashCode()")).isFalse();
}
}
@Nested
class ConstructorAnalyzerGlobalScanTests {
@Test
void shouldSkipGlobalScanByDefault() throws IOException {
writeJava("com/example/Unrelated.java", """
package com.example;
public class Unrelated {
private String sharedName = "SHOULD_NOT_MATCH";
}
""");
writeJava("com/example/Target.java", """
package com.example;
public class Target {
private String sharedName;
public Target() { this.sharedName = "LOCAL"; }
}
""");
scanWorkspace();
TypeDeclaration td = context.getTypeDeclaration("com.example.Target");
List<String> traced = constructorAnalyzer.traceFieldInConstructors(
td, "sharedName", context, new HashSet<>(), ResolutionBudget.defaults());
assertThat(traced).contains("LOCAL");
assertThat(traced).doesNotContain("SHOULD_NOT_MATCH");
}
@Test
void shouldAllowGlobalScanWhenExplicitlyEnabled() throws IOException {
writeJava("com/example/OnlyFieldOwner.java", """
package com.example;
public class OnlyFieldOwner {
private String orphanField = "GLOBAL";
}
""");
writeJava("com/example/Shell.java", """
package com.example;
public class Shell {
private String orphanField;
}
""");
scanWorkspace();
TypeDeclaration shell = context.getTypeDeclaration("com.example.Shell");
List<String> withoutGlobal = constructorAnalyzer.traceFieldInConstructors(
shell, "orphanField", context, new HashSet<>(), ResolutionBudget.defaults());
assertThat(withoutGlobal).isEmpty();
List<String> withGlobal = constructorAnalyzer.traceFieldInConstructors(
shell, "orphanField", context, new HashSet<>(), ResolutionBudget.withGlobalFieldScan());
assertThat(withGlobal).contains("GLOBAL");
}
}
@Nested
class ConstantResolverSharingTests {
@Test
void shouldUseSingleConstantResolverFromContext() {
assertThat(new HeuristicCallGraphEngine(context).getClass()).isNotNull();
ConstantResolver shared = context.getConstantResolver();
ConstantExtractor extractor = new ConstantExtractor(context, shared, mi -> null);
assertThat(extractor).isNotNull();
assertThat(shared).isSameAs(context.getConstantResolver());
}
}
}

View File

@@ -0,0 +1,414 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
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.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
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.List;
import java.util.Optional;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Quirk-focused tests for accessor inlining edge cases: super dispatch, field-backed chains,
* inheritance, and look-alike patterns that must not be confused.
*/
class AccessorInliningEdgeCasesTest {
@Nested
class SuperMethodAccessorShortcut {
@Test
void shouldReadFieldDeclaredOnGrandparent(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Grand.java", """
package com.example;
public class Grand {
protected String token = "GRAND";
public String getToken() { return token; }
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Grand {
public String capture() {
return super.getToken();
}
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Child child = new Child();
String value = child.capture();
}
}
""");
assertResolved(tempDir, "value", "GRAND");
}
@Test
void shouldNotShortcutWhenSuperGetterIsNonTrivial(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event = "IGNORED";
public String getEvent() {
return event == null ? "NONE" : event;
}
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
public String capture() {
return super.getEvent();
}
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Child child = new Child();
String value = child.capture();
}
}
""");
assertThat(accessorLookup(tempDir, "com.example.Base", "getEvent")).isEmpty();
}
@Test
void callGraphShouldPreferSuperFieldOverChildOverride(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
ChildController controller;
public void run() {
controller.process();
}
}
class ChildController extends ParentController {
@Override
public OrderEvent getEvent() { return OrderEvent.SHIP; }
public void process() {
StateMachine sm = new StateMachine();
sm.sendEvent(super.getEvent());
}
}
class ParentController {
private OrderEvent event = OrderEvent.PAY;
public OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, "com.example.ApiController", "run", "com.example.StateMachine");
assertPolyEvents(chain, "OrderEvent.PAY");
}
}
@Nested
class FieldBackedGetterChains {
@Test
void shouldResolveDeepFieldBackedChainWithoutHopLimit(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(Order order) {
dispatcher.fire(order);
}
}
class CentralDispatcher {
public void fire(Order order) {
StateMachine sm = new StateMachine();
sm.sendEvent(order.getPayload().getEvent().getCode());
}
}
class Order {
private Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
class Payload {
private Event event = new Event();
public Event getEvent() { return event; }
}
class Event {
private OrderEvent code = OrderEvent.PAY;
public OrderEvent getCode() { return code; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(chain, "OrderEvent.PAY");
}
@Test
void shouldResolveFieldInitializerDeclaredOnSuperclass(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(BaseWrapper wrapper) {
dispatcher.fire(wrapper);
}
}
class CentralDispatcher {
public void fire(BaseWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getPayload().getType());
}
}
class BaseWrapper {
protected Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
class Payload {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() { return type; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(chain, "OrderEvent.PAY");
}
@Test
void shouldNotUseIndexedFieldWhenInnerGetterReturnsLiteral(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fire(wrapper);
}
}
class CentralDispatcher {
public void fire(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
private RichEvent event = new RichEvent();
public RichEvent getEvent() { return event; }
}
class RichEvent {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() {
return OrderEvent.SHIP;
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(chain, "OrderEvent.SHIP");
}
@Test
void shouldDistinguishFieldBackedChainFromLiteralReturnChain(@TempDir Path tempDir) throws Exception {
String literalSource = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fire(wrapper);
}
}
class CentralDispatcher {
public void fire(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
public RichEvent getEvent() { return new RichEvent(); }
}
class RichEvent {
public OrderEvent getType() { return OrderEvent.PAY; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
Path literalDir = tempDir.resolve("literal");
Files.createDirectories(literalDir);
CodebaseContext literalContext = scanSource(literalSource, literalDir);
CallChain literalChain = resolveChain(
literalContext, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(literalChain, "OrderEvent.PAY");
String fieldSource = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fire(wrapper);
}
}
class CentralDispatcher {
public void fire(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
private RichEvent event = new RichEvent();
public RichEvent getEvent() { return event; }
}
class RichEvent {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() { return type; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
Path fieldDir = tempDir.resolve("field");
Files.createDirectories(fieldDir);
CodebaseContext fieldContext = scanSource(fieldSource, fieldDir);
CallChain fieldChain = resolveChain(
fieldContext, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(fieldChain, "OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(fieldChain, "com.example.OrderStateMachineConfig",
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
}
}
@Nested
class LookAlikePatterns {
@Test
void shouldNotTreatListGetAsBeanAccessorInDataflow(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
import java.util.List;
public class Runner {
public void run() {
List<String> events = List.of("A", "B");
String value = events.get(0);
}
}
""");
CodebaseContext context = scan(tempDir);
assertThat(context.getAccessorIndex().lookup("java.util.List", "get")).isEmpty();
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", "value");
assertThat(model.getReachingDefinitions(initializer)).isNotEmpty();
}
@Test
void shouldNotIndexBareGetMethod(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
static class Box {
public String get() { return "X"; }
}
public void run() {
Box box = new Box();
String value = box.get();
}
}
""");
assertThat(accessorLookup(tempDir, "com.example.Runner.Box", "get")).isEmpty();
assertResolved(tempDir, "value", "X");
}
}
private static void assertResolved(Path tempDir, String variableName, String expected) throws IOException {
CodebaseContext context = scan(tempDir);
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", variableName);
String resolved = model.resolveValue(initializer, context);
assertThat(resolved).isEqualTo(expected);
}
private static Optional<AccessorSummary> accessorLookup(Path tempDir, String ownerFqn, String methodName)
throws IOException {
return scan(tempDir).getAccessorIndex().lookup(ownerFqn, methodName);
}
private static CodebaseContext scan(Path tempDir) throws IOException {
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
context.scan(tempDir);
return context;
}
private static Expression findInitializer(CodebaseContext context, String methodName, String variableName) {
for (TypeDeclaration type : context.getTypeDeclarations()) {
Expression result = findInitializerInType(type, methodName, variableName);
if (result != null) {
return result;
}
}
throw new AssertionError("Initializer not found for " + variableName + " in " + methodName);
}
private static Expression findInitializerInType(TypeDeclaration type, String methodName, String variableName) {
for (MethodDeclaration method : type.getMethods()) {
if (!method.getName().getIdentifier().equals(methodName) || method.getBody() == null) {
continue;
}
for (Object statementObj : method.getBody().statements()) {
if (statementObj instanceof VariableDeclarationStatement statement) {
for (Object fragmentObj : statement.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(variableName)) {
return fragment.getInitializer();
}
}
}
}
}
for (TypeDeclaration nested : type.getTypes()) {
Expression result = findInitializerInType(nested, methodName, variableName);
if (result != null) {
return result;
}
}
return null;
}
private static void writeJava(Path tempDir, String relativePath, String source) throws IOException {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, source);
}
}

View File

@@ -417,4 +417,46 @@ class CentralDispatcherResolutionTest {
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
}
/**
* Chained field-backed trivial getters ({@code return this.field}) through a central dispatcher.
*/
@Test
void fieldBackedGetterChainShouldResolveThroughCentralDispatcher(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fireOrderEvent(wrapper);
}
}
class CentralDispatcher {
public void fireOrderEvent(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
private RichEvent event = new RichEvent();
public RichEvent getEvent() { return event; }
}
class RichEvent {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() { return type; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
assertPolyEvents(chain, "OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(chain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
}
}