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

@@ -0,0 +1,133 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
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.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class EnricherPipelineRegressionTest {
@Test
void shouldCanonicalizeChainTriggerEventsBeforeLinker(@TempDir Path tempDir) throws IOException {
writeSampleMachine(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TriggerPoint rawTrigger = TriggerPoint.builder()
.className("com.example.web.OrderController")
.methodName("pay")
.event("OrderEvent.PAY")
.sourceState("OrderState.NEW")
.eventTypeFqn("OrderEvent")
.stateTypeFqn("OrderState")
.build();
CallChain rawChain = CallChain.builder()
.triggerPoint(rawTrigger)
.build();
CodebaseIntelligenceProvider intelligence = new CodebaseIntelligenceProvider() {
@Override
public List<TriggerPoint> findTriggerPoints() {
return List.of(rawTrigger);
}
@Override
public List<EntryPoint> findEntryPoints() {
return List.of();
}
@Override
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
return List.of(rawChain);
}
@Override
public Map<String, Map<String, String>> resolveProperties() {
return Map.of();
}
};
Transition transition = new Transition();
transition.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
transition.setSourceStates(List.of(State.of("OrderState.NEW", "com.example.order.OrderState.NEW")));
transition.setTargetStates(List.of(State.of("OrderState.PAID", "com.example.order.OrderState.PAID")));
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.transitions(List.of(transition))
.metadata(CodebaseMetadata.builder()
.entryPoints(List.of())
.triggers(List.of(rawTrigger))
.build())
.build();
new CallChainEnricher().enrich(result, context, intelligence);
new TriggerCanonicalizationEnricher().enrich(result, context, intelligence);
new TransitionLinkerEnricher().enrich(result, context, intelligence);
TriggerPoint chainTrigger = result.getMetadata().getCallChains().get(0).getTriggerPoint();
assertThat(chainTrigger.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(chainTrigger.getSourceState()).isEqualTo("com.example.order.OrderState.NEW");
assertThat(result.getMetadata().getTriggers().get(0).getEvent())
.isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.order.OrderEvent.PAY");
}
private static void writeSampleMachine(Path tempDir) throws IOException {
Path orderPkg = tempDir.resolve("com/example/order");
Path configPkg = tempDir.resolve("com/example/config");
Path webPkg = tempDir.resolve("com/example/web");
Files.createDirectories(orderPkg);
Files.createDirectories(configPkg);
Files.createDirectories(webPkg);
Files.writeString(orderPkg.resolve("OrderState.java"),
"""
package com.example.order;
public enum OrderState { NEW, PAID }
""");
Files.writeString(orderPkg.resolve("OrderEvent.java"),
"""
package com.example.order;
public enum OrderEvent { PAY }
""");
Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"),
"""
package com.example.config;
import com.example.order.OrderEvent;
import com.example.order.OrderState;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
public class OrderStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
}
""");
Files.writeString(webPkg.resolve("OrderController.java"),
"""
package com.example.web;
public class OrderController {
public void pay() {}
}
""");
}
}

View File

@@ -149,6 +149,46 @@ class MachineEnumCanonicalizerTest {
.isEqualTo("getType()");
}
@Test
void shouldSyncFnFormRawNameWhenFullIdentifierAlreadyCanonical(@TempDir Path tempDir) throws IOException {
writeSampleConfig(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
"com.example.config.OrderStateMachineConfiguration", context);
State mismatched = State.of("UNDEFINED", "com.example.order.OrderState.NEW");
State canonical = MachineEnumCanonicalizer.canonicalizeStates(Set.of(mismatched), types.stateTypeFqn())
.iterator().next();
assertThat(canonical.fullIdentifier()).isEqualTo("com.example.order.OrderState.NEW");
assertThat(canonical.rawName()).isEqualTo("OrderState.NEW");
}
@Test
void shouldDedupeStatesByCanonicalFullIdentifier(@TempDir Path tempDir) throws IOException {
writeSampleConfig(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
"com.example.config.OrderStateMachineConfiguration", context);
State shortForm = State.of("OrderState.NEW", "OrderState.NEW");
State packageForm = State.of("UNDEFINED", "com.example.order.OrderState.NEW");
Set<State> deduped = MachineEnumCanonicalizer.canonicalizeStates(
Set.of(shortForm, packageForm), types.stateTypeFqn());
assertThat(deduped).hasSize(1);
State only = deduped.iterator().next();
assertThat(only.fullIdentifier()).isEqualTo("com.example.order.OrderState.NEW");
assertThat(only.rawName()).isEqualTo("OrderState.NEW");
}
private static void writeSampleConfig(Path tempDir) throws IOException {
Path orderPkg = tempDir.resolve("com/example/order");
Path configPkg = tempDir.resolve("com/example/config");