Fix golden audit issues and tighten analysis/export pipeline.

Resolve property placeholders before call-chain linking, skip phantom routing states and lifecycle triggers from transition matching, dedupe exported states, and add golden JSON audit regression plus HTML lifecycle endpoint display.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 05:49:25 +02:00
parent 4f13c9449c
commit b158179559
53 changed files with 882 additions and 2057 deletions

View File

@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
@@ -42,6 +43,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
continue;
}
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
updatedChains.add(chain);
continue;
}
String triggerEvent = simplify(tp.getEvent());
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null;

View File

@@ -12,6 +12,11 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
return false;
}
if (click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers
.isLifecycle(triggerPoint.getEvent())) {
return false;
}
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
String rawTriggerEvent = triggerPoint.getEvent();

View File

@@ -0,0 +1,36 @@
package click.kamil.springstatemachineexporter.analysis.model;
/**
* Synthetic trigger markers for persistence/lifecycle operations that do not fire
* {@code sendEvent} transitions (e.g. {@code StateMachinePersister.restore(...)}).
*/
public final class LifecycleTriggerMarkers {
public static final String PREFIX = "[LIFECYCLE:";
private LifecycleTriggerMarkers() {
}
public static boolean isLifecycle(String event) {
return event != null && event.startsWith(PREFIX) && event.endsWith("]");
}
public static String lifecycleType(String event) {
if (!isLifecycle(event)) {
return null;
}
return event.substring(PREFIX.length(), event.length() - 1);
}
public static String displayLabel(String event) {
String type = lifecycleType(event);
if (type == null) {
return event;
}
return switch (type.toUpperCase()) {
case "RESTORE" -> "Restore persisted state";
case "RESET" -> "Reset state machine";
default -> "Lifecycle: " + type.toLowerCase();
};
}
}

View File

@@ -1,17 +1,77 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.AnalysisEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.path.ForwardPathEstimationEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.RequiredArgsConstructor;
import java.util.List;
@RequiredArgsConstructor
public class EnrichmentService {
private final List<AnalysisEnricher> enrichers;
private final List<AnalysisEnricher> prePropertyEnrichers;
private final List<AnalysisEnricher> postPropertyEnrichers;
/** Single-phase pipeline (all enrichers run together; used by focused tests). */
public EnrichmentService(List<AnalysisEnricher> enrichers) {
this(enrichers, List.of());
}
public EnrichmentService(
List<AnalysisEnricher> prePropertyEnrichers,
List<AnalysisEnricher> postPropertyEnrichers) {
this.prePropertyEnrichers = prePropertyEnrichers;
this.postPropertyEnrichers = postPropertyEnrichers;
}
public static EnrichmentService createDefault() {
return new EnrichmentService(
List.of(
new TriggerEnricher(),
new EntryPointEnricher(),
new PropertyEnricher()),
List.of(
new CallChainEnricher(),
new ForwardPathEstimationEnricher(),
new TriggerCanonicalizationEnricher(),
new TransitionLinkerEnricher()));
}
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
enrichPreProperty(result, context, intelligence);
enrichPostProperty(result, context, intelligence);
}
public void enrichPreProperty(
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
runEnrichers(prePropertyEnrichers, result, context, intelligence);
}
public void enrichPostProperty(
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
runEnrichers(postPropertyEnrichers, result, context, intelligence);
}
/** Re-runs transition linking after property resolution without rebuilding call chains. */
public void relinkAfterPropertyResolution(
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
runEnrichers(
List.of(new TriggerCanonicalizationEnricher(), new TransitionLinkerEnricher()),
result,
context,
intelligence);
}
private static void runEnrichers(
List<AnalysisEnricher> enrichers,
AnalysisResult result,
CodebaseContext context,
CodebaseIntelligenceProvider intelligence) {
for (AnalysisEnricher enricher : enrichers) {
enricher.enrich(result, context, intelligence);
}

View File

@@ -11,6 +11,7 @@ import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
@Slf4j
@RequiredArgsConstructor
@@ -19,6 +20,17 @@ public class GenericEventDetector {
private final ConstantResolver constantResolver;
private final List<LibraryHint> hints;
/** Parameter names that route to a machine, not SM state enum constants. */
private static final Set<String> ROUTING_PARAMETER_NAMES = Set.of(
"machineType",
"machineId",
"machineName",
"domain",
"type",
"action",
"eventString",
"version");
public List<TriggerPoint> detect(CompilationUnit cu) {
List<TriggerPoint> triggers = new ArrayList<>();
String fileName = "unknown";
@@ -296,8 +308,10 @@ public class GenericEventDetector {
String state = extractStateFromSiblings(current, block.statements());
if (state != null) return state;
} else if (parent instanceof SwitchStatement switchStmt) {
String state = extractStateFromSiblings(current, switchStmt.statements());
if (state != null) return state;
if (!isRoutingParameter(switchStmt.getExpression())) {
String state = extractStateFromSiblings(current, switchStmt.statements());
if (state != null) return state;
}
}
current = parent;
@@ -354,6 +368,10 @@ public class GenericEventDetector {
if (left instanceof NullLiteral || right instanceof NullLiteral) {
return null;
}
if (isRoutingParameter(left) || isRoutingParameter(right)) {
return null;
}
// Usually one is a method call like getState() or a variable like `state`
// and the other is the constant enum like `OrderState.PENDING` or `"PENDING"`
@@ -374,6 +392,10 @@ public class GenericEventDetector {
if (("equals".equals(methodName) || "equalsIgnoreCase".equals(methodName)) && !mi.arguments().isEmpty()) {
Expression receiver = mi.getExpression();
Expression arg = (Expression) mi.arguments().get(0);
if (isRoutingParameter(receiver) || isRoutingParameter(arg)) {
return null;
}
// If receiver is null (e.g., implicit this), fall back to arg
if (receiver == null) {
@@ -395,6 +417,13 @@ public class GenericEventDetector {
return null;
}
private boolean isRoutingParameter(Expression expr) {
if (expr instanceof SimpleName sn) {
return ROUTING_PARAMETER_NAMES.contains(sn.getIdentifier());
}
return false;
}
private String getSimpleNameString(Expression expr) {
if (expr instanceof QualifiedName qn) {
return qn.getName().getIdentifier();

View File

@@ -45,20 +45,44 @@ public class TransitionStateUtils {
}
public static Set<State> findAllStates(Collection<Transition> transitions, Set<String> initialStates, Set<String> endStates) {
Set<State> allStates = new java.util.HashSet<>();
java.util.Map<String, State> byFullIdentifier = new java.util.LinkedHashMap<>();
if (transitions != null) {
transitions.forEach(t -> {
if (t.getSourceStates() != null) allStates.addAll(t.getSourceStates());
if (t.getTargetStates() != null) allStates.addAll(t.getTargetStates());
mergeState(byFullIdentifier, t.getSourceStates());
mergeState(byFullIdentifier, t.getTargetStates());
});
}
if (initialStates != null) {
initialStates.forEach(s -> allStates.add(State.of(s)));
initialStates.forEach(label -> mergeState(byFullIdentifier, State.of(label)));
}
if (endStates != null) {
endStates.forEach(s -> allStates.add(State.of(s)));
endStates.forEach(label -> mergeState(byFullIdentifier, State.of(label)));
}
return allStates;
return new java.util.LinkedHashSet<>(byFullIdentifier.values());
}
private static void mergeState(java.util.Map<String, State> map, State state) {
if (state == null) {
return;
}
String key = stateKey(state);
map.putIfAbsent(key, state);
}
private static void mergeState(java.util.Map<String, State> map, java.util.Collection<State> states) {
if (states == null) {
return;
}
for (State state : states) {
mergeState(map, state);
}
}
private static String stateKey(State state) {
if (state.fullIdentifier() != null && !state.fullIdentifier().isBlank()) {
return state.fullIdentifier();
}
return state.rawName();
}
private static Stream<String> normalizeStates(Collection<State> states) {

View File

@@ -28,6 +28,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CodebaseContext {
private final Map<String, CompilationUnit> classes = new HashMap<>();
private final Map<String, org.eclipse.jdt.core.dom.RecordDeclaration> recordDeclarations = new HashMap<>();
private final Map<String, Path> classPaths = new HashMap<>();
private final Map<String, String> simpleNameToFqn = new HashMap<>();
private final Set<String> ambiguousSimpleNames = new HashSet<>();
@@ -317,6 +318,7 @@ public class CodebaseContext {
// Treat as a class since it is a type
classes.put(fqn, (CompilationUnit) rd.getRoot());
classPaths.put(fqn, javaFile);
recordDeclarations.put(fqn, rd);
if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName);
@@ -645,6 +647,21 @@ public class CodebaseContext {
return null;
}
public org.eclipse.jdt.core.dom.RecordDeclaration getRecordDeclaration(String fqn) {
if (fqn == null || fqn.isEmpty()) {
return null;
}
org.eclipse.jdt.core.dom.RecordDeclaration indexed = recordDeclarations.get(fqn);
if (indexed != null) {
return indexed;
}
AbstractTypeDeclaration abstractType = getAbstractTypeDeclaration(fqn);
if (abstractType instanceof org.eclipse.jdt.core.dom.RecordDeclaration recordDeclaration) {
return recordDeclaration;
}
return null;
}
public AbstractTypeDeclaration getAbstractTypeDeclaration(String name) {
// Try exact FQN match first
CompilationUnit cu = classes.get(name);

View File

@@ -745,11 +745,7 @@ public class JdtDataFlowModel implements DataFlowModel {
}
private org.eclipse.jdt.core.dom.RecordDeclaration findRecordDeclaration(String fqn) {
org.eclipse.jdt.core.dom.AbstractTypeDeclaration abstractType = context.getAbstractTypeDeclaration(fqn);
if (abstractType instanceof org.eclipse.jdt.core.dom.RecordDeclaration recordDeclaration) {
return recordDeclaration;
}
return null;
return context.getRecordDeclaration(fqn);
}
private void inlineAccessorSetter(

View File

@@ -1,11 +1,7 @@
package click.kamil.springstatemachineexporter.service;
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
import click.kamil.springstatemachineexporter.analysis.service.AnalysisResultFinalizer;
@@ -44,15 +40,7 @@ public class ExportService {
}
public ExportService(List<StateMachineExporter> exporters) {
this(exporters, new EnrichmentService(List.of(
new TriggerEnricher(),
new EntryPointEnricher(),
new PropertyEnricher(),
new CallChainEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.path.ForwardPathEstimationEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher()
)));
this(exporters, EnrichmentService.createDefault());
}
private static final List<String> TARGET_ANNOTATIONS = List.of("EnableStateMachineFactory", "EnableStateMachine");
@@ -195,6 +183,8 @@ public class ExportService {
log.info("JSON re-export: no source project found; using embedded machine types if present");
}
enrichmentService.relinkAfterPropertyResolution(result, context, null);
StateMachineTypeResolver.MachineTypes machineTypes =
click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory.resolveMachineTypes(
result.getName(),
@@ -295,9 +285,9 @@ public class ExportService {
.flows(flows)
.build();
enrichmentService.enrich(result, context, intelligence);
enrichmentService.enrichPreProperty(result, context, intelligence);
resolveProperties(result, activeProfiles);
enrichmentService.enrichPostProperty(result, context, intelligence);
AnalysisResultFinalizer.finalizeResult(result, context);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
@@ -337,9 +327,9 @@ public class ExportService {
.flows(flows)
.build();
enrichmentService.enrich(result, context, intelligence);
enrichmentService.enrichPreProperty(result, context, intelligence);
resolveProperties(result, activeProfiles);
enrichmentService.enrichPostProperty(result, context, intelligence);
AnalysisResultFinalizer.finalizeResult(result, context);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);