Expand path bindings through boolean predicate helpers safely.

Recognize single-literal equals predicates in if-chains, inline them (including negated else-if constraints) for branch compatibility, skip traversing into predicate helpers during alias walks, and fail closed when a path variable cannot be proven.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 18:18:14 +02:00
parent fd21cfa440
commit 610c83a7ec
2 changed files with 594 additions and 4 deletions

View File

@@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -1091,4 +1092,346 @@ class EntryPointBindingExpanderTest {
assertThat(variants.stream().map(v -> v.get("machineType")).distinct())
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
}
@Test
void shouldExtractMachineTypeCasesFromBooleanPredicateIfChain(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
class StateMachineDispatcher {
void dispatch(String machineType, String eventString) {
if (matchesOrder(machineType)) {
OrderEvent.valueOf(eventString.toUpperCase());
} else if (matchesDocument(machineType)) {
DocumentEvent.valueOf(eventString.toUpperCase());
}
}
boolean matchesOrder(String raw) {
return "ORDER".equalsIgnoreCase(raw.trim());
}
boolean matchesDocument(String raw) {
return "DOCUMENT".equalsIgnoreCase(raw.trim());
}
}
enum OrderEvent { PAY, SHIP }
enum DocumentEvent { SUBMIT, APPROVE }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
List<String> cases = EntryPointBindingExpander.extractStringSwitchCases(
"com.example.StateMachineDispatcher.dispatch", "machineType", context);
assertThat(cases).containsExactlyInAnyOrder("ORDER", "DOCUMENT");
}
@Test
void shouldInlineBooleanPredicateEdgeConstraints(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
class StateMachineDispatcher {
void dispatch(String machineType, String eventString) {
if (matchesOrder(machineType)) {
fireOrder(eventString);
} else if (matchesDocument(machineType)) {
fireDocument(eventString);
}
}
boolean matchesOrder(String raw) {
return "ORDER".equalsIgnoreCase(raw.trim());
}
boolean matchesDocument(String raw) {
return "DOCUMENT".equalsIgnoreCase(raw.trim());
}
void fireOrder(String eventString) {
OrderEvent.valueOf(eventString.toUpperCase());
}
void fireDocument(String eventString) {
DocumentEvent.valueOf(eventString.toUpperCase());
}
}
enum OrderEvent { PAY, SHIP }
enum DocumentEvent { SUBMIT, APPROVE }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
String caller = "com.example.StateMachineDispatcher.dispatch";
String orderConstraint = EntryPointBindingExpander.inlineBooleanPredicateConstraint(
"matchesOrder(machineType)", caller, context);
String documentConstraint = EntryPointBindingExpander.inlineBooleanPredicateConstraint(
"matchesDocument(machineType)", caller, context);
assertThat(orderConstraint).contains("ORDER");
assertThat(documentConstraint).contains("DOCUMENT");
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
orderConstraint, Map.of("machineType", "DOCUMENT"))).isFalse();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
documentConstraint, Map.of("machineType", "DOCUMENT"))).isTrue();
String elseIfConstraint = EntryPointBindingExpander.inlineBooleanPredicateConstraint(
"!(matchesOrder(machineType)) && matchesDocument(machineType)", caller, context);
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
elseIfConstraint, Map.of("machineType", "DOCUMENT"))).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
elseIfConstraint, Map.of("machineType", "ORDER"))).isFalse();
}
@Test
void shouldFilterDelegatedBranchesUsingBooleanPredicateConstraints(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class MachineController {
StateMachineDispatcher dispatcher;
public void transition(String machineType, String event) {
dispatcher.dispatch(machineType, event);
}
}
class StateMachineDispatcher {
void dispatch(String machineType, String eventString) {
if (matchesOrder(machineType)) {
fireOrder(eventString);
} else if (matchesDocument(machineType)) {
fireDocument(eventString);
}
}
boolean matchesOrder(String raw) {
return "ORDER".equalsIgnoreCase(raw.trim());
}
boolean matchesDocument(String raw) {
return "DOCUMENT".equalsIgnoreCase(raw.trim());
}
void fireOrder(String eventString) {
OrderEvent.valueOf(eventString.toUpperCase());
}
void fireDocument(String eventString) {
DocumentEvent.valueOf(eventString.toUpperCase());
}
}
enum OrderEvent { PAY, SHIP }
enum DocumentEvent { SUBMIT, APPROVE }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> graph = engine.buildCallGraph();
List<String> machineTypes = EntryPointBindingExpander.expandPathVariableBindings(
EntryPoint.builder()
.className("com.example.MachineController")
.methodName("transition")
.name("POST /api/machine/{machineType}/transition/{event}")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("machineType")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build(),
context,
graph).stream().map(v -> v.get("machineType")).toList();
assertThat(machineTypes).containsExactlyInAnyOrder("ORDER", "DOCUMENT");
}
@Test
void shouldExpandMachineTypeFromBooleanPredicateHelper(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class MachineController {
StateMachineDispatcher dispatcher;
public void transition(String machineType, String event) {
dispatcher.dispatch(machineType, event);
}
}
class StateMachineDispatcher {
void dispatch(String machineType, String eventString) {
if (matchesOrder(machineType)) {
fireOrder(eventString);
} else if (matchesDocument(machineType)) {
fireDocument(eventString);
}
}
boolean matchesOrder(String raw) {
return "ORDER".equalsIgnoreCase(raw.trim());
}
boolean matchesDocument(String raw) {
return "DOCUMENT".equalsIgnoreCase(raw.trim());
}
void fireOrder(String eventString) {
OrderEvent.valueOf(eventString.toUpperCase());
}
void fireDocument(String eventString) {
DocumentEvent.valueOf(eventString.toUpperCase());
}
}
enum OrderEvent { PAY, SHIP }
enum DocumentEvent { SUBMIT, APPROVE }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> graph = engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.MachineController")
.methodName("transition")
.name("POST /api/machine/{machineType}/transition/{event}")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("machineType")
.type("String")
.annotations(List.of("PathVariable"))
.build(),
EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph);
assertThat(variants.stream().map(v -> v.get("machineType")).distinct())
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
assertThat(variants.stream()
.filter(v -> "ORDER".equals(v.get("machineType")))
.map(v -> v.get("event"))
.toList())
.containsExactlyInAnyOrder("PAY", "SHIP");
}
@Test
void shouldExpandMachineTypeFromCrossClassBooleanPredicateHelper(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class MachineController {
StateMachineDispatcher dispatcher;
public void transition(String machineType, String event) {
dispatcher.dispatch(machineType, event);
}
}
class StateMachineDispatcher {
void dispatch(String machineType, String eventString) {
if (TypeMatcher.matchesOrder(machineType)) {
OrderEvent.valueOf(eventString.toUpperCase());
} else if (TypeMatcher.matchesDocument(machineType)) {
DocumentEvent.valueOf(eventString.toUpperCase());
}
}
}
class TypeMatcher {
static boolean matchesOrder(String raw) {
return "ORDER".equalsIgnoreCase(raw.trim());
}
static boolean matchesDocument(String raw) {
return "DOCUMENT".equalsIgnoreCase(raw.trim());
}
}
enum OrderEvent { PAY, SHIP }
enum DocumentEvent { SUBMIT, APPROVE }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> graph = engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.MachineController")
.methodName("transition")
.name("POST /api/machine/{machineType}/transition/{event}")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("machineType")
.type("String")
.annotations(List.of("PathVariable"))
.build(),
EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph);
assertThat(variants.stream().map(v -> v.get("machineType")).distinct())
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
}
@Test
void shouldNotExpandWhenBooleanPredicateMatchesMultipleLiterals(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class MachineController {
StateMachineDispatcher dispatcher;
public void transition(String machineType, String event) {
dispatcher.dispatch(machineType, event);
}
}
class StateMachineDispatcher {
void dispatch(String machineType, String eventString) {
if (matchesAny(machineType)) {
OrderEvent.valueOf(eventString.toUpperCase());
}
}
boolean matchesAny(String raw) {
if ("ORDER".equalsIgnoreCase(raw)) {
return true;
}
return "DOCUMENT".equalsIgnoreCase(raw);
}
}
enum OrderEvent { PAY, SHIP }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> graph = engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.MachineController")
.methodName("transition")
.name("POST /api/machine/{machineType}/transition/{event}")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("machineType")
.type("String")
.annotations(List.of("PathVariable"))
.build(),
EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph);
assertThat(variants).isEmpty();
}
}