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

@@ -65,6 +65,9 @@ public class HtmlExporterCommand implements Callable<Integer> {
@Option(names = {"--include-generated-sources"}, description = "Scan existing Maven target/ and Gradle build/ trees for pre-generated .java sources. Does not run Maven or Gradle.", negatable = true, defaultValue = "true", fallbackValue = "true")
private boolean includeGeneratedSources = true;
@Option(names = {"--inline-accessors"}, description = "Index trivial JavaBean/record accessors at scan time and inline them during call-chain and constant resolution.", negatable = true, defaultValue = "true", fallbackValue = "true")
private boolean inlineAccessors = true;
@Override
public Integer call() throws Exception {
var out = spec.commandLine().getOut();
@@ -100,7 +103,7 @@ public class HtmlExporterCommand implements Callable<Integer> {
exportService.runJsonExporter(jsonFile, outputDir, formats, profiles, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, inputDir);
} else {
boolean renderChoicesAsDiamonds = !noDiamonds;
exportService.runExporter(inputDir, outputDir, formats, renderChoicesAsDiamonds, profiles, flowsFile, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, includeGeneratedSources);
exportService.runExporter(inputDir, outputDir, formats, renderChoicesAsDiamonds, profiles, flowsFile, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, includeGeneratedSources, inlineAccessors);
}
System.clearProperty("html.hideMetadataPane");

View File

@@ -356,19 +356,35 @@
});
}
function isLifecycleChain(c) {
const ev = c.triggerPoint && c.triggerPoint.event;
return ev && ev.startsWith('[LIFECYCLE:');
}
function formatLifecycleLabel(event) {
const m = event && event.match(/^\[LIFECYCLE:(\w+)\]$/);
if (!m) return event;
const type = m[1].toUpperCase();
if (type === 'RESTORE') return 'Restore persisted state';
if (type === 'RESET') return 'Reset state machine';
return 'Lifecycle: ' + m[1].toLowerCase();
}
function chainsForEntryPoint(ep) {
return (metadata.metadata.callChains || []).filter(c =>
c.entryPoint.className === ep.className &&
c.entryPoint.methodName === ep.methodName &&
((c.matchedTransitions && c.matchedTransitions.length > 0) || isLifecycleChain(c))
);
}
function populateSidebar() {
const list = document.getElementById('ep-list');
if (!list) return;
const entryPoints = metadata.metadata.entryPoints || [];
entryPoints.forEach(ep => {
// Quality Filter: Hide if it doesn't trigger a machine event
const chains = (metadata.metadata.callChains || []).filter(c =>
c.entryPoint.className === ep.className &&
c.entryPoint.methodName === ep.methodName &&
c.matchedTransitions && c.matchedTransitions.length > 0
);
const chains = chainsForEntryPoint(ep);
if (chains.length === 0) return;
const card = document.createElement('div');
@@ -381,18 +397,24 @@
${sourceHint}
`;
// Add tags for matched events
let triggersHtml = '<div class="ep-transitions" style="margin-top: 8px;">Triggers: ';
const addedEvents = new Set();
chains.forEach(c => {
c.matchedTransitions.forEach(t => {
const evName = t.event || "";
if (!addedEvents.has(evName)) {
addedEvents.add(evName);
triggersHtml += `<span class="badge transition" style="font-size: 0.65rem; padding: 2px 6px;">${evName}</span> `;
}
let triggersHtml = '<div class="ep-transitions" style="margin-top: 8px;">';
const lifecycleChain = chains.find(isLifecycleChain);
if (lifecycleChain) {
const label = formatLifecycleLabel(lifecycleChain.triggerPoint.event);
triggersHtml += `<span class="badge lifecycle" style="font-size: 0.65rem; padding: 2px 6px; background:#ecfdf5; color:#047857; border:1px solid #a7f3d0;">${label}</span>`;
} else {
triggersHtml += 'Triggers: ';
const addedEvents = new Set();
chains.forEach(c => {
c.matchedTransitions.forEach(t => {
const evName = t.event || "";
if (!addedEvents.has(evName)) {
addedEvents.add(evName);
triggersHtml += `<span class="badge transition" style="font-size: 0.65rem; padding: 2px 6px;">${evName}</span> `;
}
});
});
});
}
triggersHtml += '</div>';
card.innerHTML += triggersHtml;
@@ -427,20 +449,18 @@
function highlightEntryPoint(ep) {
clearHighlights();
const chains = metadata.metadata.callChains || [];
const chains = chainsForEntryPoint(ep);
const seen = new Set();
let steps = [];
chains.forEach(c => {
if (c.entryPoint.className === ep.className && c.entryPoint.methodName === ep.methodName) {
if (c.matchedTransitions && c.matchedTransitions.length > 0) {
c.matchedTransitions.forEach(t => {
const key = buildLinkKey(t.sourceState, t.event);
if (!key || seen.has(key)) return;
seen.add(key);
steps.push({ event: t.event, source: t.sourceState, ambiguous: c.triggerPoint && c.triggerPoint.ambiguous });
});
}
if (c.matchedTransitions && c.matchedTransitions.length > 0) {
c.matchedTransitions.forEach(t => {
const key = buildLinkKey(t.sourceState, t.event);
if (!key || seen.has(key)) return;
seen.add(key);
steps.push({ event: t.event, source: t.sourceState, ambiguous: c.triggerPoint && c.triggerPoint.ambiguous });
});
}
});
@@ -667,11 +687,14 @@
if (chains && chains.length > 0) {
html += `<div style="font-size:0.7rem; font-weight:800; color:#94a3b8; text-transform:uppercase; margin-bottom:10px; border-top:1px solid #eee; padding-top:10px">Triggered by</div>`;
chains.forEach(c => {
const triggerEvent = c.triggerPoint && c.triggerPoint.event;
const lifecycleLabel = triggerEvent && triggerEvent.startsWith('[LIFECYCLE:')
? formatLifecycleLabel(triggerEvent) : null;
const triggerInfo = c.triggerPoint.sourceFile ? ` <span style="color:#94a3b8; font-weight:400; font-size:0.6rem;">(${c.triggerPoint.sourceFile}:${c.triggerPoint.lineNumber})</span>` : '';
const ambiguousLabel = c.triggerPoint.ambiguous ? `<div style="display:inline-block; margin-top:4px; padding:2px 6px; background:#fff7ed; color:#ea580c; border:1px solid #fed7aa; border-radius:4px; font-size:0.6rem; font-weight:700;">⚠️ Ambiguous Resolution (Over-approximated)</div>` : '';
html += `<div style="margin-bottom:20px">
<div style="font-weight:700; font-size:0.85rem; color:var(--text);">${c.entryPoint.name}</div>
<div style="font-size:0.65rem; color:#64748b; font-family:monospace; margin-top:2px;">Trigger: ${c.triggerPoint.methodName}${triggerInfo}</div>
<div style="font-size:0.65rem; color:#64748b; font-family:monospace; margin-top:2px;">${lifecycleLabel ? 'Lifecycle: ' + lifecycleLabel : 'Trigger: ' + c.triggerPoint.methodName}${triggerInfo}</div>
${ambiguousLabel}
${renderParameters(c.entryPoint.parameters)}
<div style="margin-left:10px; border-left:2px solid #e2e8f0; padding-left:12px; margin-top:10px">

View File

@@ -0,0 +1,38 @@
package click.kamil.springstatemachineexporter.html;
import click.kamil.springstatemachineexporter.html.exporter.HtmlExporter;
import click.kamil.springstatemachineexporter.service.ExportService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ExtendedStateMachineLifecycleHtmlTest {
private static final Path EXTENDED_GOLDEN_JSON = Path.of(
"../state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json")
.toAbsolutePath()
.normalize();
@Test
void shouldShowLifecycleRestoreEndpointsInSidebar(@TempDir Path tempDir) throws Exception {
assertThat(EXTENDED_GOLDEN_JSON).exists();
ExportService exportService = new ExportService(List.of(new HtmlExporter()));
exportService.runJsonExporter(EXTENDED_GOLDEN_JSON, tempDir, List.of("html"), List.of());
Path machineDir = tempDir.resolve("click.kamil.examples.statemachine.extended.config.ExtendedStateMachineConfig");
Path htmlFile = machineDir.resolve("click.kamil.examples.statemachine.extended.config.ExtendedStateMachineConfig.html");
assertThat(htmlFile).exists();
String html = Files.readString(htmlFile);
assertThat(html).contains("function formatLifecycleLabel");
assertThat(html).contains("POST /api/orders/resume");
assertThat(html).contains("Restore persisted state");
assertThat(html).contains("[LIFECYCLE:RESTORE]");
}
}