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