This commit is contained in:
2026-07-16 18:02:43 +02:00
parent b4a7575bb9
commit 4809423004
8 changed files with 357 additions and 14 deletions

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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<String> 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<String> findConstantsUnderPrefix(String prefix, String constant, CodebaseContext context) {
LinkedHashSet<String> matches = new LinkedHashSet<>();
for (Map.Entry<String, List<String>> entry : context.getEnumValuesMap().entrySet()) {
String enumFqn = entry.getKey();
if (!enumUnderPrefix(enumFqn, prefix)) {
continue;
}
String candidate = enumFqn + "." + constant;
List<String> 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<String> values = context.getEnumValues(typeName);
return values != null && !values.isEmpty();
}
private static boolean enumHasConstant(String enumTypeFqn, String constant, CodebaseContext context) {
List<String> 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<String> 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;
}
}

View File

@@ -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<String> path, Map<String, List<CallEdge>> callGraph) {
if (argName == null || path == null || path.size() < 2) {
return null;

View File

@@ -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<String> 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) {

View File

@@ -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)";

View File

@@ -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;
}
}

View File

@@ -93,7 +93,7 @@ class PathBindingEvaluatorTest {
Map<String, String> 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()