Add --inline-accessors CLI flag, layered dispatcher regression goldens, and accessor index docs.

Expose accessor index toggling through ExportService and picocli, lock in layered_dispatcher_sample exports as golden fixtures, and document the static analysis pipeline in the README.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-11 20:29:07 +02:00
parent a108d8cc6e
commit 8fa6a44e75
14 changed files with 2430 additions and 2 deletions

View File

@@ -36,3 +36,16 @@ Define sequence of events in `src/main/resources/flows.json`:
- `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`). - `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`).
- `transitions.actions[].internalLogic`: Extracted source code/lambdas. - `transitions.actions[].internalLogic`: Extracted source code/lambdas.
- `metadata.properties`: Dictionary of all detected Spring profiles. - `metadata.properties`: Dictionary of all detected Spring profiles.
## Static analysis: accessor inlining
During scan, the exporter builds an **accessor index** of trivial getters, setters, record components, and constant-returning methods. At analysis time, `AccessorResolver` uses this index first (O(1) lookup), then falls back to constructor/field tracing and method-body evaluation.
This improves resolution of DTO/event chains such as `request.getPayload().getType()` without running Maven or Gradle on the target project.
```bash
# Enabled by default; disable for debugging or A/B comparison
./gradlew :state_machine_exporter:run --args="-i ./my-project -o ./out --no-inline-accessors"
```
Pipeline components live under `analysis/pipeline/` (`AccessorResolver`, `ResolutionBudget`, `MethodInvocationUnwrapper`, `FieldInitializerFinder`, `LibraryUnwrapRegistry`).

View File

@@ -45,6 +45,7 @@ public class CodebaseContext {
private Path projectRoot; private Path projectRoot;
private final Map<String, Object> cache = new HashMap<>(); private final Map<String, Object> cache = new HashMap<>();
private AccessorIndex accessorIndex = AccessorIndex.empty(); private AccessorIndex accessorIndex = AccessorIndex.empty();
private boolean inlineAccessors = true;
public Map<String, Object> getCache() { public Map<String, Object> getCache() {
return cache; return cache;
@@ -54,6 +55,14 @@ public class CodebaseContext {
return accessorIndex; return accessorIndex;
} }
public boolean isInlineAccessors() {
return inlineAccessors;
}
public void setInlineAccessors(boolean inlineAccessors) {
this.inlineAccessors = inlineAccessors;
}
public void setProjectRoot(Path root) { public void setProjectRoot(Path root) {
this.projectRoot = root; this.projectRoot = root;
} }
@@ -247,7 +256,15 @@ public class CodebaseContext {
} }
} }
this.accessorIndex = new AccessorIndexBuilder().build(this); this.accessorIndex = inlineAccessors
? new AccessorIndexBuilder().build(this)
: AccessorIndex.empty();
if (inlineAccessors) {
log.info("Built accessor index: {} trivial accessors across {} types",
accessorIndex.trivialCount(), accessorIndex.typeCount());
} else {
log.info("Accessor inlining disabled; skipping accessor index build.");
}
} }
private void indexType(TypeDeclaration td, String parentFqn, Path javaFile) { private void indexType(TypeDeclaration td, String parentFqn, Path javaFile) {

View File

@@ -60,6 +60,9 @@ public class ExporterCommand implements Callable<Integer> {
@Option(names = {"--include-generated-sources"}, description = "Scan existing Maven target/ and Gradle build/ trees for pre-generated .java sources (MapStruct, annotation processors, etc.). Does not run Maven or Gradle.", negatable = true, defaultValue = "true", fallbackValue = "true") @Option(names = {"--include-generated-sources"}, description = "Scan existing Maven target/ and Gradle build/ trees for pre-generated .java sources (MapStruct, annotation processors, etc.). Does not run Maven or Gradle.", negatable = true, defaultValue = "true", fallbackValue = "true")
private boolean includeGeneratedSources = true; private boolean includeGeneratedSources = true;
@Option(names = {"--inline-accessors"}, description = "Index trivial JavaBean/record accessors at scan time and inline them during call-chain and constant resolution.", negatable = true, defaultValue = "true", fallbackValue = "true")
private boolean inlineAccessors = true;
@Override @Override
public Integer call() throws Exception { public Integer call() throws Exception {
var out = spec.commandLine().getOut(); var out = spec.commandLine().getOut();
@@ -90,7 +93,7 @@ public class ExporterCommand implements Callable<Integer> {
if (jsonFile != null) { if (jsonFile != null) {
exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat); exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat);
} else { } else {
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, includeGeneratedSources); exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, includeGeneratedSources, inlineAccessors);
} }
out.println(CommandLine.Help.Ansi.AUTO.string("%n@|bold,green SUCCESS:|@ All state machines have been exported to " + outputDir.toAbsolutePath())); out.println(CommandLine.Help.Ansi.AUTO.string("%n@|bold,green SUCCESS:|@ All state machines have been exported to " + outputDir.toAbsolutePath()));
return 0; return 0;

View File

@@ -75,8 +75,13 @@ public class ExportService {
} }
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, boolean includeGeneratedSources) throws IOException { public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, boolean includeGeneratedSources) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, eventFormat, stateFormat, includeGeneratedSources, true);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, boolean includeGeneratedSources, boolean inlineAccessors) throws IOException {
CodebaseContext context = new CodebaseContext(); CodebaseContext context = new CodebaseContext();
context.setActiveProfiles(activeProfiles); context.setActiveProfiles(activeProfiles);
context.setInlineAccessors(inlineAccessors);
click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver siblingResolver = click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver siblingResolver =
new click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver(); new click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver();

View File

@@ -155,6 +155,18 @@ public class RegressionTest {
root.resolve("state_machines/polymorphic_events_sample"), root.resolve("state_machines/polymorphic_events_sample"),
Path.of("src/test/resources/golden/PolymorphicStateMachineConfiguration"), Path.of("src/test/resources/golden/PolymorphicStateMachineConfiguration"),
"PolymorphicStateMachineConfiguration" "PolymorphicStateMachineConfiguration"
),
new TestScenario(
"Layered Dispatcher (Order)",
root.resolve("state_machines/layered_dispatcher_sample"),
Path.of("src/test/resources/golden/StandardOrderStateMachineConfiguration"),
"StandardOrderStateMachineConfiguration"
),
new TestScenario(
"Layered Dispatcher (Document)",
root.resolve("state_machines/layered_dispatcher_sample"),
Path.of("src/test/resources/golden/StandardDocumentStateMachineConfiguration"),
"StandardDocumentStateMachineConfiguration"
) )
); );
} }

View File

@@ -0,0 +1,106 @@
package click.kamil.springstatemachineexporter.service;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
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 ExportServiceInlineAccessorsTest {
@Test
void shouldSkipAccessorIndexWhenInlineAccessorsDisabled(@TempDir Path tempDir) throws IOException {
Path sourceRoot = tempDir.resolve("project");
writeJava(sourceRoot, "com/example/Payload.java", """
package com.example;
public class Payload {
private String event = "PAY";
public String getEvent() { return event; }
}
""");
writeJava(sourceRoot, "com/example/Config.java", """
package com.example;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachine;
import org.springframework.statemachine.config.StateMachineConfigurerAdapter;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
@Configuration
@EnableStateMachine
public class Config extends StateMachineConfigurerAdapter<String, String> {
@Override
public void configure(StateMachineStateConfigurer<String, String> states) throws Exception {
states.withStates().initial("NEW").state("PAID");
}
@Override
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withExternal().source("NEW").target("PAID").event("PAY");
}
}
""");
CodebaseContext enabledContext = scanContext(sourceRoot, true);
assertThat(enabledContext.getAccessorIndex().trivialCount()).isGreaterThan(0);
CodebaseContext disabledContext = scanContext(sourceRoot, false);
assertThat(disabledContext.getAccessorIndex().trivialCount()).isZero();
}
@Test
void shouldExportWithInlineAccessorsFlag(@TempDir Path inputDir, @TempDir Path outputDir) throws IOException {
writeJava(inputDir, "com/example/SimpleConfig.java", """
package com.example;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachine;
import org.springframework.statemachine.config.StateMachineConfigurerAdapter;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
@Configuration
@EnableStateMachine
public class SimpleConfig extends StateMachineConfigurerAdapter<String, String> {
@Override
public void configure(StateMachineStateConfigurer<String, String> states) throws Exception {
states.withStates().initial("A").state("B");
}
@Override
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withExternal().source("A").target("B").event("GO");
}
}
""");
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runExporter(inputDir, outputDir, List.of("json"), true, List.of(), null, null,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
true,
false);
try (var stream = Files.list(outputDir)) {
assertThat(stream.filter(Files::isDirectory).count()).isGreaterThan(0);
}
}
private static CodebaseContext scanContext(Path sourceRoot, boolean inlineAccessors) throws IOException {
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(sourceRoot.toAbsolutePath().toString()));
context.setInlineAccessors(inlineAccessors);
context.scan(sourceRoot);
return context;
}
private static void writeJava(Path root, String relativePath, String source) throws IOException {
Path file = root.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, source);
}
}

View File

@@ -0,0 +1,14 @@
digraph statemachine {
rankdir=LR;
node [shape=rounded, style=filled, fillcolor=white, fontname="Arial"];
edge [fontname="Arial", fontsize=10];
_start [shape=circle, label="", fillcolor=black, width=0.1];
_start -> DRAFT;
REJECTED [fillcolor=lightgray];
APPROVED [fillcolor=lightgray];
DRAFT -> SUBMITTED [label="DocumentTransitionEvent.SUBMIT", style="solid", color="black"];
SUBMITTED -> APPROVED [label="DocumentTransitionEvent.APPROVE", style="solid", color="black"];
SUBMITTED -> REJECTED [label="DocumentTransitionEvent.REJECT", style="solid", color="black"];
}

View File

@@ -0,0 +1,34 @@
@startuml
!pragma layout smetana
set separator none
hide empty description
hide stereotype
skinparam state {
BackgroundColor white
BorderColor #94a3b8
BorderThickness 1
FontName Inter
FontSize 9
FontStyle bold
RoundCorner 20
Padding 1
}
skinparam shadowing false
skinparam ArrowFontName JetBrains Mono
skinparam ArrowFontSize 8
skinparam ArrowColor #cbd5e1
skinparam ArrowThickness 1
skinparam dpi 110
skinparam svgLinkTarget _self
[*] --> DocumentState.DRAFT
DocumentState.DRAFT -[#1E90FF,bold]-> DocumentState.SUBMITTED <<external>> : DocumentTransitionEvent.SUBMIT
DocumentState.SUBMITTED -[#1E90FF,bold]-> DocumentState.APPROVED <<external>> : DocumentTransitionEvent.APPROVE
DocumentState.SUBMITTED -[#1E90FF,bold]-> DocumentState.REJECTED <<external>> : DocumentTransitionEvent.REJECT
DocumentState.REJECTED --> [*]
DocumentState.APPROVED --> [*]
@enduml

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="DRAFT">
<state id="DRAFT">
<transition target="SUBMITTED" event="DocumentTransitionEvent.SUBMIT"/>
</state>
<state id="SUBMITTED">
<transition target="APPROVED" event="DocumentTransitionEvent.APPROVE"/>
<transition target="REJECTED" event="DocumentTransitionEvent.REJECT"/>
</state>
<state id="APPROVED">
</state>
<state id="REJECTED">
</state>
</scxml>

View File

@@ -0,0 +1,14 @@
digraph statemachine {
rankdir=LR;
node [shape=rounded, style=filled, fillcolor=white, fontname="Arial"];
edge [fontname="Arial", fontsize=10];
_start [shape=circle, label="", fillcolor=black, width=0.1];
_start -> NEW;
SHIPPED [fillcolor=lightgray];
CANCELLED [fillcolor=lightgray];
NEW -> PAID [label="OrderTransitionEvent.PAY", style="solid", color="black"];
PAID -> SHIPPED [label="OrderTransitionEvent.SHIP", style="solid", color="black"];
PAID -> CANCELLED [label="OrderTransitionEvent.CANCEL", style="solid", color="black"];
}

View File

@@ -0,0 +1,34 @@
@startuml
!pragma layout smetana
set separator none
hide empty description
hide stereotype
skinparam state {
BackgroundColor white
BorderColor #94a3b8
BorderThickness 1
FontName Inter
FontSize 9
FontStyle bold
RoundCorner 20
Padding 1
}
skinparam shadowing false
skinparam ArrowFontName JetBrains Mono
skinparam ArrowFontSize 8
skinparam ArrowColor #cbd5e1
skinparam ArrowThickness 1
skinparam dpi 110
skinparam svgLinkTarget _self
[*] --> OrderState.NEW
OrderState.NEW -[#1E90FF,bold]-> OrderState.PAID <<external>> : OrderTransitionEvent.PAY
OrderState.PAID -[#1E90FF,bold]-> OrderState.SHIPPED <<external>> : OrderTransitionEvent.SHIP
OrderState.PAID -[#1E90FF,bold]-> OrderState.CANCELLED <<external>> : OrderTransitionEvent.CANCEL
OrderState.SHIPPED --> [*]
OrderState.CANCELLED --> [*]
@enduml

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="NEW">
<state id="NEW">
<transition target="PAID" event="OrderTransitionEvent.PAY"/>
</state>
<state id="PAID">
<transition target="SHIPPED" event="OrderTransitionEvent.SHIP"/>
<transition target="CANCELLED" event="OrderTransitionEvent.CANCEL"/>
</state>
<state id="SHIPPED">
</state>
<state id="CANCELLED">
</state>
</scxml>