This commit is contained in:
2026-07-19 07:08:05 +02:00
parent 61a04241c8
commit 0d677572e4
19 changed files with 2666 additions and 711 deletions

View File

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

View File

@@ -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<String> adoptExpandOrEmpty(ExpandConsult consult) {
if (consult == null || !consult.adoptableAsPolyExpand()) {
return List.of();
}
return new ArrayList<>(consult.members());
}
}

View File

@@ -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<String> 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<String> 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<String> resolveExpanded(
Expression expression,
List<String> methodPath,
Map<String, List<CallEdge>> callGraph) {
return resolveExpandConsult(expression, methodPath, callGraph).members();
}
/**
* Shared unique-or-expand consult for ACE and future poly publishers.
* <ol>
* <li>If {@link #resolveUnique} proves one constant → {@link ExpandConsult.Source#UNIQUE}.</li>
* <li>Else if expr-local or helper-return expand hint licenses an enum type →
* {@link ExpandConsult.Source#ENUM_EXPAND} with all concrete members.</li>
* <li>Else {@link ExpandConsult.Source#EMPTY}. Never returns analysis tokens.</li>
* </ol>
* Poly publishers must use {@link ExpandConsult#adoptableAsPolyExpand()} (or
* {@link EventIdentityPolyPublish#adoptExpandOrEmpty}) — never publish UNIQUE from consult.
*/
public ExpandConsult resolveExpandConsult(
Expression expression,
List<String> methodPath,
Map<String, List<CallEdge>> callGraph) {
if (expression == null) {
return ExpandConsult.empty();
}
List<String> unique = resolveUnique(expression, methodPath, callGraph);
if (unique.size() == 1) {
String enumType = inferExpandEnumType(expression);
List<String> qualified = qualifyMembers(unique, enumType);
return ExpandConsult.unique(qualified, enumType);
}
if (expressionHasExpandHintDeep(expression)) {
String enumType = inferExpandEnumType(expression);
if (enumType != null && !enumType.isBlank()) {
List<String> 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<String> resolveUniqueAgreeing(
List<Expression> exprs,
List<String> methodPath,
Map<String, List<CallEdge>> callGraph) {
LinkedHashSet<String> proven = new LinkedHashSet<>();
// simpleName → preferred (qualified) form
java.util.LinkedHashMap<String, String> proven = new java.util.LinkedHashMap<>();
for (Expression expr : exprs) {
List<String> 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<String> resolveUnique(
@@ -1301,21 +1362,19 @@ public final class EventIdentityResolver {
}
/**
* Peel identity-preserving String ops: trim/strip/toUpperCase/toLowerCase with <b>0 args</b>.
* 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<String> methodPath) {
if (expression == null || methodPath == null || methodPath.isEmpty()) {
return ExpandConsult.empty();
}
MethodDeclaration enclosing = findEnclosingMethod(expression);
String enclosingClass = enclosingClassFqn(enclosing);
List<Expression> 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<String> members = expandDeclaredEnumMembers(enumType);
if (!members.isEmpty()) {
return ExpandConsult.enumExpand(members, enumType);
}
}
return ExpandConsult.empty();
}
private void collectExpandCandidates(Expression expression, List<Expression> 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<String> 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<String> 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<String> 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<String> qualifyMembers(List<String> members, String enumType) {
if (members == null || members.isEmpty()) {
return List.of();
}
String simpleType = simpleEnumTypeName(enumType);
if (simpleType == null) {
return List.copyOf(members);
}
List<String> 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("<SYMBOLIC:") || member.startsWith("ENUM_SET:")) {
return member;
}
String parsed = member.contains(".")
? member.substring(member.lastIndexOf('.', member.lastIndexOf('.') - 1) + 1)
: member;
if (parsed.contains(".")) {
return parsed;
}
if (parsed.matches("[A-Z_][A-Z0-9_]*")) {
return simpleType + "." + parsed;
}
return member;
}
private static String simpleEnumTypeName(String enumType) {
if (enumType == null || enumType.isBlank()) {
return null;
}
return enumType.contains(".")
? enumType.substring(enumType.lastIndexOf('.') + 1)
: enumType;
}
private List<String> expandDeclaredEnumMembers(String declaredType) {
if (declaredType == null || declaredType.isBlank() || context == null) {
return List.of();
}
List<String> 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<String> 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);
}

View File

@@ -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).
* <p>
* <b>Publish contract:</b> do <em>not</em> 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<String> members;
private final Source source;
private final boolean licensedByHint;
private final String enumType;
private ExpandConsult(List<String> 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<String> members, String enumType) {
if (members == null || members.size() != 1) {
return empty();
}
return new ExpandConsult(members, Source.UNIQUE, false, enumType);
}
public static ExpandConsult enumExpand(List<String> members, String enumType) {
if (members == null || members.isEmpty()) {
return empty();
}
return new ExpandConsult(members, Source.ENUM_EXPAND, true, enumType);
}
public List<String> 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;
}
}

View File

@@ -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 <SYMBOLIC: Enum.*>} to concrete enum members (ACE poly fill).
* <p>
* 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;
}
}

View File

@@ -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<Expression> selectEventArgument(MethodInvocation call) {
if (call == null || call.arguments().isEmpty()) {
return Optional.empty();
}
List<Expression> 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;
}
}

View File

@@ -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.
* <p>
* Two roles (Locale arity intentionally split):
* <ul>
* <li><b>Peel</b> — identity unwrap for unique proofs: 0-arg {@code trim}/{@code strip}/
* {@code toUpperCase}/{@code toLowerCase}. Locale arity stays unpeeled (fail-closed).</li>
* <li><b>Expand hint</b> — license to widen unbound {@code <SYMBOLIC: Enum.*>}: 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.</li>
* </ul>
*/
public final class IdentityStringTransformSupport {
private static final Set<String> PEEL_NAMES = Set.of(
"trim", "strip", "toUpperCase", "toLowerCase");
private static final Set<String> 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;
}
}

View File

@@ -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 <SYMBOLIC:Enum.*> / 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<String> expandDeclaredEnumValues(String declaredType) {
if (declaredType == null) {
return List.of();
}
List<String> 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<String> 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<String> fromEi = EventIdentityPolyPublish.adoptExpandOrEmpty(consult);
boolean eiExpand = !fromEi.isEmpty();
if (polymorphicEvents.isEmpty()) {
if (callerArgumentHasEnumExpansionHint(path)) {
List<String> fromCaller = resolvePolymorphicEventsFromCallerArgument(path);
if (fromCaller.stream().anyMatch(this::looksLikeEnumConstant)) {
return fromCaller;
}
if (eiExpand) {
return fromEi;
}
return polymorphicEvents;
}
Map<String, String> bindings = resolvePathBindings(path, callGraph, Map.of());
if (bindings.isEmpty()) {
boolean symbolicOnly = polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant);
if (symbolicOnly) {
List<String> 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<String> 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<String> 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<String> 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<String> 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<String> path,
Map<String, List<CallEdge>> 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<String, List<CallEdge>> graphForFacts = callGraph != null ? callGraph : this.graph;
return eventIdentityResolver.resolveExpandConsult(eventArg[0], path, graphForFacts);
}
private boolean hasUniqueConcretePolymorphic(List<String> polymorphicEvents) {
if (polymorphicEvents == null || polymorphicEvents.size() != 1) {
return false;
}
return looksLikeEnumConstant(polymorphicEvents.get(0));
}
private List<String> resolvePolymorphicEventsFromBindings(
Map<String, String> bindings,
List<String> path) {
@@ -3895,344 +3927,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return resolved.stream().distinct().collect(java.util.stream.Collectors.toList());
}
private List<String> resolvePolymorphicEventsFromCallerArgument(List<String> 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<String> 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<Expression> 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<String> constants = new ArrayList<>();
constantExtractor.extractConstantsFromExpression(candidateExpr, constants);
for (String candidate : constants) {
addResolvedConstantToList(candidate, resolved);
}
}
return super.visit(node);
}
});
List<String> 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<String> 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<String> 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<Expression> 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<String> inferEnumValuesFromPath(List<String> 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<String> 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<String> path) {
if (path == null || path.isEmpty()) {
return false;
}
Set<String> 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<String> 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<String> 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<String> expandSingleSymbolicType(List<String> values) {
if (values == null || values.isEmpty()) {
return List.of();
}
Set<String> normalizedTypes = new LinkedHashSet<>();
String preferredType = null;
for (String value : values) {
if (value == null || !value.startsWith("<SYMBOLIC: ") || !value.endsWith(".*>")) {
continue;
}
String symbolicType = value.substring("<SYMBOLIC: ".length(), value.length() - 3).trim();
if (preferredType == null || symbolicType.contains(".")) {
preferredType = symbolicType;
}
String normalized = symbolicType.contains(".")
? symbolicType.substring(symbolicType.lastIndexOf('.') + 1)
: symbolicType;
normalizedTypes.add(normalized);
}
if (preferredType == null || normalizedTypes.size() != 1) {
return List.of();
}
return expandDeclaredEnumValues(preferredType);
}
private String resolveKeyedValueFromMapExpression(Expression mapExpr, String key) {
if (mapExpr == null || key == null) {
return null;

View File

@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.pipeline.EventArgumentSelector;
import click.kamil.springstatemachineexporter.analysis.pipeline.MessageCarrierSupport;
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
@@ -281,8 +282,11 @@ public class GenericEventDetector {
eventValue = context.resolveString(forcedEvent);
} else {
if (node.arguments().isEmpty()) return Collections.emptyList();
Expression eventExpr = (Expression) node.arguments().get(0);
Expression eventExpr = EventArgumentSelector.selectEventArgument(node).orElse(null);
if (eventExpr == null) {
return Collections.emptyList();
}
// Try MessageBuilder extraction first
eventValue = extractEventFromMessageBuilder(eventExpr);
@@ -305,16 +309,21 @@ public class GenericEventDetector {
String sourceState = extractSourceState(node);
if (sourceState == null && node instanceof MethodInvocation mi) {
sourceState = extractSourceStateFromArguments(mi);
Expression selected = EventArgumentSelector.selectEventArgument(mi).orElse(null);
if (EventArgumentSelector.selectedIsFirstArgument(mi, selected)) {
sourceState = extractSourceStateFromArguments(mi);
}
}
String stateMachineId = extractStateMachineId(node);
String[] smTypes = resolveStateMachineTypeArguments(node);
boolean external = false;
if (!node.arguments().isEmpty()) {
Expression eventExpr = (Expression) node.arguments().get(0);
AssignmentDag dag = new AssignmentDag(method);
external = dag.isExternal(eventExpr, new java.util.HashSet<>());
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);

View File

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

View File

@@ -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", "<SYMBOLIC: OrderEvent.*>")).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();
}
}

View File

@@ -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<String> 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<String> path = List.of("com.example.C.pay", "com.example.C.fire");
Map<String, List<CallEdge>> callGraph = Map.of(
"com.example.C.pay",
List.of(new CallEdge("com.example.C.fire", List.of("new PayRichEvent()"))));
List<String> unique = f.resolver.resolveUnique(f.valueOfCall, path, callGraph);
List<String> 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<String> 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<String> 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) {
}
}

View File

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

View File

@@ -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<Expression> 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<Expression> 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<String, Object> headers, String code) {
sm.sendEvent(headers, OrderEvent.valueOf(code));
}
}
class StateMachine { void sendEvent(Map<String, Object> headers, OrderEvent e) {} }
enum OrderEvent { PAY, SHIP }
""");
Optional<Expression> 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];
}
}

View File

@@ -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<String, String> 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<OrderEvent, String> 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<Person, String> 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];
}
}

View File

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

View File

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

View File

@@ -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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
List<String> 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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
List<String> 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();

View File

@@ -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<String, Object> headers, String code) {
machine.sendEvent(headers, OrderEvent.valueOf(code));
}
}
class StateMachine { public void sendEvent(Map<String, Object> 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<TriggerPoint> 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<String, Object> headers, String action) {
sm.sendEvent(headers, OrderEvent.valueOf(action.toUpperCase()));
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine {
public void sendEvent(Map<String, Object> 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<CallChain> 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");
}
}