10 Commits

Author SHA1 Message Date
342efaef8e agents.md from glm 8b 2026-07-15 19:36:41 +02:00
9a3bd3394f cursor out of token 2026-07-15 16:29:41 +02:00
d6754b7464 Unify getter and path-binding resolution through anchored dataflow.
Route chained getters, path bindings, and setter-before-getter reads through JdtDataFlowModel instead of parallel mini-interpreters, with regression tests covering interface hops, anonymous classes, and anchored vs detached AST resolution.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 08:40:02 +02:00
0558fd2bb7 Add layered regression tests for functional-interface dataflow linking.
Lock Supplier.get(), Runnable.run(), and call-site enum resolution to the dataflow pipeline so a parallel resolver cannot creep back in unnoticed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 07:41:09 +02:00
d5a6adafcc Remove CallSiteArgumentResolver; route functional args through dataflow.
Delete the parallel Supplier/Runnable bridging layer and resolve functional call-site arguments via VariableTracer, PathBindingEvaluator, and JdtDataFlowModel instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 07:37:21 +02:00
9a54616e5f Fix Runnable lambda and rich-event predicate linking gaps.
Trace execute→Runnable.run arguments via source AST and JdtDataFlowModel void side-effects, and narrow polymorphic events using domain isCorrect() predicates on rich event types.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 07:28:53 +02:00
dbc22bf1af Fix zero transition links when config event types are unresolved.
Derive machine enum types from transition events for poly narrowing, scan sendEvent literals in caller methods, and keep generic REST templates external without blocking dedicated endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 20:22:32 +02:00
758d433726 Narrow wide polymorphic events per call chain before linking.
Use multi-source endpoint evidence so shared dispatchers resolve to one event per endpoint without over-linking or fail-closed AMBIGUOUS_WIDEN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 20:08:09 +02:00
1957266a98 Require endpoint-narrow polymorphic evidence before linking transitions.
Fail closed on broad enum widens so dispatchers link one event per endpoint instead of every transition on the machine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:55:18 +02:00
33ccc4a6c5 Link transitions when the same event maps to multiple sources in one state enum.
Stop treating multi-source same-event triggers as ambiguous widen failures; trust bare polymorphic constant names when the machine event type is known.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:44:38 +02:00
48 changed files with 5198 additions and 681 deletions

63
AGENTS.md Normal file
View File

@@ -0,0 +1,63 @@
# AGENTS.md
## Project Overview
Spring State Machine Explorer is a static analysis tool that extracts and visualizes Spring State Machines from source code. It does **not** run Maven/Gradle on target projects.
## Build System
- **Java 21** required (toolchain configured in all build.gradle files)
- **Gradle** build system with shadow plugin for fat JARs
- Run from root: `./gradlew <task>`
## Key Commands
### Run the exporter
```bash
./gradlew :state_machine_exporter:run --args="-i ./my-project -o ./out -f json"
```
### Run the HTML generator
```bash
./gradlew :state_machine_exporter_html:run --args="-i ./my-project -o ./out_html"
```
### Update golden test files
```bash
./gradlew :state_machine_exporter:runGolden
```
### Run tests
```bash
./gradlew :state_machine_exporter:test
./gradlew :state_machine_exporter_html:test
```
## Module Structure
- **state_machine_exporter**: Core Java AST analyzer (Main class: `click.kamil.springstatemachineexporter.Main`)
- **state_machine_exporter_html**: HTML portal generator (Main class: `click.kamil.springstatemachineexporter.html.Main`)
- **state_machine_exporter_spring_based**: Spring-based exporter sample
- **file_combine**: Utility for concatenating Java files
- **state_machines/**: Sample state machine projects for testing
## Architecture Notes
- **Source-first analysis**: Reads source + build metadata (pom.xml, settings.gradle) statically
- **Accessor inlining**: Enabled by default (O(1) lookup for trivial getters/setters). Disable with `--no-inline-accessors`
- **Generated sources**: Scans `target/`/`build/` by default. Control with `--include-generated-sources`
- **Debug mode**: Use `--debug` flag for verbose logging and unresolved chain diagnostics
- **Spring profiles**: Pass with `-p, --profiles` for property placeholder resolution
## Testing
- **Golden tests**: JSON/PNG/PUML/DOT/SCXML outputs are compared against golden files in `state_machine_exporter/src/test/resources/golden/`
- **E2E tests**: Tagged with `@Tag("e2e")` in `PlantUmlE2ETest.java`
- **Test scenarios**: Defined in `TestScenario.java` record with name, inputPath, goldenPath, baseName, activeProfiles
## Important Constraints
- **No Maven/Gradle execution**: The tool does not run the target project's build
- **JDT bindings**: Limited to library types (Spring Boot 3.2.0, Spring State Machine 3.2.0) - external JARs not resolved
- **Monorepo structure**: All modules in root; state_machines/ contains sample projects
- **Java 21 only**: All modules require Java 21 toolchain

View File

@@ -6,9 +6,13 @@ import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.ExternalTriggerPolicy; import click.kamil.springstatemachineexporter.analysis.service.ExternalTriggerPolicy;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
@@ -19,6 +23,91 @@ public final class CallChainLinkPolicy {
private CallChainLinkPolicy() { private CallChainLinkPolicy() {
} }
/**
* Resolves the machine event enum FQN from config AST, embedded analysis fields, or transition events.
* Abstract/inherited configs often omit AST type args; transition {@code fullIdentifier} values still
* carry the correct enum type for endpoint narrowing and linking.
*/
public static String resolveMachineEventTypeFqn(
String machineConfigName,
String embeddedEventTypeFqn,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Transition> machineTransitions,
CodebaseContext context) {
if (machineTypes != null && machineTypes.eventTypeFqn() != null && !machineTypes.eventTypeFqn().isBlank()) {
return machineTypes.eventTypeFqn();
}
if (embeddedEventTypeFqn != null && !embeddedEventTypeFqn.isBlank()) {
return embeddedEventTypeFqn;
}
if (context != null && machineConfigName != null) {
String[] types = StateMachineTypeResolver.resolve(machineConfigName, context);
if (types != null && types.length > 1 && types[1] != null && !types[1].isBlank()) {
return types[1];
}
}
return eventTypeFromTransitions(machineTransitions);
}
public static StateMachineTypeResolver.MachineTypes resolveEffectiveMachineTypes(
String machineConfigName,
String embeddedStateTypeFqn,
String embeddedEventTypeFqn,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Transition> machineTransitions,
CodebaseContext context) {
String eventTypeFqn = resolveMachineEventTypeFqn(
machineConfigName, embeddedEventTypeFqn, machineTypes, machineTransitions, context);
String stateTypeFqn = machineTypes != null && machineTypes.stateTypeFqn() != null
? machineTypes.stateTypeFqn()
: embeddedStateTypeFqn;
if ((stateTypeFqn == null || stateTypeFqn.isBlank()) && context != null && machineConfigName != null) {
String[] types = StateMachineTypeResolver.resolve(machineConfigName, context);
if (types != null && types.length > 0 && types[0] != null && !types[0].isBlank()) {
stateTypeFqn = types[0];
}
}
if ((stateTypeFqn == null || stateTypeFqn.isBlank()) && machineTransitions != null) {
stateTypeFqn = stateTypeFromTransitions(machineTransitions);
}
return new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn);
}
private static String eventTypeFromTransitions(List<Transition> machineTransitions) {
if (machineTransitions == null) {
return null;
}
for (Transition transition : machineTransitions) {
Event event = transition.getEvent();
if (event == null) {
continue;
}
String fullIdentifier = event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
if (fullIdentifier != null && fullIdentifier.contains(".")) {
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
}
}
return null;
}
private static String stateTypeFromTransitions(List<Transition> machineTransitions) {
if (machineTransitions == null) {
return null;
}
for (Transition transition : machineTransitions) {
if (transition.getSourceStates() == null) {
continue;
}
for (var state : transition.getSourceStates()) {
String fullIdentifier = state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName();
if (fullIdentifier != null && fullIdentifier.contains(".")) {
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
}
}
}
return null;
}
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint trigger) { public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint trigger) {
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, null, null); return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, null, null);
} }
@@ -36,21 +125,50 @@ public final class CallChainLinkPolicy {
if (trigger == null || trigger.isExternal() || !trigger.isAmbiguous()) { if (trigger == null || trigger.isExternal() || !trigger.isAmbiguous()) {
return false; return false;
} }
List<String> poly = trigger.getPolymorphicEvents(); String eventTypeFqn = trigger.getEventTypeFqn();
if (poly == null || poly.size() <= 1) { if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
eventTypeFqn = machineEventTypeFqn;
}
List<String> concrete = concretePolymorphicCandidates(
trigger.getPolymorphicEvents(), eventTypeFqn, context);
if (concrete.size() <= 1) {
return false; return false;
} }
String event = trigger.getEvent(); String event = trigger.getEvent();
if (event != null && event.startsWith("ENUM_SET:")) { if (event != null && event.startsWith("ENUM_SET:")) {
return true; return true;
} }
if (event != null && event.contains(".valueOf(")) { return MachineEnumCanonicalizer.isDynamicTriggerExpression(event);
return MachineEnumCanonicalizer.isDynamicTriggerExpression(event) }
&& !isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context);
/**
* True when call-graph evidence resolves to a single concrete machine enum event for this chain.
* Dispatcher/rich-event paths should narrow to one constant per endpoint before linking.
*/
public static boolean isEndpointNarrowPolyEvidence(
TriggerPoint trigger,
String machineEventTypeFqn,
CodebaseContext context) {
if (trigger == null) {
return false;
} }
return MachineEnumCanonicalizer.isDynamicTriggerExpression(event) String eventTypeFqn = trigger.getEventTypeFqn();
&& event != null if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
&& !isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context); eventTypeFqn = machineEventTypeFqn;
}
List<String> concrete = concretePolymorphicCandidates(
trigger.getPolymorphicEvents(), eventTypeFqn, context);
return concrete.size() == 1;
}
public static boolean isEndpointNarrowPolyEvidence(TriggerPoint trigger) {
return isEndpointNarrowPolyEvidence(trigger, null, null);
}
public static boolean isEndpointNarrowPolyEvidence(
TriggerPoint trigger,
String machineEventTypeFqn) {
return isEndpointNarrowPolyEvidence(trigger, machineEventTypeFqn, null);
} }
/** /**
@@ -81,18 +199,40 @@ public final class CallChainLinkPolicy {
return false; return false;
} }
List<String> poly = trigger.getPolymorphicEvents(); List<String> poly = trigger.getPolymorphicEvents();
if (poly == null || poly.size() <= 1) { List<String> concrete = concretePolymorphicCandidates(poly, eventTypeFqn, context);
if (concrete.size() != 1) {
return false; return false;
} }
return MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(
concrete.get(0), eventTypeFqn, context);
}
static List<String> concretePolymorphicCandidates(
List<String> poly,
String eventTypeFqn,
CodebaseContext context) {
if (poly == null || poly.isEmpty()) {
return List.of();
}
List<String> concrete = new ArrayList<>();
for (String pe : poly) { for (String pe : poly) {
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:") || !pe.contains(".")) { if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:")) {
return false; continue;
} }
if (!MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(pe, eventTypeFqn, context)) { String qualified = pe;
return false; if (!pe.contains(".")) {
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
continue;
}
qualified = eventTypeFqn + "." + pe;
} else if (eventTypeFqn != null && !eventTypeFqn.isBlank()) {
qualified = MachineEnumCanonicalizer.canonicalizeLabel(pe, eventTypeFqn, context);
}
if (qualified != null && qualified.contains(".")) {
concrete.add(qualified);
} }
} }
return true; return concrete;
} }
public static LinkResolution resolveLinkResolution( public static LinkResolution resolveLinkResolution(
@@ -119,8 +259,10 @@ public final class CallChainLinkPolicy {
if (trigger != null && trigger.isExternal()) { if (trigger != null && trigger.isExternal()) {
return LinkResolution.UNRESOLVED_EXTERNAL; return LinkResolution.UNRESOLVED_EXTERNAL;
} }
if (ambiguousSource if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) {
|| shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) { return LinkResolution.AMBIGUOUS_WIDEN;
}
if (ambiguousSource) {
return LinkResolution.AMBIGUOUS_WIDEN; return LinkResolution.AMBIGUOUS_WIDEN;
} }
if (matched != null && !matched.isEmpty()) { if (matched != null && !matched.isEmpty()) {

View File

@@ -0,0 +1,543 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.enricher.path.SymbolicPathValueEstimator;
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.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Narrows wide call-graph polymorphic widens to endpoint-specific enum constants.
*
* <p>Uses multiple independent evidence sources (REST path, command keys, constraints, static trigger
* events, method-chain literals, camelCase method names) and only narrows when evidence converges.
*/
public final class CallChainPolyNarrower {
private static final int STRONG = 3;
private static final int MEDIUM = 2;
private static final int WEAK = 1;
private static final Pattern QUOTED_EQUALS_PARAM = Pattern.compile(
"\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)");
private static final Pattern PARAM_EQUALS_LITERAL = Pattern.compile(
"(\\w+)\\.equals(?:IgnoreCase)?\\(\"([^\"]+)\"\\)");
private static final Pattern ENUM_EQ = Pattern.compile(
"([A-Za-z_][\\w.]*)\\s*==\\s*([A-Za-z_][\\w.]*)");
private static final Pattern ROUTE_LITERAL = Pattern.compile(
"route\\(\\s*\"([^\"]+)\"\\s*,\\s*\"([^\"]+)\"\\s*\\)");
private static final Set<String> FIRE_METHOD_NAMES = Set.of(
"sendEvent", "fire", "fireEvent", "dispatchEvent", "send", "trigger");
private CallChainPolyNarrower() {
}
public static TriggerPoint narrowForCallChain(
TriggerPoint trigger,
CallChain chain,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Transition> machineTransitions,
CodebaseContext context) {
return narrowForCallChain(trigger, chain, machineTypes, null, machineTransitions, context);
}
public static TriggerPoint narrowForCallChain(
TriggerPoint trigger,
CallChain chain,
StateMachineTypeResolver.MachineTypes machineTypes,
String embeddedEventTypeFqn,
List<Transition> machineTransitions,
CodebaseContext context) {
if (trigger == null) {
return trigger;
}
List<String> poly = trigger.getPolymorphicEvents();
if (poly == null || poly.size() <= 1) {
return trigger;
}
String eventTypeFqn = CallChainLinkPolicy.resolveMachineEventTypeFqn(
null,
embeddedEventTypeFqn != null ? embeddedEventTypeFqn : trigger.getEventTypeFqn(),
machineTypes,
machineTransitions,
context);
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
return trigger;
}
Set<String> configuredConstants = configuredEventConstants(machineTransitions, eventTypeFqn, context);
if (configuredConstants.isEmpty()) {
return trigger;
}
HintVotes votes = new HintVotes();
collectHints(trigger, chain, configuredConstants, context, votes);
String chosen = votes.choose(configuredConstants);
if (chosen == null) {
return trigger;
}
List<String> narrowed = filterPolyToConstant(poly, chosen, eventTypeFqn, context);
if (narrowed.size() == 1) {
return trigger.toBuilder()
.polymorphicEvents(narrowed)
.ambiguous(false)
.build();
}
return trigger.toBuilder()
.polymorphicEvents(List.of(eventTypeFqn + "." + chosen))
.ambiguous(false)
.build();
}
private static void collectHints(
TriggerPoint trigger,
CallChain chain,
Set<String> configuredConstants,
CodebaseContext context,
HintVotes votes) {
collectFromStaticTriggerEvent(trigger, configuredConstants, votes);
collectFromEntryPoint(chain != null ? chain.getEntryPoint() : null, configuredConstants, votes);
collectFromConstraint(trigger.getConstraint(), configuredConstants, votes);
if (chain != null) {
collectFromMethodChain(chain.getMethodChain(), configuredConstants, votes);
collectFromSendEventLiterals(chain.getMethodChain(), configuredConstants, context, votes);
collectFromPathEstimator(chain, configuredConstants, context, votes);
}
}
private static void collectFromStaticTriggerEvent(
TriggerPoint trigger,
Set<String> configuredConstants,
HintVotes votes) {
String event = trigger.getEvent();
if (event == null || event.isBlank()) {
return;
}
if (MachineEnumCanonicalizer.isDynamicTriggerExpression(event)) {
return;
}
addEnumReference(event, configuredConstants, votes, STRONG);
}
private static void collectFromEntryPoint(
EntryPoint entryPoint,
Set<String> configuredConstants,
HintVotes votes) {
if (entryPoint == null) {
return;
}
if (entryPoint.getName() != null) {
collectFromPath(entryPoint.getName(), configuredConstants, votes);
}
if (entryPoint.getMetadata() != null) {
Object path = entryPoint.getMetadata().get("path");
if (path instanceof String pathValue) {
collectFromPath(pathValue, configuredConstants, votes);
}
}
if (entryPoint.getMethodName() != null) {
addUniqueIdentifierMatch(entryPoint.getMethodName(), configuredConstants, votes, WEAK);
}
}
private static void collectFromPath(String rawPath, Set<String> configuredConstants, HintVotes votes) {
if (rawPath == null || rawPath.isBlank()) {
return;
}
int space = rawPath.indexOf(' ');
String path = space >= 0 ? rawPath.substring(space + 1).trim() : rawPath.trim();
if (path.isBlank()) {
return;
}
for (String segment : path.split("/")) {
if (segment.isBlank() || segment.contains("{")) {
continue;
}
addPathToken(segment, configuredConstants, votes, STRONG);
}
}
private static void collectFromConstraint(
String constraint,
Set<String> configuredConstants,
HintVotes votes) {
if (constraint == null || constraint.isBlank()) {
return;
}
Matcher quoted = QUOTED_EQUALS_PARAM.matcher(constraint);
while (quoted.find()) {
String literal = quoted.group(1);
String param = quoted.group(2);
if ("event".equals(param) || "eventString".equals(param) || "actionKey".equals(param)
|| "action".equals(param)) {
addPathToken(literal, configuredConstants, votes, MEDIUM);
}
if ("commandKey".equals(param) || literal.contains(".")) {
addPathToken(literal, configuredConstants, votes, MEDIUM);
}
}
Matcher route = ROUTE_LITERAL.matcher(constraint);
while (route.find()) {
addPathToken(route.group(2), configuredConstants, votes, MEDIUM);
}
Matcher enumEq = ENUM_EQ.matcher(constraint);
while (enumEq.find()) {
addEnumReference(enumEq.group(1), configuredConstants, votes, MEDIUM);
addEnumReference(enumEq.group(2), configuredConstants, votes, MEDIUM);
}
Matcher reverseEquals = PARAM_EQUALS_LITERAL.matcher(constraint);
while (reverseEquals.find()) {
String param = reverseEquals.group(1);
String literal = reverseEquals.group(2);
if ("event".equals(param) || "eventString".equals(param) || "actionKey".equals(param)
|| "action".equals(param) || "commandKey".equals(param)) {
addPathToken(literal, configuredConstants, votes, MEDIUM);
}
}
}
private static void collectFromMethodChain(
List<String> methodChain,
Set<String> configuredConstants,
HintVotes votes) {
if (methodChain == null) {
return;
}
for (String methodFqn : methodChain) {
if (methodFqn == null || !methodFqn.contains(".")) {
continue;
}
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
addUniqueIdentifierMatch(methodName, configuredConstants, votes, WEAK);
for (String token : splitCamelCase(methodName)) {
addPathToken(token, configuredConstants, votes, WEAK);
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
int nestedDot = className.lastIndexOf('.');
if (nestedDot >= 0) {
addUniqueIdentifierMatch(className.substring(nestedDot + 1), configuredConstants, votes, WEAK);
}
}
}
private static void collectFromSendEventLiterals(
List<String> methodChain,
Set<String> configuredConstants,
CodebaseContext context,
HintVotes votes) {
if (context == null || methodChain == null) {
return;
}
ConstantResolver resolver = new ConstantResolver();
for (String methodFqn : methodChain) {
if (methodFqn == null || !methodFqn.contains(".")) {
continue;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration == null) {
continue;
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null) {
continue;
}
for (Object parameter : methodDeclaration.parameters()) {
if (parameter instanceof SingleVariableDeclaration variable) {
addUniqueIdentifierMatch(variable.getType().toString(), configuredConstants, votes, MEDIUM);
}
}
if (methodDeclaration.getBody() == null) {
continue;
}
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (!FIRE_METHOD_NAMES.contains(node.getName().getIdentifier())) {
return super.visit(node);
}
for (Object argument : node.arguments()) {
if (!(argument instanceof Expression expression)) {
continue;
}
if (expression instanceof QualifiedName qualifiedName) {
addEnumReference(qualifiedName.getFullyQualifiedName(), configuredConstants, votes, STRONG);
} else if (expression instanceof SimpleName simpleName) {
addEnumReference(simpleName.getIdentifier(), configuredConstants, votes, STRONG);
} else {
String resolved = resolver.resolve(expression, context);
if (resolved != null) {
addEnumReference(resolved, configuredConstants, votes, STRONG);
}
}
}
return super.visit(node);
}
});
}
}
private static void collectFromPathEstimator(
CallChain chain,
Set<String> configuredConstants,
CodebaseContext context,
HintVotes votes) {
if (context == null) {
return;
}
List<String> estimated = new SymbolicPathValueEstimator().estimatePossibleValues(chain, context);
if (estimated == null || estimated.isEmpty()) {
return;
}
int weight = estimated.size() == 1 ? MEDIUM : WEAK;
for (String value : estimated) {
addEnumReference(value, configuredConstants, votes, weight);
}
}
private static void addPathToken(
String token,
Set<String> configuredConstants,
HintVotes votes,
int weight) {
if (token == null || token.isBlank()) {
return;
}
addPathTokenVariants(token, configuredConstants, votes, weight);
if (token.contains(".")) {
addPathTokenVariants(token.substring(token.lastIndexOf('.') + 1), configuredConstants, votes, weight);
}
}
private static void addPathTokenVariants(
String token,
Set<String> configuredConstants,
HintVotes votes,
int weight) {
maybeVoteConstant(votes, normalizeToken(token), configuredConstants, weight);
for (String part : token.split("[-_.]")) {
if (!part.isBlank()) {
maybeVoteConstant(votes, normalizeToken(part), configuredConstants, weight);
}
}
if (token.toUpperCase(Locale.ROOT).contains("_")) {
String tail = token.substring(token.lastIndexOf('_') + 1);
maybeVoteConstant(votes, normalizeToken(tail), configuredConstants, weight);
}
}
private static void addEnumReference(
String reference,
Set<String> configuredConstants,
HintVotes votes,
int weight) {
if (reference == null || reference.isBlank()) {
return;
}
maybeVoteConstant(votes, constantName(reference), configuredConstants, weight);
if (reference.contains(".")) {
maybeVoteConstant(votes, constantName(reference), configuredConstants, weight);
}
String upper = reference.toUpperCase(Locale.ROOT);
if (upper.contains("_")) {
maybeVoteConstant(votes, upper.substring(upper.lastIndexOf('_') + 1), configuredConstants, weight);
}
}
private static void addUniqueIdentifierMatch(
String identifier,
Set<String> configuredConstants,
HintVotes votes,
int weight) {
if (identifier == null || identifier.isBlank()) {
return;
}
String upper = identifier.toUpperCase(Locale.ROOT);
List<String> matched = new ArrayList<>();
for (String constant : configuredConstants) {
if (upper.contains(constant)) {
matched.add(constant);
}
}
if (matched.size() == 1) {
votes.add(matched.get(0), weight);
}
}
private static void maybeVoteConstant(
HintVotes votes,
String candidate,
Set<String> configuredConstants,
int weight) {
if (candidate == null || candidate.isBlank()) {
return;
}
String normalized = candidate.toUpperCase(Locale.ROOT);
if (configuredConstants.contains(normalized)) {
votes.add(normalized, weight);
}
}
private static List<String> filterPolyToConstant(
List<String> poly,
String constant,
String eventTypeFqn,
CodebaseContext context) {
List<String> narrowed = new ArrayList<>();
for (String candidate : poly) {
if (candidate == null || candidate.startsWith("<SYMBOLIC:") || candidate.startsWith("ENUM_SET:")) {
continue;
}
if (!constant.equalsIgnoreCase(constantName(candidate))) {
continue;
}
String canonical = candidate.contains(".")
? MachineEnumCanonicalizer.canonicalizeLabel(candidate, eventTypeFqn, context)
: eventTypeFqn + "." + constant;
if (!narrowed.contains(canonical)) {
narrowed.add(canonical);
}
}
return narrowed;
}
private static String normalizeToken(String token) {
return token.replace('-', '_').toUpperCase(Locale.ROOT);
}
private static List<String> splitCamelCase(String identifier) {
if (identifier == null || identifier.isBlank()) {
return List.of();
}
List<String> tokens = new ArrayList<>();
StringBuilder current = new StringBuilder();
for (int i = 0; i < identifier.length(); i++) {
char ch = identifier.charAt(i);
if (Character.isUpperCase(ch) && !current.isEmpty()) {
tokens.add(current.toString());
current.setLength(0);
}
current.append(ch);
}
if (!current.isEmpty()) {
tokens.add(current.toString());
}
return tokens;
}
private static Set<String> configuredEventConstants(
List<Transition> transitions,
String eventTypeFqn,
CodebaseContext context) {
Set<String> constants = new LinkedHashSet<>();
for (String event : MachineEnumCanonicalizer.polymorphicEventsFromTransitions(
transitions, eventTypeFqn, context)) {
constants.add(constantName(event).toUpperCase(Locale.ROOT));
}
return constants;
}
private static String constantName(String ref) {
int dot = ref.lastIndexOf('.');
return (dot >= 0 ? ref.substring(dot + 1) : ref).toUpperCase(Locale.ROOT);
}
private static final class HintVotes {
private final Map<String, Integer> scores = new HashMap<>();
private final Map<String, Integer> maxWeight = new HashMap<>();
void add(String constant, int weight) {
if (constant == null || constant.isBlank()) {
return;
}
String key = constant.toUpperCase(Locale.ROOT);
scores.merge(key, weight, Integer::sum);
maxWeight.merge(key, weight, Math::max);
}
String choose(Set<String> configured) {
String strongUnique = uniqueAboveWeight(configured, STRONG);
if (strongUnique != null) {
return strongUnique;
}
String mediumUnique = uniqueAboveWeight(configured, MEDIUM);
if (mediumUnique != null) {
return mediumUnique;
}
String best = null;
int bestScore = 0;
for (String constant : configured) {
int score = scores.getOrDefault(constant, 0);
if (score > bestScore) {
bestScore = score;
best = constant;
}
}
if (best == null || bestScore < MEDIUM) {
return null;
}
final int winningScore = bestScore;
List<String> tied = new ArrayList<>();
for (String constant : configured) {
if (scores.getOrDefault(constant, 0) == winningScore) {
tied.add(constant);
}
}
if (tied.size() == 1) {
return best;
}
String maxWeightWinner = null;
int bestMaxWeight = 0;
for (String constant : tied) {
int weight = maxWeight.getOrDefault(constant, 0);
if (weight > bestMaxWeight) {
bestMaxWeight = weight;
maxWeightWinner = constant;
} else if (weight == bestMaxWeight && weight > 0) {
maxWeightWinner = null;
}
}
return maxWeightWinner;
}
private String uniqueAboveWeight(Set<String> configured, int minWeight) {
String chosen = null;
for (String constant : configured) {
if (maxWeight.getOrDefault(constant, 0) >= minWeight) {
if (chosen != null) {
return null;
}
chosen = constant;
}
}
return chosen;
}
}
}

View File

@@ -49,6 +49,15 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
result.getStateTypeFqn(), result.getStateTypeFqn(),
result.getEventTypeFqn(), result.getEventTypeFqn(),
context); context);
StateMachineTypeResolver.MachineTypes effectiveMachineTypes =
CallChainLinkPolicy.resolveEffectiveMachineTypes(
result.getName(),
result.getStateTypeFqn(),
result.getEventTypeFqn(),
machineTypes,
stateMachineTransitions,
context);
String effectiveEventTypeFqn = effectiveMachineTypes.eventTypeFqn();
for (CallChain chain : result.getMetadata().getCallChains()) { for (CallChain chain : result.getMetadata().getCallChains()) {
TriggerPoint tp = chain.getTriggerPoint(); TriggerPoint tp = chain.getTriggerPoint();
@@ -58,31 +67,40 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
} }
tp = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints( tp = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints(
tp, machineTypes, context, chain.getEntryPoint()); tp, effectiveMachineTypes, context, chain.getEntryPoint());
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
tp, machineTypes, context, stateMachineTransitions, true); tp, effectiveMachineTypes, context, stateMachineTransitions, true);
tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, machineTypes, context); tp = CallChainPolyNarrower.narrowForCallChain(
tp, chain, effectiveMachineTypes, result.getEventTypeFqn(), stateMachineTransitions, context);
tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, effectiveMachineTypes, context);
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context); tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
if (machineTypes != null) { tp = tp.toBuilder()
tp = tp.toBuilder() .eventTypeFqn(tp.getEventTypeFqn() != null
.eventTypeFqn(tp.getEventTypeFqn() != null ? tp.getEventTypeFqn()
? tp.getEventTypeFqn() : effectiveEventTypeFqn)
: machineTypes.eventTypeFqn()) .stateTypeFqn(tp.getStateTypeFqn() != null
.stateTypeFqn(tp.getStateTypeFqn() != null ? tp.getStateTypeFqn()
? tp.getStateTypeFqn() : effectiveMachineTypes.stateTypeFqn())
: machineTypes.stateTypeFqn()) .build();
.build();
}
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) { if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
updatedChains.add(chain); updatedChains.add(chain);
continue; continue;
} }
if (tp.isExternal() && isUnresolvedGenericRestEndpoint(chain.getEntryPoint())) {
updatedChains.add(chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(null)
.linkResolution(LinkResolution.UNRESOLVED_EXTERNAL)
.build());
continue;
}
String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null; String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null;
if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden( if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
tp, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null, effectiveEventTypeFqn,
context)) { context)) {
updatedChains.add(chain.toBuilder() updatedChains.add(chain.toBuilder()
.triggerPoint(tp) .triggerPoint(tp)
@@ -96,12 +114,13 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
if (triggerSource == null) { if (triggerSource == null) {
Map<String, Set<String>> sourcesByEvent = new LinkedHashMap<>(); Map<String, Set<String>> sourcesByEvent = new LinkedHashMap<>();
Map<String, Set<String>> stateTypesByEvent = new LinkedHashMap<>();
Map<String, String> canonicalSourceBySimplified = new LinkedHashMap<>(); Map<String, String> canonicalSourceBySimplified = new LinkedHashMap<>();
for (Transition t : stateMachineTransitions) { for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp) if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)
&& isRoutedToCorrectMachine( && isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp, chain, result.getName(), context, stateMachineTransitions, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null) effectiveEventTypeFqn)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
String smEventForLink = canonicalEvent(t.getEvent()); String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) { for (State smSourceState : t.getSourceStates()) {
@@ -109,12 +128,21 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
String smSource = simplifySourceState(smSourceForLink); String smSource = simplifySourceState(smSourceForLink);
sourcesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>()) sourcesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
.add(smSource); .add(smSource);
stateTypesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
.add(extractStateEnumType(smSourceForLink));
canonicalSourceBySimplified.putIfAbsent(smSource, smSourceForLink); canonicalSourceBySimplified.putIfAbsent(smSource, smSourceForLink);
} }
} }
} }
if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) { boolean crossStateTypeConflict = stateTypesByEvent.values().stream()
.anyMatch(types -> types.size() > 1);
if (crossStateTypeConflict) {
ambiguousSource = true; ambiguousSource = true;
} else if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) {
ambiguousSource = !CallChainLinkPolicy.isEndpointNarrowPolyEvidence(
tp,
effectiveEventTypeFqn,
context);
} else { } else {
Set<String> allDistinctSources = new LinkedHashSet<>(); Set<String> allDistinctSources = new LinkedHashSet<>();
sourcesByEvent.values().forEach(allDistinctSources::addAll); sourcesByEvent.values().forEach(allDistinctSources::addAll);
@@ -145,7 +173,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.build(); .build();
if (isRoutedToCorrectMachine( if (isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp, chain, result.getName(), context, stateMachineTransitions, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null) effectiveEventTypeFqn)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
@@ -159,7 +187,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.build(); .build();
if (isRoutedToCorrectMachine( if (isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp, chain, result.getName(), context, stateMachineTransitions, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null) effectiveEventTypeFqn)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
@@ -179,7 +207,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
tp, tp,
matched, matched,
ambiguousSource, ambiguousSource,
machineTypes != null ? machineTypes.eventTypeFqn() : null, effectiveEventTypeFqn,
context); context);
if (!matched.isEmpty()) { if (!matched.isEmpty()) {
@@ -257,6 +285,17 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return stripped.trim(); return stripped.trim();
} }
private String extractStateEnumType(String stateIdentifier) {
if (stateIdentifier == null || stateIdentifier.isBlank()) {
return stateIdentifier;
}
int lastDot = stateIdentifier.lastIndexOf('.');
if (lastDot <= 0) {
return stateIdentifier;
}
return stateIdentifier.substring(0, lastDot);
}
private final java.util.Map<String, String> simplifySourceCache = new java.util.HashMap<>(); private final java.util.Map<String, String> simplifySourceCache = new java.util.HashMap<>();
private String simplifySourceState(String name) { private String simplifySourceState(String name) {
@@ -293,4 +332,20 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(); return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
} }
private static boolean isUnresolvedGenericRestEndpoint(EntryPoint entryPoint) {
if (entryPoint == null) {
return false;
}
if (entryPoint.getName() != null && entryPoint.getName().contains("{")) {
return true;
}
if (entryPoint.getMetadata() != null) {
Object path = entryPoint.getMetadata().get("path");
if (path instanceof String pathValue && pathValue.contains("{")) {
return true;
}
}
return false;
}
} }

View File

@@ -1,11 +1,13 @@
package click.kamil.springstatemachineexporter.analysis.model; package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.extern.jackson.Jacksonized; import lombok.extern.jackson.Jacksonized;
import java.util.List; import java.util.List;
import java.util.Map;
@Data @Data
@Builder(toBuilder = true) @Builder(toBuilder = true)
@@ -19,4 +21,6 @@ public class CallChain {
private final List<MatchedTransition> matchedTransitions; private final List<MatchedTransition> matchedTransitions;
/** How this chain's trigger was linked; derived from trigger flags and matchedTransitions. */ /** How this chain's trigger was linked; derived from trigger flags and matchedTransitions. */
private final LinkResolution linkResolution; private final LinkResolution linkResolution;
@JsonIgnore
private final Map<String, String> pathBindings;
} }

View File

@@ -14,6 +14,10 @@ public final class LibraryUnwrapRegistry {
private static final Set<String> UNWRAP_METHOD_NAMES = Set.of( private static final Set<String> UNWRAP_METHOD_NAMES = Set.of(
"of", "ofEntries", "asList", "entry", "just", "withPayload", "success"); "of", "ofEntries", "asList", "entry", "just", "withPayload", "success");
private static final Set<String> REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success");
private static final Set<String> REACTIVE_TRANSFORM_METHODS = Set.of("map", "flatMap", "switchMap", "concatMap");
private static final String[] STOP_UNWRAP_PREFIXES = { private static final String[] STOP_UNWRAP_PREFIXES = {
"map", "parse", "convert", "to", "resolve", "build", "create", "from", "map", "parse", "convert", "to", "resolve", "build", "create", "from",
"transform", "translate", "derive", "determine", "calculate", "decode", "transform", "translate", "derive", "determine", "calculate", "decode",
@@ -31,6 +35,14 @@ public final class LibraryUnwrapRegistry {
return methodName != null && UNWRAP_METHOD_NAMES.contains(methodName); return methodName != null && UNWRAP_METHOD_NAMES.contains(methodName);
} }
public static boolean isReactiveFactoryMethod(String methodName) {
return methodName != null && REACTIVE_FACTORY_METHODS.contains(methodName);
}
public static boolean isReactiveTransformMethod(String methodName) {
return methodName != null && REACTIVE_TRANSFORM_METHODS.contains(methodName);
}
public static boolean shouldStopUnwrap(String methodName) { public static boolean shouldStopUnwrap(String methodName) {
if (methodName == null) { if (methodName == null) {
return true; return true;

View File

@@ -1,6 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.pipeline; package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.Block; import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.Expression; import org.eclipse.jdt.core.dom.Expression;
@@ -10,16 +11,11 @@ import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.SimpleName; import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.ASTNode; import org.eclipse.jdt.core.dom.ASTNode;
import java.util.Set;
/** /**
* Extracts terminal payload literals from reactive factory chains ({@code Mono.just}, nested transforms). * Extracts terminal payload literals from reactive factory chains ({@code Mono.just}, nested transforms).
*/ */
public final class ReactiveExpressionSupport { public final class ReactiveExpressionSupport {
private static final Set<String> REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success");
private static final Set<String> REACTIVE_TRANSFORM_METHODS = Set.of("map", "flatMap", "switchMap", "concatMap");
private ReactiveExpressionSupport() { private ReactiveExpressionSupport() {
} }
@@ -27,6 +23,45 @@ public final class ReactiveExpressionSupport {
return extractPayload(expression, null, constantResolver, context); return extractPayload(expression, null, constantResolver, context);
} }
/**
* Peels {@code just}/{@code withPayload}/{@code success} wrappers on an invocation chain and returns
* the payload {@link Expression} for dataflow analysis.
*/
public static Expression peelFactoryPayloadExpression(Expression expression) {
if (!(expression instanceof MethodInvocation mi)) {
return null;
}
MethodInvocation current = mi;
while (current != null) {
String methodName = current.getName().getIdentifier();
if (LibraryUnwrapRegistry.isReactiveFactoryMethod(methodName) && !current.arguments().isEmpty()) {
return MethodInvocationUnwrapper.peelExpression((Expression) current.arguments().get(0));
}
Expression receiver = current.getExpression();
if (receiver instanceof MethodInvocation nextMi) {
current = nextMi;
} else {
break;
}
}
return null;
}
public static Expression lambdaBodyExpression(LambdaExpression lambda) {
if (lambda.getBody() instanceof Expression bodyExpression) {
return bodyExpression;
}
if (lambda.getBody() instanceof Block block) {
for (Object statement : block.statements()) {
if (statement instanceof ReturnStatement returnStatement
&& returnStatement.getExpression() != null) {
return returnStatement.getExpression();
}
}
}
return null;
}
/** /**
* Rewrites {@code p.getEvent()} inside a reactive transform lambda to {@code payload.getEvent()} * Rewrites {@code p.getEvent()} inside a reactive transform lambda to {@code payload.getEvent()}
* when {@code p} is fed by {@code Mono.just(payload)} (or a chained reactive source). * when {@code p} is fed by {@code Mono.just(payload)} (or a chained reactive source).
@@ -70,7 +105,7 @@ public final class ReactiveExpressionSupport {
String methodFqn, String methodFqn,
ConstantResolver constantResolver, ConstantResolver constantResolver,
CodebaseContext context) { CodebaseContext context) {
MethodInvocation getter = findMethodInvocationInMethod(methodFqn, expressionText, context); MethodInvocation getter = AstUtils.findMethodInvocationInMethod(methodFqn, expressionText, context);
if (getter == null) { if (getter == null) {
return null; return null;
} }
@@ -81,37 +116,6 @@ public final class ReactiveExpressionSupport {
return null; return null;
} }
private static MethodInvocation findMethodInvocationInMethod(
String methodFqn,
String expressionText,
CodebaseContext context) {
if (methodFqn == null || expressionText == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
org.eclipse.jdt.core.dom.MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
final MethodInvocation[] match = new MethodInvocation[1];
md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (expressionText.equals(node.toString())) {
match[0] = node;
return false;
}
return super.visit(node);
}
});
return match[0];
}
private static LambdaExpression findEnclosingLambda(ASTNode node) { private static LambdaExpression findEnclosingLambda(ASTNode node) {
ASTNode current = node.getParent(); ASTNode current = node.getParent();
while (current != null) { while (current != null) {
@@ -127,7 +131,7 @@ public final class ReactiveExpressionSupport {
ASTNode current = node.getParent(); ASTNode current = node.getParent();
while (current != null) { while (current != null) {
if (current instanceof MethodInvocation mi if (current instanceof MethodInvocation mi
&& REACTIVE_TRANSFORM_METHODS.contains(mi.getName().getIdentifier())) { && LibraryUnwrapRegistry.isReactiveTransformMethod(mi.getName().getIdentifier())) {
return mi; return mi;
} }
current = current.getParent(); current = current.getParent();
@@ -145,14 +149,14 @@ public final class ReactiveExpressionSupport {
} }
if (expression instanceof MethodInvocation mi) { if (expression instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier(); String methodName = mi.getName().getIdentifier();
if (REACTIVE_TRANSFORM_METHODS.contains(methodName) && !mi.arguments().isEmpty()) { if (LibraryUnwrapRegistry.isReactiveTransformMethod(methodName) && !mi.arguments().isEmpty()) {
String fromArgument = extractTransformArgumentPayload( String fromArgument = extractTransformArgumentPayload(
(Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context); (Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context);
if (fromArgument != null) { if (fromArgument != null) {
return fromArgument; return fromArgument;
} }
} }
if (REACTIVE_FACTORY_METHODS.contains(methodName) && !mi.arguments().isEmpty()) { if (LibraryUnwrapRegistry.isReactiveFactoryMethod(methodName) && !mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0); Expression arg = (Expression) mi.arguments().get(0);
if (constantResolver != null && context != null) { if (constantResolver != null && context != null) {
String resolved = constantResolver.resolve(arg, context); String resolved = constantResolver.resolve(arg, context);
@@ -197,7 +201,7 @@ public final class ReactiveExpressionSupport {
if (!(bodyExpression instanceof MethodInvocation factoryCall)) { if (!(bodyExpression instanceof MethodInvocation factoryCall)) {
return null; return null;
} }
if (!REACTIVE_FACTORY_METHODS.contains(factoryCall.getName().getIdentifier()) if (!LibraryUnwrapRegistry.isReactiveFactoryMethod(factoryCall.getName().getIdentifier())
|| factoryCall.arguments().isEmpty()) { || factoryCall.arguments().isEmpty()) {
return null; return null;
} }
@@ -237,7 +241,7 @@ public final class ReactiveExpressionSupport {
return null; return null;
} }
if (transformReceiver instanceof MethodInvocation receiverTransform if (transformReceiver instanceof MethodInvocation receiverTransform
&& REACTIVE_TRANSFORM_METHODS.contains(receiverTransform.getName().getIdentifier()) && LibraryUnwrapRegistry.isReactiveTransformMethod(receiverTransform.getName().getIdentifier())
&& !receiverTransform.arguments().isEmpty() && !receiverTransform.arguments().isEmpty()
&& receiverTransform.arguments().get(0) instanceof LambdaExpression feederLambda && receiverTransform.arguments().get(0) instanceof LambdaExpression feederLambda
&& feederLambda != lambda) { && feederLambda != lambda) {
@@ -287,29 +291,13 @@ public final class ReactiveExpressionSupport {
} }
private static Expression peelJustArgument(Expression expression) { private static Expression peelJustArgument(Expression expression) {
if (expression instanceof MethodInvocation mi Expression peeled = peelFactoryPayloadExpression(expression);
&& REACTIVE_FACTORY_METHODS.contains(mi.getName().getIdentifier()) if (peeled != null) {
&& !mi.arguments().isEmpty()) { return peeled;
return (Expression) mi.arguments().get(0);
} }
if (expression instanceof MethodInvocation mi && mi.getExpression() != null) { if (expression instanceof MethodInvocation mi && mi.getExpression() != null) {
return peelJustArgument(mi.getExpression()); return peelJustArgument(mi.getExpression());
} }
return null; return null;
} }
private static Expression lambdaBodyExpression(LambdaExpression lambda) {
if (lambda.getBody() instanceof Expression bodyExpression) {
return bodyExpression;
}
if (lambda.getBody() instanceof Block block) {
for (Object statement : block.statements()) {
if (statement instanceof ReturnStatement returnStatement
&& returnStatement.getExpression() != null) {
return returnStatement.getExpression();
}
}
}
return null;
}
} }

View File

@@ -1,7 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.resolver; package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
@@ -850,7 +849,7 @@ public class ConstantResolver {
} }
} }
for (String classFqn : classesFromCurrentCallPath()) { for (String classFqn : classesFromCurrentCallPath(context)) {
TypeDeclaration pathTd = context.getTypeDeclaration(classFqn); TypeDeclaration pathTd = context.getTypeDeclaration(classFqn);
if (pathTd != null) { if (pathTd != null) {
String result = resolveFieldInType(pathTd, sn.getIdentifier(), classFqn, context, visited); String result = resolveFieldInType(pathTd, sn.getIdentifier(), classFqn, context, visited);
@@ -922,7 +921,7 @@ public class ConstantResolver {
if (td != null) { if (td != null) {
return td; return td;
} }
String entryClass = getCurrentCallPathEntryClass(); String entryClass = getCurrentCallPathEntryClass(context);
if (entryClass == null) { if (entryClass == null) {
return null; return null;
} }
@@ -934,15 +933,15 @@ public class ConstantResolver {
} }
private TypeDeclaration typeFromCurrentCallPath(CodebaseContext context) { private TypeDeclaration typeFromCurrentCallPath(CodebaseContext context) {
List<String> classes = classesFromCurrentCallPath(); List<String> classes = classesFromCurrentCallPath(context);
if (classes.isEmpty()) { if (classes.isEmpty()) {
return null; return null;
} }
return context.getTypeDeclaration(classes.get(0)); return context.getTypeDeclaration(classes.get(0));
} }
private List<String> classesFromCurrentCallPath() { private List<String> classesFromCurrentCallPath(CodebaseContext context) {
List<String> path = JdtDataFlowModel.getCurrentPath(); List<String> path = context.getAnalysisCallPath();
if (path == null || path.isEmpty()) { if (path == null || path.isEmpty()) {
return List.of(); return List.of();
} }
@@ -960,8 +959,8 @@ public class ConstantResolver {
return classes; return classes;
} }
private String getCurrentCallPathEntryClass() { private String getCurrentCallPathEntryClass(CodebaseContext context) {
List<String> classes = classesFromCurrentCallPath(); List<String> classes = classesFromCurrentCallPath(context);
return classes.isEmpty() ? null : classes.get(classes.size() - 1); return classes.isEmpty() ? null : classes.get(classes.size() - 1);
} }

View File

@@ -247,7 +247,7 @@ public final class MachineEnumCanonicalizer {
return polymorphicEvents; return polymorphicEvents;
} }
return narrowPolymorphicCandidates( return narrowPolymorphicCandidates(
polymorphicEvents, constraint, eventTypeFqn, machineTransitions, context); polymorphicEvents, constraint, eventTypeFqn, machineTransitions, context, null);
} }
/** /**
@@ -303,7 +303,8 @@ public final class MachineEnumCanonicalizer {
expanded.getConstraint(), expanded.getConstraint(),
machineTypes.eventTypeFqn(), machineTypes.eventTypeFqn(),
machineTransitions, machineTransitions,
context); context,
expanded);
if (!narrowed.equals(expanded.getPolymorphicEvents())) { if (!narrowed.equals(expanded.getPolymorphicEvents())) {
return expanded.toBuilder() return expanded.toBuilder()
.polymorphicEvents(narrowed) .polymorphicEvents(narrowed)
@@ -317,7 +318,8 @@ public final class MachineEnumCanonicalizer {
expanded.getConstraint(), expanded.getConstraint(),
machineTypes.eventTypeFqn(), machineTypes.eventTypeFqn(),
machineTransitions, machineTransitions,
context); context,
expanded);
if (!machineEvents.isEmpty()) { if (!machineEvents.isEmpty()) {
return expanded.toBuilder() return expanded.toBuilder()
.polymorphicEvents(machineEvents) .polymorphicEvents(machineEvents)
@@ -414,7 +416,8 @@ public final class MachineEnumCanonicalizer {
String constraint, String constraint,
String eventTypeFqn, String eventTypeFqn,
List<Transition> machineTransitions, List<Transition> machineTransitions,
CodebaseContext context) { CodebaseContext context,
TriggerPoint trigger) {
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context); List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context);
if (transitionEvents.isEmpty()) { if (transitionEvents.isEmpty()) {
return List.of(); return List.of();
@@ -425,6 +428,13 @@ public final class MachineEnumCanonicalizer {
if (!filtered.isEmpty()) { if (!filtered.isEmpty()) {
return filtered; return filtered;
} }
if (trigger != null) {
List<String> richFiltered = RichEventPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, trigger, context);
if (!richFiltered.isEmpty()) {
return richFiltered;
}
}
return List.of(); return List.of();
} }
return transitionEvents.size() == 1 ? transitionEvents : List.of(); return transitionEvents.size() == 1 ? transitionEvents : List.of();
@@ -436,6 +446,17 @@ public final class MachineEnumCanonicalizer {
String eventTypeFqn, String eventTypeFqn,
List<Transition> machineTransitions, List<Transition> machineTransitions,
CodebaseContext context) { CodebaseContext context) {
return narrowPolymorphicCandidates(
current, constraint, eventTypeFqn, machineTransitions, context, null);
}
private static List<String> narrowPolymorphicCandidates(
List<String> current,
String constraint,
String eventTypeFqn,
List<Transition> machineTransitions,
CodebaseContext context,
TriggerPoint trigger) {
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context); List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context);
List<String> result = current == null ? List.of() : new ArrayList<>(current); List<String> result = current == null ? List.of() : new ArrayList<>(current);
@@ -444,6 +465,23 @@ public final class MachineEnumCanonicalizer {
result, constraint, eventTypeFqn, context); result, constraint, eventTypeFqn, context);
if (!filtered.isEmpty()) { if (!filtered.isEmpty()) {
result = filtered; result = filtered;
} else if (trigger != null) {
List<String> source = !transitionEvents.isEmpty() ? transitionEvents : result;
List<String> richFiltered = RichEventPredicateEvaluator.filterEnumConstants(
source, constraint, eventTypeFqn, trigger, context);
if (!richFiltered.isEmpty()) {
result = richFiltered;
} else if (!transitionEvents.isEmpty()) {
filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, context);
if (!filtered.isEmpty()) {
result = filtered;
} else {
result = List.of();
}
} else {
result = List.of();
}
} else if (!transitionEvents.isEmpty()) { } else if (!transitionEvents.isEmpty()) {
filtered = EnumMemberPredicateEvaluator.filterEnumConstants( filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, context); transitionEvents, constraint, eventTypeFqn, context);
@@ -756,8 +794,14 @@ public final class MachineEnumCanonicalizer {
String raw; String raw;
if (isStringOrPrimitiveType(enumTypeFqn) && canonical.startsWith(enumTypeFqn + ".")) { if (isStringOrPrimitiveType(enumTypeFqn) && canonical.startsWith(enumTypeFqn + ".")) {
String constant = canonical.substring(enumTypeFqn.length() + 1); int constantStart = enumTypeFqn.length() + 1;
raw = "\"" + constant + "\""; if (constantStart >= canonical.length()) {
String fnRaw = toFnForm(canonical, enumTypeFqn);
raw = fnRaw != null ? fnRaw : resolvePlaceholder(state.rawName());
} else {
String constant = canonical.substring(constantStart);
raw = "\"" + constant + "\"";
}
} else { } else {
String fnRaw = toFnForm(canonical, enumTypeFqn); String fnRaw = toFnForm(canonical, enumTypeFqn);
raw = fnRaw != null ? fnRaw : resolvePlaceholder(state.rawName()); raw = fnRaw != null ? fnRaw : resolvePlaceholder(state.rawName());
@@ -1064,10 +1108,12 @@ public final class MachineEnumCanonicalizer {
return trigger; return trigger;
} }
String constraint = trigger.getConstraint(); String constraint = trigger.getConstraint();
if (constraint == null || constraint.isBlank()) { List<String> boundEventLiterals = constraint != null && !constraint.isBlank()
return trigger; ? extractBoundEventLiteralsFromConstraint(constraint)
: List.of();
if (boundEventLiterals.size() != 1) {
boundEventLiterals = extractEventLiteralsFromEntryPointPath(entryPoint);
} }
List<String> boundEventLiterals = extractBoundEventLiteralsFromConstraint(constraint);
if (boundEventLiterals.size() != 1) { if (boundEventLiterals.size() != 1) {
return trigger; return trigger;
} }
@@ -1081,7 +1127,9 @@ public final class MachineEnumCanonicalizer {
&& !entryPoint.getName().toUpperCase().endsWith("/" + literal)) { && !entryPoint.getName().toUpperCase().endsWith("/" + literal)) {
return trigger; return trigger;
} }
String machineTypeLiteral = extractBoundParamLiteralFromConstraint(constraint, "machineType"); String machineTypeLiteral = constraint != null
? extractBoundParamLiteralFromConstraint(constraint, "machineType")
: null;
if (machineTypeLiteral != null && entryPoint != null && entryPoint.getName() != null if (machineTypeLiteral != null && entryPoint != null && entryPoint.getName() != null
&& !entryPoint.getName().toUpperCase().contains("/" + machineTypeLiteral.toUpperCase() + "/")) { && !entryPoint.getName().toUpperCase().contains("/" + machineTypeLiteral.toUpperCase() + "/")) {
return trigger; return trigger;
@@ -1122,4 +1170,27 @@ public final class MachineEnumCanonicalizer {
} }
return literals; return literals;
} }
private static List<String> extractEventLiteralsFromEntryPointPath(EntryPoint entryPoint) {
if (entryPoint == null || entryPoint.getName() == null || entryPoint.getName().isBlank()) {
return List.of();
}
String rawPath = entryPoint.getName();
int space = rawPath.indexOf(' ');
String path = space >= 0 ? rawPath.substring(space + 1).trim() : rawPath.trim();
if (path.isBlank()) {
return List.of();
}
String[] segments = path.split("/");
for (int i = segments.length - 1; i >= 0; i--) {
String segment = segments[i];
if (segment.isBlank() || segment.contains("{")) {
continue;
}
if (segment.matches("[A-Z_][A-Z0-9_]*")) {
return List.of(segment);
}
}
return List.of();
}
} }

View File

@@ -0,0 +1,212 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.TypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.InfixExpression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Evaluates boolean predicates on rich/domain event types (e.g. {@code myEvent.isCorrect()})
* and maps matching implementations to machine enum constants via {@code getType()}.
*/
public final class RichEventPredicateEvaluator {
private RichEventPredicateEvaluator() {
}
public static List<String> filterEnumConstants(
List<String> candidates,
String constraint,
String machineEnumFqn,
TriggerPoint trigger,
CodebaseContext context) {
if (candidates == null || candidates.isEmpty() || constraint == null || context == null || trigger == null) {
return List.of();
}
List<EnumMemberPredicateEvaluator.PredicateCall> predicates =
EnumMemberPredicateEvaluator.extractPredicateCalls(constraint);
if (predicates.isEmpty()) {
return List.of();
}
EnumMemberPredicateEvaluator.PredicateCall predicate = predicates.get(0);
String receiverTypeFqn = resolveReceiverTypeFqn(trigger, predicate.receiverName(), context);
if (receiverTypeFqn == null) {
return List.of();
}
Set<String> ownerTypes = new LinkedHashSet<>();
ownerTypes.add(receiverTypeFqn);
ownerTypes.addAll(context.getImplementations(receiverTypeFqn));
Set<String> matched = new LinkedHashSet<>();
for (String ownerFqn : ownerTypes) {
if (!predicateMatches(ownerFqn, predicate, context)) {
continue;
}
String typeConstant = resolveDirectEnumFromPredicateMethod(ownerFqn, "getType", context);
if (typeConstant != null && matchesCandidate(typeConstant, candidates, machineEnumFqn, context)) {
matched.add(canonicalize(typeConstant, machineEnumFqn, context));
}
String directConstant = resolveDirectEnumFromPredicateMethod(ownerFqn, predicate.methodName(), context);
if (directConstant != null && matchesCandidate(directConstant, candidates, machineEnumFqn, context)) {
matched.add(canonicalize(directConstant, machineEnumFqn, context));
}
}
List<String> filtered = new ArrayList<>();
for (String candidate : candidates) {
String canonical = canonicalize(candidate, machineEnumFqn, context);
if (matched.contains(canonical)) {
filtered.add(candidate);
}
}
return filtered;
}
private static String resolveReceiverTypeFqn(TriggerPoint trigger, String receiverName, CodebaseContext context) {
if (trigger.getClassName() == null || trigger.getMethodName() == null || receiverName == null) {
return null;
}
TypeDeclaration owner = context.getTypeDeclaration(trigger.getClassName());
if (owner == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(owner, trigger.getMethodName(), true);
if (method == null) {
return null;
}
TypeResolver typeResolver = new TypeResolver(context);
for (Object parameter : method.parameters()) {
if (parameter instanceof org.eclipse.jdt.core.dom.SingleVariableDeclaration variable
&& receiverName.equals(variable.getName().getIdentifier())) {
return typeResolver.resolveTypeToFqn(variable.getType(), owner.getRoot());
}
}
return null;
}
private static boolean predicateMatches(
String ownerFqn,
EnumMemberPredicateEvaluator.PredicateCall predicate,
CodebaseContext context) {
TypeDeclaration type = context.getTypeDeclaration(ownerFqn);
if (type == null) {
return false;
}
MethodDeclaration method = context.findMethodDeclaration(type, predicate.methodName(), true);
if (method == null || method.getBody() == null) {
return false;
}
Boolean[] result = new Boolean[1];
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (result[0] == null && node.getExpression() != null) {
result[0] = evaluateBoolean(node.getExpression());
}
return super.visit(node);
}
});
if (result[0] == null) {
return false;
}
return predicate.negated() ? !result[0] : result[0];
}
private static Boolean evaluateBoolean(Expression expression) {
if (expression instanceof org.eclipse.jdt.core.dom.BooleanLiteral literal) {
return literal.booleanValue();
}
if (expression instanceof MethodInvocation invocation
&& "isEvent".equals(invocation.getName().getIdentifier())
&& invocation.getExpression() instanceof MethodInvocation getType
&& "getType".equals(getType.getName().getIdentifier())) {
return true;
}
if (expression instanceof InfixExpression infix && infix.getOperator() == InfixExpression.Operator.EQUALS) {
return true;
}
return null;
}
private static String resolveDirectEnumFromPredicateMethod(
String ownerFqn,
String predicateMethod,
CodebaseContext context) {
TypeDeclaration type = context.getTypeDeclaration(ownerFqn);
if (type == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(type, predicateMethod, true);
if (method == null || method.getBody() == null) {
return null;
}
final String[] constant = new String[1];
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (constant[0] != null || node.getExpression() == null) {
return super.visit(node);
}
constant[0] = extractEnumConstant(node.getExpression());
return super.visit(node);
}
});
return constant[0];
}
private static String extractEnumConstant(Expression expression) {
if (expression instanceof QualifiedName qualifiedName) {
return qualifiedName.getFullyQualifiedName();
}
if (expression instanceof InfixExpression infix && infix.getOperator() == InfixExpression.Operator.EQUALS) {
String left = extractEnumConstant(infix.getLeftOperand());
if (left != null) {
return left;
}
return extractEnumConstant(infix.getRightOperand());
}
if (expression instanceof MethodInvocation invocation
&& invocation.getExpression() instanceof MethodInvocation getType
&& "getType".equals(getType.getName().getIdentifier())) {
if (invocation.getExpression() instanceof MethodInvocation inner
&& inner.getExpression() instanceof ClassInstanceCreation creation
&& !creation.arguments().isEmpty()) {
return extractEnumConstant((Expression) creation.arguments().get(0));
}
}
return null;
}
private static boolean matchesCandidate(
String constant,
List<String> candidates,
String machineEnumFqn,
CodebaseContext context) {
if (constant == null) {
return false;
}
String canonical = canonicalize(constant, machineEnumFqn, context);
for (String candidate : candidates) {
if (canonical.equals(canonicalize(candidate, machineEnumFqn, context))) {
return true;
}
}
return false;
}
private static String canonicalize(String constant, String machineEnumFqn, CodebaseContext context) {
return MachineEnumCanonicalizer.canonicalizeLabel(constant, machineEnumFqn, context);
}
}

View File

@@ -34,39 +34,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
protected final PathBindingEvaluator pathBindingEvaluator; protected final PathBindingEvaluator pathBindingEvaluator;
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>(); private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
private final Map<String, List<String>> polymorphicCallCache = new HashMap<>(); private final Map<String, List<String>> polymorphicCallCache = new HashMap<>();
private final Map<String, Map<String, String>> pathBindingsCache = new HashMap<>();
protected Map<String, List<CallEdge>> graph; protected Map<String, List<CallEdge>> graph;
private ASTNode parseExpressionString(String expr) { private ASTNode parseExpressionString(String expr) {
if (expr == null) return null; if (expr == null) {
return parsedNodeCache.computeIfAbsent(expr, e -> { return null;
ASTParser parser = ASTParser.newParser(AST.JLS17); }
Map<String, String> options = org.eclipse.jdt.core.JavaCore.getOptions(); return parsedNodeCache.computeIfAbsent(expr, click.kamil.springstatemachineexporter.ast.common.AstUtils::parseExpression);
org.eclipse.jdt.core.JavaCore.setComplianceOptions(org.eclipse.jdt.core.JavaCore.VERSION_17, options);
parser.setCompilerOptions(options);
parser.setKind(ASTParser.K_EXPRESSION);
parser.setSource(e.toCharArray());
try {
ASTNode node = parser.createAST(null);
if (node instanceof org.eclipse.jdt.core.dom.CompilationUnit) {
ASTParser fallbackParser = ASTParser.newParser(AST.JLS17);
fallbackParser.setCompilerOptions(options);
fallbackParser.setKind(ASTParser.K_COMPILATION_UNIT);
fallbackParser.setSource(("class A { Object o = " + e + "; }").toCharArray());
org.eclipse.jdt.core.dom.CompilationUnit cu = (org.eclipse.jdt.core.dom.CompilationUnit) fallbackParser.createAST(null);
if (!cu.types().isEmpty()) {
org.eclipse.jdt.core.dom.TypeDeclaration td = (org.eclipse.jdt.core.dom.TypeDeclaration) cu.types().get(0);
if (!td.bodyDeclarations().isEmpty() && td.bodyDeclarations().get(0) instanceof org.eclipse.jdt.core.dom.FieldDeclaration fd) {
if (!fd.fragments().isEmpty() && fd.fragments().get(0) instanceof org.eclipse.jdt.core.dom.VariableDeclarationFragment vdf) {
return vdf.getInitializer();
}
}
}
}
return node;
} catch (Exception ex) {
return null;
}
});
} }
@FunctionalInterface @FunctionalInterface
@@ -86,6 +61,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
// Wire up cross dependencies // Wire up cross dependencies
this.variableTracer.setConstantExtractor(this.constantExtractor); this.variableTracer.setConstantExtractor(this.constantExtractor);
this.variableTracer.setTypeResolver(this.typeResolver);
this.constantExtractor.setVariableTracer(this.variableTracer); this.constantExtractor.setVariableTracer(this.variableTracer);
this.constantExtractor.setConstructorAnalyzer(this.constructorAnalyzer); this.constantExtractor.setConstructorAnalyzer(this.constructorAnalyzer);
this.constructorAnalyzer.setVariableTracer(this.variableTracer); this.constructorAnalyzer.setVariableTracer(this.variableTracer);
@@ -107,6 +83,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
context.clearAnalysisCaches(); context.clearAnalysisCaches();
pathFinder.clearAnalysisCaches(); pathFinder.clearAnalysisCaches();
pathBindingEvaluator.clearAnalysisCaches(); pathBindingEvaluator.clearAnalysisCaches();
pathBindingsCache.clear();
parsedNodeCache.clear(); parsedNodeCache.clear();
Map<String, List<CallEdge>> callGraph = buildCallGraph(); Map<String, List<CallEdge>> callGraph = buildCallGraph();
List<CallChain> chains = new ArrayList<>(); List<CallChain> chains = new ArrayList<>();
@@ -150,6 +127,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
continue; continue;
} }
foundAny = true; foundAny = true;
Map<String, String> pathBindings = resolvePathBindings(path, callGraph, initialBindings);
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph, initialBindings); TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph, initialBindings);
if (resolvedTp != null) { if (resolvedTp != null) {
String contextMachineId = pathFinder.extractContextMachineId(path, callGraph); String contextMachineId = pathFinder.extractContextMachineId(path, callGraph);
@@ -158,6 +136,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
.triggerPoint(resolvedTp) .triggerPoint(resolvedTp)
.methodChain(path) .methodChain(path)
.contextMachineId(contextMachineId) .contextMachineId(contextMachineId)
.pathBindings(pathBindings.isEmpty() ? null : Map.copyOf(pathBindings))
.build()); .build());
} }
} }
@@ -175,6 +154,33 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return resolveTriggerPointParameters(tp, path, callGraph, Map.of()); return resolveTriggerPointParameters(tp, path, callGraph, Map.of());
} }
protected Map<String, String> resolvePathBindings(
List<String> path,
Map<String, List<CallEdge>> callGraph,
Map<String, String> initialBindings) {
if (path == null || path.size() < 2) {
return initialBindings != null ? Map.copyOf(initialBindings) : Map.of();
}
String cacheKey = pathBindingsCacheKey(path, initialBindings);
return pathBindingsCache.computeIfAbsent(cacheKey, ignored -> {
Map<String, String> traced = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
return mergeBindings(initialBindings, traced);
});
}
private static String pathBindingsCacheKey(List<String> path, Map<String, String> initialBindings) {
StringJoiner joiner = new StringJoiner("\0");
for (String hop : path) {
joiner.add(hop);
}
if (initialBindings != null) {
initialBindings.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> joiner.add(entry.getKey() + "=" + entry.getValue()));
}
return joiner.toString();
}
protected TriggerPoint resolveTriggerPointParameters( protected TriggerPoint resolveTriggerPointParameters(
TriggerPoint tp, TriggerPoint tp,
List<String> path, List<String> path,
@@ -257,7 +263,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
Map<String, List<CallEdge>> callGraph, Map<String, List<CallEdge>> callGraph,
String[] finalParamNameRef, String[] finalParamNameRef,
Map<String, String> initialBindings) { Map<String, String> initialBindings) {
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath(path); click.kamil.springstatemachineexporter.ast.common.CodebaseContext contextRef = context;
contextRef.setAnalysisCallPath(path);
final boolean debug = log.isDebugEnabled(); final boolean debug = log.isDebugEnabled();
try { try {
String event = tp.getEvent(); String event = tp.getEvent();
@@ -1170,7 +1177,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return tp; return tp;
} finally { } finally {
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath(); context.clearAnalysisCallPath();
} }
} }
@@ -1206,6 +1213,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
} }
} }
if (expr instanceof LambdaExpression) {
String lambdaText = expr.toString();
String val = constantResolver.resolve(expr, context);
return val != null ? val : lambdaText;
}
} }
if (expr instanceof ExpressionMethodReference emr) { if (expr instanceof ExpressionMethodReference emr) {
@@ -1853,6 +1865,47 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
protected List<String> evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List<String> path, Map<String, List<CallEdge>> callGraph) { protected List<String> evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List<String> path, Map<String, List<CallEdge>> callGraph) {
String expressionText = click.kamil.springstatemachineexporter.ast.common.AstUtils.combineExpressionText(
resolvedValue, methodSuffix);
List<String> searchMethods = buildGetterSearchMethods(resolvedValue, entryMethod, path);
List<String> constants = resolveGetterConstantsViaDataflow(expressionText, searchMethods, path, callGraph, entryMethod);
if (!constants.isEmpty()) {
return constants;
}
for (String scopeMethod : searchMethods) {
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetterInMethod(
expressionText, scopeMethod, constantResolver, context);
if (remapped != null && !remapped.equals(expressionText)) {
constants = resolveGetterConstantsViaDataflow(remapped, searchMethods, path, callGraph, entryMethod);
if (!constants.isEmpty()) {
return constants;
}
}
}
return resolveGetterConstantsViaAccessorPipeline(resolvedValue, methodSuffix, entryMethod, path);
}
private List<String> resolveGetterConstantsViaDataflow(
String expressionText,
List<String> searchMethods,
List<String> path,
Map<String, List<CallEdge>> callGraph,
String entryMethod) {
Expression anchored = click.kamil.springstatemachineexporter.ast.common.AstUtils.findExpressionInMethods(
searchMethods, expressionText, context);
if (anchored != null) {
List<String> constants = new ArrayList<>();
addConstantsFromTracedExpression(anchored, constants, path, callGraph, entryMethod);
if (!constants.isEmpty()) {
return constants.stream().distinct().toList();
}
}
return variableTracer.resolveConstantsFromSourceText(expressionText, searchMethods, path);
}
private List<String> resolveGetterConstantsViaAccessorPipeline(String resolvedValue, String methodSuffix, String entryMethod, List<String> path) {
ASTNode exprNode = parseExpressionString(resolvedValue); ASTNode exprNode = parseExpressionString(resolvedValue);
while (exprNode instanceof ParenthesizedExpression pe) { while (exprNode instanceof ParenthesizedExpression pe) {
exprNode = pe.getExpression(); exprNode = pe.getExpression();
@@ -1953,7 +2006,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
mi.toString(), scopeMethod, constantResolver, context); mi.toString(), scopeMethod, constantResolver, context);
} }
if (remapped != null) { if (remapped != null) {
return evaluateGetterOnReceiver(remapped, "", entryMethod, path, callGraph); return evaluateGetterOnReceiver(remapped, "", entryMethod, path, null);
} }
} }
@@ -2056,6 +2109,35 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return null; return null;
} }
private List<String> buildGetterSearchMethods(String resolvedValue, String entryMethod, List<String> path) {
java.util.LinkedHashSet<String> methods = new java.util.LinkedHashSet<>();
methods.add(resolveScopeMethodForResolvedValue(resolvedValue, entryMethod, path));
if (path != null) {
methods.addAll(path);
}
if (entryMethod != null) {
methods.add(entryMethod);
}
return List.copyOf(methods);
}
private String resolveScopeMethodForResolvedValue(String resolvedValue, String entryMethod, List<String> path) {
ASTNode node = parseExpressionString(resolvedValue);
while (node instanceof ParenthesizedExpression pe) {
node = pe.getExpression();
}
if (node instanceof Expression expression) {
Expression root = expression;
while (root instanceof MethodInvocation mi) {
root = mi.getExpression();
}
if (root instanceof SimpleName sn) {
return resolveScopeMethodForVariable(sn.getIdentifier(), entryMethod, path);
}
}
return entryMethod;
}
protected Expression findVariableInitializer(String methodFqn, String varName) { protected Expression findVariableInitializer(String methodFqn, String varName) {
int lastDot = methodFqn.lastIndexOf('.'); int lastDot = methodFqn.lastIndexOf('.');
if (lastDot < 0) return null; if (lastDot < 0) return null;
@@ -3013,8 +3095,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (isExternalParameter(entryMethod, finalParamName)) { if (isExternalParameter(entryMethod, finalParamName)) {
return true; return true;
} }
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); Map<String, String> bindings = resolvePathBindings(path, callGraph, initialBindings);
bindings = mergeBindings(initialBindings, bindings);
for (String boundName : bindings.keySet()) { for (String boundName : bindings.keySet()) {
if (isExternalParameter(entryMethod, boundName)) { if (isExternalParameter(entryMethod, boundName)) {
return true; return true;
@@ -3051,7 +3132,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return sl.getLiteralValue(); return sl.getLiteralValue();
} }
if (keyArg instanceof SimpleName sn) { if (keyArg instanceof SimpleName sn) {
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); Map<String, String> bindings = resolvePathBindings(path, callGraph, Map.of());
String bound = bindings.get(sn.getIdentifier()); String bound = bindings.get(sn.getIdentifier());
if (bound != null) { if (bound != null) {
return unwrapStringLiteral(bound); return unwrapStringLiteral(bound);
@@ -3079,7 +3160,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (!(keyArg instanceof SimpleName sn)) { if (!(keyArg instanceof SimpleName sn)) {
return false; return false;
} }
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); Map<String, String> bindings = resolvePathBindings(path, callGraph, Map.of());
if (bindings.containsKey(sn.getIdentifier())) { if (bindings.containsKey(sn.getIdentifier())) {
return true; return true;
} }
@@ -3101,21 +3182,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
private void addConstantsFromTracedExpression( private void addConstantsFromTracedExpression(
Expression expr, List<String> constants, List<String> path, Map<String, List<CallEdge>> callGraph, String scopeMethod) { Expression expr, List<String> constants, List<String> path, Map<String, List<CallEdge>> callGraph, String scopeMethod) {
if (expr instanceof MethodInvocation mi
&& "get".equals(mi.getName().getIdentifier())
&& mi.arguments().isEmpty()
&& !expressionAccessClassifier.isKeyedLookup(mi, scopeMethod)) {
List<String> callerFrameConstants = CallSiteArgumentResolver.resolveFunctionalGetFromCallerFrame(
mi, path, callGraph, scopeMethod, typeResolver, pathFinder, constantExtractor, callSiteHooks());
if (!callerFrameConstants.isEmpty()) {
for (String constant : callerFrameConstants) {
if (!constants.contains(constant)) {
constants.add(constant);
}
}
return;
}
}
List<Expression> traced = variableTracer.traceVariableAll(expr); List<Expression> traced = variableTracer.traceVariableAll(expr);
for (Expression tracedExpr : traced) { for (Expression tracedExpr : traced) {
addConstantsFromSingleExpression(tracedExpr, constants, path, callGraph, scopeMethod); addConstantsFromSingleExpression(tracedExpr, constants, path, callGraph, scopeMethod);
@@ -3153,7 +3219,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) { if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return polymorphicEvents; return polymorphicEvents;
} }
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); Map<String, String> bindings = resolvePathBindings(path, callGraph, Map.of());
if (bindings.isEmpty()) { if (bindings.isEmpty()) {
if (hasMultiClassPolymorphicPath(path) if (hasMultiClassPolymorphicPath(path)
&& polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) { && polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) {
@@ -3194,6 +3260,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (tp.isExternal() && hasConcrete) { if (tp.isExternal() && hasConcrete) {
narrowed.removeIf(pe -> pe != null && pe.startsWith("<SYMBOLIC:")); narrowed.removeIf(pe -> pe != null && pe.startsWith("<SYMBOLIC:"));
} }
narrowed.removeIf(pe -> pe != null && (pe.equals("*>") || pe.equals(".*>")));
return narrowed; return narrowed;
} }
@@ -3539,21 +3606,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return name.matches("[A-Z_][A-Z0-9_]*"); return name.matches("[A-Z_][A-Z0-9_]*");
} }
private CallSiteArgumentResolver.ExpressionHooks callSiteHooks() {
return new CallSiteArgumentResolver.ExpressionHooks() {
@Override
public String resolveArgument(Expression expr) {
return AbstractCallGraphEngine.this.resolveArgument(expr);
}
@Override
public Expression parseExpression(String expr) {
ASTNode node = parseExpressionString(expr);
return node instanceof Expression e ? e : null;
}
};
}
private void trackKeyedMapLookupFlags( private void trackKeyedMapLookupFlags(
Expression expr, Expression expr,
List<String> path, List<String> path,

View File

@@ -1,120 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.SimpleName;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Resolves call-site arguments across stack frames (e.g. {@code Supplier.get()} → caller lambda).
*/
public final class CallSiteArgumentResolver {
public interface ExpressionHooks {
/** Unwrap lambda / enum literal argument expressions to a stable string form. */
String resolveArgument(Expression expr);
/** Parse a stringified expression back to AST for constant extraction. */
Expression parseExpression(String expr);
}
private CallSiteArgumentResolver() {
}
/**
* When {@code supplierParam.get()} is seen inside {@code scopeMethod}, walk to the caller frame
* and extract enum/constants from the argument bound to {@code supplierParam}.
*/
public static List<String> resolveFunctionalGetFromCallerFrame(
MethodInvocation getCall,
List<String> path,
Map<String, List<CallEdge>> callGraph,
String scopeMethod,
TypeResolver typeResolver,
CallGraphPathFinder pathFinder,
ConstantExtractor constantExtractor,
ExpressionHooks hooks) {
if (path == null || path.size() < 2 || scopeMethod == null || getCall.getExpression() == null) {
return List.of();
}
if (!(getCall.getExpression() instanceof SimpleName supplierParam)) {
return List.of();
}
int scopeIndex = indexOfMethod(path, scopeMethod);
if (scopeIndex <= 0) {
return List.of();
}
String callee = path.get(scopeIndex);
String caller = path.get(scopeIndex - 1);
int paramIndex = typeResolver.getParameterIndex(callee, supplierParam.getIdentifier());
if (paramIndex < 0) {
return List.of();
}
String argValue = findCallSiteArgument(caller, callee, paramIndex, callGraph, pathFinder);
if (argValue == null) {
return List.of();
}
return extractConstantsFromCallSiteArgument(argValue, constantExtractor, hooks);
}
public static String findCallSiteArgument(
String caller,
String callee,
int paramIndex,
Map<String, List<CallEdge>> callGraph,
CallGraphPathFinder pathFinder) {
List<CallEdge> edges = callGraph.get(caller);
if (edges == null) {
return null;
}
for (CallEdge edge : edges) {
if (!edge.getTargetMethod().equals(callee) && !pathFinder.isHeuristicMatch(edge.getTargetMethod(), callee)) {
continue;
}
if (paramIndex < edge.getArguments().size()) {
return edge.getArguments().get(paramIndex);
}
}
return null;
}
public static int indexOfMethod(List<String> path, String methodFqn) {
for (int i = path.size() - 1; i >= 0; i--) {
if (methodFqn.equals(path.get(i))) {
return i;
}
}
return -1;
}
public static List<String> extractConstantsFromCallSiteArgument(
String argValue,
ConstantExtractor constantExtractor,
ExpressionHooks hooks) {
if (argValue == null || argValue.isBlank()) {
return List.of();
}
Expression parsed = hooks.parseExpression(argValue);
if (parsed == null) {
if (FunctionalInterfaceTypes.looksLikeEnumConstant(argValue)) {
return List.of(argValue);
}
return List.of();
}
String resolved = hooks.resolveArgument(parsed);
Expression resolvedExpr = hooks.parseExpression(resolved);
List<String> constants = new ArrayList<>();
if (resolvedExpr != null) {
constantExtractor.extractConstantsFromExpression(resolvedExpr, constants);
}
if (constants.isEmpty() && FunctionalInterfaceTypes.looksLikeEnumConstant(resolved)) {
constants.add(resolved);
}
return constants;
}
}

View File

@@ -0,0 +1,225 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Matches {@link CallEdge}s to concrete {@link MethodInvocation}s in caller source when
* multiple call sites target the same method.
*/
public final class CallSiteMatcher {
private static final Pattern ENUM_LITERAL = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*\\.[A-Z_][A-Z0-9_]*");
private CallSiteMatcher() {
}
public static CallEdge selectEdge(
String caller,
String target,
List<CallEdge> edges,
Map<String, String> bindings) {
if (edges == null || edges.isEmpty()) {
return null;
}
List<CallEdge> matching = new ArrayList<>();
for (CallEdge edge : edges) {
if (edge.getTargetMethod().equals(target) || heuristicTargetMatch(edge.getTargetMethod(), target)) {
matching.add(edge);
}
}
if (matching.isEmpty()) {
return null;
}
if (matching.size() == 1) {
return matching.get(0);
}
List<CallEdge> constraintFiltered = matching;
if (bindings != null && !bindings.isEmpty()) {
List<CallEdge> compatible = new ArrayList<>();
for (CallEdge edge : matching) {
String constraint = edge.getConstraint();
if (constraint == null
|| constraint.isBlank()
|| BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, bindings)) {
compatible.add(edge);
}
}
if (!compatible.isEmpty()) {
constraintFiltered = compatible;
}
}
if (constraintFiltered.size() == 1) {
return constraintFiltered.get(0);
}
if (bindings != null && !bindings.isEmpty()) {
for (CallEdge edge : constraintFiltered) {
if (edge.getArguments() == null) {
continue;
}
for (String edgeArg : edge.getArguments()) {
for (String bindingValue : bindings.values()) {
if (edgeArg == null || bindingValue == null) {
continue;
}
if (edgeArg.contains(bindingValue) || bindingValue.contains(edgeArg)) {
return edge;
}
}
}
}
}
for (CallEdge edge : constraintFiltered) {
if (findMatchingInvocation(caller, target, edge, null) != null) {
return edge;
}
}
return constraintFiltered.get(0);
}
public static MethodInvocation findMatchingInvocation(
String caller,
String callee,
CallEdge edge,
CodebaseContext context) {
List<MethodInvocation> invocations = findCallInvocations(caller, callee, context);
if (invocations.isEmpty()) {
return null;
}
if (edge == null || invocations.size() == 1) {
return invocations.get(0);
}
for (MethodInvocation invocation : invocations) {
if (invocationMatchesEdge(invocation, edge)) {
return invocation;
}
}
return null;
}
public static Expression findCallSiteArgumentExpression(
String caller,
String callee,
int paramIndex,
CallEdge edge,
CodebaseContext context) {
MethodInvocation invocation = findMatchingInvocation(caller, callee, edge, context);
if (invocation == null) {
return null;
}
if (paramIndex < 0 || paramIndex >= invocation.arguments().size()) {
return null;
}
return (Expression) invocation.arguments().get(paramIndex);
}
static List<MethodInvocation> findCallInvocations(String caller, String callee, CodebaseContext context) {
if (caller == null || callee == null || !caller.contains(".") || !callee.contains(".") || context == null) {
return List.of();
}
String callerClass = caller.substring(0, caller.lastIndexOf('.'));
String callerMethod = caller.substring(caller.lastIndexOf('.') + 1);
String calleeMethod = callee.substring(callee.lastIndexOf('.') + 1);
String calleeClass = callee.substring(0, callee.lastIndexOf('.'));
TypeDeclaration callerType = context.getTypeDeclaration(callerClass);
if (callerType == null) {
return List.of();
}
MethodDeclaration method = context.findMethodDeclaration(callerType, callerMethod, true);
if (method == null || method.getBody() == null) {
return List.of();
}
List<MethodInvocation> invocations = new ArrayList<>();
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (!calleeMethod.equals(node.getName().getIdentifier())) {
return super.visit(node);
}
IMethodBinding binding = node.resolveMethodBinding();
if (binding != null && binding.getDeclaringClass() != null) {
String declaringFqn = binding.getDeclaringClass().getErasure().getQualifiedName();
if (!declaringFqn.equals(calleeClass) && !declaringFqn.endsWith("." + calleeClass)) {
String calleeSimple = calleeClass.substring(calleeClass.lastIndexOf('.') + 1);
if (!declaringFqn.endsWith("." + calleeSimple)) {
return super.visit(node);
}
}
}
invocations.add(node);
return super.visit(node);
}
});
return invocations;
}
static boolean invocationMatchesEdge(MethodInvocation invocation, CallEdge edge) {
if (edge == null || edge.getArguments() == null) {
return true;
}
List<?> astArgs = invocation.arguments();
List<String> edgeArgs = edge.getArguments();
int count = Math.min(astArgs.size(), edgeArgs.size());
if (count == 0) {
return edgeArgs.isEmpty();
}
int matched = 0;
for (int i = 0; i < count; i++) {
if (argumentTokensMatch(((Expression) astArgs.get(i)).toString(), edgeArgs.get(i))) {
matched++;
}
}
return matched == count;
}
static boolean argumentTokensMatch(String astArg, String edgeArg) {
if (astArg == null || edgeArg == null) {
return false;
}
String left = normalizeArgument(astArg);
String right = normalizeArgument(edgeArg);
if (left.equals(right)) {
return true;
}
String leftEnum = extractEnumLiteral(left);
String rightEnum = extractEnumLiteral(right);
return leftEnum != null && leftEnum.equals(rightEnum);
}
private static String normalizeArgument(String value) {
return value.replaceAll("\\s+", "");
}
private static String extractEnumLiteral(String value) {
var matcher = ENUM_LITERAL.matcher(value);
return matcher.find() ? matcher.group() : null;
}
private static boolean heuristicTargetMatch(String edgeTarget, String pathTarget) {
if (edgeTarget == null || pathTarget == null) {
return false;
}
if (edgeTarget.equals(pathTarget)) {
return true;
}
String edgeSimple = edgeTarget.substring(edgeTarget.lastIndexOf('.') + 1);
String pathSimple = pathTarget.substring(pathTarget.lastIndexOf('.') + 1);
return edgeSimple.equals(pathSimple);
}
}

View File

@@ -78,6 +78,18 @@ public class ConstantExtractor {
extractConstantsFromArgument((Expression) expObj, constants); extractConstantsFromArgument((Expression) expObj, constants);
} }
} else if (expr instanceof MethodInvocation mi) { } else if (expr instanceof MethodInvocation mi) {
if (variableTracer != null) {
int sizeBefore = constants.size();
for (Expression def : variableTracer.traceVariableAll(mi)) {
if (def != null && !def.toString().equals(mi.toString())) {
extractConstantsFromExpression(def, constants);
}
}
if (constants.size() > sizeBefore) {
return;
}
}
String methodName = mi.getName().getIdentifier(); String methodName = mi.getName().getIdentifier();
if (log.isTraceEnabled()) { if (log.isTraceEnabled()) {
@@ -99,42 +111,20 @@ public class ConstantExtractor {
} }
return; return;
} }
if ((methodName.startsWith("get") || methodName.equals("type") || methodName.equals("event")) && mi.getExpression() instanceof SimpleName sn) { if ((methodName.startsWith("get") || methodName.equals("type") || methodName.equals("event"))
String varName = sn.getIdentifier(); && mi.getExpression() instanceof SimpleName sn
String propName = methodName.startsWith("get") ? methodName.substring(3) : methodName; && variableTracer != null) {
org.eclipse.jdt.core.dom.MethodDeclaration md =
Block block = findEnclosingBlock(mi); click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(mi);
if (block != null) { TypeDeclaration td = findEnclosingType(mi);
for (Object stmtObj : block.statements()) { if (md != null && td != null) {
if (stmtObj == mi.getParent() || stmtObj == mi) break; String methodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
if (stmtObj instanceof ExpressionStatement es) { Expression setterArg = variableTracer.traceLocalSetter(methodFqn, sn.getIdentifier(), methodName);
if (es.getExpression() instanceof MethodInvocation setterMi) { if (setterArg != null) {
if (setterMi.getName().getIdentifier().equalsIgnoreCase("set" + propName) || setterMi.getName().getIdentifier().equalsIgnoreCase(propName)) { extractConstantsFromExpression(setterArg, constants);
if (setterMi.getExpression() instanceof SimpleName setterSn && setterSn.getIdentifier().equals(varName)) { if (!constants.isEmpty()) {
if (!setterMi.arguments().isEmpty()) { return;
extractConstantsFromExpression((Expression) setterMi.arguments().get(0), constants);
return;
}
}
}
} else if (es.getExpression() instanceof Assignment assignment) {
if (assignment.getLeftHandSide() instanceof FieldAccess fa) {
if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) {
if (fa.getName().getIdentifier().equalsIgnoreCase(propName)) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
return;
}
}
} else if (assignment.getLeftHandSide() instanceof QualifiedName qqn) {
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
if (qqn.getName().getIdentifier().equalsIgnoreCase(propName)) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
return;
}
}
}
}
} }
} }
} }
@@ -678,14 +668,6 @@ public class ConstantExtractor {
} }
} }
private Block findEnclosingBlock(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof Block)) {
parent = parent.getParent();
}
return (Block) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) { private TypeDeclaration findEnclosingType(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node); return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
} }

View File

@@ -25,6 +25,17 @@ public final class ExternalTriggerPolicy {
if (trigger == null) { if (trigger == null) {
return false; return false;
} }
if (entryPoint != null && entryPointHasUnboundPlaceholders(entryPoint)
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
String paramName = resolveRestEventParameterName(entryPoint, resolvedEventParamName);
if (paramName != null) {
RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context);
if (binding != null && binding.pathVariable() && binding.enumType()) {
return false;
}
}
return true;
}
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent()) if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) { == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) {
return false; return false;

View File

@@ -1,14 +1,38 @@
package click.kamil.springstatemachineexporter.analysis.service; package click.kamil.springstatemachineexporter.analysis.service;
import java.util.Set;
/** /**
* Recognizes JDK functional interface parameters used as delayed event providers * Recognizes JDK functional interface parameters used as delayed event providers
* ({@code Supplier.get()}, etc.) in call-graph argument tracing. * ({@code Supplier.get()}, {@code Runnable.run()}, {@code Consumer.accept()}, etc.)
* in call-graph argument tracing.
*/ */
public final class FunctionalInterfaceTypes { public final class FunctionalInterfaceTypes {
private static final Set<String> SAM_METHOD_NAMES = Set.of(
"run", "get", "call", "accept", "apply", "test");
private FunctionalInterfaceTypes() { private FunctionalInterfaceTypes() {
} }
public static boolean isSamMethodName(String methodName) {
return methodName != null && SAM_METHOD_NAMES.contains(methodName);
}
/**
* Whether {@code typeName.methodName} is a single-abstract-method invocation on a functional interface
* (e.g. {@code java.lang.Runnable.run}, {@code java.util.function.Consumer.accept}).
*/
public static boolean isFunctionalSamMethod(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) {
return false;
}
int lastDot = methodFqn.lastIndexOf('.');
String methodName = methodFqn.substring(lastDot + 1);
String typeName = methodFqn.substring(0, lastDot);
return isSamMethodName(methodName) && isFunctionalInterface(typeName);
}
public static boolean isFunctionalInterface(String typeName) { public static boolean isFunctionalInterface(String typeName) {
if (typeName == null || typeName.isBlank()) { if (typeName == null || typeName.isBlank()) {
return false; return false;
@@ -17,7 +41,11 @@ public final class FunctionalInterfaceTypes {
if (simple.contains("<")) { if (simple.contains("<")) {
simple = simple.substring(0, simple.indexOf('<')); simple = simple.substring(0, simple.indexOf('<'));
} }
return "Supplier".equals(simple) || "Function".equals(simple) || "Callable".equals(simple); return switch (simple) {
case "Runnable", "Supplier", "Function", "Callable", "Consumer", "BiConsumer", "Predicate", "BiFunction" ->
true;
default -> simple.contains("Supplier") || simple.contains("Function") || simple.contains("Callable");
};
} }
public static boolean isLambdaArgument(String argValue) { public static boolean isLambdaArgument(String argValue) {

View File

@@ -130,16 +130,10 @@ public class GenericEventDetector {
return reactivePayload; return reactivePayload;
} }
if (receiver == null) return null; if (receiver == null) return null;
if (receiver instanceof MethodInvocation mi) { Expression factoryPayload = ReactiveExpressionSupport.peelFactoryPayloadExpression(receiver);
String mName = mi.getName().getIdentifier(); if (factoryPayload != null) {
if (("just".equals(mName) || "withPayload".equals(mName) || "success".equals(mName)) && !mi.arguments().isEmpty()) { String resolved = constantResolver.resolve(factoryPayload, context);
Expression arg = (Expression) mi.arguments().get(0); return resolved != null ? resolved : factoryPayload.toString();
String resolved = constantResolver.resolve(arg, context);
return resolved != null ? resolved : arg.toString();
}
if (mi.getExpression() != null) {
return extractEventFromReceiver(mi.getExpression());
}
} }
if (receiver instanceof SimpleName sn) { if (receiver instanceof SimpleName sn) {
return sn.getIdentifier(); return sn.getIdentifier();
@@ -804,36 +798,33 @@ public class GenericEventDetector {
if (!(expr instanceof MethodInvocation mi)) return null; if (!(expr instanceof MethodInvocation mi)) return null;
// Trace back chain: MessageBuilder.withPayload(event).setHeader(...).build() Expression factoryPayload = ReactiveExpressionSupport.peelFactoryPayloadExpression(mi);
if (factoryPayload != null) {
String extracted = extractEventFromMessageBuilder(factoryPayload);
if (extracted != null) {
return extracted;
}
String resolved = constantResolver.resolve(factoryPayload, context);
if (resolved != null) {
return resolved;
}
Expression payloadExpr = factoryPayload;
if (payloadExpr instanceof CastExpression ce) {
payloadExpr = ce.getExpression();
}
if (payloadExpr instanceof SimpleName sn) {
String traced = extractEventFromMessageBuilder(payloadExpr);
if (traced != null) {
return traced;
}
return sn.getIdentifier();
}
return payloadExpr.toString();
}
MethodInvocation current = mi; MethodInvocation current = mi;
while (current != null) { while (current != null) {
String name = current.getName().getIdentifier(); if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) {
if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) {
Expression payloadExpr = (Expression) current.arguments().get(0);
// If it's Mono.just(msg), where msg is a variable
if ("just".equals(name)) {
String extracted = extractEventFromMessageBuilder(payloadExpr);
if (extracted != null) return extracted;
}
String resolved = constantResolver.resolve(payloadExpr, context);
if (resolved != null) return resolved;
// If not a constant, it might be a parameter like 'event'
if (payloadExpr instanceof CastExpression ce) {
payloadExpr = ce.getExpression();
}
if (payloadExpr instanceof SimpleName sn) {
String extracted = extractEventFromMessageBuilder(payloadExpr);
if (extracted != null) {
return extracted;
}
return sn.getIdentifier(); // Fall back to returning the parameter name
}
return payloadExpr.toString();
} else if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) {
// If the event is obtained by calling a method on a provider/supplier parameter, // If the event is obtained by calling a method on a provider/supplier parameter,
// return the provider's name so the call-graph engine can trace the lambda argument // return the provider's name so the call-graph engine can trace the lambda argument
String traced = extractEventFromMessageBuilder(sn); String traced = extractEventFromMessageBuilder(sn);

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.service; package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer; import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -139,21 +140,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
} }
private Expression unwrapMessageBuilder(Expression expr) { private Expression unwrapMessageBuilder(Expression expr) {
if (expr instanceof MethodInvocation mi) { Expression peeled = ReactiveExpressionSupport.peelFactoryPayloadExpression(expr);
MethodInvocation current = mi; return peeled != null ? peeled : expr;
while (current != null) {
String name = current.getName().getIdentifier();
if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) {
return (Expression) current.arguments().get(0);
}
Expression receiver = current.getExpression();
if (receiver instanceof MethodInvocation nextMi) {
current = nextMi;
} else {
current = null;
}
}
}
return expr;
} }
} }

View File

@@ -4,7 +4,6 @@ import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator; import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -21,6 +20,7 @@ public class PathBindingEvaluator {
private final VariableTracer variableTracer; private final VariableTracer variableTracer;
private final ConstantResolver constantResolver; private final ConstantResolver constantResolver;
private final TypeResolver typeResolver; private final TypeResolver typeResolver;
private final PathBindingExpressionResolver expressionResolver;
private final Map<String, Map<String, String>> traceBindingsCache = new HashMap<>(); private final Map<String, Map<String, String>> traceBindingsCache = new HashMap<>();
public PathBindingEvaluator( public PathBindingEvaluator(
@@ -28,10 +28,20 @@ public class PathBindingEvaluator {
VariableTracer variableTracer, VariableTracer variableTracer,
ConstantResolver constantResolver, ConstantResolver constantResolver,
TypeResolver typeResolver) { TypeResolver typeResolver) {
this(context, variableTracer, constantResolver, typeResolver, new PathBindingExpressionResolver(context, variableTracer, constantResolver));
}
PathBindingEvaluator(
CodebaseContext context,
VariableTracer variableTracer,
ConstantResolver constantResolver,
TypeResolver typeResolver,
PathBindingExpressionResolver expressionResolver) {
this.context = context; this.context = context;
this.variableTracer = variableTracer; this.variableTracer = variableTracer;
this.constantResolver = constantResolver; this.constantResolver = constantResolver;
this.typeResolver = typeResolver; this.typeResolver = typeResolver;
this.expressionResolver = expressionResolver;
} }
/** Visible for tests: bindings accumulated while walking a path forward (ignores constraint rejection). */ /** Visible for tests: bindings accumulated while walking a path forward (ignores constraint rejection). */
@@ -55,17 +65,22 @@ public class PathBindingEvaluator {
private Map<String, String> computeTraceBindings( private Map<String, String> computeTraceBindings(
List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) { List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
Map<String, String> bindings = new HashMap<>(); context.setAnalysisCallPath(path);
for (int i = 0; i < path.size() - 1; i++) { try {
String caller = path.get(i); Map<String, String> bindings = new HashMap<>();
String target = path.get(i + 1); for (int i = 0; i < path.size() - 1; i++) {
CallEdge edge = findEdge(caller, target, callGraph, pathFinder); String caller = path.get(i);
if (edge == null) { String target = path.get(i + 1);
continue; CallEdge edge = findEdge(caller, target, callGraph, pathFinder);
if (edge == null) {
continue;
}
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings);
} }
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings); return bindings;
} finally {
context.clearAnalysisCallPath();
} }
return bindings;
} }
private static String pathCacheKey(List<String> path) { private static String pathCacheKey(List<String> path) {
@@ -143,7 +158,24 @@ public class PathBindingEvaluator {
Map<String, List<CallEdge>> callGraph, Map<String, List<CallEdge>> callGraph,
CallGraphPathFinder pathFinder, CallGraphPathFinder pathFinder,
Map<String, String> bindings) { Map<String, String> bindings) {
Map<String, String> paramValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, pathIndex); if (FunctionalInterfaceTypes.isFunctionalSamMethod(target) && pathIndex >= 2) {
int paramIndex = variableTracer.findFunctionalParameterIndex(caller);
if (paramIndex < 0) {
paramIndex = 0;
}
String functionalCaller = path.get(pathIndex - 2);
CallEdge functionalEdge = CallSiteMatcher.selectEdge(
functionalCaller, caller, callGraph.get(functionalCaller), bindings);
List<String> constants = variableTracer.resolveConstantsFromCallSiteArgument(
functionalCaller, caller, paramIndex, functionalEdge);
for (String constant : constants) {
if (constant != null && !constant.isBlank()) {
bindings.putIfAbsent(constant, constant);
}
}
}
Map<String, String> paramValues = variableTracer.buildParameterValuesMap(
caller, target, callGraph, path, pathIndex, bindings);
mergeResolvedBindings(bindings, paramValues, caller); mergeResolvedBindings(bindings, paramValues, caller);
List<String> targetParams = typeResolver.getParameterNames(target); List<String> targetParams = typeResolver.getParameterNames(target);
@@ -153,6 +185,14 @@ public class PathBindingEvaluator {
for (int i = 0; i < edge.getArguments().size() && i < targetParams.size(); i++) { for (int i = 0; i < edge.getArguments().size() && i < targetParams.size(); i++) {
String arg = edge.getArguments().get(i); String arg = edge.getArguments().get(i);
String paramName = targetParams.get(i); String paramName = targetParams.get(i);
if (typeResolver != null
&& FunctionalInterfaceTypes.isFunctionalInterface(typeResolver.getParameterType(target, i))) {
List<String> constants = variableTracer.resolveConstantsFromCallSiteArgument(caller, target, i, edge);
if (constants.size() == 1) {
bindings.put(paramName, constants.get(0));
continue;
}
}
String resolved = resolveBindingValue(caller, arg, bindings); String resolved = resolveBindingValue(caller, arg, bindings);
if (shouldStoreBinding(paramName, resolved)) { if (shouldStoreBinding(paramName, resolved)) {
bindings.put(paramName, resolved); bindings.put(paramName, resolved);
@@ -197,7 +237,7 @@ public class PathBindingEvaluator {
if (traced.startsWith("\"") || isEnumLikeConstant(traced)) { if (traced.startsWith("\"") || isEnumLikeConstant(traced)) {
return traced; return traced;
} }
String fromCall = resolveMethodCallFromSource(callerFqn, rawValue, bindings); String fromCall = expressionResolver.resolveMethodCallFromSource(callerFqn, rawValue, bindings);
if (fromCall != null) { if (fromCall != null) {
return fromCall; return fromCall;
} }
@@ -208,155 +248,6 @@ public class PathBindingEvaluator {
return rawValue; return rawValue;
} }
private String resolveMethodCallFromSource(String callerFqn, String varName, Map<String, String> bindings) {
MethodDeclaration md = findMethodDeclaration(callerFqn);
if (md == null || md.getBody() == null) {
return null;
}
final Expression[] initializer = new Expression[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializer[0] = node.getInitializer();
}
return super.visit(node);
}
});
if (initializer[0] == null) {
return null;
}
return evaluateExpressionValue(initializer[0], callerFqn, bindings);
}
private String evaluateExpressionValue(Expression expr, String callerFqn, Map<String, String> bindings) {
if (expr instanceof StringLiteral sl) {
return "\"" + sl.getLiteralValue() + "\"";
}
if (expr instanceof QualifiedName qn) {
return qn.getFullyQualifiedName();
}
if (expr instanceof SimpleName sn) {
return bindings.getOrDefault(sn.getIdentifier(), sn.getIdentifier());
}
if (expr instanceof MethodInvocation mi) {
return evaluateMethodInvocationReturn(mi, callerFqn, bindings);
}
if (expr instanceof SwitchExpression se) {
return constantResolver.evaluateSwitchWithParams(se, bindings, context);
}
String resolved = constantResolver.resolve(expr, context);
return resolved;
}
private String evaluateMethodInvocationReturn(MethodInvocation mi, String callerFqn, Map<String, String> bindings) {
String ownerFqn = resolveMethodOwnerFqn(mi, callerFqn);
if (ownerFqn == null) {
return null;
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration owner = context.getTypeDeclaration(ownerFqn);
if (owner == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
Map<String, String> callBindings = new HashMap<>(bindings);
for (int i = 0; i < md.parameters().size() && i < mi.arguments().size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i);
String paramName = param.getName().getIdentifier();
String argValue = evaluateExpressionValue((Expression) mi.arguments().get(i), callerFqn, callBindings);
if (argValue != null) {
callBindings.put(paramName, argValue);
}
}
final String[] result = new String[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() instanceof SwitchExpression se) {
result[0] = constantResolver.evaluateSwitchWithParams(se, callBindings, context);
} else if (node.getExpression() != null) {
result[0] = evaluateExpressionValue(node.getExpression(), ownerFqn + "." + methodName, callBindings);
}
return super.visit(node);
}
});
return result[0];
}
private String resolveFieldTypeFqn(String callerFqn, String fieldName) {
if (callerFqn == null || !callerFqn.contains(".")) {
return null;
}
String className = callerFqn.substring(0, callerFqn.lastIndexOf('.'));
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
for (FieldDeclaration field : td.getFields()) {
for (Object fragmentObj : field.fragments()) {
VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj;
if (fragment.getName().getIdentifier().equals(fieldName)) {
IVariableBinding binding = fragment.resolveBinding();
if (binding != null && binding.getType() != null) {
return binding.getType().getErasure().getQualifiedName();
}
String simpleType = field.getType().toString();
TypeDeclaration resolved = context.getTypeDeclaration(simpleType);
if (resolved != null) {
return context.getFqn(resolved);
}
if (td.getRoot() instanceof CompilationUnit cu && cu.getPackage() != null) {
String pkg = cu.getPackage().getName().getFullyQualifiedName();
resolved = context.getTypeDeclaration(pkg + "." + simpleType);
if (resolved != null) {
return context.getFqn(resolved);
}
}
return simpleType;
}
}
}
return null;
}
private String resolveMethodOwnerFqn(MethodInvocation mi, String callerFqn) {
IMethodBinding methodBinding = mi.resolveMethodBinding();
if (methodBinding != null && methodBinding.getDeclaringClass() != null) {
return methodBinding.getDeclaringClass().getErasure().getQualifiedName();
}
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
return resolveFieldTypeFqn(callerFqn, sn.getIdentifier());
}
if (receiver instanceof FieldAccess fa) {
IVariableBinding fieldBinding = fa.resolveFieldBinding();
if (fieldBinding != null && fieldBinding.getType() != null) {
return fieldBinding.getType().getErasure().getQualifiedName();
}
return resolveFieldTypeFqn(callerFqn, fa.getName().getIdentifier());
}
return null;
}
private MethodDeclaration findMethodDeclaration(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
return context.findMethodDeclaration(td, methodName, true);
}
private static String normalizeLiteral(String value) { private static String normalizeLiteral(String value) {
if (value != null && value.startsWith("\"") && value.endsWith("\"")) { if (value != null && value.startsWith("\"") && value.endsWith("\"")) {
return value.substring(1, value.length() - 1); return value.substring(1, value.length() - 1);

View File

@@ -0,0 +1,221 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Resolves expression values under path-binding context using the dataflow pipeline
* ({@link VariableTracer} / {@link click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel})
* instead of a parallel AST mini-interpreter.
*/
public class PathBindingExpressionResolver {
private final CodebaseContext context;
private final VariableTracer variableTracer;
private final ConstantResolver constantResolver;
public PathBindingExpressionResolver(
CodebaseContext context,
VariableTracer variableTracer,
ConstantResolver constantResolver) {
this.context = context;
this.variableTracer = variableTracer;
this.constantResolver = constantResolver;
}
public String resolveMethodCallFromSource(String callerFqn, String varName, Map<String, String> bindings) {
MethodDeclaration md = findMethodDeclaration(callerFqn);
if (md == null || md.getBody() == null) {
return null;
}
final Expression[] initializer = new Expression[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializer[0] = node.getInitializer();
}
return super.visit(node);
}
});
if (initializer[0] == null) {
return null;
}
return resolve(initializer[0], callerFqn, bindings);
}
public String resolve(Expression expr, String callerFqn, Map<String, String> bindings) {
if (expr == null) {
return null;
}
Map<String, String> effectiveBindings = bindings == null ? Map.of() : new HashMap<>(bindings);
context.setAnalysisNamedBindings(effectiveBindings);
try {
return resolveInternal(expr, callerFqn, effectiveBindings);
} finally {
context.clearAnalysisNamedBindings();
}
}
private String resolveInternal(Expression expr, String callerFqn, Map<String, String> bindings) {
if (expr instanceof StringLiteral sl) {
return "\"" + sl.getLiteralValue() + "\"";
}
if (expr instanceof QualifiedName qn) {
return qn.getFullyQualifiedName();
}
if (expr instanceof SimpleName sn) {
return bindings.getOrDefault(sn.getIdentifier(), sn.getIdentifier());
}
if (expr instanceof SwitchExpression se) {
return constantResolver.evaluateSwitchWithParams(se, bindings, context);
}
if (expr instanceof MethodInvocation mi) {
return resolveMethodInvocationReturn(mi, callerFqn, bindings);
}
return resolveViaDataflow(expr);
}
private String resolveMethodInvocationReturn(MethodInvocation mi, String callerFqn, Map<String, String> bindings) {
String ownerFqn = resolveMethodOwnerFqn(mi, callerFqn);
if (ownerFqn == null) {
return null;
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration owner = context.getTypeDeclaration(ownerFqn);
if (owner == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true);
if (md == null) {
return null;
}
Map<String, String> callBindings = new HashMap<>(bindings);
for (int i = 0; i < md.parameters().size() && i < mi.arguments().size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i);
String paramName = param.getName().getIdentifier();
String argValue = resolveInternal((Expression) mi.arguments().get(i), callerFqn, callBindings);
if (argValue != null) {
callBindings.put(paramName, argValue);
}
}
context.setAnalysisNamedBindings(callBindings);
try {
return resolveViaDataflow(mi);
} finally {
context.setAnalysisNamedBindings(bindings);
}
}
private String resolveViaDataflow(Expression expr) {
Set<String> constants = new LinkedHashSet<>();
for (Expression def : variableTracer.traceVariableAll(expr)) {
String val = constantResolver.resolve(def, context);
if (val != null && !val.isBlank()) {
constants.add(val);
}
}
if (constants.size() == 1) {
return formatResolvedConstant(constants.iterator().next());
}
if (constants.isEmpty()) {
String direct = constantResolver.resolve(expr, context);
return direct != null ? formatResolvedConstant(direct) : null;
}
return null;
}
private static String formatResolvedConstant(String value) {
if (value == null) {
return null;
}
if (value.startsWith("ENUM_SET:")
|| value.startsWith("<SYMBOLIC:")
|| value.contains("SYMBOLIC:")
|| value.endsWith(".*>")) {
return value;
}
if (value.startsWith("\"") || AstUtils.isEnumLikeConstantName(value)) {
return value;
}
return "\"" + value + "\"";
}
private String resolveMethodOwnerFqn(MethodInvocation mi, String callerFqn) {
IMethodBinding methodBinding = mi.resolveMethodBinding();
if (methodBinding != null && methodBinding.getDeclaringClass() != null) {
return methodBinding.getDeclaringClass().getErasure().getQualifiedName();
}
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
return resolveFieldTypeFqn(callerFqn, sn.getIdentifier());
}
if (receiver instanceof FieldAccess fa) {
IVariableBinding fieldBinding = fa.resolveFieldBinding();
if (fieldBinding != null && fieldBinding.getType() != null) {
return fieldBinding.getType().getErasure().getQualifiedName();
}
return resolveFieldTypeFqn(callerFqn, fa.getName().getIdentifier());
}
return null;
}
private String resolveFieldTypeFqn(String callerFqn, String fieldName) {
if (callerFqn == null || !callerFqn.contains(".")) {
return null;
}
String className = callerFqn.substring(0, callerFqn.lastIndexOf('.'));
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
for (FieldDeclaration field : td.getFields()) {
for (Object fragmentObj : field.fragments()) {
VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj;
if (fragment.getName().getIdentifier().equals(fieldName)) {
IVariableBinding binding = fragment.resolveBinding();
if (binding != null && binding.getType() != null) {
return binding.getType().getErasure().getQualifiedName();
}
String simpleType = field.getType().toString();
TypeDeclaration resolved = context.getTypeDeclaration(simpleType);
if (resolved != null) {
return context.getFqn(resolved);
}
if (td.getRoot() instanceof CompilationUnit cu && cu.getPackage() != null) {
String pkg = cu.getPackage().getName().getFullyQualifiedName();
resolved = context.getTypeDeclaration(pkg + "." + simpleType);
if (resolved != null) {
return context.getFqn(resolved);
}
}
return simpleType;
}
}
}
return null;
}
private MethodDeclaration findMethodDeclaration(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
return context.findMethodDeclaration(td, methodName, true);
}
}

View File

@@ -9,6 +9,7 @@ import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver
import click.kamil.springstatemachineexporter.analysis.index.AccessorNaming; import click.kamil.springstatemachineexporter.analysis.index.AccessorNaming;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector; import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel; import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
@@ -18,6 +19,7 @@ public class VariableTracer {
private final CodebaseContext context; private final CodebaseContext context;
private final ConstantResolver constantResolver; private final ConstantResolver constantResolver;
private ConstantExtractor constantExtractor; private ConstantExtractor constantExtractor;
private TypeResolver typeResolver;
private final click.kamil.springstatemachineexporter.ast.common.DataFlowModel dataFlowModel; private final click.kamil.springstatemachineexporter.ast.common.DataFlowModel dataFlowModel;
private final Map<String, String> variableDeclaredTypeCache = new HashMap<>(); private final Map<String, String> variableDeclaredTypeCache = new HashMap<>();
@@ -34,6 +36,10 @@ public class VariableTracer {
this.constantExtractor = constantExtractor; this.constantExtractor = constantExtractor;
} }
public void setTypeResolver(TypeResolver typeResolver) {
this.typeResolver = typeResolver;
}
public void clearAnalysisCaches() { public void clearAnalysisCaches() {
variableDeclaredTypeCache.clear(); variableDeclaredTypeCache.clear();
if (dataFlowModel instanceof JdtDataFlowModel jdtDataFlowModel) { if (dataFlowModel instanceof JdtDataFlowModel jdtDataFlowModel) {
@@ -476,7 +482,33 @@ public class VariableTracer {
private record BranchAssignment(Expression expression, String constraint) {} 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) { public Map<String, String> buildParameterValuesMap(String caller, String target, Map<String, List<CallEdge>> callGraph, List<String> path, int pathIndex) {
return buildParameterValuesMap(caller, target, callGraph, path, pathIndex, Map.of());
}
public Map<String, String> buildParameterValuesMap(
String caller,
String target,
Map<String, List<CallEdge>> callGraph,
List<String> path,
int pathIndex,
Map<String, String> bindings) {
Map<String, String> paramValues = new HashMap<>(); Map<String, String> paramValues = new HashMap<>();
context.setAnalysisCallPath(path);
try {
return buildParameterValuesMapInternal(caller, target, callGraph, path, pathIndex, bindings, paramValues);
} finally {
context.clearAnalysisCallPath();
}
}
private Map<String, String> buildParameterValuesMapInternal(
String caller,
String target,
Map<String, List<CallEdge>> callGraph,
List<String> path,
int pathIndex,
Map<String, String> bindings,
Map<String, String> paramValues) {
List<CallEdge> edges = callGraph.get(caller); List<CallEdge> edges = callGraph.get(caller);
boolean hasEdge = false; boolean hasEdge = false;
@@ -509,38 +541,51 @@ public class VariableTracer {
return paramValues; return paramValues;
} }
// We also want to support heuristic matches using a helper if isHeuristicMatch is not directly available, but let's check CallEdge selectedEdge = CallSiteMatcher.selectEdge(caller, target, edges, bindings);
// We can check if name equals or if it resolves to target if (selectedEdge == null) {
for (CallEdge edge : edges) { return paramValues;
if (edge.getTargetMethod().equals(target) || isHeuristicMatch(edge.getTargetMethod(), target)) { }
List<String> args = edge.getArguments();
if (args == null || args.isEmpty()) break;
int lastDot = target.lastIndexOf('.'); List<String> args = selectedEdge.getArguments();
if (lastDot < 0) break; if (args == null || args.isEmpty()) {
String className = target.substring(0, lastDot); return paramValues;
String methodName = target.substring(lastDot + 1); }
TypeDeclaration td = context.getTypeDeclaration(className); int lastDot = target.lastIndexOf('.');
if (td == null) break; if (lastDot < 0) {
return paramValues;
}
String className = target.substring(0, lastDot);
String methodName = target.substring(lastDot + 1);
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); TypeDeclaration td = context.getTypeDeclaration(className);
if (md == null) break; if (td == null) {
return paramValues;
}
List<String> paramNames = new ArrayList<>(); MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
for (Object paramObj : md.parameters()) { if (md == null) {
SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj; return paramValues;
paramNames.add(param.getName().getIdentifier()); }
List<String> paramNames = new ArrayList<>();
for (Object paramObj : md.parameters()) {
SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj;
paramNames.add(param.getName().getIdentifier());
}
int minSize = Math.min(paramNames.size(), args.size());
for (int j = 0; j < minSize; j++) {
String argValue = args.get(j);
String resolvedArgValue = resolveArgumentValue(argValue, caller, path, pathIndex, callGraph);
if (typeResolver != null
&& FunctionalInterfaceTypes.isFunctionalInterface(typeResolver.getParameterType(target, j))) {
List<String> constants = resolveConstantsFromCallSiteArgument(caller, target, j, selectedEdge);
if (constants.size() == 1) {
resolvedArgValue = constants.get(0);
} }
int minSize = Math.min(paramNames.size(), args.size());
for (int j = 0; j < minSize; j++) {
String argValue = args.get(j);
String resolvedArgValue = resolveArgumentValue(argValue, caller, path, pathIndex, callGraph);
paramValues.put(paramNames.get(j), resolvedArgValue);
}
break;
} }
paramValues.put(paramNames.get(j), resolvedArgValue);
} }
return paramValues; return paramValues;
} }
@@ -698,4 +743,86 @@ public class VariableTracer {
} }
return null; return null;
} }
/**
* Resolves enum/constants passed at a call site using source AST + {@link JdtDataFlowModel}.
* Used for {@code Supplier}, {@code Runnable}, and similar functional parameters.
*/
public List<String> resolveConstantsFromCallSiteArgument(String caller, String callee, int paramIndex) {
return resolveConstantsFromCallSiteArgument(caller, callee, paramIndex, null);
}
public List<String> resolveConstantsFromCallSiteArgument(
String caller, String callee, int paramIndex, CallEdge edge) {
if (constantExtractor == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
Expression sourceArg = CallSiteMatcher.findCallSiteArgumentExpression(
caller, callee, paramIndex, edge, context);
if (sourceArg != null) {
resolveConstantsFromExpressionViaDataflow(sourceArg, constants);
}
return constants;
}
public int findFunctionalParameterIndex(String methodFqn) {
if (methodFqn == null || typeResolver == null) {
return -1;
}
for (int i = 0; i < 8; i++) {
String paramType = typeResolver.getParameterType(methodFqn, i);
if (paramType == null) {
break;
}
if (FunctionalInterfaceTypes.isFunctionalInterface(paramType)) {
return i;
}
}
return -1;
}
public static boolean isFunctionalSamMethod(String methodFqn) {
return FunctionalInterfaceTypes.isFunctionalSamMethod(methodFqn);
}
public void resolveConstantsViaDataflow(Expression expression, List<String> constants) {
resolveConstantsFromExpressionViaDataflow(expression, constants);
}
public List<String> resolveConstantsFromSourceText(
String expressionText, List<String> searchMethods, List<String> callPath) {
if (expressionText == null || expressionText.isBlank()) {
return List.of();
}
context.setAnalysisCallPath(callPath);
try {
Expression anchored = AstUtils.findExpressionInMethods(searchMethods, expressionText, context);
Expression expr = anchored != null ? anchored : AstUtils.parseExpression(expressionText);
if (expr == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
resolveConstantsViaDataflow(expr, constants);
return constants.stream()
.filter(c -> c != null && !c.isBlank())
.distinct()
.toList();
} finally {
context.clearAnalysisCallPath();
}
}
private void resolveConstantsFromExpressionViaDataflow(Expression expression, List<String> constants) {
for (Expression def : dataFlowModel.getReachingDefinitions(expression)) {
constantExtractor.extractConstantsFromExpression(def, constants);
}
if (constants.isEmpty()) {
constantExtractor.extractConstantsFromExpression(expression, constants);
}
}
Expression findCallSiteArgumentExpression(String caller, String callee, int paramIndex) {
return CallSiteMatcher.findCallSiteArgumentExpression(caller, callee, paramIndex, null, context);
}
} }

View File

@@ -1,8 +1,15 @@
package click.kamil.springstatemachineexporter.ast.common; package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.Annotation; import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.ArrayType; import org.eclipse.jdt.core.dom.ArrayType;
import org.eclipse.jdt.core.dom.ASTNode; import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.MemberValuePair; import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodDeclaration; import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.Name; import org.eclipse.jdt.core.dom.Name;
@@ -17,6 +24,9 @@ import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.Type; import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration; import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.List;
import java.util.Map;
public final class AstUtils { public final class AstUtils {
private AstUtils() { private AstUtils() {
} }
@@ -263,4 +273,145 @@ public final class AstUtils {
combined.append(')'); combined.append(')');
return combined.toString(); return combined.toString();
} }
public static Expression parseExpression(String snippet) {
if (snippet == null || snippet.isBlank()) {
return null;
}
ASTParser parser = ASTParser.newParser(AST.JLS17);
Map<String, String> options = JavaCore.getOptions();
JavaCore.setComplianceOptions(JavaCore.VERSION_17, options);
parser.setCompilerOptions(options);
parser.setKind(ASTParser.K_EXPRESSION);
parser.setSource(snippet.toCharArray());
try {
ASTNode node = parser.createAST(null);
if (node instanceof Expression expression) {
return expression;
}
if (node instanceof CompilationUnit) {
ASTParser fallbackParser = ASTParser.newParser(AST.JLS17);
fallbackParser.setCompilerOptions(options);
fallbackParser.setKind(ASTParser.K_COMPILATION_UNIT);
fallbackParser.setSource(("class A { Object o = " + snippet + "; }").toCharArray());
CompilationUnit cu = (CompilationUnit) fallbackParser.createAST(null);
if (!cu.types().isEmpty() && cu.types().get(0) instanceof TypeDeclaration td) {
if (!td.bodyDeclarations().isEmpty() && td.bodyDeclarations().get(0) instanceof FieldDeclaration fd) {
if (!fd.fragments().isEmpty() && fd.fragments().get(0) instanceof VariableDeclarationFragment vdf) {
return vdf.getInitializer();
}
}
}
}
} catch (RuntimeException ignored) {
return null;
}
return null;
}
public static Expression findExpressionInMethod(String methodFqn, String expressionText, CodebaseContext context) {
if (methodFqn == null || expressionText == null || !methodFqn.contains(".") || context == null) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
final Expression[] match = new Expression[1];
md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(org.eclipse.jdt.core.dom.MethodInvocation node) {
if (expressionText.equals(node.toString())) {
match[0] = node;
return false;
}
return super.visit(node);
}
@Override
public boolean visit(org.eclipse.jdt.core.dom.SuperMethodInvocation node) {
if (expressionText.equals(node.toString())) {
match[0] = node;
return false;
}
return super.visit(node);
}
@Override
public boolean visit(QualifiedName node) {
if (expressionText.equals(node.toString())) {
match[0] = node;
return false;
}
return super.visit(node);
}
@Override
public boolean visit(org.eclipse.jdt.core.dom.SimpleName node) {
if (expressionText.equals(node.getIdentifier())) {
match[0] = node;
return false;
}
return super.visit(node);
}
});
return match[0];
}
public static org.eclipse.jdt.core.dom.MethodInvocation findMethodInvocationInMethod(
String methodFqn, String expressionText, CodebaseContext context) {
Expression found = findExpressionInMethod(methodFqn, expressionText, context);
return found instanceof org.eclipse.jdt.core.dom.MethodInvocation mi ? mi : null;
}
public static Expression findExpressionInMethods(
List<String> methodFqns, String expressionText, CodebaseContext context) {
if (methodFqns == null || expressionText == null) {
return null;
}
for (String methodFqn : methodFqns) {
Expression found = findExpressionInMethod(methodFqn, expressionText, context);
if (found != null) {
return found;
}
}
return null;
}
public static String combineExpressionText(String base, String suffix) {
if (suffix == null || suffix.isEmpty()) {
return base;
}
return base + suffix;
}
public static String toParseSnippet(String boundValue) {
if (boundValue == null || boundValue.isBlank()) {
return boundValue;
}
if (boundValue.startsWith("\"") && boundValue.endsWith("\"")) {
return boundValue;
}
if (isEnumLikeConstantName(boundValue)) {
return boundValue;
}
return "\"" + boundValue.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
public static boolean isEnumLikeConstantName(String value) {
if (value == null || value.isEmpty()) {
return false;
}
int dot = value.lastIndexOf('.');
if (dot <= 0 || dot >= value.length() - 1) {
return false;
}
return Character.isUpperCase(value.charAt(dot + 1));
}
} }

View File

@@ -177,6 +177,32 @@ public class CodebaseContext {
private String[] classpath = new String[0]; private String[] classpath = new String[0];
private String[] sourcepath = new String[0]; private String[] sourcepath = new String[0];
private boolean resolveBindings = false; private boolean resolveBindings = false;
private transient List<String> analysisCallPath;
private transient Map<String, String> analysisNamedBindings;
public void setAnalysisCallPath(List<String> path) {
this.analysisCallPath = path;
}
public List<String> getAnalysisCallPath() {
return analysisCallPath;
}
public void clearAnalysisCallPath() {
this.analysisCallPath = null;
}
public void setAnalysisNamedBindings(Map<String, String> bindings) {
this.analysisNamedBindings = bindings;
}
public Map<String, String> getAnalysisNamedBindings() {
return analysisNamedBindings;
}
public void clearAnalysisNamedBindings() {
this.analysisNamedBindings = null;
}
public Map<String, Map<String, String>> getProperties() { public Map<String, Map<String, String>> getProperties() {
return Collections.unmodifiableMap(allProperties); return Collections.unmodifiableMap(allProperties);

View File

@@ -1,7 +1,12 @@
package click.kamil.springstatemachineexporter.ast.common; package click.kamil.springstatemachineexporter.ast.common;
import click.kamil.springstatemachineexporter.analysis.index.AccessorInlining; import click.kamil.springstatemachineexporter.analysis.index.AccessorInlining;
import click.kamil.springstatemachineexporter.analysis.index.AccessorKind;
import click.kamil.springstatemachineexporter.analysis.index.AccessorNaming;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.index.GetterChainEdge;
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget; import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
import java.util.*; import java.util.*;
@@ -15,20 +20,6 @@ public class JdtDataFlowModel implements DataFlowModel {
private final Map<MethodDeclaration, ReachingDefinitions> rdCache = new HashMap<>(); private final Map<MethodDeclaration, ReachingDefinitions> rdCache = new HashMap<>();
private final Map<ASTNode, Map<IVariableBinding, Expression>> objectFieldBindings = new HashMap<>(); private final Map<ASTNode, Map<IVariableBinding, Expression>> objectFieldBindings = new HashMap<>();
private static final ThreadLocal<List<String>> CURRENT_PATH = new ThreadLocal<>();
public static void setCurrentPath(List<String> path) {
CURRENT_PATH.set(path);
}
public static List<String> getCurrentPath() {
return CURRENT_PATH.get();
}
public static void clearCurrentPath() {
CURRENT_PATH.remove();
}
public JdtDataFlowModel(CodebaseContext context) { public JdtDataFlowModel(CodebaseContext context) {
this.context = context; this.context = context;
} }
@@ -64,6 +55,13 @@ public class JdtDataFlowModel implements DataFlowModel {
return List.of(); return List.of();
} }
if (expr instanceof LambdaExpression lambda) {
List<Expression> resolved = resolveFunctionalBoundExpression(
lambda, visited, paramBindings, instanceFieldBindings, depth);
visited.remove(expr);
return resolved;
}
// 1. Handle Ternary / Conditional Expression // 1. Handle Ternary / Conditional Expression
if (expr instanceof ConditionalExpression ce) { if (expr instanceof ConditionalExpression ce) {
String condVal = resolveValue(ce.getExpression(), context); String condVal = resolveValue(ce.getExpression(), context);
@@ -111,6 +109,18 @@ public class JdtDataFlowModel implements DataFlowModel {
} }
} }
Map<String, String> namedBindings = context.getAnalysisNamedBindings();
if (namedBindings != null && namedBindings.containsKey(sn.getIdentifier())) {
String boundValue = namedBindings.get(sn.getIdentifier());
Expression synthetic = AstUtils.parseExpression(AstUtils.toParseSnippet(boundValue));
if (synthetic != null) {
List<Expression> resolved = getReachingDefinitions(
synthetic, visited, paramBindings, instanceFieldBindings, depth + 1);
visited.remove(expr);
return resolved;
}
}
// Fallback: Local Reaching Definitions // Fallback: Local Reaching Definitions
MethodDeclaration md = findEnclosingMethod(sn); MethodDeclaration md = findEnclosingMethod(sn);
ReachingDefinitions rd = getReachingDefinitionsForMethod(md); ReachingDefinitions rd = getReachingDefinitionsForMethod(md);
@@ -193,13 +203,59 @@ public class JdtDataFlowModel implements DataFlowModel {
// 4. Handle Method Invocations (Static Factory methods / Transforming Getters) // 4. Handle Method Invocations (Static Factory methods / Transforming Getters)
if (expr instanceof MethodInvocation mi) { if (expr instanceof MethodInvocation mi) {
String mName = mi.getName().getIdentifier(); Expression factoryPayload = ReactiveExpressionSupport.peelFactoryPayloadExpression(mi);
if (("just".equals(mName) || "withPayload".equals(mName) || "success".equals(mName)) && !mi.arguments().isEmpty()) { if (factoryPayload != null) {
List<Expression> resolved = getReachingDefinitions((Expression) mi.arguments().get(0), visited, paramBindings, instanceFieldBindings, depth + 1); List<Expression> resolved = getReachingDefinitions(
factoryPayload, visited, paramBindings, instanceFieldBindings, depth + 1);
visited.remove(expr); visited.remove(expr);
return resolved; return resolved;
} }
String mName = mi.getName().getIdentifier();
if (LibraryUnwrapRegistry.isReactiveTransformMethod(mName) && !mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0);
if (arg instanceof LambdaExpression lambda && lambda.parameters().size() == 1) {
Expression source = ReactiveExpressionSupport.peelFactoryPayloadExpression(mi.getExpression());
if (source != null) {
Object parameter = lambda.parameters().get(0);
if (parameter instanceof SingleVariableDeclaration svd) {
IVariableBinding paramBinding = svd.resolveBinding();
if (paramBinding != null) {
Map<IVariableBinding, Expression> transformBindings = new HashMap<>(paramBindings);
transformBindings.put(paramBinding, source);
Expression bodyExpression = ReactiveExpressionSupport.lambdaBodyExpression(lambda);
if (bodyExpression != null) {
Expression nestedFactory = ReactiveExpressionSupport.peelFactoryPayloadExpression(bodyExpression);
if (nestedFactory != null) {
List<Expression> resolved = getReachingDefinitions(
nestedFactory, visited, transformBindings, instanceFieldBindings, depth + 1);
if (!resolved.isEmpty()) {
visited.remove(expr);
return resolved;
}
}
List<Expression> resolved = getReachingDefinitions(
bodyExpression, visited, transformBindings, instanceFieldBindings, depth + 1);
if (!resolved.isEmpty()) {
visited.remove(expr);
return resolved;
}
}
}
}
}
}
}
if (isFunctionalSamInvocation(mi)) {
List<Expression> functional = resolveFunctionalInterfaceInvocation(
mi, visited, paramBindings, instanceFieldBindings, depth);
if (!functional.isEmpty()) {
visited.remove(expr);
return functional;
}
}
List<Expression> inlined = tryInlineAccessorInvocation( List<Expression> inlined = tryInlineAccessorInvocation(
mi, new HashSet<>(visited), paramBindings, instanceFieldBindings, depth); mi, new HashSet<>(visited), paramBindings, instanceFieldBindings, depth);
if (!inlined.isEmpty()) { if (!inlined.isEmpty()) {
@@ -253,6 +309,9 @@ public class JdtDataFlowModel implements DataFlowModel {
} }
} }
} }
} else {
collectSideEffectExpressions(
md.getBody(), results, visited, newParamBindings, target.fieldBindings, depth);
} }
} }
} }
@@ -352,6 +411,11 @@ public class JdtDataFlowModel implements DataFlowModel {
receiverDefs = getReachingDefinitions(receiver, visited, paramBindings, instanceFieldBindings, depth + 1); receiverDefs = getReachingDefinitions(receiver, visited, paramBindings, instanceFieldBindings, depth + 1);
accessor = AccessorInlining.lookupAccessor(context, mi, receiverDefs); accessor = AccessorInlining.lookupAccessor(context, mi, receiverDefs);
if (accessor.isEmpty()) { if (accessor.isEmpty()) {
List<Expression> anonymous = inlineAnonymousFromReceiverDefs(
methodName, receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
if (!anonymous.isEmpty()) {
return anonymous;
}
return List.of(); return List.of();
} }
} }
@@ -380,6 +444,11 @@ public class JdtDataFlowModel implements DataFlowModel {
} }
if (accessor.isEmpty()) { if (accessor.isEmpty()) {
List<Expression> anonymous = inlineAnonymousFromReceiverDefs(
methodName, receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
if (!anonymous.isEmpty()) {
return anonymous;
}
return List.of(); return List.of();
} }
if (!accessor.get().isGetter()) { if (!accessor.get().isGetter()) {
@@ -394,9 +463,72 @@ public class JdtDataFlowModel implements DataFlowModel {
return inlineRecordComponentRead(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth); return inlineRecordComponentRead(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
} }
List<Expression> indexedHop = tryIndexedGetterChainHop(
mi, accessor.get(), visited, paramBindings, instanceFieldBindings, depth);
if (!indexedHop.isEmpty()) {
return indexedHop;
}
return inlineAccessorGetter(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth); return inlineAccessorGetter(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
} }
/**
* Scan-time {@link GetterChainEdge} fast path for trivial field-default getter hops.
* Skipped when the receiver may have been mutated before this use site.
*/
private List<Expression> tryIndexedGetterChainHop(
MethodInvocation mi,
AccessorSummary accessor,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (accessor.kind() != AccessorKind.GETTER || hasReceiverMutationsBeforeUse(mi)) {
return List.of();
}
Optional<GetterChainEdge> edge = context.getGetterChainIndex().lookup(accessor.ownerFqn(), accessor.methodName());
if (edge.isEmpty() || edge.get().fieldInitializerCic() == null) {
return List.of();
}
ClassInstanceCreation fieldDefaultCic = edge.get().fieldInitializerCic();
return getReachingDefinitions(
fieldDefaultCic, visited, paramBindings, instanceFieldBindings, depth + 1);
}
private boolean hasReceiverMutationsBeforeUse(MethodInvocation useSite) {
Expression receiver = useSite.getExpression();
if (!(receiver instanceof SimpleName receiverName)) {
return false;
}
MethodDeclaration enclosingMethod = findEnclosingMethod(useSite);
if (enclosingMethod == null || enclosingMethod.getBody() == null) {
return false;
}
int usePosition = useSite.getStartPosition();
final boolean[] mutationFound = { false };
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (mutationFound[0] || node.getStartPosition() >= usePosition) {
return false;
}
if (!(node.getExpression() instanceof SimpleName callReceiver)) {
return true;
}
if (!callReceiver.getIdentifier().equals(receiverName.getIdentifier())) {
return true;
}
String callee = node.getName().getIdentifier();
if (AccessorNaming.isBeanStyleAccessorName(callee) && callee.startsWith("set")) {
mutationFound[0] = true;
return false;
}
return true;
}
});
return mutationFound[0];
}
private List<Expression> inlineConstantGetter( private List<Expression> inlineConstantGetter(
AccessorSummary accessor, AccessorSummary accessor,
Set<ASTNode> visited, Set<ASTNode> visited,
@@ -623,11 +755,257 @@ public class JdtDataFlowModel implements DataFlowModel {
if (fieldValue != null) { if (fieldValue != null) {
results.addAll(getReachingDefinitions(fieldValue, visited, paramBindings, fieldBindings, depth + 1)); results.addAll(getReachingDefinitions(fieldValue, visited, paramBindings, fieldBindings, depth + 1));
} }
if (results.isEmpty() && cic.getAnonymousClassDeclaration() != null) {
results.addAll(inlineAnonymousGetterReturns(
cic, accessor.methodName(), visited, paramBindings, fieldBindings, depth));
}
} else {
TypeDeclaration receiverType = resolveExpressionTypeDeclaration(
candidate, paramBindings, instanceFieldBindings);
if (receiverType != null) {
Map<IVariableBinding, Expression> typeFieldBindings =
buildTypeDefaultFieldBindings(receiverType);
if (receiver instanceof SimpleName sn && candidate == sn) {
applyFlowSensitiveMutationsForLocalReceiver(
sn, mi, accessor, typeFieldBindings, paramBindings, visited, depth);
}
Expression fieldValue = readFieldValue(accessor, typeFieldBindings, mi);
if (fieldValue != null) {
results.addAll(getReachingDefinitions(
fieldValue, visited, paramBindings, typeFieldBindings, depth + 1));
}
if (results.isEmpty()) {
results.addAll(inlineGetterMethodReturns(
accessor, receiverType, visited, paramBindings, typeFieldBindings, depth));
}
if (results.isEmpty() && receiverType.isInterface()) {
results.addAll(inlineConcreteAccessorFromReceiverDefs(
accessor, receiverDefs, visited, paramBindings, typeFieldBindings, depth));
}
}
} }
} }
return results; return results;
} }
private List<Expression> inlineAnonymousGetterReturns(
ClassInstanceCreation cic,
String methodName,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> fieldBindings,
int depth) {
if (cic.getAnonymousClassDeclaration() == null || methodName == null) {
return List.of();
}
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
if (declObj instanceof MethodDeclaration methodDeclaration
&& methodName.equals(methodDeclaration.getName().getIdentifier())
&& methodDeclaration.getBody() != null) {
List<Expression> results = new ArrayList<>();
for (ReturnStatement rs : findReturnStatements(methodDeclaration.getBody())) {
if (rs.getExpression() != null) {
results.addAll(getReachingDefinitions(
rs.getExpression(), visited, paramBindings, fieldBindings, depth + 1));
}
}
return results;
}
}
return List.of();
}
private List<Expression> inlineConcreteAccessorFromReceiverDefs(
AccessorSummary accessor,
List<Expression> receiverDefs,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> fieldBindings,
int depth) {
List<Expression> results = new ArrayList<>();
for (Expression receiverDef : receiverDefs) {
String concreteFqn = concreteReceiverTypeFqn(receiverDef);
if (concreteFqn == null || concreteFqn.equals(accessor.ownerFqn())) {
continue;
}
TypeDeclaration concreteType = context.getTypeDeclaration(concreteFqn);
if (concreteType == null) {
continue;
}
Map<IVariableBinding, Expression> concreteBindings = buildTypeDefaultFieldBindings(concreteType);
if (receiverDef instanceof ClassInstanceCreation cic) {
concreteBindings = getOrCreateFieldBindings(cic, paramBindings, fieldBindings);
}
Expression fieldValue = readFieldValue(accessor, concreteBindings, null);
if (fieldValue != null) {
results.addAll(getReachingDefinitions(
fieldValue, visited, paramBindings, concreteBindings, depth + 1));
}
if (results.isEmpty()) {
results.addAll(inlineGetterMethodReturns(
accessor, concreteType, visited, paramBindings, concreteBindings, depth));
}
}
return results;
}
private List<Expression> inlineAnonymousFromReceiverDefs(
String methodName,
List<Expression> receiverDefs,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (receiverDefs == null || methodName == null) {
return List.of();
}
for (Expression receiverDef : receiverDefs) {
if (receiverDef instanceof ClassInstanceCreation cic) {
List<Expression> inlined = inlineAnonymousGetterReturns(
cic, methodName, visited, paramBindings, instanceFieldBindings, depth);
if (!inlined.isEmpty()) {
return inlined;
}
}
}
return List.of();
}
private void applyFlowSensitiveMutationsForLocalReceiver(
SimpleName receiverName,
MethodInvocation useSite,
AccessorSummary accessor,
Map<IVariableBinding, Expression> fieldBindings,
Map<IVariableBinding, Expression> paramBindings,
Set<ASTNode> visited,
int depth) {
MethodDeclaration enclosingMethod = findEnclosingMethod(useSite);
if (enclosingMethod == null) {
return;
}
ASTNode defNode = findVariableDefinitionNode(enclosingMethod, receiverName.getIdentifier());
if (defNode instanceof SingleVariableDeclaration) {
String fieldName = accessor.fieldName();
if (fieldName == null || fieldName.isEmpty()) {
return;
}
String setterName = "set" + Character.toUpperCase(fieldName.charAt(0))
+ fieldName.substring(1);
Expression setterArg = findLocalSetterArgument(
enclosingMethod,
receiverName.getIdentifier(),
accessor.methodName(),
setterName,
accessor.fieldName());
if (setterArg != null) {
IVariableBinding fieldBinding = findFieldVariableBinding(
accessor.declaringFqn(), accessor.fieldName(), useSite);
if (fieldBinding != null) {
List<Expression> resolved = getReachingDefinitions(
setterArg, visited, paramBindings, fieldBindings, depth + 1);
fieldBindings.put(fieldBinding, resolved.isEmpty() ? setterArg : resolved.get(0));
}
}
return;
}
if (defNode != null) {
applyIntermediateMutations(
receiverName, defNode, useSite, enclosingMethod, fieldBindings, paramBindings, visited, depth);
}
}
private ASTNode findVariableDefinitionNode(MethodDeclaration methodDeclaration, String varName) {
for (Object paramObj : methodDeclaration.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd
&& varName.equals(svd.getName().getIdentifier())) {
return svd;
}
}
if (methodDeclaration.getBody() == null) {
return null;
}
final ASTNode[] found = new ASTNode[1];
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragmentObj : node.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& varName.equals(fragment.getName().getIdentifier())) {
found[0] = node;
return false;
}
}
return super.visit(node);
}
});
return found[0];
}
private List<Expression> inlineGetterMethodReturns(
AccessorSummary accessor,
TypeDeclaration ownerType,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> fieldBindings,
int depth) {
MethodDeclaration getter = context.findMethodDeclaration(ownerType, accessor.methodName(), true);
if (getter == null || getter.getBody() == null) {
return List.of();
}
List<Expression> results = new ArrayList<>();
for (ReturnStatement rs : findReturnStatements(getter.getBody())) {
if (rs.getExpression() != null) {
results.addAll(getReachingDefinitions(
rs.getExpression(), visited, paramBindings, fieldBindings, depth + 1));
}
}
return results;
}
private Map<IVariableBinding, Expression> buildTypeDefaultFieldBindings(TypeDeclaration typeDeclaration) {
Map<IVariableBinding, Expression> fieldBindings = new HashMap<>();
if (typeDeclaration == null) {
return fieldBindings;
}
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
for (Object fragmentObj : fieldDeclaration.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getInitializer() != null) {
IVariableBinding binding = fragment.resolveBinding();
if (binding != null) {
fieldBindings.put(binding, fragment.getInitializer());
}
}
}
}
return fieldBindings;
}
private TypeDeclaration resolveExpressionTypeDeclaration(
Expression expr,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings) {
if (expr == null) {
return null;
}
ITypeBinding typeBinding = expr.resolveTypeBinding();
if (typeBinding == null) {
IVariableBinding variableBinding = getVariableBinding(expr);
if (variableBinding != null && variableBinding.getType() != null) {
typeBinding = variableBinding.getType();
}
}
if (typeBinding == null) {
return null;
}
String fqn = typeBinding.getErasure().getQualifiedName();
TypeDeclaration td = context.getTypeDeclaration(fqn);
if (td == null && expr.getRoot() instanceof CompilationUnit cu) {
td = context.getTypeDeclaration(fqn, cu);
}
return td;
}
private void applyFlowSensitiveMutationsBeforeUse( private void applyFlowSensitiveMutationsBeforeUse(
SimpleName receiverName, SimpleName receiverName,
MethodInvocation useSite, MethodInvocation useSite,
@@ -1110,7 +1488,7 @@ public class JdtDataFlowModel implements DataFlowModel {
if (declaringClassFqn != null && !declaringClassFqn.isEmpty()) { if (declaringClassFqn != null && !declaringClassFqn.isEmpty()) {
// Find all known implementation subclasses in the codebase // Find all known implementation subclasses in the codebase
List<String> implClassFqns = context.getImplementations(declaringClassFqn); List<String> implClassFqns = context.getImplementations(declaringClassFqn);
List<String> currentPath = CURRENT_PATH.get(); List<String> currentPath = activeAnalysisPath();
if (currentPath != null && implClassFqns != null && !implClassFqns.isEmpty()) { if (currentPath != null && implClassFqns != null && !implClassFqns.isEmpty()) {
Set<String> contextTypes = getCompatibleContextTypes(currentPath, declaringClassFqn); Set<String> contextTypes = getCompatibleContextTypes(currentPath, declaringClassFqn);
List<String> filteredImpls = new ArrayList<>(); List<String> filteredImpls = new ArrayList<>();
@@ -1168,6 +1546,10 @@ public class JdtDataFlowModel implements DataFlowModel {
return targets; return targets;
} }
private List<String> activeAnalysisPath() {
return context.getAnalysisCallPath();
}
private boolean isExpressionWrapping(Expression outer, Expression inner) { private boolean isExpressionWrapping(Expression outer, Expression inner) {
Expression current = outer; Expression current = outer;
while (current != null) { while (current != null) {
@@ -1360,6 +1742,9 @@ public class JdtDataFlowModel implements DataFlowModel {
if (cfg == null) return; if (cfg == null) return;
ControlFlowGraph.CfgNode defCfgNode = findCfgNode(cfg, defNode); ControlFlowGraph.CfgNode defCfgNode = findCfgNode(cfg, defNode);
if (defCfgNode == null && defNode instanceof SingleVariableDeclaration) {
defCfgNode = cfg.getEntryNode();
}
ControlFlowGraph.CfgNode useCfgNode = findCfgNode(cfg, useNode); ControlFlowGraph.CfgNode useCfgNode = findCfgNode(cfg, useNode);
if (defCfgNode != null && useCfgNode != null) { if (defCfgNode != null && useCfgNode != null) {
@@ -1463,6 +1848,145 @@ public class JdtDataFlowModel implements DataFlowModel {
return findMethodDeclarationInType(mb.getDeclaringClass(), mb); return findMethodDeclarationInType(mb.getDeclaringClass(), mb);
} }
private static boolean isFunctionalSamInvocation(MethodInvocation mi) {
String methodName = mi.getName().getIdentifier();
if (!isKnownSamMethodName(methodName)) {
return false;
}
if ("get".equals(methodName) && !mi.arguments().isEmpty()) {
return false;
}
Expression receiver = mi.getExpression();
if (receiver == null) {
return false;
}
ITypeBinding typeBinding = receiver.resolveTypeBinding();
if (typeBinding == null) {
return false;
}
ITypeBinding erasure = typeBinding.getErasure();
return isKnownFunctionalInterfaceName(erasure.getQualifiedName());
}
private static boolean isKnownSamMethodName(String methodName) {
return switch (methodName) {
case "run", "get", "call", "accept", "apply", "test" -> true;
default -> false;
};
}
private static boolean isKnownFunctionalInterfaceName(String fqn) {
if (fqn == null || fqn.isBlank()) {
return false;
}
String simple = fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
return switch (simple) {
case "Runnable", "Supplier", "Function", "Callable", "Consumer", "BiConsumer", "Predicate", "BiFunction" ->
true;
default -> fqn.contains("Supplier") || fqn.contains("Function") || fqn.contains("Callable");
};
}
private List<Expression> resolveFunctionalInterfaceInvocation(
MethodInvocation mi,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
IBinding binding = sn.resolveBinding();
if (binding instanceof IVariableBinding variableBinding && paramBindings.containsKey(variableBinding)) {
return resolveFunctionalBoundExpression(
paramBindings.get(variableBinding), visited, paramBindings, instanceFieldBindings, depth + 1);
}
}
List<Expression> receiverDefs = getReachingDefinitions(receiver, visited, paramBindings, instanceFieldBindings, depth + 1);
List<Expression> results = new ArrayList<>();
for (Expression receiverDef : receiverDefs) {
results.addAll(resolveFunctionalBoundExpression(
receiverDef, visited, paramBindings, instanceFieldBindings, depth + 1));
}
return results;
}
private List<Expression> resolveFunctionalBoundExpression(
Expression bound,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (bound == null) {
return List.of();
}
if (bound instanceof LambdaExpression lambda) {
ASTNode body = lambda.getBody();
if (body instanceof Expression bodyExpr) {
List<Expression> sideEffectArgs = collectVoidCallArgumentDefinitions(
bodyExpr, visited, paramBindings, instanceFieldBindings, depth);
if (!sideEffectArgs.isEmpty()) {
return sideEffectArgs;
}
return getReachingDefinitions(bodyExpr, visited, paramBindings, instanceFieldBindings, depth);
}
if (body instanceof Block block) {
List<Expression> results = new ArrayList<>();
collectSideEffectExpressions(block, results, visited, paramBindings, instanceFieldBindings, depth);
return results;
}
}
return getReachingDefinitions(bound, visited, paramBindings, instanceFieldBindings, depth);
}
private List<Expression> collectVoidCallArgumentDefinitions(
Expression bodyExpr,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (!(bodyExpr instanceof MethodInvocation mi) || !isVoidMethodInvocation(mi)) {
return List.of();
}
List<Expression> results = new ArrayList<>();
for (Object argObj : mi.arguments()) {
results.addAll(getReachingDefinitions(
(Expression) argObj, visited, paramBindings, instanceFieldBindings, depth + 1));
}
return results;
}
private static boolean isVoidMethodInvocation(MethodInvocation mi) {
IMethodBinding binding = mi.resolveMethodBinding();
return binding != null && binding.getReturnType() != null && "void".equals(binding.getReturnType().getName());
}
private void collectSideEffectExpressions(
Block body,
List<Expression> results,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> fieldBindings,
int depth) {
if (body == null) {
return;
}
body.accept(new ASTVisitor() {
@Override
public boolean visit(ExpressionStatement node) {
Expression statementExpr = node.getExpression();
if (statementExpr instanceof MethodInvocation nested) {
for (Object argObj : nested.arguments()) {
results.addAll(getReachingDefinitions(
(Expression) argObj, visited, paramBindings, fieldBindings, depth + 1));
}
}
results.addAll(getReachingDefinitions(
statementExpr, visited, paramBindings, fieldBindings, depth + 1));
return super.visit(node);
}
});
}
private List<ReturnStatement> findReturnStatements(ASTNode node) { private List<ReturnStatement> findReturnStatements(ASTNode node) {
List<ReturnStatement> returns = new ArrayList<>(); List<ReturnStatement> returns = new ArrayList<>();
node.accept(new ASTVisitor() { node.accept(new ASTVisitor() {

View File

@@ -48,77 +48,57 @@ class CallChainLinkPolicyTest {
} }
@Test @Test
void shouldAllowTrustedMachineScopedValueOfWiden() { void shouldAllowEndpointNarrowSingleConcretePoly() {
TriggerPoint trigger = TriggerPoint.builder() TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true) .ambiguous(true)
.eventTypeFqn("com.example.OrderEvent") .eventTypeFqn("com.example.OrderEvent")
.event("OrderEvent.valueOf(eventStr)") .event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP")) .polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.build(); .build();
assertThat(CallChainLinkPolicy.isEndpointNarrowPolyEvidence(trigger)).isTrue();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isTrue(); assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isTrue();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isFalse(); assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isFalse();
} }
@Test @Test
void shouldAllowTrustedWidenUsingMachineEventTypeWhenTriggerTypeIsNull() { void shouldFailClosedWhenMultipleConcretePolyCandidatesRemain() {
TriggerPoint trigger = TriggerPoint.builder() TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true) .ambiguous(true)
.event("OrderEvent.valueOf(eventStr)") .event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP")) .polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP"))
.build(); .build();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden( assertThat(CallChainLinkPolicy.isEndpointNarrowPolyEvidence(
trigger, "com.example.OrderEvent")).isTrue();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.OrderEvent")).isFalse(); trigger, "com.example.OrderEvent")).isFalse();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
trigger, "com.example.OrderEvent")).isFalse();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.OrderEvent")).isTrue();
} }
@Test @Test
void shouldResolveLinkWhenTrustedWidenPassesUsingMachineEventType() { void shouldResolveLinkWhenEndpointNarrowPolyHasMatches() {
TriggerPoint trigger = TriggerPoint.builder() TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(false) .ambiguous(true)
.event("OrderEvent.valueOf(eventStr)") .event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP")) .polymorphicEvents(List.of("OrderEvent.PAY"))
.build(); .build();
var matched = List.of(
click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder()
.event("com.example.order.OrderEvent.PAY")
.build());
assertThat(CallChainLinkPolicy.resolveLinkResolution( assertThat(CallChainLinkPolicy.resolveLinkResolution(
trigger, trigger,
List.of(), matched,
false, false,
"com.example.OrderEvent")).isEqualTo(LinkResolution.NO_MATCH); "com.example.order.OrderEvent")).isEqualTo(LinkResolution.RESOLVED);
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.OrderEvent")).isFalse();
} }
@Test @Test
void shouldNotTrustImportStylePolymorphicWidenWithoutPackage() { void shouldNotResolveWhenBroadPolyWidenHasMatches() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
.build();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isFalse();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isTrue();
}
@Test
void shouldTrustImportStylePolymorphicWidenWhenMachineEventTypeIsKnown() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
.build();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
trigger, "com.example.order.OrderEvent")).isTrue();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.order.OrderEvent")).isFalse();
}
@Test
void shouldResolveWhenTrustedWidenHasMatchesDespiteAmbiguousCallGraphFlag() {
TriggerPoint trigger = TriggerPoint.builder() TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true) .ambiguous(true)
.event("OrderEvent.valueOf(eventStr)") .event("OrderEvent.valueOf(eventStr)")
@@ -134,6 +114,20 @@ class CallChainLinkPolicyTest {
trigger, trigger,
matched, matched,
false, false,
"com.example.order.OrderEvent")).isEqualTo(LinkResolution.RESOLVED); "com.example.order.OrderEvent")).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldTrustImportStyleSingleConcretePolyWhenMachineEventTypeIsKnown() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.build();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
trigger, "com.example.order.OrderEvent")).isTrue();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.order.OrderEvent")).isFalse();
} }
} }

View File

@@ -0,0 +1,372 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
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.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class CallChainPolyNarrowerTest {
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
@Test
void shouldNarrowWidePolyUsingExpandedRestPathSegment() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/machine/ORDER/transition/PAY")
.className("com.example.MachineController")
.methodName("transition")
.build())
.methodChain(List.of(
"com.example.MachineController.transition",
"com.example.StateMachineDispatcher.fireOrder",
"com.example.StateMachine.send"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
.containsExactly("com.example.OrderEvent.PAY");
}
@Test
void shouldNarrowWidePolyUsingRichDispatcherMethodName() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/orders/rich/pay")
.className("com.example.RichOrderController")
.methodName("payViaRichEvent")
.build())
.methodChain(List.of(
"com.example.RichOrderController.payViaRichEvent",
"com.example.CommandGateway.payOrderViaRichEvent",
"com.example.CentralEventDispatcher.orderPayViaRichEvent",
"com.example.CentralEventDispatcher.sendOrderEvent"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("richEvent.getType()")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void shouldNarrowUsingDottedCommandKeyPath() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/commands/order.pay")
.className("com.example.GenericCommandController")
.methodName("execute")
.build())
.methodChain(List.of(
"com.example.GenericCommandController.execute",
"com.example.CommandGateway.executeViaMapper",
"com.example.CentralEventDispatcher.orderPay",
"com.example.CentralEventDispatcher.sendOrderEvent"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(actionKey)")
.constraint("\"order.pay\".equalsIgnoreCase(commandKey) && command == ORDER_PAY")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void shouldNarrowWidePolyUsingSendEventLiteralInCallerMethod(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class OrderController {
CentralEventDispatcher dispatcher;
public void pay() { dispatcher.orderPay(); }
}
class CentralEventDispatcher {
void orderPay() { sendOrderEvent(OrderEvent.PAY); }
void orderShip() { sendOrderEvent(OrderEvent.SHIP); }
private void sendOrderEvent(OrderEvent event) {
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/orders/pay")
.className("com.example.OrderController")
.methodName("pay")
.build())
.methodChain(List.of(
"com.example.OrderController.pay",
"com.example.CentralEventDispatcher.orderPay",
"com.example.CentralEventDispatcher.sendOrderEvent",
"com.example.StateMachine.sendEvent"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("event")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, context, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void shouldNarrowWidePolyUsingRichEventParameterType(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class RichOrderController {
CentralEventDispatcher dispatcher;
public void payViaRichEvent() {
dispatcher.orderPayViaRichEvent(new PayRichOrderEvent());
}
}
class PayRichOrderEvent {
OrderEvent getType() { return OrderEvent.PAY; }
}
class CentralEventDispatcher {
void orderPayViaRichEvent(PayRichOrderEvent event) {
sendOrderEvent(event.getType());
}
private void sendOrderEvent(OrderEvent event) {
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/orders/rich/pay")
.className("com.example.RichOrderController")
.methodName("payViaRichEvent")
.build())
.methodChain(List.of(
"com.example.RichOrderController.payViaRichEvent",
"com.example.CentralEventDispatcher.orderPayViaRichEvent",
"com.example.CentralEventDispatcher.sendOrderEvent",
"com.example.StateMachine.sendEvent"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("event.getType()")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, context, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void shouldNarrowWidePolyWhenConfigEventTypeFqnIsMissing() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/machine/ORDER/transition/PAY")
.className("com.example.MachineController")
.methodName("transition")
.build())
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void shouldFailClosedWhenWidePolyHasNoEndpointSpecificHint() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/machine/{machineType}/transition/{event}")
.className("com.example.MachineController")
.methodName("transition")
.build())
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.external(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
assertThat(linked.getLinkResolution()).isIn(
LinkResolution.AMBIGUOUS_WIDEN,
LinkResolution.UNRESOLVED_EXTERNAL);
}
private static Transition transition(String source, String target, String eventFqn) {
Transition transition = new Transition();
transition.setSourceStates(List.of(State.of(source, "com.example.OrderState." + source)));
transition.setTargetStates(List.of(State.of(target, "com.example.OrderState." + target)));
transition.setEvent(Event.of(eventFqn.substring(eventFqn.lastIndexOf('.') + 1), eventFqn));
return transition;
}
}

View File

@@ -221,6 +221,84 @@ class TransitionLinkerEnricherTest {
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isFalse(); assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isFalse();
} }
@Test
void shouldLinkWhenSameEventAppearsFromMultipleSourcesWithinSameStateEnum() {
Transition payFromNew = new Transition();
payFromNew.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
payFromNew.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
payFromNew.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
Transition payFromPending = new Transition();
payFromPending.setSourceStates(List.of(State.of("PENDING", "com.example.order.OrderState.PENDING")));
payFromPending.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
payFromPending.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("com.example.order.OrderEvent.PAY"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.eventTypeFqn("com.example.order.OrderEvent")
.stateTypeFqn("com.example.order.OrderState")
.transitions(List.of(payFromNew, payFromPending))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).hasSize(2);
assertThat(updatedChain.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
}
@Test
void shouldNotLinkAllTransitionsWhenPolyContainsManyEnumConstants() {
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
pay.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
pay.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setSourceStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
ship.setTargetStates(List.of(State.of("SHIPPED", "com.example.order.OrderState.SHIPPED")));
ship.setEvent(Event.of("SHIP", "com.example.order.OrderEvent.SHIP"));
Transition cancel = new Transition();
cancel.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
cancel.setTargetStates(List.of(State.of("CANCELLED", "com.example.order.OrderState.CANCELLED")));
cancel.setEvent(Event.of("CANCEL", "com.example.order.OrderEvent.CANCEL"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderEvent.SHIP",
"com.example.order.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.eventTypeFqn("com.example.order.OrderEvent")
.stateTypeFqn("com.example.order.OrderState")
.transitions(List.of(pay, ship, cancel))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
assertThat(updatedChain.getLinkResolution()).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
}
@Test @Test
void shouldNotInferSourceStateWhenDifferentEnumsShareSameConstantName() { void shouldNotInferSourceStateWhenDifferentEnumsShareSameConstantName() {
Transition t1 = new Transition(); Transition t1 = new Transition();

View File

@@ -53,7 +53,7 @@ class TrustedEnumWidenLinkingRegressionTest {
.event("OrderEvent.valueOf(eventStr)") .event("OrderEvent.valueOf(eventStr)")
.ambiguous(true) .ambiguous(true)
.external(false) .external(false)
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP")) .polymorphicEvents(List.of("OrderEvent.PAY"))
.build()) .build())
.build(); .build();
@@ -71,7 +71,7 @@ class TrustedEnumWidenLinkingRegressionTest {
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSizeGreaterThanOrEqualTo(1); assertThat(linked.getMatchedTransitions()).hasSizeGreaterThanOrEqualTo(1);
assertThat(linked.getTriggerPoint().getPolymorphicEvents()) assertThat(linked.getTriggerPoint().getPolymorphicEvents())
.containsExactly("com.example.order.OrderEvent.PAY", "com.example.order.OrderEvent.SHIP"); .containsExactly("com.example.order.OrderEvent.PAY");
} }
@Test @Test
@@ -102,7 +102,7 @@ class TrustedEnumWidenLinkingRegressionTest {
.event("OrderEvent.valueOf(eventStr)") .event("OrderEvent.valueOf(eventStr)")
.ambiguous(true) .ambiguous(true)
.external(false) .external(false)
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP")) .polymorphicEvents(List.of("OrderEvent.PAY"))
.build()) .build())
.build(); .build();

View File

@@ -91,6 +91,8 @@ class PipelineRefactorTest {
void shouldRecognizeReactiveReceiversAndFactoryMethods() { void shouldRecognizeReactiveReceiversAndFactoryMethods() {
assertThat(LibraryUnwrapRegistry.isUnwrapReceiverType("Mono")).isTrue(); assertThat(LibraryUnwrapRegistry.isUnwrapReceiverType("Mono")).isTrue();
assertThat(LibraryUnwrapRegistry.isUnwrapMethodName("just")).isTrue(); assertThat(LibraryUnwrapRegistry.isUnwrapMethodName("just")).isTrue();
assertThat(LibraryUnwrapRegistry.isReactiveFactoryMethod("withPayload")).isTrue();
assertThat(LibraryUnwrapRegistry.isReactiveTransformMethod("flatMap")).isTrue();
assertThat(LibraryUnwrapRegistry.shouldStopUnwrap("buildPayload")).isTrue(); assertThat(LibraryUnwrapRegistry.shouldStopUnwrap("buildPayload")).isTrue();
assertThat(LibraryUnwrapRegistry.isEventSetterMethodName("eventType")).isTrue(); assertThat(LibraryUnwrapRegistry.isEventSetterMethodName("eventType")).isTrue();
} }

View File

@@ -647,8 +647,7 @@ public class ConstantResolverTest {
MethodInvocation getCall = (MethodInvocation) ((VariableDeclarationFragment) fd.fragments().get(0)).getInitializer(); MethodInvocation getCall = (MethodInvocation) ((VariableDeclarationFragment) fd.fragments().get(0)).getInitializer();
ConstantExtractor extractor = new ConstantExtractor(context, new ConstantResolver(), mi -> null); ConstantExtractor extractor = new ConstantExtractor(context, new ConstantResolver(), mi -> null);
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath( context.setAnalysisCallPath(List.of("com.example.ApiController.dispatch"));
List.of("com.example.ApiController.dispatch"));
try { try {
ConstantResolver resolver = new ConstantResolver(); ConstantResolver resolver = new ConstantResolver();
SimpleName routesName = (SimpleName) getCall.getExpression(); SimpleName routesName = (SimpleName) getCall.getExpression();
@@ -660,7 +659,7 @@ public class ConstantResolverTest {
extractor.extractConstantsFromExpression(getCall, constants); extractor.extractConstantsFromExpression(getCall, constants);
assertThat(constants).contains("OrderEvent.PAY", "OrderEvent.SHIP"); assertThat(constants).contains("OrderEvent.PAY", "OrderEvent.SHIP");
} finally { } finally {
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath(); context.clearAnalysisCallPath();
} }
} }

View File

@@ -433,6 +433,17 @@ class MachineEnumCanonicalizerTest {
assertThat(only.rawName()).isEqualTo("OrderState.NEW"); assertThat(only.rawName()).isEqualTo("OrderState.NEW");
} }
@Test
void shouldNotThrowWhenStringMachineStateHasTypePrefixOnly() {
String stateTypeFqn = "com.company.project.OrderState";
State state = State.of("PAID", stateTypeFqn + ".");
State canonical = MachineEnumCanonicalizer.canonicalizeState(state, "String");
assertThat(canonical).isNotNull();
assertThat(canonical.fullIdentifier()).isEqualTo(stateTypeFqn + ".");
}
private static void writeSampleConfig(Path tempDir) throws IOException { private static void writeSampleConfig(Path tempDir) throws IOException {
Path orderPkg = tempDir.resolve("com/example/order"); Path orderPkg = tempDir.resolve("com/example/order");
Path configPkg = tempDir.resolve("com/example/config"); Path configPkg = tempDir.resolve("com/example/config");

View File

@@ -0,0 +1,184 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class RichEventIsCorrectPredicateTest {
@Test
void shouldFilterEnumConstantsByRichEventIsCorrectPredicate(@TempDir Path tempDir) throws Exception {
CodebaseContext context = scanProject(tempDir);
List<String> all = List.of(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP",
"com.example.order.OrderCommand.META");
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.service.AbstractOrderService")
.methodName("dispatch")
.build();
List<String> filtered = RichEventPredicateEvaluator.filterEnumConstants(
all, "myEvent.isCorrect()", "com.example.order.OrderCommand", trigger, context);
assertThat(filtered).containsExactly(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP");
}
@Test
void shouldInferPolymorphicEventsForRichEventConstraint(@TempDir Path tempDir) throws Exception {
CodebaseContext context = scanProject(tempDir);
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.service.AbstractOrderService")
.methodName("dispatch")
.event("myEvent.getType()")
.constraint("myEvent.isCorrect()")
.external(true)
.ambiguous(true)
.build();
MachineTypes types = new MachineTypes(null, "com.example.order.OrderCommand");
List<Transition> transitions = List.of(
transition("OrderCommand.PAY", "NEW", "PAID"),
transition("OrderCommand.SHIP", "PAID", "SHIPPED"));
TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
trigger, types, context, transitions, true);
assertThat(enriched.getPolymorphicEvents())
.containsExactlyInAnyOrder(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP");
assertThat(enriched.isAmbiguous()).isTrue();
}
@Test
void shouldLinkTransitionsWhenConstraintUsesRichEventPredicate(@TempDir Path tempDir) throws Exception {
CodebaseContext context = scanProject(tempDir);
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.className("com.example.service.AbstractOrderService")
.methodName("dispatch")
.event("myEvent.getType()")
.polymorphicEvents(List.of(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP",
"com.example.order.OrderCommand.META"))
.constraint("myEvent.isCorrect()")
.ambiguous(true)
.external(true)
.build())
.build();
Transition pay = transition("OrderCommand.PAY", "NEW", "PAID");
Transition ship = transition("OrderCommand.SHIP", "PAID", "SHIPPED");
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.eventTypeFqn("com.example.order.OrderCommand")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, context, null);
TriggerPoint tp = result.getMetadata().getCallChains().get(0).getTriggerPoint();
assertThat(tp.getPolymorphicEvents())
.containsExactlyInAnyOrder(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP");
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2);
}
private static CodebaseContext scanProject(Path tempDir) throws Exception {
Path javaRoot = tempDir.resolve("src/main/java/com/example");
Files.createDirectories(javaRoot.resolve("order"));
Files.createDirectories(javaRoot.resolve("domain"));
Files.createDirectories(javaRoot.resolve("service"));
Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }");
Files.writeString(javaRoot.resolve("order/OrderCommand.java"), """
package com.example.order;
public enum OrderCommand { PAY, SHIP, META }
""");
Files.writeString(javaRoot.resolve("domain/DomainEvent.java"), """
package com.example.domain;
import com.example.order.OrderCommand;
public interface DomainEvent {
OrderCommand getType();
boolean isCorrect();
}
""");
Files.writeString(javaRoot.resolve("domain/PayEvent.java"), """
package com.example.domain;
import com.example.order.OrderCommand;
public class PayEvent implements DomainEvent {
@Override
public OrderCommand getType() { return OrderCommand.PAY; }
@Override
public boolean isCorrect() { return true; }
}
""");
Files.writeString(javaRoot.resolve("domain/ShipEvent.java"), """
package com.example.domain;
import com.example.order.OrderCommand;
public class ShipEvent implements DomainEvent {
@Override
public OrderCommand getType() { return OrderCommand.SHIP; }
@Override
public boolean isCorrect() { return true; }
}
""");
Files.writeString(javaRoot.resolve("domain/MetaEvent.java"), """
package com.example.domain;
import com.example.order.OrderCommand;
public class MetaEvent implements DomainEvent {
@Override
public OrderCommand getType() { return OrderCommand.META; }
@Override
public boolean isCorrect() { return false; }
}
""");
Files.writeString(javaRoot.resolve("service/AbstractOrderService.java"), """
package com.example.service;
import com.example.domain.DomainEvent;
import com.example.order.OrderCommand;
public abstract class AbstractOrderService {
protected void dispatch(DomainEvent myEvent) {
if (myEvent.isCorrect()) {
fire(myEvent.getType());
}
}
protected abstract void fire(OrderCommand event);
}
""");
CodebaseContext context = new CodebaseContext();
context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString()));
context.scan(tempDir);
return context;
}
private static Transition transition(String event, String source, String target) {
Transition transition = new Transition();
transition.setEvent(Event.of(event, event));
transition.setSourceStates(List.of(State.of(source, source)));
transition.setTargetStates(List.of(State.of(target, target)));
return transition;
}
}

View File

@@ -0,0 +1,100 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class CallSiteMatcherTest {
@Test
void shouldMatchCallSiteArgumentToSpecificEdgeWhenMultipleCallsExist(@TempDir Path tempDir) throws IOException {
Files.writeString(tempDir.resolve("App.java"), """
package com.example;
import java.util.function.Supplier;
public enum OrderCommand { PAY, SHIP }
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) { this.service = service; }
public void both() {
service.fireEvent(() -> OrderCommand.PAY);
service.fireEvent(() -> OrderCommand.SHIP);
}
}
class OrderService {
void fireEvent(Supplier<OrderCommand> provider) {}
}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
CallEdge payEdge = new CallEdge(
"com.example.OrderService.fireEvent",
List.of("() -> OrderCommand.PAY"));
CallEdge shipEdge = new CallEdge(
"com.example.OrderService.fireEvent",
List.of("() -> OrderCommand.SHIP"));
MethodInvocation payInvocation = CallSiteMatcher.findMatchingInvocation(
"com.example.OrderController.both",
"com.example.OrderService.fireEvent",
payEdge,
context);
MethodInvocation shipInvocation = CallSiteMatcher.findMatchingInvocation(
"com.example.OrderController.both",
"com.example.OrderService.fireEvent",
shipEdge,
context);
assertThat(payInvocation).isNotNull();
assertThat(shipInvocation).isNotNull();
assertThat(payInvocation).isNotSameAs(shipInvocation);
assertThat(payInvocation.arguments().get(0)).isInstanceOf(LambdaExpression.class);
assertThat(shipInvocation.arguments().get(0)).isInstanceOf(LambdaExpression.class);
VariableTracer tracer = new VariableTracer(context, context.getConstantResolver());
tracer.setTypeResolver(new TypeResolver(context));
tracer.setConstantExtractor(new ConstantExtractor(context, context.getConstantResolver(), null));
assertThat(tracer.resolveConstantsFromCallSiteArgument(
"com.example.OrderController.both",
"com.example.OrderService.fireEvent",
0,
payEdge)).containsExactly("OrderCommand.PAY");
assertThat(tracer.resolveConstantsFromCallSiteArgument(
"com.example.OrderController.both",
"com.example.OrderService.fireEvent",
0,
shipEdge)).containsExactly("OrderCommand.SHIP");
}
@Test
void shouldSelectEdgeUsingPathBindings(@TempDir Path tempDir) throws IOException {
CallEdge payEdge = new CallEdge(
"com.example.OrderService.fireEvent",
List.of("() -> OrderCommand.PAY"));
CallEdge shipEdge = new CallEdge(
"com.example.OrderService.fireEvent",
List.of("() -> OrderCommand.SHIP"));
CallEdge selected = CallSiteMatcher.selectEdge(
"com.example.OrderController.both",
"com.example.OrderService.fireEvent",
List.of(payEdge, shipEdge),
Map.of("event", "OrderCommand.SHIP"));
assertThat(selected).isSameAs(shipEdge);
}
}

View File

@@ -0,0 +1,277 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Proves {@link JdtDataFlowModel} + anchored source AST resolves getter chains that detached
* re-parsing cannot, without relying on the call-graph accessor fallback tier.
*/
class DataFlowGetterEnablesMoreTest {
private CodebaseContext context;
private VariableTracer variableTracer;
private ConstantExtractor constantExtractor;
private JdtDataFlowModel dataFlowModel;
@BeforeEach
void setUp(@TempDir Path tempDir) throws IOException {
context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
ConstantResolver constantResolver = context.getConstantResolver();
variableTracer = new VariableTracer(context, constantResolver);
constantExtractor = new ConstantExtractor(context, constantResolver, null);
variableTracer.setConstantExtractor(constantExtractor);
dataFlowModel = new JdtDataFlowModel(context);
}
@Test
void shouldResolveChainedParameterGetterViaAnchoredDataflow(@TempDir Path tempDir) throws IOException {
scan(tempDir, """
package com.example;
public class OrderController {
public void processOrderEvent(EventWrapper wrapper) {
wrapper.getEvent().getType();
}
}
class EventWrapper {
public RichEvent getEvent() { return new RichEvent(); }
}
class RichEvent {
public OrderEvents getType() { return OrderEvents.PAY; }
}
enum OrderEvents { PAY }
""");
String methodFqn = "com.example.OrderController.processOrderEvent";
String expressionText = "wrapper.getEvent().getType()";
assertThat(resolveViaDataflow(expressionText, methodFqn))
.anyMatch(value -> value.contains("PAY"));
assertThat(resolveViaDetachedParse(expressionText))
.isEmpty();
}
@Test
void shouldResolveSuperGetterFieldViaAnchoredDataflow(@TempDir Path tempDir) throws IOException {
scan(tempDir, """
package com.example;
class ChildController extends ParentController {
void process() {
super.getEvent();
}
}
class ParentController {
private OrderEvent event = OrderEvent.PAY;
OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
""");
String methodFqn = "com.example.ChildController.process";
String expressionText = "super.getEvent()";
assertThat(resolveViaDataflow(expressionText, methodFqn))
.anyMatch(value -> value.contains("PAY"));
}
@Test
void shouldResolveDeepFieldBackedChainViaAnchoredDataflow(@TempDir Path tempDir) throws IOException {
scan(tempDir, """
package com.example;
class CentralDispatcher {
void fire(Order order) {
order.getPayload().getEvent().getCode();
}
}
class Order {
private Payload payload = new Payload();
Payload getPayload() { return payload; }
}
class Payload {
private Event event = new Event();
Event getEvent() { return event; }
}
class Event {
private OrderEvent code = OrderEvent.PAY;
OrderEvent getCode() { return code; }
}
enum OrderEvent { PAY, SHIP }
""");
String methodFqn = "com.example.CentralDispatcher.fire";
String expressionText = "order.getPayload().getEvent().getCode()";
assertThat(resolveViaDataflow(expressionText, methodFqn))
.anyMatch(value -> value.contains("PAY"));
}
@Test
void shouldResolveInterfaceMiddleHopViaAnchoredDataflow(@TempDir Path tempDir) throws IOException {
scan(tempDir, """
package com.example;
class ApiController {
Outer outer;
void pay() {
outer.getInner().getPayload().getEvent();
}
}
interface Inner {
Payload getPayload();
}
class InnerImpl implements Inner {
private Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
class Outer {
public Inner getInner() { return new InnerImpl(); }
}
class Payload {
private OrderEvent event = OrderEvent.PAY;
public OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
""");
String methodFqn = "com.example.ApiController.pay";
String expressionText = "outer.getInner().getPayload().getEvent()";
assertThat(resolveViaDataflow(expressionText, methodFqn))
.anyMatch(value -> value.contains("PAY"));
}
@Test
void shouldResolveAnonymousClassGetterViaAnchoredDataflow(@TempDir Path tempDir) throws IOException {
scan(tempDir, """
package com.example;
class Runner {
void run() {
Handler handler = new Handler() {
public OrderEvent getEvent() { return OrderEvent.PAY; }
};
handler.getEvent();
}
}
interface Handler {
OrderEvent getEvent();
}
enum OrderEvent { PAY, SHIP }
""");
String methodFqn = "com.example.Runner.run";
String expressionText = "handler.getEvent()";
assertThat(resolveViaDataflow(expressionText, methodFqn))
.anyMatch(value -> value.contains("PAY"));
}
@Test
void shouldResolveSetterBeforeGetterOnParameterViaDataflow(@TempDir Path tempDir) throws IOException {
scan(tempDir, """
package com.example;
class Runner {
static class Payload {
private String event = "DEFAULT";
public void setEvent(String event) { this.event = event; }
public String getEvent() { return event; }
}
void run(Payload payload) {
payload.setEvent("PAY");
payload.getEvent();
}
}
""");
String methodFqn = "com.example.Runner.run";
String expressionText = "payload.getEvent()";
assertThat(resolveViaDataflow(expressionText, methodFqn))
.anyMatch(value -> value.contains("PAY"));
}
@Test
void shouldResolveChainedGetterViaJdtDataFlowModelOnAnchoredAst(@TempDir Path tempDir) throws IOException {
scan(tempDir, """
package com.example;
public class Runner {
public void run(EventWrapper wrapper) {
OrderEvents value = wrapper.getEvent().getType();
}
}
class EventWrapper {
public RichEvent getEvent() { return new RichEvent(); }
}
class RichEvent {
public OrderEvents getType() { return OrderEvents.PAY; }
}
enum OrderEvents { PAY }
""");
MethodInvocation chain = findMethodInvocation(
"com.example.Runner", "run", "wrapper.getEvent().getType()");
assertThat(chain).isNotNull();
String resolved = dataFlowModel.resolveValue(chain, context);
assertThat(resolved).contains("PAY");
Expression orphan = AstUtils.parseExpression("wrapper.getEvent().getType()");
assertThat(dataFlowModel.resolveValue(orphan, context))
.isEqualTo("wrapper.getEvent().getType()");
}
private List<String> resolveViaDataflow(String expressionText, String methodFqn) {
List<String> searchMethods = List.of(methodFqn);
List<String> callPath = List.of(methodFqn);
return variableTracer.resolveConstantsFromSourceText(expressionText, searchMethods, callPath);
}
private List<String> resolveViaDetachedParse(String expressionText) {
Expression orphan = AstUtils.parseExpression(expressionText);
if (orphan == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
for (Expression def : dataFlowModel.getReachingDefinitions(orphan)) {
if (def != null && !def.toString().equals(orphan.toString())) {
constantExtractor.extractConstantsFromExpression(def, constants);
}
}
return constants;
}
private MethodInvocation findMethodInvocation(String classFqn, String methodName, String expressionText) {
Expression found = AstUtils.findExpressionInMethod(classFqn + "." + methodName, expressionText, context);
return found instanceof MethodInvocation mi ? mi : null;
}
private void scan(Path tempDir, String source) throws IOException {
Files.writeString(tempDir.resolve("App.java"), source);
context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
ConstantResolver constantResolver = context.getConstantResolver();
variableTracer = new VariableTracer(context, constantResolver);
constantExtractor = new ConstantExtractor(context, constantResolver, null);
variableTracer.setConstantExtractor(constantExtractor);
dataFlowModel = new JdtDataFlowModel(context);
}
}

View File

@@ -149,6 +149,8 @@ class DispatcherEndpointTest {
TriggerPoint linkedTrigger = result.getMetadata().getCallChains().get(0).getTriggerPoint(); TriggerPoint linkedTrigger = result.getMetadata().getCallChains().get(0).getTriggerPoint();
assertThat(linkedTrigger.getPolymorphicEvents()) assertThat(linkedTrigger.getPolymorphicEvents())
.contains("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP"); .contains("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP");
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2); assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
assertThat(result.getMetadata().getCallChains().get(0).getLinkResolution())
.isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
} }
} }

View File

@@ -67,14 +67,12 @@ class DispatcherPolyCeilingPipelineTest {
CallChain linked = linkChain( CallChain linked = linkChain(
context, bloatedChain, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship); context, bloatedChain, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship);
assertPolyWithinTransitions(linked, 2);
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
assertThat(linked.getTriggerPoint().getPolymorphicEvents()) assertThat(linked.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder(EVENT_TYPE + ".PAY", EVENT_TYPE + ".SHIP"); .containsExactly(EVENT_TYPE + ".PAY");
assertMatchedWithinTransitions(linked, 2); assertMatchedWithinTransitions(linked, 1);
assertThat(linked.getMatchedTransitions()) assertThat(linked.getMatchedTransitions())
.extracting(MatchedTransition::getEvent) .extracting(MatchedTransition::getEvent)
.containsExactlyInAnyOrder(EVENT_TYPE + ".PAY", EVENT_TYPE + ".SHIP"); .containsExactly(EVENT_TYPE + ".PAY");
} }
@Test @Test

View File

@@ -0,0 +1,121 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.service.ExportService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.StreamSupport;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Audit dedicated and expanded REST endpoints across dispatcher samples: none of the
* known concrete endpoints should remain {@code AMBIGUOUS_WIDEN}.
*/
class ExportLinkResolutionAuditTest {
@Test
void enterpriseOrderMachineKnownEndpointsShouldNotBeAmbiguousWiden(@TempDir Path tempDir) throws Exception {
JsonNode orderMachine = exportMachine(
tempDir, "state_machines/state_machine_enterprise", "OrderStateMachineConfiguration");
List<String> mustResolve = List.of(
"POST /api/machine/order/pay",
"POST /api/machine/order/ship",
"POST /api/machine/ORDER/transition/PAY",
"POST /api/machine/ORDER/transition/SHIP");
for (String endpoint : mustResolve) {
JsonNode chain = findChainByEndpoint(orderMachine, endpoint);
assertThat(chain).as("chain for %s", endpoint).isNotNull();
assertThat(chain.path("linkResolution").asText())
.as("linkResolution for %s", endpoint)
.isEqualTo("RESOLVED");
assertThat(chain.path("matchedTransitions").isArray()).isTrue();
assertThat(chain.path("matchedTransitions")).isNotEmpty();
}
StreamSupport.stream(orderMachine.path("metadata").path("callChains").spliterator(), false)
.filter(chain -> chain.path("entryPoint").path("name").asText().contains("/transition/{event}"))
.forEach(chain -> assertThat(chain.path("linkResolution").asText())
.isIn("UNRESOLVED_EXTERNAL", "AMBIGUOUS_WIDEN", "NO_MATCH"));
}
@Test
void layeredDispatcherKnownEndpointsShouldNotBeAmbiguousWiden(@TempDir Path tempDir) throws Exception {
JsonNode orderMachine = exportMachine(
tempDir, "state_machines/layered_dispatcher_sample", "StandardOrderStateMachineConfiguration");
List<String> mustResolve = List.of(
"POST /api/orders/pay",
"POST /api/orders/ship",
"POST /api/orders/cancel",
"POST /api/orders/rich/pay",
"POST /api/orders/rich/ship",
"POST /api/string-dispatch/orders/pay",
"POST /api/string-dispatch/orders/ship",
"POST /api/commands/order.pay",
"POST /api/commands/order.ship");
Map<String, String> failures = new LinkedHashMap<>();
for (String endpoint : mustResolve) {
JsonNode chain = findChainByEndpoint(orderMachine, endpoint);
if (chain == null) {
failures.put(endpoint, "missing chain");
continue;
}
String resolution = chain.path("linkResolution").asText();
if (!"RESOLVED".equals(resolution)) {
failures.put(endpoint, resolution + " poly="
+ chain.path("triggerPoint").path("polymorphicEvents"));
}
}
assertThat(failures).isEmpty();
}
private static JsonNode exportMachine(Path tempDir, String sampleRelativePath, String configBaseName)
throws Exception {
Path sampleRoot = findProjectRoot().resolve(sampleRelativePath);
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runExporter(sampleRoot, tempDir, List.of("json"), true, List.of(), null, null,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
return readMachineJson(tempDir, configBaseName, new ObjectMapper());
}
private static JsonNode findChainByEndpoint(JsonNode machineJson, String endpointName) {
return StreamSupport.stream(machineJson.path("metadata").path("callChains").spliterator(), false)
.filter(chain -> endpointName.equals(chain.path("entryPoint").path("name").asText()))
.findFirst()
.orElse(null);
}
private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper)
throws Exception {
Path machineDir;
try (var stream = Files.list(outputDir)) {
machineDir = stream
.filter(Files::isDirectory)
.filter(path -> path.getFileName().toString().endsWith(configBaseName))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No output for " + configBaseName));
}
return mapper.readTree(machineDir.resolve(machineDir.getFileName() + ".json").toFile());
}
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
return current;
}
}

View File

@@ -0,0 +1,670 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.DataFlowModel;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.ExpressionStatement;
import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Regression suite for functional-interface event resolution ({@code Supplier.get()},
* {@code Runnable.run()}, {@code Consumer.accept()}, {@code Callable.call()}, side-effect lambdas).
* Must stay on the dataflow pipeline:
* {@link JdtDataFlowModel} → {@link VariableTracer} → {@link PathBindingEvaluator}
* → {@link PathBindingExpressionResolver} → call graph.
* Do not reintroduce a parallel {@code CallSiteArgumentResolver} bridge.
*/
class FunctionalInterfaceDataFlowRegressionTest {
@Nested
class JdtDataFlowModelLayer {
@Test
void shouldResolveEnumFromSupplierGetOnLocalLambda(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scan(tempDir, """
package com.example;
import java.util.function.Supplier;
public enum OrderCommand { PAY, SHIP }
public class Hooks {
OrderCommand read() {
Supplier<OrderCommand> supplier = () -> OrderCommand.PAY;
return supplier.get();
}
}
""");
MethodInvocation getCall = findReturnMethodInvocation(context, "com.example.Hooks", "read");
assertThat(getCall.getName().getIdentifier()).isEqualTo("get");
DataFlowModel model = new JdtDataFlowModel(context);
List<String> constants = new ArrayList<>();
for (Expression def : model.getReachingDefinitions(getCall)) {
new ConstantExtractor(context, context.getConstantResolver(), null)
.extractConstantsFromExpression(def, constants);
}
assertThat(constants).anyMatch(c -> c.contains("PAY"));
}
@Test
void shouldResolveEnumLiteralThroughDataflow(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scan(tempDir, """
package com.example;
public enum OrderCommand { PAY, SHIP }
public class Service {
public void fireEvent() {
sendEvent(OrderCommand.PAY);
}
protected void sendEvent(OrderCommand event) {}
}
""");
MethodInvocation sendEventCall = findSideEffectCall(context, "com.example.Service", "fireEvent");
Expression eventArg = (Expression) sendEventCall.arguments().get(0);
DataFlowModel model = new JdtDataFlowModel(context);
List<String> fromDataflow = new ArrayList<>();
for (Expression def : model.getReachingDefinitions(eventArg)) {
new ConstantExtractor(context, context.getConstantResolver(), null)
.extractConstantsFromExpression(def, fromDataflow);
}
assertThat(fromDataflow).anyMatch(c -> c.contains("PAY"));
}
@Test
void shouldNotTreatPlainObjectGetAsFunctionalSupplier(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scan(tempDir, """
package com.example;
public class Runner {
static class Box {
public String get() { return "X"; }
}
public void run() {
Box box = new Box();
String value = box.get();
}
}
""");
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "com.example.Runner", "run", "value");
assertThat(model.resolveValue(initializer, context)).isEqualTo("X");
}
@Test
void shouldResolveEnumFromCallableCallOnLocalLambda(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scan(tempDir, """
package com.example;
import java.util.concurrent.Callable;
public enum OrderCommand { PAY, SHIP }
public class Hooks {
OrderCommand read() throws Exception {
Callable<OrderCommand> callable = () -> OrderCommand.PAY;
return callable.call();
}
}
""");
MethodInvocation call = findReturnMethodInvocation(context, "com.example.Hooks", "read");
assertThat(call.getName().getIdentifier()).isEqualTo("call");
DataFlowModel model = new JdtDataFlowModel(context);
List<String> constants = new ArrayList<>();
for (Expression def : model.getReachingDefinitions(call)) {
new ConstantExtractor(context, context.getConstantResolver(), null)
.extractConstantsFromExpression(def, constants);
}
assertThat(constants).anyMatch(c -> c.contains("PAY"));
}
@Test
void shouldResolveEnumFromConsumerAcceptSideEffectLambda(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scan(tempDir, """
package com.example;
import java.util.function.Consumer;
public enum OrderCommand { PAY, SHIP }
public class Hooks {
void run() {
Consumer<String> handler = ignored -> sendEvent(OrderCommand.PAY);
handler.accept("trigger");
}
void sendEvent(OrderCommand event) {}
}
""");
MethodInvocation acceptCall = findSideEffectCall(context, "com.example.Hooks", "run");
assertThat(acceptCall.getName().getIdentifier()).isEqualTo("accept");
DataFlowModel model = new JdtDataFlowModel(context);
List<String> constants = new ArrayList<>();
for (Expression def : model.getReachingDefinitions(acceptCall)) {
new ConstantExtractor(context, context.getConstantResolver(), null)
.extractConstantsFromExpression(def, constants);
}
assertThat(constants).anyMatch(c -> c.contains("PAY"));
}
@Test
void shouldResolveEnumThroughReactiveTransformLambdaBinding(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scan(tempDir, """
package com.example;
public enum OrderCommand { PAY, SHIP }
public class Hooks {
OrderCommand read() {
return Mono.just(OrderCommand.PAY).map(x -> x);
}
}
class Mono {
static Mono just(Object o) { return new Mono(); }
OrderCommand map(java.util.function.Function fn) { return OrderCommand.PAY; }
}
""");
MethodInvocation mapCall = findReturnMethodInvocation(context, "com.example.Hooks", "read");
assertThat(mapCall.getName().getIdentifier()).isEqualTo("map");
DataFlowModel model = new JdtDataFlowModel(context);
List<String> constants = new ArrayList<>();
for (Expression def : model.getReachingDefinitions(mapCall)) {
new ConstantExtractor(context, context.getConstantResolver(), null)
.extractConstantsFromExpression(def, constants);
}
assertThat(constants).anyMatch(c -> c.contains("PAY"));
}
}
@Nested
class VariableTracerLayer {
@Test
void shouldResolveSupplierLambdaAtCallSite(@TempDir Path tempDir) throws IOException {
RunnableProject project = RunnableProject.supplierOnly(tempDir);
VariableTracer tracer = tracer(project.context());
List<String> constants = tracer.resolveConstantsFromCallSiteArgument(
"com.example.web.OrderController.process",
"com.example.service.OrderService.fireEvent",
0);
assertThat(constants).containsExactly("OrderCommand.PAY");
}
@Test
void shouldResolveRunnableSideEffectLambdaAtCallSite(@TempDir Path tempDir) throws IOException {
RunnableProject project = RunnableProject.withExecutor(tempDir);
VariableTracer tracer = tracer(project.context());
assertThat(tracer.findFunctionalParameterIndex("com.example.executor.TaskExecutor.execute"))
.isZero();
assertThat(tracer.findCallSiteArgumentExpression(
"com.example.web.OrderController.process",
"com.example.executor.TaskExecutor.execute",
0)).isInstanceOf(LambdaExpression.class);
List<String> constants = tracer.resolveConstantsFromCallSiteArgument(
"com.example.service.AbstractOrderService.fireEvent",
"com.example.service.AbstractOrderService.sendEvent",
0);
assertThat(constants).containsExactly("OrderCommand.PAY");
}
}
@Nested
class PathBindingEvaluatorLayer {
@Test
void shouldPropagateRunnableLambdaEnumThroughPathBindings(@TempDir Path tempDir) throws IOException {
RunnableProject project = RunnableProject.withExecutor(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(project.context());
Map<String, List<CallEdge>> graph = engine.buildCallGraph();
CallGraphPathFinder pathFinder = new CallGraphPathFinder(project.context());
PathBindingEvaluator evaluator = new PathBindingEvaluator(
project.context(), engine.variableTracer, project.context().getConstantResolver(), engine.typeResolver);
List<String> path = List.of(
"com.example.web.OrderController.process",
"com.example.executor.TaskExecutor.execute",
runnableRunHop(graph),
"com.example.service.AbstractOrderService.fireEvent",
"com.example.service.AbstractOrderService.sendEvent");
Map<String, String> bindings = evaluator.traceBindings(path, graph, pathFinder);
assertThat(bindings.values()).anyMatch(v -> v.contains("PAY"));
}
@Test
void shouldPropagateSupplierLambdaThroughParameterMap(@TempDir Path tempDir) throws IOException {
RunnableProject project = RunnableProject.supplierOnly(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(project.context());
Map<String, List<CallEdge>> graph = engine.buildCallGraph();
Map<String, String> paramValues = engine.variableTracer.buildParameterValuesMap(
"com.example.web.OrderController.process",
"com.example.service.OrderService.fireEvent",
graph,
List.of(
"com.example.web.OrderController.process",
"com.example.service.OrderService.fireEvent"),
1);
assertThat(paramValues.get("provider")).contains("PAY");
}
}
@Nested
class CallGraphEndToEndLayer {
@Test
void shouldLinkSupplierLambdaToSingleEnum(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scan(tempDir, """
package com.example;
import java.util.function.Supplier;
public class MyController {
private StateMachine machine;
public void process() {
fireEvent(() -> TransitionEnum.STATE_L);
}
private void fireEvent(Supplier<TransitionEnum> eventSupplier) {
machine.fire(eventSupplier.get());
}
}
class StateMachine {
public void fire(TransitionEnum event) {}
}
enum TransitionEnum { STATE_L, STATE_M }
""");
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> chains = engine.findChains(
List.of(EntryPoint.builder()
.className("com.example.MyController")
.methodName("process")
.build()),
List.of(TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("fire")
.event("event")
.build()));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("TransitionEnum.STATE_L");
}
@Test
void shouldLinkCrossClassSupplierLambda(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("cross");
Files.createDirectories(root);
Files.writeString(root.resolve("JmsListener.java"), """
package com.example.web;
import com.example.service.StateMachineService;
import com.example.domain.OrderEvent;
public class JmsOrderListener {
private final StateMachineService service;
public JmsOrderListener(StateMachineService service) { this.service = service; }
public void receive() { service.sendWithProvider(() -> OrderEvent.CANCEL); }
}
""");
Files.writeString(root.resolve("Service.java"), """
package com.example.service;
import com.example.domain.OrderEvent;
import java.util.function.Supplier;
public class StateMachineService {
public <T extends OrderEvent> void sendWithProvider(Supplier<T> provider) {
OrderEvent event = provider.get();
fire(event);
}
private void fire(OrderEvent event) {}
}
""");
Files.writeString(root.resolve("OrderEvent.java"), """
package com.example.domain;
public enum OrderEvent { CANCEL, PROCESS }
""");
CodebaseContext context = new CodebaseContext();
context.scan(root);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> chains = engine.findChains(
List.of(EntryPoint.builder()
.className("com.example.web.JmsOrderListener")
.methodName("receive")
.build()),
List.of(TriggerPoint.builder()
.className("com.example.service.StateMachineService")
.methodName("fire")
.event("event")
.build()));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactly("OrderEvent.CANCEL");
}
@Test
void shouldLinkRunnableExecuteSideEffectLambda(@TempDir Path tempDir) throws IOException {
RunnableProject project = RunnableProject.withExecutor(tempDir);
assertSinglePayEvent(chains(project, "com.example.service.AbstractOrderService", "sendEvent"));
}
@Test
void shouldLinkConsumerAcceptSideEffectLambda(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scan(tempDir, """
package com.example;
import java.util.function.Consumer;
public enum OrderCommand { PAY, SHIP }
public class OrderService {
private final EventDispatcher dispatcher = new EventDispatcher();
public void processOrder() {
dispatcher.dispatch("order", ignored -> fireEvent());
}
public void fireEvent() { sendEvent(OrderCommand.PAY); }
void sendEvent(OrderCommand event) {}
}
class EventDispatcher {
public void dispatch(String order, Consumer<String> callback) {
callback.accept("trigger");
}
}
""");
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> chains = engine.findChains(
List.of(EntryPoint.builder()
.className("com.example.OrderService")
.methodName("processOrder")
.build()),
List.of(TriggerPoint.builder()
.className("com.example.OrderService")
.methodName("sendEvent")
.event("event")
.build()));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderCommand.PAY");
}
@Test
void shouldReachFireSiteThroughRunnableRunHop(@TempDir Path tempDir) throws IOException {
RunnableProject project = RunnableProject.withExecutor(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(project.context());
Map<String, List<CallEdge>> graph = engine.buildCallGraph();
assertThat(graph.get("com.example.executor.TaskExecutor.execute"))
.extracting(CallEdge::getTargetMethod)
.anyMatch(target -> target.contains("Runnable.run"));
List<CallChain> chains = engine.findChains(
List.of(EntryPoint.builder()
.className("com.example.web.OrderController")
.methodName("process")
.build()),
List.of(TriggerPoint.builder()
.className("com.example.service.AbstractOrderService")
.methodName("sendEvent")
.event("event")
.build()));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderCommand.PAY");
}
}
private static void assertSinglePayEvent(List<CallChain> chains) {
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactly("OrderCommand.PAY");
}
private static List<CallChain> chains(RunnableProject project, String triggerClass, String triggerMethod) {
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(project.context());
return engine.findChains(
List.of(EntryPoint.builder()
.className("com.example.web.OrderController")
.methodName("process")
.build()),
List.of(TriggerPoint.builder()
.className(triggerClass)
.methodName(triggerMethod)
.event("event")
.build()));
}
private static String runnableRunHop(Map<String, List<CallEdge>> graph) {
List<CallEdge> edges = graph.get("com.example.executor.TaskExecutor.execute");
assertThat(edges).isNotNull();
return edges.stream()
.map(CallEdge::getTargetMethod)
.filter(hop -> hop.endsWith("Runnable.run"))
.findFirst()
.orElseThrow();
}
private static VariableTracer tracer(CodebaseContext context) {
VariableTracer tracer = new VariableTracer(context, context.getConstantResolver());
TypeResolver typeResolver = new TypeResolver(context);
ConstantExtractor constantExtractor = new ConstantExtractor(context, context.getConstantResolver(), null);
tracer.setTypeResolver(typeResolver);
tracer.setConstantExtractor(constantExtractor);
return tracer;
}
private static CodebaseContext scan(Path tempDir, String source) throws IOException {
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
return context;
}
private static Expression findInitializer(
CodebaseContext context, String typeFqn, String methodName, String variableName) {
TypeDeclaration type = context.getTypeDeclaration(typeFqn);
MethodDeclaration method = context.findMethodDeclaration(type, methodName, true);
for (Object statementObj : method.getBody().statements()) {
if (statementObj instanceof VariableDeclarationStatement statement) {
for (Object fragmentObj : statement.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& variableName.equals(fragment.getName().getIdentifier())) {
return fragment.getInitializer();
}
}
}
}
throw new AssertionError("Initializer not found: " + variableName);
}
private static MethodInvocation findMethodInvocationInitializer(
CodebaseContext context, String typeFqn, String methodName, String variableName) {
Expression initializer = findInitializer(context, typeFqn, methodName, variableName);
assertThat(initializer).isInstanceOf(MethodInvocation.class);
return (MethodInvocation) initializer;
}
private static MethodInvocation findSideEffectCall(CodebaseContext context, String typeFqn, String methodName) {
TypeDeclaration type = context.getTypeDeclaration(typeFqn);
MethodDeclaration method = context.findMethodDeclaration(type, methodName, true);
for (Object statementObj : method.getBody().statements()) {
if (statementObj instanceof ExpressionStatement statement
&& statement.getExpression() instanceof MethodInvocation invocation) {
return invocation;
}
}
throw new AssertionError("Side-effect call not found in " + methodName);
}
private static MethodInvocation findReturnMethodInvocation(CodebaseContext context, String typeFqn, String methodName) {
TypeDeclaration type = context.getTypeDeclaration(typeFqn);
MethodDeclaration method = context.findMethodDeclaration(type, methodName, true);
for (Object statementObj : method.getBody().statements()) {
if (statementObj instanceof org.eclipse.jdt.core.dom.ReturnStatement returnStatement
&& returnStatement.getExpression() instanceof MethodInvocation invocation) {
return invocation;
}
}
throw new AssertionError("Return MethodInvocation not found in " + methodName);
}
private record RunnableProject(CodebaseContext context) {
static RunnableProject supplierOnly(Path tempDir) throws IOException {
Path root = tempDir.resolve("supplier");
Files.createDirectories(root);
writeSharedSources(root, false);
return new RunnableProject(scanRoot(root));
}
static RunnableProject withExecutor(Path tempDir) throws IOException {
Path root = tempDir.resolve("runnable");
Files.createDirectories(root);
writeSharedSources(root, true);
return new RunnableProject(scanRoot(root));
}
private static CodebaseContext scanRoot(Path root) throws IOException {
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(root);
return context;
}
private static void writeSharedSources(Path root, boolean withExecutor) throws IOException {
Files.writeString(root.resolve("OrderCommand.java"), """
package com.example.domain;
public enum OrderCommand { PAY, SHIP, META }
""");
if (withExecutor) {
Files.writeString(root.resolve("TaskExecutor.java"), """
package com.example.executor;
public class TaskExecutor {
public void execute(Runnable task) { task.run(); }
}
""");
Files.writeString(root.resolve("OrderController.java"), """
package com.example.web;
import com.example.executor.TaskExecutor;
import com.example.service.AbstractOrderService;
public class OrderController {
private final TaskExecutor executor;
private final AbstractOrderService service;
public OrderController(TaskExecutor executor, AbstractOrderService service) {
this.executor = executor;
this.service = service;
}
public void process() {
executor.execute(() -> service.fireEvent());
}
}
""");
Files.writeString(root.resolve("AbstractOrderService.java"), """
package com.example.service;
import com.example.domain.OrderCommand;
public abstract class AbstractOrderService {
protected com.example.machine.StateMachine machine;
public void fireEvent() { sendEvent(OrderCommand.PAY); }
protected void sendEvent(OrderCommand event) { machine.fire(event); }
}
""");
Files.writeString(root.resolve("StateMachine.java"), """
package com.example.machine;
import com.example.domain.OrderCommand;
public class StateMachine {
public void fire(OrderCommand event) {}
}
""");
} else {
Files.writeString(root.resolve("OrderController.java"), """
package com.example.web;
import com.example.service.OrderService;
import com.example.domain.OrderCommand;
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) { this.service = service; }
public void process() {
service.fireEvent(() -> OrderCommand.PAY);
}
}
""");
Files.writeString(root.resolve("OrderService.java"), """
package com.example.service;
import com.example.domain.OrderCommand;
import com.example.machine.StateMachine;
import java.util.function.Supplier;
public class OrderService {
private StateMachine machine;
public void fireEvent(Supplier<OrderCommand> provider) {
machine.fire(provider.get());
}
}
""");
Files.writeString(root.resolve("StateMachine.java"), """
package com.example.machine;
import com.example.domain.OrderCommand;
public class StateMachine {
public void fire(OrderCommand event) {}
}
""");
}
}
}
@Nested
class GetterChainDataFlowLayer {
@Test
void shouldResolveChainedGetterThroughDataflowBeforeAccessorFallback(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scan(tempDir, """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent(EventWrapper wrapper) {
service.updateOrderState(wrapper.getEvent().getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class EventWrapper {
public RichEvent getEvent() { return new RichEvent(); }
}
class RichEvent {
public OrderEvents getType() { return OrderEvents.PAY; }
}
enum OrderEvents { PAY }
""");
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> chains = engine.findChains(
List.of(EntryPoint.builder()
.className("com.example.OrderController")
.methodName("processOrderEvent")
.build()),
List.of(TriggerPoint.builder()
.className("com.example.OrderService")
.methodName("updateOrderState")
.event("event")
.build()));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("OrderEvents.PAY");
}
}
}

View File

@@ -12,6 +12,24 @@ class FunctionalInterfaceTypesTest {
.isTrue(); .isTrue();
} }
@Test
void shouldRecognizeConsumerSamMethod() {
assertThat(FunctionalInterfaceTypes.isFunctionalSamMethod("java.util.function.Consumer.accept"))
.isTrue();
}
@Test
void shouldRecognizeRunnableSamMethod() {
assertThat(FunctionalInterfaceTypes.isFunctionalSamMethod("java.lang.Runnable.run"))
.isTrue();
}
@Test
void shouldRejectMapGetAsSamMethod() {
assertThat(FunctionalInterfaceTypes.isFunctionalSamMethod("java.util.Map.get"))
.isFalse();
}
@Test @Test
void shouldAcceptLambdaIntoSupplierParameter() { void shouldAcceptLambdaIntoSupplierParameter() {
assertThat(FunctionalInterfaceTypes.isProvablyResolvedCallSiteArgument( assertThat(FunctionalInterfaceTypes.isProvablyResolvedCallSiteArgument(

View File

@@ -0,0 +1,71 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class PathBindingExpressionResolverTest {
private CodebaseContext context;
private PathBindingExpressionResolver resolver;
@BeforeEach
void setUp(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
enum DomainCommand { ORDER_PAY, ORDER_SHIP }
class CommandGateway {
private final StringCommandMapper mapper = new StringCommandMapper();
void executeViaMapper(String commandKey) {
DomainCommand command = mapper.fromString(commandKey);
dispatch(command);
}
void dispatch(DomainCommand command) {}
}
class StringCommandMapper {
DomainCommand fromString(String commandKey) {
return switch (commandKey) {
case "order.pay" -> DomainCommand.ORDER_PAY;
case "order.ship" -> DomainCommand.ORDER_SHIP;
default -> throw new IllegalArgumentException(commandKey);
};
}
}
""";
Files.writeString(tempDir.resolve("App.java"), source);
context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
VariableTracer variableTracer = new VariableTracer(context, context.getConstantResolver());
resolver = new PathBindingExpressionResolver(context, variableTracer, context.getConstantResolver());
}
@Test
void shouldResolveMapperFromStringViaDataflowWithNamedBindings() {
String resolved = resolver.resolveMethodCallFromSource(
"com.example.CommandGateway.executeViaMapper",
"command",
Map.of("commandKey", "order.pay"));
assertThat(resolved).isEqualTo("DomainCommand.ORDER_PAY");
}
@Test
void shouldResolveAlternateSwitchArmViaDataflowNamedBindings() {
String resolved = resolver.resolveMethodCallFromSource(
"com.example.CommandGateway.executeViaMapper",
"command",
Map.of("commandKey", "order.ship"));
assertThat(resolved).isEqualTo("DomainCommand.ORDER_SHIP");
}
}

View File

@@ -0,0 +1,120 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Regression tests for path-binding cache-once-per-chain and index-first getter-chain hops.
*/
class PathResolutionOptimizationTest {
@Test
void findChainsShouldAttachPathBindingsOncePerChain(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
private final CommandGateway gateway = new CommandGateway();
public void pay() { gateway.executeViaMapper("order.pay"); }
}
enum DomainCommand { ORDER_PAY, ORDER_SHIP }
enum OrderEvent { PAY, SHIP }
class CommandGateway {
private final StringCommandMapper mapper = new StringCommandMapper();
private final CentralDispatcher central = new CentralDispatcher();
void executeViaMapper(String commandKey) {
DomainCommand command = mapper.fromString(commandKey);
central.dispatch(command);
}
}
class StringCommandMapper {
DomainCommand fromString(String commandKey) {
return switch (commandKey) {
case "order.pay" -> DomainCommand.ORDER_PAY;
case "order.ship" -> DomainCommand.ORDER_SHIP;
default -> throw new IllegalArgumentException(commandKey);
};
}
}
class CentralDispatcher {
void dispatch(DomainCommand command) {
switch (command) {
case ORDER_PAY -> orderPay();
case ORDER_SHIP -> orderShip();
}
}
void orderPay() { fire(OrderEvent.PAY); }
void orderShip() { fire(OrderEvent.SHIP); }
void fire(OrderEvent event) {}
}
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder().className("com.example.ApiController").methodName("pay").build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.CentralDispatcher")
.methodName("fire")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getPathBindings())
.containsEntry("command", "DomainCommand.ORDER_PAY");
assertThat(chains.get(0).getMethodChain()).contains("com.example.CentralDispatcher.orderPay");
}
@Test
void indexFirstGetterChainHopShouldResolveFieldDefaultChain(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class OrderService {
public void pay(Order order) {
order.getPayload().getType();
}
}
class Order {
private Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
class Payload {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() { return type; }
}
enum OrderEvent { PAY, SHIP }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
JdtDataFlowModel dataFlowModel = new JdtDataFlowModel(context);
Expression found = AstUtils.findExpressionInMethod(
"com.example.OrderService.pay", "order.getPayload().getType()", context);
assertThat(found).isInstanceOf(MethodInvocation.class);
String resolved = dataFlowModel.resolveValue((MethodInvocation) found, context);
assertThat(resolved).contains("PAY");
}
}

View File

@@ -0,0 +1,106 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class RunnableLambdaDispatchTest {
@Test
void shouldResolveEventThroughRunnableExecuteLambda() throws IOException {
String controllerSource = """
package com.example.web;
import com.example.service.AbstractOrderService;
import com.example.executor.TaskExecutor;
public class OrderController {
private final TaskExecutor executor;
private final AbstractOrderService orderService;
public OrderController(TaskExecutor executor, AbstractOrderService orderService) {
this.executor = executor;
this.orderService = orderService;
}
public void process() {
executor.execute(() -> orderService.fireEvent());
}
}
""";
String executorSource = """
package com.example.executor;
public class TaskExecutor {
public void execute(Runnable task) {
task.run();
}
}
""";
String serviceSource = """
package com.example.service;
import com.example.domain.OrderCommand;
public abstract class AbstractOrderService {
protected StateMachine machine;
public void fireEvent() {
sendEvent(OrderCommand.PAY);
}
protected void sendEvent(OrderCommand event) {
machine.fire(event);
}
}
class StateMachine {
public void fire(OrderCommand event) {}
}
""";
String enumSource = """
package com.example.domain;
public enum OrderCommand { PAY, SHIP, META }
""";
Path tempDir = Files.createTempDirectory("callgraph_runnable_lambda");
Files.writeString(tempDir.resolve("OrderController.java"), controllerSource);
Files.writeString(tempDir.resolve("TaskExecutor.java"), executorSource);
Files.writeString(tempDir.resolve("AbstractOrderService.java"), serviceSource);
Files.writeString(tempDir.resolve("OrderCommand.java"), enumSource);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.web.OrderController")
.methodName("process")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.service.AbstractOrderService")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderCommand.PAY");
}
}

View File

@@ -125,6 +125,21 @@
"metadata" : { "metadata" : {
"triggers" : [ ], "triggers" : [ ],
"entryPoints" : [ { "entryPoints" : [ {
"type" : "REST",
"name" : "POST /api/orders/reactive",
"className" : "click.kamil.examples.statemachine.extended.web.OrderController",
"methodName" : "reactiveOrder",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java",
"metadata" : {
"path" : "/api/orders/reactive",
"verb" : "POST"
},
"parameters" : [ {
"name" : "orderId",
"type" : "String",
"annotations" : [ ]
} ]
}, {
"type" : "REST", "type" : "REST",
"name" : "GET /api/base/{id}", "name" : "GET /api/base/{id}",
"className" : "click.kamil.examples.statemachine.extended.web.PaymentController", "className" : "click.kamil.examples.statemachine.extended.web.PaymentController",
@@ -155,7 +170,46 @@
"annotations" : [ "PathVariable" ] "annotations" : [ "PathVariable" ]
} ] } ]
} ], } ],
"callChains" : [ ], "callChains" : [ {
"entryPoint" : {
"type" : "REST",
"name" : "POST /api/orders/reactive",
"className" : "click.kamil.examples.statemachine.extended.web.OrderController",
"methodName" : "reactiveOrder",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java",
"metadata" : {
"path" : "/api/orders/reactive",
"verb" : "POST"
},
"parameters" : [ {
"name" : "orderId",
"type" : "String",
"annotations" : [ ]
} ]
},
"methodChain" : [ "click.kamil.examples.statemachine.extended.web.OrderController.reactiveOrder", "click.kamil.examples.statemachine.extended.service.ReactiveOrderService.processReactive" ],
"triggerPoint" : {
"event" : "java.lang.String.REACTIVE_EVENT",
"className" : "click.kamil.examples.statemachine.extended.service.ReactiveOrderService",
"methodName" : "processReactive",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/ReactiveOrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : "java.lang.String.START",
"lineNumber" : 18,
"polymorphicEvents" : [ "java.lang.String.REACTIVE_EVENT" ],
"external" : false,
"constraint" : null,
"ambiguous" : false
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "String.START",
"targetState" : "String.PROCESSING",
"event" : "String.REACTIVE_EVENT"
} ],
"linkResolution" : "RESOLVED"
} ],
"properties" : { "properties" : {
"default" : { "default" : {
"app.states.initial" : "INIT_STATE" "app.states.initial" : "INIT_STATE"

View File

@@ -125,6 +125,21 @@
"metadata" : { "metadata" : {
"triggers" : [ ], "triggers" : [ ],
"entryPoints" : [ { "entryPoints" : [ {
"type" : "REST",
"name" : "POST /api/orders/reactive",
"className" : "click.kamil.examples.statemachine.extended.web.OrderController",
"methodName" : "reactiveOrder",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java",
"metadata" : {
"path" : "/api/orders/reactive",
"verb" : "POST"
},
"parameters" : [ {
"name" : "orderId",
"type" : "String",
"annotations" : [ ]
} ]
}, {
"type" : "REST", "type" : "REST",
"name" : "GET /api/base/{id}", "name" : "GET /api/base/{id}",
"className" : "click.kamil.examples.statemachine.extended.web.PaymentController", "className" : "click.kamil.examples.statemachine.extended.web.PaymentController",
@@ -155,7 +170,46 @@
"annotations" : [ "PathVariable" ] "annotations" : [ "PathVariable" ]
} ] } ]
} ], } ],
"callChains" : [ ], "callChains" : [ {
"entryPoint" : {
"type" : "REST",
"name" : "POST /api/orders/reactive",
"className" : "click.kamil.examples.statemachine.extended.web.OrderController",
"methodName" : "reactiveOrder",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java",
"metadata" : {
"path" : "/api/orders/reactive",
"verb" : "POST"
},
"parameters" : [ {
"name" : "orderId",
"type" : "String",
"annotations" : [ ]
} ]
},
"methodChain" : [ "click.kamil.examples.statemachine.extended.web.OrderController.reactiveOrder", "click.kamil.examples.statemachine.extended.service.ReactiveOrderService.processReactive" ],
"triggerPoint" : {
"event" : "java.lang.String.REACTIVE_EVENT",
"className" : "click.kamil.examples.statemachine.extended.service.ReactiveOrderService",
"methodName" : "processReactive",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/ReactiveOrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : "java.lang.String.START",
"lineNumber" : 18,
"polymorphicEvents" : [ "java.lang.String.REACTIVE_EVENT" ],
"external" : false,
"constraint" : null,
"ambiguous" : false
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "String.START",
"targetState" : "String.PROCESSING",
"event" : "String.REACTIVE_EVENT"
} ],
"linkResolution" : "RESOLVED"
} ],
"properties" : { "properties" : {
"default" : { "default" : {
"app.states.initial" : "INIT_STATE" "app.states.initial" : "INIT_STATE"

View File

@@ -60,8 +60,62 @@
"eventTypeFqn" : "String", "eventTypeFqn" : "String",
"metadata" : { "metadata" : {
"triggers" : [ ], "triggers" : [ ],
"entryPoints" : [ ], "entryPoints" : [ {
"callChains" : [ ], "type" : "JMS",
"name" : "JMS: order.queue",
"className" : "click.kamil.maven.api.JmsOrderListener",
"methodName" : "onMessage",
"sourceFile" : "api-module/src/main/java/click/kamil/maven/api/JmsOrderListener.java",
"metadata" : {
"protocol" : "JMS",
"destination" : "order.queue"
},
"parameters" : [ {
"name" : "message",
"type" : "String",
"annotations" : [ ]
} ]
} ],
"callChains" : [ {
"entryPoint" : {
"type" : "JMS",
"name" : "JMS: order.queue",
"className" : "click.kamil.maven.api.JmsOrderListener",
"methodName" : "onMessage",
"sourceFile" : "api-module/src/main/java/click/kamil/maven/api/JmsOrderListener.java",
"metadata" : {
"protocol" : "JMS",
"destination" : "order.queue"
},
"parameters" : [ {
"name" : "message",
"type" : "String",
"annotations" : [ ]
} ]
},
"methodChain" : [ "click.kamil.maven.api.JmsOrderListener.onMessage", "click.kamil.maven.core.MavenOrderStateMachine.OrderService.onMessage" ],
"triggerPoint" : {
"event" : "java.lang.String.ORDER_EVENT",
"className" : "click.kamil.maven.core.MavenOrderStateMachine.OrderService",
"methodName" : "onMessage",
"sourceFile" : "core-module/src/main/java/click/kamil/maven/core/MavenOrderStateMachine.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : "java.lang.String.BUSY",
"lineNumber" : 52,
"polymorphicEvents" : [ "java.lang.String.ORDER_EVENT" ],
"external" : false,
"constraint" : null,
"ambiguous" : false
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "String.BUSY",
"targetState" : "String.DONE",
"event" : "String.ORDER_EVENT"
} ],
"linkResolution" : "RESOLVED"
} ],
"properties" : { "properties" : {
"default" : { } "default" : { }
} }

View File

@@ -636,6 +636,45 @@
"event" : "String.AUTHORIZE" "event" : "String.AUTHORIZE"
} ], } ],
"linkResolution" : "RESOLVED" "linkResolution" : "RESOLVED"
}, {
"entryPoint" : {
"type" : "REST",
"name" : "POST /api/payment/{id}/capture",
"className" : "click.kamil.examples.statemachine.extended.web.PaymentController",
"methodName" : "capturePaymentEndpoint",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/PaymentController.java",
"metadata" : {
"path" : "/api/payment/{id}/capture",
"verb" : "POST"
},
"parameters" : [ {
"name" : "id",
"type" : "String",
"annotations" : [ "PathVariable" ]
} ]
},
"methodChain" : [ "click.kamil.examples.statemachine.extended.web.PaymentController.capturePaymentEndpoint", "click.kamil.examples.statemachine.extended.service.PaymentServiceImpl.capturePayment" ],
"triggerPoint" : {
"event" : "java.lang.String.CAPTURE",
"className" : "click.kamil.examples.statemachine.extended.service.PaymentServiceImpl",
"methodName" : "capturePayment",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/service/PaymentServiceImpl.java",
"sourceModule" : null,
"stateMachineId" : "paymentStateMachine",
"sourceState" : "java.lang.String.AUTHORIZED",
"lineNumber" : 35,
"polymorphicEvents" : [ "java.lang.String.CAPTURE" ],
"external" : false,
"constraint" : null,
"ambiguous" : false
},
"contextMachineId" : "paymentId",
"matchedTransitions" : [ {
"sourceState" : "String.AUTHORIZED",
"targetState" : "String.CAPTURED",
"event" : "String.CAPTURE"
} ],
"linkResolution" : "RESOLVED"
} ], } ],
"properties" : { "properties" : {
"default" : { "default" : {

View File

@@ -738,16 +738,8 @@
"ambiguous" : true "ambiguous" : true
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : null,
"sourceState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.SUBMITTED", "linkResolution" : "AMBIGUOUS_WIDEN"
"targetState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.PAID",
"event" : "click.kamil.examples.statemachine.polymorphic.OrderEvents.PAY"
}, {
"sourceState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.PAID",
"targetState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.CANCELED",
"event" : "click.kamil.examples.statemachine.polymorphic.OrderEvents.ABCD"
} ],
"linkResolution" : "RESOLVED"
} ], } ],
"properties" : { "properties" : {
"default" : { "default" : {

View File

@@ -315,11 +315,19 @@
"polymorphicEvents" : [ "click.kamil.domain.OrderEvent.CANCEL" ], "polymorphicEvents" : [ "click.kamil.domain.OrderEvent.CANCEL" ],
"external" : false, "external" : false,
"constraint" : "event instanceof OrderEvent", "constraint" : "event instanceof OrderEvent",
"ambiguous" : true "ambiguous" : false
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : null, "matchedTransitions" : [ {
"linkResolution" : "AMBIGUOUS_WIDEN" "sourceState" : "click.kamil.domain.OrderState.NEW",
"targetState" : "click.kamil.domain.OrderState.CANCELLED",
"event" : "click.kamil.domain.OrderEvent.CANCEL"
}, {
"sourceState" : "click.kamil.domain.OrderState.PROCESSING",
"targetState" : "click.kamil.domain.OrderState.CANCELLED",
"event" : "click.kamil.domain.OrderEvent.CANCEL"
} ],
"linkResolution" : "RESOLVED"
}, { }, {
"entryPoint" : { "entryPoint" : {
"type" : "REST", "type" : "REST",
@@ -463,11 +471,19 @@
"polymorphicEvents" : [ "click.kamil.domain.OrderEvent.CANCEL" ], "polymorphicEvents" : [ "click.kamil.domain.OrderEvent.CANCEL" ],
"external" : false, "external" : false,
"constraint" : "event != null", "constraint" : "event != null",
"ambiguous" : true "ambiguous" : false
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : null, "matchedTransitions" : [ {
"linkResolution" : "AMBIGUOUS_WIDEN" "sourceState" : "click.kamil.domain.OrderState.NEW",
"targetState" : "click.kamil.domain.OrderState.CANCELLED",
"event" : "click.kamil.domain.OrderEvent.CANCEL"
}, {
"sourceState" : "click.kamil.domain.OrderState.PROCESSING",
"targetState" : "click.kamil.domain.OrderState.CANCELLED",
"event" : "click.kamil.domain.OrderEvent.CANCEL"
} ],
"linkResolution" : "RESOLVED"
} ], } ],
"properties" : { "properties" : {
"default" : { } "default" : { }