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