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