Expand path bindings for if-equals on helper-wrapped parameters.

Use balanced-paren receiver-equals substitution so compound branch constraints stay correct while supporting forms like "ORDER".equalsIgnoreCase(normalizeType(machineType)).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 18:03:40 +02:00
parent 2d478d5779
commit 8d7e04cc33
5 changed files with 262 additions and 15 deletions

View File

@@ -127,12 +127,12 @@ public final class BooleanConstraintEvaluator {
if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) {
cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
}
String literalPattern = Pattern.quote(cleanValue);
String receiverEquals = "(?i)[\"']?" + literalPattern + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*"
+ Pattern.quote(varName) + "\\s*\\)";
String argumentEquals = "(?i)" + Pattern.quote(varName) + "\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
String varToken = Pattern.quote(varName);
String argumentEquals = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
+ literalPattern + "[\"']?\\s*\\)";
expr = expr.replaceAll(receiverEquals, "true");
expr = replaceReceiverEqualsForVariable(expr, varName, cleanValue, "true");
expr = expr.replaceAll(argumentEquals, "true");
Set<String> allLiterals = extractStringLiterals(expr);
@@ -141,16 +141,58 @@ public final class BooleanConstraintEvaluator {
continue;
}
String otherLiteral = Pattern.quote(literal);
String otherReceiver = "(?i)[\"']?" + otherLiteral + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*"
+ Pattern.quote(varName) + "\\s*\\)";
String otherArgument = "(?i)" + Pattern.quote(varName) + "\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
String otherArgument = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
+ otherLiteral + "[\"']?\\s*\\)";
expr = expr.replaceAll(otherReceiver, "false");
expr = replaceReceiverEqualsForVariable(expr, varName, literal, "false");
expr = expr.replaceAll(otherArgument, "false");
}
return expr;
}
private static String replaceReceiverEqualsForVariable(
String expr, String varName, String literalValue, String replacement) {
Pattern head = Pattern.compile(
"(?is)[\"']?" + Pattern.quote(literalValue) + "[\"']?\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(");
Matcher matcher = head.matcher(expr);
StringBuilder result = new StringBuilder();
int cursor = 0;
while (matcher.find()) {
int matchStart = matcher.start();
int openParen = matcher.end() - 1;
int closeParen = findMatchingCloseParen(expr, openParen);
if (closeParen < 0) {
break;
}
String argument = expr.substring(openParen + 1, closeParen).trim();
result.append(expr, cursor, matchStart);
if (argument.equals(varName)
|| argument.matches("(?s).*\\b" + Pattern.quote(varName) + "\\b.*")) {
result.append(replacement);
} else {
result.append(expr, matchStart, closeParen + 1);
}
cursor = closeParen + 1;
}
result.append(expr.substring(cursor));
return result.toString();
}
private static int findMatchingCloseParen(String expr, int openIdx) {
int depth = 0;
for (int i = openIdx; i < expr.length(); i++) {
char c = expr.charAt(i);
if (c == '(') {
depth++;
} else if (c == ')') {
depth--;
if (depth == 0) {
return i;
}
}
}
return -1;
}
private static Set<String> extractEqualityVariables(String constraint) {
Set<String> vars = new HashSet<>();
Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*==");

View File

@@ -759,7 +759,7 @@ public final class EntryPointBindingExpander {
@Override
public boolean visit(IfStatement node) {
collectCasesFromIfChain(node, paramName, cases);
collectCasesFromIfChain(node, paramName, cases, method, context);
return super.visit(node);
}
});
@@ -801,10 +801,16 @@ public final class EntryPointBindingExpander {
return false;
}
private static void collectCasesFromIfChain(IfStatement ifStatement, String paramName, Set<String> cases) {
private static void collectCasesFromIfChain(
IfStatement ifStatement,
String paramName,
Set<String> cases,
MethodDeclaration ownerMethod,
CodebaseContext context) {
IfStatement current = ifStatement;
while (current != null) {
String literal = extractEqualsLiteralForParameter(current.getExpression(), paramName);
String literal = extractEqualsLiteralForParameter(
current.getExpression(), paramName, ownerMethod, context);
if (literal != null) {
cases.add(literal);
}
@@ -813,7 +819,8 @@ public final class EntryPointBindingExpander {
current = nestedIf;
} else {
if (elseStatement != null) {
String elseLiteral = extractEqualsLiteralForParameterFromStatement(elseStatement, paramName);
String elseLiteral = extractEqualsLiteralForParameterFromStatement(
elseStatement, paramName, ownerMethod, context);
if (elseLiteral != null) {
cases.add(elseLiteral);
}
@@ -823,14 +830,27 @@ public final class EntryPointBindingExpander {
}
}
private static String extractEqualsLiteralForParameterFromStatement(Statement statement, String paramName) {
private static String extractEqualsLiteralForParameterFromStatement(
Statement statement,
String paramName,
MethodDeclaration ownerMethod,
CodebaseContext context) {
if (statement instanceof IfStatement ifStatement) {
return extractEqualsLiteralForParameter(ifStatement.getExpression(), paramName);
return extractEqualsLiteralForParameter(
ifStatement.getExpression(), paramName, ownerMethod, context);
}
return null;
}
private static String extractEqualsLiteralForParameter(Expression expression, String paramName) {
return extractEqualsLiteralForParameter(expression, paramName, null, null);
}
private static String extractEqualsLiteralForParameter(
Expression expression,
String paramName,
MethodDeclaration ownerMethod,
CodebaseContext context) {
if (!(expression instanceof MethodInvocation invocation)) {
return null;
}
@@ -849,11 +869,23 @@ public final class EntryPointBindingExpander {
&& paramName.equals(simpleName.getIdentifier())) {
return stringLiteral.getLiteralValue();
}
if (receiver instanceof StringLiteral stringLiteral
&& ownerMethod != null
&& context != null
&& switchSelectorReferencesParameter(argument, paramName, ownerMethod, context)) {
return stringLiteral.getLiteralValue();
}
if (receiver instanceof SimpleName simpleName
&& paramName.equals(simpleName.getIdentifier())
&& argument instanceof StringLiteral paramLiteral) {
return paramLiteral.getLiteralValue();
}
if (ownerMethod != null
&& context != null
&& switchSelectorReferencesParameter(receiver, paramName, ownerMethod, context)
&& argument instanceof StringLiteral paramLiteral) {
return paramLiteral.getLiteralValue();
}
return null;
}

View File

@@ -49,4 +49,17 @@ class BooleanConstraintEvaluatorBindingsTest {
"command == ORDER_PAY",
Map.of("commandKey", "order.pay"))).isFalse();
}
@Test
void shouldEvaluateEqualsIgnoreCaseWithHelperWrappedParameter() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"\"ORDER\".equalsIgnoreCase(normalizeType(machineType))",
Map.of("machineType", "ORDER"))).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"\"ORDER\".equalsIgnoreCase(normalizeType(machineType))",
Map.of("machineType", "DOCUMENT"))).isFalse();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"\"DOCUMENT\".equalsIgnoreCase(normalizeType(machineType))",
Map.of("machineType", "DOCUMENT"))).isTrue();
}
}

View File

@@ -889,4 +889,78 @@ class EntryPointBindingExpanderTest {
assertThat(variants).extracting(map -> map.get("event"))
.containsExactlyInAnyOrder("PAY", "SHIP");
}
@Test
void shouldExpandMachineTypeFromIfEqualsOnSingleArgHelper(@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 ("ORDER".equalsIgnoreCase(normalizeType(machineType))) {
fireOrder(eventString);
} else if ("DOCUMENT".equalsIgnoreCase(normalizeType(machineType))) {
fireDocument(eventString);
}
}
String normalizeType(String raw) {
return 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");
assertThat(variants.stream()
.filter(v -> "DOCUMENT".equals(v.get("machineType")))
.map(v -> v.get("event"))
.toList())
.containsExactlyInAnyOrder("SUBMIT", "APPROVE");
}
}

View File

@@ -17,7 +17,7 @@ import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Path binding expansion must resolve helpers declared in sibling Gradle modules without executing the build.
* Path binding expansion must resolve helpers declared in sibling modules without executing the build.
*/
class MultiModulePathBindingExpanderTest {
@@ -94,4 +94,90 @@ class MultiModulePathBindingExpanderTest {
entryPoint, Map.of("event", "PAY")).getName())
.isEqualTo("POST /api/order/PAY");
}
@Test
void shouldExpandEventUsingHelperFromSiblingMavenModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-maven");
Files.createDirectories(root.resolve(".git"));
Files.writeString(root.resolve("pom.xml"), """
<project><groupId>com.example</groupId><artifactId>order-maven</artifactId></project>
""");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/util"));
Files.writeString(apiModule.resolve("pom.xml"), """
<project><groupId>com.example</groupId><artifactId>api-module</artifactId></project>
""");
Files.writeString(apiModule.resolve("src/main/java/com/example/util/EventNormalizer.java"), """
package com.example.util;
public class EventNormalizer {
public static String normalize(String raw) {
return raw.toUpperCase();
}
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("pom.xml"), """
<project>
<groupId>com.example</groupId>
<artifactId>core-module</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>api-module</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>
""");
Files.writeString(coreModule.resolve("src/main/java/com/example/OrderGateway.java"), """
package com.example;
import com.example.util.EventNormalizer;
public class OrderController {
OrderGateway gateway;
public void transition(String event) {
gateway.trigger(event);
}
}
class OrderGateway {
void trigger(String event) {
OrderEvent.valueOf(EventNormalizer.normalize(event));
}
}
enum OrderEvent { PAY, SHIP }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
assertThat(context.getTypeDeclaration("com.example.util.EventNormalizer")).isNotNull();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
.methodName("transition")
.name("POST /api/order/{event}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("event"))
.containsExactlyInAnyOrder("PAY", "SHIP");
}
}