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:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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*==");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user