Fix HTML export linking by restoring trigger canonicalization and state deduplication.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 10:46:38 +02:00
parent 66aaa9563f
commit 8d954804b2
9 changed files with 254 additions and 33 deletions

View File

@@ -4,12 +4,14 @@ 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.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
public class CallChainEnricher implements AnalysisEnricher {
@@ -18,15 +20,27 @@ public class CallChainEnricher implements AnalysisEnricher {
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with call chains", result.getName());
List<TriggerPoint> scopedTriggers = result.getMetadata() != null
&& result.getMetadata().getTriggers() != null
? result.getMetadata().getTriggers()
: Collections.emptyList();
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
List<TriggerPoint> canonicalTriggers = intelligence.findTriggerPoints().stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
.collect(Collectors.toList());
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
canonicalTriggers, result.getName(), context);
List<CallChain> chains = intelligence.findCallChains(
result.getMetadata().getEntryPoints(),
scopedTriggers
);
chains = chains.stream()
.map(chain -> chain.getTriggerPoint() == null
? chain
: chain.toBuilder()
.triggerPoint(MachineEnumCanonicalizer.canonicalizeTriggerPoint(
chain.getTriggerPoint(), machineTypes))
.build())
.collect(Collectors.toList());
chains = MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context);
result.addMetadata(CodebaseMetadata.builder()

View File

@@ -11,6 +11,7 @@ import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.stream.Collectors;
/**
* Canonicalizes trigger-side enum identifiers ({@code event}, {@code polymorphicEvents},
@@ -21,14 +22,20 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || context == null) {
if (result.getMetadata() == null) {
return;
}
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
StateMachineTypeResolver.MachineTypes machineTypes = resolveMachineTypes(result, context);
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) {
return;
}
List<TriggerPoint> triggers = result.getMetadata().getTriggers();
List<TriggerPoint> canonicalTriggers = triggers == null ? null : triggers.stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
.collect(Collectors.toList());
List<CallChain> callChains = result.getMetadata().getCallChains();
List<CallChain> canonicalChains = callChains == null ? null : callChains.stream()
.map(chain -> {
@@ -40,19 +47,33 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
chain.getTriggerPoint(), machineTypes))
.build();
})
.toList();
.collect(Collectors.toList());
if (canonicalChains == null) {
if (canonicalTriggers == null && canonicalChains == null) {
return;
}
log.debug("Canonicalized triggers for {}", result.getName());
result.setMetadata(CodebaseMetadata.builder()
.triggers(triggers)
.triggers(canonicalTriggers != null ? canonicalTriggers : triggers)
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(canonicalChains != null ? canonicalChains : callChains)
.properties(result.getMetadata().getProperties())
.build());
}
private static StateMachineTypeResolver.MachineTypes resolveMachineTypes(
AnalysisResult result, CodebaseContext context) {
StateMachineTypeResolver.MachineTypes resolved =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
if (resolved.stateTypeFqn() != null || resolved.eventTypeFqn() != null) {
return resolved;
}
if (result.getStateTypeFqn() != null || result.getEventTypeFqn() != null) {
return new StateMachineTypeResolver.MachineTypes(
result.getStateTypeFqn(), result.getEventTypeFqn());
}
return StateMachineTypeResolver.MachineTypes.empty();
}
}

View File

@@ -6,6 +6,7 @@ import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@@ -55,9 +56,12 @@ public final class MachineEnumCanonicalizer {
if (states == null || states.isEmpty()) {
return states;
}
return states.stream()
.map(state -> canonicalizeState(state, stateTypeFqn))
.collect(Collectors.toCollection(LinkedHashSet::new));
LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>();
for (State state : states) {
State canonical = canonicalizeState(state, stateTypeFqn);
byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical);
}
return new LinkedHashSet<>(byFullIdentifier.values());
}
public static TriggerPoint canonicalizeTriggerPoint(
@@ -166,11 +170,12 @@ public final class MachineEnumCanonicalizer {
if (states == null) {
return null;
}
List<State> canonical = new ArrayList<>(states.size());
LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>();
for (State state : states) {
canonical.add(canonicalizeState(state, stateTypeFqn));
State canonical = canonicalizeState(state, stateTypeFqn);
byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical);
}
return canonical;
return new ArrayList<>(byFullIdentifier.values());
}
static Event canonicalizeEvent(Event event, String enumTypeFqn) {
@@ -194,21 +199,21 @@ public final class MachineEnumCanonicalizer {
if (state == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return state;
}
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;
}
String raw;
if (isStringOrPrimitiveType(enumTypeFqn) && canonical.startsWith(enumTypeFqn + ".")) {
String constant = canonical.substring(enumTypeFqn.length() + 1);
raw = "\"" + constant + "\"";
} else {
String fnRaw = toFnForm(canonical, enumTypeFqn);
if (fnRaw != null) {
raw = fnRaw;
}
raw = fnRaw != null ? fnRaw : resolvePlaceholder(state.rawName());
}
if (canonical.equals(state.fullIdentifier()) && raw.equals(state.rawName())) {
return state;
}
return State.of(raw, canonical);
}

View File

@@ -18,15 +18,19 @@ public final class JsonExportContextFactory {
public static Path findProjectRoot(Path start) {
Path current = start != null ? start.toAbsolutePath().normalize() : null;
Path settingsRoot = null;
Path buildRoot = 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;
if (settingsRoot == null && Files.exists(current.resolve("settings.gradle"))) {
settingsRoot = current;
}
if (buildRoot == null && (Files.exists(current.resolve("pom.xml"))
|| Files.exists(current.resolve("build.gradle")))) {
buildRoot = current;
}
current = current.getParent();
}
return null;
return settingsRoot != null ? settingsRoot : buildRoot;
}
public static CodebaseContext scanProjectRoot(Path projectRoot, List<String> activeProfiles) throws IOException {

View File

@@ -177,8 +177,12 @@ public class ExportService {
}
if (sourceRoot != null) {
log.info("JSON re-export: scanning source project at {}", sourceRoot);
context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
.scanProjectRoot(sourceRoot, activeProfiles);
try {
context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
.scanProjectRoot(sourceRoot, activeProfiles);
} catch (RuntimeException ex) {
log.warn("JSON re-export: source scan failed; using embedded machine types if present", ex);
}
} else {
log.info("JSON re-export: no source project found; using embedded machine types if present");
}