Link REST endpoints via source-proven call-graph scoping and branch expansion.

Scope machines from call-chain type evidence, expand path bindings from switch/if literals, unify external trigger policy, add linkResolution export metadata, and built-in trigger detection without hints.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 17:46:14 +02:00
parent 1bf75c8a34
commit f8f64487d1
57 changed files with 3202 additions and 297 deletions

View File

@@ -16,8 +16,9 @@ public class EntryPointEnricher implements AnalysisEnricher {
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with entry points", result.getName());
// Keep all entry points; machine scoping is applied after call-chain resolution
// when source evidence (trigger type FQNs, literals, constraints) is available.
List<EntryPoint> entryPoints = intelligence.findEntryPoints();
entryPoints = MachineScopeFilter.filterEntryPointsForMachine(entryPoints, result.getName(), context);
result.addMetadata(CodebaseMetadata.builder()
.entryPoints(entryPoints)

View File

@@ -0,0 +1,160 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.GenericEventDetector;
import click.kamil.springstatemachineexporter.analysis.service.JdtCallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.CompilationUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Scopes REST entry points to a machine using call-graph reachability and trigger type evidence,
* not URL path segment guessing.
*/
public final class EntryPointScopeResolver {
private static final BeanResolutionEngine ROUTING = new HeuristicBeanResolutionEngine();
private static final String TRIGGERS_CACHE = "entryPointScope.triggers";
public enum Affinity {
FOR_MACHINE,
AGAINST_MACHINE,
UNKNOWN
}
private EntryPointScopeResolver() {
}
public static List<EntryPoint> scopeFromCallChains(List<CallChain> chains, List<EntryPoint> allEntryPoints) {
Map<String, EntryPoint> scoped = new LinkedHashMap<>();
if (chains != null) {
for (CallChain chain : chains) {
EntryPoint entryPoint = chain.getEntryPoint();
if (entryPoint != null) {
scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint);
}
}
}
if (allEntryPoints != null) {
for (EntryPoint entryPoint : allEntryPoints) {
if (hasPathVariablePlaceholder(entryPoint)) {
scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint);
}
}
}
return new ArrayList<>(scoped.values());
}
public static Affinity resolveAffinity(EntryPoint entryPoint, String machineName, CodebaseContext context) {
if (entryPoint == null || machineName == null) {
return Affinity.UNKNOWN;
}
if (entryPoint.getClassName() == null || entryPoint.getMethodName() == null) {
return Affinity.UNKNOWN;
}
if (context == null || context.getCompilationUnits().isEmpty()) {
return Affinity.UNKNOWN;
}
CallGraphEngine engine = createCallGraphEngine(context);
List<CallChain> chains = engine.findChains(List.of(entryPoint), getOrCollectTriggers(context));
if (chains.isEmpty()) {
return Affinity.UNKNOWN;
}
boolean matched = false;
boolean mismatched = false;
for (CallChain chain : chains) {
TriggerPoint trigger = chain.getTriggerPoint();
if (trigger == null || !hasTypeEvidence(trigger)) {
continue;
}
if (ROUTING.isRoutedToCorrectMachine(chain, machineName, context)) {
matched = true;
} else {
mismatched = true;
}
}
if (matched) {
return Affinity.FOR_MACHINE;
}
if (mismatched) {
return Affinity.AGAINST_MACHINE;
}
return Affinity.UNKNOWN;
}
public static boolean hasPathVariablePlaceholder(EntryPoint entryPoint) {
if (entryPoint == null) {
return false;
}
String name = entryPoint.getName();
if (name != null && name.contains("{")) {
return true;
}
Map<String, String> metadata = entryPoint.getMetadata();
if (metadata != null) {
String path = metadata.get("path");
return path != null && path.contains("{");
}
return false;
}
private static boolean hasTypeEvidence(TriggerPoint trigger) {
if (trigger.getEventTypeFqn() != null || trigger.getStateTypeFqn() != null) {
return true;
}
return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM;
}
@SuppressWarnings("unchecked")
private static List<TriggerPoint> getOrCollectTriggers(CodebaseContext context) {
Object cached = context.getCache().get(TRIGGERS_CACHE);
if (cached instanceof List<?> list) {
return (List<TriggerPoint>) list;
}
List<TriggerPoint> triggers = new ArrayList<>();
GenericEventDetector detector =
new GenericEventDetector(context, context.getConstantResolver(), context.getLibraryHints());
for (CompilationUnit cu : context.getCompilationUnits()) {
triggers.addAll(detector.detect(cu));
}
context.getCache().put(TRIGGERS_CACHE, triggers);
return triggers;
}
private static CallGraphEngine createCallGraphEngine(CodebaseContext context) {
SpringBeanRegistry registry = new SpringBeanRegistry();
SpringContextScanner scanner = new SpringContextScanner(registry);
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(scanner);
}
SpringDependencyResolver dependencyResolver = new SpringDependencyResolver(registry);
InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(dependencyResolver);
return new JdtCallGraphEngine(context, injectionAnalyzer);
}
private static String entryPointKey(EntryPoint entryPoint) {
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
return entryPoint.getName();
}
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
}
}

View File

@@ -9,7 +9,6 @@ import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Filters codebase-wide analysis artifacts to those relevant for a single state machine config.
@@ -54,10 +53,9 @@ public final class MachineScopeFilter {
if (entryPoints == null || machineName == null) {
return entryPoints == null ? List.of() : entryPoints;
}
String machineDomain = MachineDomainKeys.extractMachineDomainKey(machineName);
List<EntryPoint> filtered = new ArrayList<>();
for (EntryPoint entryPoint : entryPoints) {
if (isEntryPointForMachine(entryPoint, machineDomain, machineName, context)) {
if (isEntryPointForMachine(entryPoint, machineName, context)) {
filtered.add(entryPoint);
}
}
@@ -65,54 +63,25 @@ public final class MachineScopeFilter {
}
private static boolean isEntryPointForMachine(
EntryPoint entryPoint, String machineDomain, String machineName, CodebaseContext context) {
EntryPoint entryPoint, String machineName, CodebaseContext context) {
if (entryPoint == null) {
return false;
}
String path = entryPoint.getMetadata() != null ? entryPoint.getMetadata().get("path") : null;
if (path != null) {
String pathDomain = inferDomainFromPath(path);
if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain)
&& domainsMatch(pathDomain, machineDomain)) {
return true;
}
if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain)
&& !domainsMatch(pathDomain, machineDomain)) {
return false;
}
if (pathDomain == null && path.contains("{")) {
return true;
}
if (EntryPointScopeResolver.hasPathVariablePlaceholder(entryPoint)) {
EntryPointScopeResolver.Affinity affinity =
EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
return affinity != EntryPointScopeResolver.Affinity.AGAINST_MACHINE;
}
CallChain probe = CallChain.builder()
.entryPoint(entryPoint)
.methodChain(List.of(entryPoint.getClassName() + "." + entryPoint.getMethodName()))
.build();
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context);
}
private static boolean isKnownDomainScopedMachine(String machineDomain) {
return "ORDER".equals(machineDomain) || "DOCUMENT".equals(machineDomain) || "USER".equals(machineDomain);
}
private static String inferDomainFromPath(String path) {
String lower = path.toLowerCase(Locale.ROOT);
if (lower.contains("/orders") || lower.contains("order.")) {
return "ORDER";
EntryPointScopeResolver.Affinity affinity =
EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
if (affinity == EntryPointScopeResolver.Affinity.FOR_MACHINE) {
return true;
}
if (lower.contains("/documents") || lower.contains("document.")) {
return "DOCUMENT";
}
return null;
}
private static boolean domainsMatch(String pathDomain, String machineDomain) {
if (pathDomain == null || machineDomain == null) {
if (affinity == EntryPointScopeResolver.Affinity.AGAINST_MACHINE) {
return false;
}
return pathDomain.equalsIgnoreCase(machineDomain)
|| machineDomain.startsWith(pathDomain)
|| pathDomain.startsWith(machineDomain);
return false;
}
private static boolean isTriggerForMachine(TriggerPoint trigger, String machineName, CodebaseContext context) {

View File

@@ -5,9 +5,11 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers;
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.ExternalTriggerPolicy;
import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.State;
@@ -57,7 +59,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
tp, machineTypes, context, stateMachineTransitions, true);
tp = markRestEndpointExternal(tp, chain);
tp = markRestEndpointExternal(tp, chain, context);
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
updatedChains.add(chain);
@@ -69,6 +71,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
updatedChains.add(chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(null)
.linkResolution(LinkResolution.AMBIGUOUS_WIDEN)
.build());
continue;
}
@@ -150,24 +153,28 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
tp = tp.toBuilder().ambiguous(true).build();
}
LinkResolution linkResolution = resolveLinkResolution(tp, matched, ambiguousSource);
if (!matched.isEmpty()) {
CallChain newChain = chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(matched)
.linkResolution(linkResolution)
.build();
updatedChains.add(newChain);
} else {
updatedChains.add(chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(null)
.linkResolution(linkResolution)
.build());
}
}
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
result.getMetadata().getTriggers(), result.getName(), context);
List<EntryPoint> scopedEntryPoints = MachineScopeFilter.filterEntryPointsForMachine(
result.getMetadata().getEntryPoints(), result.getName(), context);
List<EntryPoint> scopedEntryPoints = EntryPointScopeResolver.scopeFromCallChains(
updatedChains, result.getMetadata().getEntryPoints());
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
.triggers(scopedTriggers)
@@ -226,7 +233,23 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
}
private boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint tp) {
private static LinkResolution resolveLinkResolution(
TriggerPoint trigger, List<MatchedTransition> matched, boolean ambiguousSource) {
if (trigger != null && trigger.isExternal()) {
return LinkResolution.UNRESOLVED_EXTERNAL;
}
if (ambiguousSource
|| (trigger != null && trigger.isAmbiguous())
|| shouldFailClosedOnAmbiguousCallGraphWiden(trigger)) {
return LinkResolution.AMBIGUOUS_WIDEN;
}
if (matched != null && !matched.isEmpty()) {
return LinkResolution.RESOLVED;
}
return LinkResolution.NO_MATCH;
}
private static boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint tp) {
if (tp == null || tp.isExternal() || !tp.isAmbiguous()) {
return false;
}
@@ -243,23 +266,18 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
&& !event.contains("valueOf");
}
private TriggerPoint markRestEndpointExternal(TriggerPoint trigger, CallChain chain) {
if (trigger == null || trigger.isExternal() || chain.getEntryPoint() == null) {
private TriggerPoint markRestEndpointExternal(TriggerPoint trigger, CallChain chain, CodebaseContext context) {
if (trigger == null || chain.getEntryPoint() == null) {
return trigger;
}
EntryPoint entryPoint = chain.getEntryPoint();
if (entryPoint.getType() != EntryPoint.Type.REST) {
String entryMethod = entryPoint.getClassName() + "." + entryPoint.getMethodName();
boolean external = ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, entryMethod, trigger.getEvent(), context);
if (external == trigger.isExternal()) {
return trigger;
}
String endpointName = entryPoint.getName();
if (endpointName == null || !endpointName.contains("{")) {
return trigger;
}
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
!= MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
return trigger;
}
return trigger.toBuilder().external(true).build();
return trigger.toBuilder().external(external).build();
}
}

View File

@@ -12,7 +12,7 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
@Override
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
// Precise FQN Type argument match
// Precise FQN Type argument match from JDT bindings at sendEvent site
if (chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn();
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn();
@@ -20,31 +20,37 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context);
String machineStateFqn = machineTypes[0];
String machineEventFqn = machineTypes[1];
boolean matched = false;
boolean mismatched = false;
if (triggerEventFqn != null && machineEventFqn != null) {
if (eraseGenerics(triggerEventFqn).equals(eraseGenerics(machineEventFqn))) {
return true;
}
if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
return false;
}
if (context != null && isSingleStateMachine(context)
matched = true;
} else if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
mismatched = true;
} else if (isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) {
return true;
matched = true;
}
}
if (triggerStateFqn != null && machineStateFqn != null) {
if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) {
return true; // Match!
}
if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
return false; // Mismatch!
}
if (context != null && isSingleStateMachine(context)
matched = true;
} else if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
mismatched = true;
} else if (isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) {
return true;
matched = true;
}
}
if (matched) {
return true;
}
if (mismatched) {
return false;
}
// Provable generic types but no machine match — fail closed (no package-name fallback).
return false;
}
}

View File

@@ -17,4 +17,6 @@ public class CallChain {
private final TriggerPoint triggerPoint;
private final String contextMachineId;
private final List<MatchedTransition> matchedTransitions;
/** How this chain's trigger was linked; derived from trigger flags and matchedTransitions. */
private final LinkResolution linkResolution;
}

View File

@@ -0,0 +1,16 @@
package click.kamil.springstatemachineexporter.analysis.model;
/**
* Export metadata describing how a call-chain trigger was linked to state-machine transitions.
* Derived from existing trigger flags only — no additional heuristics.
*/
public enum LinkResolution {
/** Concrete trigger linked to one or more matched transitions. */
RESOLVED,
/** REST or other external trigger that cannot be statically linked. */
UNRESOLVED_EXTERNAL,
/** Call-graph widening or source-state ambiguity prevented linking. */
AMBIGUOUS_WIDEN,
/** Static trigger with no matching transition link. */
NO_MATCH
}

View File

@@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.resolver;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
@@ -82,11 +83,64 @@ public final class BooleanConstraintEvaluator {
}
String expr = constraint;
for (Map.Entry<String, String> entry : bindings.entrySet()) {
if (!isConcreteBindingValue(entry.getKey(), entry.getValue())) {
continue;
}
expr = substituteVariableBindings(expr, entry.getKey(), entry.getValue());
expr = substituteEqualsLiteralBindings(expr, entry.getKey(), entry.getValue());
}
return evaluateBooleanExpression(expr);
}
private static boolean isConcreteBindingValue(String paramName, String boundValue) {
if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) {
return false;
}
if (boundValue.contains("(") && !boundValue.contains("valueOf")) {
return false;
}
if (boundValue.startsWith("\"") && boundValue.endsWith("\"")) {
return true;
}
if (boundValue.contains(".") && Character.isUpperCase(boundValue.charAt(boundValue.lastIndexOf('.') + 1))) {
return true;
}
return boundValue.equals(boundValue.toUpperCase(Locale.ROOT)) && boundValue.chars().allMatch(ch ->
Character.isUpperCase(ch) || ch == '_');
}
private static String substituteEqualsLiteralBindings(String expr, String varName, String boundValue) {
if (boundValue == null || boundValue.isBlank()) {
return expr;
}
String cleanValue = boundValue;
if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) {
cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
}
String literalPattern = Pattern.quote(cleanValue);
String receiverEquals = "(?i)[\"']?" + literalPattern + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*"
+ Pattern.quote(varName) + "\\s*\\)";
String argumentEquals = "(?i)" + Pattern.quote(varName) + "\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
+ literalPattern + "[\"']?\\s*\\)";
expr = expr.replaceAll(receiverEquals, "true");
expr = expr.replaceAll(argumentEquals, "true");
Set<String> allLiterals = extractStringLiterals(expr);
for (String literal : allLiterals) {
if (literal.equalsIgnoreCase(cleanValue)) {
continue;
}
String otherLiteral = Pattern.quote(literal);
String otherReceiver = "(?i)[\"']?" + otherLiteral + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*"
+ Pattern.quote(varName) + "\\s*\\)";
String otherArgument = "(?i)" + Pattern.quote(varName) + "\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
+ otherLiteral + "[\"']?\\s*\\)";
expr = expr.replaceAll(otherReceiver, "false");
expr = expr.replaceAll(otherArgument, "false");
}
return expr;
}
private static Set<String> extractEqualityVariables(String constraint) {
Set<String> vars = new HashSet<>();
Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*==");

View File

@@ -132,17 +132,25 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<List<String>> allPaths = new ArrayList<>();
for (String sMethod : startMethods) {
allPaths.addAll(pathFinder.findAllPaths(
sMethod, targetMethod, callGraph, new HashSet<>(),
pathBindingEvaluator, initialBindings));
if (initialBindings.isEmpty()) {
allPaths.addAll(pathFinder.findAllPaths(
sMethod, targetMethod, callGraph, new HashSet<>()));
} else {
allPaths.addAll(pathFinder.findAllPaths(
sMethod, targetMethod, callGraph, new HashSet<>(),
pathBindingEvaluator, initialBindings));
}
}
Set<List<String>> uniquePaths = new LinkedHashSet<>(allPaths);
for (List<String> path : uniquePaths) {
if (!pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder, initialBindings)) {
boolean compatible = initialBindings.isEmpty()
? pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder)
: pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder, initialBindings);
if (!compatible) {
continue;
}
foundAny = true;
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph);
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph, initialBindings);
if (resolvedTp != null) {
String contextMachineId = pathFinder.extractContextMachineId(path, callGraph);
chains.add(CallChain.builder()
@@ -164,6 +172,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
protected TriggerPoint resolveTriggerPointParameters(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> callGraph) {
return resolveTriggerPointParameters(tp, path, callGraph, Map.of());
}
protected TriggerPoint resolveTriggerPointParameters(
TriggerPoint tp,
List<String> path,
Map<String, List<CallEdge>> callGraph,
Map<String, String> initialBindings) {
if (path.size() < 2) {
boolean isExternal = isExternalParameter(tp.getClassName() + "." + tp.getMethodName(), tp.getEvent());
return tp.toBuilder().external(isExternal).constraint(tp.getConstraint()).build();
@@ -175,26 +191,42 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
String[] finalParamNameRef = { event };
TriggerPoint resolved = resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef);
TriggerPoint resolved = resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef, initialBindings);
if (resolved == null) return null;
String entryMethod = path.isEmpty() ? (tp.getClassName() + "." + tp.getMethodName()) : path.get(0);
boolean isExternal = isExternalTrigger(entryMethod, finalParamNameRef[0], path, callGraph);
boolean isExternal = isExternalTrigger(entryMethod, finalParamNameRef[0], path, callGraph, initialBindings);
String pathConstraint = extractPathConstraints(path, callGraph);
String pathConstraint = extractPathConstraints(path, callGraph, initialBindings);
String triggerConstraint = resolved.getConstraint();
String finalConstraint = null;
if (pathConstraint != null && triggerConstraint != null) {
finalConstraint = pathConstraint + " && " + triggerConstraint;
} else if (pathConstraint != null) {
finalConstraint = pathConstraint;
} else {
finalConstraint = triggerConstraint;
}
String finalConstraint = mergeConstraints(pathConstraint, triggerConstraint);
return resolved.toBuilder().external(isExternal).constraint(finalConstraint).build();
}
protected TriggerPoint resolveTriggerPointParametersOriginal(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> callGraph, String[] finalParamNameRef) {
private static String mergeConstraints(String pathConstraint, String triggerConstraint) {
if (pathConstraint != null && triggerConstraint != null) {
return pathConstraint + " && " + triggerConstraint;
}
if (pathConstraint != null) {
return pathConstraint;
}
return triggerConstraint;
}
protected TriggerPoint resolveTriggerPointParametersOriginal(
TriggerPoint tp,
List<String> path,
Map<String, List<CallEdge>> callGraph,
String[] finalParamNameRef) {
return resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef, Map.of());
}
protected TriggerPoint resolveTriggerPointParametersOriginal(
TriggerPoint tp,
List<String> path,
Map<String, List<CallEdge>> callGraph,
String[] finalParamNameRef,
Map<String, String> initialBindings) {
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath(path);
final boolean debug = log.isDebugEnabled();
try {
@@ -243,7 +275,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
if (paramIndex < 0) {
Map<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i);
Map<String, String> parameterValues = mergeBindings(
initialBindings,
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, target);
@@ -264,7 +298,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (edges != null) {
for (CallEdge edge : edges) {
if (edge.getTargetMethod().equals(target) || pathFinder.isHeuristicMatch(edge.getTargetMethod(), target)) {
String expectedType = typeResolver.getParameterType(target, paramIndex);
String expectedType = typeResolver.getParameterTypeAtCallSite(caller, target, paramIndex);
if (expectedType == null) {
expectedType = typeResolver.getParameterType(target, paramIndex);
}
if (expectedType != null && paramIndex < edge.getArguments().size()) {
String argValue = edge.getArguments().get(paramIndex);
String actualType = null;
@@ -394,7 +431,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
}
String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName);
String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName, initialBindings);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix, entryMethod);
tracedVar = extractedFinalTraced[0];
@@ -2098,10 +2135,17 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
for (Object modifier : svd.modifiers()) {
if (modifier instanceof Annotation annotation) {
String typeName = annotation.getTypeName().getFullyQualifiedName();
if (typeName.endsWith("PathVariable") || typeName.endsWith("RequestBody") || typeName.endsWith("RequestParam")
if (typeName.endsWith("PathVariable") || typeName.endsWith("RequestParam")
|| typeName.endsWith("PathParam") || typeName.endsWith("QueryParam")) {
return true;
}
if (typeName.endsWith("RequestBody")) {
ITypeBinding typeBinding = svd.getType().resolveBinding();
if (typeBinding != null && typeBinding.isEnum()) {
return false;
}
return true;
}
}
}
}
@@ -2112,8 +2156,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return false;
}
private String extractPathConstraints(List<String> path, Map<String, List<CallEdge>> callGraph) {
private String extractPathConstraints(
List<String> path, Map<String, List<CallEdge>> callGraph, Map<String, String> initialBindings) {
List<String> constraints = new ArrayList<>();
String bindingConstraint = constraintFromBindings(initialBindings);
if (bindingConstraint != null) {
constraints.add(bindingConstraint);
}
for (int i = 0; i < path.size() - 1; i++) {
String caller = path.get(i);
String target = path.get(i + 1);
@@ -2133,6 +2182,33 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return String.join(" && ", constraints);
}
private static String constraintFromBindings(Map<String, String> bindings) {
if (bindings == null || bindings.isEmpty()) {
return null;
}
List<String> parts = new ArrayList<>();
for (Map.Entry<String, String> entry : bindings.entrySet()) {
String value = entry.getValue();
if (value == null || value.isBlank() || value.contains("(")) {
continue;
}
parts.add("\"" + value + "\".equalsIgnoreCase(" + entry.getKey() + ")");
}
return parts.isEmpty() ? null : String.join(" && ", parts);
}
private static Map<String, String> mergeBindings(
Map<String, String> initialBindings, Map<String, String> tracedBindings) {
Map<String, String> merged = new HashMap<>();
if (tracedBindings != null) {
merged.putAll(tracedBindings);
}
if (initialBindings != null) {
merged.putAll(initialBindings);
}
return merged;
}
protected String resolveConstraint(MethodInvocation node, String calledMethod, String baseConstraint) {
String resolvedConstraint = baseConstraint;
if (node.getExpression() instanceof MethodInvocation miReceiver) {
@@ -2295,7 +2371,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
protected abstract String resolveCalledMethod(MethodInvocation node);
@SuppressWarnings("unchecked")
protected Map<String, List<CallEdge>> buildCallGraph() {
public Map<String, List<CallEdge>> buildCallGraph() {
Map<String, List<CallEdge>> cached =
(Map<String, List<CallEdge>>) context.getCache().get(callGraphCacheKey());
if (cached != null) {
@@ -2838,11 +2914,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String entryMethod,
String finalParamName,
List<String> path,
Map<String, List<CallEdge>> callGraph) {
Map<String, List<CallEdge>> callGraph,
Map<String, String> initialBindings) {
if (isExternalParameter(entryMethod, finalParamName)) {
return true;
}
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
bindings = mergeBindings(initialBindings, bindings);
for (String boundName : bindings.keySet()) {
if (isExternalParameter(entryMethod, boundName)) {
return true;

View File

@@ -27,6 +27,9 @@ public final class EntryPointBindingExpander {
if (!isPathOrQueryVariable(parameter)) {
continue;
}
if (isPlaceholderResolvedInPath(entryPoint, parameter.getName())) {
continue;
}
List<String> cases = resolveStringCasesForParameter(
entryPoint.getClassName() + "." + entryPoint.getMethodName(),
parameter.getName(),
@@ -73,6 +76,22 @@ public final class EntryPointBindingExpander {
.build();
}
private static boolean isPlaceholderResolvedInPath(EntryPoint entryPoint, String paramName) {
String placeholder = "{" + paramName + "}";
String name = entryPoint.getName();
if (name != null && !name.contains(placeholder)) {
return true;
}
Map<String, String> metadata = entryPoint.getMetadata();
if (metadata != null) {
String path = metadata.get("path");
if (path != null && !path.contains(placeholder)) {
return true;
}
}
return false;
}
private static boolean isPathOrQueryVariable(EntryPoint.Parameter parameter) {
if (parameter.getAnnotations() == null) {
return false;
@@ -135,6 +154,12 @@ public final class EntryPointBindingExpander {
collectCasesFromSwitch(node.getExpression(), node.statements(), paramName, cases);
return super.visit(node);
}
@Override
public boolean visit(IfStatement node) {
collectCasesFromIfChain(node, paramName, cases);
return super.visit(node);
}
});
return List.copyOf(cases);
}
@@ -158,6 +183,62 @@ public final class EntryPointBindingExpander {
}
}
private static void collectCasesFromIfChain(IfStatement ifStatement, String paramName, Set<String> cases) {
IfStatement current = ifStatement;
while (current != null) {
String literal = extractEqualsLiteralForParameter(current.getExpression(), paramName);
if (literal != null) {
cases.add(literal);
}
Statement elseStatement = current.getElseStatement();
if (elseStatement instanceof IfStatement nestedIf) {
current = nestedIf;
} else {
if (elseStatement != null) {
String elseLiteral = extractEqualsLiteralForParameterFromStatement(elseStatement, paramName);
if (elseLiteral != null) {
cases.add(elseLiteral);
}
}
break;
}
}
}
private static String extractEqualsLiteralForParameterFromStatement(Statement statement, String paramName) {
if (statement instanceof IfStatement ifStatement) {
return extractEqualsLiteralForParameter(ifStatement.getExpression(), paramName);
}
return null;
}
private static String extractEqualsLiteralForParameter(Expression expression, String paramName) {
if (!(expression instanceof MethodInvocation invocation)) {
return null;
}
String methodName = invocation.getName().getIdentifier();
if (!"equals".equals(methodName) && !"equalsIgnoreCase".equals(methodName)) {
return null;
}
if (invocation.arguments().size() != 1) {
return null;
}
Expression argument = (Expression) invocation.arguments().get(0);
Expression receiver = invocation.getExpression();
if (receiver instanceof StringLiteral stringLiteral
&& argument instanceof SimpleName simpleName
&& paramName.equals(simpleName.getIdentifier())) {
return stringLiteral.getLiteralValue();
}
if (receiver instanceof SimpleName simpleName
&& paramName.equals(simpleName.getIdentifier())
&& argument instanceof StringLiteral paramLiteral) {
return paramLiteral.getLiteralValue();
}
return null;
}
private static boolean hasParameter(MethodDeclaration method, String paramName) {
for (Object paramObj : method.parameters()) {
if (paramObj instanceof SingleVariableDeclaration param

View File

@@ -0,0 +1,270 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
/**
* Single source-derived policy for marking REST triggers as external vs internal.
*/
public final class ExternalTriggerPolicy {
private ExternalTriggerPolicy() {
}
public static boolean isExternalFromSource(
EntryPoint entryPoint,
TriggerPoint trigger,
String entryMethodFqn,
String resolvedEventParamName,
CodebaseContext context) {
if (trigger == null) {
return false;
}
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) {
return false;
}
if (trigger.getPolymorphicEvents() != null
&& !trigger.getPolymorphicEvents().isEmpty()
&& MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) {
return false;
}
if (entryPoint != null && entryPoint.getType() == EntryPoint.Type.REST) {
String paramName = resolveRestEventParameterName(entryPoint, resolvedEventParamName);
if (paramName != null) {
RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context);
if (binding != null) {
if (binding.pathOrQueryVariable()) {
return true;
}
if (binding.requestBodyEnum()) {
return false;
}
}
if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) {
RestParamBinding requestBodyField = findRequestBodyEnumFieldBinding(
entryPoint.getClassName() + "." + entryPoint.getMethodName(), paramName, context);
if (requestBodyField != null && requestBodyField.requestBodyEnum()) {
return false;
}
}
}
if (entryPoint.getName() != null && entryPoint.getName().contains("{")
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
return true;
}
}
if (entryMethodFqn != null && resolvedEventParamName != null) {
RestParamBinding binding = findMethodParameterBinding(entryMethodFqn, resolvedEventParamName, context);
if (binding != null) {
if (binding.pathOrQueryVariable()) {
return true;
}
if (binding.requestBodyEnum()) {
return false;
}
}
RestParamBinding requestBodyField = findRequestBodyEnumFieldBinding(
entryMethodFqn, resolvedEventParamName, context);
if (requestBodyField != null && requestBodyField.requestBodyEnum()) {
return false;
}
}
return trigger.isExternal();
}
private static String resolveRestEventParameterName(EntryPoint entryPoint, String resolvedEventParamName) {
if (resolvedEventParamName != null && !resolvedEventParamName.isBlank()) {
return resolvedEventParamName;
}
if (entryPoint.getParameters() == null) {
return null;
}
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
if (parameter.getAnnotations() == null) {
continue;
}
for (String annotation : parameter.getAnnotations()) {
if ("PathVariable".equals(annotation) || "RequestParam".equals(annotation)) {
return parameter.getName();
}
}
}
return null;
}
private static RestParamBinding findRestParameterBinding(
EntryPoint entryPoint, String paramName, CodebaseContext context) {
if (entryPoint.getParameters() == null || paramName == null) {
return null;
}
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
if (!paramName.equals(parameter.getName())) {
continue;
}
return RestParamBinding.fromEntryPointParameter(parameter);
}
if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) {
return findMethodParameterBinding(
entryPoint.getClassName() + "." + entryPoint.getMethodName(), paramName, context);
}
return null;
}
private static RestParamBinding findMethodParameterBinding(
String methodFqn, String paramName, CodebaseContext context) {
if (methodFqn == null || !methodFqn.contains(".") || paramName == null || context == null) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (method == null) {
return null;
}
for (Object paramObj : method.parameters()) {
if (!(paramObj instanceof SingleVariableDeclaration param)) {
continue;
}
if (!paramName.equals(param.getName().getIdentifier())) {
continue;
}
return RestParamBinding.fromAstParameter(param);
}
return null;
}
private static RestParamBinding findRequestBodyEnumFieldBinding(
String methodFqn, String fieldName, CodebaseContext context) {
if (methodFqn == null || !methodFqn.contains(".") || fieldName == null || context == null) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (method == null) {
return null;
}
for (Object paramObj : method.parameters()) {
if (!(paramObj instanceof SingleVariableDeclaration param)) {
continue;
}
if (!hasParameterAstAnnotation(param, "RequestBody") || param.getType() == null) {
continue;
}
ITypeBinding bodyBinding = param.getType().resolveBinding();
if (bodyBinding == null || bodyBinding.isEnum()) {
continue;
}
if (isEnumRecordComponent(bodyBinding, fieldName)) {
return new RestParamBinding(false, false, true, true);
}
if (isEnumDtoField(bodyBinding, fieldName, context)) {
return new RestParamBinding(false, false, true, true);
}
}
return null;
}
private static boolean isEnumRecordComponent(ITypeBinding bodyBinding, String fieldName) {
if (bodyBinding == null || fieldName == null) {
return false;
}
for (IVariableBinding component : bodyBinding.getDeclaredFields()) {
if (!fieldName.equals(component.getName())) {
continue;
}
ITypeBinding fieldType = component.getType();
return fieldType != null && fieldType.isEnum();
}
return false;
}
private static boolean isEnumDtoField(ITypeBinding bodyBinding, String fieldName, CodebaseContext context) {
if (bodyBinding == null || fieldName == null || context == null) {
return false;
}
String bodyFqn = bodyBinding.getQualifiedName();
if (bodyFqn == null) {
return false;
}
AbstractTypeDeclaration bodyType = context.getAbstractTypeDeclaration(bodyFqn);
if (!(bodyType instanceof TypeDeclaration td)) {
return false;
}
for (FieldDeclaration field : td.getFields()) {
for (Object fragObj : field.fragments()) {
if (!(fragObj instanceof VariableDeclarationFragment fragment)) {
continue;
}
if (!fieldName.equals(fragment.getName().getIdentifier())) {
continue;
}
ITypeBinding fieldBinding = field.getType().resolveBinding();
return fieldBinding != null && fieldBinding.isEnum();
}
}
return false;
}
private static boolean hasParameterAstAnnotation(SingleVariableDeclaration param, String simpleName) {
for (Object modifier : param.modifiers()) {
if (modifier instanceof Annotation annotation
&& annotation.getTypeName().getFullyQualifiedName().endsWith(simpleName)) {
return true;
}
}
return false;
}
private record RestParamBinding(boolean pathVariable, boolean requestParam, boolean requestBody, boolean enumType) {
boolean pathOrQueryVariable() {
return pathVariable || requestParam;
}
boolean requestBodyEnum() {
return requestBody && enumType;
}
static RestParamBinding fromEntryPointParameter(EntryPoint.Parameter parameter) {
boolean pathVariable = hasAnnotation(parameter.getAnnotations(), "PathVariable");
boolean requestParam = hasAnnotation(parameter.getAnnotations(), "RequestParam");
boolean requestBody = hasAnnotation(parameter.getAnnotations(), "RequestBody");
boolean enumType = isEnumTypeName(parameter.getType());
return new RestParamBinding(pathVariable, requestParam, requestBody, enumType);
}
static RestParamBinding fromAstParameter(SingleVariableDeclaration param) {
boolean pathVariable = hasParameterAstAnnotation(param, "PathVariable");
boolean requestParam = hasParameterAstAnnotation(param, "RequestParam");
boolean requestBody = hasParameterAstAnnotation(param, "RequestBody");
boolean enumType = param.getType() != null && param.getType().resolveBinding() != null
&& param.getType().resolveBinding().isEnum();
return new RestParamBinding(pathVariable, requestParam, requestBody, enumType);
}
private static boolean hasAnnotation(java.util.List<String> annotations, String name) {
return annotations != null && annotations.stream().anyMatch(name::equals);
}
private static boolean isEnumTypeName(String typeName) {
if (typeName == null || typeName.isBlank()) {
return false;
}
int lastDot = typeName.lastIndexOf('.');
String simple = lastDot >= 0 ? typeName.substring(lastDot + 1) : typeName;
return Character.isUpperCase(simple.charAt(0)) && !simple.endsWith("String");
}
}
}

View File

@@ -41,6 +41,11 @@ public class GenericEventDetector {
private static final Set<String> TRIGGER_METHOD_NAMES = Set.of(
"sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger");
/** Built-in source patterns for Spring State Machine and messaging APIs (no hints.json required). */
private static final List<LibraryHint> BUILTIN_SOURCE_PATTERNS = List.of(
LibraryHint.builder().methodFqn("org.springframework.messaging.support.MessageBuilder.build").eventArgumentIndex(0).build(),
LibraryHint.builder().methodFqn("org.springframework.messaging.support.GenericMessage.getPayload").eventArgumentMethod("getPayload").build());
public List<TriggerPoint> detect(CompilationUnit cu) {
List<TriggerPoint> triggers = new ArrayList<>();
String fileName = "unknown";
@@ -68,6 +73,7 @@ public class GenericEventDetector {
}
}
processBuiltInPatterns(node, cu, triggers);
processHints(node, cu, triggers);
return super.visit(node);
@@ -88,6 +94,9 @@ public class GenericEventDetector {
if (type == null) return;
String sourceState = extractSourceState(node);
if (sourceState == null) {
sourceState = extractSourceStateFromArguments(node);
}
String[] smTypes = resolveStateMachineTypeArgumentsForExpression(emr.getExpression(), node);
boolean external = false;
@@ -176,40 +185,78 @@ public class GenericEventDetector {
}
}
private void processHints(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
if (hints == null || hints.isEmpty()) return;
private void processBuiltInPatterns(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
String methodName = node.getName().getIdentifier();
if (TRIGGER_METHOD_NAMES.contains(methodName)) {
return;
}
String calledMethod = resolveCalledMethodName(node);
if (calledMethod == null) return;
for (LibraryHint hint : hints) {
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) {
String eventToUse = hint.getEvent();
if (eventToUse == null && hint.getEventArgumentIndex() != null) {
if (node.arguments().size() > hint.getEventArgumentIndex()) {
Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex());
eventToUse = argExpr.toString();
if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) {
eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()";
}
}
}
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
if (log.isDebugEnabled()) {
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
}
triggers.add(trigger);
if (calledMethod == null) {
return;
}
for (LibraryHint pattern : BUILTIN_SOURCE_PATTERNS) {
if (!matchesHint(calledMethod, pattern.getMethodFqn())) {
continue;
}
String eventToUse = resolveHintEvent(node, pattern);
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
if (log.isDebugEnabled()) {
log.debug("Built trigger from built-in source pattern {}: {}", pattern.getMethodFqn(), trigger.getEvent());
}
triggers.add(trigger);
}
}
}
}
private boolean isHintMatch(String called, String hintFqn) {
return hintFqn.endsWith("." + called);
private void processHints(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
if (hints == null || hints.isEmpty()) {
return;
}
String calledMethod = resolveCalledMethodName(node);
if (calledMethod == null) {
return;
}
for (LibraryHint hint : hints) {
if (!matchesHint(calledMethod, hint.getMethodFqn())) {
continue;
}
if (log.isDebugEnabled()) {
log.debug("Applying optional hints.json override for {}", hint.getMethodFqn());
}
String eventToUse = resolveHintEvent(node, hint);
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
if (log.isDebugEnabled()) {
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
}
triggers.add(trigger);
}
}
}
}
private static String resolveHintEvent(MethodInvocation node, LibraryHint hint) {
String eventToUse = hint.getEvent();
if (eventToUse == null && hint.getEventArgumentIndex() != null) {
if (node.arguments().size() > hint.getEventArgumentIndex()) {
Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex());
eventToUse = argExpr.toString();
if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) {
eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()";
}
}
}
return eventToUse;
}
private static boolean matchesHint(String calledMethod, String hintFqn) {
return calledMethod.equals(hintFqn) || hintFqn.endsWith("." + calledMethod);
}
private String resolveCalledMethodName(MethodInvocation node) {
@@ -261,6 +308,9 @@ public class GenericEventDetector {
if (type == null) return Collections.emptyList();
String sourceState = extractSourceState(node);
if (sourceState == null && node instanceof MethodInvocation mi) {
sourceState = extractSourceStateFromArguments(mi);
}
String[] smTypes = resolveStateMachineTypeArguments(node);
boolean external = false;
@@ -340,6 +390,40 @@ public class GenericEventDetector {
return null;
}
/**
* Infers source state from a literal second argument to sendEvent(event, sourceState) when provable from AST.
*/
private String extractSourceStateFromArguments(MethodInvocation node) {
if (node.arguments().size() < 2) {
return null;
}
Expression stateExpr = (Expression) node.arguments().get(1);
return resolveProvableStateLiteral(stateExpr);
}
private String resolveProvableStateLiteral(Expression expr) {
if (expr == null) {
return null;
}
Expression peeled = expr;
while (peeled instanceof ParenthesizedExpression pe) {
peeled = pe.getExpression();
}
while (peeled instanceof CastExpression ce) {
peeled = ce.getExpression();
}
if (peeled instanceof QualifiedName || peeled instanceof FieldAccess || peeled instanceof StringLiteral) {
String resolved = constantResolver.resolve(peeled, context);
if (resolved != null && !resolved.isBlank()) {
return getSimpleNameString(peeled);
}
if (peeled instanceof StringLiteral sl) {
return sl.getLiteralValue();
}
}
return null;
}
private String extractStateFromSiblings(ASTNode currentNode, List<?> statements) {
int index = statements.indexOf(currentNode);
if (index <= 0) return null; // No previous siblings

View File

@@ -170,6 +170,43 @@ public class TypeResolver {
return null;
}
/**
* Resolves parameter type from the actual call site in {@code callerFqn}, so overloaded
* targets (e.g. {@code send(OrderEvent)} vs {@code send(DocumentEvent)}) use the invoked overload.
*/
public String getParameterTypeAtCallSite(String callerFqn, String calleeFqn, int paramIndex) {
if (callerFqn == null || calleeFqn == null || paramIndex < 0
|| !callerFqn.contains(".") || !calleeFqn.contains(".")) {
return null;
}
String callerClass = callerFqn.substring(0, callerFqn.lastIndexOf('.'));
String callerMethod = callerFqn.substring(callerFqn.lastIndexOf('.') + 1);
String calleeMethod = calleeFqn.substring(calleeFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(callerClass);
if (td == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(td, callerMethod, true);
if (md == null || md.getBody() == null) {
return null;
}
final String[] result = new String[1];
md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(org.eclipse.jdt.core.dom.MethodInvocation node) {
if (!calleeMethod.equals(node.getName().getIdentifier())) {
return super.visit(node);
}
org.eclipse.jdt.core.dom.IMethodBinding binding = node.resolveMethodBinding();
if (binding != null && binding.getParameterTypes().length > paramIndex) {
result[0] = binding.getParameterTypes()[paramIndex].getErasure().getQualifiedName();
}
return super.visit(node);
}
});
return result[0];
}
public boolean isTypeCompatible(String actualType, String expectedType) {
if (actualType == null || expectedType == null) return true;
if (expectedType.equals("Object") || expectedType.equals("java.lang.Object")) return true;

View File

@@ -1,6 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
@@ -280,6 +281,21 @@ public class VariableTracer {
}
public String traceLocalVariable(String methodFqn, String varName) {
return traceLocalVariable(methodFqn, varName, null);
}
public String traceLocalVariable(String methodFqn, String varName, Map<String, String> branchBindings) {
String result = traceLocalVariableInternal(methodFqn, varName, branchBindings);
if (result != null && branchBindings != null && !branchBindings.isEmpty()) {
String switchEvaluated = evaluateSwitchAssignment(methodFqn, varName, branchBindings);
if (switchEvaluated != null) {
return switchEvaluated;
}
}
return result;
}
private String traceLocalVariableInternal(String methodFqn, String varName, Map<String, String> branchBindings) {
if (methodFqn == null || !methodFqn.contains(".")) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
@@ -287,27 +303,33 @@ public class VariableTracer {
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
List<Expression> initializers = new ArrayList<>();
List<BranchAssignment> assignments = new ArrayList<>();
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializers.add(peelExpression(node.getInitializer()));
assignments.add(new BranchAssignment(
peelExpression(node.getInitializer()),
click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node)));
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
initializers.add(peelExpression(node.getRightHandSide()));
assignments.add(new BranchAssignment(
peelExpression(node.getRightHandSide()),
click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node)));
}
return super.visit(node);
}
});
if (!initializers.isEmpty()) {
List<BranchAssignment> filtered = filterAssignmentsByBindings(assignments, branchBindings);
if (!filtered.isEmpty()) {
List<String> stringified = new ArrayList<>();
for (Expression expr : initializers) {
for (BranchAssignment assignment : filtered) {
Expression expr = assignment.expression();
Expression traced = traceVariable(expr);
if (traced instanceof MethodInvocation mi) {
Expression innerMost = unwrapMethodInvocation(mi, 0, methodFqn);
@@ -321,7 +343,7 @@ public class VariableTracer {
if (stringified.size() == 1) {
return stringified.get(0).replaceAll("->\\s*yield\\s+", "-> ");
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < stringified.size() - 1; i++) {
sb.append("true ? ").append(stringified.get(i)).append(" : ");
@@ -380,62 +402,79 @@ public class VariableTracer {
return null;
}
public String traceLocalVariable(String methodFqn, String varName, Map<String, String> parameterValues) {
String result = traceLocalVariable(methodFqn, varName);
if (result != null && parameterValues != null && !parameterValues.isEmpty()) {
TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
if (td != null) {
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
final ASTNode[] switchNode = new ASTNode[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
Expression init = node.getInitializer();
if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION) {
switchNode[0] = init;
return false;
}
if (init.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = init;
return false;
}
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
Expression rhs = node.getRightHandSide();
if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION) {
switchNode[0] = rhs;
return false;
}
if (rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = rhs;
return false;
}
}
return super.visit(node);
}
});
if (switchNode[0] != null) {
if (switchNode[0] instanceof SwitchExpression se) {
String evaluated = constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
if (evaluated != null) return evaluated;
} else if (switchNode[0] instanceof SwitchStatement ss) {
String evaluated = constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
if (evaluated != null) return evaluated;
}
private String evaluateSwitchAssignment(String methodFqn, String varName, Map<String, String> parameterValues) {
TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
if (td == null) {
return null;
}
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
final ASTNode[] switchNode = new ASTNode[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
Expression init = node.getInitializer();
if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION || init.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = init;
return false;
}
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
Expression rhs = node.getRightHandSide();
if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION || rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = rhs;
return false;
}
}
return super.visit(node);
}
});
if (switchNode[0] instanceof SwitchExpression se) {
return constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
}
if (switchNode[0] instanceof SwitchStatement ss) {
return constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
}
return null;
}
private static List<BranchAssignment> filterAssignmentsByBindings(
List<BranchAssignment> assignments, Map<String, String> branchBindings) {
if (assignments.isEmpty()) {
return assignments;
}
if (branchBindings == null || branchBindings.isEmpty()) {
return assignments;
}
List<BranchAssignment> compatible = new ArrayList<>();
List<BranchAssignment> unconstrained = new ArrayList<>();
for (BranchAssignment assignment : assignments) {
String constraint = assignment.constraint();
if (constraint == null || constraint.isBlank()) {
unconstrained.add(assignment);
continue;
}
if (BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, branchBindings)) {
compatible.add(assignment);
}
}
return result;
if (!compatible.isEmpty()) {
return compatible;
}
return unconstrained;
}
private record BranchAssignment(Expression expression, String constraint) {}
public Map<String, String> buildParameterValuesMap(String caller, String target, Map<String, List<CallEdge>> callGraph, List<String> path, int pathIndex) {
Map<String, String> paramValues = new HashMap<>();
List<CallEdge> edges = callGraph.get(caller);

View File

@@ -419,19 +419,31 @@ public final class AnalysisCanonicalFormValidator {
if (LifecycleTriggerMarkers.isLifecycle(trigger.getEvent())) {
return;
}
if (trigger.isAmbiguous() && !trigger.isExternal()) {
return;
}
if (!MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) {
return;
}
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
!= MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
if (isIntentionalAmbiguousFailClosed(trigger)) {
return;
}
if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) {
return;
}
MachineEnumCanonicalizer.TriggerEventKind eventKind =
MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent());
if (eventKind == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) {
if (eventMatchesAnyTransition(trigger.getEvent(), transitions, eventTypeFqn)) {
violations.add(new Violation(
chainPrefix + ".matchedTransitions",
"empty",
"non-empty when trigger has concrete machine enum event matching transitions"));
}
return;
}
if (!MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) {
return;
}
if (eventKind != MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
return;
}
if (!polymorphicEventsMatchAnyTransition(trigger.getPolymorphicEvents(), transitions, eventTypeFqn)) {
return;
}
@@ -441,6 +453,47 @@ public final class AnalysisCanonicalFormValidator {
"non-empty when dynamic trigger has concrete polymorphicEvents matching transitions"));
}
private static boolean isIntentionalAmbiguousFailClosed(TriggerPoint trigger) {
if (trigger == null) {
return false;
}
if (trigger.isAmbiguous() && !trigger.isExternal()) {
return true;
}
String event = trigger.getEvent();
if (event != null && event.startsWith("ENUM_SET:")) {
return true;
}
if (trigger.isExternal()
&& trigger.isAmbiguous()
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(event)
&& (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty())) {
return true;
}
return false;
}
private static boolean eventMatchesAnyTransition(
String triggerEvent,
List<Transition> transitions,
String eventTypeFqn) {
if (transitions == null || transitions.isEmpty() || triggerEvent == null) {
return false;
}
for (Transition transition : transitions) {
if (transition.getEvent() == null) {
continue;
}
String smEvent = transition.getEvent().fullIdentifier() != null
? transition.getEvent().fullIdentifier()
: transition.getEvent().rawName();
if (eventsMatch(triggerEvent, smEvent, eventTypeFqn)) {
return true;
}
}
return false;
}
private static boolean polymorphicEventsMatchAnyTransition(
List<String> polymorphicEvents,
List<Transition> transitions,

View File

@@ -139,9 +139,9 @@ public class CodebaseContext {
public void loadLibraryHints(Path hintsFile) throws IOException {
if (Files.exists(hintsFile)) {
System.out.println("Loading hints from " + hintsFile.toAbsolutePath());
log.debug("Loading optional hints.json override from {}", hintsFile.toAbsolutePath());
this.libraryHints = objectMapper.readValue(hintsFile.toFile(), new TypeReference<List<LibraryHint>>() {});
System.out.println("Loaded " + libraryHints.size() + " library hints");
log.debug("Loaded {} optional library hints from hints.json", libraryHints.size());
}
}

View File

@@ -125,7 +125,7 @@ public class ExportService {
}
if (Files.exists(hintsFile)) {
log.info("Loading hints from {}", hintsFile.toAbsolutePath());
log.debug("Loading optional hints.json override from {}", hintsFile.toAbsolutePath());
context.loadLibraryHints(hintsFile);
}