From 4809423004ec28e72b562b2ab6aba5af40dff198 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Thu, 16 Jul 2026 18:02:43 +0200 Subject: [PATCH] a --- .../resolver/BooleanConstraintEvaluator.java | 3 + .../resolver/MachineEnumCanonicalizer.java | 8 + .../TruncatedEnumRefReconstructor.java | 224 ++++++++++++++++++ .../service/AbstractCallGraphEngine.java | 17 +- .../service/PathBindingEvaluator.java | 29 ++- .../MachineEnumCanonicalizerTest.java | 11 + .../TruncatedEnumRefReconstructorTest.java | 77 ++++++ .../service/PathBindingEvaluatorTest.java | 2 +- 8 files changed, 357 insertions(+), 14 deletions(-) create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/TruncatedEnumRefReconstructor.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/TruncatedEnumRefReconstructorTest.java 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 a9f521f..e5d0cdf 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 @@ -100,6 +100,9 @@ public final class BooleanConstraintEvaluator { if (isIncompleteDottedName(boundValue)) { return false; } + if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(boundValue)) { + return false; + } if (boundValue.contains("(") && !boundValue.contains("valueOf")) { return false; } 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 d4d098d..93a01c6 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,6 +879,14 @@ public final class MachineEnumCanonicalizer { return value; } + if (context != null) { + String reconstructed = TruncatedEnumRefReconstructor.reconstruct(stripped, enumTypeFqn, context); + if (reconstructed != null && !reconstructed.equals(stripped)) { + stripped = reconstructed; + value = reconstructed; + } + } + if (stripped.startsWith(".") || stripped.endsWith(".") || stripped.contains("..")) { // Truncated/malformed refs (e.g. com.example.DomainCommand.) are not canonical. return value; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/TruncatedEnumRefReconstructor.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/TruncatedEnumRefReconstructor.java new file mode 100644 index 0000000..86a0331 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/TruncatedEnumRefReconstructor.java @@ -0,0 +1,224 @@ +package click.kamil.springstatemachineexporter.analysis.resolver; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Repairs truncated enum references produced by older resolution bugs, e.g. + * {@code com.example.PAY} (package + constant, type segment dropped) → + * {@code com.example.OrderEvent.PAY} when the match is unique or matches a preferred type. + */ +public final class TruncatedEnumRefReconstructor { + + private static final Pattern ENUM_CONSTANT = Pattern.compile("[A-Z_][A-Z0-9_]*"); + + private TruncatedEnumRefReconstructor() { + } + + /** + * @return reconstructed FQN when uniquely determined; otherwise the original {@code value} + */ + public static String reconstruct(String value, CodebaseContext context) { + return reconstruct(value, null, context); + } + + /** + * @param preferredEnumTypeFqn optional machine/event type that disambiguates when several enums + * share the same constant name under a package prefix + * @return reconstructed FQN when uniquely determined; otherwise the original {@code value} + */ + public static String reconstruct(String value, String preferredEnumTypeFqn, CodebaseContext context) { + if (value == null || value.isBlank() || context == null) { + return value; + } + String stripped = stripQuotes(value.trim()); + if (stripped.isBlank() || stripped.contains("(") || stripped.contains(")")) { + return value; + } + if (stripped.startsWith(".") || stripped.contains("..")) { + return value; + } + + // Type-only trailing dot (com.example.OrderEvent.) — no constant to recover. + if (stripped.endsWith(".")) { + return value; + } + + int lastDot = stripped.lastIndexOf('.'); + if (lastDot < 0) { + return reconstructBareConstant(stripped, preferredEnumTypeFqn, context, value); + } + if (lastDot == 0 || lastDot >= stripped.length() - 1) { + return value; + } + + String prefix = stripped.substring(0, lastDot); + String constant = stripped.substring(lastDot + 1); + if (!ENUM_CONSTANT.matcher(constant).matches()) { + return value; + } + + // Already a known Type.CONSTANT (simple or FQN) — normalize to indexed FQN when possible. + if (isKnownEnumType(prefix, context)) { + String canonical = canonicalConstantRef(prefix, constant, context); + return canonical != null ? canonical : value; + } + + // Preferred type: classic truncation com.example.PAY + preferred com.example.OrderEvent. + if (preferredEnumTypeFqn != null + && !preferredEnumTypeFqn.isBlank() + && enumHasConstant(preferredEnumTypeFqn, constant, context) + && prefixMatchesPreferredType(prefix, preferredEnumTypeFqn)) { + return preferredEnumTypeFqn + "." + constant; + } + + List matches = findConstantsUnderPrefix(prefix, constant, context); + if (matches.size() == 1) { + return matches.get(0); + } + if (preferredEnumTypeFqn != null) { + for (String match : matches) { + if (match.startsWith(preferredEnumTypeFqn + ".")) { + return match; + } + } + } + return value; + } + + /** + * True when {@code value} looks like the old package+CONSTANT truncation + * ({@code com.example.PAY}) rather than a real {@code Type.CONSTANT} or routing key. + */ + public static boolean looksLikePackageTruncatedEnumRef(String value) { + if (value == null || value.isBlank() || value.contains("(")) { + return false; + } + String stripped = stripQuotes(value.trim()); + int lastDot = stripped.lastIndexOf('.'); + if (lastDot <= 0 || lastDot >= stripped.length() - 1) { + return false; + } + String constant = stripped.substring(lastDot + 1); + if (!ENUM_CONSTANT.matcher(constant).matches()) { + return false; + } + String prefix = stripped.substring(0, lastDot); + int prevDot = prefix.lastIndexOf('.'); + String lastSegment = prevDot >= 0 ? prefix.substring(prevDot + 1) : prefix; + return !lastSegment.isEmpty() && Character.isLowerCase(lastSegment.charAt(0)); + } + + private static String reconstructBareConstant( + String constant, String preferredEnumTypeFqn, CodebaseContext context, String original) { + if (!ENUM_CONSTANT.matcher(constant).matches() || preferredEnumTypeFqn == null) { + return original; + } + if (enumHasConstant(preferredEnumTypeFqn, constant, context)) { + return preferredEnumTypeFqn + "." + constant; + } + return original; + } + + private static boolean prefixMatchesPreferredType(String prefix, String preferredEnumTypeFqn) { + if (prefix.equals(preferredEnumTypeFqn)) { + return true; + } + String preferredPackage = packageOf(preferredEnumTypeFqn); + if (prefix.equals(preferredPackage)) { + return true; + } + return preferredEnumTypeFqn.startsWith(prefix + "."); + } + + private static List findConstantsUnderPrefix(String prefix, String constant, CodebaseContext context) { + LinkedHashSet matches = new LinkedHashSet<>(); + for (Map.Entry> entry : context.getEnumValuesMap().entrySet()) { + String enumFqn = entry.getKey(); + if (!enumUnderPrefix(enumFqn, prefix)) { + continue; + } + String candidate = enumFqn + "." + constant; + List values = entry.getValue(); + if (values == null) { + continue; + } + if (values.contains(candidate) + || values.stream().anyMatch(v -> v != null && v.endsWith("." + constant))) { + matches.add(candidate); + } + } + return new ArrayList<>(matches); + } + + private static boolean enumUnderPrefix(String enumFqn, String prefix) { + if (enumFqn == null || prefix == null) { + return false; + } + if (enumFqn.equals(prefix)) { + return true; + } + if (enumFqn.startsWith(prefix + ".")) { + return true; + } + return prefix.equals(packageOf(enumFqn)); + } + + private static boolean isKnownEnumType(String typeName, CodebaseContext context) { + if (typeName == null || typeName.isBlank()) { + return false; + } + if (context.getEnumValuesMap().containsKey(typeName)) { + return true; + } + List values = context.getEnumValues(typeName); + return values != null && !values.isEmpty(); + } + + private static boolean enumHasConstant(String enumTypeFqn, String constant, CodebaseContext context) { + List values = context.getEnumValues(enumTypeFqn); + if (values == null || values.isEmpty()) { + return false; + } + String suffix = "." + constant; + for (String value : values) { + if (value != null && (value.endsWith(suffix) || value.equals(constant))) { + return true; + } + } + return false; + } + + private static String canonicalConstantRef(String typeName, String constant, CodebaseContext context) { + List values = context.getEnumValues(typeName); + if (values == null) { + return null; + } + String suffix = "." + constant; + for (String value : values) { + if (value != null && value.endsWith(suffix)) { + return value; + } + } + return null; + } + + private static String packageOf(String typeFqn) { + int dot = typeFqn.lastIndexOf('.'); + return dot > 0 ? typeFqn.substring(0, dot) : ""; + } + + private static String stripQuotes(String value) { + if (value.length() >= 2 + && ((value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'")))) { + return value.substring(1, value.length() - 1); + } + return value; + } +} 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 82ae730..77f57a7 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 @@ -595,9 +595,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String literal = sl.getLiteralValue(); if (literal != null && !literal.isBlank()) { if (enumTypeName != null) { - polymorphicEvents.add(enumTypeName + "." + literal); + polymorphicEvents.add(reconstructEnumRef(enumTypeName + "." + literal)); } else { - polymorphicEvents.add(literal); + polymorphicEvents.add(reconstructEnumRef(literal)); } } methodName = null; @@ -606,9 +606,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String constant = qn.getName().getIdentifier(); if (constant != null && !constant.isBlank()) { if (enumTypeName != null) { - polymorphicEvents.add(enumTypeName + "." + constant); + polymorphicEvents.add(reconstructEnumRef(enumTypeName + "." + constant)); } else { - polymorphicEvents.add(constant); + polymorphicEvents.add(reconstructEnumRef(constant)); } } methodName = null; @@ -617,9 +617,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String resolvedConstant = resolveEnumConstantFromArgument(sn.getIdentifier(), path, callGraph); if (resolvedConstant != null && !resolvedConstant.isBlank()) { if (enumTypeName != null) { - polymorphicEvents.add(enumTypeName + "." + resolvedConstant); + polymorphicEvents.add(reconstructEnumRef(enumTypeName + "." + resolvedConstant)); } else { - polymorphicEvents.add(resolvedConstant); + polymorphicEvents.add(reconstructEnumRef(resolvedConstant)); } methodName = null; handledStaticEnum = true; @@ -2896,6 +2896,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return !simple.isEmpty() && Character.isUpperCase(simple.charAt(0)); } + private String reconstructEnumRef(String value) { + return click.kamil.springstatemachineexporter.analysis.resolver.TruncatedEnumRefReconstructor + .reconstruct(value, context); + } + private String resolveEnumConstantFromArgument(String argName, List path, Map> callGraph) { if (argName == null || path == null || path.size() < 2) { return null; 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 4ed0846..bbd05ec 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 @@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.service; import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.analysis.resolver.TruncatedEnumRefReconstructor; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import java.util.HashMap; @@ -189,7 +190,10 @@ public class PathBindingEvaluator { && FunctionalInterfaceTypes.isFunctionalInterface(typeResolver.getParameterType(target, i))) { List constants = variableTracer.resolveConstantsFromCallSiteArgument(caller, target, i, edge); if (constants.size() == 1) { - bindings.put(paramName, constants.get(0)); + String constant = maybeReconstruct(constants.get(0)); + if (shouldStoreBinding(paramName, constant)) { + bindings.put(paramName, constant); + } continue; } } @@ -207,6 +211,10 @@ public class PathBindingEvaluator { if (BooleanConstraintEvaluator.isIncompleteDottedName(resolved)) { return false; } + // Unrepaired package+CONSTANT truncation (com.example.PAY) is not a trustworthy binding. + if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(resolved)) { + return false; + } if (resolved.contains("(") && !isEnumLikeConstant(resolved)) { return false; } @@ -230,25 +238,32 @@ public class PathBindingEvaluator { return null; } if (rawValue.startsWith("\"") || isEnumLikeConstant(rawValue)) { - return normalizeLiteral(rawValue); + return maybeReconstruct(normalizeLiteral(rawValue)); } if (bindings.containsKey(rawValue)) { - return bindings.get(rawValue); + return maybeReconstruct(bindings.get(rawValue)); } String traced = variableTracer.traceLocalVariable(callerFqn, rawValue, bindings); if (traced != null && !traced.isBlank()) { if (traced.startsWith("\"") || isEnumLikeConstant(traced)) { - return traced; + return maybeReconstruct(traced); } String fromCall = expressionResolver.resolveMethodCallFromSource(callerFqn, rawValue, bindings); if (fromCall != null) { - return fromCall; + return maybeReconstruct(fromCall); } if (!traced.equals(rawValue)) { - return traced; + return maybeReconstruct(traced); } } - return rawValue; + return maybeReconstruct(rawValue); + } + + private String maybeReconstruct(String value) { + if (value == null || context == null) { + return value; + } + return TruncatedEnumRefReconstructor.reconstruct(value, context); } private static String normalizeLiteral(String value) { 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 d61de5c..421083e 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 @@ -185,6 +185,17 @@ class MachineEnumCanonicalizerTest { .isEqualTo(truncated); } + @Test + void shouldReconstructPackageTruncatedEnumConstant(@TempDir Path tempDir) throws IOException { + writeSampleConfig(tempDir); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + assertThat(MachineEnumCanonicalizer.canonicalizeLabel( + "com.example.order.PAY", "com.example.order.OrderEvent", context)) + .isEqualTo("com.example.order.OrderEvent.PAY"); + } + @Test void shouldDenormalizeCorruptedPackageValueOfTriggerExpression() { String corrupted = "com.abcd.aaa.MyEvent.valueOf(event)"; diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/TruncatedEnumRefReconstructorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/TruncatedEnumRefReconstructorTest.java new file mode 100644 index 0000000..5b818f6 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/TruncatedEnumRefReconstructorTest.java @@ -0,0 +1,77 @@ +package click.kamil.springstatemachineexporter.analysis.resolver; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class TruncatedEnumRefReconstructorTest { + + @Test + void shouldReconstructPackageTruncatedConstantWhenUnique(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scanOrderAndDoc(tempDir, false); + + assertThat(TruncatedEnumRefReconstructor.reconstruct("com.example.PAY", context)) + .isEqualTo("com.example.OrderEvent.PAY"); + assertThat(TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef("com.example.PAY")) + .isTrue(); + assertThat(TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef("com.example.OrderEvent.PAY")) + .isFalse(); + assertThat(TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef("order.pay")) + .isFalse(); + } + + @Test + void shouldUsePreferredTypeWhenConstantIsAmbiguousAcrossEnums(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scanOrderAndDoc(tempDir, true); + + // Both OrderEvent and DocumentEvent have PAY under com.example + assertThat(TruncatedEnumRefReconstructor.reconstruct("com.example.PAY", context)) + .isEqualTo("com.example.PAY"); + assertThat(TruncatedEnumRefReconstructor.reconstruct( + "com.example.PAY", "com.example.OrderEvent", context)) + .isEqualTo("com.example.OrderEvent.PAY"); + assertThat(TruncatedEnumRefReconstructor.reconstruct( + "com.example.PAY", "com.example.DocumentEvent", context)) + .isEqualTo("com.example.DocumentEvent.PAY"); + } + + @Test + void shouldNotInventConstantForTrailingDotTypeRef(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scanOrderAndDoc(tempDir, false); + + assertThat(TruncatedEnumRefReconstructor.reconstruct("com.example.OrderEvent.", context)) + .isEqualTo("com.example.OrderEvent."); + assertThat(TruncatedEnumRefReconstructor.reconstruct( + "com.example.OrderEvent.", "com.example.OrderEvent", context)) + .isEqualTo("com.example.OrderEvent."); + } + + @Test + void shouldCanonicalizeTruncatedPolyEventViaMachineEnumCanonicalizer(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scanOrderAndDoc(tempDir, false); + + assertThat(MachineEnumCanonicalizer.canonicalizeLabel( + "com.example.PAY", "com.example.OrderEvent", context)) + .isEqualTo("com.example.OrderEvent.PAY"); + } + + private static CodebaseContext scanOrderAndDoc(Path tempDir, boolean documentAlsoHasPay) throws IOException { + Path dir = tempDir.resolve("com/example"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("OrderEvent.java"), + "package com.example;\npublic enum OrderEvent { PAY, SHIP }\n"); + String docConstants = documentAlsoHasPay ? "PAY, ARCHIVE" : "ARCHIVE"; + Files.writeString(dir.resolve("DocumentEvent.java"), + "package com.example;\npublic enum DocumentEvent { " + docConstants + " }\n"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + return context; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluatorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluatorTest.java index 621a187..ba9a596 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluatorTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluatorTest.java @@ -93,7 +93,7 @@ class PathBindingEvaluatorTest { Map payBindings = evaluator.traceBindings(rawPaths.get(0), graph, pathFinder); assertThat(payBindings) .as("pay path bindings, compatible=%s", payPathCompatible) - .containsEntry("command", "DomainCommand.ORDER_PAY"); + .containsEntry("command", "com.example.DomainCommand.ORDER_PAY"); EntryPoint entry = EntryPoint.builder().className("com.example.ApiController").methodName("pay").build(); TriggerPoint trigger = TriggerPoint.builder()