Harden enum identifier validation for rawName sync, polymorphic events, and matched transitions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 12:22:39 +02:00
parent 393c998b66
commit 7ffd157105
5 changed files with 489 additions and 18 deletions

View File

@@ -211,6 +211,28 @@ class MachineEnumCanonicalizerTest {
"com.example.order.OrderEvent.PAY");
}
@Test
void shouldValidateRawNameConsistencyForCanonicalHelpers() {
assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier(
"OrderEvent.PAY", "com.example.order.OrderEvent.PAY", "com.example.order.OrderEvent"))
.isTrue();
assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier(
"UNDEFINED", "com.example.order.OrderEvent.PAY", "com.example.order.OrderEvent"))
.isFalse();
assertThat(MachineEnumCanonicalizer.isValidPolymorphicEventForm("com.example.order.OrderEvent.PAY"))
.isTrue();
assertThat(MachineEnumCanonicalizer.isValidPolymorphicEventForm("<SYMBOLIC: OrderEvent.*>"))
.isTrue();
assertThat(MachineEnumCanonicalizer.isValidPolymorphicEventForm("OrderEvent.valueOf(event)"))
.isFalse();
assertThat(MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(
List.of("com.example.order.OrderEvent.PAY")))
.isTrue();
assertThat(MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(
List.of("<SYMBOLIC: OrderEvent.*>")))
.isFalse();
}
@Test
void shouldDedupeStatesByCanonicalFullIdentifier(@TempDir Path tempDir) throws IOException {
writeSampleConfig(tempDir);

View File

@@ -82,9 +82,83 @@ class ExportPipelineIdentifierConsistencyTest {
.resolve("com.example.config.OrderStateMachineConfiguration.json"));
assertNoMixedEnumIdentifiers(exported);
assertRawNameSync(exported);
assertThat(exported.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNotEmpty();
}
static void assertRawNameSync(AnalysisResult result) {
String stateTypeFqn = result.getStateTypeFqn();
String eventTypeFqn = result.getEventTypeFqn();
if (stateTypeFqn == null || !stateTypeFqn.contains(".")
|| MachineEnumCanonicalizer.isStringOrPrimitiveType(stateTypeFqn)) {
return;
}
if (result.getStates() != null) {
for (State state : result.getStates()) {
assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier(
state.rawName(), state.fullIdentifier(), stateTypeFqn))
.as("state rawName sync for %s", state.fullIdentifier())
.isTrue();
}
}
if (result.getTransitions() != null) {
for (Transition transition : result.getTransitions()) {
if (transition.getEvent() != null) {
assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier(
transition.getEvent().rawName(),
transition.getEvent().fullIdentifier(),
eventTypeFqn))
.as("event rawName sync for %s", transition.getEvent().fullIdentifier())
.isTrue();
}
assertStateListRawSync(transition.getSourceStates(), stateTypeFqn);
assertStateListRawSync(transition.getTargetStates(), stateTypeFqn);
}
}
}
private static void assertStateListRawSync(List<State> states, String stateTypeFqn) {
if (states == null) {
return;
}
for (State state : states) {
assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier(
state.rawName(), state.fullIdentifier(), stateTypeFqn))
.as("state rawName sync for %s", state.fullIdentifier())
.isTrue();
}
}
private static void assertPolymorphicEventForms(AnalysisResult result) {
if (result.getMetadata() == null) {
return;
}
collectTriggers(result).forEach(trigger -> {
if (trigger.getPolymorphicEvents() != null) {
for (String poly : trigger.getPolymorphicEvents()) {
assertThat(MachineEnumCanonicalizer.isValidPolymorphicEventForm(poly))
.as("polymorphic event form: %s", poly)
.isTrue();
}
}
});
}
private static List<TriggerPoint> collectTriggers(AnalysisResult result) {
List<TriggerPoint> triggers = new java.util.ArrayList<>();
if (result.getMetadata().getTriggers() != null) {
triggers.addAll(result.getMetadata().getTriggers());
}
if (result.getMetadata().getCallChains() != null) {
for (CallChain chain : result.getMetadata().getCallChains()) {
if (chain.getTriggerPoint() != null) {
triggers.add(chain.getTriggerPoint());
}
}
}
return triggers;
}
@Test
void astExportShouldNotMixShortAndPackageCanonicalStateIdentifiers(@TempDir Path tempDir) throws Exception {
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
@@ -105,7 +179,9 @@ class ExportPipelineIdentifierConsistencyTest {
}
AnalysisResult result = new JsonImportService().importAnalysisResult(json);
assertNoMixedEnumIdentifiers(result);
assertRawNameSync(result);
assertTriggerEventForms(result);
assertPolymorphicEventForms(result);
}
}
}

View File

@@ -171,6 +171,151 @@ class AnalysisCanonicalFormValidatorTest {
.noneMatch(v -> v.path().contains(".event"));
}
@Test
void shouldFailWhenEventRawNameDoesNotMatchFnForm() {
Transition transition = new Transition();
transition.setEvent(Event.of("UNDEFINED", "com.example.order.OrderEvent.PAY"));
transition.setSourceStates(List.of(
State.of("OrderState.NEW", "com.example.order.OrderState.NEW")));
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(transition))
.build();
List<AnalysisCanonicalFormValidator.Violation> violations =
AnalysisCanonicalFormValidator.validateWithMachineTypes(
result,
new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState", "com.example.order.OrderEvent"));
assertThat(violations)
.extracting(AnalysisCanonicalFormValidator.Violation::path)
.contains("transitions[0].event.rawName");
}
@Test
void shouldFailWhenPolymorphicEventIsDynamicExpression() {
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.metadata(CodebaseMetadata.builder()
.triggers(List.of(TriggerPoint.builder()
.event("OrderEvent.valueOf(eventString)")
.className("com.example.web.OrderController")
.methodName("dispatch")
.sourceFile("OrderController.java")
.polymorphicEvents(List.of("OrderEvent.valueOf(eventString)"))
.build()))
.build())
.build();
List<AnalysisCanonicalFormValidator.Violation> violations =
AnalysisCanonicalFormValidator.validateWithMachineTypes(
result,
new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState", "com.example.order.OrderEvent"));
assertThat(violations)
.extracting(AnalysisCanonicalFormValidator.Violation::path)
.contains("metadata.triggers[0].polymorphicEvents[0]");
}
@Test
void shouldFailWhenSourceStateHasCorruptedPackageValueOfForm() {
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.metadata(CodebaseMetadata.builder()
.triggers(List.of(TriggerPoint.builder()
.event("com.example.order.OrderEvent.PAY")
.sourceState("com.example.order.OrderState.valueOf(stateString)")
.className("com.example.web.OrderController")
.methodName("dispatch")
.sourceFile("OrderController.java")
.build()))
.build())
.build();
List<AnalysisCanonicalFormValidator.Violation> violations =
AnalysisCanonicalFormValidator.validateWithMachineTypes(
result,
new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState", "com.example.order.OrderEvent"));
assertThat(violations)
.extracting(AnalysisCanonicalFormValidator.Violation::path)
.contains("metadata.triggers[0].sourceState");
}
@Test
void shouldFailWhenDynamicTriggerHasConcretePolymorphicEventsButNoMatchedTransitions() {
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(transition(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderState.NEW",
"com.example.order.OrderState.PAID")))
.metadata(CodebaseMetadata.builder()
.callChains(List.of(CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("event.getType()")
.className("com.example.web.OrderController")
.methodName("dispatch")
.sourceFile("OrderController.java")
.polymorphicEvents(List.of("com.example.order.OrderEvent.PAY"))
.build())
.build()))
.build())
.build();
List<AnalysisCanonicalFormValidator.Violation> violations =
AnalysisCanonicalFormValidator.validateWithMachineTypes(
result,
new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState", "com.example.order.OrderEvent"));
assertThat(violations)
.extracting(AnalysisCanonicalFormValidator.Violation::path)
.contains("metadata.callChains[0].matchedTransitions");
}
@Test
void shouldAllowSymbolicPolymorphicEventsWithoutMatchedTransitions() {
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(transition(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderState.NEW",
"com.example.order.OrderState.PAID")))
.metadata(CodebaseMetadata.builder()
.callChains(List.of(CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("event.getType()")
.className("com.example.web.OrderController")
.methodName("dispatch")
.sourceFile("OrderController.java")
.polymorphicEvents(List.of("<SYMBOLIC: OrderEvent.*>"))
.build())
.build()))
.build())
.build();
assertThat(AnalysisCanonicalFormValidator.validateWithMachineTypes(
result,
new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState", "com.example.order.OrderEvent")))
.noneMatch(v -> v.path().contains("matchedTransitions"));
}
@Test
void shouldSkipValidationForStringStateMachines(@TempDir Path tempDir) throws IOException {
Path configPkg = tempDir.resolve("com/example/config");
@@ -195,13 +340,25 @@ class AnalysisCanonicalFormValidatorTest {
}
private static Transition transition(String eventFqn, String sourceFqn, String targetFqn) {
String eventRaw = fnForm(eventFqn);
String sourceRaw = fnForm(sourceFqn);
String targetRaw = fnForm(targetFqn);
Transition transition = new Transition();
transition.setEvent(Event.of(eventFqn, eventFqn));
transition.setSourceStates(List.of(State.of(sourceFqn, sourceFqn)));
transition.setTargetStates(List.of(State.of(targetFqn, targetFqn)));
transition.setEvent(Event.of(eventRaw, eventFqn));
transition.setSourceStates(List.of(State.of(sourceRaw, sourceFqn)));
transition.setTargetStates(List.of(State.of(targetRaw, targetFqn)));
return transition;
}
private static String fnForm(String identifier) {
if (identifier == null || !identifier.contains(".")) {
return identifier;
}
int lastDot = identifier.lastIndexOf('.');
int prevDot = identifier.lastIndexOf('.', lastDot - 1);
return prevDot > 0 ? identifier.substring(prevDot + 1) : identifier;
}
private static void writeSampleConfig(Path tempDir) throws IOException {
Path orderPkg = tempDir.resolve("com/example/order");
Path configPkg = tempDir.resolve("com/example/config");