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:
13
README.md
13
README.md
@@ -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`).
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"];
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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"];
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
Reference in New Issue
Block a user