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)) {
|
if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (isIncompleteDottedName(boundValue)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (boundValue.contains("(") && !boundValue.contains("valueOf")) {
|
if (boundValue.contains("(") && !boundValue.contains("valueOf")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -106,7 +109,10 @@ public final class BooleanConstraintEvaluator {
|
|||||||
if ("true".equalsIgnoreCase(boundValue) || "false".equalsIgnoreCase(boundValue)) {
|
if ("true".equalsIgnoreCase(boundValue) || "false".equalsIgnoreCase(boundValue)) {
|
||||||
return true;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
// Treat simple routing keys (e.g. order.pay) as concrete string bindings, but avoid
|
// 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 == '_');
|
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) {
|
private static String substituteEqualsLiteralBindings(String expr, String varName, String boundValue) {
|
||||||
if (boundValue == null || boundValue.isBlank()) {
|
if (boundValue == null || boundValue.isBlank()) {
|
||||||
return expr;
|
return expr;
|
||||||
|
|||||||
@@ -879,8 +879,17 @@ public final class MachineEnumCanonicalizer {
|
|||||||
return value;
|
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 + ".")) {
|
if (stripped.startsWith(enumTypeFqn + ".")) {
|
||||||
return stripped;
|
String suffix = stripped.substring(enumTypeFqn.length() + 1);
|
||||||
|
if (!suffix.isEmpty()) {
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
String constant = constantName(stripped);
|
String constant = constantName(stripped);
|
||||||
@@ -891,7 +900,7 @@ public final class MachineEnumCanonicalizer {
|
|||||||
return enumTypeFqn + "." + constant;
|
return enumTypeFqn + "." + constant;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typePart != null && enumTypesMatch(enumTypeFqn, typePart, context)) {
|
if (typePart != null && !constant.isEmpty() && enumTypesMatch(enumTypeFqn, typePart, context)) {
|
||||||
return enumTypeFqn + "." + constant;
|
return enumTypeFqn + "." + constant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -592,24 +592,30 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
Object arg = mi.arguments().get(0);
|
Object arg = mi.arguments().get(0);
|
||||||
String enumTypeName = resolveValueOfEnumTypeName(mi);
|
String enumTypeName = resolveValueOfEnumTypeName(mi);
|
||||||
if (arg instanceof StringLiteral sl) {
|
if (arg instanceof StringLiteral sl) {
|
||||||
if (enumTypeName != null) {
|
String literal = sl.getLiteralValue();
|
||||||
polymorphicEvents.add(enumTypeName + "." + sl.getLiteralValue());
|
if (literal != null && !literal.isBlank()) {
|
||||||
} else {
|
if (enumTypeName != null) {
|
||||||
polymorphicEvents.add(sl.getLiteralValue());
|
polymorphicEvents.add(enumTypeName + "." + literal);
|
||||||
|
} else {
|
||||||
|
polymorphicEvents.add(literal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
methodName = null;
|
methodName = null;
|
||||||
handledStaticEnum = true;
|
handledStaticEnum = true;
|
||||||
} else if (arg instanceof QualifiedName qn) {
|
} else if (arg instanceof QualifiedName qn) {
|
||||||
if (enumTypeName != null) {
|
String constant = qn.getName().getIdentifier();
|
||||||
polymorphicEvents.add(enumTypeName + "." + qn.getName().getIdentifier());
|
if (constant != null && !constant.isBlank()) {
|
||||||
} else {
|
if (enumTypeName != null) {
|
||||||
polymorphicEvents.add(qn.getName().getIdentifier());
|
polymorphicEvents.add(enumTypeName + "." + constant);
|
||||||
|
} else {
|
||||||
|
polymorphicEvents.add(constant);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
methodName = null;
|
methodName = null;
|
||||||
handledStaticEnum = true;
|
handledStaticEnum = true;
|
||||||
} else if (arg instanceof SimpleName sn) {
|
} else if (arg instanceof SimpleName sn) {
|
||||||
String resolvedConstant = resolveEnumConstantFromArgument(sn.getIdentifier(), path, callGraph);
|
String resolvedConstant = resolveEnumConstantFromArgument(sn.getIdentifier(), path, callGraph);
|
||||||
if (resolvedConstant != null) {
|
if (resolvedConstant != null && !resolvedConstant.isBlank()) {
|
||||||
if (enumTypeName != null) {
|
if (enumTypeName != null) {
|
||||||
polymorphicEvents.add(enumTypeName + "." + resolvedConstant);
|
polymorphicEvents.add(enumTypeName + "." + resolvedConstant);
|
||||||
} else {
|
} else {
|
||||||
@@ -2867,21 +2873,27 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
return fqn;
|
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();
|
String full = qn.getFullyQualifiedName();
|
||||||
if (full.contains(".")) {
|
if (isResolvedEnumTypeFqn(full)) {
|
||||||
String typePart = full.substring(0, full.lastIndexOf('.'));
|
return full;
|
||||||
if (isResolvedEnumTypeFqn(typePart)) {
|
|
||||||
return typePart;
|
|
||||||
}
|
|
||||||
return full.substring(full.lastIndexOf('.') + 1);
|
|
||||||
}
|
}
|
||||||
return full;
|
if (full != null && !full.isBlank() && !full.endsWith(".") && !full.startsWith(".")) {
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isResolvedEnumTypeFqn(String fqn) {
|
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) {
|
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)) {
|
if (resolved == null || resolved.isBlank() || resolved.equals(paramName)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (BooleanConstraintEvaluator.isIncompleteDottedName(resolved)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (resolved.contains("(") && !isEnumLikeConstant(resolved)) {
|
if (resolved.contains("(") && !isEnumLikeConstant(resolved)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,4 +75,30 @@ class BooleanConstraintEvaluatorBindingsTest {
|
|||||||
"java.util.Objects.equals(normalizeType(machineType), \"DOCUMENT\")",
|
"java.util.Objects.equals(normalizeType(machineType), \"DOCUMENT\")",
|
||||||
Map.of("machineType", "DOCUMENT"))).isTrue();
|
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");
|
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
|
@Test
|
||||||
void shouldResolveMapOfGetWithLiteralKey(@TempDir Path tempDir) throws IOException {
|
void shouldResolveMapOfGetWithLiteralKey(@TempDir Path tempDir) throws IOException {
|
||||||
Path dir = tempDir.resolve("com/example");
|
Path dir = tempDir.resolve("com/example");
|
||||||
|
|||||||
@@ -177,6 +177,14 @@ class MachineEnumCanonicalizerTest {
|
|||||||
.isEqualTo("MyEvent.valueOf(event)");
|
.isEqualTo("MyEvent.valueOf(event)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldNotCanonicalizeTrailingDotEnumRefs() {
|
||||||
|
String truncated = "com.example.order.OrderEvent.";
|
||||||
|
assertThat(MachineEnumCanonicalizer.canonicalizeLabel(
|
||||||
|
truncated, "com.example.order.OrderEvent"))
|
||||||
|
.isEqualTo(truncated);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldDenormalizeCorruptedPackageValueOfTriggerExpression() {
|
void shouldDenormalizeCorruptedPackageValueOfTriggerExpression() {
|
||||||
String corrupted = "com.abcd.aaa.MyEvent.valueOf(event)";
|
String corrupted = "com.abcd.aaa.MyEvent.valueOf(event)";
|
||||||
|
|||||||
@@ -515,6 +515,68 @@ class HeuristicCallGraphEngineTypeTest {
|
|||||||
.containsExactlyInAnyOrder("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: UserEvent.*>");
|
.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
|
@Test
|
||||||
void testEnterpriseDispatcherResolution() throws IOException {
|
void testEnterpriseDispatcherResolution() throws IOException {
|
||||||
CodebaseContext context = new CodebaseContext();
|
CodebaseContext context = new CodebaseContext();
|
||||||
|
|||||||
Reference in New Issue
Block a user