Support Objects.equals for path binding and branch constraints.

Expand machineType routing when dispatchers use java.util.Objects.equals with helper-wrapped parameters, and evaluate those compound constraints without breaking existing receiver-equals forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 18:06:00 +02:00
parent 8d7e04cc33
commit fd21cfa440
5 changed files with 364 additions and 0 deletions

View File

@@ -133,6 +133,7 @@ public final class BooleanConstraintEvaluator {
String argumentEquals = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
+ literalPattern + "[\"']?\\s*\\)";
expr = replaceReceiverEqualsForVariable(expr, varName, cleanValue, "true");
expr = replaceObjectsEqualsForVariable(expr, varName, cleanValue, "true");
expr = expr.replaceAll(argumentEquals, "true");
Set<String> allLiterals = extractStringLiterals(expr);
@@ -144,11 +145,86 @@ public final class BooleanConstraintEvaluator {
String otherArgument = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
+ otherLiteral + "[\"']?\\s*\\)";
expr = replaceReceiverEqualsForVariable(expr, varName, literal, "false");
expr = replaceObjectsEqualsForVariable(expr, varName, literal, "false");
expr = expr.replaceAll(otherArgument, "false");
}
return expr;
}
private static String replaceObjectsEqualsForVariable(
String expr, String varName, String literalValue, String replacement) {
Pattern head = Pattern.compile("(?is)(?:java\\.util\\.)?Objects\\.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[] args = splitTopLevelComma(expr.substring(openParen + 1, closeParen));
result.append(expr, cursor, matchStart);
if (args != null
&& args.length == 2
&& objectsEqualsArgumentMatches(args[0], args[1], varName, literalValue)) {
result.append(replacement);
} else {
result.append(expr, matchStart, closeParen + 1);
}
cursor = closeParen + 1;
}
result.append(expr.substring(cursor));
return result.toString();
}
private static boolean objectsEqualsArgumentMatches(
String left, String right, String varName, String literalValue) {
String leftLiteral = stripConstraintLiteral(left);
String rightLiteral = stripConstraintLiteral(right);
if (leftLiteral != null
&& leftLiteral.equalsIgnoreCase(literalValue)
&& constraintArgumentReferencesVariable(right, varName)) {
return true;
}
return rightLiteral != null
&& rightLiteral.equalsIgnoreCase(literalValue)
&& constraintArgumentReferencesVariable(left, varName);
}
private static String stripConstraintLiteral(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
if (trimmed.length() >= 2
&& ((trimmed.startsWith("\"") && trimmed.endsWith("\""))
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
return trimmed.substring(1, trimmed.length() - 1);
}
return null;
}
private static boolean constraintArgumentReferencesVariable(String argument, String varName) {
return argument != null && argument.matches("(?s).*\\b" + Pattern.quote(varName) + "\\b.*");
}
private static String[] splitTopLevelComma(String args) {
int depth = 0;
for (int i = 0; i < args.length(); i++) {
char c = args.charAt(i);
if (c == '(') {
depth++;
} else if (c == ')') {
depth--;
} else if (c == ',' && depth == 0) {
return new String[] {args.substring(0, i), args.substring(i + 1)};
}
}
return null;
}
private static String replaceReceiverEqualsForVariable(
String expr, String varName, String literalValue, String replacement) {
Pattern head = Pattern.compile(

View File

@@ -851,6 +851,10 @@ public final class EntryPointBindingExpander {
String paramName,
MethodDeclaration ownerMethod,
CodebaseContext context) {
String fromObjects = extractObjectsEqualsLiteral(expression, paramName, ownerMethod, context);
if (fromObjects != null) {
return fromObjects;
}
if (!(expression instanceof MethodInvocation invocation)) {
return null;
}
@@ -889,6 +893,64 @@ public final class EntryPointBindingExpander {
return null;
}
private static String extractObjectsEqualsLiteral(
Expression expression,
String paramName,
MethodDeclaration ownerMethod,
CodebaseContext context) {
if (!(expression instanceof MethodInvocation invocation)) {
return null;
}
if (!isObjectsEqualsInvocation(invocation)) {
return null;
}
if (invocation.arguments().size() != 2) {
return null;
}
Expression left = (Expression) invocation.arguments().get(0);
Expression right = (Expression) invocation.arguments().get(1);
if (left instanceof StringLiteral stringLiteral
&& parameterExpressionReferencesBinding(right, paramName, ownerMethod, context)) {
return stringLiteral.getLiteralValue();
}
if (right instanceof StringLiteral stringLiteral
&& parameterExpressionReferencesBinding(left, paramName, ownerMethod, context)) {
return stringLiteral.getLiteralValue();
}
return null;
}
private static boolean isObjectsEqualsInvocation(MethodInvocation invocation) {
if (!"equals".equals(invocation.getName().getIdentifier())) {
return false;
}
Expression receiver = invocation.getExpression();
if (receiver == null) {
return false;
}
if (receiver instanceof SimpleName simpleName) {
return "Objects".equals(simpleName.getIdentifier());
}
if (receiver instanceof QualifiedName qualifiedName) {
String fqn = qualifiedName.getFullyQualifiedName();
return "java.util.Objects".equals(fqn) || fqn.endsWith(".Objects");
}
return false;
}
private static boolean parameterExpressionReferencesBinding(
Expression expression,
String paramName,
MethodDeclaration ownerMethod,
CodebaseContext context) {
if (expression instanceof SimpleName simpleName) {
return paramName.equals(simpleName.getIdentifier());
}
return ownerMethod != null
&& context != null
&& switchSelectorReferencesParameter(expression, paramName, ownerMethod, context);
}
private static boolean tracksBindingName(MethodDeclaration method, String name) {
if (hasParameter(method, name)) {
return true;

View File

@@ -62,4 +62,17 @@ class BooleanConstraintEvaluatorBindingsTest {
"\"DOCUMENT\".equalsIgnoreCase(normalizeType(machineType))",
Map.of("machineType", "DOCUMENT"))).isTrue();
}
@Test
void shouldEvaluateObjectsEqualsWithHelperWrappedParameter() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"java.util.Objects.equals(\"ORDER\", normalizeType(machineType))",
Map.of("machineType", "ORDER"))).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"java.util.Objects.equals(\"ORDER\", normalizeType(machineType))",
Map.of("machineType", "DOCUMENT"))).isFalse();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"java.util.Objects.equals(normalizeType(machineType), \"DOCUMENT\")",
Map.of("machineType", "DOCUMENT"))).isTrue();
}
}

View File

@@ -963,4 +963,132 @@ class EntryPointBindingExpanderTest {
.toList())
.containsExactlyInAnyOrder("SUBMIT", "APPROVE");
}
@Test
void shouldExpandMachineTypeFromArgumentEqualsOnSingleArgHelper(@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 (normalizeType(machineType).equalsIgnoreCase("ORDER")) {
fireOrder(eventString);
} else if (normalizeType(machineType).equalsIgnoreCase("DOCUMENT")) {
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");
}
@Test
void shouldExpandMachineTypeFromObjectsEqualsOnSingleArgHelper(@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 (java.util.Objects.equals("ORDER", normalizeType(machineType))) {
fireOrder(eventString);
} else if (java.util.Objects.equals("DOCUMENT", 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");
}
}

View File

@@ -180,4 +180,89 @@ class MultiModulePathBindingExpanderTest {
assertThat(variants).extracting(map -> map.get("event"))
.containsExactlyInAnyOrder("PAY", "SHIP");
}
@Test
void shouldExpandMachineTypeFromIfEqualsOnSiblingModuleHelper(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("enterprise-style");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/util"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/util/MachineTypeNormalizer.java"), """
package com.example.util;
public class MachineTypeNormalizer {
public static String normalize(String raw) {
return raw.trim();
}
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/MachineController.java"), """
package com.example;
import com.example.util.MachineTypeNormalizer;
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(MachineTypeNormalizer.normalize(machineType))) {
OrderEvent.valueOf(eventString.toUpperCase());
} else if ("DOCUMENT".equalsIgnoreCase(MachineTypeNormalizer.normalize(machineType))) {
DocumentEvent.valueOf(eventString.toUpperCase());
}
}
}
enum OrderEvent { PAY, SHIP }
enum DocumentEvent { SUBMIT, APPROVE }
""");
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());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
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, callGraph);
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");
}
}