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:
@@ -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, """
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user