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