From b4a7575bb97822d0c75493219412071cc10ddaf9 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Thu, 16 Jul 2026 17:51:36 +0200 Subject: [PATCH] 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 --- .../resolver/BooleanConstraintEvaluator.java | 17 ++++- .../resolver/MachineEnumCanonicalizer.java | 13 +++- .../service/AbstractCallGraphEngine.java | 46 +++++++++----- .../service/PathBindingEvaluator.java | 3 + ...ooleanConstraintEvaluatorBindingsTest.java | 26 ++++++++ .../resolver/ConstantResolverTest.java | 31 ++++++++++ .../MachineEnumCanonicalizerTest.java | 8 +++ .../HeuristicCallGraphEngineTypeTest.java | 62 +++++++++++++++++++ 8 files changed, 186 insertions(+), 20 deletions(-) diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java index f131e63..a9f521f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java @@ -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; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java index 2423f77..d4d098d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java @@ -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; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 3a30d88..82ae730 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -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 path, Map> callGraph) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java index 607fd5f..4ed0846 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java @@ -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; } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java index 2d54109..7de7bc6 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java @@ -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(); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java index 1a95c86..68a2b8b 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java @@ -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"); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java index e9d1bb1..d61de5c 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java @@ -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)"; diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineTypeTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineTypeTest.java index 7c24ff4..9684e96 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineTypeTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineTypeTest.java @@ -515,6 +515,68 @@ class HeuristicCallGraphEngineTypeTest { .containsExactlyInAnyOrder("", ""); } + @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 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 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();