Index trivial accessors and inline them in dataflow, constant extraction, and call graph resolution.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package click.kamil.springstatemachineexporter;
|
||||
|
||||
import org.junit.jupiter.api.parallel.Execution;
|
||||
import org.junit.jupiter.api.parallel.ExecutionMode;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
|
||||
import click.kamil.springstatemachineexporter.exporter.Dot;
|
||||
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
|
||||
@@ -21,8 +23,12 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
public class RegressionTest {
|
||||
|
||||
private final List<StateMachineExporter> exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
|
||||
private final ExportService exportService = new ExportService(exporters);
|
||||
private static final List<StateMachineExporter> EXPORTERS =
|
||||
List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
|
||||
|
||||
private ExportService exportService() {
|
||||
return new ExportService(EXPORTERS);
|
||||
}
|
||||
|
||||
private static Path findProjectRoot() {
|
||||
Path current = Path.of(".").toAbsolutePath();
|
||||
@@ -155,9 +161,10 @@ public class RegressionTest {
|
||||
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("provideTestScenarios")
|
||||
@Execution(ExecutionMode.CONCURRENT)
|
||||
void testOutputMatchesGolden(TestScenario scenario, @TempDir Path tempDir) throws Exception {
|
||||
System.out.println("Running test for " + scenario.name());
|
||||
if (exportService == null) System.out.println("exportService is NULL");
|
||||
ExportService exportService = exportService();
|
||||
if (scenario.inputPath() == null) System.out.println("inputPath is NULL");
|
||||
if (tempDir == null) System.out.println("tempDir is NULL");
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
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.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AccessorIndexBuilderTest {
|
||||
|
||||
@Nested
|
||||
class ClassAccessors {
|
||||
|
||||
@Test
|
||||
void shouldIndexTrivialGetterAndSetter(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Order.java", """
|
||||
package com.example;
|
||||
public class Order {
|
||||
private String event;
|
||||
public String getEvent() { return event; }
|
||||
public void setEvent(String event) { this.event = event; }
|
||||
}
|
||||
""");
|
||||
|
||||
AccessorIndex index = scan(tempDir);
|
||||
|
||||
assertThat(index.lookup("com.example.Order", "getEvent")).isPresent();
|
||||
assertThat(index.lookup("com.example.Order", "setEvent")).isPresent();
|
||||
assertThat(index.accessorsForType("com.example.Order")).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIndexNestedClassAccessors(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Outer.java", """
|
||||
package com.example;
|
||||
public class Outer {
|
||||
static class Inner {
|
||||
private String code;
|
||||
public String getCode() { return code; }
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
AccessorIndex index = scan(tempDir);
|
||||
|
||||
assertThat(index.lookup("com.example.Outer.Inner", "getCode")).isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Inheritance {
|
||||
|
||||
@Test
|
||||
void shouldInheritParentTrivialGetter(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Base.java", """
|
||||
package com.example;
|
||||
public class Base {
|
||||
protected String event;
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Child.java", """
|
||||
package com.example;
|
||||
public class Child extends Base {
|
||||
public void run() {}
|
||||
}
|
||||
""");
|
||||
|
||||
AccessorIndex index = scan(tempDir);
|
||||
|
||||
assertThat(index.lookup("com.example.Child", "getEvent")).isPresent();
|
||||
assertThat(index.lookup("com.example.Child", "getEvent").orElseThrow().ownerFqn())
|
||||
.isEqualTo("com.example.Child");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotInheritWhenChildOverridesWithLogic(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Base.java", """
|
||||
package com.example;
|
||||
public class Base {
|
||||
protected String event;
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Child.java", """
|
||||
package com.example;
|
||||
public class Child extends Base {
|
||||
@Override
|
||||
public String getEvent() {
|
||||
return event == null ? "NONE" : event;
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
AccessorIndex index = scan(tempDir);
|
||||
|
||||
assertThat(index.lookup("com.example.Base", "getEvent")).isPresent();
|
||||
assertThat(index.lookup("com.example.Child", "getEvent")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreferChildTrivialOverride(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Base.java", """
|
||||
package com.example;
|
||||
public class Base {
|
||||
protected String event;
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Child.java", """
|
||||
package com.example;
|
||||
public class Child extends Base {
|
||||
@Override
|
||||
public String getEvent() { return this.event; }
|
||||
}
|
||||
""");
|
||||
|
||||
AccessorIndex index = scan(tempDir);
|
||||
|
||||
Optional<AccessorSummary> child = index.lookup("com.example.Child", "getEvent");
|
||||
assertThat(child).isPresent();
|
||||
assertThat(child.orElseThrow().ownerFqn()).isEqualTo("com.example.Child");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveFieldDeclaredInSuperclass(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Base.java", """
|
||||
package com.example;
|
||||
public class Base {
|
||||
protected String event;
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Child.java", """
|
||||
package com.example;
|
||||
public class Child extends Base {
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
""");
|
||||
|
||||
AccessorIndex index = scan(tempDir);
|
||||
|
||||
AccessorSummary summary = index.lookup("com.example.Child", "getEvent").orElseThrow();
|
||||
assertThat(summary.declaringFqn()).isEqualTo("com.example.Base");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Records {
|
||||
|
||||
@Test
|
||||
void shouldIndexRecordComponents(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Event.java", """
|
||||
package com.example;
|
||||
public record Event(String code, int version) {}
|
||||
""");
|
||||
|
||||
AccessorIndex index = scan(tempDir);
|
||||
|
||||
assertThat(index.lookup("com.example.Event", "code")).isPresent();
|
||||
assertThat(index.lookup("com.example.Event", "code").orElseThrow().kind())
|
||||
.isEqualTo(AccessorKind.RECORD_COMPONENT);
|
||||
assertThat(index.lookup("com.example.Event", "version")).isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Interfaces {
|
||||
|
||||
@Test
|
||||
void shouldIndexDefaultInterfaceAccessor(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/HasEvent.java", """
|
||||
package com.example;
|
||||
public interface HasEvent {
|
||||
default String getEvent() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
AccessorIndex index = scan(tempDir);
|
||||
|
||||
// Default method body is not trivial (returns null literal, not field read)
|
||||
assertThat(index.lookup("com.example.HasEvent", "getEvent")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldIndexTrivialDefaultInterfaceAccessor(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Holder.java", """
|
||||
package com.example;
|
||||
public interface Holder {
|
||||
String getEvent();
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Impl.java", """
|
||||
package com.example;
|
||||
public class Impl implements Holder {
|
||||
private String event;
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
""");
|
||||
|
||||
AccessorIndex index = scan(tempDir);
|
||||
|
||||
assertThat(index.lookup("com.example.Impl", "getEvent")).isPresent();
|
||||
assertThat(index.lookup("com.example.Holder", "getEvent")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Enums {
|
||||
|
||||
@Test
|
||||
void shouldIndexEnumFieldGetter(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Status.java", """
|
||||
package com.example;
|
||||
public enum Status {
|
||||
OK;
|
||||
private final String code = "ok";
|
||||
public String getCode() { return code; }
|
||||
}
|
||||
""");
|
||||
|
||||
AccessorIndex index = scan(tempDir);
|
||||
|
||||
assertThat(index.lookup("com.example.Status", "getCode")).isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBuildViaCodebaseContext(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Demo.java", """
|
||||
package com.example;
|
||||
public class Demo {
|
||||
private String type;
|
||||
public String getType() { return type; }
|
||||
}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(context.getAccessorIndex().lookup("com.example.Demo", "getType")).isPresent();
|
||||
assertThat(context.getAccessorIndex().trivialCount()).isGreaterThan(0);
|
||||
}
|
||||
|
||||
private static AccessorIndex scan(Path tempDir) throws IOException {
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
return context.getAccessorIndex();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
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 static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AccessorInliningDataFlowTest {
|
||||
|
||||
@Nested
|
||||
class GetterInlining {
|
||||
|
||||
@Test
|
||||
void shouldResolveTrivialGetterThroughFieldBinding(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public static class Payload {
|
||||
private String event = "SHIP";
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
public void run() {
|
||||
Payload payload = new Payload();
|
||||
String value = payload.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "SHIP");
|
||||
assertAccessorIndexed(tempDir, "com.example.Runner.Payload", "getEvent");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveChainedTrivialGetters(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public static class Event {
|
||||
private String code = "PAY";
|
||||
public String getCode() { return code; }
|
||||
}
|
||||
public static class Payload {
|
||||
private Event event = new Event();
|
||||
public Event getEvent() { return event; }
|
||||
}
|
||||
public static class Order {
|
||||
private Payload payload = new Payload();
|
||||
public Payload getPayload() { return payload; }
|
||||
}
|
||||
public void run() {
|
||||
Order order = new Order();
|
||||
String code = order.getPayload().getEvent().getCode();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "code", "PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveInheritedFieldThroughChildGetter(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Base.java", """
|
||||
package com.example;
|
||||
public class Base {
|
||||
protected String event = "BASE";
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Child.java", """
|
||||
package com.example;
|
||||
public class Child extends Base {
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public void run() {
|
||||
Child child = new Child();
|
||||
String value = child.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "BASE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveRecordComponentAfterNonTrivialGetterWrapper(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Event.java", """
|
||||
package com.example;
|
||||
public record Event(String code) {}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Payload.java", """
|
||||
package com.example;
|
||||
public class Payload {
|
||||
private Event event = new Event("WRAPPED");
|
||||
public Event getEvent() { return event; }
|
||||
public Event getEventViaWrapper() { return getEvent(); }
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public void run() {
|
||||
Payload payload = new Payload();
|
||||
String code = payload.getEventViaWrapper().code();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "code", "WRAPPED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveRecordComponentThroughAccessorChain(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Event.java", """
|
||||
package com.example;
|
||||
public record Event(String code) {}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Payload.java", """
|
||||
package com.example;
|
||||
public class Payload {
|
||||
private Event event = new Event("RECORD");
|
||||
public Event getEvent() { return event; }
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public void run() {
|
||||
Payload payload = new Payload();
|
||||
String code = payload.getEvent().code();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "code", "RECORD");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class SetterThenGetter {
|
||||
|
||||
@Test
|
||||
void shouldApplySetterMutationBeforeGetterRead(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public static class Command {
|
||||
private String event = "DEFAULT";
|
||||
public void setEvent(String event) { this.event = event; }
|
||||
public String getEvent() { return this.event; }
|
||||
}
|
||||
public void run() {
|
||||
Command cmd = new Command();
|
||||
cmd.setEvent("PAY");
|
||||
String value = cmd.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldApplySetterThroughInterfaceTypedReceiver(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public interface Command {
|
||||
void setEvent(String event);
|
||||
String getEvent();
|
||||
}
|
||||
public static class Impl implements Command {
|
||||
private String event = "DEFAULT";
|
||||
@Override
|
||||
public void setEvent(String event) { this.event = event; }
|
||||
@Override
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
public void run() {
|
||||
Command cmd = new Impl();
|
||||
cmd.setEvent("PAY");
|
||||
String value = cmd.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveSetterArgumentThroughInterfaceTypedReceiver(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public interface Command {
|
||||
void setEvent(String event);
|
||||
String getEvent();
|
||||
}
|
||||
public static class Impl implements Command {
|
||||
private String event = "DEFAULT";
|
||||
@Override
|
||||
public void setEvent(String event) { this.event = event; }
|
||||
@Override
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
public static class Helper {
|
||||
public String constant() { return "PAY"; }
|
||||
}
|
||||
public void run() {
|
||||
Command cmd = new Impl();
|
||||
Helper helper = new Helper();
|
||||
cmd.setEvent(helper.constant());
|
||||
String value = cmd.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveFluentSetterChain(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public static class Builder {
|
||||
private String event = "INIT";
|
||||
public Builder setEvent(String event) {
|
||||
this.event = event;
|
||||
return this;
|
||||
}
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
public void run() {
|
||||
Builder builder = new Builder();
|
||||
builder.setEvent("FLUENT");
|
||||
String value = builder.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "FLUENT");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class MustNotBreakExistingResolution {
|
||||
|
||||
@Test
|
||||
void shouldStillResolveLiteralReturnWhenGetterIsNotTrivial(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public static class Service {
|
||||
public String getEvent() { return "LITERAL"; }
|
||||
}
|
||||
public void run() {
|
||||
Service service = new Service();
|
||||
String value = service.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "LITERAL");
|
||||
assertThat(accessorLookup(tempDir, "com.example.Runner.Service", "getEvent")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStillResolveDynamicDispatchWhenImplementationReturnsLiteral(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public interface Service {
|
||||
String getEvent();
|
||||
}
|
||||
public static class PayService implements Service {
|
||||
public String getEvent() { return "PAY"; }
|
||||
}
|
||||
public void run() {
|
||||
Service service = new PayService();
|
||||
String value = service.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotInlineParentAccessorWhenSubclassOverrideIsBlocked(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Base.java", """
|
||||
package com.example;
|
||||
public class Base {
|
||||
protected String event = "base";
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Child.java", """
|
||||
package com.example;
|
||||
public class Child extends Base {
|
||||
@Override
|
||||
public String getEvent() {
|
||||
return "child";
|
||||
}
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public void run() {
|
||||
Base target = new Child();
|
||||
String value = target.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "child");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldApplySetterMutationsOnInstanceFieldReceiver(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
private Command command = new Command();
|
||||
public void run() {
|
||||
this.command.setEvent("PAY");
|
||||
String value = this.command.getEvent();
|
||||
}
|
||||
public static class Command {
|
||||
private String event = "DEFAULT";
|
||||
public void setEvent(String event) { this.event = event; }
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotApplyParentAccessorIndexWhenChildOverrideIsBlocked(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Base.java", """
|
||||
package com.example;
|
||||
public class Base {
|
||||
protected String event = "BASE";
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Child.java", """
|
||||
package com.example;
|
||||
public class Child extends Base {
|
||||
@Override
|
||||
public String getEvent() {
|
||||
return event == null ? "NONE" : event.toUpperCase();
|
||||
}
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public void run() {
|
||||
Child child = new Child();
|
||||
child.event = "pay";
|
||||
String value = child.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertThat(accessorLookup(tempDir, "com.example.Child", "getEvent")).isEmpty();
|
||||
assertThat(accessorLookup(tempDir, "com.example.Base", "getEvent")).isPresent();
|
||||
|
||||
CodebaseContext context = scan(tempDir);
|
||||
DataFlowModel model = new JdtDataFlowModel(context);
|
||||
Expression initializer = findInitializer(context, "run", "value");
|
||||
String resolved = model.resolveValue(initializer, context);
|
||||
assertThat(resolved).isNotEqualTo("BASE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotConfuseMapGetWithBeanGetter(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
public class Runner {
|
||||
public void run() {
|
||||
Map<String, String> map = new HashMap<>();
|
||||
map.put("event", "MAP_VALUE");
|
||||
String value = map.get("event");
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertThat(accessorLookup(tempDir, "java.util.HashMap", "get")).isEmpty();
|
||||
// Map resolution is separate; ensure we do not crash or return wrong constant.
|
||||
CodebaseContext context = scan(tempDir);
|
||||
DataFlowModel model = new JdtDataFlowModel(context);
|
||||
Expression initializer = findInitializer(context, "run", "value");
|
||||
List<Expression> defs = model.getReachingDefinitions(initializer);
|
||||
assertThat(defs).isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreserveBranchSensitiveSetterVisibility(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public static class Command {
|
||||
private String event = "DEFAULT";
|
||||
public void setEvent(String event) { this.event = event; }
|
||||
public String getEvent() { return this.event; }
|
||||
}
|
||||
public void run(boolean pay) {
|
||||
Command cmd = new Command();
|
||||
if (pay) {
|
||||
cmd.setEvent("PAY");
|
||||
} else {
|
||||
cmd.setEvent("CANCEL");
|
||||
}
|
||||
String value = cmd.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
CodebaseContext context = scan(tempDir);
|
||||
DataFlowModel model = new JdtDataFlowModel(context);
|
||||
Expression initializer = findInitializer(context, "run", "value");
|
||||
String resolved = model.resolveValue(initializer, context);
|
||||
assertThat(resolved).isIn("PAY", "CANCEL", null);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class SchemaStressCases {
|
||||
|
||||
@Test
|
||||
void shouldHandleDeepGetterChainWithoutStackOverflow(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public static class L4 { private String v = "DEEP"; public String getV() { return v; } }
|
||||
public static class L3 { private L4 l4 = new L4(); public L4 getL4() { return l4; } }
|
||||
public static class L2 { private L3 l3 = new L3(); public L3 getL3() { return l3; } }
|
||||
public static class L1 { private L2 l2 = new L2(); public L2 getL2() { return l2; } }
|
||||
public void run() {
|
||||
L1 root = new L1();
|
||||
String value = root.getL2().getL3().getL4().getV();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "DEEP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleMixedTrivialAndLiteralGetterChain(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public static class Inner {
|
||||
public String code() { return "INNER"; }
|
||||
}
|
||||
public static class Outer {
|
||||
private Inner inner = new Inner();
|
||||
public Inner getInner() { return inner; }
|
||||
}
|
||||
public void run() {
|
||||
Outer outer = new Outer();
|
||||
String value = outer.getInner().code();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "INNER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveSuperclassFieldViaSubclassThisGetter(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public static class Base {
|
||||
protected String event = "SUPER";
|
||||
}
|
||||
public static class Sub extends Base {
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
public void run() {
|
||||
Sub sub = new Sub();
|
||||
String value = sub.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "SUPER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyWhenAccessorIndexHasNoEntry(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public static class Worker {
|
||||
public String compute() { return "WORK"; }
|
||||
}
|
||||
public void run() {
|
||||
Worker worker = new Worker();
|
||||
String value = worker.compute();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
assertResolvedValue(tempDir, "value", "WORK");
|
||||
assertThat(accessorLookup(tempDir, "com.example.Runner.Worker", "compute")).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertResolvedValue(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 void assertAccessorIndexed(Path tempDir, String ownerFqn, String methodName) throws IOException {
|
||||
assertThat(accessorLookup(tempDir, ownerFqn, methodName)).isPresent();
|
||||
}
|
||||
|
||||
private static java.util.Optional<AccessorSummary> accessorLookup(Path tempDir, String ownerFqn, String methodName)
|
||||
throws IOException {
|
||||
CodebaseContext context = scan(tempDir);
|
||||
return context.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()) {
|
||||
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 nestedResult = findInitializerInType(nested, methodName, variableName);
|
||||
if (nestedResult != null) {
|
||||
return nestedResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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 ConstantExtractorAccessorInliningTest {
|
||||
|
||||
private CodebaseContext context;
|
||||
private ConstantExtractor constantExtractor;
|
||||
private VariableTracer variableTracer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Payload.java", """
|
||||
package com.example;
|
||||
public class Payload {
|
||||
private String event = "SHIP";
|
||||
public String getEvent() { return event; }
|
||||
public void setEvent(String event) { this.event = event; }
|
||||
}
|
||||
""");
|
||||
writeJava(tempDir, "com/example/Runner.java", """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
public void run() {
|
||||
Payload payload = new Payload();
|
||||
payload.setEvent("PAY");
|
||||
String value = payload.getEvent();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
|
||||
context.scan(tempDir);
|
||||
|
||||
constantExtractor = new ConstantExtractor(context, context.getConstantResolver(), mi -> null);
|
||||
variableTracer = new VariableTracer(context, context.getConstantResolver());
|
||||
constantExtractor.setVariableTracer(variableTracer);
|
||||
constantExtractor.setConstructorAnalyzer(new ConstructorAnalyzer(context, context.getConstantResolver()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveMethodReturnConstantShouldReadIndexedGetterField() {
|
||||
List<String> constants = constantExtractor.resolveMethodReturnConstant(
|
||||
"com.example.Payload", "getEvent", 0, new HashSet<>(), null);
|
||||
|
||||
assertThat(constants).contains("SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void traceLocalSetterShouldUseAccessorIndexFieldName() {
|
||||
Expression result = variableTracer.traceLocalSetter(
|
||||
"com.example.Runner.run", "payload", "getEvent");
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.toString()).contains("PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void indexedGetterShouldExistForPayload() {
|
||||
assertThat(context.getAccessorIndex().lookup("com.example.Payload", "getEvent")).isPresent();
|
||||
assertThat(context.getAccessorIndex().lookup("com.example.Payload", "setEvent")).isPresent();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import org.eclipse.jdt.core.dom.AST;
|
||||
import org.eclipse.jdt.core.dom.ASTParser;
|
||||
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TrivialAccessorDetectorTest {
|
||||
|
||||
private final TrivialAccessorDetector detector = new TrivialAccessorDetector();
|
||||
|
||||
private static final TrivialAccessorDetector.FieldLookup KNOWN_FIELDS =
|
||||
fieldName -> Optional.of("com.example.Demo");
|
||||
|
||||
@Nested
|
||||
class Getters {
|
||||
|
||||
@Test
|
||||
void shouldDetectPlainGetter() {
|
||||
MethodDeclaration md = method("""
|
||||
public class Demo {
|
||||
private String event;
|
||||
public String getEvent() { return this.event; }
|
||||
}
|
||||
""", 0);
|
||||
|
||||
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
|
||||
|
||||
assertThat(summary).isPresent();
|
||||
assertThat(summary.get().kind()).isEqualTo(AccessorKind.GETTER);
|
||||
assertThat(summary.get().fieldName()).isEqualTo("event");
|
||||
assertThat(summary.get().methodName()).isEqualTo("getEvent");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDetectGetterWithoutThisPrefix() {
|
||||
MethodDeclaration md = method("""
|
||||
public class Demo {
|
||||
private String event;
|
||||
public String getEvent() { return event; }
|
||||
}
|
||||
""", 0);
|
||||
|
||||
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDetectBooleanGetter() {
|
||||
MethodDeclaration md = method("""
|
||||
public class Demo {
|
||||
private boolean active;
|
||||
public boolean isActive() { return active; }
|
||||
}
|
||||
""", 0);
|
||||
|
||||
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
|
||||
|
||||
assertThat(summary).isPresent();
|
||||
assertThat(summary.get().kind()).isEqualTo(AccessorKind.BOOLEAN_GETTER);
|
||||
assertThat(summary.get().fieldName()).isEqualTo("active");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDetectGetterWithCast() {
|
||||
MethodDeclaration md = method("""
|
||||
public class Demo {
|
||||
private Object event;
|
||||
public Object getEvent() { return (Object) this.event; }
|
||||
}
|
||||
""", 0);
|
||||
|
||||
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectLazyInitGetter() {
|
||||
MethodDeclaration md = method("""
|
||||
public class Demo {
|
||||
private String event;
|
||||
public String getEvent() {
|
||||
if (event == null) {
|
||||
event = "default";
|
||||
}
|
||||
return event;
|
||||
}
|
||||
}
|
||||
""", 0);
|
||||
|
||||
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectGetterWithMethodCall() {
|
||||
MethodDeclaration md = method("""
|
||||
public class Demo {
|
||||
private String event;
|
||||
public String getEvent() { return event.trim(); }
|
||||
}
|
||||
""", 0);
|
||||
|
||||
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectUnknownField() {
|
||||
MethodDeclaration md = method("""
|
||||
public class Demo {
|
||||
public String getMissing() { return missing; }
|
||||
}
|
||||
""", 0);
|
||||
|
||||
assertThat(detector.detect("com.example.Demo", md, fieldName -> Optional.empty())).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Setters {
|
||||
|
||||
@Test
|
||||
void shouldDetectVoidSetter() {
|
||||
MethodDeclaration md = method("""
|
||||
public class Demo {
|
||||
private String event;
|
||||
public void setEvent(String event) { this.event = event; }
|
||||
}
|
||||
""", 0);
|
||||
|
||||
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
|
||||
|
||||
assertThat(summary).isPresent();
|
||||
assertThat(summary.get().kind()).isEqualTo(AccessorKind.SETTER);
|
||||
assertThat(summary.get().paramName()).isEqualTo("event");
|
||||
assertThat(summary.get().returnsThis()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDetectFluentSetter() {
|
||||
MethodDeclaration md = method("""
|
||||
public class Demo {
|
||||
private String event;
|
||||
public Demo setEvent(String event) {
|
||||
this.event = event;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
""", 0);
|
||||
|
||||
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
|
||||
|
||||
assertThat(summary).isPresent();
|
||||
assertThat(summary.get().kind()).isEqualTo(AccessorKind.FLUENT_SETTER);
|
||||
assertThat(summary.get().returnsThis()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectSetterWithValidation() {
|
||||
MethodDeclaration md = method("""
|
||||
public class Demo {
|
||||
private String event;
|
||||
public void setEvent(String event) {
|
||||
java.util.Objects.requireNonNull(event);
|
||||
this.event = event;
|
||||
}
|
||||
}
|
||||
""", 0);
|
||||
|
||||
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Records {
|
||||
|
||||
@Test
|
||||
void shouldDetectRecordComponentAccessor() {
|
||||
Optional<AccessorSummary> summary = detector.detectRecordComponent("com.example.Event", "code");
|
||||
|
||||
assertThat(summary).isPresent();
|
||||
assertThat(summary.get().kind()).isEqualTo(AccessorKind.RECORD_COMPONENT);
|
||||
assertThat(summary.get().methodName()).isEqualTo("code");
|
||||
assertThat(summary.get().fieldName()).isEqualTo("code");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Naming {
|
||||
|
||||
@Test
|
||||
void shouldDecapitalizeJavaBeanProperty() {
|
||||
assertThat(TrivialAccessorDetector.decapitalize("Event")).isEqualTo("event");
|
||||
assertThat(TrivialAccessorDetector.decapitalize("URL")).isEqualTo("URL");
|
||||
}
|
||||
}
|
||||
|
||||
private static MethodDeclaration method(String classSource, int methodIndex) {
|
||||
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
|
||||
parser.setSource(classSource.toCharArray());
|
||||
parser.setKind(ASTParser.K_COMPILATION_UNIT);
|
||||
CompilationUnit cu = (CompilationUnit) parser.createAST(null);
|
||||
TypeDeclaration td = (TypeDeclaration) cu.types().get(0);
|
||||
return td.getMethods()[methodIndex];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
junit.jupiter.execution.parallel.enabled=true
|
||||
junit.jupiter.execution.parallel.mode.default=concurrent
|
||||
junit.jupiter.execution.parallel.mode.classes.default=concurrent
|
||||
junit.jupiter.execution.parallel.config.strategy=dynamic
|
||||
junit.jupiter.execution.parallel.config.dynamic.factor=1.0
|
||||
Reference in New Issue
Block a user