Defer polymorphic qualification to linker with context

Stop rewriting polymorphic events in TriggerPoint construction without ambiguity context; canonicalize labels at link time while preserving type FQNs for shared-infrastructure routing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 06:09:00 +02:00
parent bd9136a45c
commit 5c0d9c466d
5 changed files with 64 additions and 7 deletions

View File

@@ -59,6 +59,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
tp, machineTypes, context, stateMachineTransitions, true);
tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, machineTypes, context);
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {

View File

@@ -7,6 +7,7 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
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.model.Transition;
import lombok.Builder;
@@ -139,6 +140,8 @@ public class AnalysisResult {
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream()
.map(value -> resolver.resolveValue(value, properties))
.map(value -> MachineEnumCanonicalizer.qualifyEventIdentifier(
value, trigger.getEventTypeFqn()))
.collect(java.util.stream.Collectors.toList());
return trigger.toBuilder()
.event(trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null)

View File

@@ -55,13 +55,7 @@ public class TriggerPoint {
this.stateTypeFqn = stateTypeFqn;
this.eventTypeFqn = eventTypeFqn;
this.event = MachineEnumCanonicalizer.qualifyEventIdentifier(event, eventTypeFqn);
if (polymorphicEvents != null) {
this.polymorphicEvents = polymorphicEvents.stream()
.map(pe -> MachineEnumCanonicalizer.qualifyEventIdentifier(pe, eventTypeFqn))
.collect(java.util.stream.Collectors.toList());
} else {
this.polymorphicEvents = null;
}
this.polymorphicEvents = polymorphicEvents;
this.external = external;
this.constraint = constraint;
this.ambiguous = ambiguous;

View File

@@ -100,6 +100,37 @@ public final class MachineEnumCanonicalizer {
.build();
}
/**
* Canonicalizes trigger labels for linking using machine types, but preserves the trigger's own
* {@code eventTypeFqn}/{@code stateTypeFqn} so routing evidence (e.g. shared-infrastructure
* detection) is not overwritten before machine affinity is resolved.
*/
public static TriggerPoint canonicalizeTriggerLabelsForLinking(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
if (trigger == null) {
return null;
}
String eventTypeFqnForLabels = preferFullTypeFqn(
trigger.getEventTypeFqn(),
machineTypes != null ? machineTypes.eventTypeFqn() : null);
String stateTypeFqnForLabels = preferFullTypeFqn(
trigger.getStateTypeFqn(),
machineTypes != null ? machineTypes.stateTypeFqn() : null);
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream()
.map(event -> canonicalizeLabel(event, eventTypeFqnForLabels, context))
.collect(Collectors.toList());
return trigger.toBuilder()
.event(canonicalizeLabel(trigger.getEvent(), eventTypeFqnForLabels, context))
.sourceState(canonicalizeLabel(trigger.getSourceState(), stateTypeFqnForLabels, context))
.polymorphicEvents(polymorphicEvents)
.build();
}
/**
* Expands {@code <SYMBOLIC: EnumType.*>} placeholders into concrete package-canonical enum
* constants for the owning machine's event type. Non-matching symbolic entries are dropped.

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -64,4 +65,31 @@ class MachineEnumCanonicalizerCrossPackageTest {
"a.OrderEvent",
context)).isEmpty();
}
@Test
void shouldCanonicalizeLabelsForLinkingWithoutOverwritingTypeFqns(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("com/example/order"));
Files.writeString(tempDir.resolve("com/example/order/OrderEvent.java"),
"package com.example.order; public enum OrderEvent { PAY, SHIP }");
Files.writeString(tempDir.resolve("App.java"), "package com.example; class App {}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.valueOf(action)")
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
.build();
StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState", "com.example.order.OrderEvent");
TriggerPoint linked = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(
trigger, types, context);
assertThat(linked.getEventTypeFqn()).isNull();
assertThat(linked.getStateTypeFqn()).isNull();
assertThat(linked.getPolymorphicEvents()).containsExactly(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderEvent.SHIP");
}
}