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