Fix valueOf FQN truncation and reject incomplete dotted bindings.

Keep Type.valueOf receivers intact so package prefixes are not mistaken for enum types, and harden binding/canonicalization paths against trailing-dot names that caused index crashes on large codebases.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-16 17:51:36 +02:00
parent 109957b5fa
commit b4a7575bb9
8 changed files with 186 additions and 20 deletions

View File

@@ -97,6 +97,9 @@ public final class BooleanConstraintEvaluator {
if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) {
return false;
}
if (isIncompleteDottedName(boundValue)) {
return false;
}
if (boundValue.contains("(") && !boundValue.contains("valueOf")) {
return false;
}
@@ -106,7 +109,10 @@ public final class BooleanConstraintEvaluator {
if ("true".equalsIgnoreCase(boundValue) || "false".equalsIgnoreCase(boundValue)) {
return true;
}
if (boundValue.contains(".") && Character.isUpperCase(boundValue.charAt(boundValue.lastIndexOf('.') + 1))) {
int lastDot = boundValue.lastIndexOf('.');
if (lastDot >= 0
&& lastDot + 1 < boundValue.length()
&& Character.isUpperCase(boundValue.charAt(lastDot + 1))) {
return true;
}
// Treat simple routing keys (e.g. order.pay) as concrete string bindings, but avoid
@@ -119,6 +125,15 @@ public final class BooleanConstraintEvaluator {
Character.isUpperCase(ch) || ch == '_');
}
/**
* True for truncated/malformed dotted names such as {@code com.example.DomainCommand.} or {@code .PAY}.
*/
public static boolean isIncompleteDottedName(String value) {
return value != null
&& !value.isBlank()
&& (value.startsWith(".") || value.endsWith(".") || value.contains(".."));
}
private static String substituteEqualsLiteralBindings(String expr, String varName, String boundValue) {
if (boundValue == null || boundValue.isBlank()) {
return expr;

View File

@@ -879,8 +879,17 @@ public final class MachineEnumCanonicalizer {
return value;
}
if (stripped.startsWith(".") || stripped.endsWith(".") || stripped.contains("..")) {
// Truncated/malformed refs (e.g. com.example.DomainCommand.) are not canonical.
return value;
}
if (stripped.startsWith(enumTypeFqn + ".")) {
return stripped;
String suffix = stripped.substring(enumTypeFqn.length() + 1);
if (!suffix.isEmpty()) {
return stripped;
}
return value;
}
String constant = constantName(stripped);
@@ -891,7 +900,7 @@ public final class MachineEnumCanonicalizer {
return enumTypeFqn + "." + constant;
}
if (typePart != null && enumTypesMatch(enumTypeFqn, typePart, context)) {
if (typePart != null && !constant.isEmpty() && enumTypesMatch(enumTypeFqn, typePart, context)) {
return enumTypeFqn + "." + constant;
}

View File

@@ -592,24 +592,30 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
Object arg = mi.arguments().get(0);
String enumTypeName = resolveValueOfEnumTypeName(mi);
if (arg instanceof StringLiteral sl) {
if (enumTypeName != null) {
polymorphicEvents.add(enumTypeName + "." + sl.getLiteralValue());
} else {
polymorphicEvents.add(sl.getLiteralValue());
String literal = sl.getLiteralValue();
if (literal != null && !literal.isBlank()) {
if (enumTypeName != null) {
polymorphicEvents.add(enumTypeName + "." + literal);
} else {
polymorphicEvents.add(literal);
}
}
methodName = null;
handledStaticEnum = true;
} else if (arg instanceof QualifiedName qn) {
if (enumTypeName != null) {
polymorphicEvents.add(enumTypeName + "." + qn.getName().getIdentifier());
} else {
polymorphicEvents.add(qn.getName().getIdentifier());
String constant = qn.getName().getIdentifier();
if (constant != null && !constant.isBlank()) {
if (enumTypeName != null) {
polymorphicEvents.add(enumTypeName + "." + constant);
} else {
polymorphicEvents.add(constant);
}
}
methodName = null;
handledStaticEnum = true;
} else if (arg instanceof SimpleName sn) {
String resolvedConstant = resolveEnumConstantFromArgument(sn.getIdentifier(), path, callGraph);
if (resolvedConstant != null) {
if (resolvedConstant != null && !resolvedConstant.isBlank()) {
if (enumTypeName != null) {
polymorphicEvents.add(enumTypeName + "." + resolvedConstant);
} else {
@@ -2867,21 +2873,27 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return fqn;
}
}
// Receiver of Type.valueOf(...) IS the enum type. Do not strip the last segment —
// that turns com.example.DomainCommand into package prefix com.example.
String full = qn.getFullyQualifiedName();
if (full.contains(".")) {
String typePart = full.substring(0, full.lastIndexOf('.'));
if (isResolvedEnumTypeFqn(typePart)) {
return typePart;
}
return full.substring(full.lastIndexOf('.') + 1);
if (isResolvedEnumTypeFqn(full)) {
return full;
}
return full;
if (full != null && !full.isBlank() && !full.endsWith(".") && !full.startsWith(".")) {
return full;
}
return null;
}
return null;
}
private static boolean isResolvedEnumTypeFqn(String fqn) {
return fqn != null && !fqn.isBlank() && fqn.contains(".") && !fqn.startsWith("<");
if (fqn == null || fqn.isBlank() || fqn.startsWith("<") || fqn.startsWith(".") || fqn.endsWith(".")) {
return false;
}
int lastDot = fqn.lastIndexOf('.');
String simple = lastDot >= 0 ? fqn.substring(lastDot + 1) : fqn;
return !simple.isEmpty() && Character.isUpperCase(simple.charAt(0));
}
private String resolveEnumConstantFromArgument(String argName, List<String> path, Map<String, List<CallEdge>> callGraph) {

View File

@@ -204,6 +204,9 @@ public class PathBindingEvaluator {
if (resolved == null || resolved.isBlank() || resolved.equals(paramName)) {
return false;
}
if (BooleanConstraintEvaluator.isIncompleteDottedName(resolved)) {
return false;
}
if (resolved.contains("(") && !isEnumLikeConstant(resolved)) {
return false;
}

View File

@@ -75,4 +75,30 @@ class BooleanConstraintEvaluatorBindingsTest {
"java.util.Objects.equals(normalizeType(machineType), \"DOCUMENT\")",
Map.of("machineType", "DOCUMENT"))).isTrue();
}
@Test
void shouldNotThrowWhenBindingValueEndsWithDot() {
// Unresolved/truncated FQNs can end with '.' — must not charAt past the end,
// and must not be treated as concrete (which would force a false match).
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"command == ORDER_PAY",
Map.of("command", "com.example.DomainCommand."))).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"key == \"x\"",
Map.of("key", "."))).isTrue();
}
@Test
void shouldRejectIncompleteDottedNamesAsConcreteBindings() {
assertThat(BooleanConstraintEvaluator.isIncompleteDottedName("com.example.DomainCommand.")).isTrue();
assertThat(BooleanConstraintEvaluator.isIncompleteDottedName(".PAY")).isTrue();
assertThat(BooleanConstraintEvaluator.isIncompleteDottedName("com..DomainCommand.PAY")).isTrue();
assertThat(BooleanConstraintEvaluator.isIncompleteDottedName("com.example.DomainCommand.PAY")).isFalse();
assertThat(BooleanConstraintEvaluator.isIncompleteDottedName("order.pay")).isFalse();
// Incomplete values must not be treated as concrete routing keys.
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"command == ORDER_PAY",
Map.of("command", "com.example.DomainCommand."))).isTrue();
}
}

View File

@@ -528,6 +528,37 @@ public class ConstantResolverTest {
assertThat(result2).isEqualTo("com.example.OrderEvent.SHIPPED");
}
@Test
void shouldResolveFullyQualifiedEnumValueOfWithoutTruncatingPackage(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("OrderEvent.java"),
"package com.example;\n" +
"public enum OrderEvent { PAY, SHIP }\n");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" OrderEvent e = com.example.OrderEvent.valueOf(\"PAY\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0];
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(0);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer();
String result = new ConstantResolver().resolve(mi, context);
assertThat(result)
.as("must not truncate com.example.OrderEvent to package prefix com.example")
.isEqualTo("com.example.OrderEvent.PAY")
.doesNotContain("com.example.PAY");
}
@Test
void shouldResolveMapOfGetWithLiteralKey(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");

View File

@@ -177,6 +177,14 @@ class MachineEnumCanonicalizerTest {
.isEqualTo("MyEvent.valueOf(event)");
}
@Test
void shouldNotCanonicalizeTrailingDotEnumRefs() {
String truncated = "com.example.order.OrderEvent.";
assertThat(MachineEnumCanonicalizer.canonicalizeLabel(
truncated, "com.example.order.OrderEvent"))
.isEqualTo(truncated);
}
@Test
void shouldDenormalizeCorruptedPackageValueOfTriggerExpression() {
String corrupted = "com.abcd.aaa.MyEvent.valueOf(event)";

View File

@@ -515,6 +515,68 @@ class HeuristicCallGraphEngineTypeTest {
.containsExactlyInAnyOrder("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: UserEvent.*>");
}
@Test
void shouldResolveFullyQualifiedValueOfWithoutTruncatingPackage(@TempDir Path tempDir) throws IOException {
// Regression: QualifiedName receivers must keep the type simple name.
// Old bug stripped com.example.OrderEvent → com.example, yielding com.example.PAY.
String source = """
package com.example;
public class Dispatcher {
public void dispatch() {
StateMachine sm = new StateMachine();
sm.sendEvent(com.example.OrderEvent.valueOf("PAY"));
}
public void dispatchVar(String event) {
StateMachine sm = new StateMachine();
sm.sendEvent(com.example.OrderEvent.valueOf(event));
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine {
public void sendEvent(Object e) {}
}
""";
Files.writeString(tempDir.resolve("Dispatcher.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
EntryPoint literalEntry = EntryPoint.builder()
.className("com.example.Dispatcher")
.methodName("dispatch")
.build();
EntryPoint varEntry = EntryPoint.builder()
.className("com.example.Dispatcher")
.methodName("dispatchVar")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("event")
.type("String")
.build()))
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("e")
.build();
List<CallChain> literalChains = builder.findChains(List.of(literalEntry), List.of(trigger));
assertThat(literalChains).hasSize(1);
assertThat(literalChains.get(0).getTriggerPoint().getPolymorphicEvents())
.as("must keep OrderEvent type segment (not package-only com.example.PAY)")
.isNotEmpty()
.allSatisfy(e -> assertThat(e).contains("OrderEvent"))
.noneMatch(e -> e.equals("com.example.PAY") || e.endsWith("."));
List<CallChain> varChains = builder.findChains(List.of(varEntry), List.of(trigger));
assertThat(varChains).isNotEmpty();
assertThat(varChains.stream()
.flatMap(c -> c.getTriggerPoint().getPolymorphicEvents().stream())
.toList())
.as("variable valueOf must not truncate FQN type to package prefix")
.noneMatch(e -> e.equals("com.example.PAY") || e.startsWith("com.example.PAY") || e.endsWith("."));
}
@Test
void testEnterpriseDispatcherResolution() throws IOException {
CodebaseContext context = new CodebaseContext();