Replace .get() heuristics with type-based access classification and extend literal dataflow resolution.
Introduce ExpressionAccessClassifier to distinguish map lookups from Supplier/bean accessors, fold Map.of and string/array literals at compile time, and add regression tests for dispatcher and dataflow paths. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import click.kamil.springstatemachineexporter.analysis.enricher.routing.Heuristi
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
|
||||
|
||||
public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
|
||||
@@ -109,91 +110,30 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
}
|
||||
|
||||
private boolean isConstraintCompatible(String constraint, String machineName) {
|
||||
if (constraint == null || machineName == null) return true;
|
||||
|
||||
String cleanMachine = machineName.substring(machineName.lastIndexOf('.') + 1).toUpperCase();
|
||||
if (cleanMachine.endsWith("STATEMACHINECONFIGURATION")) {
|
||||
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "STATEMACHINECONFIGURATION".length());
|
||||
}
|
||||
if (cleanMachine.endsWith("STATEMACHINE")) {
|
||||
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "STATEMACHINE".length());
|
||||
}
|
||||
if (cleanMachine.endsWith("CONFIG")) {
|
||||
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "CONFIG".length());
|
||||
}
|
||||
|
||||
String expr = constraint;
|
||||
java.util.regex.Pattern p = java.util.regex.Pattern.compile("[\"']([a-zA-Z0-9_-]+)[\"']");
|
||||
java.util.regex.Matcher matcher = p.matcher(constraint);
|
||||
java.util.Set<String> literals = new java.util.HashSet<>();
|
||||
while (matcher.find()) {
|
||||
literals.add(matcher.group(1));
|
||||
}
|
||||
|
||||
for (String m : literals) {
|
||||
boolean isCurrent = m.equalsIgnoreCase(cleanMachine);
|
||||
String replacement = isCurrent ? "true" : "false";
|
||||
|
||||
String regex1 = "(?i)[\"']?" + m + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\([^)]*\\)";
|
||||
String regex2 = "(?i)[a-zA-Z0-9._]+\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?" + m + "[\"']?\\s*\\)";
|
||||
String regex3 = "(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + m + "[\"']?";
|
||||
String regex4 = "(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + m + "[\"']?";
|
||||
|
||||
expr = expr.replaceAll(regex1, replacement);
|
||||
expr = expr.replaceAll(regex2, replacement);
|
||||
expr = expr.replaceAll(regex3, replacement);
|
||||
expr = expr.replaceAll(regex4, isCurrent ? "false" : "true");
|
||||
}
|
||||
|
||||
try {
|
||||
return BooleanEvaluator.evaluate(expr);
|
||||
} catch (Exception e) {
|
||||
return !expr.contains("false");
|
||||
}
|
||||
}
|
||||
|
||||
private static class BooleanEvaluator {
|
||||
public static boolean evaluate(String expr) {
|
||||
expr = expr.replaceAll("\\s+", "");
|
||||
return parseExpr(expr);
|
||||
}
|
||||
|
||||
private static boolean parseExpr(String s) {
|
||||
if (s.isEmpty()) return true;
|
||||
|
||||
int parenDepth = 0;
|
||||
for (int i = s.length() - 1; i >= 0; i--) {
|
||||
char c = s.charAt(i);
|
||||
if (c == ')') parenDepth++;
|
||||
else if (c == '(') parenDepth--;
|
||||
else if (parenDepth == 0 && i > 0 && s.charAt(i) == '|' && s.charAt(i - 1) == '|') {
|
||||
return parseExpr(s.substring(0, i - 1)) || parseExpr(s.substring(i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
parenDepth = 0;
|
||||
for (int i = s.length() - 1; i >= 0; i--) {
|
||||
char c = s.charAt(i);
|
||||
if (c == ')') parenDepth++;
|
||||
else if (c == '(') parenDepth--;
|
||||
else if (parenDepth == 0 && i > 0 && s.charAt(i) == '&' && s.charAt(i - 1) == '&') {
|
||||
return parseExpr(s.substring(0, i - 1)) && parseExpr(s.substring(i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
if (s.startsWith("!")) {
|
||||
return !parseExpr(s.substring(1));
|
||||
}
|
||||
|
||||
if (s.startsWith("(") && s.endsWith(")")) {
|
||||
return parseExpr(s.substring(1, s.length() - 1));
|
||||
}
|
||||
|
||||
if (s.equalsIgnoreCase("true")) return true;
|
||||
if (s.equalsIgnoreCase("false")) return false;
|
||||
|
||||
if (constraint == null || machineName == null) {
|
||||
return true;
|
||||
}
|
||||
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
constraint, extractMachineDomainKey(machineName));
|
||||
}
|
||||
|
||||
private String extractMachineDomainKey(String machineName) {
|
||||
String simple = machineName.substring(machineName.lastIndexOf('.') + 1);
|
||||
String upper = simple.toUpperCase();
|
||||
|
||||
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) {
|
||||
int idx = upper.indexOf(marker);
|
||||
if (idx > 0) {
|
||||
return upper.substring(0, idx);
|
||||
}
|
||||
}
|
||||
|
||||
int stateIdx = upper.indexOf("STATE");
|
||||
if (stateIdx > 0) {
|
||||
return upper.substring(0, stateIdx);
|
||||
}
|
||||
|
||||
return upper;
|
||||
}
|
||||
|
||||
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>();
|
||||
|
||||
@@ -13,55 +13,45 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
}
|
||||
|
||||
if (triggerPoint.isExternal()) {
|
||||
return true;
|
||||
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null
|
||||
? triggerPoint.getPolymorphicEvents()
|
||||
: java.util.Collections.emptyList();
|
||||
if (polyEvents.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
|
||||
String rawTriggerEvent = triggerPoint.getEvent();
|
||||
|
||||
if (rawTriggerEvent != null && rawTriggerEvent.startsWith("<SYMBOLIC: ") && rawTriggerEvent.endsWith(".*>")) {
|
||||
String targetType = rawTriggerEvent.substring(11, rawTriggerEvent.length() - 3);
|
||||
if (smEventRaw.startsWith(targetType + ".")) {
|
||||
return true;
|
||||
}
|
||||
if (smEventRaw.contains(".")) {
|
||||
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
||||
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
|
||||
|
||||
for (String pe : polyEvents) {
|
||||
if (matchEventNames(pe, smEventRaw, triggerPoint.getEventTypeFqn())) {
|
||||
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
|
||||
continue;
|
||||
}
|
||||
String eventTypeFqn = triggerPoint.getEventTypeFqn();
|
||||
if (pe.contains(".") && isStringTypeOrPrimitive(eventTypeFqn)) {
|
||||
eventTypeFqn = null;
|
||||
}
|
||||
if (matchEventNames(pe, smEventRaw, eventTypeFqn)) {
|
||||
return true;
|
||||
}
|
||||
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
|
||||
String targetType = pe.substring(11, pe.length() - 3);
|
||||
if (smEventRaw.startsWith(targetType + ".")) {
|
||||
return true;
|
||||
}
|
||||
if (smEventRaw.contains(".")) {
|
||||
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
||||
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
|
||||
return true;
|
||||
}
|
||||
if (!pe.contains(".") && smEventRaw.contains(".")) {
|
||||
String smConst = constantName(smEventRaw);
|
||||
if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null
|
||||
&& !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
||||
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
||||
return enumTypesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rawTriggerEvent != null && rawTriggerEvent.contains(".valueOf(")) {
|
||||
String enumClass = rawTriggerEvent.substring(0, rawTriggerEvent.indexOf(".valueOf("));
|
||||
if (enumClass.contains(" ")) {
|
||||
enumClass = enumClass.substring(enumClass.lastIndexOf(" ") + 1);
|
||||
}
|
||||
String smClass = smEventRaw.contains(".") ? smEventRaw.substring(0, smEventRaw.lastIndexOf('.')) : smEventRaw;
|
||||
if (enumClass.equals(smClass) || enumClass.endsWith("." + smClass) || smClass.endsWith("." + enumClass)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -91,7 +81,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
if (!tConst.equals(smConst)) return false;
|
||||
|
||||
// Prevent matching string/primitive triggers to enum transitions
|
||||
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".")) {
|
||||
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Shared boolean simplification for transition guards and dispatcher domain checks.
|
||||
*/
|
||||
public final class BooleanConstraintEvaluator {
|
||||
|
||||
private static final Pattern STRING_LITERAL_PATTERN = Pattern.compile("[\"']([a-zA-Z0-9_-]+)[\"']");
|
||||
|
||||
private BooleanConstraintEvaluator() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplifies constraints such as {@code "ORDER".equals(domain)} against a machine domain key.
|
||||
*/
|
||||
public static boolean isCompatibleWithMachineDomain(String constraint, String machineDomainKey) {
|
||||
if (constraint == null || machineDomainKey == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String cleanMachine = machineDomainKey.toUpperCase();
|
||||
String expr = constraint;
|
||||
Set<String> literals = extractStringLiterals(constraint);
|
||||
|
||||
for (String literal : literals) {
|
||||
boolean matchesMachine = literal.equalsIgnoreCase(cleanMachine)
|
||||
|| cleanMachine.equalsIgnoreCase(literal)
|
||||
|| (cleanMachine.length() > literal.length() && cleanMachine.startsWith(literal.toUpperCase()));
|
||||
String replacement = matchesMachine ? "true" : "false";
|
||||
|
||||
String regex1 = "(?i)[\"']?" + Pattern.quote(literal) + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\([^)]*\\)";
|
||||
String regex2 = "(?i)[a-zA-Z0-9._]+\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||
+ Pattern.quote(literal) + "[\"']?\\s*\\)";
|
||||
String regex3 = "(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + Pattern.quote(literal) + "[\"']?";
|
||||
String regex4 = "(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + Pattern.quote(literal) + "[\"']?";
|
||||
|
||||
expr = expr.replaceAll(regex1, replacement);
|
||||
expr = expr.replaceAll(regex2, replacement);
|
||||
expr = expr.replaceAll(regex3, replacement);
|
||||
expr = expr.replaceAll(regex4, matchesMachine ? "false" : "true");
|
||||
}
|
||||
|
||||
return evaluateBooleanExpression(expr);
|
||||
}
|
||||
|
||||
public static boolean evaluateBooleanExpression(String expression) {
|
||||
if (expression == null || expression.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return parseExpression(expression.replaceAll("\\s+", ""));
|
||||
} catch (Exception e) {
|
||||
return !expression.contains("false");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true}/{@code false} when both sides are compile-time string literals; otherwise {@code null}.
|
||||
*/
|
||||
public static Boolean evaluateStringEquals(String leftLiteral, String rightLiteral, boolean ignoreCase) {
|
||||
if (leftLiteral == null || rightLiteral == null) {
|
||||
return null;
|
||||
}
|
||||
return ignoreCase
|
||||
? leftLiteral.equalsIgnoreCase(rightLiteral)
|
||||
: leftLiteral.equals(rightLiteral);
|
||||
}
|
||||
|
||||
private static Set<String> extractStringLiterals(String constraint) {
|
||||
Set<String> literals = new HashSet<>();
|
||||
Matcher matcher = STRING_LITERAL_PATTERN.matcher(constraint);
|
||||
while (matcher.find()) {
|
||||
literals.add(matcher.group(1));
|
||||
}
|
||||
return literals;
|
||||
}
|
||||
|
||||
private static boolean parseExpression(String expression) {
|
||||
if (expression.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
int parenDepth = 0;
|
||||
for (int i = expression.length() - 1; i >= 0; i--) {
|
||||
char c = expression.charAt(i);
|
||||
if (c == ')') {
|
||||
parenDepth++;
|
||||
} else if (c == '(') {
|
||||
parenDepth--;
|
||||
} else if (parenDepth == 0 && i > 0 && expression.charAt(i) == '|' && expression.charAt(i - 1) == '|') {
|
||||
return parseExpression(expression.substring(0, i - 1)) || parseExpression(expression.substring(i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
parenDepth = 0;
|
||||
for (int i = expression.length() - 1; i >= 0; i--) {
|
||||
char c = expression.charAt(i);
|
||||
if (c == ')') {
|
||||
parenDepth++;
|
||||
} else if (c == '(') {
|
||||
parenDepth--;
|
||||
} else if (parenDepth == 0 && i > 0 && expression.charAt(i) == '&' && expression.charAt(i - 1) == '&') {
|
||||
return parseExpression(expression.substring(0, i - 1)) && parseExpression(expression.substring(i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
if (expression.startsWith("!")) {
|
||||
return !parseExpression(expression.substring(1));
|
||||
}
|
||||
if (expression.startsWith("(") && expression.endsWith(")")) {
|
||||
return parseExpression(expression.substring(1, expression.length() - 1));
|
||||
}
|
||||
if ("true".equalsIgnoreCase(expression)) {
|
||||
return true;
|
||||
}
|
||||
if ("false".equalsIgnoreCase(expression)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -17,6 +19,10 @@ public class ConstantResolver {
|
||||
return resolveInternal(expr, context, new HashSet<>());
|
||||
}
|
||||
|
||||
public static List<String> decodeMapLiteralValues(String encoded) {
|
||||
return LiteralExpressionSupport.mapLiteralValues(encoded);
|
||||
}
|
||||
|
||||
public String evaluateSwitchWithParams(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context) {
|
||||
return evaluateSwitchExpression(se, paramValues, context, new HashSet<>());
|
||||
}
|
||||
@@ -35,6 +41,25 @@ public class ConstantResolver {
|
||||
if (expr instanceof ParenthesizedExpression pe) {
|
||||
return resolveInternal(pe.getExpression(), context, visited);
|
||||
}
|
||||
if (expr instanceof ArrayAccess access) {
|
||||
String indexed = LiteralExpressionSupport.resolveArrayAccess(
|
||||
access, context, visited, (e, v) -> resolveInternal(e, context, v));
|
||||
if (indexed != null) {
|
||||
return indexed;
|
||||
}
|
||||
}
|
||||
if (expr instanceof ArrayCreation creation && creation.getInitializer() != null) {
|
||||
List<String> elements = extractArrayElementsFromCreation(creation, context, visited);
|
||||
if (elements != null) {
|
||||
return LiteralExpressionSupport.encodeArrayLiteral(elements);
|
||||
}
|
||||
}
|
||||
if (expr instanceof ArrayInitializer initializer) {
|
||||
List<String> elements = extractArrayElementsFromInitializer(initializer, context, visited);
|
||||
if (elements != null) {
|
||||
return LiteralExpressionSupport.encodeArrayLiteral(elements);
|
||||
}
|
||||
}
|
||||
if (expr instanceof MethodInvocation mi && (mi.getName().getIdentifier().equals("requireNonNull") || mi.getName().getIdentifier().equals("notNull"))) {
|
||||
if (!mi.arguments().isEmpty()) {
|
||||
return resolveInternal((Expression) mi.arguments().get(0), context, visited);
|
||||
@@ -76,6 +101,10 @@ public class ConstantResolver {
|
||||
return val != null ? val : qn.toString();
|
||||
}
|
||||
if (expr instanceof FieldAccess fa) {
|
||||
String staticField = resolveStaticFieldAccess(fa, context, visited);
|
||||
if (staticField != null) {
|
||||
return staticField;
|
||||
}
|
||||
return resolveManual(fa.getName(), context, visited);
|
||||
}
|
||||
if (expr instanceof SimpleName sn) {
|
||||
@@ -84,7 +113,26 @@ public class ConstantResolver {
|
||||
|
||||
|
||||
if (expr instanceof MethodInvocation mi) {
|
||||
if ((mi.getName().getIdentifier().equals("name") || mi.getName().getIdentifier().equals("toString")) && mi.arguments().isEmpty()) {
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
if ("get".equals(methodName) || "getOrDefault".equals(methodName)) {
|
||||
String mapValue = LiteralExpressionSupport.resolveMapGet(
|
||||
mi, context, visited, (e, v) -> resolveInternal(e, context, v));
|
||||
if (mapValue != null) {
|
||||
return mapValue;
|
||||
}
|
||||
}
|
||||
String transformed = LiteralExpressionSupport.resolveLiteralStringTransform(
|
||||
mi, visited, (e, v) -> resolveInternal(e, context, v));
|
||||
if (transformed != null) {
|
||||
return transformed;
|
||||
}
|
||||
java.util.Map<String, String> mapEntries = LiteralExpressionSupport.extractMapEntries(
|
||||
mi, context, visited, (e, v) -> resolveInternal(e, context, v));
|
||||
if (mapEntries != null && isMapFactoryInvocation(mi)) {
|
||||
return LiteralExpressionSupport.encodeMapLiteral(mapEntries);
|
||||
}
|
||||
|
||||
if ((methodName.equals("name") || methodName.equals("toString")) && mi.arguments().isEmpty()) {
|
||||
String val = resolveInternal(mi.getExpression(), context, visited);
|
||||
if (val != null) {
|
||||
// If it was resolved as a fully qualified enum string like "MyEvents.ORDER_PAID", strip it
|
||||
@@ -671,6 +719,50 @@ public class ConstantResolver {
|
||||
return fields.isEmpty() ? null : fields;
|
||||
}
|
||||
|
||||
private List<String> extractArrayElementsFromCreation(ArrayCreation creation, CodebaseContext context, Set<String> visited) {
|
||||
if (creation.getInitializer() == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> values = new ArrayList<>();
|
||||
for (Object expressionObj : creation.getInitializer().expressions()) {
|
||||
String value = resolveInternal((Expression) expressionObj, context, visited);
|
||||
if (value == null || value.startsWith("ARRAY:") || value.startsWith("MAP:")) {
|
||||
return null;
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private List<String> extractArrayElementsFromInitializer(ArrayInitializer initializer, CodebaseContext context, Set<String> visited) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (Object expressionObj : initializer.expressions()) {
|
||||
String value = resolveInternal((Expression) expressionObj, context, visited);
|
||||
if (value == null || value.startsWith("ARRAY:") || value.startsWith("MAP:")) {
|
||||
return null;
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private boolean isMapFactoryInvocation(MethodInvocation mi) {
|
||||
if (!"of".equals(mi.getName().getIdentifier()) && !"ofEntries".equals(mi.getName().getIdentifier())) {
|
||||
return false;
|
||||
}
|
||||
Expression receiver = mi.getExpression();
|
||||
if (receiver == null) {
|
||||
return false;
|
||||
}
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
return "Map".equals(sn.getIdentifier());
|
||||
}
|
||||
if (receiver instanceof QualifiedName qn) {
|
||||
return "Map".equals(qn.getName().getIdentifier());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String resolveInfix(InfixExpression expr, CodebaseContext context, Set<String> visited) {
|
||||
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
|
||||
|
||||
@@ -721,7 +813,18 @@ public class ConstantResolver {
|
||||
if (td != null) {
|
||||
String fqn = context.getFqn(td);
|
||||
String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited);
|
||||
if (result != null) return result;
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
td = typeFromCurrentCallPath(context);
|
||||
if (td != null) {
|
||||
String fqn = context.getFqn(td);
|
||||
String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Static imports
|
||||
@@ -742,6 +845,9 @@ public class ConstantResolver {
|
||||
|
||||
CompilationUnit contextCu = (CompilationUnit) qn.getRoot();
|
||||
TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu);
|
||||
if (td == null) {
|
||||
td = resolveTypeInContext(qualifier, qn, context);
|
||||
}
|
||||
|
||||
if (td != null) {
|
||||
String fqn = context.getFqn(td);
|
||||
@@ -751,6 +857,67 @@ public class ConstantResolver {
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveStaticFieldAccess(FieldAccess fa, CodebaseContext context, Set<String> visited) {
|
||||
Expression receiver = fa.getExpression();
|
||||
if (receiver instanceof ThisExpression) {
|
||||
return null;
|
||||
}
|
||||
String typeName = null;
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
typeName = sn.getIdentifier();
|
||||
} else if (receiver instanceof QualifiedName qn) {
|
||||
typeName = qn.getFullyQualifiedName();
|
||||
}
|
||||
if (typeName == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration td = resolveTypeInContext(typeName, fa, context);
|
||||
if (td == null) {
|
||||
td = typeFromCurrentCallPath(context);
|
||||
}
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
return resolveFieldInType(td, fa.getName().getIdentifier(), context.getFqn(td), context, visited);
|
||||
}
|
||||
|
||||
private TypeDeclaration resolveTypeInContext(String typeName, ASTNode node, CodebaseContext context) {
|
||||
CompilationUnit contextCu = node.getRoot() instanceof CompilationUnit cu ? cu : null;
|
||||
TypeDeclaration td = contextCu != null
|
||||
? context.getTypeDeclaration(typeName, contextCu)
|
||||
: context.getTypeDeclaration(typeName);
|
||||
if (td != null) {
|
||||
return td;
|
||||
}
|
||||
String entryClass = getCurrentCallPathEntryClass();
|
||||
if (entryClass == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration entryTd = context.getTypeDeclaration(entryClass);
|
||||
if (entryTd == null || !(entryTd.getRoot() instanceof CompilationUnit entryCu)) {
|
||||
return null;
|
||||
}
|
||||
return context.getTypeDeclaration(typeName, entryCu);
|
||||
}
|
||||
|
||||
private TypeDeclaration typeFromCurrentCallPath(CodebaseContext context) {
|
||||
String entryClass = getCurrentCallPathEntryClass();
|
||||
if (entryClass == null) {
|
||||
return null;
|
||||
}
|
||||
return context.getTypeDeclaration(entryClass);
|
||||
}
|
||||
|
||||
private String getCurrentCallPathEntryClass() {
|
||||
List<String> path = JdtDataFlowModel.getCurrentPath();
|
||||
if (path == null || path.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String entryMethod = path.get(0);
|
||||
int dot = entryMethod.lastIndexOf('.');
|
||||
return dot > 0 ? entryMethod.substring(0, dot) : null;
|
||||
}
|
||||
|
||||
private String resolveFieldInType(TypeDeclaration td, String fieldName, String typeFqn, CodebaseContext context, Set<String> visited) {
|
||||
String fieldId = typeFqn + "#" + fieldName;
|
||||
if (visited.contains(fieldId)) {
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/**
|
||||
* Compile-time literal folding for map lookups, array indexing, and narrow string transforms.
|
||||
*/
|
||||
final class LiteralExpressionSupport {
|
||||
|
||||
private LiteralExpressionSupport() {
|
||||
}
|
||||
|
||||
static String resolveArrayAccess(
|
||||
ArrayAccess access,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
Integer index = resolveLiteralInt(access.getIndex(), context, visited, resolver);
|
||||
if (index == null || index < 0) {
|
||||
return null;
|
||||
}
|
||||
List<String> elements = extractArrayElements(access.getArray(), context, visited, resolver);
|
||||
if (elements == null || index >= elements.size()) {
|
||||
return null;
|
||||
}
|
||||
return elements.get(index);
|
||||
}
|
||||
|
||||
static String resolveMapGet(
|
||||
MethodInvocation getCall,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (getCall.arguments().isEmpty() || getCall.getExpression() == null) {
|
||||
return null;
|
||||
}
|
||||
String key = resolver.apply((Expression) getCall.arguments().get(0), visited);
|
||||
if (key == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> entries = extractMapEntries(getCall.getExpression(), context, visited, resolver);
|
||||
if (entries == null) {
|
||||
return null;
|
||||
}
|
||||
return entries.get(key);
|
||||
}
|
||||
|
||||
static String resolveLiteralStringTransform(
|
||||
MethodInvocation transformCall,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (!transformCall.arguments().isEmpty() || transformCall.getExpression() == null) {
|
||||
return null;
|
||||
}
|
||||
String receiver = resolver.apply(transformCall.getExpression(), visited);
|
||||
if (receiver == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (transformCall.getName().getIdentifier()) {
|
||||
case "toUpperCase" -> receiver.toUpperCase(Locale.ROOT);
|
||||
case "toLowerCase" -> receiver.toLowerCase(Locale.ROOT);
|
||||
case "trim" -> receiver.trim();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
static Integer resolveLiteralInt(
|
||||
Expression expr,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (expr instanceof NumberLiteral nl) {
|
||||
try {
|
||||
return Integer.parseInt(nl.getToken().replace("_", ""));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (expr instanceof ParenthesizedExpression pe) {
|
||||
return resolveLiteralInt(pe.getExpression(), context, visited, resolver);
|
||||
}
|
||||
if (expr instanceof InfixExpression infix) {
|
||||
Integer left = resolveLiteralInt(infix.getLeftOperand(), context, visited, resolver);
|
||||
Integer right = resolveLiteralInt(infix.getRightOperand(), context, visited, resolver);
|
||||
if (left == null || right == null) {
|
||||
return null;
|
||||
}
|
||||
InfixExpression.Operator op = infix.getOperator();
|
||||
if (op == InfixExpression.Operator.PLUS) {
|
||||
return left + right;
|
||||
}
|
||||
if (op == InfixExpression.Operator.MINUS) {
|
||||
return left - right;
|
||||
}
|
||||
}
|
||||
String resolved = resolver.apply(expr, visited);
|
||||
if (resolved != null) {
|
||||
try {
|
||||
return Integer.parseInt(resolved.replace("_", ""));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, String> extractMapEntries(
|
||||
Expression expr,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (expr instanceof MethodInvocation mi) {
|
||||
Map<String, String> fromFactory = extractMapFactoryEntries(mi, context, visited, resolver);
|
||||
if (fromFactory != null) {
|
||||
return fromFactory;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
String resolvedField = resolver.apply(expr, visited);
|
||||
if (resolvedField != null && resolvedField.startsWith("MAP:")) {
|
||||
return parseMapLiteral(resolvedField);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, String> extractMapFactoryEntries(
|
||||
MethodInvocation mi,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (!isMapFactory(mi.getExpression())) {
|
||||
return null;
|
||||
}
|
||||
String method = mi.getName().getIdentifier();
|
||||
if ("of".equals(method)) {
|
||||
return extractAlternatingPairs(mi.arguments(), context, visited, resolver);
|
||||
}
|
||||
if ("ofEntries".equals(method)) {
|
||||
Map<String, String> entries = new LinkedHashMap<>();
|
||||
for (Object argObj : mi.arguments()) {
|
||||
if (argObj instanceof Expression entryExpr) {
|
||||
Map<String, String> single = extractMapEntryCall(entryExpr, context, visited, resolver);
|
||||
if (single != null) {
|
||||
entries.putAll(single);
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries.isEmpty() ? null : entries;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, String> extractMapEntryCall(
|
||||
Expression expr,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (!(expr instanceof MethodInvocation entryCall) || !"entry".equals(entryCall.getName().getIdentifier())) {
|
||||
return null;
|
||||
}
|
||||
if (!isMapFactory(entryCall.getExpression()) || entryCall.arguments().size() != 2) {
|
||||
return null;
|
||||
}
|
||||
String key = resolver.apply((Expression) entryCall.arguments().get(0), visited);
|
||||
String value = resolver.apply((Expression) entryCall.arguments().get(1), visited);
|
||||
if (key == null || value == null) {
|
||||
return null;
|
||||
}
|
||||
return Map.of(key, value);
|
||||
}
|
||||
|
||||
private static Map<String, String> extractAlternatingPairs(
|
||||
List<?> arguments,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (arguments.size() < 2) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> entries = new LinkedHashMap<>();
|
||||
for (int i = 0; i + 1 < arguments.size(); i += 2) {
|
||||
String key = resolver.apply((Expression) arguments.get(i), visited);
|
||||
String value = resolver.apply((Expression) arguments.get(i + 1), visited);
|
||||
if (key == null || value == null) {
|
||||
return null;
|
||||
}
|
||||
entries.put(key, value);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
static String encodeMapLiteral(Map<String, String> entries) {
|
||||
StringBuilder sb = new StringBuilder("MAP:");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> entry : entries.entrySet()) {
|
||||
if (!first) {
|
||||
sb.append('|');
|
||||
}
|
||||
first = false;
|
||||
sb.append(escape(entry.getKey())).append('=').append(escape(entry.getValue()));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
static Map<String, String> parseMapLiteral(String encoded) {
|
||||
if (encoded == null || !encoded.startsWith("MAP:")) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> entries = new LinkedHashMap<>();
|
||||
for (String pair : encoded.substring(4).split("\\|")) {
|
||||
int eq = pair.indexOf('=');
|
||||
if (eq <= 0) {
|
||||
continue;
|
||||
}
|
||||
entries.put(unescape(pair.substring(0, eq)), unescape(pair.substring(eq + 1)));
|
||||
}
|
||||
return entries.isEmpty() ? null : entries;
|
||||
}
|
||||
|
||||
static List<String> mapLiteralValues(String encoded) {
|
||||
Map<String, String> entries = parseMapLiteral(encoded);
|
||||
if (entries == null) {
|
||||
return null;
|
||||
}
|
||||
return List.copyOf(entries.values());
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value.replace("\\", "\\\\").replace("|", "\\|").replace("=", "\\=");
|
||||
}
|
||||
|
||||
private static String unescape(String value) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (c == '\\' && i + 1 < value.length()) {
|
||||
sb.append(value.charAt(++i));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static List<String> extractArrayElements(
|
||||
Expression arrayExpr,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (arrayExpr instanceof ArrayCreation creation && creation.getInitializer() != null) {
|
||||
return extractExpressionList(creation.getInitializer().expressions(), context, visited, resolver);
|
||||
}
|
||||
if (arrayExpr instanceof ArrayInitializer initializer) {
|
||||
return extractExpressionList(initializer.expressions(), context, visited, resolver);
|
||||
}
|
||||
String resolved = resolver.apply(arrayExpr, visited);
|
||||
if (resolved != null && resolved.startsWith("ARRAY:")) {
|
||||
return List.of(resolved.substring(6).split("\\|", -1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String encodeArrayLiteral(List<String> values) {
|
||||
return "ARRAY:" + String.join("|", values.stream().map(LiteralExpressionSupport::escape).toList());
|
||||
}
|
||||
|
||||
private static List<String> extractExpressionList(
|
||||
List<?> expressions,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (Object expressionObj : expressions) {
|
||||
String value = resolver.apply((Expression) expressionObj, visited);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static boolean isMapFactory(Expression expression) {
|
||||
if (expression == null) {
|
||||
return false;
|
||||
}
|
||||
if (expression instanceof SimpleName sn) {
|
||||
return "Map".equals(sn.getIdentifier());
|
||||
}
|
||||
if (expression instanceof QualifiedName qn) {
|
||||
return "Map".equals(qn.getName().getIdentifier());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
protected final VariableTracer variableTracer;
|
||||
protected final ConstantExtractor constantExtractor;
|
||||
protected final ConstructorAnalyzer constructorAnalyzer;
|
||||
protected final ExpressionAccessClassifier expressionAccessClassifier;
|
||||
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
|
||||
|
||||
private ASTNode parseExpressionString(String expr) {
|
||||
@@ -77,6 +78,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
this.constantExtractor.setConstructorAnalyzer(this.constructorAnalyzer);
|
||||
this.constructorAnalyzer.setVariableTracer(this.variableTracer);
|
||||
this.constructorAnalyzer.setConstantExtractor(this.constantExtractor);
|
||||
this.expressionAccessClassifier = new ExpressionAccessClassifier(
|
||||
context, variableTracer, constantResolver, this::parseExpressionString);
|
||||
this.constantExtractor.setExpressionAccessClassifier(this.expressionAccessClassifier);
|
||||
}
|
||||
|
||||
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
|
||||
@@ -156,7 +160,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
String currentParamName = event;
|
||||
String resolvedValue = event;
|
||||
String methodSuffix = "";
|
||||
String[] extractedEntry = extractMethodSuffix(currentParamName, methodSuffix);
|
||||
String scopeMethod = path.isEmpty() ? null : path.get(0);
|
||||
String[] extractedEntry = extractMethodSuffix(currentParamName, methodSuffix, scopeMethod);
|
||||
currentParamName = extractedEntry[0];
|
||||
methodSuffix = extractedEntry[1];
|
||||
boolean isAmbiguous = false;
|
||||
@@ -197,7 +202,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
Map<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i);
|
||||
String tracedVar = variableTracer.traceLocalVariable(target, currentParamName, parameterValues);
|
||||
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
|
||||
String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix);
|
||||
String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix, target);
|
||||
tracedVar = extractedTraced[0];
|
||||
methodSuffix = extractedTraced[1];
|
||||
currentParamName = tracedVar;
|
||||
@@ -259,7 +264,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
// Intentionally not replacing 'super' because super should remain statically bound to the superclass of where the code is defined.
|
||||
}
|
||||
String[] extractedArg = extractMethodSuffix(arg, methodSuffix);
|
||||
String[] extractedArg = extractMethodSuffix(arg, methodSuffix, caller);
|
||||
arg = extractedArg[0];
|
||||
methodSuffix = extractedArg[1];
|
||||
currentParamName = arg;
|
||||
@@ -290,11 +295,67 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
|
||||
String entryMethod = path.get(0);
|
||||
|
||||
if (methodSuffix.startsWith(".get") || methodSuffix.contains(".type") || methodSuffix.contains(".event")) {
|
||||
List<String> chainedGetterEvents = evaluateGetterOnReceiver(resolvedValue, methodSuffix, entryMethod, path, callGraph);
|
||||
if (chainedGetterEvents != null && !chainedGetterEvents.isEmpty()) {
|
||||
log.debug("Early return (chained getter): getterEvents = {}", chainedGetterEvents);
|
||||
return TriggerPoint.builder()
|
||||
.event(resolvedValue)
|
||||
.className(tp.getClassName())
|
||||
.methodName(tp.getMethodName())
|
||||
.sourceFile(tp.getSourceFile())
|
||||
.sourceModule(tp.getSourceModule())
|
||||
.stateMachineId(tp.getStateMachineId())
|
||||
.sourceState(tp.getSourceState())
|
||||
.lineNumber(tp.getLineNumber())
|
||||
.polymorphicEvents(chainedGetterEvents)
|
||||
.constraint(tp.getConstraint())
|
||||
.stateTypeFqn(tp.getStateTypeFqn())
|
||||
.eventTypeFqn(tp.getEventTypeFqn())
|
||||
.external(tp.isExternal())
|
||||
.build();
|
||||
}
|
||||
|
||||
if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event")
|
||||
|| methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) {
|
||||
String localMethodName = methodSuffix.substring(1).replace("()", "");
|
||||
String scopeForVar = resolveScopeMethodForVariable(currentParamName, entryMethod, path);
|
||||
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, currentParamName, localMethodName);
|
||||
if (localSetterExpr != null) {
|
||||
List<String> setterEvents = new ArrayList<>();
|
||||
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
|
||||
for (Expression tracedSetter : tracedSetters) {
|
||||
constantExtractor.extractConstantsFromExpression(tracedSetter, setterEvents);
|
||||
}
|
||||
if (!setterEvents.isEmpty()) {
|
||||
log.debug("Early return (scoped setter): setterEvents = {}", setterEvents);
|
||||
return TriggerPoint.builder()
|
||||
.event(resolvedValue)
|
||||
.className(tp.getClassName())
|
||||
.methodName(tp.getMethodName())
|
||||
.sourceFile(tp.getSourceFile())
|
||||
.sourceModule(tp.getSourceModule())
|
||||
.stateMachineId(tp.getStateMachineId())
|
||||
.sourceState(tp.getSourceState())
|
||||
.lineNumber(tp.getLineNumber())
|
||||
.polymorphicEvents(setterEvents)
|
||||
.constraint(tp.getConstraint())
|
||||
.stateTypeFqn(tp.getStateTypeFqn())
|
||||
.eventTypeFqn(tp.getEventTypeFqn())
|
||||
.external(tp.isExternal())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int entryParamIndex = typeResolver.getParameterIndex(entryMethod, currentParamName);
|
||||
if (entryParamIndex < 0) {
|
||||
if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event") || methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) {
|
||||
String localMethodName = methodSuffix.substring(1).replace("()", "");
|
||||
Expression localSetterExpr = variableTracer.traceLocalSetter(entryMethod, currentParamName, localMethodName);
|
||||
String scopeForVar = resolveScopeMethodForVariable(currentParamName, entryMethod, path);
|
||||
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, currentParamName, localMethodName);
|
||||
if (localSetterExpr != null) {
|
||||
List<String> setterEvents = new ArrayList<>();
|
||||
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
|
||||
@@ -304,7 +365,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (!setterEvents.isEmpty()) {
|
||||
log.debug("Early return 1: setterEvents = {}", setterEvents);
|
||||
return TriggerPoint.builder()
|
||||
.event(tp.getEvent())
|
||||
.event(resolvedValue)
|
||||
.className(tp.getClassName())
|
||||
.methodName(tp.getMethodName())
|
||||
.sourceFile(tp.getSourceFile())
|
||||
@@ -329,7 +390,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (getterEvents != null && !getterEvents.isEmpty()) {
|
||||
log.debug("Early return 2: getterEvents = {}", getterEvents);
|
||||
return TriggerPoint.builder()
|
||||
.event(tp.getEvent())
|
||||
.event(resolvedValue)
|
||||
.className(tp.getClassName())
|
||||
.methodName(tp.getMethodName())
|
||||
.sourceFile(tp.getSourceFile())
|
||||
@@ -348,7 +409,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName);
|
||||
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
|
||||
String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix);
|
||||
String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix, entryMethod);
|
||||
tracedVar = extractedFinalTraced[0];
|
||||
methodSuffix = extractedFinalTraced[1];
|
||||
currentParamName = tracedVar;
|
||||
@@ -366,7 +427,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (!polymorphicEvents.contains(parsed)) polymorphicEvents.add(parsed);
|
||||
}
|
||||
return TriggerPoint.builder()
|
||||
.event(tp.getEvent())
|
||||
.event(resolvedValue)
|
||||
.className(tp.getClassName())
|
||||
.methodName(tp.getMethodName())
|
||||
.sourceFile(tp.getSourceFile())
|
||||
@@ -394,17 +455,62 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
if (exprNode instanceof MethodInvocation mi) {
|
||||
methodName = mi.getName().getIdentifier();
|
||||
if ("get".equals(methodName) || "getOrDefault".equals(methodName)) {
|
||||
boolean keyedLookup = expressionAccessClassifier.isKeyedLookup(mi, entryMethod);
|
||||
boolean functionalGet = "get".equals(methodName) && mi.arguments().isEmpty() && !keyedLookup;
|
||||
if (keyedLookup || functionalGet) {
|
||||
constantExtractor.extractConstantsFromExpression((Expression) exprNode, polymorphicEvents);
|
||||
if (!polymorphicEvents.isEmpty()) {
|
||||
return TriggerPoint.builder()
|
||||
.event(resolvedValue)
|
||||
.className(tp.getClassName())
|
||||
.methodName(tp.getMethodName())
|
||||
.sourceFile(tp.getSourceFile())
|
||||
.sourceModule(tp.getSourceModule())
|
||||
.stateMachineId(tp.getStateMachineId())
|
||||
.sourceState(tp.getSourceState())
|
||||
.lineNumber(tp.getLineNumber())
|
||||
.polymorphicEvents(polymorphicEvents)
|
||||
.constraint(tp.getConstraint())
|
||||
.stateTypeFqn(tp.getStateTypeFqn())
|
||||
.eventTypeFqn(tp.getEventTypeFqn())
|
||||
.external(tp.isExternal())
|
||||
.ambiguous(isAmbiguous)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean handledStaticEnum = false;
|
||||
if (methodName.equals("valueOf") && mi.arguments().size() == 1) {
|
||||
Object arg = mi.arguments().get(0);
|
||||
String enumTypeName = resolveValueOfEnumTypeName(mi);
|
||||
if (arg instanceof StringLiteral sl) {
|
||||
polymorphicEvents.add(sl.getLiteralValue());
|
||||
if (enumTypeName != null) {
|
||||
polymorphicEvents.add(enumTypeName + "." + sl.getLiteralValue());
|
||||
} else {
|
||||
polymorphicEvents.add(sl.getLiteralValue());
|
||||
}
|
||||
methodName = null;
|
||||
handledStaticEnum = true;
|
||||
} else if (arg instanceof QualifiedName qn) {
|
||||
polymorphicEvents.add(qn.getName().getIdentifier());
|
||||
if (enumTypeName != null) {
|
||||
polymorphicEvents.add(enumTypeName + "." + qn.getName().getIdentifier());
|
||||
} else {
|
||||
polymorphicEvents.add(qn.getName().getIdentifier());
|
||||
}
|
||||
methodName = null;
|
||||
handledStaticEnum = true;
|
||||
} else if (arg instanceof SimpleName sn) {
|
||||
String resolvedConstant = resolveEnumConstantFromArgument(sn.getIdentifier(), path, callGraph);
|
||||
if (resolvedConstant != null) {
|
||||
if (enumTypeName != null) {
|
||||
polymorphicEvents.add(enumTypeName + "." + resolvedConstant);
|
||||
} else {
|
||||
polymorphicEvents.add(resolvedConstant);
|
||||
}
|
||||
methodName = null;
|
||||
handledStaticEnum = true;
|
||||
}
|
||||
}
|
||||
} else if (methodName.equals("name") && mi.arguments().isEmpty()) {
|
||||
if (mi.getExpression() instanceof QualifiedName qn) {
|
||||
@@ -444,7 +550,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
}
|
||||
return TriggerPoint.builder()
|
||||
.event(tp.getEvent())
|
||||
.event(resolvedValue)
|
||||
.className(tp.getClassName())
|
||||
.methodName(tp.getMethodName())
|
||||
.sourceFile(tp.getSourceFile())
|
||||
@@ -579,7 +685,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
if (methodName != null) {
|
||||
if (varName != null && !varName.isEmpty() && Character.isLowerCase(varName.charAt(0)) && !methodName.equals("VariableReference")) {
|
||||
Expression localSetterExpr = variableTracer.traceLocalSetter(entryMethod, varName, methodName);
|
||||
String scopeForVar = resolveScopeMethodForVariable(varName, entryMethod, path);
|
||||
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, varName, methodName);
|
||||
if (localSetterExpr != null) {
|
||||
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
|
||||
for (Expression tracedSetter : tracedSetters) {
|
||||
@@ -724,10 +831,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (declaredType != null) {
|
||||
List<String> enumValues = context.getEnumValues(declaredType);
|
||||
if (enumValues != null && !enumValues.isEmpty()) {
|
||||
for (String ev : enumValues) {
|
||||
polymorphicEvents.add(constantExtractor.parseEnumSetElement(ev));
|
||||
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
|
||||
&& !tp.isExternal()
|
||||
&& !isRuntimeEnumParameter(exprNode instanceof Expression expression ? expression : null)) {
|
||||
for (String ev : enumValues) {
|
||||
polymorphicEvents.add(constantExtractor.parseEnumSetElement(ev));
|
||||
}
|
||||
isAmbiguous = true;
|
||||
}
|
||||
isAmbiguous = true;
|
||||
} else {
|
||||
List<String> typesToInspect = new ArrayList<>();
|
||||
if ("inline-instantiation".equals(sourceMethod)) {
|
||||
@@ -849,6 +960,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
polymorphicEvents = unpacked;
|
||||
|
||||
polymorphicEvents = polymorphicEvents.stream().distinct().collect(java.util.stream.Collectors.toList());
|
||||
|
||||
Expression runtimeExpr = exprNode instanceof Expression expression ? expression : null;
|
||||
if (isRuntimeEnumParameter(runtimeExpr) || tp.isExternal()) {
|
||||
polymorphicEvents.removeIf(pe -> pe != null && pe.startsWith("<SYMBOLIC:"));
|
||||
}
|
||||
|
||||
if (!resolvedValue.equals(event) || !polymorphicEvents.isEmpty()) {
|
||||
return TriggerPoint.builder()
|
||||
@@ -1030,12 +1146,108 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return constructorAnalyzer.findReturnedCIC(className, methodName, context);
|
||||
}
|
||||
|
||||
protected record GetterChainReceiver(String typeFqn, ClassInstanceCreation cic) {}
|
||||
|
||||
/**
|
||||
* Resolves a nested getter prefix such as {@code wrapper.getEvent()} to the declared return type
|
||||
* and, when possible, a {@code new ...()} instance from the getter body.
|
||||
*/
|
||||
protected GetterChainReceiver resolveGetterChainToReceiver(Expression expr, String entryMethod) {
|
||||
if (expr instanceof SimpleName sn) {
|
||||
String type = variableTracer.getVariableDeclaredType(entryMethod, sn.getIdentifier());
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
Expression init = findVariableInitializer(entryMethod, sn.getIdentifier());
|
||||
ClassInstanceCreation cic = init instanceof ClassInstanceCreation c ? c : null;
|
||||
return new GetterChainReceiver(type, cic);
|
||||
}
|
||||
if (expr instanceof ThisExpression) {
|
||||
int lastDot = entryMethod.lastIndexOf('.');
|
||||
if (lastDot <= 0) {
|
||||
return null;
|
||||
}
|
||||
String className = entryMethod.substring(0, lastDot);
|
||||
return new GetterChainReceiver(className, null);
|
||||
}
|
||||
if (expr instanceof ClassInstanceCreation cic) {
|
||||
return new GetterChainReceiver(
|
||||
click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType()),
|
||||
cic);
|
||||
}
|
||||
if (!(expr instanceof MethodInvocation mi)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
GetterChainReceiver base = resolveGetterChainToReceiver(mi.getExpression(), entryMethod);
|
||||
if (base == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String getterName = mi.getName().getIdentifier();
|
||||
String simpleType = simplifyTypeName(base.typeFqn());
|
||||
ClassInstanceCreation nextCic = base.cic();
|
||||
if (nextCic == null) {
|
||||
nextCic = constructorAnalyzer.findReturnedCIC(simpleType, getterName, context);
|
||||
}
|
||||
if (nextCic != null) {
|
||||
return new GetterChainReceiver(
|
||||
click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(nextCic.getType()),
|
||||
nextCic);
|
||||
}
|
||||
|
||||
TypeDeclaration td = context.getTypeDeclaration(simpleType);
|
||||
if (td == null) {
|
||||
td = context.getTypeDeclaration(base.typeFqn());
|
||||
}
|
||||
if (td != null) {
|
||||
MethodDeclaration getter = context.findMethodDeclaration(td, getterName, true);
|
||||
if (getter != null && getter.getReturnType2() != null) {
|
||||
return new GetterChainReceiver(getter.getReturnType2().toString(), null);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String simplifyTypeName(String typeFqn) {
|
||||
if (typeFqn == null) {
|
||||
return null;
|
||||
}
|
||||
String simple = typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn;
|
||||
if (simple.contains("<")) {
|
||||
simple = simple.substring(0, simple.indexOf('<'));
|
||||
}
|
||||
return simple;
|
||||
}
|
||||
|
||||
private String resolveScopeMethodForVariable(String varName, String entryMethod, List<String> path) {
|
||||
if (varName != null && path != null) {
|
||||
for (String methodFqn : path) {
|
||||
if (variableTracer.getVariableDeclaredType(methodFqn, varName) != null) {
|
||||
return methodFqn;
|
||||
}
|
||||
}
|
||||
}
|
||||
return entryMethod;
|
||||
}
|
||||
|
||||
protected List<String> evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List<String> path, Map<String, List<CallEdge>> callGraph) {
|
||||
ASTNode exprNode = parseExpressionString(resolvedValue);
|
||||
while (exprNode instanceof ParenthesizedExpression pe) {
|
||||
exprNode = pe.getExpression();
|
||||
}
|
||||
|
||||
String scopeMethod = entryMethod;
|
||||
Expression rootExpr = exprNode instanceof Expression e ? e : null;
|
||||
if (rootExpr != null) {
|
||||
while (rootExpr instanceof MethodInvocation mi) {
|
||||
rootExpr = mi.getExpression();
|
||||
}
|
||||
if (rootExpr instanceof SimpleName sn) {
|
||||
scopeMethod = resolveScopeMethodForVariable(sn.getIdentifier(), entryMethod, path);
|
||||
}
|
||||
}
|
||||
|
||||
if (exprNode instanceof SuperMethodInvocation smi) {
|
||||
String getterName = smi.getName().getIdentifier();
|
||||
int lastDot = entryMethod.lastIndexOf('.');
|
||||
@@ -1055,25 +1267,31 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
receiverName = sn.getIdentifier();
|
||||
receiverType = variableTracer.getVariableDeclaredType(entryMethod, receiverName);
|
||||
receiverType = variableTracer.getVariableDeclaredType(scopeMethod, receiverName);
|
||||
} else if (receiver instanceof ClassInstanceCreation cic) {
|
||||
directCic = cic;
|
||||
receiverType = cic.getType().toString();
|
||||
} else if (receiver instanceof MethodInvocation receiverMi) {
|
||||
Expression currentMi = receiverMi;
|
||||
while (currentMi instanceof MethodInvocation chainMi) {
|
||||
String mName = chainMi.getName().getIdentifier();
|
||||
if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) {
|
||||
if (!chainMi.arguments().isEmpty()) {
|
||||
Expression arg = (Expression) chainMi.arguments().get(0);
|
||||
List<String> builderConstants = new ArrayList<>();
|
||||
constantExtractor.extractConstantsFromExpression(arg, builderConstants);
|
||||
if (!builderConstants.isEmpty()) {
|
||||
return builderConstants;
|
||||
GetterChainReceiver chainedReceiver = resolveGetterChainToReceiver(receiverMi, scopeMethod);
|
||||
if (chainedReceiver != null) {
|
||||
receiverType = chainedReceiver.typeFqn();
|
||||
directCic = chainedReceiver.cic();
|
||||
} else {
|
||||
Expression currentMi = receiverMi;
|
||||
while (currentMi instanceof MethodInvocation chainMi) {
|
||||
String mName = chainMi.getName().getIdentifier();
|
||||
if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) {
|
||||
if (!chainMi.arguments().isEmpty()) {
|
||||
Expression arg = (Expression) chainMi.arguments().get(0);
|
||||
List<String> builderConstants = new ArrayList<>();
|
||||
constantExtractor.extractConstantsFromExpression(arg, builderConstants);
|
||||
if (!builderConstants.isEmpty()) {
|
||||
return builderConstants;
|
||||
}
|
||||
}
|
||||
}
|
||||
currentMi = chainMi.getExpression();
|
||||
}
|
||||
currentMi = chainMi.getExpression();
|
||||
}
|
||||
} else if (receiver instanceof SuperMethodInvocation) {
|
||||
int lastDot = entryMethod.lastIndexOf('.');
|
||||
@@ -1123,7 +1341,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (directCic != null) {
|
||||
cic = directCic;
|
||||
} else {
|
||||
Expression initExpr = findVariableInitializer(entryMethod, varName);
|
||||
Expression initExpr = findVariableInitializer(scopeMethod, varName);
|
||||
if (initExpr instanceof MethodInvocation initMi) {
|
||||
initExpr = unwrapMethodInvocation(initMi, 0, entryMethod);
|
||||
}
|
||||
@@ -1148,18 +1366,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
if (getter != null && getter.getBody() != null) {
|
||||
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor));
|
||||
int lastDotEntry = entryMethod.lastIndexOf('.');
|
||||
if (lastDotEntry > 0) {
|
||||
String className = entryMethod.substring(0, lastDotEntry);
|
||||
String methodName = entryMethod.substring(lastDot + 1);
|
||||
TypeDeclaration entryTd = context.getTypeDeclaration(className);
|
||||
if (entryTd != null) {
|
||||
MethodDeclaration entryMd = context.findMethodDeclaration(entryTd, methodName, false);
|
||||
if (entryMd != null && entryMd.getBody() != null) {
|
||||
// Skip trackSetterCallsBetween
|
||||
}
|
||||
}
|
||||
}
|
||||
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>());
|
||||
if (result != null) {
|
||||
String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null;
|
||||
@@ -1208,7 +1414,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return initExpr[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a traced argument into a receiver base and a trailing accessor suffix for getter-chain
|
||||
* resolution, e.g. {@code wrapper.getEvent()} → {@code ["wrapper", ".getEvent()"]}.
|
||||
* <p>
|
||||
* Map/collection lookups ({@code ROUTES.get(key)}) stay intact and are resolved by
|
||||
* {@link ConstantExtractor}; see {@link ExpressionAccessClassifier} for the type-based distinction
|
||||
* from {@code Supplier.get()} and JavaBean accessors.
|
||||
*/
|
||||
protected String[] extractMethodSuffix(String paramName, String currentSuffix) {
|
||||
return extractMethodSuffix(paramName, currentSuffix, null);
|
||||
}
|
||||
|
||||
protected String[] extractMethodSuffix(String paramName, String currentSuffix, String scopeMethod) {
|
||||
if (expressionAccessClassifier.isKeyedLookup(paramName, scopeMethod)) {
|
||||
return new String[] { paramName, currentSuffix };
|
||||
}
|
||||
if (paramName != null && !paramName.contains(" ") && !paramName.startsWith("new ")) {
|
||||
int bracketIndex = paramName.indexOf('[');
|
||||
int dotIndex = -1;
|
||||
@@ -1591,12 +1812,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return allResolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Peels enum factory wrappers while tracing call-path arguments, e.g.
|
||||
* {@code OrderEvent.valueOf(action)} → {@code action} so the next hop can resolve the parameter.
|
||||
* <p>
|
||||
* Map/collection lookups ({@code ROUTES.get(key)}) are intentionally <em>not</em> peeled — they are
|
||||
* routing tables, not enum converters. See {@link ExpressionAccessClassifier}.
|
||||
*/
|
||||
private String unwrapConverterMethods(String arg) {
|
||||
if (arg == null) return null;
|
||||
String current = arg;
|
||||
while (current.contains("(") && !current.startsWith("new ")) {
|
||||
org.eclipse.jdt.core.dom.ASTNode argNode = parseExpressionString(current);
|
||||
if (argNode instanceof org.eclipse.jdt.core.dom.MethodInvocation miArg && !miArg.arguments().isEmpty()) {
|
||||
if (expressionAccessClassifier.isKeyedLookup(miArg, null)) {
|
||||
break;
|
||||
}
|
||||
boolean shouldUnpack = false;
|
||||
org.eclipse.jdt.core.dom.Expression recv = miArg.getExpression();
|
||||
if (recv == null || recv instanceof org.eclipse.jdt.core.dom.ThisExpression) {
|
||||
@@ -1619,4 +1850,118 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private String resolveValueOfEnumTypeName(MethodInvocation mi) {
|
||||
if (mi.getExpression() instanceof SimpleName sn) {
|
||||
return sn.getIdentifier();
|
||||
}
|
||||
if (mi.getExpression() instanceof QualifiedName qn) {
|
||||
String full = qn.getFullyQualifiedName();
|
||||
return full.contains(".") ? full.substring(full.lastIndexOf('.') + 1) : full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveEnumConstantFromArgument(String argName, List<String> path, Map<String, List<CallEdge>> callGraph) {
|
||||
if (argName == null || path == null || path.size() < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (int i = path.size() - 1; i > 0; i--) {
|
||||
String target = path.get(i);
|
||||
String caller = path.get(i - 1);
|
||||
|
||||
Map<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i);
|
||||
String traced = variableTracer.traceLocalVariable(target, argName, parameterValues);
|
||||
if (traced != null && !traced.equals(argName)) {
|
||||
String normalized = normalizeEnumConstantName(traced);
|
||||
if (normalized != null) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
int paramIndex = typeResolver.getParameterIndex(target, argName);
|
||||
if (paramIndex >= 0) {
|
||||
List<CallEdge> edges = callGraph.get(caller);
|
||||
if (edges != null) {
|
||||
for (CallEdge edge : edges) {
|
||||
if (edge.getTargetMethod().equals(target) || pathFinder.isHeuristicMatch(edge.getTargetMethod(), target)) {
|
||||
if (paramIndex < edge.getArguments().size()) {
|
||||
String normalized = normalizeEnumConstantName(edge.getArguments().get(paramIndex));
|
||||
if (normalized != null) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String normalizeEnumConstantName(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
|
||||
trimmed = trimmed.substring(1, trimmed.length() - 1);
|
||||
}
|
||||
if (trimmed.contains("(") || trimmed.contains(" ")) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.contains(".")) {
|
||||
trimmed = trimmed.substring(trimmed.lastIndexOf('.') + 1);
|
||||
}
|
||||
if (!trimmed.matches("[A-Z_][A-Z0-9_]*")) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private boolean hasConcreteEnumConstants(List<String> polymorphicEvents, String declaredType, CodebaseContext context) {
|
||||
if (polymorphicEvents == null || polymorphicEvents.isEmpty() || declaredType == null) {
|
||||
return false;
|
||||
}
|
||||
String simpleDeclared = declaredType.contains(".")
|
||||
? declaredType.substring(declaredType.lastIndexOf('.') + 1)
|
||||
: declaredType;
|
||||
List<String> enumValues = context.getEnumValues(declaredType);
|
||||
|
||||
for (String pe : polymorphicEvents) {
|
||||
if (pe == null || pe.startsWith("<SYMBOLIC:")) {
|
||||
continue;
|
||||
}
|
||||
if (pe.contains(".")) {
|
||||
String typePart = pe.substring(0, pe.lastIndexOf('.'));
|
||||
String simpleType = typePart.contains(".")
|
||||
? typePart.substring(typePart.lastIndexOf('.') + 1)
|
||||
: typePart;
|
||||
if (simpleType.equals(simpleDeclared)) {
|
||||
return true;
|
||||
}
|
||||
if (enumValues != null && enumValues.contains(pe)) {
|
||||
return true;
|
||||
}
|
||||
} else if (enumValues != null) {
|
||||
for (String ev : enumValues) {
|
||||
if (ev.endsWith("." + pe)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isRuntimeEnumParameter(Expression exprNode) {
|
||||
if (!(exprNode instanceof MethodInvocation mi)) {
|
||||
return false;
|
||||
}
|
||||
if (!"valueOf".equals(mi.getName().getIdentifier()) || mi.arguments().size() != 1) {
|
||||
return false;
|
||||
}
|
||||
return mi.arguments().get(0) instanceof SimpleName;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public class ConstantExtractor {
|
||||
private final Function<MethodInvocation, String> calledMethodResolver;
|
||||
private VariableTracer variableTracer;
|
||||
private ConstructorAnalyzer constructorAnalyzer;
|
||||
private ExpressionAccessClassifier expressionAccessClassifier;
|
||||
|
||||
public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function<MethodInvocation, String> calledMethodResolver) {
|
||||
this.context = context;
|
||||
@@ -29,25 +30,20 @@ public class ConstantExtractor {
|
||||
this.constructorAnalyzer = constructorAnalyzer;
|
||||
}
|
||||
|
||||
public void setExpressionAccessClassifier(ExpressionAccessClassifier expressionAccessClassifier) {
|
||||
this.expressionAccessClassifier = expressionAccessClassifier;
|
||||
}
|
||||
|
||||
public void extractConstantsFromExpression(Expression expr, List<String> constants) {
|
||||
if (constantResolver != null) {
|
||||
String resolved = constantResolver.resolve(expr, context);
|
||||
if (resolved != null) {
|
||||
if (resolved.startsWith("ENUM_SET:")) {
|
||||
for (String eVal : resolved.substring(9).split(",")) {
|
||||
String parsed = parseEnumSetElement(eVal);
|
||||
if (!constants.contains(parsed)) constants.add(parsed);
|
||||
}
|
||||
} else {
|
||||
constants.add(resolved);
|
||||
}
|
||||
addResolvedConstant(resolved, constants);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (expr instanceof QualifiedName qn) {
|
||||
constants.add(qn.toString());
|
||||
} else if (expr instanceof StringLiteral sl) {
|
||||
if (expr instanceof StringLiteral sl) {
|
||||
constants.add(sl.getLiteralValue());
|
||||
} else if (expr instanceof ConditionalExpression ce) {
|
||||
extractConstantsFromExpression(ce.getThenExpression(), constants);
|
||||
@@ -90,18 +86,15 @@ public class ConstantExtractor {
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
|
||||
log.trace("Processing mi: {}", mi);
|
||||
if (methodName.equals("get") || methodName.equals("getOrDefault")) {
|
||||
if (mi.getExpression() != null) {
|
||||
if (variableTracer != null) {
|
||||
List<Expression> traced = variableTracer.traceVariableAll(mi.getExpression());
|
||||
for (Expression t : traced) {
|
||||
extractConstantsFromExpression(t, constants);
|
||||
}
|
||||
} else {
|
||||
extractConstantsFromExpression(mi.getExpression(), constants);
|
||||
}
|
||||
if (("get".equals(methodName) || "getOrDefault".equals(methodName)) && mi.getExpression() != null) {
|
||||
if (isKeyedLookupMethod(mi)) {
|
||||
extractMapReceiverConstants(mi.getExpression(), constants);
|
||||
return;
|
||||
}
|
||||
if ("get".equals(methodName) && mi.arguments().isEmpty()) {
|
||||
extractMapReceiverConstants(mi.getExpression(), constants);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") ||
|
||||
methodName.equals("just") || methodName.equals("withPayload") || methodName.equals("success")) {
|
||||
@@ -402,6 +395,84 @@ public class ConstantExtractor {
|
||||
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
|
||||
}
|
||||
|
||||
private boolean isKeyedLookupMethod(MethodInvocation mi) {
|
||||
if (expressionAccessClassifier != null) {
|
||||
return expressionAccessClassifier.isKeyedLookup(mi, null);
|
||||
}
|
||||
return "getOrDefault".equals(mi.getName().getIdentifier())
|
||||
|| ("get".equals(mi.getName().getIdentifier()) && !mi.arguments().isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves constants from the map/collection receiver of {@code .get(key)}.
|
||||
* Uses {@link ConstantResolver} for static fields ({@code ROUTES = Map.of(...)}) first;
|
||||
* falls back to dataflow tracing for locals ({@code Map map = ...; map.get(k)}).
|
||||
*/
|
||||
private void extractMapReceiverConstants(Expression receiver, List<String> constants) {
|
||||
if (tryAddResolvedExpression(receiver, constants)) {
|
||||
return;
|
||||
}
|
||||
if (receiver instanceof MethodInvocation || receiver instanceof ArrayCreation || receiver instanceof ArrayInitializer) {
|
||||
extractConstantsFromExpression(receiver, constants);
|
||||
if (!constants.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (variableTracer == null) {
|
||||
return;
|
||||
}
|
||||
String receiverText = receiver.toString();
|
||||
for (Expression traced : variableTracer.traceVariableAll(receiver)) {
|
||||
if (traced == null || receiverText.equals(traced.toString())) {
|
||||
continue;
|
||||
}
|
||||
if (tryAddResolvedExpression(traced, constants)) {
|
||||
continue;
|
||||
}
|
||||
extractConstantsFromExpression(traced, constants);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean tryAddResolvedExpression(Expression expr, List<String> constants) {
|
||||
if (constantResolver == null) {
|
||||
return false;
|
||||
}
|
||||
String resolved = constantResolver.resolve(expr, context);
|
||||
if (resolved == null) {
|
||||
return false;
|
||||
}
|
||||
addResolvedConstant(resolved, constants);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void addResolvedConstant(String resolved, List<String> constants) {
|
||||
if (resolved.startsWith("ENUM_SET:")) {
|
||||
for (String eVal : resolved.substring(9).split(",")) {
|
||||
String parsed = parseEnumSetElement(eVal);
|
||||
if (!constants.contains(parsed)) {
|
||||
constants.add(parsed);
|
||||
}
|
||||
}
|
||||
} else if (resolved.startsWith("MAP:")) {
|
||||
List<String> mapValues = ConstantResolver.decodeMapLiteralValues(resolved);
|
||||
if (mapValues != null) {
|
||||
for (String mapValue : mapValues) {
|
||||
if (!constants.contains(mapValue)) {
|
||||
constants.add(mapValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (resolved.startsWith("ARRAY:")) {
|
||||
for (String arrayValue : resolved.substring(6).split("\\|", -1)) {
|
||||
if (!constants.contains(arrayValue)) {
|
||||
constants.add(arrayValue);
|
||||
}
|
||||
}
|
||||
} else if (!constants.contains(resolved)) {
|
||||
constants.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
private Block findEnclosingBlock(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null && !(parent instanceof Block)) {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Classifies trailing expression access (map lookup vs traceable accessor) using AST structure and
|
||||
* declared receiver types instead of string heuristics.
|
||||
*/
|
||||
public final class ExpressionAccessClassifier {
|
||||
|
||||
public enum AccessKind {
|
||||
/** {@code Map.get(key)}, {@code list.get(i)} — keep intact for {@link ConstantExtractor}. */
|
||||
KEYED_LOOKUP,
|
||||
/** {@code getEvent()}, {@code supplier.get()} — split receiver/suffix for call-path tracing. */
|
||||
TRACEABLE_ACCESSOR,
|
||||
NONE
|
||||
}
|
||||
|
||||
private final CodebaseContext context;
|
||||
private final VariableTracer variableTracer;
|
||||
private final ConstantResolver constantResolver;
|
||||
private final Function<String, ASTNode> expressionParser;
|
||||
|
||||
public ExpressionAccessClassifier(
|
||||
CodebaseContext context,
|
||||
VariableTracer variableTracer,
|
||||
ConstantResolver constantResolver,
|
||||
Function<String, ASTNode> expressionParser) {
|
||||
this.context = context;
|
||||
this.variableTracer = variableTracer;
|
||||
this.constantResolver = constantResolver;
|
||||
this.expressionParser = expressionParser;
|
||||
}
|
||||
|
||||
public AccessKind classifyTrailingAccess(String expression, String scopeMethod) {
|
||||
if (expression == null || expression.isBlank()) {
|
||||
return AccessKind.NONE;
|
||||
}
|
||||
ASTNode node = expressionParser.apply(expression);
|
||||
while (node instanceof ParenthesizedExpression pe) {
|
||||
node = pe.getExpression();
|
||||
}
|
||||
if (node instanceof ArrayAccess) {
|
||||
return AccessKind.NONE;
|
||||
}
|
||||
if (node instanceof MethodInvocation mi) {
|
||||
return classifyMethodInvocation(mi, scopeMethod);
|
||||
}
|
||||
return AccessKind.NONE;
|
||||
}
|
||||
|
||||
public AccessKind classifyMethodInvocation(MethodInvocation mi, String scopeMethod) {
|
||||
if (mi == null) {
|
||||
return AccessKind.NONE;
|
||||
}
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
int argCount = mi.arguments().size();
|
||||
|
||||
if ("getOrDefault".equals(methodName) && argCount >= 1) {
|
||||
return AccessKind.KEYED_LOOKUP;
|
||||
}
|
||||
|
||||
if ("get".equals(methodName)) {
|
||||
String receiverType = resolveReceiverType(mi.getExpression(), scopeMethod);
|
||||
if (argCount == 0) {
|
||||
if (isMapLikeType(receiverType)) {
|
||||
return AccessKind.KEYED_LOOKUP;
|
||||
}
|
||||
return AccessKind.TRACEABLE_ACCESSOR;
|
||||
}
|
||||
if (isMapLikeType(receiverType) || isIndexedCollectionType(receiverType)) {
|
||||
return AccessKind.KEYED_LOOKUP;
|
||||
}
|
||||
if (receiverExpressionIsMapLiteral(mi.getExpression())) {
|
||||
return AccessKind.KEYED_LOOKUP;
|
||||
}
|
||||
return AccessKind.TRACEABLE_ACCESSOR;
|
||||
}
|
||||
|
||||
if (argCount == 0 && (isBeanAccessorName(methodName) || isRecordStyleAccessor(methodName))) {
|
||||
return AccessKind.TRACEABLE_ACCESSOR;
|
||||
}
|
||||
|
||||
return AccessKind.TRACEABLE_ACCESSOR;
|
||||
}
|
||||
|
||||
public boolean isKeyedLookup(String expression, String scopeMethod) {
|
||||
return classifyTrailingAccess(expression, scopeMethod) == AccessKind.KEYED_LOOKUP;
|
||||
}
|
||||
|
||||
public boolean isKeyedLookup(MethodInvocation mi, String scopeMethod) {
|
||||
return classifyMethodInvocation(mi, scopeMethod) == AccessKind.KEYED_LOOKUP;
|
||||
}
|
||||
|
||||
private boolean receiverExpressionIsMapLiteral(Expression receiver) {
|
||||
if (constantResolver == null || context == null || receiver == null) {
|
||||
return false;
|
||||
}
|
||||
String resolved = constantResolver.resolve(receiver, context);
|
||||
return resolved != null && resolved.startsWith("MAP:");
|
||||
}
|
||||
|
||||
private String resolveReceiverType(Expression receiver, String scopeMethod) {
|
||||
if (receiver == null) {
|
||||
return null;
|
||||
}
|
||||
if (receiverExpressionIsMapLiteral(receiver)) {
|
||||
return "Map";
|
||||
}
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
return lookupDeclaredType(scopeMethod, sn.getIdentifier());
|
||||
}
|
||||
if (receiver instanceof ThisExpression) {
|
||||
return classNameFromScopeMethod(scopeMethod);
|
||||
}
|
||||
if (receiver instanceof FieldAccess fa) {
|
||||
String fieldName = fa.getName().getIdentifier();
|
||||
if (fa.getExpression() instanceof ThisExpression) {
|
||||
return lookupFieldType(classNameFromScopeMethod(scopeMethod), fieldName);
|
||||
}
|
||||
if (fa.getExpression() instanceof SimpleName outer) {
|
||||
String outerType = lookupDeclaredType(scopeMethod, outer.getIdentifier());
|
||||
return lookupFieldType(rawTypeName(outerType), fieldName);
|
||||
}
|
||||
if (fa.getExpression() instanceof QualifiedName qn) {
|
||||
String outerType = resolveQualifiedTypeName(qn);
|
||||
return lookupFieldType(outerType, fieldName);
|
||||
}
|
||||
}
|
||||
if (receiver instanceof QualifiedName qn) {
|
||||
String typeName = resolveQualifiedTypeName(qn);
|
||||
if (typeName != null) {
|
||||
String fieldName = qn.getName().getIdentifier();
|
||||
String fieldType = lookupFieldType(typeName, fieldName);
|
||||
if (fieldType != null) {
|
||||
return fieldType;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (receiver instanceof MethodInvocation chainMi) {
|
||||
AccessKind kind = classifyMethodInvocation(chainMi, scopeMethod);
|
||||
if (kind == AccessKind.TRACEABLE_ACCESSOR) {
|
||||
String getterName = chainMi.getName().getIdentifier();
|
||||
String baseType = resolveReceiverType(chainMi.getExpression(), scopeMethod);
|
||||
return lookupMethodReturnType(rawTypeName(baseType), getterName);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String lookupDeclaredType(String scopeMethod, String name) {
|
||||
if (variableTracer == null || scopeMethod == null || name == null) {
|
||||
return null;
|
||||
}
|
||||
return variableTracer.getVariableDeclaredType(scopeMethod, name);
|
||||
}
|
||||
|
||||
private String lookupFieldType(String typeName, String fieldName) {
|
||||
if (context == null || typeName == null || fieldName == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration td = context.getTypeDeclaration(typeName);
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
return findFieldType(td, fieldName, new HashSet<>());
|
||||
}
|
||||
|
||||
private String findFieldType(TypeDeclaration td, String fieldName, Set<String> visited) {
|
||||
String typeFqn = context.getFqn(td);
|
||||
if (typeFqn == null || !visited.add(typeFqn)) {
|
||||
return null;
|
||||
}
|
||||
for (FieldDeclaration fd : td.getFields()) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
|
||||
if (frag.getName().getIdentifier().equals(fieldName)) {
|
||||
return fd.getType().toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
String superFqn = context.getSuperclassFqn(td);
|
||||
if (superFqn != null) {
|
||||
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
||||
if (superTd != null) {
|
||||
String inherited = findFieldType(superTd, fieldName, visited);
|
||||
if (inherited != null) {
|
||||
return inherited;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String lookupMethodReturnType(String typeName, String methodName) {
|
||||
if (context == null || typeName == null || methodName == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration td = context.getTypeDeclaration(typeName);
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md != null && md.getReturnType2() != null) {
|
||||
return md.getReturnType2().toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveQualifiedTypeName(QualifiedName qn) {
|
||||
String full = qn.getFullyQualifiedName();
|
||||
if (full.contains(".")) {
|
||||
return full.substring(0, full.lastIndexOf('.'));
|
||||
}
|
||||
return classNameFromScopeMethod(null);
|
||||
}
|
||||
|
||||
private static String classNameFromScopeMethod(String scopeMethod) {
|
||||
if (scopeMethod == null || !scopeMethod.contains(".")) {
|
||||
return null;
|
||||
}
|
||||
return scopeMethod.substring(0, scopeMethod.lastIndexOf('.'));
|
||||
}
|
||||
|
||||
private static boolean isBeanAccessorName(String methodName) {
|
||||
return (methodName.startsWith("get") && methodName.length() > 3)
|
||||
|| (methodName.startsWith("is") && methodName.length() > 2);
|
||||
}
|
||||
|
||||
private static boolean isRecordStyleAccessor(String methodName) {
|
||||
return "type".equals(methodName) || "event".equals(methodName) || "name".equals(methodName);
|
||||
}
|
||||
|
||||
static boolean isMapLikeType(String type) {
|
||||
String raw = rawTypeName(type);
|
||||
if (raw == null) {
|
||||
return false;
|
||||
}
|
||||
return "Map".equals(raw) || raw.endsWith("Map");
|
||||
}
|
||||
|
||||
static boolean isIndexedCollectionType(String type) {
|
||||
String raw = rawTypeName(type);
|
||||
if (raw == null) {
|
||||
return false;
|
||||
}
|
||||
return "List".equals(raw) || raw.endsWith("List")
|
||||
|| "Set".equals(raw) || raw.endsWith("Set")
|
||||
|| raw.endsWith("Deque") || "Queue".equals(raw);
|
||||
}
|
||||
|
||||
static String rawTypeName(String type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
String raw = type.trim();
|
||||
if (raw.contains("<")) {
|
||||
raw = raw.substring(0, raw.indexOf('<'));
|
||||
}
|
||||
if (raw.contains(".")) {
|
||||
raw = raw.substring(raw.lastIndexOf('.') + 1);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
CURRENT_PATH.set(path);
|
||||
}
|
||||
|
||||
public static List<String> getCurrentPath() {
|
||||
return CURRENT_PATH.get();
|
||||
}
|
||||
|
||||
public static void clearCurrentPath() {
|
||||
CURRENT_PATH.remove();
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ public class ExporterCommand implements Callable<Integer> {
|
||||
@Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false")
|
||||
private boolean debug;
|
||||
|
||||
@Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "false")
|
||||
private boolean resolveClasspath;
|
||||
@Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "true", fallbackValue = "true")
|
||||
private boolean resolveClasspath = true;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
|
||||
Reference in New Issue
Block a user