Make JSON a self-contained analysis interchange for downstream exporters.

Persist machine types, finalize on JSON import, resolve @Value placeholders, and canonicalize String states so HTML and other consumers can import JSON without source re-analysis.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 05:33:27 +02:00
parent 0aca0aade9
commit 4f13c9449c
45 changed files with 2349 additions and 849 deletions

View File

@@ -35,6 +35,11 @@ public class AnalysisResult {
@Builder.Default
private List<BusinessFlow> flows = java.util.Collections.emptyList();
/** Resolved {@code StateMachineConfigurerAdapter<S,E>} state type; persisted for JSON round-trip. */
private String stateTypeFqn;
/** Resolved {@code StateMachineConfigurerAdapter<S,E>} event type; persisted for JSON round-trip. */
private String eventTypeFqn;
@Builder.Default
private CodebaseMetadata metadata = CodebaseMetadata.empty();
@@ -56,7 +61,9 @@ public class AnalysisResult {
// 2. Resolve states (State record is immutable, so recreate)
if (states != null) {
this.states = states.stream()
.map(s -> click.kamil.springstatemachineexporter.model.State.of(s.rawName(), resolver.resolveValue(s.fullIdentifier(), properties)))
.map(s -> click.kamil.springstatemachineexporter.model.State.of(
resolver.resolveValue(s.rawName(), properties),
resolver.resolveValue(s.fullIdentifier(), properties)))
.collect(java.util.stream.Collectors.toSet());
}

View File

@@ -1037,9 +1037,19 @@ public class ConstantResolver {
}
if (valueExpr instanceof StringLiteral sl) {
return sl.getLiteralValue();
String literal = sl.getLiteralValue();
if (literal != null && literal.contains("${")) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
literal, java.util.Collections.emptyMap());
}
return literal;
}
return valueExpr != null ? valueExpr.toString().replaceAll("^\"|\"$", "") : null;
String raw = valueExpr != null ? valueExpr.toString().replaceAll("^\"|\"$", "") : null;
if (raw != null && raw.contains("${")) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
raw, java.util.Collections.emptyMap());
}
return raw;
}
private TypeDeclaration findEnclosingType(ASTNode node) {

View File

@@ -190,13 +190,26 @@ public final class MachineEnumCanonicalizer {
if (state == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return state;
}
String canonical = canonicalizeLabel(
state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName(),
enumTypeFqn);
if (canonical.equals(state.fullIdentifier())) {
String raw = resolvePlaceholder(state.rawName());
String full = resolvePlaceholder(
state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName());
String canonical = canonicalizeLabel(full, enumTypeFqn);
if (canonical.equals(state.fullIdentifier()) && raw.equals(state.rawName())) {
return state;
}
return State.of(state.rawName(), canonical);
if (isStringOrPrimitiveType(enumTypeFqn) && canonical.startsWith(enumTypeFqn + ".")) {
String constant = canonical.substring(enumTypeFqn.length() + 1);
raw = "\"" + constant + "\"";
}
return State.of(raw, canonical);
}
private static String resolvePlaceholder(String value) {
if (value == null || !value.contains("${")) {
return value;
}
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
value, java.util.Collections.emptyMap());
}
public static boolean isMachineEnumReference(String value, String enumTypeFqn) {
@@ -230,6 +243,10 @@ public final class MachineEnumCanonicalizer {
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return value;
}
value = resolvePlaceholder(value);
if (value == null || value.isBlank()) {
return value;
}
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
return stripQuotes(value);
}

View File

@@ -23,17 +23,50 @@ public final class AnalysisResultFinalizer {
}
public static void finalizeResult(AnalysisResult result, CodebaseContext context) {
if (result == null || context == null) {
if (result == null) {
return;
}
StateMachineTypeResolver.MachineTypes machineTypes = JsonExportContextFactory.resolveMachineTypes(
result.getName(),
result.getStateTypeFqn(),
result.getEventTypeFqn(),
context);
finalizeResult(result, context, machineTypes);
}
public static void finalizeResult(
AnalysisResult result,
CodebaseContext context,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (result == null || machineTypes == null) {
return;
}
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
persistMachineTypes(result, machineTypes);
applyCanonicalization(result, machineTypes);
if (context != null) {
AnalysisCanonicalFormValidator.enforce(result, context);
} else {
AnalysisCanonicalFormValidator.enforceWithMachineTypes(result, machineTypes);
}
}
private static void persistMachineTypes(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes) {
result.setStateTypeFqn(machineTypes.stateTypeFqn());
result.setEventTypeFqn(machineTypes.eventTypeFqn());
}
private static void applyCanonicalization(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes) {
MachineEnumCanonicalizer.canonicalizeTransitions(result.getTransitions(), machineTypes);
if (result.getStates() != null) {
result.setStates(MachineEnumCanonicalizer.canonicalizeStates(result.getStates(), machineTypes.stateTypeFqn()));
result.setStates(MachineEnumCanonicalizer.canonicalizeStates(
result.getStates(), machineTypes.stateTypeFqn()));
}
result.setStartStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
result.getStartStates(), machineTypes.stateTypeFqn()));
@@ -51,8 +84,6 @@ public final class AnalysisResultFinalizer {
.properties(metadata.getProperties())
.build());
}
AnalysisCanonicalFormValidator.enforce(result, context);
}
private static List<TriggerPoint> canonicalizeTriggers(

View File

@@ -0,0 +1,73 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/** Builds a minimal {@link CodebaseContext} for JSON re-export when sources are available. */
@Slf4j
public final class JsonExportContextFactory {
private JsonExportContextFactory() {
}
public static Path findProjectRoot(Path start) {
Path current = start != null ? start.toAbsolutePath().normalize() : null;
while (current != null) {
if (Files.exists(current.resolve("settings.gradle"))
|| Files.exists(current.resolve("pom.xml"))
|| Files.exists(current.resolve("build.gradle"))) {
return current;
}
current = current.getParent();
}
return null;
}
public static CodebaseContext scanProjectRoot(Path projectRoot, List<String> activeProfiles) throws IOException {
CodebaseContext context = new CodebaseContext();
if (activeProfiles != null) {
context.setActiveProfiles(activeProfiles);
}
context.setProjectRoot(projectRoot);
context.setResolveBindings(true);
Path mainJava = projectRoot.resolve("src/main/java");
if (Files.isDirectory(mainJava)) {
context.setSourcepath(List.of(mainJava.toString()));
context.scan(projectRoot);
return context;
}
click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver siblingResolver =
new click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver();
var projectGraph = siblingResolver.analyzeProject(projectRoot);
var scanPaths = projectGraph.resolveScanPaths(projectRoot, false);
context.scan(scanPaths, java.util.Collections.emptySet());
return context;
}
public static StateMachineTypeResolver.MachineTypes resolveMachineTypes(
String machineName,
String embeddedStateTypeFqn,
String embeddedEventTypeFqn,
CodebaseContext context) {
if (context != null && machineName != null) {
StateMachineTypeResolver.MachineTypes resolved =
StateMachineTypeResolver.resolveTypes(machineName, context);
if (resolved.stateTypeFqn() != null || resolved.eventTypeFqn() != null) {
return resolved;
}
}
return new StateMachineTypeResolver.MachineTypes(embeddedStateTypeFqn, embeddedEventTypeFqn);
}
private static boolean hasPackageQualifiedType(String typeFqn) {
return typeFqn != null && typeFqn.contains(".");
}
}

View File

@@ -49,6 +49,46 @@ public final class AnalysisCanonicalFormValidator {
return violations;
}
validateFields(result, machineTypes, violations);
return violations;
}
public static List<Violation> validateWithMachineTypes(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes) {
List<Violation> violations = new ArrayList<>();
if (result == null || machineTypes == null) {
return violations;
}
if (!hasPackageQualifiedEnumType(machineTypes.stateTypeFqn())
&& !hasPackageQualifiedEnumType(machineTypes.eventTypeFqn())) {
return violations;
}
validateFields(result, machineTypes, violations);
return violations;
}
public static void enforce(AnalysisResult result, CodebaseContext context) {
List<Violation> violations = validate(result, context);
if (!violations.isEmpty()) {
throw new AnalysisCanonicalFormException(violations);
}
}
public static void enforceWithMachineTypes(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes) {
List<Violation> violations = validateWithMachineTypes(result, machineTypes);
if (!violations.isEmpty()) {
throw new AnalysisCanonicalFormException(violations);
}
}
private static void validateFields(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) {
validateTransitions(result.getTransitions(), machineTypes, violations);
validateStateCollection(result.getStates(), machineTypes.stateTypeFqn(), "states", violations);
validateStateLabels(result.getStartStates(), machineTypes.stateTypeFqn(), "startStates", violations);
@@ -58,15 +98,6 @@ public final class AnalysisCanonicalFormValidator {
validateTriggers(result.getMetadata().getTriggers(), machineTypes, violations);
validateCallChains(result.getMetadata().getCallChains(), machineTypes, violations);
}
return violations;
}
public static void enforce(AnalysisResult result, CodebaseContext context) {
List<Violation> violations = validate(result, context);
if (!violations.isEmpty()) {
throw new AnalysisCanonicalFormException(violations);
}
}
private static String describeTypes(StateMachineTypeResolver.MachineTypes machineTypes) {

View File

@@ -69,17 +69,26 @@ public class StateResolver {
}
private String extractAnnotationValue(Annotation ann) {
String raw;
if (ann instanceof SingleMemberAnnotation sma) {
return stripQuotes(sma.getValue().toString());
raw = stripQuotes(sma.getValue().toString());
} else if (ann instanceof NormalAnnotation na) {
raw = null;
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if ("value".equals(pair.getName().getIdentifier())) {
return stripQuotes(pair.getValue().toString());
raw = stripQuotes(pair.getValue().toString());
break;
}
}
} else {
return null;
}
return null;
if (raw != null && raw.contains("${")) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
raw, java.util.Collections.emptyMap());
}
return raw;
}
private String stripQuotes(String s) {

View File

@@ -30,7 +30,7 @@ public class ExporterCommand implements Callable<Integer> {
@CommandLine.Spec
CommandLine.Model.CommandSpec spec;
@Option(names = {"-i", "--input"}, description = "Input directory containing the Spring Boot project source code.")
@Option(names = {"-i", "--input"}, description = "Input directory containing the Spring Boot project source code. With -j, optionally rescans sources to resolve machine enum types.")
private Path inputDir;
@Option(names = {"-j", "--json"}, description = "Input JSON file containing the state machine definition.")
@@ -91,7 +91,7 @@ public class ExporterCommand implements Callable<Integer> {
try {
List<String> profiles = activeProfiles != null ? activeProfiles : java.util.Collections.emptyList();
if (jsonFile != null) {
exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat);
exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat, inputDir);
} else {
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, includeGeneratedSources, inlineAccessors);
}

View File

@@ -20,15 +20,9 @@ public class JsonExporter implements StateMachineExporter {
@Override
public String export(click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result, ExportOptions options) {
try {
java.util.Map<String, Object> output = new java.util.HashMap<>();
output.put("name", result.getName());
output.put("transitions", result.getTransitions());
output.put("startStates", result.getStartStates());
output.put("endStates", result.getEndStates());
output.put("renderChoicesAsDiamonds", options.isRenderChoicesAsDiamonds());
output.put("metadata", result.getMetadata());
return objectMapper.writeValueAsString(output);
// Full AnalysisResult serialization: self-contained interchange format for downstream
// exporters (HTML, etc.) without requiring source code re-analysis.
return objectMapper.writeValueAsString(result);
} catch (Exception e) {
throw new RuntimeException("Failed to export to JSON", e);
}

View File

@@ -172,12 +172,37 @@ public class ExportService {
}
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
runJsonExporter(jsonFile, outputDir, selectedFormats, activeProfiles, eventFormat, stateFormat, null);
}
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, Path optionalSourceDir) throws IOException {
JsonImportService jsonImportService = new JsonImportService();
AnalysisResult result = jsonImportService.importAnalysisResult(jsonFile);
// Apply property resolution pass if placeholders exist
resolveProperties(result, activeProfiles);
CodebaseContext context = null;
Path sourceRoot = optionalSourceDir;
if (sourceRoot == null) {
sourceRoot = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
.findProjectRoot(jsonFile.toAbsolutePath().getParent());
}
if (sourceRoot != null) {
log.info("JSON re-export: scanning source project at {}", sourceRoot);
context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
.scanProjectRoot(sourceRoot, activeProfiles);
} else {
log.info("JSON re-export: no source project found; using embedded machine types if present");
}
StateMachineTypeResolver.MachineTypes machineTypes =
click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory.resolveMachineTypes(
result.getName(),
result.getStateTypeFqn(),
result.getEventTypeFqn(),
context);
AnalysisResultFinalizer.finalizeResult(result, context, machineTypes);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
}