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:
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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("""
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user