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