Harden call-graph enum resolution and JSON re-export polymorphic repair.

Replace eager enum widen with symbolic placeholders and deferred narrowing, add switch-based predicate evaluation and entry-point synthesis for JSON round-trips.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 16:54:14 +02:00
parent 83008a6898
commit e6effa3dcd
13 changed files with 1066 additions and 69 deletions

View File

@@ -51,6 +51,37 @@ class EnumMemberPredicatePolymorphicInferenceTest {
assertThat(filtered).containsExactly("com.example.order.OrderCommand.PAY");
}
@Test
void shouldFilterEnumConstantsBySwitchBasedPredicate(@TempDir Path tempDir) throws Exception {
CodebaseContext context = scanEnumProject(tempDir, """
package com.example.order;
public enum OrderCommand {
PAY,
SHIP,
META;
public boolean isEvent() {
return switch (this) {
case PAY, SHIP -> true;
default -> false;
};
}
}
""");
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
List.of(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP",
"com.example.order.OrderCommand.META"),
"eventType.isEvent()",
"com.example.order.OrderCommand",
context);
assertThat(filtered).containsExactly(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP");
}
@Test
void shouldPreferTransitionEventsOverFullEnumOnDynamicTrigger(@TempDir Path tempDir) throws Exception {
CodebaseContext context = scanEnumProject(tempDir, """
@@ -380,6 +411,30 @@ class EnumMemberPredicatePolymorphicInferenceTest {
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2);
}
@Test
void shouldEvaluateDirectFieldReturnInPredicateMethod(@TempDir Path tempDir) throws Exception {
CodebaseContext context = scanEnumProject(tempDir, """
package com.example.order;
public enum OrderCommand {
PAY(true),
META(false);
private final boolean event;
OrderCommand(boolean event) { this.event = event; }
public boolean isEvent() { return event; }
}
""");
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
List.of(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.META"),
"eventType.isEvent()",
"com.example.order.OrderCommand",
context);
assertThat(filtered).containsExactly("com.example.order.OrderCommand.PAY");
}
@Test
void shouldNotMatchNonEventConstantsWhenPolyListWasOverBroad(@TempDir Path tempDir) throws Exception {
CodebaseContext context = scanEnumProject(tempDir, """

View File

@@ -474,6 +474,54 @@ class CentralDispatcherResolutionTest {
"OrderEvent.SHIP");
}
@Test
void staticRoutingHelperShouldForwardLiteralsToCentralDispatcher(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CommandGateway gateway;
public void pay() { gateway.routeAndPay(); }
public void ship() { gateway.routeAndShip(); }
}
class CommandGateway {
CentralDispatcher dispatcher;
void routeAndPay() { dispatcher.dispatch("ORDER", OrderRoutingHelper.payAction()); }
void routeAndShip() { dispatcher.dispatch("ORDER", OrderRoutingHelper.shipAction()); }
}
class OrderRoutingHelper {
static String payAction() { return "PAY"; }
static String shipAction() { return "SHIP"; }
}
class CentralDispatcher {
void dispatch(String domain, String action) {
StateMachine sm = new StateMachine();
sm.sendEvent(OrderEvent.valueOf(action));
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain payChain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
CallChain shipChain = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE);
assertPolyEvents(payChain, "OrderEvent.PAY");
assertPolyEvents(shipChain, "OrderEvent.SHIP");
assertLinkedEvent(
linkEndpointToTransition(payChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(shipChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.SHIP");
}
/**
* Tier 1 — DTO field set via constructor in controller, read via getter in gateway.
*

View File

@@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.service;
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.exporter.ExportOptions;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
@@ -145,6 +146,60 @@ class JsonRoundTripCanonicalizationTest {
.isEqualTo("com.example.order.OrderState.NEW");
}
@Test
void shouldRefreshCallChainsWhenJsonLacksTopLevelEntryPoints(@TempDir Path tempDir) throws Exception {
writeSampleProject(tempDir);
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.className("com.example.web.OrderController")
.methodName("pay")
.sourceFile("OrderController.java")
.build())
.triggerPoint(TriggerPoint.builder()
.event("event")
.className("com.example.web.OrderStateMachine")
.methodName("sendEvent")
.sourceFile("OrderStateMachine.java")
.build())
.build();
AnalysisResult shortForm = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(shortTransition(
"OrderEvent.PAY",
"OrderState.NEW",
"OrderState.PAID")))
.metadata(CodebaseMetadata.builder()
.callChains(List.of(chain))
.build())
.build();
Path jsonFile = tempDir.resolve("machine.json");
Files.writeString(jsonFile, new JsonExporter().export(shortForm, ExportOptions.builder().build()));
Path outputDir = tempDir.resolve("out");
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runJsonExporter(jsonFile, outputDir, List.of("json"));
AnalysisResult roundTripped = new JsonImportService().importAnalysisResult(
outputDir.resolve("com.example.config.OrderStateMachineConfiguration")
.resolve("com.example.config.OrderStateMachineConfiguration.json"));
assertThat(roundTripped.getMetadata().getCallChains()).hasSize(1);
assertThat(roundTripped.getMetadata().getCallChains().get(0).getEntryPoint()).isNotNull();
assertThat(roundTripped.getMetadata().getCallChains().get(0).getEntryPoint().getClassName())
.isEqualTo("com.example.web.OrderController");
assertThat(roundTripped.getMetadata().getCallChains().get(0).getEntryPoint().getMethodName())
.isEqualTo("pay");
assertThat(roundTripped.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
assertThat(roundTripped.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.order.OrderEvent.PAY");
}
@Test
void shouldDedupeStatesByFullIdentifierAfterPropertyResolution(@TempDir Path tempDir) throws Exception {
writeSampleProject(tempDir);
@@ -189,8 +244,10 @@ class JsonRoundTripCanonicalizationTest {
Path javaRoot = projectRoot.resolve("src/main/java");
Path orderPkg = javaRoot.resolve("com/example/order");
Path configPkg = javaRoot.resolve("com/example/config");
Path webPkg = javaRoot.resolve("com/example/web");
Files.createDirectories(orderPkg);
Files.createDirectories(configPkg);
Files.createDirectories(webPkg);
Files.writeString(projectRoot.resolve("build.gradle"), "plugins { id 'java' }");
Files.writeString(orderPkg.resolve("OrderState.java"),
"package com.example.order; public enum OrderState { NEW, PAID }");
@@ -206,5 +263,25 @@ class JsonRoundTripCanonicalizationTest {
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
}
""");
Files.writeString(webPkg.resolve("OrderController.java"),
"""
package com.example.web;
import com.example.order.OrderEvent;
public class OrderController {
private final OrderStateMachine stateMachine = new OrderStateMachine();
public void pay() {
stateMachine.sendEvent(OrderEvent.PAY);
}
}
""");
Files.writeString(webPkg.resolve("OrderStateMachine.java"),
"""
package com.example.web;
import com.example.order.OrderEvent;
public class OrderStateMachine {
public void sendEvent(OrderEvent event) {
}
}
""");
}
}

View File

@@ -286,6 +286,42 @@ class AnalysisCanonicalFormValidatorTest {
.contains("metadata.callChains[0].matchedTransitions");
}
@Test
void shouldFailWhenExternalAmbiguousTriggerHasResolvablePolymorphicEventsButNoMatchedTransitions() {
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("eventType")
.className("com.example.web.OrderController")
.methodName("dispatch")
.sourceFile("OrderController.java")
.polymorphicEvents(List.of("com.example.order.OrderEvent.PAY"))
.external(true)
.ambiguous(true)
.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()