diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityNormalize.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityNormalize.java new file mode 100644 index 0000000..64cc876 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityNormalize.java @@ -0,0 +1,87 @@ +package click.kamil.springstatemachineexporter.analysis.flow.eventid; + +import click.kamil.springstatemachineexporter.ast.common.AstUtils; + +/** + * Format-safe identity for event constants that may appear bare ({@code PAY}) or + * qualified ({@code OrderEvent.PAY}). Used at compare/merge boundaries only — + * {@code resolveUnique} may still return bare carriers for PathBinding callers. + */ +public final class EventIdentityNormalize { + + private EventIdentityNormalize() { + } + + /** + * Last segment of a dotted constant, or the whole string if bare. + * Analysis tokens yield {@code null}. + */ + public static String simpleName(String value) { + if (value == null || value.isBlank() || AstUtils.isAnalysisToken(value)) { + return null; + } + int dot = value.lastIndexOf('.'); + if (dot < 0 || dot >= value.length() - 1) { + return value; + } + // Prefer EnumType.MEMBER stripping (last two segments → MEMBER is wrong; + // use last segment only for identity). + return value.substring(dot + 1); + } + + /** True when both name the same enum member (bare or qualified). */ + public static boolean sameConstant(String a, String b) { + String sa = simpleName(a); + String sb = simpleName(b); + return sa != null && sa.equals(sb); + } + + /** + * Prefer {@code EnumType.MEMBER} when either side is already qualified or + * {@code enumType} is known; otherwise keep the first non-blank. + */ + public static String preferQualified(String existing, String candidate, String enumType) { + String a = existing == null || existing.isBlank() ? null : existing; + String b = candidate == null || candidate.isBlank() ? null : candidate; + if (a == null) { + return qualifyIfPossible(b, enumType); + } + if (b == null) { + return qualifyIfPossible(a, enumType); + } + if (!sameConstant(a, b)) { + return a; + } + String preferred = moreQualified(a, b); + return qualifyIfPossible(preferred, enumType); + } + + private static String moreQualified(String a, String b) { + boolean aQ = a.contains("."); + boolean bQ = b.contains("."); + if (aQ && !bQ) { + return a; + } + if (bQ && !aQ) { + return b; + } + return a; + } + + private static String qualifyIfPossible(String value, String enumType) { + if (value == null || value.isBlank() || AstUtils.isAnalysisToken(value)) { + return value; + } + if (value.contains(".") || enumType == null || enumType.isBlank()) { + return value; + } + String simple = simpleName(value); + if (simple == null || !simple.matches("[A-Z_][A-Z0-9_]*")) { + return value; + } + String typeSimple = enumType.contains(".") + ? enumType.substring(enumType.lastIndexOf('.') + 1) + : enumType; + return typeSimple + "." + simple; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityPolyPublish.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityPolyPublish.java new file mode 100644 index 0000000..f1f53d2 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityPolyPublish.java @@ -0,0 +1,25 @@ +package click.kamil.springstatemachineexporter.analysis.flow.eventid; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shared poly-publish helpers so ACE (and future publishers) do not reinvent + * UNIQUE-vs-ENUM_EXPAND adopt rules. + */ +public final class EventIdentityPolyPublish { + + private EventIdentityPolyPublish() { + } + + /** + * Mutable copy of expand members when the consult is adoptable as poly widen; + * otherwise empty list (never UNIQUE proofs). + */ + public static List adoptExpandOrEmpty(ExpandConsult consult) { + if (consult == null || !consult.adoptableAsPolyExpand()) { + return List.of(); + } + return new ArrayList<>(consult.members()); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolver.java index d776905..d80e932 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolver.java @@ -7,6 +7,8 @@ import click.kamil.springstatemachineexporter.analysis.flow.alloc.InstanceofNarr import click.kamil.springstatemachineexporter.analysis.flow.alloc.ReceiverFacts; import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver; import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import click.kamil.springstatemachineexporter.analysis.pipeline.EventArgumentSelector; +import click.kamil.springstatemachineexporter.analysis.pipeline.IdentityStringTransformSupport; import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry; import click.kamil.springstatemachineexporter.analysis.pipeline.MessageCarrierSupport; import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper; @@ -85,10 +87,6 @@ public final class EventIdentityResolver { "sendEventMono", "trigger"); - /** Identity-preserving String instance methods (0-arg only). */ - private static final Set IDENTITY_STRING_PEELS = Set.of( - "trim", "strip", "toUpperCase", "toLowerCase"); - /** Compact 0-arg identity accessor names; still gated by unique compile-time body constant. */ private static final Set COMPACT_ACCESSOR_NAMES = Set.of( "type", "event", "code", "a", "id"); @@ -129,6 +127,57 @@ public final class EventIdentityResolver { return resolveUnique(expression, methodPath, callGraph, 0, new LinkedHashSet<>()); } + /** + * Hint-gated multi-member expand for unbound dynamic strings. + * Thin wrapper over {@link #resolveExpandConsult}; returns concrete members only. + */ + public List resolveExpanded( + Expression expression, + List methodPath, + Map> callGraph) { + return resolveExpandConsult(expression, methodPath, callGraph).members(); + } + + /** + * Shared unique-or-expand consult for ACE and future poly publishers. + *
    + *
  1. If {@link #resolveUnique} proves one constant → {@link ExpandConsult.Source#UNIQUE}.
  2. + *
  3. Else if expr-local or helper-return expand hint licenses an enum type → + * {@link ExpandConsult.Source#ENUM_EXPAND} with all concrete members.
  4. + *
  5. Else {@link ExpandConsult.Source#EMPTY}. Never returns analysis tokens.
  6. + *
+ * Poly publishers must use {@link ExpandConsult#adoptableAsPolyExpand()} (or + * {@link EventIdentityPolyPublish#adoptExpandOrEmpty}) — never publish UNIQUE from consult. + */ + public ExpandConsult resolveExpandConsult( + Expression expression, + List methodPath, + Map> callGraph) { + if (expression == null) { + return ExpandConsult.empty(); + } + List unique = resolveUnique(expression, methodPath, callGraph); + if (unique.size() == 1) { + String enumType = inferExpandEnumType(expression); + List qualified = qualifyMembers(unique, enumType); + return ExpandConsult.unique(qualified, enumType); + } + if (expressionHasExpandHintDeep(expression)) { + String enumType = inferExpandEnumType(expression); + if (enumType != null && !enumType.isBlank()) { + List members = expandDeclaredEnumMembers(enumType); + if (!members.isEmpty()) { + return ExpandConsult.enumExpand(members, enumType); + } + } + } + ExpandConsult fromHelper = expandViaHelperPath(expression, methodPath); + if (!fromHelper.isEmpty()) { + return fromHelper; + } + return ExpandConsult.empty(); + } + /** * Peel expression to an {@link AccessProof} when a virtual accessor + receiver facts are proven. */ @@ -187,9 +236,8 @@ public final class EventIdentityResolver { preferred.add(arg); return super.visit(node); } - if (isSendLikeName(name) && node.arguments().size() == 1 - && node.arguments().get(0) instanceof Expression arg) { - preferred.add(arg); + if (isSendLikeName(name) && !node.arguments().isEmpty()) { + EventArgumentSelector.selectEventArgument(node).ifPresent(preferred::add); return super.visit(node); } if (node.arguments().size() == 1 && looksLikeValueOfHelper(node)) { @@ -213,22 +261,35 @@ public final class EventIdentityResolver { /** * Resolve preferred exprs; succeed only when every unique proof agrees on the same constant. * Multiple different unique proofs → empty (fail-closed, no first-wins). + * Bare {@code PAY} and qualified {@code OrderEvent.PAY} count as the same proof. */ private List resolveUniqueAgreeing( List exprs, List methodPath, Map> callGraph) { - LinkedHashSet proven = new LinkedHashSet<>(); + // simpleName → preferred (qualified) form + java.util.LinkedHashMap proven = new java.util.LinkedHashMap<>(); for (Expression expr : exprs) { List unique = resolveUnique(expr, methodPath, callGraph); - if (unique.size() == 1) { - proven.add(unique.get(0)); - if (proven.size() > 1) { - return List.of(); - } + if (unique.size() != 1) { + continue; + } + String constant = unique.get(0); + String simple = EventIdentityNormalize.simpleName(constant); + if (simple == null) { + continue; + } + String existing = proven.get(simple); + if (existing == null) { + proven.put(simple, constant); + } else { + proven.put(simple, EventIdentityNormalize.preferQualified(existing, constant, null)); + } + if (proven.size() > 1) { + return List.of(); } } - return proven.size() == 1 ? List.copyOf(proven) : List.of(); + return proven.size() == 1 ? List.copyOf(proven.values()) : List.of(); } private List resolveUnique( @@ -1301,21 +1362,19 @@ public final class EventIdentityResolver { } /** - * Peel identity-preserving String ops: trim/strip/toUpperCase/toLowerCase with 0 args. - * Also peels Optional.of / get / orElseThrow (0-arg) wrappers when the receiver is clearly - * an Optional/Mono/Flux factory chain — not {@code Supplier.get()}. - * Locale/{@code replace} stay intact (fail-closed). + * Peel identity-preserving String ops via {@link IdentityStringTransformSupport} + * (0-arg trim/strip/toUpperCase/toLowerCase). Also peels Optional.of / get / orElseThrow + * (0-arg) wrappers when the receiver is clearly an Optional/Mono/Flux factory chain — + * not {@code Supplier.get()}. Locale/{@code replace} stay intact (fail-closed). */ private static Expression peelIdentityString(Expression expression) { Expression current = peel(expression); boolean progressed; do { progressed = false; - if (current instanceof MethodInvocation mi - && mi.getExpression() != null - && mi.arguments().isEmpty() - && IDENTITY_STRING_PEELS.contains(mi.getName().getIdentifier())) { - current = peel(mi.getExpression()); + Expression afterStringOps = IdentityStringTransformSupport.peelIdentityStringOps(current); + if (afterStringOps != current) { + current = peel(afterStringOps); progressed = true; continue; } @@ -1778,6 +1837,403 @@ public final class EventIdentityResolver { return System.identityHashCode(expression) + "@" + pathKey + "#" + depth; } + private boolean expressionHasExpandHintDeep(Expression expression) { + if (expression == null) { + return false; + } + if (IdentityStringTransformSupport.expressionHasExpandHint(expression)) { + return true; + } + Expression peeled = peel(expression); + if (peeled instanceof MethodInvocation valueOfMi + && "valueOf".equals(valueOfMi.getName().getIdentifier()) + && !valueOfMi.arguments().isEmpty()) { + Expression arg = (Expression) valueOfMi.arguments().get(valueOfMi.arguments().size() - 1); + if (IdentityStringTransformSupport.expressionHasExpandHint(arg)) { + return true; + } + if (arg instanceof SimpleName && variableTracer != null) { + for (Expression def : variableTracer.traceVariableAll(arg)) { + if (IdentityStringTransformSupport.expressionHasExpandHint(def)) { + return true; + } + } + } + } + if (peeled instanceof SimpleName && variableTracer != null) { + for (Expression def : variableTracer.traceVariableAll(peeled)) { + if (IdentityStringTransformSupport.expressionHasExpandHint(def)) { + return true; + } + } + } + return false; + } + + private String inferExpandEnumType(Expression expression) { + Expression peeled = peel(expression); + if (peeled instanceof MethodInvocation valueOfMi + && "valueOf".equals(valueOfMi.getName().getIdentifier())) { + Expression receiver = valueOfMi.getExpression(); + if (receiver != null) { + String fromRecv = typeNameOf(receiver); + if (fromRecv != null) { + return fromRecv; + } + } + if (valueOfMi.arguments().size() == 2 + && valueOfMi.arguments().get(0) instanceof Expression classArg) { + String fromClass = typeNameOf(classArg); + if (fromClass != null) { + return fromClass; + } + } + ITypeBinding binding = valueOfMi.resolveTypeBinding(); + if (binding != null && binding.isEnum()) { + return binding.getQualifiedName(); + } + } + ITypeBinding binding = peeled == null ? null : peeled.resolveTypeBinding(); + if (binding != null && binding.isEnum()) { + return binding.getQualifiedName(); + } + return null; + } + + private static String typeNameOf(Expression expression) { + if (expression == null) { + return null; + } + Expression current = expression; + while (current instanceof ParenthesizedExpression pe) { + current = pe.getExpression(); + } + if (current instanceof SimpleName sn) { + return sn.getIdentifier(); + } + if (current instanceof QualifiedName qn) { + return qn.getFullyQualifiedName(); + } + if (current instanceof Name name) { + return name.getFullyQualifiedName(); + } + ITypeBinding binding = current.resolveTypeBinding(); + if (binding != null) { + String qn = binding.getQualifiedName(); + if (qn != null && !qn.isBlank()) { + return qn; + } + return binding.getName(); + } + return current.toString(); + } + + /** + * Path-owned / same-class helper returns with expand hints (e.g. {@code valueOf(x).name()}). + */ + private ExpandConsult expandViaHelperPath(Expression expression, List methodPath) { + if (expression == null || methodPath == null || methodPath.isEmpty()) { + return ExpandConsult.empty(); + } + MethodDeclaration enclosing = findEnclosingMethod(expression); + String enclosingClass = enclosingClassFqn(enclosing); + List candidates = new ArrayList<>(); + collectExpandCandidates(expression, candidates); + for (Expression candidate : candidates) { + if (!(candidate instanceof MethodInvocation localMethod)) { + continue; + } + if (localMethod.getExpression() != null + && !(localMethod.getExpression() instanceof ThisExpression)) { + continue; + } + String helperName = localMethod.getName().getIdentifier(); + MethodDeclaration helper = resolvePathHelper(methodPath, enclosingClass, helperName); + if (helper == null) { + continue; + } + if (!IdentityStringTransformSupport.returnExpressionsHaveExpandHint(helper)) { + continue; + } + String enumType = inferEnumTypeFromMethod(helper); + if (enumType == null || enumType.isBlank()) { + continue; + } + List members = expandDeclaredEnumMembers(enumType); + if (!members.isEmpty()) { + return ExpandConsult.enumExpand(members, enumType); + } + } + return ExpandConsult.empty(); + } + + private void collectExpandCandidates(Expression expression, List out) { + if (expression == null) { + return; + } + Expression peeled = peel(expression); + if (peeled instanceof SimpleName sn) { + if (variableTracer != null) { + for (Expression def : variableTracer.traceVariableAll(peeled)) { + if (def != null) { + out.add(def); + Expression defPeeled = peel(def); + if (defPeeled != null && defPeeled != def) { + out.add(defPeeled); + } + } + } + } + MethodDeclaration enclosing = findEnclosingMethod(peeled); + Expression init = findLocalInitializer(enclosing, sn.getIdentifier()); + if (init != null) { + out.add(init); + Expression initPeeled = peel(init); + if (initPeeled != null && initPeeled != init) { + out.add(initPeeled); + } + } + } + if (peeled instanceof MethodInvocation valueOfMi + && "valueOf".equals(valueOfMi.getName().getIdentifier()) + && !valueOfMi.arguments().isEmpty()) { + Expression arg = (Expression) valueOfMi.arguments().get(valueOfMi.arguments().size() - 1); + collectExpandCandidates(arg, out); + } + if (peeled != null) { + out.add(peeled); + } + out.add(expression); + } + + private MethodDeclaration resolvePathHelper( + List methodPath, + String enclosingClass, + String helperName) { + if (helperName == null || helperName.isBlank()) { + return null; + } + // Prefer concrete path owners with a hinted method *body* (subclass overrides) + // over abstract declaring types from includeSuper lookups. + if (methodPath != null) { + for (int i = methodPath.size() - 1; i >= 0; i--) { + String methodFqn = methodPath.get(i); + if (methodFqn == null || !methodFqn.contains(".")) { + continue; + } + String ownerFqn = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + MethodDeclaration hinted = findHintedHelperOnOwner(ownerFqn, helperName); + if (hinted != null) { + return hinted; + } + } + } + String pathMethod = findPathMethodByName(methodPath, helperName); + if (pathMethod != null && pathMethod.contains(".")) { + String ownerFqn = pathMethod.substring(0, pathMethod.lastIndexOf('.')); + String methodName = pathMethod.substring(pathMethod.lastIndexOf('.') + 1); + MethodDeclaration hinted = findHintedHelperOnOwner(ownerFqn, methodName); + if (hinted != null) { + return hinted; + } + } + if (enclosingClass != null) { + String fallbackOwner = findPrecedingConcretePathClass(methodPath, enclosingClass); + if (fallbackOwner != null) { + MethodDeclaration hinted = findHintedHelperOnOwner(fallbackOwner, helperName); + if (hinted != null) { + return hinted; + } + } + MethodDeclaration local = findHintedHelperOnOwner(enclosingClass, helperName); + if (local != null) { + return local; + } + } + return null; + } + + /** + * Own-type method first (includeSuper=false), then inherited — but only if the + * declaration has a body with an expand hint (skips abstract stubs). + */ + private MethodDeclaration findHintedHelperOnOwner(String ownerFqn, String helperName) { + TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn); + if (ownerType == null) { + return null; + } + MethodDeclaration own = context.findMethodDeclaration(ownerType, helperName, false); + if (isHintedHelperBody(own)) { + return own; + } + MethodDeclaration inherited = context.findMethodDeclaration(ownerType, helperName, true); + if (inherited != own && isHintedHelperBody(inherited)) { + return inherited; + } + return null; + } + + private static boolean isHintedHelperBody(MethodDeclaration md) { + return md != null + && md.getBody() != null + && IdentityStringTransformSupport.returnExpressionsHaveExpandHint(md); + } + + private static String findPathMethodByName(List path, String methodName) { + if (path == null || methodName == null) { + return null; + } + for (int i = path.size() - 1; i >= 0; i--) { + String methodFqn = path.get(i); + if (methodFqn != null && methodFqn.endsWith("." + methodName)) { + return methodFqn; + } + } + return null; + } + + private static String findPrecedingConcretePathClass(List path, String callerClass) { + if (path == null || callerClass == null) { + return null; + } + for (int i = path.size() - 1; i >= 0; i--) { + String methodFqn = path.get(i); + if (methodFqn == null || !methodFqn.contains(".")) { + continue; + } + String classFqn = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + if (!callerClass.equals(classFqn)) { + return classFqn; + } + } + return null; + } + + private String enclosingClassFqn(MethodDeclaration enclosing) { + if (enclosing == null) { + return null; + } + ASTNode parent = enclosing.getParent(); + while (parent != null && !(parent instanceof TypeDeclaration)) { + parent = parent.getParent(); + } + if (parent instanceof TypeDeclaration td) { + return context.getFqn(td); + } + return null; + } + + private String inferEnumTypeFromMethod(MethodDeclaration methodDeclaration) { + if (methodDeclaration == null || methodDeclaration.getBody() == null) { + return null; + } + final String[] enumType = {null}; + methodDeclaration.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (!"valueOf".equals(node.getName().getIdentifier()) || node.getExpression() == null) { + return super.visit(node); + } + String receiver = typeNameOf(node.getExpression()); + if (receiver != null && !receiver.isBlank() && Character.isUpperCase(receiver.charAt(0))) { + enumType[0] = receiver; + return false; + } + return super.visit(node); + } + }); + return enumType[0]; + } + + private List qualifyMembers(List members, String enumType) { + if (members == null || members.isEmpty()) { + return List.of(); + } + String simpleType = simpleEnumTypeName(enumType); + if (simpleType == null) { + return List.copyOf(members); + } + List qualified = new ArrayList<>(); + for (String member : members) { + if (member == null || member.isBlank()) { + continue; + } + String normalized = qualifyMember(member, simpleType); + if (normalized != null && !qualified.contains(normalized)) { + qualified.add(normalized); + } + } + return qualified; + } + + private static String qualifyMember(String member, String simpleType) { + if (member == null || member.isBlank() || simpleType == null) { + return member; + } + if (member.startsWith(" expandDeclaredEnumMembers(String declaredType) { + if (declaredType == null || declaredType.isBlank() || context == null) { + return List.of(); + } + List enumValues = context.getEnumValues(declaredType); + if ((enumValues == null || enumValues.isEmpty()) && declaredType.contains(".")) { + enumValues = context.getEnumValues(declaredType.substring(declaredType.lastIndexOf('.') + 1)); + } + if (enumValues == null || enumValues.isEmpty()) { + TypeDeclaration td = context.getTypeDeclaration(declaredType); + if (td == null && declaredType.contains(".")) { + td = context.getTypeDeclaration(declaredType.substring(declaredType.lastIndexOf('.') + 1)); + } + if (td != null) { + String fqn = context.getFqn(td); + if (fqn != null) { + enumValues = context.getEnumValues(fqn); + } + } + } + if (enumValues == null || enumValues.isEmpty()) { + return List.of(); + } + String simpleType = simpleEnumTypeName(declaredType); + List expanded = new ArrayList<>(); + for (String enumValue : enumValues) { + if (enumValue == null || enumValue.isBlank()) { + continue; + } + String parsed = enumValue.contains(".") + ? enumValue.substring(enumValue.lastIndexOf('.', enumValue.lastIndexOf('.') - 1) + 1) + : enumValue; + String qualified = qualifyMember(parsed, simpleType); + if (qualified != null && !expanded.contains(qualified)) { + expanded.add(qualified); + } + } + return expanded; + } + public static boolean isSendLikeName(String name) { return name != null && SEND_LIKE_NAMES.contains(name); } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/ExpandConsult.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/ExpandConsult.java new file mode 100644 index 0000000..cdfb638 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/ExpandConsult.java @@ -0,0 +1,81 @@ +package click.kamil.springstatemachineexporter.analysis.flow.eventid; + +import java.util.List; + +/** + * Shared result for unique-or-hint-gated enum expand. + * Members are concrete only (never {@code ENUM_SET:}/{@code SYMBOLIC} tokens). + *

+ * Publish contract: do not publish {@link Source#UNIQUE} into polymorphic + * events from this consult — use {@code recoverFromPath} / {@code resolveUnique} instead. + * Poly widen only when {@link #adoptableAsPolyExpand()} is true (hint-licensed expand). + * Do not treat non-empty {@link #members()} as expand without that check. + */ +public final class ExpandConsult { + + public enum Source { + UNIQUE, + ENUM_EXPAND, + EMPTY + } + + private final List members; + private final Source source; + private final boolean licensedByHint; + private final String enumType; + + private ExpandConsult(List members, Source source, boolean licensedByHint, String enumType) { + this.members = members == null ? List.of() : List.copyOf(members); + this.source = source == null ? Source.EMPTY : source; + this.licensedByHint = licensedByHint; + this.enumType = enumType; + } + + public static ExpandConsult empty() { + return new ExpandConsult(List.of(), Source.EMPTY, false, null); + } + + public static ExpandConsult unique(List members, String enumType) { + if (members == null || members.size() != 1) { + return empty(); + } + return new ExpandConsult(members, Source.UNIQUE, false, enumType); + } + + public static ExpandConsult enumExpand(List members, String enumType) { + if (members == null || members.isEmpty()) { + return empty(); + } + return new ExpandConsult(members, Source.ENUM_EXPAND, true, enumType); + } + + public List members() { + return members; + } + + public Source source() { + return source; + } + + public boolean licensedByHint() { + return licensedByHint; + } + + public String enumType() { + return enumType; + } + + public boolean isEmpty() { + return source == Source.EMPTY || members.isEmpty(); + } + + /** Hint-licensed multi-member widen suitable for polymorphicEvents. */ + public boolean adoptableAsPolyExpand() { + return source == Source.ENUM_EXPAND && !members.isEmpty(); + } + + /** Single proven constant — publish via unique/recover, not poly expand. */ + public boolean isUniqueProof() { + return source == Source.UNIQUE && members.size() == 1; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/EnumExpansionHintSupport.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/EnumExpansionHintSupport.java deleted file mode 100644 index be1a236..0000000 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/EnumExpansionHintSupport.java +++ /dev/null @@ -1,64 +0,0 @@ -package click.kamil.springstatemachineexporter.analysis.pipeline; - -import org.eclipse.jdt.core.dom.ASTNode; -import org.eclipse.jdt.core.dom.ASTVisitor; -import org.eclipse.jdt.core.dom.Expression; -import org.eclipse.jdt.core.dom.MethodDeclaration; -import org.eclipse.jdt.core.dom.MethodInvocation; - -/** - * Shared AST detection of dynamic string transforms that justify widening - * {@code } to concrete enum members (ACE poly fill). - *

- * Qualifying invocations: zero-arg {@code name()} (Enum.name peel), and - * {@code toUpperCase} with any arity (including {@code Locale}). Does not match - * {@code getName()}, string/comment substrings, or other {@code *name*} methods. - */ -public final class EnumExpansionHintSupport { - - private EnumExpansionHintSupport() { - } - - public static boolean expressionHasHint(Expression expr) { - return nodeHasHint(expr); - } - - public static boolean methodBodyHasHint(MethodDeclaration methodDeclaration) { - if (methodDeclaration == null || methodDeclaration.getBody() == null) { - return false; - } - return nodeHasHint(methodDeclaration.getBody()); - } - - private static boolean nodeHasHint(ASTNode node) { - if (node == null) { - return false; - } - final boolean[] hint = {false}; - node.accept(new ASTVisitor() { - @Override - public boolean visit(MethodInvocation invocation) { - if (isQualifyingHint(invocation)) { - hint[0] = true; - return false; - } - return super.visit(invocation); - } - }); - return hint[0]; - } - - static boolean isQualifyingHint(MethodInvocation invocation) { - if (invocation == null || invocation.getName() == null) { - return false; - } - String name = invocation.getName().getIdentifier(); - if ("name".equals(name)) { - return invocation.arguments().isEmpty(); - } - if ("toUpperCase".equals(name)) { - return true; - } - return false; - } -} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/EventArgumentSelector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/EventArgumentSelector.java new file mode 100644 index 0000000..625e0de --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/EventArgumentSelector.java @@ -0,0 +1,205 @@ +package click.kamil.springstatemachineexporter.analysis.pipeline; + +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.ITypeBinding; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.SimpleName; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * Chooses which argument of a send-like call is the event expression. + * Fail-closed when multi-arg shapes are ambiguous. + */ +public final class EventArgumentSelector { + + private EventArgumentSelector() { + } + + /** + * @return the event argument expression, or empty when none / ambiguous + */ + public static Optional selectEventArgument(MethodInvocation call) { + if (call == null || call.arguments().isEmpty()) { + return Optional.empty(); + } + List args = new ArrayList<>(); + for (Object argObj : call.arguments()) { + if (argObj instanceof Expression expr) { + args.add(expr); + } + } + if (args.isEmpty()) { + return Optional.empty(); + } + if (args.size() == 1) { + return Optional.of(args.get(0)); + } + + Expression arg0 = args.get(0); + Expression arg1 = args.get(1); + + if (looksLikeHeaders(arg0)) { + for (int i = 1; i < args.size(); i++) { + if (looksLikeEvent(args.get(i))) { + return Optional.of(args.get(i)); + } + } + } + + if (looksLikeEvent(arg0) && looksLikeState(arg1)) { + return Optional.of(arg0); + } + + Expression soleEnumOrMessage = null; + int hits = 0; + for (Expression arg : args) { + if (looksLikeEnumOrMessageType(arg)) { + hits++; + soleEnumOrMessage = arg; + } + } + if (hits == 1) { + return Optional.of(soleEnumOrMessage); + } + + return Optional.empty(); + } + + /** True when {@code selected} is the same AST node as {@code call}'s first argument. */ + public static boolean selectedIsFirstArgument(MethodInvocation call, Expression selected) { + if (call == null || selected == null || call.arguments().isEmpty()) { + return false; + } + return call.arguments().get(0) == selected; + } + + static boolean looksLikeHeaders(Expression expr) { + if (expr == null) { + return false; + } + String name = simpleNameOf(expr); + if (name != null) { + String lower = name.toLowerCase(Locale.ROOT); + if (lower.contains("header") || "headers".equals(lower) || "hdrs".equals(lower)) { + return true; + } + } + String type = typeSimpleName(expr); + if (type != null) { + String lower = type.toLowerCase(Locale.ROOT); + if (lower.contains("header") || "map".equals(lower) || lower.endsWith("map")) { + return true; + } + } + return false; + } + + static boolean looksLikeEvent(Expression expr) { + if (expr == null) { + return false; + } + if (looksLikeEnumOrMessageType(expr)) { + return true; + } + String name = simpleNameOf(expr); + if (name != null) { + String lower = name.toLowerCase(Locale.ROOT); + if ("event".equals(lower) + || "payload".equals(lower) + || "e".equals(lower) + || "ev".equals(lower) + || lower.endsWith("event") + || lower.endsWith("payload")) { + return true; + } + } + String text = expr.toString(); + return text != null && (text.contains("valueOf") || text.contains("Event.")); + } + + static boolean looksLikeState(Expression expr) { + if (expr == null) { + return false; + } + String name = simpleNameOf(expr); + if (name != null) { + String lower = name.toLowerCase(Locale.ROOT); + if ("state".equals(lower) + || "source".equals(lower) + || "sourcestate".equals(lower) + || "current".equals(lower) + || lower.endsWith("state")) { + return true; + } + } + String type = typeSimpleName(expr); + if (type != null) { + String lower = type.toLowerCase(Locale.ROOT); + if (lower.contains("state") && !lower.contains("statemachine")) { + return true; + } + } + String text = expr.toString(); + return text != null && text.contains("State."); + } + + static boolean looksLikeEnumOrMessageType(Expression expr) { + if (expr == null) { + return false; + } + ITypeBinding binding = expr.resolveTypeBinding(); + if (binding != null) { + if (binding.isEnum()) { + return true; + } + String qn = binding.getQualifiedName(); + if (qn != null && (qn.equals("org.springframework.messaging.Message") + || qn.startsWith("org.springframework.messaging.Message<"))) { + return true; + } + String simple = binding.getName(); + if (simple != null && "Message".equals(simple)) { + return true; + } + } + String type = typeSimpleName(expr); + if (type != null && ("Message".equals(type) || type.endsWith("Message"))) { + return true; + } + return textSuggestsEnumConstant(expr); + } + + private static boolean textSuggestsEnumConstant(Expression expr) { + String text = expr.toString(); + return text != null && (text.contains(".valueOf") + || text.matches(".*\\b[A-Z][A-Za-z0-9_]*\\.[A-Z][A-Z0-9_]+\\b.*")); + } + + private static String simpleNameOf(Expression expr) { + if (expr instanceof SimpleName sn) { + return sn.getIdentifier(); + } + return null; + } + + private static String typeSimpleName(Expression expr) { + ITypeBinding binding = expr.resolveTypeBinding(); + if (binding != null) { + String name = binding.getName(); + if (name != null && !name.isBlank()) { + int generic = name.indexOf('<'); + return generic >= 0 ? name.substring(0, generic) : name; + } + String qn = binding.getErasure().getQualifiedName(); + if (qn != null && qn.contains(".")) { + return qn.substring(qn.lastIndexOf('.') + 1); + } + return qn; + } + return null; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/IdentityStringTransformSupport.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/IdentityStringTransformSupport.java new file mode 100644 index 0000000..b71d920 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/IdentityStringTransformSupport.java @@ -0,0 +1,186 @@ +package click.kamil.springstatemachineexporter.analysis.pipeline; + +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.ExpressionMethodReference; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.ParenthesizedExpression; +import org.eclipse.jdt.core.dom.ReturnStatement; +import org.eclipse.jdt.core.dom.SimpleName; +import org.eclipse.jdt.core.dom.TypeMethodReference; + +import java.util.Set; + +/** + * Shared string-transform policy for EventIdentity peels and ACE enum-expansion hints. + *

+ * Two roles (Locale arity intentionally split): + *

    + *
  • Peel — identity unwrap for unique proofs: 0-arg {@code trim}/{@code strip}/ + * {@code toUpperCase}/{@code toLowerCase}. Locale arity stays unpeeled (fail-closed).
  • + *
  • Expand hint — license to widen unbound {@code }: 0-arg + * {@code name()}, plus {@code toUpperCase}/{@code toLowerCase} with any arity, + * including method references ({@code String::toUpperCase}, {@code Enum::name}). + * {@code trim}/{@code strip} alone do not expand.
  • + *
+ */ +public final class IdentityStringTransformSupport { + + private static final Set PEEL_NAMES = Set.of( + "trim", "strip", "toUpperCase", "toLowerCase"); + + private static final Set EXPAND_HINT_NAMES = Set.of( + "name", "toUpperCase", "toLowerCase"); + + private IdentityStringTransformSupport() { + } + + /** Identity-preserving String instance method with 0 args. */ + public static boolean isPeelInvocation(MethodInvocation invocation) { + if (invocation == null || invocation.getName() == null) { + return false; + } + if (!PEEL_NAMES.contains(invocation.getName().getIdentifier())) { + return false; + } + return invocation.arguments().isEmpty(); + } + + /** + * License to widen SYMBOLIC poly: 0-arg {@code name()}, or case transforms any arity. + * Does not match {@code getName()} or {@code trim}/{@code strip} alone. + */ + public static boolean isExpandHintInvocation(MethodInvocation invocation) { + if (invocation == null || invocation.getName() == null) { + return false; + } + String name = invocation.getName().getIdentifier(); + if ("name".equals(name)) { + return invocation.arguments().isEmpty(); + } + return "toUpperCase".equals(name) || "toLowerCase".equals(name); + } + + /** + * Method references that carry the same expand license as invocations + * ({@code String::toUpperCase}, {@code OrderEvent::name}). Not peels. + */ + public static boolean isExpandHintMethodReference(Expression expression) { + String name = methodReferenceName(expression); + if (name == null) { + return false; + } + return EXPAND_HINT_NAMES.contains(name); + } + + /** + * Repeatedly unwraps 0-arg peel invocations (and parentheses). Does not peel casts, + * Optional/container wrappers, or method references — callers own those. + */ + public static Expression peelIdentityStringOps(Expression expression) { + Expression current = expression; + boolean progressed; + do { + progressed = false; + while (current instanceof ParenthesizedExpression pe) { + current = pe.getExpression(); + progressed = true; + } + if (current instanceof MethodInvocation mi + && mi.getExpression() != null + && isPeelInvocation(mi)) { + current = mi.getExpression(); + progressed = true; + } + } while (progressed); + return current; + } + + public static boolean expressionHasExpandHint(Expression expr) { + return nodeHasExpandHint(expr); + } + + /** + * Whole-body scan (includes logging / dead code). Prefer + * {@link #returnExpressionsHaveExpandHint(MethodDeclaration)} for ACE helper follow-up. + */ + public static boolean methodBodyHasExpandHint(MethodDeclaration methodDeclaration) { + if (methodDeclaration == null || methodDeclaration.getBody() == null) { + return false; + } + return nodeHasExpandHint(methodDeclaration.getBody()); + } + + /** + * True when any {@code return} expression carries an expand hint. + * Side-effect invocations elsewhere in the body do not count. + */ + public static boolean returnExpressionsHaveExpandHint(MethodDeclaration methodDeclaration) { + if (methodDeclaration == null || methodDeclaration.getBody() == null) { + return false; + } + final boolean[] hint = {false}; + methodDeclaration.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(ReturnStatement node) { + if (expressionHasExpandHint(node.getExpression())) { + hint[0] = true; + return false; + } + return super.visit(node); + } + }); + return hint[0]; + } + + private static boolean nodeHasExpandHint(ASTNode node) { + if (node == null) { + return false; + } + final boolean[] hint = {false}; + node.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation invocation) { + if (isExpandHintInvocation(invocation)) { + hint[0] = true; + return false; + } + return super.visit(invocation); + } + + @Override + public boolean visit(ExpressionMethodReference node) { + if (isExpandHintMethodReference(node)) { + hint[0] = true; + return false; + } + return super.visit(node); + } + + @Override + public boolean visit(TypeMethodReference node) { + if (isExpandHintMethodReference(node)) { + hint[0] = true; + return false; + } + return super.visit(node); + } + }); + return hint[0]; + } + + private static String methodReferenceName(Expression expression) { + if (expression instanceof ExpressionMethodReference emr && emr.getName() != null) { + return emr.getName().getIdentifier(); + } + if (expression instanceof TypeMethodReference tmr && tmr.getName() != null) { + return tmr.getName().getIdentifier(); + } + if (expression instanceof SimpleName) { + return null; + } + return null; + } +} 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 b0561b2..0137068 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 @@ -4,9 +4,12 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.flow.alloc.AllocationFactPropagator; import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver; import click.kamil.springstatemachineexporter.analysis.flow.eventid.EventIdentityResolver; +import click.kamil.springstatemachineexporter.analysis.flow.eventid.EventIdentityNormalize; +import click.kamil.springstatemachineexporter.analysis.flow.eventid.EventIdentityPolyPublish; +import click.kamil.springstatemachineexporter.analysis.flow.eventid.ExpandConsult; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver; -import click.kamil.springstatemachineexporter.analysis.pipeline.EnumExpansionHintSupport; +import click.kamil.springstatemachineexporter.analysis.pipeline.EventArgumentSelector; import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder; import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry; import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper; @@ -1293,7 +1296,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { // sendEvent arg may already be / full enum widen while the dispatcher // frame still has valueOf(wrapper.getEvent().getType()) with a proven CIC — narrow here. - if (polymorphicEvents.size() != 1 + // Skip only when poly already holds a unique concrete (SYMBOLIC size==1 must still recover). + if (!hasUniqueConcretePolymorphic(polymorphicEvents) && tryRecoverAllocationSensitiveInto(path, callGraph, polymorphicEvents)) { isAmbiguous = false; } @@ -3623,27 +3627,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return constants; } - private List expandDeclaredEnumValues(String declaredType) { - if (declaredType == null) { - return List.of(); - } - List enumValues = context.getEnumValues(declaredType); - if ((enumValues == null || enumValues.isEmpty()) && declaredType.contains(".")) { - enumValues = context.getEnumValues(declaredType.substring(declaredType.lastIndexOf('.') + 1)); - } - if (enumValues == null || enumValues.isEmpty()) { - return List.of(); - } - List expanded = new ArrayList<>(); - for (String enumValue : enumValues) { - String parsed = constantExtractor.parseEnumSetElement(enumValue); - if (parsed != null && !expanded.contains(parsed)) { - expanded.add(parsed); - } - } - return expanded; - } - private boolean isRuntimeEnumParameter(Expression exprNode) { if (!(exprNode instanceof MethodInvocation mi)) { return false; @@ -3781,34 +3764,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (polymorphicEvents == null) { return polymorphicEvents; } - // Empty poly: expand only via name()/toUpperCase caller hints (PolymorphicDispatch). + // Shared EI expand consult — ACE only publishes ENUM_EXPAND via shared policy. + // UNIQUE without hint stays on tryRecoverAllocationSensitiveInto (fail-closed disagree). + ExpandConsult consult = tryExpandViaEventIdentity(path, callGraph); + List fromEi = EventIdentityPolyPublish.adoptExpandOrEmpty(consult); + boolean eiExpand = !fromEi.isEmpty(); if (polymorphicEvents.isEmpty()) { - if (callerArgumentHasEnumExpansionHint(path)) { - List fromCaller = resolvePolymorphicEventsFromCallerArgument(path); - if (fromCaller.stream().anyMatch(this::looksLikeEnumConstant)) { - return fromCaller; - } + if (eiExpand) { + return fromEi; } return polymorphicEvents; } Map bindings = resolvePathBindings(path, callGraph, Map.of()); if (bindings.isEmpty()) { boolean symbolicOnly = polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant); - if (symbolicOnly) { - List hinted = resolvePolymorphicEventsFromCallerArgument(path); - // Only adopt when caller-arg path set an expansion hint (toUpperCase/name), - // detected as concrete enum members without relying on multi-class widen alone. - if (hinted.stream().anyMatch(this::looksLikeEnumConstant) - && callerArgumentHasEnumExpansionHint(path)) { - return hinted; - } - } - if (hasMultiClassPolymorphicPath(path) - && polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) { - List callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path); - if (!callerArgumentResolved.isEmpty()) { - return callerArgumentResolved; - } + if (eiExpand && symbolicOnly) { + return fromEi; } return polymorphicEvents; } @@ -3817,22 +3788,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { List bindingResolved = resolvePolymorphicEventsFromBindings(bindings, path); boolean bindingHasConcrete = bindingResolved.stream().anyMatch(this::looksLikeEnumConstant); if (bindingHasConcrete) { - narrowed = new ArrayList<>(bindingResolved); + narrowed = new ArrayList<>(); + for (String candidate : bindingResolved) { + addConstantPreferQualified(narrowed, candidate); + } } else { for (String candidate : bindingResolved) { - if (candidate != null && !narrowed.contains(candidate)) { - narrowed.add(candidate); - } + addConstantPreferQualified(narrowed, candidate); } } } boolean originallySymbolicOnly = polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant); - if (originallySymbolicOnly && narrowed.stream().filter(this::looksLikeEnumConstant).count() <= 1) { - List callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path); - for (String candidate : callerArgumentResolved) { - if (candidate != null && !narrowed.contains(candidate)) { - narrowed.add(candidate); - } + if (originallySymbolicOnly + && narrowed.stream().filter(this::looksLikeEnumConstant).count() <= 1 + && eiExpand) { + for (String candidate : fromEi) { + addConstantPreferQualified(narrowed, candidate); } } boolean hasConcrete = narrowed.stream().anyMatch(this::looksLikeEnumConstant); @@ -3846,6 +3817,67 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return narrowed; } + /** Merge bare/qualified enum constants without double-counting. */ + private void addConstantPreferQualified(List target, String candidate) { + if (candidate == null || candidate.isBlank()) { + return; + } + for (int i = 0; i < target.size(); i++) { + if (EventIdentityNormalize.sameConstant(target.get(i), candidate)) { + target.set(i, EventIdentityNormalize.preferQualified(target.get(i), candidate, null)); + return; + } + } + target.add(candidate); + } + + /** + * Shared EI expand for hint-gated unbound valueOf / string transforms / helper returns. + */ + private ExpandConsult tryExpandViaEventIdentity( + List path, + Map> callGraph) { + if (eventIdentityResolver == null || path == null || path.size() < 2) { + return ExpandConsult.empty(); + } + String callerMethod = path.get(path.size() - 2); + String targetMethod = path.get(path.size() - 1); + String callerClass = callerMethod.substring(0, callerMethod.lastIndexOf('.')); + String callerMethodName = callerMethod.substring(callerMethod.lastIndexOf('.') + 1); + String targetMethodName = targetMethod.substring(targetMethod.lastIndexOf('.') + 1); + TypeDeclaration callerType = context.getTypeDeclaration(callerClass); + if (callerType == null) { + return ExpandConsult.empty(); + } + MethodDeclaration callerDeclaration = context.findMethodDeclaration(callerType, callerMethodName, true); + if (callerDeclaration == null || callerDeclaration.getBody() == null) { + return ExpandConsult.empty(); + } + Expression[] eventArg = {null}; + callerDeclaration.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (!targetMethodName.equals(node.getName().getIdentifier()) || node.arguments().isEmpty()) { + return super.visit(node); + } + eventArg[0] = EventArgumentSelector.selectEventArgument(node).orElse(null); + return eventArg[0] == null; + } + }); + if (eventArg[0] == null) { + return ExpandConsult.empty(); + } + Map> graphForFacts = callGraph != null ? callGraph : this.graph; + return eventIdentityResolver.resolveExpandConsult(eventArg[0], path, graphForFacts); + } + + private boolean hasUniqueConcretePolymorphic(List polymorphicEvents) { + if (polymorphicEvents == null || polymorphicEvents.size() != 1) { + return false; + } + return looksLikeEnumConstant(polymorphicEvents.get(0)); + } + private List resolvePolymorphicEventsFromBindings( Map bindings, List path) { @@ -3895,344 +3927,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return resolved.stream().distinct().collect(java.util.stream.Collectors.toList()); } - private List resolvePolymorphicEventsFromCallerArgument(List path) { - if (path == null || path.size() < 2) { - return List.of(); - } - String callerMethod = path.get(path.size() - 2); - String targetMethod = path.get(path.size() - 1); - String callerClass = callerMethod.substring(0, callerMethod.lastIndexOf('.')); - String callerMethodName = callerMethod.substring(callerMethod.lastIndexOf('.') + 1); - String targetMethodName = targetMethod.substring(targetMethod.lastIndexOf('.') + 1); - TypeDeclaration callerType = context.getTypeDeclaration(callerClass); - if (callerType == null) { - return List.of(); - } - MethodDeclaration callerDeclaration = context.findMethodDeclaration(callerType, callerMethodName, true); - if (callerDeclaration == null || callerDeclaration.getBody() == null) { - return List.of(); - } - List resolved = new ArrayList<>(); - final boolean[] expansionHint = {false}; - callerDeclaration.accept(new ASTVisitor() { - @Override - public boolean visit(MethodInvocation node) { - if (!targetMethodName.equals(node.getName().getIdentifier()) || node.arguments().isEmpty()) { - return super.visit(node); - } - Expression argument = (Expression) node.arguments().get(0); - List candidateExpressions = new ArrayList<>(); - if (argument instanceof SimpleName && variableTracer != null) { - candidateExpressions.addAll(variableTracer.traceVariableAll(argument)); - } - if (candidateExpressions.isEmpty()) { - candidateExpressions.add(argument); - } - for (Expression candidateExpr : candidateExpressions) { - if (candidateExpr == null) { - continue; - } - if (EnumExpansionHintSupport.expressionHasHint(candidateExpr)) { - expansionHint[0] = true; - } - if (candidateExpr instanceof MethodInvocation localMethod - && (localMethod.getExpression() == null - || localMethod.getExpression() instanceof ThisExpression)) { - String resolvedPathMethod = findPathMethodByName(path, localMethod.getName().getIdentifier()); - if (resolvedPathMethod == null) { - String fallbackOwner = findPrecedingConcretePathClass(path, callerClass); - if (fallbackOwner != null) { - TypeDeclaration fallbackType = context.getTypeDeclaration(fallbackOwner); - MethodDeclaration fallbackMethod = fallbackType != null - ? context.findMethodDeclaration( - fallbackType, localMethod.getName().getIdentifier(), true) - : null; - if (fallbackMethod != null) { - resolvedPathMethod = fallbackOwner + "." + localMethod.getName().getIdentifier(); - } - } - } - if (resolvedPathMethod == null - && context.findMethodDeclaration( - callerType, localMethod.getName().getIdentifier(), true) != null) { - resolvedPathMethod = callerClass + "." + localMethod.getName().getIdentifier(); - } - if (resolvedPathMethod != null && resolvedPathMethod.contains(".")) { - String ownerFqn = resolvedPathMethod.substring(0, resolvedPathMethod.lastIndexOf('.')); - String methodName = resolvedPathMethod.substring(resolvedPathMethod.lastIndexOf('.') + 1); - TypeDeclaration pathOwnerType = context.getTypeDeclaration(ownerFqn); - MethodDeclaration pathMethodDeclaration = pathOwnerType != null - ? context.findMethodDeclaration(pathOwnerType, methodName, true) - : null; - if (EnumExpansionHintSupport.methodBodyHasHint(pathMethodDeclaration)) { - expansionHint[0] = true; - String hintedEnumType = inferEnumTypeFromMethod(pathMethodDeclaration); - if (hintedEnumType != null) { - for (String expanded : expandDeclaredEnumValues(hintedEnumType)) { - addResolvedConstantToList(expanded, resolved); - } - } - } - for (String candidate : constantExtractor.resolveMethodReturnConstant( - ownerFqn, methodName, 0, new HashSet<>(), null)) { - addResolvedConstantToList(candidate, resolved); - } - } - for (String candidate : resolveLocalSwitchMethodConstants(callerType, localMethod)) { - addResolvedConstantToList(candidate, resolved); - } - } - String directResolved = constantResolver.resolve(candidateExpr, context); - if (directResolved != null) { - addResolvedConstantToList(directResolved, resolved); - } - List constants = new ArrayList<>(); - constantExtractor.extractConstantsFromExpression(candidateExpr, constants); - for (String candidate : constants) { - addResolvedConstantToList(candidate, resolved); - } - } - return super.visit(node); - } - }); - List distinct = resolved.stream().distinct().collect(java.util.stream.Collectors.toList()); - if (distinct.stream().noneMatch(AbstractCallGraphEngine.this::looksLikeEnumConstant)) { - for (int i = Math.max(0, path.size() - 2); i >= 0; i--) { - String pathMethod = path.get(i); - if (pathMethod == null || !pathMethod.contains(".")) { - continue; - } - String ownerFqn = pathMethod.substring(0, pathMethod.lastIndexOf('.')); - String methodName = pathMethod.substring(pathMethod.lastIndexOf('.') + 1); - for (String candidate : constantExtractor.resolveMethodReturnConstant( - ownerFqn, methodName, 0, new HashSet<>(), null)) { - addResolvedConstantToList(candidate, distinct); - } - } - } - if ((expansionHint[0] || hasMultiClassPolymorphicPath(path)) - && distinct.stream().noneMatch(AbstractCallGraphEngine.this::looksLikeEnumConstant)) { - List expanded = expandSingleSymbolicType(distinct); - if (expanded.isEmpty() && (expansionHint[0] || hasMultiClassPolymorphicPath(path))) { - expanded = inferEnumValuesFromPath(path); - } - if (expanded.isEmpty() && expansionHint[0]) { - String enumType = inferEnumTypeFromMethod(callerDeclaration); - if (enumType != null) { - expanded = expandDeclaredEnumValues(enumType); - } - } - if (!expanded.isEmpty()) { - return expanded; - } - } - return distinct; - } - - /** - * True when the sendEvent (or trigger) argument at the caller shows a dynamic string - * transform ({@code toUpperCase}/{@code name}) that should widen SYMBOLIC to enum members. - */ - private boolean callerArgumentHasEnumExpansionHint(List path) { - if (path == null || path.size() < 2) { - return false; - } - String callerMethod = path.get(path.size() - 2); - String targetMethod = path.get(path.size() - 1); - String callerClass = callerMethod.substring(0, callerMethod.lastIndexOf('.')); - String callerMethodName = callerMethod.substring(callerMethod.lastIndexOf('.') + 1); - String targetMethodName = targetMethod.substring(targetMethod.lastIndexOf('.') + 1); - TypeDeclaration callerType = context.getTypeDeclaration(callerClass); - if (callerType == null) { - return false; - } - MethodDeclaration callerDeclaration = context.findMethodDeclaration(callerType, callerMethodName, true); - if (callerDeclaration == null || callerDeclaration.getBody() == null) { - return false; - } - final boolean[] hint = {false}; - callerDeclaration.accept(new ASTVisitor() { - @Override - public boolean visit(MethodInvocation node) { - if (!targetMethodName.equals(node.getName().getIdentifier()) || node.arguments().isEmpty()) { - return super.visit(node); - } - Expression argument = (Expression) node.arguments().get(0); - List toCheck = new ArrayList<>(); - if (argument instanceof SimpleName && variableTracer != null) { - toCheck.addAll(variableTracer.traceVariableAll(argument)); - } - if (toCheck.isEmpty() && argument != null) { - toCheck.add(argument); - } - for (Expression argumentExpr : toCheck) { - if (argumentExpr != null && EnumExpansionHintSupport.expressionHasHint(argumentExpr)) { - hint[0] = true; - return false; - } - if (argumentExpr instanceof MethodInvocation localMethod - && (localMethod.getExpression() == null - || localMethod.getExpression() instanceof ThisExpression)) { - String owner = findPathMethodByName(path, localMethod.getName().getIdentifier()); - if (owner == null) { - String fallbackOwner = findPrecedingConcretePathClass(path, callerClass); - if (fallbackOwner != null) { - owner = fallbackOwner + "." + localMethod.getName().getIdentifier(); - } - } - if (owner == null) { - // Same-class private helper not always on the call path FQN list - MethodDeclaration localMd = context.findMethodDeclaration( - callerType, localMethod.getName().getIdentifier(), true); - if (EnumExpansionHintSupport.methodBodyHasHint(localMd)) { - hint[0] = true; - return false; - } - } - if (owner != null && owner.contains(".")) { - String ownerFqn = owner.substring(0, owner.lastIndexOf('.')); - String methodName = owner.substring(owner.lastIndexOf('.') + 1); - TypeDeclaration td = context.getTypeDeclaration(ownerFqn); - MethodDeclaration md = td != null - ? context.findMethodDeclaration(td, methodName, true) : null; - if (EnumExpansionHintSupport.methodBodyHasHint(md)) { - hint[0] = true; - return false; - } - } - } - } - return super.visit(node); - } - }); - return hint[0]; - } - - private List inferEnumValuesFromPath(List path) { - if (path == null || path.isEmpty()) { - return List.of(); - } - for (int i = path.size() - 1; i >= 0; i--) { - String methodFqn = path.get(i); - if (methodFqn == null || !methodFqn.contains(".")) { - continue; - } - String ownerFqn = methodFqn.substring(0, methodFqn.lastIndexOf('.')); - String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); - TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn); - MethodDeclaration methodDeclaration = ownerType != null - ? context.findMethodDeclaration(ownerType, methodName, true) - : null; - String enumType = inferEnumTypeFromMethod(methodDeclaration); - if (enumType == null && ownerType != null) { - for (MethodDeclaration candidateMethod : ownerType.getMethods()) { - enumType = inferEnumTypeFromMethod(candidateMethod); - if (enumType != null) { - break; - } - } - } - if (enumType != null) { - List expanded = expandDeclaredEnumValues(enumType); - if (!expanded.isEmpty()) { - return expanded; - } - } - } - return List.of(); - } - - private String inferEnumTypeFromMethod(MethodDeclaration methodDeclaration) { - if (methodDeclaration == null || methodDeclaration.getBody() == null) { - return null; - } - final String[] enumType = {null}; - methodDeclaration.accept(new ASTVisitor() { - @Override - public boolean visit(MethodInvocation node) { - if (!"valueOf".equals(node.getName().getIdentifier()) || node.getExpression() == null) { - return super.visit(node); - } - String receiver = node.getExpression().toString(); - if (receiver != null && !receiver.isBlank() && Character.isUpperCase(receiver.charAt(0))) { - enumType[0] = receiver; - return false; - } - return super.visit(node); - } - }); - return enumType[0]; - } - - private boolean hasMultiClassPolymorphicPath(List path) { - if (path == null || path.isEmpty()) { - return false; - } - Set classes = new LinkedHashSet<>(); - for (String methodFqn : path) { - if (methodFqn == null || !methodFqn.contains(".")) { - continue; - } - classes.add(methodFqn.substring(0, methodFqn.lastIndexOf('.'))); - } - return classes.size() > 2; - } - - private String findPrecedingConcretePathClass(List path, String callerClass) { - if (path == null || callerClass == null) { - return null; - } - for (int i = path.size() - 1; i >= 0; i--) { - String methodFqn = path.get(i); - if (methodFqn == null || !methodFqn.contains(".")) { - continue; - } - String classFqn = methodFqn.substring(0, methodFqn.lastIndexOf('.')); - if (!callerClass.equals(classFqn)) { - return classFqn; - } - } - return null; - } - - private String findPathMethodByName(List path, String methodName) { - if (path == null || methodName == null) { - return null; - } - for (int i = path.size() - 1; i >= 0; i--) { - String methodFqn = path.get(i); - if (methodFqn != null && methodFqn.endsWith("." + methodName)) { - return methodFqn; - } - } - return null; - } - - private List expandSingleSymbolicType(List values) { - if (values == null || values.isEmpty()) { - return List.of(); - } - Set normalizedTypes = new LinkedHashSet<>(); - String preferredType = null; - for (String value : values) { - if (value == null || !value.startsWith("")) { - continue; - } - String symbolicType = value.substring("()); + Expression eventExpr = EventArgumentSelector.selectEventArgument(node).orElse(null); + if (eventExpr != null) { + AssignmentDag dag = new AssignmentDag(method); + external = dag.isExternal(eventExpr, new java.util.HashSet<>()); + } } String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrowerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrowerTest.java index 596dc85..fae2e7c 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrowerTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrowerTest.java @@ -155,6 +155,106 @@ class InstanceofNarrowerTest { assertThat(refined.isEmpty()).isTrue(); } + @Test + void elseBranchStaysEmpty(@TempDir Path tempDir) throws Exception { + Fixture f = scan(tempDir, """ + package com.example; + class C { + void fire(Object o) { + if (o instanceof PayRichEvent) { + } else { + use(((PayRichEvent) o).getA()); + } + } + void use(String s) {} + } + class PayRichEvent { String getA() { return "PAY"; } } + """); + MethodInvocation getA = findInvocation(f.fire, "getA"); + ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context); + assertThat(facts.isEmpty()).isTrue(); + } + + @Test + void interfaceInstanceofTargetStaysEmpty(@TempDir Path tempDir) throws Exception { + Fixture f = scan(tempDir, """ + package com.example; + class C { + void fire(Object o) { + if (o instanceof RichEvent) { + use(((RichEvent) o).getA()); + } + } + void use(String s) {} + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { public String getA() { return "PAY"; } } + """); + MethodInvocation getA = findInvocation(f.fire, "getA"); + ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context); + assertThat(facts.isEmpty()).isTrue(); + } + + @Test + void abstractInstanceofTargetStaysEmpty(@TempDir Path tempDir) throws Exception { + Fixture f = scan(tempDir, """ + package com.example; + class C { + void fire(Object o) { + if (o instanceof BaseEvent) { + use(((BaseEvent) o).getA()); + } + } + void use(String s) {} + } + abstract class BaseEvent { abstract String getA(); } + class PayRichEvent extends BaseEvent { String getA() { return "PAY"; } } + """); + MethodInvocation getA = findInvocation(f.fire, "getA"); + ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context); + assertThat(facts.isEmpty()).isTrue(); + } + + @Test + void nonSimpleNameReceiverStaysEmpty(@TempDir Path tempDir) throws Exception { + Fixture f = scan(tempDir, """ + package com.example; + class C { + Holder holder; + void fire() { + if (holder.getEvent() instanceof PayRichEvent) { + use(((PayRichEvent) holder.getEvent()).getA()); + } + } + void use(String s) {} + } + class Holder { Object getEvent() { return null; } } + class PayRichEvent { String getA() { return "PAY"; } } + """); + MethodInvocation getA = findInvocation(f.fire, "getA"); + ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context); + assertThat(facts.isEmpty()).isTrue(); + } + + @Test + void orGuardInstanceofStaysFailClosed(@TempDir Path tempDir) throws Exception { + Fixture f = scan(tempDir, """ + package com.example; + class C { + void fire(Object o, boolean flag) { + if (flag || o instanceof PayRichEvent) { + use(((PayRichEvent) o).getA()); + } + } + void use(String s) {} + } + class PayRichEvent { String getA() { return "PAY"; } } + """); + MethodInvocation getA = findInvocation(f.fire, "getA"); + ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context); + assertThat(facts.isEmpty()).isTrue(); + } + private static Fixture scan(Path tempDir, String source) throws Exception { Files.writeString(tempDir.resolve("C.java"), source); CodebaseContext context = new CodebaseContext(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityNormalizeTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityNormalizeTest.java new file mode 100644 index 0000000..7658eb4 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityNormalizeTest.java @@ -0,0 +1,42 @@ +package click.kamil.springstatemachineexporter.analysis.flow.eventid; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventIdentityNormalizeTest { + + @Test + void sameConstantTreatsBareAndQualifiedAsEqual() { + assertThat(EventIdentityNormalize.sameConstant("PAY", "OrderEvent.PAY")).isTrue(); + assertThat(EventIdentityNormalize.sameConstant("OrderEvent.PAY", "com.example.OrderEvent.PAY")).isTrue(); + assertThat(EventIdentityNormalize.sameConstant("PAY", "SHIP")).isFalse(); + assertThat(EventIdentityNormalize.sameConstant("PAY", "")).isFalse(); + } + + @Test + void preferQualifiedKeepsDottedForm() { + assertThat(EventIdentityNormalize.preferQualified("PAY", "OrderEvent.PAY", null)) + .isEqualTo("OrderEvent.PAY"); + assertThat(EventIdentityNormalize.preferQualified("OrderEvent.PAY", "PAY", null)) + .isEqualTo("OrderEvent.PAY"); + assertThat(EventIdentityNormalize.preferQualified("PAY", "PAY", "OrderEvent")) + .isEqualTo("OrderEvent.PAY"); + } + + @Test + void expandConsultAdoptPolicy() { + assertThat(ExpandConsult.empty().adoptableAsPolyExpand()).isFalse(); + assertThat(ExpandConsult.unique(java.util.List.of("OrderEvent.PAY"), "OrderEvent").adoptableAsPolyExpand()) + .isFalse(); + assertThat(ExpandConsult.unique(java.util.List.of("OrderEvent.PAY"), "OrderEvent").isUniqueProof()) + .isTrue(); + ExpandConsult expand = ExpandConsult.enumExpand( + java.util.List.of("OrderEvent.PAY", "OrderEvent.SHIP"), "OrderEvent"); + assertThat(expand.adoptableAsPolyExpand()).isTrue(); + assertThat(EventIdentityPolyPublish.adoptExpandOrEmpty(expand)) + .containsExactly("OrderEvent.PAY", "OrderEvent.SHIP"); + assertThat(EventIdentityPolyPublish.adoptExpandOrEmpty( + ExpandConsult.unique(java.util.List.of("PAY"), null))).isEmpty(); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolveExpandedTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolveExpandedTest.java new file mode 100644 index 0000000..a038082 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolveExpandedTest.java @@ -0,0 +1,301 @@ +package click.kamil.springstatemachineexporter.analysis.flow.eventid; + +import click.kamil.springstatemachineexporter.analysis.flow.alloc.AllocationFactPropagator; +import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver; +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver; +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor; +import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer; +import click.kamil.springstatemachineexporter.analysis.service.VariableTracer; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unique vs hint-gated expand split for EventIdentityResolver. + */ +class EventIdentityResolveExpandedTest { + + @Test + void toUpperCaseExpandsWhileUniqueStaysEmpty(@TempDir Path tempDir) throws Exception { + Fixture f = fixture(tempDir, """ + package com.example; + class C { + void process(String action) { + send(OrderEvent.valueOf(action.toUpperCase())); + } + void send(OrderEvent e) {} + } + enum OrderEvent { PAY, SHIP, CANCEL } + """); + List unique = f.resolver.resolveUnique(f.valueOfCall, List.of("com.example.C.process"), Map.of()); + ExpandConsult consult = f.resolver.resolveExpandConsult( + f.valueOfCall, List.of("com.example.C.process"), Map.of()); + assertThat(unique).isEmpty(); + assertThat(consult.source()).isEqualTo(ExpandConsult.Source.ENUM_EXPAND); + assertThat(consult.licensedByHint()).isTrue(); + assertThat(consult.members()).containsExactlyInAnyOrder( + "OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"); + } + + @Test + void localeToUpperCaseStillExpands(@TempDir Path tempDir) throws Exception { + Fixture f = fixture(tempDir, """ + package com.example; + import java.util.Locale; + class C { + void process(String action) { + send(OrderEvent.valueOf(action.toUpperCase(Locale.ROOT))); + } + void send(OrderEvent e) {} + } + enum OrderEvent { PAY, SHIP, CANCEL } + """); + assertThat(f.resolver.resolveUnique(f.valueOfCall, List.of("com.example.C.process"), Map.of())).isEmpty(); + assertThat(f.resolver.resolveExpanded(f.valueOfCall, List.of("com.example.C.process"), Map.of())) + .containsExactlyInAnyOrder("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"); + } + + @Test + void plainValueOfStaysEmptyForUniqueAndExpanded(@TempDir Path tempDir) throws Exception { + Fixture f = fixture(tempDir, """ + package com.example; + class C { + void process(String action) { + send(OrderEvent.valueOf(action)); + } + void send(OrderEvent e) {} + } + enum OrderEvent { PAY, SHIP, CANCEL } + """); + assertThat(f.resolver.resolveUnique(f.valueOfCall, List.of("com.example.C.process"), Map.of())).isEmpty(); + ExpandConsult consult = f.resolver.resolveExpandConsult( + f.valueOfCall, List.of("com.example.C.process"), Map.of()); + assertThat(consult.source()).isEqualTo(ExpandConsult.Source.EMPTY); + assertThat(consult.members()).isEmpty(); + } + + @Test + void allocProvenUniqueAlsoReturnedByExpanded(@TempDir Path tempDir) throws Exception { + Fixture f = fixture(tempDir, """ + package com.example; + class C { + void pay() { + fire(new PayRichEvent()); + } + void fire(RichEvent e) { + send(OrderEvent.valueOf(e.getA())); + } + void send(OrderEvent e) {} + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + enum OrderEvent { PAY, SHIP, CANCEL } + """); + List path = List.of("com.example.C.pay", "com.example.C.fire"); + Map> callGraph = Map.of( + "com.example.C.pay", + List.of(new CallEdge("com.example.C.fire", List.of("new PayRichEvent()")))); + List unique = f.resolver.resolveUnique(f.valueOfCall, path, callGraph); + List expanded = f.resolver.resolveExpanded(f.valueOfCall, path, callGraph); + assertThat(unique).containsExactly("PAY"); + assertThat(expanded).containsExactly("OrderEvent.PAY"); + ExpandConsult consult = f.resolver.resolveExpandConsult(f.valueOfCall, path, callGraph); + assertThat(consult.source()).isEqualTo(ExpandConsult.Source.UNIQUE); + assertThat(consult.licensedByHint()).isFalse(); + assertThat(consult.adoptableAsPolyExpand()).isFalse(); + assertThat(consult.isUniqueProof()).isTrue(); + assertThat(consult.members()).containsExactly("OrderEvent.PAY"); + assertThat(EventIdentityNormalize.sameConstant(unique.get(0), consult.members().get(0))).isTrue(); + } + + @Test + void helperReturnNameExpandsViaConsult(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("C.java"), """ + package com.example; + abstract class AbstractOrderService { + public void process(String event) { + String resolved = assertSupportedOrderEvent(event); + sendEvent(resolved); + } + protected abstract String assertSupportedOrderEvent(String event); + public void sendEvent(String ev) {} + } + class LogisticsService extends AbstractOrderService { + @Override + protected String assertSupportedOrderEvent(String event) { + return LogisticsEvent.valueOf(event).name(); + } + } + enum LogisticsEvent { DELIVERED, RETURNED } + """); + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + ConstantResolver cr = context.getConstantResolver(); + VariableTracer vt = new VariableTracer(context, cr); + ConstructorAnalyzer ctor = new ConstructorAnalyzer(context, cr); + AllocationFactPropagator afp = new AllocationFactPropagator(context, vt, ctor); + ConstantExtractor extractor = new ConstantExtractor(context, cr, mi -> null); + AccessorResolver accessors = new AccessorResolver(context, extractor, ctor, cr); + VirtualAccessorConstResolver vacr = new VirtualAccessorConstResolver(context, accessors, cr); + EventIdentityResolver resolver = new EventIdentityResolver(context, afp, vacr, vt, cr); + TypeDeclaration td = context.getTypeDeclaration("com.example.AbstractOrderService"); + assertThat(td).isNotNull(); + Expression sendArg = null; + for (MethodDeclaration md : td.getMethods()) { + if (!"process".equals(md.getName().getIdentifier()) || md.getBody() == null) { + continue; + } + Expression[] found = {null}; + md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if ("sendEvent".equals(node.getName().getIdentifier()) && !node.arguments().isEmpty()) { + found[0] = (Expression) node.arguments().get(0); + return false; + } + return super.visit(node); + } + }); + sendArg = found[0]; + } + assertThat(sendArg).isNotNull(); + List path = List.of( + "com.example.LogisticsService.process", + "com.example.AbstractOrderService.sendEvent"); + ExpandConsult consult = resolver.resolveExpandConsult(sendArg, path, Map.of()); + assertThat(consult.source()).isEqualTo(ExpandConsult.Source.ENUM_EXPAND); + assertThat(consult.licensedByHint()).isTrue(); + assertThat(consult.members()).containsExactlyInAnyOrder( + "LogisticsEvent.DELIVERED", "LogisticsEvent.RETURNED"); + assertThat(consult.adoptableAsPolyExpand()).isTrue(); + } + + @Test + void helperExpandStaysEmptyWhenOnlyAbstractOnPath(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("C.java"), """ + package com.example; + abstract class AbstractOrderService { + public void process(String event) { + String resolved = assertSupportedOrderEvent(event); + sendEvent(resolved); + } + protected abstract String assertSupportedOrderEvent(String event); + public void sendEvent(String ev) {} + } + class LogisticsService extends AbstractOrderService { + @Override + protected String assertSupportedOrderEvent(String event) { + return LogisticsEvent.valueOf(event).name(); + } + } + class ComputerStoreService extends AbstractOrderService { + @Override + protected String assertSupportedOrderEvent(String event) { + return ComputerEvent.valueOf(event).name(); + } + } + enum LogisticsEvent { DELIVERED, RETURNED } + enum ComputerEvent { SHIPPED, REFUNDED } + """); + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + ConstantResolver cr = context.getConstantResolver(); + VariableTracer vt = new VariableTracer(context, cr); + ConstructorAnalyzer ctor = new ConstructorAnalyzer(context, cr); + AllocationFactPropagator afp = new AllocationFactPropagator(context, vt, ctor); + ConstantExtractor extractor = new ConstantExtractor(context, cr, mi -> null); + AccessorResolver accessors = new AccessorResolver(context, extractor, ctor, cr); + VirtualAccessorConstResolver vacr = new VirtualAccessorConstResolver(context, accessors, cr); + EventIdentityResolver resolver = new EventIdentityResolver(context, afp, vacr, vt, cr); + TypeDeclaration td = context.getTypeDeclaration("com.example.AbstractOrderService"); + Expression sendArg = null; + for (MethodDeclaration md : td.getMethods()) { + if (!"process".equals(md.getName().getIdentifier()) || md.getBody() == null) { + continue; + } + Expression[] found = {null}; + md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if ("sendEvent".equals(node.getName().getIdentifier()) && !node.arguments().isEmpty()) { + found[0] = (Expression) node.arguments().get(0); + return false; + } + return super.visit(node); + } + }); + sendArg = found[0]; + } + assertThat(sendArg).isNotNull(); + // Abstract frames only — must not flatten Logistics+Computer overrides. + List abstractOnlyPath = List.of( + "com.example.AbstractOrderService.process", + "com.example.AbstractOrderService.sendEvent"); + ExpandConsult miss = resolver.resolveExpandConsult(sendArg, abstractOnlyPath, Map.of()); + assertThat(miss.source()).isEqualTo(ExpandConsult.Source.EMPTY); + assertThat(miss.adoptableAsPolyExpand()).isFalse(); + } + + private static Fixture fixture(Path tempDir, String source) throws Exception { + Files.writeString(tempDir.resolve("C.java"), source); + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + ConstantResolver cr = context.getConstantResolver(); + VariableTracer vt = new VariableTracer(context, cr); + ConstructorAnalyzer ctor = new ConstructorAnalyzer(context, cr); + AllocationFactPropagator afp = new AllocationFactPropagator(context, vt, ctor); + ConstantExtractor extractor = new ConstantExtractor(context, cr, mi -> null); + AccessorResolver accessors = new AccessorResolver(context, extractor, ctor, cr); + VirtualAccessorConstResolver vacr = new VirtualAccessorConstResolver(context, accessors, cr); + EventIdentityResolver resolver = new EventIdentityResolver(context, afp, vacr, vt, cr); + TypeDeclaration td = context.getTypeDeclaration("com.example.C"); + if (td == null) { + td = context.getTypeDeclaration("C"); + } + assertThat(td).isNotNull(); + MethodInvocation valueOf = null; + for (MethodDeclaration md : td.getMethods()) { + MethodInvocation[] found = {null}; + md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if ("valueOf".equals(node.getName().getIdentifier())) { + found[0] = node; + return false; + } + return super.visit(node); + } + }); + if (found[0] != null) { + valueOf = found[0]; + break; + } + } + assertThat(valueOf).isNotNull(); + return new Fixture(resolver, valueOf); + } + + private record Fixture(EventIdentityResolver resolver, MethodInvocation valueOfCall) { + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/EnumExpansionHintSupportTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/EnumExpansionHintSupportTest.java deleted file mode 100644 index 6abedc7..0000000 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/EnumExpansionHintSupportTest.java +++ /dev/null @@ -1,224 +0,0 @@ -package click.kamil.springstatemachineexporter.analysis.pipeline; - -import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; -import org.eclipse.jdt.core.dom.Expression; -import org.eclipse.jdt.core.dom.MethodDeclaration; -import org.eclipse.jdt.core.dom.MethodInvocation; -import org.eclipse.jdt.core.dom.ReturnStatement; -import org.eclipse.jdt.core.dom.TypeDeclaration; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -class EnumExpansionHintSupportTest { - - @Test - void zeroArgNameQualifies(@TempDir Path tempDir) throws Exception { - Expression expr = returnExpr(tempDir, """ - package com.example; - class C { - String map(OrderEvent e) { - return e.name(); - } - } - enum OrderEvent { PAY } - """); - assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isTrue(); - } - - @Test - void getNameDoesNotQualify(@TempDir Path tempDir) throws Exception { - Expression expr = returnExpr(tempDir, """ - package com.example; - class C { - String map(Person p) { - return p.getName(); - } - } - class Person { String getName() { return "x"; } } - """); - assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isFalse(); - } - - @Test - void toUpperCaseZeroArgQualifies(@TempDir Path tempDir) throws Exception { - Expression expr = returnExpr(tempDir, """ - package com.example; - class C { - String map(String action) { - return action.toUpperCase(); - } - } - """); - assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isTrue(); - } - - @Test - void toUpperCaseWithLocaleQualifies(@TempDir Path tempDir) throws Exception { - Expression expr = returnExpr(tempDir, """ - package com.example; - import java.util.Locale; - class C { - String map(String action) { - return action.toUpperCase(Locale.ROOT); - } - } - """); - assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isTrue(); - } - - @Test - void trimThenToUpperCaseChainQualifies(@TempDir Path tempDir) throws Exception { - Expression expr = returnExpr(tempDir, """ - package com.example; - class C { - String map(String action) { - return action.trim().toUpperCase(); - } - } - """); - assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isTrue(); - } - - @Test - void valueOfThenNameChainQualifies(@TempDir Path tempDir) throws Exception { - Expression expr = returnExpr(tempDir, """ - package com.example; - class C { - String map(String event) { - return OrderEvent.valueOf(event).name(); - } - } - enum OrderEvent { PAY } - """); - assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isTrue(); - } - - @Test - void plainValueOfDoesNotQualify(@TempDir Path tempDir) throws Exception { - Expression expr = returnExpr(tempDir, """ - package com.example; - class C { - OrderEvent map(String str) { - return OrderEvent.valueOf(str); - } - } - enum OrderEvent { PAY } - """); - assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isFalse(); - } - - @Test - void stringLiteralContainingNameSubstringDoesNotQualify(@TempDir Path tempDir) throws Exception { - Expression expr = returnExpr(tempDir, """ - package com.example; - class C { - String map() { - return ".name()"; - } - } - """); - assertThat(EnumExpansionHintSupport.expressionHasHint(expr)).isFalse(); - } - - @Test - void methodBodyHasHintForNameAndToUpperCase(@TempDir Path tempDir) throws Exception { - MethodDeclaration nameMd = method(tempDir, "nameHelper", """ - package com.example; - class C { - String nameHelper(String event) { - return OrderEvent.valueOf(event).name(); - } - String upperHelper(String action) { - return action.toUpperCase(); - } - String plainHelper(String str) { - return str.trim(); - } - } - enum OrderEvent { PAY } - """); - MethodDeclaration upperMd = sibling(nameMd, "upperHelper"); - MethodDeclaration plainMd = sibling(nameMd, "plainHelper"); - assertThat(EnumExpansionHintSupport.methodBodyHasHint(nameMd)).isTrue(); - assertThat(EnumExpansionHintSupport.methodBodyHasHint(upperMd)).isTrue(); - assertThat(EnumExpansionHintSupport.methodBodyHasHint(plainMd)).isFalse(); - } - - @Test - void isQualifyingHintRejectsNameWithArgs(@TempDir Path tempDir) throws Exception { - MethodDeclaration md = method(tempDir, "map", """ - package com.example; - class C { - String map(Weird w) { - return w.name("x"); - } - } - class Weird { String name(String s) { return s; } } - """); - MethodInvocation[] found = {null}; - md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { - @Override - public boolean visit(MethodInvocation node) { - if ("name".equals(node.getName().getIdentifier())) { - found[0] = node; - return false; - } - return super.visit(node); - } - }); - assertThat(found[0]).isNotNull(); - assertThat(EnumExpansionHintSupport.isQualifyingHint(found[0])).isFalse(); - } - - private static Expression returnExpr(Path tempDir, String source) throws Exception { - MethodDeclaration md = method(tempDir, "map", source); - ReturnStatement rs = findReturn(md); - return rs.getExpression(); - } - - private static MethodDeclaration method(Path tempDir, String methodName, String source) throws Exception { - Files.writeString(tempDir.resolve("C.java"), source); - CodebaseContext context = new CodebaseContext(); - context.scan(tempDir); - TypeDeclaration td = context.getTypeDeclaration("com.example.C"); - if (td == null) { - td = context.getTypeDeclaration("C"); - } - assertThat(td).isNotNull(); - MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); - assertThat(md).isNotNull(); - return md; - } - - private static MethodDeclaration sibling(MethodDeclaration md, String methodName) { - TypeDeclaration td = (TypeDeclaration) md.getParent(); - MethodDeclaration found = null; - for (Object bodyDecl : td.bodyDeclarations()) { - if (bodyDecl instanceof MethodDeclaration candidate - && methodName.equals(candidate.getName().getIdentifier())) { - found = candidate; - break; - } - } - assertThat(found).isNotNull(); - return found; - } - - private static ReturnStatement findReturn(MethodDeclaration md) { - ReturnStatement[] found = {null}; - md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { - @Override - public boolean visit(ReturnStatement node) { - found[0] = node; - return false; - } - }); - assertThat(found[0]).isNotNull(); - return found[0]; - } -} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/EventArgumentSelectorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/EventArgumentSelectorTest.java new file mode 100644 index 0000000..77e1b06 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/EventArgumentSelectorTest.java @@ -0,0 +1,133 @@ +package click.kamil.springstatemachineexporter.analysis.pipeline; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventArgumentSelectorTest { + + @Test + void singleArgIsSelected(@TempDir Path tempDir) throws Exception { + MethodInvocation call = sendEventCall(tempDir, """ + package com.example; + class C { + StateMachine sm; + void fire(OrderEvent e) { + sm.sendEvent(e); + } + } + class StateMachine { void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY } + """); + Optional selected = EventArgumentSelector.selectEventArgument(call); + assertThat(selected).isPresent(); + assertThat(selected.get().toString()).isEqualTo("e"); + assertThat(EventArgumentSelector.selectedIsFirstArgument(call, selected.get())).isTrue(); + } + + @Test + void eventThenStateSelectsEvent(@TempDir Path tempDir) throws Exception { + MethodInvocation call = sendEventCall(tempDir, """ + package com.example; + class C { + StateMachine sm; + void fire() { + sm.sendEvent(OrderEvent.PAY, OrderState.PENDING); + } + } + class StateMachine { void sendEvent(OrderEvent e, OrderState s) {} } + enum OrderEvent { PAY } + enum OrderState { PENDING } + """); + Optional selected = EventArgumentSelector.selectEventArgument(call); + assertThat(selected).isPresent(); + assertThat(selected.get().toString()).contains("OrderEvent.PAY"); + assertThat(EventArgumentSelector.selectedIsFirstArgument(call, selected.get())).isTrue(); + } + + @Test + void headersThenEventSelectsEvent(@TempDir Path tempDir) throws Exception { + MethodInvocation call = sendEventCall(tempDir, """ + package com.example; + import java.util.Map; + class C { + StateMachine sm; + void fire(Map headers, String code) { + sm.sendEvent(headers, OrderEvent.valueOf(code)); + } + } + class StateMachine { void sendEvent(Map headers, OrderEvent e) {} } + enum OrderEvent { PAY, SHIP } + """); + Optional selected = EventArgumentSelector.selectEventArgument(call); + assertThat(selected).isPresent(); + assertThat(selected.get().toString()).contains("valueOf"); + assertThat(EventArgumentSelector.selectedIsFirstArgument(call, selected.get())).isFalse(); + } + + @Test + void ambiguousTwoArgsStayEmpty(@TempDir Path tempDir) throws Exception { + MethodInvocation call = sendEventCall(tempDir, """ + package com.example; + class C { + StateMachine sm; + void fire(String a, String b) { + sm.sendEvent(a, b); + } + } + class StateMachine { void sendEvent(String a, String b) {} } + """); + assertThat(EventArgumentSelector.selectEventArgument(call)).isEmpty(); + } + + @Test + void zeroArgsStayEmpty(@TempDir Path tempDir) throws Exception { + MethodInvocation call = sendEventCall(tempDir, """ + package com.example; + class C { + StateMachine sm; + void fire() { + sm.sendEvent(); + } + } + class StateMachine { void sendEvent() {} } + """); + assertThat(EventArgumentSelector.selectEventArgument(call)).isEmpty(); + } + + private static MethodInvocation sendEventCall(Path tempDir, String source) throws Exception { + Files.writeString(tempDir.resolve("C.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + TypeDeclaration td = context.getTypeDeclaration("com.example.C"); + if (td == null) { + td = context.getTypeDeclaration("C"); + } + assertThat(td).isNotNull(); + MethodDeclaration md = context.findMethodDeclaration(td, "fire", true); + assertThat(md).isNotNull(); + MethodInvocation[] found = {null}; + md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if ("sendEvent".equals(node.getName().getIdentifier())) { + found[0] = node; + return false; + } + return super.visit(node); + } + }); + assertThat(found[0]).isNotNull(); + return found[0]; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/IdentityStringTransformSupportTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/IdentityStringTransformSupportTest.java new file mode 100644 index 0000000..8e9c7a3 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/IdentityStringTransformSupportTest.java @@ -0,0 +1,307 @@ +package click.kamil.springstatemachineexporter.analysis.pipeline; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.ReturnStatement; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Peel vs expand-hint split: 0-arg peel for EI; any-arity case transforms + name() for ACE. + */ +class IdentityStringTransformSupportTest { + + @Test + void zeroArgToUpperCaseIsPeelAndExpand(@TempDir Path tempDir) throws Exception { + MethodInvocation mi = invocation(tempDir, "map", """ + package com.example; + class C { + String map(String action) { + return action.toUpperCase(); + } + } + """); + assertThat(IdentityStringTransformSupport.isPeelInvocation(mi)).isTrue(); + assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isTrue(); + } + + @Test + void localeToUpperCaseIsExpandNotPeel(@TempDir Path tempDir) throws Exception { + MethodInvocation mi = invocation(tempDir, "map", """ + package com.example; + import java.util.Locale; + class C { + String map(String action) { + return action.toUpperCase(Locale.ROOT); + } + } + """); + assertThat(IdentityStringTransformSupport.isPeelInvocation(mi)).isFalse(); + assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isTrue(); + } + + @Test + void zeroArgToLowerCaseIsPeelAndExpand(@TempDir Path tempDir) throws Exception { + MethodInvocation mi = invocation(tempDir, "map", """ + package com.example; + class C { + String map(String action) { + return action.toLowerCase(); + } + } + """); + assertThat(IdentityStringTransformSupport.isPeelInvocation(mi)).isTrue(); + assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isTrue(); + } + + @Test + void localeToLowerCaseIsExpandNotPeel(@TempDir Path tempDir) throws Exception { + MethodInvocation mi = invocation(tempDir, "map", """ + package com.example; + import java.util.Locale; + class C { + String map(String action) { + return action.toLowerCase(Locale.ROOT); + } + } + """); + assertThat(IdentityStringTransformSupport.isPeelInvocation(mi)).isFalse(); + assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isTrue(); + } + + @Test + void trimIsPeelNotExpand(@TempDir Path tempDir) throws Exception { + MethodInvocation mi = invocation(tempDir, "map", """ + package com.example; + class C { + String map(String action) { + return action.trim(); + } + } + """); + assertThat(IdentityStringTransformSupport.isPeelInvocation(mi)).isTrue(); + assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isFalse(); + assertThat(IdentityStringTransformSupport.expressionHasExpandHint(mi)).isFalse(); + } + + @Test + void zeroArgNameIsExpandNotPeel(@TempDir Path tempDir) throws Exception { + MethodInvocation mi = invocation(tempDir, "map", """ + package com.example; + class C { + String map(OrderEvent e) { + return e.name(); + } + } + enum OrderEvent { PAY } + """); + assertThat(IdentityStringTransformSupport.isPeelInvocation(mi)).isFalse(); + assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isTrue(); + } + + @Test + void getNameIsNeither(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + class C { + String map(Person p) { + return p.getName(); + } + } + class Person { String getName() { return "x"; } } + """); + assertThat(IdentityStringTransformSupport.expressionHasExpandHint(expr)).isFalse(); + } + + @Test + void peelIdentityStringOpsUnwrapsTrimThenUpper(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + class C { + String map(String action) { + return action.trim().toUpperCase(); + } + } + """); + Expression peeled = IdentityStringTransformSupport.peelIdentityStringOps(expr); + assertThat(peeled.toString()).isEqualTo("action"); + } + + @Test + void peelIdentityStringOpsLeavesLocaleIntact(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + import java.util.Locale; + class C { + String map(String action) { + return action.toUpperCase(Locale.ROOT); + } + } + """); + Expression peeled = IdentityStringTransformSupport.peelIdentityStringOps(expr); + assertThat(peeled.toString()).contains("toUpperCase"); + } + + @Test + void plainValueOfHasNoExpandHint(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + class C { + OrderEvent map(String str) { + return OrderEvent.valueOf(str); + } + } + enum OrderEvent { PAY } + """); + assertThat(IdentityStringTransformSupport.expressionHasExpandHint(expr)).isFalse(); + } + + @Test + void returnExpressionsHaveExpandHintIgnoresSideEffectNoise(@TempDir Path tempDir) throws Exception { + MethodDeclaration noisy = method(tempDir, "map", """ + package com.example; + class C { + String map(String s) { + String ignored = OrderEvent.PAY.name(); + return MyEvents.valueOf(s); + } + } + enum OrderEvent { PAY } + enum MyEvents { A, B } + """); + assertThat(IdentityStringTransformSupport.methodBodyHasExpandHint(noisy)).isTrue(); + assertThat(IdentityStringTransformSupport.returnExpressionsHaveExpandHint(noisy)).isFalse(); + } + + @Test + void returnExpressionsHaveExpandHintForValueOfName(@TempDir Path tempDir) throws Exception { + MethodDeclaration md = method(tempDir, "map", """ + package com.example; + class C { + String map(String event) { + return OrderEvent.valueOf(event).name(); + } + } + enum OrderEvent { PAY } + """); + assertThat(IdentityStringTransformSupport.returnExpressionsHaveExpandHint(md)).isTrue(); + } + + @Test + void nameWithArgsIsNotExpandHint(@TempDir Path tempDir) throws Exception { + MethodInvocation mi = invocation(tempDir, "map", """ + package com.example; + class C { + String map(Weird w) { + return w.name("x"); + } + } + class Weird { String name(String s) { return s; } } + """); + assertThat(IdentityStringTransformSupport.isExpandHintInvocation(mi)).isFalse(); + } + + @Test + void stringToUpperCaseMethodReferenceIsExpandNotPeel(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + import java.util.function.Function; + class C { + Function map() { + return String::toUpperCase; + } + } + """); + assertThat(IdentityStringTransformSupport.isExpandHintMethodReference(expr)).isTrue(); + assertThat(IdentityStringTransformSupport.expressionHasExpandHint(expr)).isTrue(); + } + + @Test + void enumNameMethodReferenceIsExpandHint(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + import java.util.function.Function; + class C { + Function map() { + return OrderEvent::name; + } + } + enum OrderEvent { PAY } + """); + assertThat(IdentityStringTransformSupport.isExpandHintMethodReference(expr)).isTrue(); + assertThat(IdentityStringTransformSupport.expressionHasExpandHint(expr)).isTrue(); + } + + @Test + void getNameMethodReferenceIsNotExpandHint(@TempDir Path tempDir) throws Exception { + Expression expr = returnExpr(tempDir, """ + package com.example; + import java.util.function.Function; + class C { + Function map() { + return Person::getName; + } + } + class Person { String getName() { return "x"; } } + """); + assertThat(IdentityStringTransformSupport.isExpandHintMethodReference(expr)).isFalse(); + assertThat(IdentityStringTransformSupport.expressionHasExpandHint(expr)).isFalse(); + } + + private static MethodInvocation invocation(Path tempDir, String methodName, String source) + throws Exception { + MethodDeclaration md = method(tempDir, methodName, source); + MethodInvocation[] found = {null}; + md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + found[0] = node; + return false; + } + }); + assertThat(found[0]).isNotNull(); + return found[0]; + } + + private static Expression returnExpr(Path tempDir, String source) throws Exception { + MethodDeclaration md = method(tempDir, "map", source); + ReturnStatement rs = findReturn(md); + return rs.getExpression(); + } + + private static MethodDeclaration method(Path tempDir, String methodName, String source) + throws Exception { + Files.writeString(tempDir.resolve("C.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + TypeDeclaration td = context.getTypeDeclaration("com.example.C"); + if (td == null) { + td = context.getTypeDeclaration("C"); + } + assertThat(td).isNotNull(); + MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); + assertThat(md).isNotNull(); + return md; + } + + private static ReturnStatement findReturn(MethodDeclaration md) { + ReturnStatement[] found = {null}; + md.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(ReturnStatement node) { + found[0] = node; + return false; + } + }); + assertThat(found[0]).isNotNull(); + return found[0]; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationCoverageBugHuntTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationCoverageBugHuntTest.java new file mode 100644 index 0000000..3c8bc98 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationCoverageBugHuntTest.java @@ -0,0 +1,223 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.model.Transition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Adversarial coverage for claimed AFP fail-closed / PostConstruct rules. + */ +class AllocationCoverageBugHuntTest { + + private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig"; + private static final String EVENT_TYPE = "com.example.OrderEvent"; + private static final String STATE_TYPE = "com.example.OrderState"; + + private static final String COMMON_TAIL = """ + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, CANCEL } + enum OrderState { NEW, PAID, SHIPPED, CANCELLED } + class OrderStateMachineConfig {} + """; + + @Test + void postConstructUniqueFieldWriteResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import javax.annotation.PostConstruct; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + RichEvent carrier; + @PostConstruct + void init() { + this.carrier = new PayRichEvent(); + } + void fire() { + machine.sendEvent(OrderEvent.valueOf(carrier.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + COMMON_TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void sameTypeMultiFieldWriteStaysFailClosed(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(new PayRichEvent()); } + } + class Dispatcher { + StateMachine machine; + RichEvent carrier; + void fire(RichEvent e) { + this.carrier = e; + this.carrier = new PayRichEvent(); + machine.sendEvent(OrderEvent.valueOf(carrier.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + COMMON_TAIL; + assertFailClosed(source, "pay", tempDir); + } + + @Test + void sequentialLocalReassignmentLastWriteWins(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + void fire() { + RichEvent e = new PayRichEvent(); + e = new ShipRichEvent(); + machine.sendEvent(OrderEvent.valueOf(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + COMMON_TAIL; + assertResolvedShip(source, tempDir); + } + + @Test + void branchingLocalAssignmentStaysFailClosed(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay(boolean flag) { dispatcher.fire(flag); } + } + class Dispatcher { + StateMachine machine; + void fire(boolean flag) { + RichEvent e; + if (flag) { + e = new PayRichEvent(); + } else { + e = new ShipRichEvent(); + } + machine.sendEvent(OrderEvent.valueOf(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + COMMON_TAIL; + assertFailClosed(source, "pay", tempDir); + } + + @Test + void cicTernaryDifferentConcretesStaysFailClosed(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay(boolean flag) { dispatcher.fire(flag); } + } + class Dispatcher { + StateMachine machine; + void fire(boolean flag) { + RichEvent e = flag ? new PayRichEvent() : new ShipRichEvent(); + machine.sendEvent(OrderEvent.valueOf(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + COMMON_TAIL; + assertFailClosed(source, "pay", tempDir); + } + + private static void assertResolvedPay(String source, Path tempDir) throws Exception { + assertResolvedEvent(source, tempDir, "pay", EVENT_TYPE + ".PAY", "PAY"); + } + + private static void assertResolvedShip(String source, Path tempDir) throws Exception { + assertResolvedEvent(source, tempDir, "pay", EVENT_TYPE + ".SHIP", "SHIP"); + } + + private static void assertResolvedEvent( + String source, + Path tempDir, + String entryMethod, + String expectedEventFqn, + String expectedToken) throws Exception { + CodebaseContext context = scanSource(source, tempDir); + Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY"); + Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP"); + Transition cancel = transition("NEW", "CANCELLED", EVENT_TYPE + ".CANCEL"); + CallChain linked = resolveLinkAndAssertSingleMatch( + context, + "com.example.ApiController", + entryMethod, + "com.example.StateMachine", + "sendEvent", + MACHINE_CONFIG, + EVENT_TYPE, + STATE_TYPE, + expectedEventFqn, + EngineKind.JDT, + pay, + ship, + cancel); + assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + assertThat(linked.getTriggerPoint().getPolymorphicEvents()) + .filteredOn(pe -> pe != null && (pe.contains("PAY") || pe.contains("SHIP") || pe.contains("CANCEL"))) + .allMatch(pe -> pe.contains(expectedToken)); + } + + private static void assertFailClosed(String source, String entryMethod, Path tempDir) throws Exception { + CodebaseContext context = scanSource(source, tempDir); + Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY"); + Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP"); + CallChain raw = resolveChain(context, "com.example.ApiController", entryMethod, + "com.example.StateMachine", "sendEvent", EngineKind.JDT); + CallChain linked = linkChain(context, raw, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship); + assertThat(linked.getMatchedTransitions()).isNullOrEmpty(); + assertThat(linked.getLinkResolution()) + .isIn(LinkResolution.AMBIGUOUS_WIDEN, LinkResolution.NO_MATCH, LinkResolution.UNRESOLVED_EXTERNAL); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/BeanAsAllocEventIdentityTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/BeanAsAllocEventIdentityTest.java index 33929c1..72330f0 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/BeanAsAllocEventIdentityTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/BeanAsAllocEventIdentityTest.java @@ -171,6 +171,129 @@ class BeanAsAllocEventIdentityTest { .isIn(LinkResolution.AMBIGUOUS_WIDEN, LinkResolution.NO_MATCH, LinkResolution.UNRESOLVED_EXTERNAL); } + @Test + void qualifierDisambiguatesMultiComponent(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("App.java"), """ + package com.example; + import org.springframework.beans.factory.annotation.Qualifier; + import org.springframework.stereotype.Component; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + @Qualifier("pay") + RichEvent carrier; + void fire() { + machine.sendEvent(OrderEvent.valueOf(carrier.getA())); + } + } + interface RichEvent { String getA(); } + @Component + @Qualifier("pay") + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + @Component + @Qualifier("ship") + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, CANCEL } + enum OrderState { NEW, PAID, SHIPPED, CANCELLED } + class OrderStateMachineConfig {} + """); + + CallChain linked = linkWithInjection(tempDir); + assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + assertThat(linked.getMatchedTransitions()).hasSize(1); + assertThat(linked.getMatchedTransitions().get(0).getEvent()) + .isEqualTo("com.example.OrderEvent.PAY"); + } + + @Test + void uniqueBeanFactoryMethodAsAllocResolves(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("App.java"), """ + package com.example; + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + RichEvent carrier; + void fire() { + machine.sendEvent(OrderEvent.valueOf(carrier.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + @Configuration + class EventConfig { + @Bean + RichEvent payCarrier() { + return new PayRichEvent(); + } + } + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, CANCEL } + enum OrderState { NEW, PAID, SHIPPED, CANCELLED } + class OrderStateMachineConfig {} + """); + + CallChain linked = linkWithInjection(tempDir); + assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + assertThat(linked.getMatchedTransitions()).hasSize(1); + assertThat(linked.getMatchedTransitions().get(0).getEvent()) + .isEqualTo("com.example.OrderEvent.PAY"); + } + + private static CallChain linkWithInjection(Path tempDir) throws Exception { + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.setClasspath(ToolingClasspath.currentJvmJarEntries()); + context.scan(Set.of(tempDir), Set.of()); + + SpringBeanRegistry registry = new SpringBeanRegistry(); + for (CompilationUnit cu : context.getCompilationUnits()) { + cu.accept(new SpringContextScanner(registry)); + } + InjectionPointAnalyzer injectionAnalyzer = + new InjectionPointAnalyzer(new SpringDependencyResolver(registry)); + JdtCallGraphEngine engine = new JdtCallGraphEngine(context, injectionAnalyzer); + + EntryPoint entry = EntryPoint.builder() + .className("com.example.ApiController") + .methodName("pay") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("e") + .build(); + List chains = engine.findChains(List.of(entry), List.of(trigger)); + assertThat(chains).hasSize(1); + + Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY"); + Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP"); + AnalysisResult result = AnalysisResult.builder() + .name("com.example.OrderStateMachineConfig") + .transitions(List.of(pay, ship)) + .metadata(CodebaseMetadata.builder().callChains(chains).build()) + .build(); + new TransitionLinkerEnricher().enrich(result, null, null); + return result.getMetadata().getCallChains().get(0); + } + private static Transition transition(String source, String target, String eventFqn) { Transition transition = new Transition(); transition.setSourceStates(List.of(State.of(source, source))); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnumExpansionHintBugHuntTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnumExpansionHintBugHuntTest.java index ac2b331..0a38abd 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnumExpansionHintBugHuntTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnumExpansionHintBugHuntTest.java @@ -17,8 +17,9 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; /** - * Adversarial regression for shared {@code EnumExpansionHintSupport} wiring in ACE: - * expand when name/toUpperCase hints are on the arg chain; stay symbolic otherwise. + * Adversarial regression for {@code IdentityStringTransformSupport} expand-hint wiring in ACE. + * Locale arity expands here; EventIdentity peels stay 0-arg only + * ({@code EventIdentityResolverPipelineTest#toUpperCaseWithLocaleStaysFailClosed}). */ class EnumExpansionHintBugHuntTest { @@ -57,6 +58,42 @@ class EnumExpansionHintBugHuntTest { .contains("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"); } + @Test + void valueOfActionToLowerCaseExpandsToEnumMembers(@TempDir Path tempDir) throws IOException { + CallChain chain = resolveSendEvent(tempDir, """ + package com.example; + public class ApiController { + StateMachine sm; + public void processAction(String action) { + sm.sendEvent(OrderEvent.valueOf(action.toLowerCase())); + } + } + enum OrderEvent { PAY, SHIP, CANCEL } + class StateMachine { public void sendEvent(OrderEvent e) {} } + """, "processAction"); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .contains("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"); + } + + @Test + void valueOfViaStringToUpperCaseMethodReferenceExpands(@TempDir Path tempDir) throws IOException { + CallChain chain = resolveSendEvent(tempDir, """ + package com.example; + import java.util.Optional; + public class ApiController { + StateMachine sm; + public void processAction(String action) { + sm.sendEvent(OrderEvent.valueOf( + Optional.of(action).map(String::toUpperCase).orElse(action))); + } + } + enum OrderEvent { PAY, SHIP, CANCEL } + class StateMachine { public void sendEvent(OrderEvent e) {} } + """, "processAction"); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .contains("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"); + } + @Test void plainValueOfUnboundStaysSymbolic(@TempDir Path tempDir) throws IOException { String source = """ @@ -174,6 +211,103 @@ class EnumExpansionHintBugHuntTest { .noneMatch(e -> e != null && e.startsWith("OrderEvent.") && !e.contains("SYMBOLIC")); } + @Test + void noiseNameInsideMappedHelperDoesNotExpand(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class Endpoint { + private OrderService service; + public void trigger(String eventString) { + service.doSomething(eventString); + } + } + class OrderService { + public void doSomething(String str) { + MyEvents event = mapToEnum(str); + sendEvent(event); + } + private MyEvents mapToEnum(String str) { + String ignored = OrderEvent.PAY.name(); + return MyEvents.valueOf(str); + } + public void sendEvent(MyEvents e) {} + } + enum MyEvents { A, B } + enum OrderEvent { PAY } + """; + Files.writeString(tempDir.resolve("Endpoint.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.Endpoint") + .methodName("trigger") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("sendEvent") + .event("e") + .build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + assertThat(chains).hasSize(1); + List poly = chains.get(0).getTriggerPoint().getPolymorphicEvents(); + assertThat(poly) + .as("noise .name() in helper body must not expand, got %s", poly) + .noneMatch(e -> e != null && (e.equals("MyEvents.A") || e.equals("MyEvents.B"))); + assertThat(poly.stream().anyMatch(e -> e != null && e.contains("SYMBOLIC"))) + .as("expected symbolic poly, got %s", poly) + .isTrue(); + } + + @Test + void threeHopPlainValueOfStaysSymbolic(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class Endpoint { + private Facade facade; + public void trigger(String eventString) { + facade.accept(eventString); + } + } + class Facade { + private OrderService service; + public void accept(String str) { + service.doSomething(str); + } + } + class OrderService { + public void doSomething(String str) { + MyEvents event = MyEvents.valueOf(str); + sendEvent(event); + } + public void sendEvent(MyEvents e) {} + } + enum MyEvents { A, B } + """; + Files.writeString(tempDir.resolve("Endpoint.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.Endpoint") + .methodName("trigger") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("sendEvent") + .event("e") + .build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + assertThat(chains).hasSize(1); + List poly = chains.get(0).getTriggerPoint().getPolymorphicEvents(); + assertThat(poly) + .as("3-hop plain valueOf must stay symbolic, got %s", poly) + .noneMatch(e -> e != null && (e.equals("MyEvents.A") || e.equals("MyEvents.B"))); + assertThat(poly.stream().anyMatch(e -> e != null && e.contains("SYMBOLIC"))) + .as("expected symbolic poly, got %s", poly) + .isTrue(); + } + @Test void multiTypeEnterpriseDispatcherStaysMultiSymbolic() throws IOException { CodebaseContext context = new CodebaseContext(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventArgumentSelectorBugHuntTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventArgumentSelectorBugHuntTest.java new file mode 100644 index 0000000..2f4ac17 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventArgumentSelectorBugHuntTest.java @@ -0,0 +1,137 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.model.Transition; +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 java.util.Collections; +import java.util.List; + +import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Adversarial coverage for {@code EventArgumentSelector} wiring in detector / ACE / EI. + */ +class EventArgumentSelectorBugHuntTest { + + private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig"; + private static final String EVENT_TYPE = "com.example.OrderEvent"; + private static final String STATE_TYPE = "com.example.OrderState"; + + @Test + void sendEventHeadersThenEventResolvesPay(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.Map; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(Map.of(), "PAY"); } + } + class Dispatcher { + StateMachine machine; + void fire(Map headers, String code) { + machine.sendEvent(headers, OrderEvent.valueOf(code)); + } + } + class StateMachine { public void sendEvent(Map headers, OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, CANCEL } + enum OrderState { NEW, PAID, SHIPPED, CANCELLED } + class OrderStateMachineConfig {} + """; + CodebaseContext context = scanSource(source, tempDir); + Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY"); + Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP"); + CallChain linked = resolveLinkAndAssertSingleMatch( + context, + "com.example.ApiController", + "pay", + "com.example.StateMachine", + "sendEvent", + MACHINE_CONFIG, + EVENT_TYPE, + STATE_TYPE, + EVENT_TYPE + ".PAY", + EngineKind.JDT, + pay, + ship); + assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + assertThat(linked.getTriggerPoint().getEvent()) + .as("event stamp must not be the headers arg") + .doesNotContain("headers") + .doesNotContain("Map"); + } + + @Test + void sendEventEventThenStateStillDetectsSourceState(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class OrderService { + private StateMachine sm; + public void pay() { + sm.sendEvent(OrderEvent.PAY, OrderState.PENDING); + } + } + class StateMachine { + public void sendEvent(OrderEvent e, OrderState s) {} + } + enum OrderState { PENDING } + enum OrderEvent { PAY } + """; + Files.writeString(tempDir.resolve("OrderService.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + GenericEventDetector detector = new GenericEventDetector( + context, new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver(), + Collections.emptyList()); + List triggers = detector.detect( + (org.eclipse.jdt.core.dom.CompilationUnit) + context.getTypeDeclaration("com.example.OrderService").getRoot()); + assertThat(triggers).hasSize(1); + assertThat(triggers.get(0).getSourceState()).isEqualTo("PENDING"); + assertThat(triggers.get(0).getEvent()).contains("PAY"); + } + + @Test + void sendEventHeadersThenValueOfPolyUsesEventArg(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + import java.util.Map; + public class ApiController { + StateMachine sm; + public void process(Map headers, String action) { + sm.sendEvent(headers, OrderEvent.valueOf(action.toUpperCase())); + } + } + enum OrderEvent { PAY, SHIP, CANCEL } + class StateMachine { + public void sendEvent(Map headers, OrderEvent e) {} + } + """; + Files.writeString(tempDir.resolve("ApiController.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + EntryPoint entry = EntryPoint.builder() + .className("com.example.ApiController") + .methodName("process") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("e") + .build(); + List chains = engine.findChains(List.of(entry), List.of(trigger)); + assertThat(chains).isNotEmpty(); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .contains("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"); + } +}