25 Commits

Author SHA1 Message Date
5300fdb881 A 2026-07-16 22:13:21 +02:00
2fc5ad53d5 A 2026-07-16 21:37:53 +02:00
961873d29c A 2026-07-16 21:30:57 +02:00
08adbcb5ff A 2026-07-16 21:05:01 +02:00
139eee0a43 A 2026-07-16 20:10:41 +02:00
4b5de0e167 A 2026-07-16 19:21:53 +02:00
1d9c986793 A 2026-07-16 18:50:33 +02:00
e067412db9 A 2026-07-16 18:30:36 +02:00
598b504873 A 2026-07-16 18:21:18 +02:00
9250e97055 A 2026-07-16 18:13:38 +02:00
4809423004 a 2026-07-16 18:02:43 +02:00
b4a7575bb9 Fix valueOf FQN truncation and reject incomplete dotted bindings.
Keep Type.valueOf receivers intact so package prefixes are not mistaken for enum types, and harden binding/canonicalization paths against trailing-dot names that caused index crashes on large codebases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 17:51:36 +02:00
grzegorz
109957b5fa opencode 2026-07-16 13:57:20 +02:00
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
6ca8e64750 Restore machine-scoped enum linking when global simple names are ambiguous.
Call-graph ambiguous metadata must not blank matchedTransitions; trust import-style polymorphic widen when the machine event type is known.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:34:08 +02:00
5b0778301a Harden cross-module map lookup and add sibling-module regression tests.
Resolve static map field declaring types via import-aware lookup when JDT binding uses the wrong package, and cover qualified inherited maps, static factory receivers, and RequestParam expansion across modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:20:18 +02:00
101 changed files with 8880 additions and 871 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);
} }
@@ -33,23 +122,56 @@ public final class CallChainLinkPolicy {
TriggerPoint trigger, TriggerPoint trigger,
String machineEventTypeFqn, String machineEventTypeFqn,
CodebaseContext context) { CodebaseContext context) {
if (trigger == null || trigger.isExternal() || !trigger.isAmbiguous()) { if (trigger == null || trigger.isExternal()) {
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);
// Multiple concrete events on a dynamic/ENUM_SET trigger must not link every transition
// (e.g. predicate-filtered PAY+SHIP, or an adjust endpoint sharing a wide dispatcher poly).
// Do not require trigger.ambiguous — that flag is often cleared incorrectly upstream.
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()) {
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);
} }
/** /**
@@ -80,22 +202,46 @@ 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.isEmpty()) {
return false; return false;
} }
for (String pe : poly) { for (String pe : concrete) {
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:") || !pe.contains(".")) { if (!MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(pe, eventTypeFqn, context)) {
return false;
}
String enumType = pe.substring(0, pe.lastIndexOf('.'));
if (!enumType.contains(".")
|| !MachineEnumCanonicalizer.enumTypesMatch(eventTypeFqn, enumType, context)) {
return false; return false;
} }
} }
return true; return true;
} }
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) {
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:")) {
continue;
}
String qualified = pe;
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 concrete;
}
public static LinkResolution resolveLinkResolution( public static LinkResolution resolveLinkResolution(
TriggerPoint trigger, TriggerPoint trigger,
List<MatchedTransition> matched, List<MatchedTransition> matched,
@@ -117,16 +263,22 @@ public final class CallChainLinkPolicy {
boolean ambiguousSource, boolean ambiguousSource,
String machineEventTypeFqn, String machineEventTypeFqn,
CodebaseContext context) { CodebaseContext context) {
// Prefer concrete matches over an external flag — dedicated endpoints can be
// mis-tagged external when unrelated RequestParams appear in path bindings.
if (matched != null && !matched.isEmpty()) {
if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) {
return LinkResolution.AMBIGUOUS_WIDEN;
}
return LinkResolution.RESOLVED;
}
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)) {
|| (trigger != null && trigger.isAmbiguous())
|| shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) {
return LinkResolution.AMBIGUOUS_WIDEN; return LinkResolution.AMBIGUOUS_WIDEN;
} }
if (matched != null && !matched.isEmpty()) { if (ambiguousSource) {
return LinkResolution.RESOLVED; return LinkResolution.AMBIGUOUS_WIDEN;
} }
return LinkResolution.NO_MATCH; return LinkResolution.NO_MATCH;
} }
@@ -137,8 +289,9 @@ public final class CallChainLinkPolicy {
} }
EntryPoint entryPoint = chain.getEntryPoint(); EntryPoint entryPoint = chain.getEntryPoint();
String entryMethod = entryPoint.getClassName() + "." + entryPoint.getMethodName(); String entryMethod = entryPoint.getClassName() + "." + entryPoint.getMethodName();
// Pass null for event param name — trigger.getEvent() is an expression/FQN, not a REST param.
boolean external = ExternalTriggerPolicy.isExternalFromSource( boolean external = ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, entryMethod, trigger.getEvent(), context); entryPoint, trigger, entryMethod, null, context);
if (external == trigger.isExternal()) { if (external == trigger.isExternal()) {
return trigger; return trigger;
} }

View File

@@ -0,0 +1,521 @@
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.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 using dataflow evidence only.
*
* <p>Evidence sources: static trigger events, path-binding map values, branch constraints,
* sendEvent enum literals along the method chain, and AST path-value estimation.
* REST URL path segments and method-name tokens are intentionally not used — those are not
* dataflow and falsely link endpoints to transitions.
*/
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);
collectFromConstraint(trigger.getConstraint(), configuredConstants, votes);
if (chain != null) {
collectFromPathBindings(chain.getPathBindings(), 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);
}
/**
* Path bindings come from {@code PathBindingEvaluator} / call-graph dataflow, not from URL text.
*/
private static void collectFromPathBindings(
Map<String, String> pathBindings,
Set<String> configuredConstants,
HintVotes votes) {
if (pathBindings == null || pathBindings.isEmpty()) {
return;
}
for (Map.Entry<String, String> entry : pathBindings.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (value == null || value.isBlank()) {
continue;
}
boolean eventLikeKey = key != null && (
key.equalsIgnoreCase("event")
|| key.equalsIgnoreCase("eventString")
|| key.equalsIgnoreCase("command")
|| key.equalsIgnoreCase("commandKey")
|| key.equalsIgnoreCase("action")
|| key.equalsIgnoreCase("actionKey")
|| key.toLowerCase(Locale.ROOT).endsWith("event"));
int weight = eventLikeKey ? STRONG : MEDIUM;
addEnumReference(value, configuredConstants, votes, weight);
addPathToken(value, configuredConstants, votes, weight);
}
}
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 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) {
boolean fireSite = FIRE_METHOD_NAMES.contains(node.getName().getIdentifier());
for (Object argument : node.arguments()) {
if (!(argument instanceof Expression expression)) {
continue;
}
if (expression instanceof QualifiedName qualifiedName) {
// Enum constant passed into the next hop (dataflow at the call site).
addEnumReference(
qualifiedName.getFullyQualifiedName(),
configuredConstants,
votes,
STRONG);
} else if (fireSite && expression instanceof SimpleName simpleName) {
// Only accept UPPERCASE identifiers that are configured constants —
// plain parameter names like "event" must not vote.
String identifier = simpleName.getIdentifier();
if (identifier != null
&& identifier.equals(identifier.toUpperCase(Locale.ROOT))
&& configuredConstants.contains(identifier)) {
addEnumReference(identifier, configuredConstants, votes, STRONG);
}
} else if (fireSite) {
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

@@ -5,7 +5,9 @@ import click.kamil.springstatemachineexporter.analysis.enricher.routing.Heuristi
import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
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 click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList; import java.util.ArrayList;
@@ -57,14 +59,48 @@ public final class MachineScopeFilter {
return chains == null ? List.of() : chains; return chains == null ? List.of() : chains;
} }
List<CallChain> filtered = new ArrayList<>(); List<CallChain> filtered = new ArrayList<>();
String machineEventTypeFqn = resolveMachineEventTypeFqn(machineName, context, machineTransitions);
for (CallChain chain : chains) { for (CallChain chain : chains) {
if (ROUTING.hasProvenMachineAffinity(chain, machineName, context, machineTransitions)) { if (ROUTING.hasProvenMachineAffinity(chain, machineName, context, machineTransitions)) {
filtered.add(chain); filtered.add(chain);
continue;
}
TriggerPoint trigger = chain.getTriggerPoint();
if (trigger != null
&& machineEventTypeFqn != null
&& CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context)) {
filtered.add(chain);
} }
} }
return filtered; return filtered;
} }
private static String resolveMachineEventTypeFqn(
String machineName,
CodebaseContext context,
List<Transition> machineTransitions) {
if (context != null && machineName != null) {
String[] types = StateMachineTypeResolver.resolve(machineName, context);
if (types != null && types.length > 1 && types[1] != null && !types[1].isBlank()) {
return types[1];
}
}
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;
}
public static List<EntryPoint> filterEntryPointsForMachine( public static List<EntryPoint> filterEntryPointsForMachine(
List<EntryPoint> entryPoints, String machineName, CodebaseContext context) { List<EntryPoint> entryPoints, String machineName, CodebaseContext context) {
if (entryPoints == null || machineName == null) { if (entryPoints == null || machineName == null) {

View File

@@ -25,9 +25,11 @@ import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMa
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine; import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine; import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine; import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.SharedServiceRoutingPolicy;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator; import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
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.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.TriggerMachineTypeRebinder;
public class TransitionLinkerEnricher implements AnalysisEnricher { public class TransitionLinkerEnricher implements AnalysisEnricher {
@@ -49,6 +51,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,21 +69,47 @@ 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 = TriggerMachineTypeRebinder.rebind(tp, chain.getMethodChain(), context);
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context); tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
tp = tp.toBuilder()
.eventTypeFqn(concreteOrFallback(tp.getEventTypeFqn(), effectiveEventTypeFqn))
.stateTypeFqn(concreteOrFallback(tp.getStateTypeFqn(), effectiveMachineTypes.stateTypeFqn()))
.build();
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) { if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
updatedChains.add(chain); updatedChains.add(chain);
continue; continue;
} }
// Unbound event templates (REST /{event}) and unbound messaging payloads stay unresolved.
// Resource placeholders like /{id}/pay must still link when the event is known.
if (tp.isExternal()
&& isUnresolvedExternalEndpoint(chain, tp)
&& !hasConcreteEventEvidence(tp)) {
updatedChains.add(chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(null)
.linkResolution(LinkResolution.UNRESOLVED_EXTERNAL)
.build());
continue;
}
// Clear spurious external flags (unrelated PathVariable/RequestParam) once we can link.
if (tp.isExternal()
&& (!isUnresolvedExternalEndpoint(chain, tp) || hasConcreteEventEvidence(tp))) {
tp = tp.toBuilder().external(false).build();
}
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)
@@ -86,10 +123,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(chain, result.getName(), context, stateMachineTransitions) && isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp,
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()) {
@@ -97,12 +137,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)) {
// Same event from multiple sources within one state enum is normal — link all.
// Do not require endpoint-narrow poly evidence (that conflates "which event?"
// with "which place?").
ambiguousSource = false;
} else { } else {
Set<String> allDistinctSources = new LinkedHashSet<>(); Set<String> allDistinctSources = new LinkedHashSet<>();
sourcesByEvent.values().forEach(allDistinctSources::addAll); sourcesByEvent.values().forEach(allDistinctSources::addAll);
@@ -131,8 +180,10 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.targetState(smSourceForLink) .targetState(smSourceForLink)
.event(smEventForLink) .event(smEventForLink)
.build(); .build();
if (isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions) if (isRoutedToCorrectMachine(
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { chain, result.getName(), context, stateMachineTransitions, tp,
effectiveEventTypeFqn)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
} else { } else {
@@ -143,7 +194,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.targetState(targetForLink) .targetState(targetForLink)
.event(smEventForLink) .event(smEventForLink)
.build(); .build();
if (isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions) if (isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp,
effectiveEventTypeFqn)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
@@ -163,7 +216,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()) {
@@ -201,10 +254,26 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
private boolean isRoutedToCorrectMachine( private boolean isRoutedToCorrectMachine(
CallChain chain, String currentMachineName, CodebaseContext context, List<Transition> machineTransitions) { CallChain chain,
String currentMachineName,
CodebaseContext context,
List<Transition> machineTransitions,
TriggerPoint triggerPoint,
String machineEventTypeFqn) {
if (triggerPoint != null
&& machineEventTypeFqn != null
&& CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(triggerPoint, machineEventTypeFqn, context)) {
return true;
}
return routingEngine.hasProvenMachineAffinity(chain, currentMachineName, context, machineTransitions); return routingEngine.hasProvenMachineAffinity(chain, currentMachineName, context, machineTransitions);
} }
private boolean isRoutedToCorrectMachine(
CallChain chain, String currentMachineName, CodebaseContext context, List<Transition> machineTransitions) {
return isRoutedToCorrectMachine(
chain, currentMachineName, context, machineTransitions, chain.getTriggerPoint(), null);
}
private boolean isConstraintCompatible(String constraint, String machineName) { private boolean isConstraintCompatible(String constraint, String machineName) {
if (constraint == null || machineName == null) { if (constraint == null || machineName == null) {
return true; return true;
@@ -218,13 +287,40 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
if (constraint == null || constraint.isBlank()) { if (constraint == null || constraint.isBlank()) {
return constraint; return constraint;
} }
String stripped = constraint.replaceAll("\"[^\"]+\"\\.equalsIgnoreCase\\(event\\)", "true"); // Event/payload binding clauses prove which event was selected — not which machine.
// Keep machineType/domain comparisons for isCompatibleWithMachineDomain.
String eventLike =
"(?i)(event|e|message|msg|payload|body|command|commandKey|commandkey|action|text)";
String stripped = constraint.replaceAll(
"\"[^\"]+\"\\s*\\.\\s*equalsIgnoreCase\\s*\\(\\s*" + eventLike + "\\s*\\)",
"true");
stripped = stripped.replaceAll(
eventLike + "\\s*\\.\\s*equalsIgnoreCase\\s*\\(\\s*\"[^\"]+\"\\s*\\)",
"true");
stripped = stripped.replaceAll(
"\"[^\"]+\"\\s*\\.\\s*equals\\s*\\(\\s*" + eventLike + "\\s*\\)",
"true");
stripped = stripped.replaceAll(
eventLike + "\\s*\\.\\s*equals\\s*\\(\\s*\"[^\"]+\"\\s*\\)",
"true");
stripped = stripped.replaceAll("&&\\s*&&+", "&&"); stripped = stripped.replaceAll("&&\\s*&&+", "&&");
stripped = stripped.replaceAll("^\\s*&&\\s*", ""); stripped = stripped.replaceAll("^\\s*&&\\s*", "");
stripped = stripped.replaceAll("\\s*&&\\s*$", ""); stripped = stripped.replaceAll("\\s*&&\\s*$", "");
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) {
// Bare constant (NEW) — treat as same unknown type within one machine analysis.
return "";
}
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) {
@@ -261,4 +357,114 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(); return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
} }
/**
* True when the entry path still has an unbound event-carrying placeholder
* ({@code {event}}, {@code {commandKey}}, …). Resource ids like {@code {id}} do not count.
*/
private static boolean isUnresolvedExternalEndpoint(CallChain chain, TriggerPoint trigger) {
if (chain == null) {
return false;
}
if (isUnresolvedGenericRestEndpoint(chain.getEntryPoint())) {
return true;
}
return isUnresolvedMessagingPayloadEndpoint(chain, trigger);
}
private static boolean isUnresolvedMessagingPayloadEndpoint(CallChain chain, TriggerPoint trigger) {
EntryPoint entryPoint = chain.getEntryPoint();
if (entryPoint == null || entryPoint.getType() == null || trigger == null) {
return false;
}
boolean messaging = switch (entryPoint.getType()) {
case JMS, KAFKA, RABBIT, SQS, SNS -> true;
default -> false;
};
if (!messaging) {
return false;
}
if (!MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
return false;
}
// Expander variants carry concrete payload bindings (same channel as REST pathBindings).
Map<String, String> bindings = chain.getPathBindings();
return bindings == null || bindings.isEmpty();
}
private static boolean isUnresolvedGenericRestEndpoint(EntryPoint entryPoint) {
if (entryPoint == null) {
return false;
}
if (hasUnboundEventPlaceholder(entryPoint.getName())) {
return true;
}
if (entryPoint.getMetadata() != null) {
Object path = entryPoint.getMetadata().get("path");
if (path instanceof String pathValue && hasUnboundEventPlaceholder(pathValue)) {
return true;
}
}
return false;
}
private static boolean hasUnboundEventPlaceholder(String pathOrName) {
if (pathOrName == null || pathOrName.isBlank() || !pathOrName.contains("{")) {
return false;
}
int start = 0;
while (true) {
int open = pathOrName.indexOf('{', start);
if (open < 0) {
return false;
}
int close = pathOrName.indexOf('}', open + 1);
if (close < 0) {
return false;
}
String placeholder = pathOrName.substring(open + 1, close).trim();
if (isEventLikePlaceholder(placeholder)) {
return true;
}
start = close + 1;
}
}
private static boolean isEventLikePlaceholder(String placeholder) {
if (placeholder == null || placeholder.isBlank()) {
return false;
}
String lower = placeholder.toLowerCase();
// Strip matrix/regex extras: event:.+ or event:.*
int colon = lower.indexOf(':');
if (colon > 0) {
lower = lower.substring(0, colon);
}
return lower.equals("event")
|| lower.equals("e")
|| lower.endsWith("event")
|| lower.equals("command")
|| lower.equals("commandkey")
|| lower.equals("transition")
|| lower.equals("action")
|| lower.equals("machinetype");
}
private static boolean hasConcreteEventEvidence(TriggerPoint trigger) {
if (trigger == null) {
return false;
}
// Only a resolved enum constant on the trigger event itself bypasses unbound {event}
// templates. Polymorphic widens / single-transition inference must not count — otherwise
// every generic dispatcher template becomes RESOLVED against a one-transition machine.
return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM;
}
private static String concreteOrFallback(String triggerTypeFqn, String machineTypeFqn) {
if (SharedServiceRoutingPolicy.isConcreteMachineType(triggerTypeFqn)) {
return triggerTypeFqn;
}
return machineTypeFqn;
}
} }

View File

@@ -131,7 +131,15 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
} }
private boolean typesMatch(String type1, String type2) { private boolean typesMatch(String type1, String type2) {
return MachineEnumCanonicalizer.enumTypesMatch(type1, type2, context); if (MachineEnumCanonicalizer.enumTypesMatch(type1, type2, context)) {
return true;
}
if (type1 != null && type2 != null) {
String machineFqn = type1.contains(".") ? type1 : type2;
String candidate = type1.contains(".") ? type2 : type1;
return MachineEnumCanonicalizer.enumTypesMatchForMachine(machineFqn, candidate, context);
}
return false;
} }
private String constantName(String event) { private String constantName(String event) {

View File

@@ -87,8 +87,8 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
// Precise FQN Type argument match from JDT bindings at sendEvent site // Precise FQN Type argument match from JDT bindings at sendEvent site
if (!hasExplicitBeanTarget && chain.getTriggerPoint() != null && context != null && currentMachineName != null) { if (!hasExplicitBeanTarget && chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn(); String triggerEventFqn = concreteTriggerType(chain.getTriggerPoint().getEventTypeFqn());
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn(); String triggerStateFqn = concreteTriggerType(chain.getTriggerPoint().getStateTypeFqn());
if (triggerEventFqn != null || triggerStateFqn != null) { if (triggerEventFqn != null || triggerStateFqn != null) {
String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context); String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context);
String machineStateFqn = machineTypes[0]; String machineStateFqn = machineTypes[0];
@@ -126,9 +126,16 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
if (mismatched) { if (mismatched) {
return false; return false;
} }
// Provable generic types but no machine match — fail closed (no package-name fallback). // Fail closed only when both trigger and machine generic types were resolved but
return false; // could not be reconciled. When machine config AST does not expose type args (stub
// config classes in tests, generated configs), fall through to single-SM / package routing.
boolean machineTypesKnown = machineEventFqn != null || machineStateFqn != null;
if (machineTypesKnown) {
return false;
}
} }
// Non-null-but-ambiguous type vars ("E"/"S") are ignored above — fall through so
// polymorphicEvents / shared-infrastructure / package evidence can decide.
} }
if (!isSingleStateMachine(context) && isAmbiguousSharedGenericTrigger(chain.getTriggerPoint())) { if (!isSingleStateMachine(context) && isAmbiguousSharedGenericTrigger(chain.getTriggerPoint())) {
@@ -137,7 +144,12 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
if (injectionMatch != null) { if (injectionMatch != null) {
return injectionMatch; return injectionMatch;
} }
return false; // Ambiguous generics without injection evidence: do not hard-fail here when the trigger
// still has concrete polymorphicEvents — callers with transition lists use
// hasProvenMachineAffinity + SharedServiceRoutingPolicy for that case.
if (!hasConcretePolymorphicEvents(chain.getTriggerPoint())) {
return false;
}
} }
Boolean packageMatch = PackageNameRoutingHeuristics.matches(chain, currentMachineName, explicitTarget); Boolean packageMatch = PackageNameRoutingHeuristics.matches(chain, currentMachineName, explicitTarget);
@@ -149,8 +161,8 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
private DistinctTypeAffinity resolveDistinctTypeAffinity( private DistinctTypeAffinity resolveDistinctTypeAffinity(
TriggerPoint trigger, String machineName, CodebaseContext context) { TriggerPoint trigger, String machineName, CodebaseContext context) {
String triggerEventFqn = trigger.getEventTypeFqn(); String triggerEventFqn = concreteTriggerType(trigger.getEventTypeFqn());
String triggerStateFqn = trigger.getStateTypeFqn(); String triggerStateFqn = concreteTriggerType(trigger.getStateTypeFqn());
if (triggerEventFqn == null && triggerStateFqn == null) { if (triggerEventFqn == null && triggerStateFqn == null) {
return DistinctTypeAffinity.UNKNOWN; return DistinctTypeAffinity.UNKNOWN;
} }
@@ -188,6 +200,27 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return DistinctTypeAffinity.UNKNOWN; return DistinctTypeAffinity.UNKNOWN;
} }
/** Concrete machine types only — unbound {@code E}/{@code S} and erasure placeholders → null. */
private static String concreteTriggerType(String typeFqn) {
return SharedServiceRoutingPolicy.isConcreteMachineType(typeFqn) ? typeFqn : null;
}
private static boolean hasConcretePolymorphicEvents(TriggerPoint trigger) {
if (trigger == null || trigger.getPolymorphicEvents() == null) {
return false;
}
for (String event : trigger.getPolymorphicEvents()) {
if (event == null || event.isBlank() || event.startsWith("<SYMBOLIC:")) {
continue;
}
if (event.startsWith("ENUM_SET:")) {
continue;
}
return true;
}
return false;
}
private boolean matchesConfiguredTransitionEvent( private boolean matchesConfiguredTransitionEvent(
TriggerPoint trigger, TriggerPoint trigger,
List<Transition> machineTransitions, List<Transition> machineTransitions,
@@ -221,7 +254,20 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
} }
private boolean isAmbiguousSharedGenericType(String typeFqn) { private boolean isAmbiguousSharedGenericType(String typeFqn) {
return isErasedOrOpaqueType(typeFqn) || isStringType(typeFqn); if (typeFqn == null || typeFqn.isBlank()) {
return false;
}
return !SharedServiceRoutingPolicy.isConcreteMachineType(typeFqn);
}
private boolean isErasedOrOpaqueType(String typeFqn) {
if (typeFqn == null) {
return false;
}
String erased = eraseGenerics(typeFqn);
return "java.lang.Object".equals(erased) || "Object".equals(erased)
|| "java.io.Serializable".equals(erased) || "Serializable".equals(erased)
|| (erased.length() == 1 && Character.isUpperCase(erased.charAt(0)));
} }
private boolean isDistinctTypeMismatched(String type1, String type2) { private boolean isDistinctTypeMismatched(String type1, String type2) {
@@ -321,15 +367,6 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return !type1.equals(type2); return !type1.equals(type2);
} }
private boolean isErasedOrOpaqueType(String typeFqn) {
if (typeFqn == null) {
return false;
}
String erased = eraseGenerics(typeFqn);
return "java.lang.Object".equals(erased) || "Object".equals(erased)
|| "java.io.Serializable".equals(erased) || "Serializable".equals(erased);
}
private boolean isSingleStateMachine(CodebaseContext context) { private boolean isSingleStateMachine(CodebaseContext context) {
return countStateMachines(context) <= 1; return countStateMachines(context) <= 1;
} }

View File

@@ -6,11 +6,13 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
* Decides whether a call chain is provably shared infrastructure that may intentionally * Decides whether a call chain is provably shared infrastructure that may intentionally
* attach to multiple state machines when they share the same transition event. * attach to multiple state machines when they share the same transition event.
* <p> * <p>
* Shared infrastructure requires source-derived evidence only: no distinct machine enum types * Shared infrastructure requires source-derived evidence only: no <em>concrete</em> machine enum
* at the sendEvent site and no vertical domain ownership in the call chain classes/packages. * types at the sendEvent site and no vertical domain ownership in the call chain classes/packages.
* Event-name matching alone is never sufficient outside this policy. * Unbound generic parameters ({@code StateMachine<S,E>} on a shared base class) and erasure
* ({@code Object}) are treated as absent — not as proven types. Event-name matching alone is never
* sufficient outside this policy.
*/ */
final class SharedServiceRoutingPolicy { public final class SharedServiceRoutingPolicy {
private SharedServiceRoutingPolicy() { private SharedServiceRoutingPolicy() {
} }
@@ -18,14 +20,35 @@ final class SharedServiceRoutingPolicy {
/** /**
* @return true when the chain may multi-attach to every machine that shares its transition event * @return true when the chain may multi-attach to every machine that shares its transition event
*/ */
static boolean isProvablySharedInfrastructure(CallChain chain) { public static boolean isProvablySharedInfrastructure(CallChain chain) {
if (chain == null) { if (chain == null) {
return false; return false;
} }
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain); MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
if (evidence.eventTypeFqn() != null || evidence.stateTypeFqn() != null) { if (isConcreteMachineType(evidence.eventTypeFqn()) || isConcreteMachineType(evidence.stateTypeFqn())) {
return false; return false;
} }
return !PackageNameRoutingHeuristics.hasVerticalDomainOwnership(chain); return !PackageNameRoutingHeuristics.hasVerticalDomainOwnership(chain);
} }
/**
* True when a trigger type FQN is a real state/event enum (or similar), not an unbound
* type variable / erasure placeholder from a shared generic base class.
*/
public static boolean isConcreteMachineType(String typeFqn) {
if (typeFqn == null || typeFqn.isBlank()) {
return false;
}
String erased = typeFqn;
int generic = erased.indexOf('<');
if (generic >= 0) {
erased = erased.substring(0, generic);
}
if (erased.length() == 1 && Character.isUpperCase(erased.charAt(0))) {
return false;
}
return !"java.lang.Object".equals(erased) && !"Object".equals(erased)
&& !"java.io.Serializable".equals(erased) && !"Serializable".equals(erased)
&& !"java.lang.String".equals(erased) && !"String".equals(erased);
}
} }

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,5 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.resolver; package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -70,6 +72,18 @@ public final class BooleanConstraintEvaluator {
* is used so {@code command == ORDER_PAY} matches {@code DomainCommand.ORDER_PAY}. * is used so {@code command == ORDER_PAY} matches {@code DomainCommand.ORDER_PAY}.
*/ */
public static boolean isCompatibleWithBindings(String constraint, Map<String, String> bindings) { public static boolean isCompatibleWithBindings(String constraint, Map<String, String> bindings) {
return isCompatibleWithBindings(constraint, bindings, null, null);
}
/**
* Like {@link #isCompatibleWithBindings(String, Map)} but reconstructs truncated enum refs
* using {@code preferredEnumTypeFqn} before treating them as concrete.
*/
public static boolean isCompatibleWithBindings(
String constraint,
Map<String, String> bindings,
String preferredEnumTypeFqn,
CodebaseContext context) {
if (constraint == null || constraint.isBlank()) { if (constraint == null || constraint.isBlank()) {
return true; return true;
} }
@@ -84,39 +98,81 @@ public final class BooleanConstraintEvaluator {
} }
String expr = constraint; String expr = constraint;
for (Map.Entry<String, String> entry : bindings.entrySet()) { for (Map.Entry<String, String> entry : bindings.entrySet()) {
if (!isConcreteBindingValue(entry.getKey(), entry.getValue())) { String concreteValue = concreteBindingValue(
entry.getKey(), entry.getValue(), preferredEnumTypeFqn, context);
if (concreteValue == null) {
continue; continue;
} }
expr = substituteVariableBindings(expr, entry.getKey(), entry.getValue()); expr = substituteVariableBindings(expr, entry.getKey(), concreteValue);
expr = substituteEqualsLiteralBindings(expr, entry.getKey(), entry.getValue()); expr = substituteEqualsLiteralBindings(expr, entry.getKey(), concreteValue);
} }
return evaluateBooleanExpression(expr); return evaluateBooleanExpression(expr);
} }
private static boolean isConcreteBindingValue(String paramName, String boundValue) { private static boolean isConcreteBindingValue(String paramName, String boundValue) {
return concreteBindingValue(paramName, boundValue, null, null) != null;
}
/**
* @return concrete value to substitute (possibly reconstructed), or {@code null} if not concrete
*/
private static String concreteBindingValue(
String paramName,
String boundValue,
String preferredEnumTypeFqn,
CodebaseContext context) {
if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) { if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) {
return false; return null;
} }
if (boundValue.contains("(") && !boundValue.contains("valueOf")) { if (isIncompleteDottedName(boundValue)) {
return false; return null;
} }
if (boundValue.startsWith("\"") && boundValue.endsWith("\"")) { String candidate = boundValue;
return true; if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(boundValue)) {
if (context == null) {
return null;
}
candidate = TruncatedEnumRefReconstructor.reconstruct(
boundValue, preferredEnumTypeFqn, context);
if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(candidate)) {
return null;
}
} }
if ("true".equalsIgnoreCase(boundValue) || "false".equalsIgnoreCase(boundValue)) { if (candidate.contains("(") && !candidate.contains("valueOf")) {
return true; return null;
} }
if (boundValue.contains(".") && Character.isUpperCase(boundValue.charAt(boundValue.lastIndexOf('.') + 1))) { if (candidate.startsWith("\"") && candidate.endsWith("\"")) {
return true; return candidate;
}
if ("true".equalsIgnoreCase(candidate) || "false".equalsIgnoreCase(candidate)) {
return candidate;
}
int lastDot = candidate.lastIndexOf('.');
if (lastDot >= 0
&& lastDot + 1 < candidate.length()
&& Character.isUpperCase(candidate.charAt(lastDot + 1))) {
return candidate;
} }
// Treat simple routing keys (e.g. order.pay) as concrete string bindings, but avoid // Treat simple routing keys (e.g. order.pay) as concrete string bindings, but avoid
// mistaking variable names (e.g. machineType) for concrete values. // mistaking variable names (e.g. machineType) for concrete values.
if ((boundValue.contains(".") || boundValue.contains("/") || boundValue.contains("-")) if ((candidate.contains(".") || candidate.contains("/") || candidate.contains("-"))
&& boundValue.matches("^[a-zA-Z0-9._/-]+$")) { && candidate.matches("^[a-zA-Z0-9._/-]+$")) {
return true; return candidate;
} }
return boundValue.equals(boundValue.toUpperCase(Locale.ROOT)) && boundValue.chars().allMatch(ch -> if (candidate.equals(candidate.toUpperCase(Locale.ROOT)) && candidate.chars().allMatch(ch ->
Character.isUpperCase(ch) || ch == '_'); Character.isUpperCase(ch) || ch == '_')) {
return candidate;
}
return null;
}
/**
* True for truncated/malformed dotted names such as {@code com.example.DomainCommand.} or {@code .PAY}.
*/
public static boolean isIncompleteDottedName(String value) {
return value != null
&& !value.isBlank()
&& (value.startsWith(".") || value.endsWith(".") || value.contains(".."));
} }
private static String substituteEqualsLiteralBindings(String expr, String varName, String boundValue) { private static String substituteEqualsLiteralBindings(String expr, String varName, String boundValue) {

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.*;
@@ -393,6 +392,9 @@ public class ConstantResolver {
localVars.put(entry.getKey(), entry.getValue()); localVars.put(entry.getKey(), entry.getValue());
String bareName = entry.getKey().substring(5); String bareName = entry.getKey().substring(5);
if (!declaredLocals.contains(bareName)) localVars.put(bareName, entry.getValue()); if (!declaredLocals.contains(bareName)) localVars.put(bareName, entry.getValue());
} else {
localVars.put(entry.getKey(), entry.getValue());
if (!declaredLocals.contains(entry.getKey())) localVars.put("this." + entry.getKey(), entry.getValue());
} }
} }
} }
@@ -714,13 +716,6 @@ public class ConstantResolver {
} }
} }
String ret = evaluateMethodBodyWithLocals(sideEffectMd, inlineLocals, context, visited); String ret = evaluateMethodBodyWithLocals(sideEffectMd, inlineLocals, context, visited);
for (java.util.Map.Entry<String, String> entry : inlineLocals.entrySet()) {
if (entry.getKey().startsWith("this.")) {
paramValues.put(entry.getKey(), entry.getValue());
String bareName = entry.getKey().substring(5);
paramValues.put(bareName, entry.getValue());
}
}
return ret; return ret;
} }
} }
@@ -850,7 +845,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 +917,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 +929,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 +955,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

@@ -70,6 +70,35 @@ public final class EnumMemberPredicateEvaluator {
return calls; return calls;
} }
/**
* Evaluate a single no-arg boolean predicate on one enum constant (method name from dataflow/AST).
*/
public static Boolean evaluateConstantPredicate(
String canonicalConstantFqn,
String enumTypeFqn,
String methodName,
boolean negated,
CodebaseContext context) {
if (canonicalConstantFqn == null || enumTypeFqn == null || methodName == null || context == null) {
return null;
}
EnumDeclaration enumDecl = findEnumDeclaration(enumTypeFqn, context);
if (enumDecl == null) {
return null;
}
String constantName = constantSimpleName(canonicalConstantFqn);
if (constantName == null) {
return null;
}
Map<String, Boolean> boolFields = resolveConstantBooleanFields(enumDecl, constantName);
Boolean methodResult = evaluateBooleanMethod(
enumDecl, methodName, constantName, boolFields, context);
if (methodResult == null) {
return null;
}
return negated ? !methodResult : methodResult;
}
public static List<String> filterEnumConstants( public static List<String> filterEnumConstants(
List<String> candidates, List<String> candidates,
String constraint, String constraint,

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,13 +303,17 @@ 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)
.ambiguous(narrowed.size() > 1 && expanded.isAmbiguous()) .ambiguous(narrowed.size() > 1)
.build(); .build();
} }
if (narrowed.size() > 1 && !expanded.isAmbiguous()) {
return expanded.toBuilder().ambiguous(true).build();
}
return expanded; return expanded;
} }
if (shouldInferPolymorphicEvents(expanded, machineTransitions, context)) { if (shouldInferPolymorphicEvents(expanded, machineTransitions, context)) {
@@ -317,7 +321,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 +419,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 +431,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 +449,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 +468,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 +797,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());
@@ -835,27 +882,51 @@ public final class MachineEnumCanonicalizer {
return value; return value;
} }
if (context != null) {
String reconstructed = TruncatedEnumRefReconstructor.reconstruct(stripped, enumTypeFqn, context);
if (reconstructed != null && !reconstructed.equals(stripped)) {
stripped = reconstructed;
value = reconstructed;
}
}
if (stripped.startsWith(".") || stripped.endsWith(".") || stripped.contains("..")) {
// Truncated/malformed refs (e.g. com.example.DomainCommand.) are not canonical.
return value;
}
if (stripped.startsWith(enumTypeFqn + ".")) { if (stripped.startsWith(enumTypeFqn + ".")) {
return stripped; String suffix = stripped.substring(enumTypeFqn.length() + 1);
if (!suffix.isEmpty()) {
return stripped;
}
return value;
} }
String constant = constantName(stripped); String constant = constantName(stripped);
String typePart = enumTypeFromRef(stripped); String typePart = enumTypeFromRef(stripped);
if (typePart != null && context != null && context.isAmbiguousSimpleName(typePart)) { if (typePart != null && !typePart.contains(".") && importStyleEnumTypeMatches(typePart, enumTypeFqn)
return stripped;
}
if (typePart != null && enumTypesMatch(enumTypeFqn, typePart, context)) {
return enumTypeFqn + "." + constant;
}
if (typePart != null && !typePart.contains(".") && !enumTypesMatch(enumTypeFqn, typePart, context)
&& importStyleEnumTypeMatches(typePart, enumTypeFqn)
&& constant.matches("[A-Z_][A-Z0-9_]*")) { && constant.matches("[A-Z_][A-Z0-9_]*")) {
return enumTypeFqn + "." + constant; return enumTypeFqn + "." + constant;
} }
if (typePart != null && !constant.isEmpty() && enumTypesMatch(enumTypeFqn, typePart, context)) {
return enumTypeFqn + "." + constant;
}
if (typePart != null && context != null && context.isAmbiguousSimpleName(typePart)) {
// Prefer machine-scoped qualification when preferred enum type is known.
if (constant.matches("[A-Z_][A-Z0-9_]*")
&& (importStyleEnumTypeMatches(typePart, enumTypeFqn)
|| enumTypesMatch(enumTypeFqn, typePart, context)
|| simpleName(enumTypeFqn).equals(typePart)
|| simpleName(enumTypeFqn).equals(simpleName(typePart)))) {
return enumTypeFqn + "." + constant;
}
return stripped;
}
if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) { if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) {
return enumTypeFqn + "." + stripped; return enumTypeFqn + "." + stripped;
} }
@@ -917,6 +988,47 @@ public final class MachineEnumCanonicalizer {
return enumTypesMatch(type1, type2); return enumTypesMatch(type1, type2);
} }
/**
* Whether a concrete polymorphic enum constant belongs to the configured machine event type.
* Uses machine context to accept import-style labels even when the enum simple name is
* ambiguous across the scanned codebase.
*/
public static boolean polymorphicEventMatchesMachineEventType(
String polymorphicEvent,
String machineEventTypeFqn,
CodebaseContext context) {
if (polymorphicEvent == null || machineEventTypeFqn == null || !polymorphicEvent.contains(".")) {
return false;
}
if (polymorphicEvent.startsWith("<SYMBOLIC:") || polymorphicEvent.startsWith("ENUM_SET:")) {
return false;
}
String enumType = polymorphicEvent.substring(0, polymorphicEvent.lastIndexOf('.'));
if (machineEventTypeFqn.equals(enumType)) {
return true;
}
if (importStyleEnumTypeMatches(enumType, machineEventTypeFqn)) {
return true;
}
return enumType.contains(".") && enumTypesMatch(machineEventTypeFqn, enumType, context);
}
public static boolean enumTypesMatchForMachine(
String machineEnumFqn,
String candidateType,
CodebaseContext context) {
if (machineEnumFqn == null || candidateType == null) {
return false;
}
if (machineEnumFqn.equals(candidateType)) {
return true;
}
if (importStyleEnumTypeMatches(candidateType, machineEnumFqn)) {
return true;
}
return candidateType.contains(".") && enumTypesMatch(machineEnumFqn, candidateType, context);
}
private static String simpleName(String fqn) { private static String simpleName(String fqn) {
return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn; return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
} }
@@ -1024,10 +1136,10 @@ 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();
List<String> boundEventLiterals = extractBoundEventLiteralsFromConstraint(constraint); // Dataflow-only: never invent event constants from REST URL path segments.
if (boundEventLiterals.size() != 1) { if (boundEventLiterals.size() != 1) {
return trigger; return trigger;
} }
@@ -1035,17 +1147,13 @@ public final class MachineEnumCanonicalizer {
if (eventTypeFqn == null || eventTypeFqn.isBlank()) { if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
return trigger; return trigger;
} }
String literal = boundEventLiterals.get(0).toUpperCase(); String literal = boundEventLiterals.get(0);
if (entryPoint != null && entryPoint.getName() != null // Command keys like "order.pay" are not enum constants — take the trailing segment.
&& !entryPoint.getName().toUpperCase().contains("/" + literal + "/") int lastDot = literal.lastIndexOf('.');
&& !entryPoint.getName().toUpperCase().endsWith("/" + literal)) { if (lastDot >= 0 && lastDot + 1 < literal.length()) {
return trigger; literal = literal.substring(lastDot + 1);
}
String machineTypeLiteral = extractBoundParamLiteralFromConstraint(constraint, "machineType");
if (machineTypeLiteral != null && entryPoint != null && entryPoint.getName() != null
&& !entryPoint.getName().toUpperCase().contains("/" + machineTypeLiteral.toUpperCase() + "/")) {
return trigger;
} }
literal = literal.toUpperCase();
String constantFqn = eventTypeFqn + "." + literal; String constantFqn = eventTypeFqn + "." + literal;
if (context != null) { if (context != null) {
List<String> machineEnumValues = context.getEnumValues(eventTypeFqn); List<String> machineEnumValues = context.getEnumValues(eventTypeFqn);
@@ -1060,23 +1168,18 @@ public final class MachineEnumCanonicalizer {
.build(); .build();
} }
private static String extractBoundParamLiteralFromConstraint(String constraint, String paramName) {
java.util.regex.Matcher matcher = java.util.regex.Pattern
.compile("\"([^\"]+)\"\\.equalsIgnoreCase\\(" + paramName + "\\)")
.matcher(constraint);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
private static List<String> extractBoundEventLiteralsFromConstraint(String constraint) { private static List<String> extractBoundEventLiteralsFromConstraint(String constraint) {
List<String> literals = new ArrayList<>(); List<String> literals = new ArrayList<>();
java.util.regex.Matcher matcher = java.util.regex.Pattern java.util.regex.Matcher matcher = java.util.regex.Pattern
.compile("\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)") .compile("\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)")
.matcher(constraint); .matcher(constraint);
while (matcher.find()) { while (matcher.find()) {
if ("event".equals(matcher.group(2))) { String param = matcher.group(2);
if ("event".equals(param)
|| "eventString".equals(param)
|| "actionKey".equals(param)
|| "action".equals(param)
|| "commandKey".equals(param)) {
literals.add(matcher.group(1)); literals.add(matcher.group(1));
} }
} }

View File

@@ -0,0 +1,291 @@
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.BooleanLiteral;
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;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Evaluates boolean predicates on rich/domain event types (e.g. {@code myEvent.isCorrect()})
* and maps matching implementations to machine enum constants via type-accessor methods
* discovered from the trigger expression / receiver type — no hard-coded predicate names.
*/
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));
List<String> typeAccessors = typeAccessorMethods(trigger, predicate.receiverName(), receiverTypeFqn, machineEnumFqn, context);
Set<String> matched = new LinkedHashSet<>();
for (String ownerFqn : ownerTypes) {
if (!predicateMatches(ownerFqn, predicate, machineEnumFqn, context)) {
continue;
}
for (String accessor : typeAccessors) {
String typeConstant = resolveDirectEnumFromPredicateMethod(ownerFqn, accessor, 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;
}
/**
* Type-accessor method names come from the trigger event expression (dataflow), e.g.
* {@code myEvent.getType()} → {@code getType}, falling back to parameterless methods on the
* receiver type that return the machine enum.
*/
private static List<String> typeAccessorMethods(
TriggerPoint trigger,
String receiverName,
String receiverTypeFqn,
String machineEnumFqn,
CodebaseContext context) {
LinkedHashSet<String> names = new LinkedHashSet<>();
String event = trigger.getEvent();
if (event != null && receiverName != null && !receiverName.isBlank()) {
Pattern pattern = Pattern.compile(
Pattern.quote(receiverName) + "\\s*\\.\\s*([a-zA-Z_][\\w]*)\\s*\\(");
Matcher matcher = pattern.matcher(event);
while (matcher.find()) {
names.add(matcher.group(1));
}
}
if (names.isEmpty() && receiverTypeFqn != null && machineEnumFqn != null && context != null) {
names.addAll(enumReturningMethods(receiverTypeFqn, machineEnumFqn, context));
}
return List.copyOf(names);
}
private static List<String> enumReturningMethods(
String receiverTypeFqn,
String machineEnumFqn,
CodebaseContext context) {
TypeDeclaration type = context.getTypeDeclaration(receiverTypeFqn);
if (type == null) {
return List.of();
}
TypeResolver typeResolver = new TypeResolver(context);
LinkedHashSet<String> names = new LinkedHashSet<>();
for (Object declObj : type.bodyDeclarations()) {
if (!(declObj instanceof MethodDeclaration method) || method.isConstructor()) {
continue;
}
if (!method.parameters().isEmpty() || method.getReturnType2() == null) {
continue;
}
String returnFqn = typeResolver.resolveTypeToFqn(method.getReturnType2(), type.getRoot());
if (returnFqn != null) {
int generic = returnFqn.indexOf('<');
if (generic >= 0) {
returnFqn = returnFqn.substring(0, generic);
}
}
if (machineEnumFqn.equals(returnFqn)) {
names.add(method.getName().getIdentifier());
}
}
return List.copyOf(names);
}
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,
String machineEnumFqn,
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(), ownerFqn, machineEnumFqn, context);
}
return super.visit(node);
}
});
if (result[0] == null) {
return false;
}
return predicate.negated() ? !result[0] : result[0];
}
private static Boolean evaluateBoolean(
Expression expression,
String ownerFqn,
String machineEnumFqn,
CodebaseContext context) {
if (expression instanceof BooleanLiteral literal) {
return literal.booleanValue();
}
// Delegate chained enum predicates (e.g. getCommand().isActive()) to enum-member evaluation —
// method names come from the AST, not a hard-coded list.
if (expression instanceof MethodInvocation invocation
&& invocation.arguments().isEmpty()
&& invocation.getExpression() instanceof MethodInvocation typeCall
&& typeCall.arguments().isEmpty()) {
String enumConstant = resolveDirectEnumFromPredicateMethod(
ownerFqn, typeCall.getName().getIdentifier(), context);
if (enumConstant != null && machineEnumFqn != null) {
return EnumMemberPredicateEvaluator.evaluateConstantPredicate(
enumConstant,
machineEnumFqn,
invocation.getName().getIdentifier(),
false,
context);
}
}
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 typeCall
&& typeCall.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

@@ -0,0 +1,224 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Repairs truncated enum references produced by older resolution bugs, e.g.
* {@code com.example.PAY} (package + constant, type segment dropped) →
* {@code com.example.OrderEvent.PAY} when the match is unique or matches a preferred type.
*/
public final class TruncatedEnumRefReconstructor {
private static final Pattern ENUM_CONSTANT = Pattern.compile("[A-Z_][A-Z0-9_]*");
private TruncatedEnumRefReconstructor() {
}
/**
* @return reconstructed FQN when uniquely determined; otherwise the original {@code value}
*/
public static String reconstruct(String value, CodebaseContext context) {
return reconstruct(value, null, context);
}
/**
* @param preferredEnumTypeFqn optional machine/event type that disambiguates when several enums
* share the same constant name under a package prefix
* @return reconstructed FQN when uniquely determined; otherwise the original {@code value}
*/
public static String reconstruct(String value, String preferredEnumTypeFqn, CodebaseContext context) {
if (value == null || value.isBlank() || context == null) {
return value;
}
String stripped = stripQuotes(value.trim());
if (stripped.isBlank() || stripped.contains("(") || stripped.contains(")")) {
return value;
}
if (stripped.startsWith(".") || stripped.contains("..")) {
return value;
}
// Type-only trailing dot (com.example.OrderEvent.) — no constant to recover.
if (stripped.endsWith(".")) {
return value;
}
int lastDot = stripped.lastIndexOf('.');
if (lastDot < 0) {
return reconstructBareConstant(stripped, preferredEnumTypeFqn, context, value);
}
if (lastDot == 0 || lastDot >= stripped.length() - 1) {
return value;
}
String prefix = stripped.substring(0, lastDot);
String constant = stripped.substring(lastDot + 1);
if (!ENUM_CONSTANT.matcher(constant).matches()) {
return value;
}
// Already a known Type.CONSTANT (simple or FQN) — normalize to indexed FQN when possible.
if (isKnownEnumType(prefix, context)) {
String canonical = canonicalConstantRef(prefix, constant, context);
return canonical != null ? canonical : value;
}
// Preferred type: classic truncation com.example.PAY + preferred com.example.OrderEvent.
if (preferredEnumTypeFqn != null
&& !preferredEnumTypeFqn.isBlank()
&& enumHasConstant(preferredEnumTypeFqn, constant, context)
&& prefixMatchesPreferredType(prefix, preferredEnumTypeFqn)) {
return preferredEnumTypeFqn + "." + constant;
}
List<String> matches = findConstantsUnderPrefix(prefix, constant, context);
if (matches.size() == 1) {
return matches.get(0);
}
if (preferredEnumTypeFqn != null) {
for (String match : matches) {
if (match.startsWith(preferredEnumTypeFqn + ".")) {
return match;
}
}
}
return value;
}
/**
* True when {@code value} looks like the old package+CONSTANT truncation
* ({@code com.example.PAY}) rather than a real {@code Type.CONSTANT} or routing key.
*/
public static boolean looksLikePackageTruncatedEnumRef(String value) {
if (value == null || value.isBlank() || value.contains("(")) {
return false;
}
String stripped = stripQuotes(value.trim());
int lastDot = stripped.lastIndexOf('.');
if (lastDot <= 0 || lastDot >= stripped.length() - 1) {
return false;
}
String constant = stripped.substring(lastDot + 1);
if (!ENUM_CONSTANT.matcher(constant).matches()) {
return false;
}
String prefix = stripped.substring(0, lastDot);
int prevDot = prefix.lastIndexOf('.');
String lastSegment = prevDot >= 0 ? prefix.substring(prevDot + 1) : prefix;
return !lastSegment.isEmpty() && Character.isLowerCase(lastSegment.charAt(0));
}
private static String reconstructBareConstant(
String constant, String preferredEnumTypeFqn, CodebaseContext context, String original) {
if (!ENUM_CONSTANT.matcher(constant).matches() || preferredEnumTypeFqn == null) {
return original;
}
if (enumHasConstant(preferredEnumTypeFqn, constant, context)) {
return preferredEnumTypeFqn + "." + constant;
}
return original;
}
private static boolean prefixMatchesPreferredType(String prefix, String preferredEnumTypeFqn) {
if (prefix.equals(preferredEnumTypeFqn)) {
return true;
}
String preferredPackage = packageOf(preferredEnumTypeFqn);
if (prefix.equals(preferredPackage)) {
return true;
}
return preferredEnumTypeFqn.startsWith(prefix + ".");
}
private static List<String> findConstantsUnderPrefix(String prefix, String constant, CodebaseContext context) {
LinkedHashSet<String> matches = new LinkedHashSet<>();
for (Map.Entry<String, List<String>> entry : context.getEnumValuesMap().entrySet()) {
String enumFqn = entry.getKey();
if (!enumUnderPrefix(enumFqn, prefix)) {
continue;
}
String candidate = enumFqn + "." + constant;
List<String> values = entry.getValue();
if (values == null) {
continue;
}
if (values.contains(candidate)
|| values.stream().anyMatch(v -> v != null && v.endsWith("." + constant))) {
matches.add(candidate);
}
}
return new ArrayList<>(matches);
}
private static boolean enumUnderPrefix(String enumFqn, String prefix) {
if (enumFqn == null || prefix == null) {
return false;
}
if (enumFqn.equals(prefix)) {
return true;
}
if (enumFqn.startsWith(prefix + ".")) {
return true;
}
return prefix.equals(packageOf(enumFqn));
}
private static boolean isKnownEnumType(String typeName, CodebaseContext context) {
if (typeName == null || typeName.isBlank()) {
return false;
}
if (context.getEnumValuesMap().containsKey(typeName)) {
return true;
}
List<String> values = context.getEnumValues(typeName);
return values != null && !values.isEmpty();
}
private static boolean enumHasConstant(String enumTypeFqn, String constant, CodebaseContext context) {
List<String> values = context.getEnumValues(enumTypeFqn);
if (values == null || values.isEmpty()) {
return false;
}
String suffix = "." + constant;
for (String value : values) {
if (value != null && (value.endsWith(suffix) || value.equals(constant))) {
return true;
}
}
return false;
}
private static String canonicalConstantRef(String typeName, String constant, CodebaseContext context) {
List<String> values = context.getEnumValues(typeName);
if (values == null) {
return null;
}
String suffix = "." + constant;
for (String value : values) {
if (value != null && value.endsWith(suffix)) {
return value;
}
}
return null;
}
private static String packageOf(String typeFqn) {
int dot = typeFqn.lastIndexOf('.');
return dot > 0 ? typeFqn.substring(0, dot) : "";
}
private static String stripQuotes(String value) {
if (value.length() >= 2
&& ((value.startsWith("\"") && value.endsWith("\""))
|| (value.startsWith("'") && value.endsWith("'")))) {
return value.substring(1, value.length() - 1);
}
return value;
}
}

View File

@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver; import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder; import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper; import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport; import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget; import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
@@ -34,39 +35,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 +62,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 +84,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,14 +128,17 @@ 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) {
resolvedTp = TriggerMachineTypeRebinder.rebind(resolvedTp, path, context);
String contextMachineId = pathFinder.extractContextMachineId(path, callGraph); String contextMachineId = pathFinder.extractContextMachineId(path, callGraph);
chains.add(CallChain.builder() chains.add(CallChain.builder()
.entryPoint(chainEntryPoint) .entryPoint(chainEntryPoint)
.triggerPoint(resolvedTp) .triggerPoint(resolvedTp)
.methodChain(path) .methodChain(path)
.contextMachineId(contextMachineId) .contextMachineId(contextMachineId)
.pathBindings(pathBindings.isEmpty() ? null : Map.copyOf(pathBindings))
.build()); .build());
} }
} }
@@ -175,6 +156,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,11 +265,13 @@ 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();
event = unwrapConverterMethods(event); String triggerScope = path.isEmpty() ? null : path.get(path.size() - 1);
event = unwrapConverterMethods(event, triggerScope);
String currentParamName = event; String currentParamName = event;
String resolvedValue = event; String resolvedValue = event;
String methodSuffix = ""; String methodSuffix = "";
@@ -353,7 +363,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (paramIndex < edge.getArguments().size()) { if (paramIndex < edge.getArguments().size()) {
String arg = edge.getArguments().get(paramIndex); String arg = edge.getArguments().get(paramIndex);
if (arg != null) { if (arg != null) {
arg = unwrapConverterMethods(arg); arg = unwrapConverterMethods(arg, caller);
String receiver = edge.getReceiver(); String receiver = edge.getReceiver();
String actualReceiver = null; String actualReceiver = null;
if ("this".equals(arg) || arg.startsWith("this.") || "super".equals(arg) || arg.startsWith("super.")) { if ("this".equals(arg) || arg.startsWith("this.") || "super".equals(arg) || arg.startsWith("super.")) {
@@ -585,28 +595,34 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
Object arg = mi.arguments().get(0); Object arg = mi.arguments().get(0);
String enumTypeName = resolveValueOfEnumTypeName(mi); String enumTypeName = resolveValueOfEnumTypeName(mi);
if (arg instanceof StringLiteral sl) { if (arg instanceof StringLiteral sl) {
if (enumTypeName != null) { String literal = sl.getLiteralValue();
polymorphicEvents.add(enumTypeName + "." + sl.getLiteralValue()); if (literal != null && !literal.isBlank()) {
} else { if (enumTypeName != null) {
polymorphicEvents.add(sl.getLiteralValue()); polymorphicEvents.add(reconstructEnumRef(enumTypeName + "." + literal));
} else {
polymorphicEvents.add(reconstructEnumRef(literal));
}
} }
methodName = null; methodName = null;
handledStaticEnum = true; handledStaticEnum = true;
} else if (arg instanceof QualifiedName qn) { } else if (arg instanceof QualifiedName qn) {
if (enumTypeName != null) { String constant = qn.getName().getIdentifier();
polymorphicEvents.add(enumTypeName + "." + qn.getName().getIdentifier()); if (constant != null && !constant.isBlank()) {
} else { if (enumTypeName != null) {
polymorphicEvents.add(qn.getName().getIdentifier()); polymorphicEvents.add(reconstructEnumRef(enumTypeName + "." + constant));
} else {
polymorphicEvents.add(reconstructEnumRef(constant));
}
} }
methodName = null; methodName = null;
handledStaticEnum = true; handledStaticEnum = true;
} else if (arg instanceof SimpleName sn) { } else if (arg instanceof SimpleName sn) {
String resolvedConstant = resolveEnumConstantFromArgument(sn.getIdentifier(), path, callGraph); String resolvedConstant = resolveEnumConstantFromArgument(sn.getIdentifier(), path, callGraph);
if (resolvedConstant != null) { if (resolvedConstant != null && !resolvedConstant.isBlank()) {
if (enumTypeName != null) { if (enumTypeName != null) {
polymorphicEvents.add(enumTypeName + "." + resolvedConstant); polymorphicEvents.add(reconstructEnumRef(enumTypeName + "." + resolvedConstant));
} else { } else {
polymorphicEvents.add(resolvedConstant); polymorphicEvents.add(reconstructEnumRef(resolvedConstant));
} }
methodName = null; methodName = null;
handledStaticEnum = true; handledStaticEnum = true;
@@ -1028,16 +1044,36 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
&& (fallbackMi.getExpression() == null || fallbackMi.getExpression() instanceof ThisExpression)) { && (fallbackMi.getExpression() == null || fallbackMi.getExpression() instanceof ThisExpression)) {
TypeDeclaration enclosingType = findEnclosingType(fallbackMi); TypeDeclaration enclosingType = findEnclosingType(fallbackMi);
CompilationUnit fallbackCu = fallbackMi.getRoot() instanceof CompilationUnit cu ? cu : null; CompilationUnit fallbackCu = fallbackMi.getRoot() instanceof CompilationUnit cu ? cu : null;
List<String> ownerCandidates = new ArrayList<>();
if (enclosingType != null) { if (enclosingType != null) {
List<String> switchConstants = resolveLocalSwitchMethodConstants(enclosingType, fallbackMi); ownerCandidates.add(context.getFqn(enclosingType));
for (String switchConstant : switchConstants) { }
if (switchConstant != null && !switchConstant.startsWith("<SYMBOLIC:") // String-parsed expressions have no enclosing AST type — walk the call path instead.
&& !polymorphicEvents.contains(switchConstant)) { for (String methodFqn : path) {
polymorphicEvents.add(switchConstant); if (methodFqn != null && methodFqn.contains(".")) {
String owner = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
if (!ownerCandidates.contains(owner)) {
ownerCandidates.add(owner);
}
}
}
for (String ownerFqn : ownerCandidates) {
TypeDeclaration ownerTd = context.getTypeDeclaration(ownerFqn);
if (ownerTd == null
|| context.findMethodDeclaration(ownerTd, fallbackMi.getName().getIdentifier(), true) == null) {
continue;
}
if (enclosingType != null && ownerFqn.equals(context.getFqn(enclosingType))) {
List<String> switchConstants = resolveLocalSwitchMethodConstants(enclosingType, fallbackMi);
for (String switchConstant : switchConstants) {
if (switchConstant != null && !switchConstant.startsWith("<SYMBOLIC:")
&& !polymorphicEvents.contains(switchConstant)) {
polymorphicEvents.add(switchConstant);
}
} }
} }
List<String> methodReturns = constantExtractor.resolveMethodReturnConstant( List<String> methodReturns = constantExtractor.resolveMethodReturnConstant(
context.getFqn(enclosingType), ownerFqn,
fallbackMi.getName().getIdentifier(), fallbackMi.getName().getIdentifier(),
0, 0,
new HashSet<>(), new HashSet<>(),
@@ -1048,6 +1084,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
polymorphicEvents.add(methodReturn); polymorphicEvents.add(methodReturn);
} }
} }
if (!polymorphicEvents.isEmpty()) {
break;
}
} }
} }
} }
@@ -1170,7 +1209,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return tp; return tp;
} finally { } finally {
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath(); context.clearAnalysisCallPath();
} }
} }
@@ -1206,6 +1245,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 +1897,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 +2038,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 +2141,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;
@@ -2174,7 +2288,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|| typeName.endsWith("PathParam") || typeName.endsWith("QueryParam")) { || typeName.endsWith("PathParam") || typeName.endsWith("QueryParam")) {
return true; return true;
} }
if (typeName.endsWith("RequestBody")) { if (typeName.endsWith("RequestBody") || typeName.endsWith("Payload")) {
ITypeBinding typeBinding = svd.getType().resolveBinding(); ITypeBinding typeBinding = svd.getType().resolveBinding();
if (typeBinding != null && typeBinding.isEnum()) { if (typeBinding != null && typeBinding.isEnum()) {
return false; return false;
@@ -2587,18 +2701,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String baseCalled = resolveCalledMethod(node); String baseCalled = resolveCalledMethod(node);
if (baseCalled == null) return Collections.emptyList(); if (baseCalled == null) return Collections.emptyList();
if (node.getExpression() == null) { if (!baseCalled.contains(".")) {
return Collections.singletonList(baseCalled); return Collections.singletonList(baseCalled);
} }
if (!baseCalled.contains(".")) { // Plain doX(m) and this.doX(m) are the same virtual dispatch on the enclosing
// instance. Returning early on a null expression skipped subclass expansion for the
// classic template-method pattern (abstract base calls doPMessage without "this.").
boolean isImplicitThis =
node.getExpression() == null || node.getExpression() instanceof ThisExpression;
if (isImplicitThis && isStaticResolvedMethod(baseCalled)) {
return Collections.singletonList(baseCalled); return Collections.singletonList(baseCalled);
} }
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.')); String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1); String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className); TypeDeclaration td = context.getTypeDeclaration(className);
boolean isImplicitThis = node.getExpression() instanceof ThisExpression;
String cacheKey = baseCalled + "#" + isImplicitThis; String cacheKey = baseCalled + "#" + isImplicitThis;
List<String> cached = polymorphicCallCache.get(cacheKey); List<String> cached = polymorphicCallCache.get(cacheKey);
if (cached != null) { if (cached != null) {
@@ -2613,7 +2731,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
shouldExpand = false; shouldExpand = false;
} else if (td == null) { } else if (td == null) {
shouldExpand = false; shouldExpand = false;
} else if (!td.isInterface() && !Modifier.isAbstract(td.getModifiers()) && isImplicitThis) { } else if (isImplicitThis) {
// Implicit-this / plain calls: only expand abstract/interface hooks. Concrete
// methods on an abstract class (the template method itself) keep a single edge to
// the declaring body — expanding them invents ClassA.processMessage / ClassB...
// keys that do not exist and cross-wires unrelated subclasses.
shouldExpand = isAbstractOrInterfaceMethod(td, methodName);
} else if (!td.isInterface() && !Modifier.isAbstract(td.getModifiers())) {
shouldExpand = false; shouldExpand = false;
} }
@@ -2643,38 +2767,101 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return frozen; return frozen;
} }
private boolean isAbstractOrInterfaceMethod(TypeDeclaration declaringType, String methodName) {
if (declaringType == null || methodName == null) {
return false;
}
if (declaringType.isInterface()) {
return true;
}
MethodDeclaration md = context.findMethodDeclaration(declaringType, methodName, false);
return md != null && Modifier.isAbstract(md.getModifiers());
}
private boolean isStaticResolvedMethod(String methodFqn) {
int lastDot = methodFqn.lastIndexOf('.');
if (lastDot <= 0) {
return false;
}
String className = methodFqn.substring(0, lastDot);
String methodName = methodFqn.substring(lastDot + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return false;
}
MethodDeclaration md = context.findMethodDeclaration(td, methodName, false);
return md != null && Modifier.isStatic(md.getModifiers());
}
/** /**
* Peels enum factory wrappers while tracing call-path arguments, e.g. * Peels enum factory / library wrappers while tracing call-path arguments, e.g.
* {@code OrderEvent.valueOf(action)} → {@code action} so the next hop can resolve the parameter. * {@code OrderEvent.valueOf(action)} → {@code action} so the next hop can resolve the parameter.
* <p> * <p>
* Does <em>not</em> peel implicit-{@code this} domain helpers ({@code toEvent(msg)}, {@code resolve(x)}).
* Those must stay intact so {@link ConstantExtractor} / {@link AccessorResolver} can widen to
* subclass implementations and walk overridden return statements. Pure passthrough helpers that
* literally {@code return arg;} are still peeled via {@link MethodInvocationUnwrapper}.
* <p>
* Map/collection lookups ({@code ROUTES.get(key)}) are intentionally <em>not</em> peeled — they are * Map/collection lookups ({@code ROUTES.get(key)}) are intentionally <em>not</em> peeled — they are
* routing tables, not enum converters. See {@link ExpressionAccessClassifier}. * routing tables, not enum converters. See {@link ExpressionAccessClassifier}.
*/ */
private String unwrapConverterMethods(String arg) { private String unwrapConverterMethods(String arg, String scopeMethodFqn) {
if (arg == null) return null; if (arg == null) return null;
String current = arg; String current = arg;
while (current.contains("(") && !current.startsWith("new ")) { while (current.contains("(") && !current.startsWith("new ")) {
org.eclipse.jdt.core.dom.ASTNode argNode = parseExpressionString(current); ASTNode argNode = parseExpressionString(current);
if (argNode instanceof org.eclipse.jdt.core.dom.MethodInvocation miArg && !miArg.arguments().isEmpty()) { if (!(argNode instanceof MethodInvocation miArg) || miArg.arguments().isEmpty()) {
if (expressionAccessClassifier.isKeyedLookup(miArg, null)) { break;
break; }
if (expressionAccessClassifier.isKeyedLookup(miArg, scopeMethodFqn)) {
break;
}
String methodName = miArg.getName().getIdentifier();
Expression recv = miArg.getExpression();
// Type.valueOf(x) / Enum.valueOf(Class, x) — peel to the name argument.
if ("valueOf".equals(methodName) && isTypeLikeReceiver(recv)) {
Expression innerArg = (Expression) miArg.arguments().get(0);
if (miArg.arguments().size() == 2) {
innerArg = (Expression) miArg.arguments().get(1);
} }
boolean shouldUnpack = false; current = innerArg.toString();
org.eclipse.jdt.core.dom.Expression recv = miArg.getExpression(); continue;
if (recv == null || recv instanceof org.eclipse.jdt.core.dom.ThisExpression) { }
shouldUnpack = true;
} else if (recv instanceof org.eclipse.jdt.core.dom.SimpleName sn) { // Optional.of / Mono.just / MessageBuilder.withPayload — peel library factories only.
shouldUnpack = !Character.isLowerCase(sn.getIdentifier().charAt(0)); if (recv != null
} else if (recv instanceof org.eclipse.jdt.core.dom.QualifiedName qn) { && LibraryUnwrapRegistry.isUnwrapReceiverType(recv.toString())
shouldUnpack = !Character.isLowerCase(qn.getName().getIdentifier().charAt(0)); && LibraryUnwrapRegistry.isUnwrapMethodName(methodName)
} && !LibraryUnwrapRegistry.shouldStopUnwrap(methodName)) {
if (shouldUnpack) { current = ((Expression) miArg.arguments().get(0)).toString();
org.eclipse.jdt.core.dom.Expression innerArg = (org.eclipse.jdt.core.dom.Expression) miArg.arguments().get(0); continue;
if (miArg.getName().getIdentifier().equals("valueOf") && miArg.arguments().size() == 2) { }
innerArg = (org.eclipse.jdt.core.dom.Expression) miArg.arguments().get(1);
// Implicit-this / this.helper(arg): only peel pure passthroughs (return the same parameter).
// Domain converters that return enum constants must remain for override/return walking.
if (recv == null || recv instanceof ThisExpression) {
Expression peeled = MethodInvocationUnwrapper.unwrap(
miArg,
0,
scopeMethodFqn,
context,
mi -> {
if (scopeMethodFqn == null || !scopeMethodFqn.contains(".")) {
return null;
}
String currentClass = scopeMethodFqn.substring(0, scopeMethodFqn.lastIndexOf('.'));
if (mi.getExpression() == null || mi.getExpression() instanceof ThisExpression) {
return currentClass + "." + mi.getName().getIdentifier();
}
return null;
});
if (peeled != null && peeled != miArg) {
String next = peeled.toString();
if (!next.equals(current)) {
current = next;
continue;
} }
current = innerArg.toString();
continue;
} }
} }
break; break;
@@ -2682,6 +2869,18 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return current; return current;
} }
private static boolean isTypeLikeReceiver(Expression recv) {
if (recv instanceof SimpleName sn) {
String name = sn.getIdentifier();
return name != null && !name.isEmpty() && !Character.isLowerCase(name.charAt(0));
}
if (recv instanceof QualifiedName qn) {
String name = qn.getName().getIdentifier();
return name != null && !name.isEmpty() && !Character.isLowerCase(name.charAt(0));
}
return false;
}
private boolean shouldDropBarePolymorphicCandidate(String candidate, String expectedType, CodebaseContext context) { private boolean shouldDropBarePolymorphicCandidate(String candidate, String expectedType, CodebaseContext context) {
if (candidate == null || candidate.contains(".") if (candidate == null || candidate.contains(".")
|| candidate.startsWith("<SYMBOLIC:") || candidate.startsWith("ENUM_SET:")) { || candidate.startsWith("<SYMBOLIC:") || candidate.startsWith("ENUM_SET:")) {
@@ -2785,21 +2984,32 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return fqn; return fqn;
} }
} }
// Receiver of Type.valueOf(...) IS the enum type. Do not strip the last segment —
// that turns com.example.DomainCommand into package prefix com.example.
String full = qn.getFullyQualifiedName(); String full = qn.getFullyQualifiedName();
if (full.contains(".")) { if (isResolvedEnumTypeFqn(full)) {
String typePart = full.substring(0, full.lastIndexOf('.')); return full;
if (isResolvedEnumTypeFqn(typePart)) {
return typePart;
}
return full.substring(full.lastIndexOf('.') + 1);
} }
return full; if (full != null && !full.isBlank() && !full.endsWith(".") && !full.startsWith(".")) {
return full;
}
return null;
} }
return null; return null;
} }
private static boolean isResolvedEnumTypeFqn(String fqn) { private static boolean isResolvedEnumTypeFqn(String fqn) {
return fqn != null && !fqn.isBlank() && fqn.contains(".") && !fqn.startsWith("<"); if (fqn == null || fqn.isBlank() || fqn.startsWith("<") || fqn.startsWith(".") || fqn.endsWith(".")) {
return false;
}
int lastDot = fqn.lastIndexOf('.');
String simple = lastDot >= 0 ? fqn.substring(lastDot + 1) : fqn;
return !simple.isEmpty() && Character.isUpperCase(simple.charAt(0));
}
private String reconstructEnumRef(String value) {
return click.kamil.springstatemachineexporter.analysis.resolver.TruncatedEnumRefReconstructor
.reconstruct(value, context);
} }
private String resolveEnumConstantFromArgument(String argName, List<String> path, Map<String, List<CallEdge>> callGraph) { private String resolveEnumConstantFromArgument(String argName, List<String> path, Map<String, List<CallEdge>> callGraph) {
@@ -3010,17 +3220,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
List<String> path, List<String> path,
Map<String, List<CallEdge>> callGraph, Map<String, List<CallEdge>> callGraph,
Map<String, String> initialBindings) { Map<String, String> initialBindings) {
if (isExternalParameter(entryMethod, finalParamName)) { // Only the resolved event parameter makes a trigger "external". Unrelated REST params
return true; // (e.g. @RequestParam userId on a dedicated /order/pay endpoint) must not flip the flag.
} return finalParamName != null && isExternalParameter(entryMethod, finalParamName);
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
bindings = mergeBindings(initialBindings, bindings);
for (String boundName : bindings.keySet()) {
if (isExternalParameter(entryMethod, boundName)) {
return true;
}
}
return false;
} }
private List<String> resolveKeyedMapLookup( private List<String> resolveKeyedMapLookup(
@@ -3051,7 +3253,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 +3281,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 +3303,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 +3340,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 +3381,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 +3727,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()) {
@@ -100,41 +112,19 @@ 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

@@ -12,8 +12,9 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* Expands REST entry points whose {@code @PathVariable} parameters flow into string-keyed * Expands entry points whose string parameters flow into switch/map/`valueOf` mappers
* switch mappers (e.g. {@code StringCommandMapper.fromString}) into concrete binding variants. * into concrete binding variants. Covers REST {@code @PathVariable}/{@code @RequestParam}
* and messaging {@code @Payload} / message-body string parameters (JMS, Kafka, Rabbit, …).
*/ */
public final class EntryPointBindingExpander { public final class EntryPointBindingExpander {
@@ -21,7 +22,7 @@ public final class EntryPointBindingExpander {
} }
/** /**
* Expands an entry point into concrete binding variants from path/query parameters: * Expands an entry point into concrete binding variants from expandable parameters:
* string switch/if arms and boolean ternary conditions provable in source. * string switch/if arms and boolean ternary conditions provable in source.
*/ */
public static List<Map<String, String>> expandEntryPointBindings( public static List<Map<String, String>> expandEntryPointBindings(
@@ -57,10 +58,12 @@ public final class EntryPointBindingExpander {
List<Map<String, String>> variants = new ArrayList<>(); List<Map<String, String>> variants = new ArrayList<>();
variants.add(new LinkedHashMap<>()); variants.add(new LinkedHashMap<>());
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) { for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
if (!isPathOrQueryVariable(parameter)) { if (!isExpandableBindingParameter(entryPoint, parameter)) {
continue; continue;
} }
if (!isRequestParam(parameter) && isPlaceholderResolvedInPath(entryPoint, parameter.getName())) { if (!isRequestParam(parameter)
&& !isMessagingPayloadParameter(entryPoint, parameter)
&& isPlaceholderResolvedInPath(entryPoint, parameter.getName())) {
continue; continue;
} }
List<Map<String, String>> expanded = new ArrayList<>(); List<Map<String, String>> expanded = new ArrayList<>();
@@ -120,6 +123,7 @@ public final class EntryPointBindingExpander {
metadata.put("path", path.replace(placeholder, binding.getValue())); metadata.put("path", path.replace(placeholder, binding.getValue()));
} }
} else if (isRequestParamParameter(entryPoint, binding.getKey()) } else if (isRequestParamParameter(entryPoint, binding.getKey())
|| isMessagingPayloadBinding(entryPoint, binding.getKey())
|| isBooleanBindingValue(binding.getValue())) { || isBooleanBindingValue(binding.getValue())) {
if (querySuffix.isEmpty()) { if (querySuffix.isEmpty()) {
querySuffix.append('?'); querySuffix.append('?');
@@ -275,19 +279,90 @@ public final class EntryPointBindingExpander {
return false; return false;
} }
private static boolean isPathOrQueryVariable(EntryPoint.Parameter parameter) { private static boolean isMessagingPayloadBinding(EntryPoint entryPoint, String paramName) {
if (parameter.getAnnotations() == null) { if (entryPoint == null || entryPoint.getParameters() == null || paramName == null) {
return false; return false;
} }
return parameter.getAnnotations().stream() for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
.anyMatch(a -> "PathVariable".equals(a) || "RequestParam".equals(a)); if (paramName.equals(parameter.getName()) && isMessagingPayloadParameter(entryPoint, parameter)) {
return true;
}
}
return false;
}
private static boolean isExpandableBindingParameter(EntryPoint entryPoint, EntryPoint.Parameter parameter) {
return isPathOrQueryVariable(parameter) || isMessagingPayloadParameter(entryPoint, parameter);
}
private static boolean isPathOrQueryVariable(EntryPoint.Parameter parameter) {
return hasSimpleAnnotation(parameter, "PathVariable") || hasSimpleAnnotation(parameter, "RequestParam");
} }
private static boolean isRequestParam(EntryPoint.Parameter parameter) { private static boolean isRequestParam(EntryPoint.Parameter parameter) {
if (parameter.getAnnotations() == null) { return hasSimpleAnnotation(parameter, "RequestParam");
}
/**
* Messaging listeners carry the event key in {@code @Payload} or an unannotated String body
* ({@code message}, {@code payload}, {@code event}, …) — same expansion surface as REST path vars.
*/
private static boolean isMessagingPayloadParameter(EntryPoint entryPoint, EntryPoint.Parameter parameter) {
if (entryPoint == null || parameter == null || !isMessagingEntryPoint(entryPoint)) {
return false; return false;
} }
return parameter.getAnnotations().stream().anyMatch("RequestParam"::equals); if (hasSimpleAnnotation(parameter, "Payload")) {
return true;
}
boolean unannotated = parameter.getAnnotations() == null || parameter.getAnnotations().isEmpty();
if (!unannotated) {
return false;
}
String type = parameter.getType();
if (type == null) {
return false;
}
String simpleType = type.contains(".") ? type.substring(type.lastIndexOf('.') + 1) : type;
if (!"String".equals(simpleType)) {
return false;
}
return isMessageOrEventParamName(parameter.getName());
}
private static boolean isMessagingEntryPoint(EntryPoint entryPoint) {
if (entryPoint.getType() == null) {
return false;
}
return switch (entryPoint.getType()) {
case JMS, KAFKA, RABBIT, SQS, SNS -> true;
default -> false;
};
}
private static boolean isMessageOrEventParamName(String name) {
if (name == null || name.isBlank()) {
return false;
}
String lower = name.toLowerCase();
return lower.equals("message")
|| lower.equals("msg")
|| lower.equals("payload")
|| lower.equals("body")
|| lower.equals("event")
|| lower.equals("e")
|| lower.endsWith("event")
|| lower.equals("command")
|| lower.equals("commandkey")
|| lower.equals("action")
|| lower.equals("text");
}
private static boolean hasSimpleAnnotation(EntryPoint.Parameter parameter, String simpleName) {
if (parameter.getAnnotations() == null || simpleName == null) {
return false;
}
return parameter.getAnnotations().stream().anyMatch(a ->
simpleName.equals(a) || (a != null && a.endsWith("." + simpleName)));
} }
private static boolean isRequestParamParameter(EntryPoint entryPoint, String paramName) { private static boolean isRequestParamParameter(EntryPoint entryPoint, String paramName) {
@@ -672,9 +747,7 @@ public final class EntryPointBindingExpander {
org.eclipse.jdt.core.dom.IVariableBinding fieldBinding = fieldAccess.resolveFieldBinding(); org.eclipse.jdt.core.dom.IVariableBinding fieldBinding = fieldAccess.resolveFieldBinding();
if (fieldBinding != null && fieldBinding.isField()) { if (fieldBinding != null && fieldBinding.isField()) {
org.eclipse.jdt.core.dom.ITypeBinding declaringClass = fieldBinding.getDeclaringClass(); org.eclipse.jdt.core.dom.ITypeBinding declaringClass = fieldBinding.getDeclaringClass();
if (declaringClass != null) { declaringType = resolveFieldDeclaringType(declaringClass, compilationUnit, context);
declaringType = context.getTypeDeclaration(declaringClass.getQualifiedName());
}
} }
if (declaringType == null) { if (declaringType == null) {
Expression qualifier = fieldAccess.getExpression(); Expression qualifier = fieldAccess.getExpression();
@@ -693,9 +766,7 @@ public final class EntryPointBindingExpander {
if (binding instanceof org.eclipse.jdt.core.dom.IVariableBinding variableBinding if (binding instanceof org.eclipse.jdt.core.dom.IVariableBinding variableBinding
&& variableBinding.isField()) { && variableBinding.isField()) {
org.eclipse.jdt.core.dom.ITypeBinding declaringClass = variableBinding.getDeclaringClass(); org.eclipse.jdt.core.dom.ITypeBinding declaringClass = variableBinding.getDeclaringClass();
if (declaringClass != null) { declaringType = resolveFieldDeclaringType(declaringClass, compilationUnit, context);
declaringType = context.getTypeDeclaration(declaringClass.getQualifiedName());
}
} }
if (declaringType == null && compilationUnit != null) { if (declaringType == null && compilationUnit != null) {
declaringType = context.resolveStaticImport(fieldName, compilationUnit); declaringType = context.resolveStaticImport(fieldName, compilationUnit);
@@ -724,6 +795,27 @@ public final class EntryPointBindingExpander {
return extractStaticFieldMapInitializer(declaringType, fieldName, context); return extractStaticFieldMapInitializer(declaringType, fieldName, context);
} }
private static TypeDeclaration resolveFieldDeclaringType(
org.eclipse.jdt.core.dom.ITypeBinding declaringClass,
CompilationUnit compilationUnit,
CodebaseContext context) {
if (declaringClass == null) {
return null;
}
org.eclipse.jdt.core.dom.ITypeBinding erased = declaringClass.getErasure();
TypeDeclaration typeDeclaration = context.getTypeDeclaration(erased.getQualifiedName());
if (typeDeclaration != null) {
return typeDeclaration;
}
if (compilationUnit != null) {
typeDeclaration = context.getTypeDeclaration(erased.getName(), compilationUnit);
if (typeDeclaration != null) {
return typeDeclaration;
}
}
return context.getTypeDeclaration(erased.getName());
}
private static boolean isStaticMapFieldReceiver(Expression receiver) { private static boolean isStaticMapFieldReceiver(Expression receiver) {
if (receiver instanceof FieldAccess fieldAccess) { if (receiver instanceof FieldAccess fieldAccess) {
org.eclipse.jdt.core.dom.IVariableBinding fieldBinding = fieldAccess.resolveFieldBinding(); org.eclipse.jdt.core.dom.IVariableBinding fieldBinding = fieldAccess.resolveFieldBinding();

View File

@@ -9,7 +9,8 @@ import java.util.HashSet;
import java.util.Set; import java.util.Set;
/** /**
* Single source-derived policy for marking REST triggers as external vs internal. * Single source-derived policy for marking entry-point triggers as external vs internal
* (REST path/query/body and messaging payload parameters).
*/ */
public final class ExternalTriggerPolicy { public final class ExternalTriggerPolicy {
@@ -25,6 +26,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;
@@ -57,7 +69,7 @@ public final class ExternalTriggerPolicy {
} }
} }
} }
if (entryPoint.getName() != null && entryPoint.getName().contains("{") if (entryPoint.getName() != null && entryPointHasUnboundPlaceholders(entryPoint)
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) { && MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
if (paramName != null) { if (paramName != null) {
RestParamBinding pathBinding = findRestParameterBinding(entryPoint, paramName, context); RestParamBinding pathBinding = findRestParameterBinding(entryPoint, paramName, context);
@@ -68,13 +80,34 @@ public final class ExternalTriggerPolicy {
return true; return true;
} }
} }
if (entryPoint != null && isMessagingEntryPoint(entryPoint)) {
String paramName = resolveMessagingEventParameterName(entryPoint, resolvedEventParamName);
if (paramName != null) {
RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context);
if (binding != null && binding.payloadEnum()) {
return false;
}
RestParamBinding astBinding = entryPoint.getClassName() != null && entryPoint.getMethodName() != null
? findMethodParameterBinding(
entryPoint.getClassName() + "." + entryPoint.getMethodName(), paramName, context)
: null;
if (astBinding != null && astBinding.payloadEnum()) {
return false;
}
}
if (MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())
&& !hasConcreteSinglePoly(trigger)) {
// Unbound messaging payload feeding valueOf/switch — treat like unbound REST event param.
return true;
}
}
if (entryMethodFqn != null && resolvedEventParamName != null) { if (entryMethodFqn != null && resolvedEventParamName != null) {
RestParamBinding binding = findMethodParameterBinding(entryMethodFqn, resolvedEventParamName, context); RestParamBinding binding = findMethodParameterBinding(entryMethodFqn, resolvedEventParamName, context);
if (binding != null) { if (binding != null) {
if (binding.pathOrQueryVariable() && !binding.enumType()) { if (binding.pathOrQueryVariable() && !binding.enumType()) {
return true; return true;
} }
if (binding.requestBodyEnum()) { if (binding.requestBodyEnum() || binding.payloadEnum()) {
return false; return false;
} }
} }
@@ -92,42 +125,155 @@ public final class ExternalTriggerPolicy {
return trigger.isExternal(); return trigger.isExternal();
} }
private static boolean entryPointHasUnboundPlaceholders(EntryPoint entryPoint) { private static boolean isMessagingEntryPoint(EntryPoint entryPoint) {
if (entryPoint == null) { if (entryPoint == null || entryPoint.getType() == null) {
return false; return false;
} }
if (entryPoint.getName() != null && entryPoint.getName().contains("{")) { return switch (entryPoint.getType()) {
return true; case JMS, KAFKA, RABBIT, SQS, SNS -> true;
} default -> false;
if (entryPoint.getMetadata() != null) { };
String path = entryPoint.getMetadata().get("path");
if (path != null && path.contains("{")) {
return true;
}
}
return false;
} }
private static String resolveRestEventParameterName(EntryPoint entryPoint, String resolvedEventParamName) { private static boolean hasConcreteSinglePoly(TriggerPoint trigger) {
if (resolvedEventParamName != null && !resolvedEventParamName.isBlank()) { if (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty()) {
return false;
}
return MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())
&& trigger.getPolymorphicEvents().size() == 1;
}
private static String resolveMessagingEventParameterName(EntryPoint entryPoint, String resolvedEventParamName) {
if (resolvedEventParamName != null && !resolvedEventParamName.isBlank()
&& looksLikeParameterName(resolvedEventParamName)) {
return resolvedEventParamName; return resolvedEventParamName;
} }
if (entryPoint.getParameters() == null) { if (entryPoint.getParameters() == null) {
return null; return null;
} }
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) { for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
if (parameter.getAnnotations() == null) { if (parameter.getAnnotations() != null
continue; && parameter.getAnnotations().stream().anyMatch(a ->
"Payload".equals(a) || (a != null && a.endsWith(".Payload")))) {
return parameter.getName();
} }
for (String annotation : parameter.getAnnotations()) { }
if ("PathVariable".equals(annotation) || "RequestParam".equals(annotation)) { for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
return parameter.getName(); if ((parameter.getAnnotations() == null || parameter.getAnnotations().isEmpty())
} && isEventLikeParameterName(parameter.getName())) {
return parameter.getName();
}
String lower = parameter.getName() == null ? "" : parameter.getName().toLowerCase();
if ((parameter.getAnnotations() == null || parameter.getAnnotations().isEmpty())
&& (lower.equals("message") || lower.equals("msg") || lower.equals("payload")
|| lower.equals("body") || lower.equals("text"))) {
return parameter.getName();
} }
} }
return null; return null;
} }
private static boolean entryPointHasUnboundPlaceholders(EntryPoint entryPoint) {
if (entryPoint == null) {
return false;
}
if (hasUnboundEventPlaceholder(entryPoint.getName())) {
return true;
}
if (entryPoint.getMetadata() != null) {
String path = entryPoint.getMetadata().get("path");
if (hasUnboundEventPlaceholder(path)) {
return true;
}
}
return false;
}
private static boolean hasUnboundEventPlaceholder(String pathOrName) {
if (pathOrName == null || pathOrName.isBlank() || !pathOrName.contains("{")) {
return false;
}
int start = 0;
while (true) {
int open = pathOrName.indexOf('{', start);
if (open < 0) {
return false;
}
int close = pathOrName.indexOf('}', open + 1);
if (close < 0) {
return false;
}
String placeholder = pathOrName.substring(open + 1, close).trim();
if (isEventLikeParameterName(placeholderName(placeholder))) {
return true;
}
start = close + 1;
}
}
private static String placeholderName(String placeholder) {
if (placeholder == null) {
return null;
}
int colon = placeholder.indexOf(':');
return colon > 0 ? placeholder.substring(0, colon).trim() : placeholder;
}
private static String resolveRestEventParameterName(EntryPoint entryPoint, String resolvedEventParamName) {
if (resolvedEventParamName != null && !resolvedEventParamName.isBlank()) {
// Only accept identifiers that look like method parameter names, not event FQNs/expressions.
if (looksLikeParameterName(resolvedEventParamName)) {
return resolvedEventParamName;
}
}
if (entryPoint.getParameters() == null) {
return null;
}
// Prefer PathVariable/RequestParam params that look like event carriers.
// Do NOT fall back to unrelated resource ids ({id}, orderId, userId).
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
if (parameter.getAnnotations() == null) {
continue;
}
boolean pathVariable = parameter.getAnnotations().stream().anyMatch("PathVariable"::equals);
boolean requestParam = parameter.getAnnotations().stream().anyMatch("RequestParam"::equals);
if (!pathVariable && !requestParam) {
continue;
}
if (isEventLikeParameterName(parameter.getName())) {
return parameter.getName();
}
}
return null;
}
private static boolean looksLikeParameterName(String value) {
if (value == null || value.isBlank()) {
return false;
}
// Reject FQNs, method calls, and dotted expressions — those are event values, not param names.
if (value.contains(".") || value.contains("(") || value.contains(")") || value.contains(" ")) {
return false;
}
return Character.isJavaIdentifierStart(value.charAt(0))
&& value.chars().allMatch(Character::isJavaIdentifierPart);
}
private static boolean isEventLikeParameterName(String name) {
if (name == null || name.isBlank()) {
return false;
}
String lower = name.toLowerCase();
return lower.equals("event")
|| lower.equals("e")
|| lower.endsWith("event")
|| lower.equals("command")
|| lower.equals("commandkey")
|| lower.equals("transition")
|| lower.equals("action")
|| lower.equals("machinetype");
}
private static RestParamBinding findRestParameterBinding( private static RestParamBinding findRestParameterBinding(
EntryPoint entryPoint, String paramName, CodebaseContext context) { EntryPoint entryPoint, String paramName, CodebaseContext context) {
if (entryPoint.getParameters() == null || paramName == null) { if (entryPoint.getParameters() == null || paramName == null) {
@@ -200,13 +346,13 @@ public final class ExternalTriggerPolicy {
continue; continue;
} }
if (isEnumRecordComponent(bodyBinding, fieldName)) { if (isEnumRecordComponent(bodyBinding, fieldName)) {
return new RestParamBinding(false, false, true, true); return new RestParamBinding(false, false, true, false, true);
} }
if (isEnumDtoField(bodyBinding, fieldName, context)) { if (isEnumDtoField(bodyBinding, fieldName, context)) {
return new RestParamBinding(false, false, true, true); return new RestParamBinding(false, false, true, false, true);
} }
if (containsNestedEnumField(bodyBinding, fieldName, context)) { if (containsNestedEnumField(bodyBinding, fieldName, context)) {
return new RestParamBinding(false, false, true, true); return new RestParamBinding(false, false, true, false, true);
} }
} }
return null; return null;
@@ -295,7 +441,12 @@ public final class ExternalTriggerPolicy {
return false; return false;
} }
private record RestParamBinding(boolean pathVariable, boolean requestParam, boolean requestBody, boolean enumType) { private record RestParamBinding(
boolean pathVariable,
boolean requestParam,
boolean requestBody,
boolean payload,
boolean enumType) {
boolean pathOrQueryVariable() { boolean pathOrQueryVariable() {
return pathVariable || requestParam; return pathVariable || requestParam;
} }
@@ -304,25 +455,32 @@ public final class ExternalTriggerPolicy {
return requestBody && enumType; return requestBody && enumType;
} }
boolean payloadEnum() {
return payload && enumType;
}
static RestParamBinding fromEntryPointParameter(EntryPoint.Parameter parameter) { static RestParamBinding fromEntryPointParameter(EntryPoint.Parameter parameter) {
boolean pathVariable = hasAnnotation(parameter.getAnnotations(), "PathVariable"); boolean pathVariable = hasAnnotation(parameter.getAnnotations(), "PathVariable");
boolean requestParam = hasAnnotation(parameter.getAnnotations(), "RequestParam"); boolean requestParam = hasAnnotation(parameter.getAnnotations(), "RequestParam");
boolean requestBody = hasAnnotation(parameter.getAnnotations(), "RequestBody"); boolean requestBody = hasAnnotation(parameter.getAnnotations(), "RequestBody");
boolean payload = hasAnnotation(parameter.getAnnotations(), "Payload");
boolean enumType = isEnumTypeName(parameter.getType()); boolean enumType = isEnumTypeName(parameter.getType());
return new RestParamBinding(pathVariable, requestParam, requestBody, enumType); return new RestParamBinding(pathVariable, requestParam, requestBody, payload, enumType);
} }
static RestParamBinding fromAstParameter(SingleVariableDeclaration param) { static RestParamBinding fromAstParameter(SingleVariableDeclaration param) {
boolean pathVariable = hasParameterAstAnnotation(param, "PathVariable"); boolean pathVariable = hasParameterAstAnnotation(param, "PathVariable");
boolean requestParam = hasParameterAstAnnotation(param, "RequestParam"); boolean requestParam = hasParameterAstAnnotation(param, "RequestParam");
boolean requestBody = hasParameterAstAnnotation(param, "RequestBody"); boolean requestBody = hasParameterAstAnnotation(param, "RequestBody");
boolean payload = hasParameterAstAnnotation(param, "Payload");
boolean enumType = param.getType() != null && param.getType().resolveBinding() != null boolean enumType = param.getType() != null && param.getType().resolveBinding() != null
&& param.getType().resolveBinding().isEnum(); && param.getType().resolveBinding().isEnum();
return new RestParamBinding(pathVariable, requestParam, requestBody, enumType); return new RestParamBinding(pathVariable, requestParam, requestBody, payload, enumType);
} }
private static boolean hasAnnotation(java.util.List<String> annotations, String name) { private static boolean hasAnnotation(java.util.List<String> annotations, String name) {
return annotations != null && annotations.stream().anyMatch(name::equals); return annotations != null && annotations.stream().anyMatch(a ->
name.equals(a) || (a != null && a.endsWith("." + name)));
} }
private static boolean isEnumTypeName(String typeName) { private static boolean isEnumTypeName(String typeName) {

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

@@ -10,6 +10,7 @@ import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@@ -130,16 +131,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();
@@ -156,7 +151,9 @@ public class GenericEventDetector {
if (smBinding != null) { if (smBinding != null) {
ITypeBinding[] typeArgs = smBinding.getTypeArguments(); ITypeBinding[] typeArgs = smBinding.getTypeArguments();
if (typeArgs.length >= 2) { if (typeArgs.length >= 2) {
return new String[]{typeArgs[0].getQualifiedName(), typeArgs[1].getQualifiedName()}; return new String[]{
concreteStateMachineTypeArgFqn(typeArgs[0]),
concreteStateMachineTypeArgFqn(typeArgs[1])};
} }
} }
} }
@@ -168,6 +165,12 @@ public class GenericEventDetector {
CompilationUnit compilationUnit = (CompilationUnit) node.getRoot(); CompilationUnit compilationUnit = (CompilationUnit) node.getRoot();
String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit); String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit);
String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit); String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit);
if (isUnresolvedTypeVariableName(stateType)) {
stateType = null;
}
if (isUnresolvedTypeVariableName(eventType)) {
eventType = null;
}
return new String[]{stateType, eventType}; return new String[]{stateType, eventType};
} }
} }
@@ -804,36 +807,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);
@@ -916,7 +916,9 @@ public class GenericEventDetector {
if (smBinding != null) { if (smBinding != null) {
ITypeBinding[] typeArgs = smBinding.getTypeArguments(); ITypeBinding[] typeArgs = smBinding.getTypeArguments();
if (typeArgs.length >= 2) { if (typeArgs.length >= 2) {
return new String[]{typeArgs[0].getQualifiedName(), typeArgs[1].getQualifiedName()}; return new String[]{
concreteStateMachineTypeArgFqn(typeArgs[0]),
concreteStateMachineTypeArgFqn(typeArgs[1])};
} }
} }
} }
@@ -936,6 +938,13 @@ public class GenericEventDetector {
if (eventType != null && eventType.length() == 1) { if (eventType != null && eventType.length() == 1) {
eventType = resolveGenericTypeVariable(eventType, node, cu); eventType = resolveGenericTypeVariable(eventType, node, cu);
} }
// Unresolved class type variables (S/E) are not concrete machine types.
if (isUnresolvedTypeVariableName(stateType)) {
stateType = null;
}
if (isUnresolvedTypeVariableName(eventType)) {
eventType = null;
}
return new String[]{stateType, eventType}; return new String[]{stateType, eventType};
} }
@@ -952,6 +961,7 @@ public class GenericEventDetector {
List<String> impls = context.getImplementations(className); List<String> impls = context.getImplementations(className);
if (impls == null || impls.isEmpty()) return typeVarName; if (impls == null || impls.isEmpty()) return typeVarName;
LinkedHashSet<String> concreteCandidates = new LinkedHashSet<>();
for (String implFqn : impls) { for (String implFqn : impls) {
org.eclipse.jdt.core.dom.AbstractTypeDeclaration implNode = context.getAbstractTypeDeclaration(implFqn); org.eclipse.jdt.core.dom.AbstractTypeDeclaration implNode = context.getAbstractTypeDeclaration(implFqn);
if (implNode instanceof TypeDeclaration td) { if (implNode instanceof TypeDeclaration td) {
@@ -975,15 +985,51 @@ public class GenericEventDetector {
} }
if (typeIndex >= 0 && typeIndex < pt.typeArguments().size()) { if (typeIndex >= 0 && typeIndex < pt.typeArguments().size()) {
Type concreteType = (Type) pt.typeArguments().get(typeIndex); Type concreteType = (Type) pt.typeArguments().get(typeIndex);
return typeResolver.resolveTypeToFqn(concreteType, (CompilationUnit) implNode.getRoot()); String resolved = typeResolver.resolveTypeToFqn(
concreteType, (CompilationUnit) implNode.getRoot());
if (resolved != null && !isUnresolvedTypeVariableName(resolved)) {
concreteCandidates.add(resolved);
}
} }
} }
} }
} }
} }
// Only stamp a concrete type when every subclass agrees — otherwise leave unbound so
// call-chain rebinding can pick the chain-scoped substitution.
if (concreteCandidates.size() == 1) {
return concreteCandidates.iterator().next();
}
return typeVarName; return typeVarName;
} }
/**
* Binding-derived {@code StateMachine<S,E>} type arguments that are still unbound type variables
* (or wildcards) are not concrete machine enum types — leave them absent so routing can use
* {@code polymorphicEvents} / shared-infrastructure evidence instead.
*/
private static String concreteStateMachineTypeArgFqn(ITypeBinding typeArg) {
if (typeArg == null) {
return null;
}
if (typeArg.isTypeVariable() || typeArg.isCapture() || typeArg.isWildcardType()) {
return null;
}
String qualified = typeArg.getQualifiedName();
if (isUnresolvedTypeVariableName(qualified)) {
return null;
}
return qualified;
}
private static boolean isUnresolvedTypeVariableName(String typeName) {
if (typeName == null || typeName.isBlank()) {
return false;
}
// JDT often reports unbound parameters as a single uppercase letter ("S", "E").
return typeName.length() == 1 && Character.isUpperCase(typeName.charAt(0));
}
private ITypeBinding findStateMachineSupertype(ITypeBinding binding, java.util.Set<String> visited) { private ITypeBinding findStateMachineSupertype(ITypeBinding binding, java.util.Set<String> visited) {
ITypeBinding current = binding; ITypeBinding current = binding;
while (current != null) { while (current != null) {

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

@@ -123,7 +123,12 @@ public class MessagingDetector {
List<String> annotations = new ArrayList<>(); List<String> annotations = new ArrayList<>();
for (Object mod : param.modifiers()) { for (Object mod : param.modifiers()) {
if (mod instanceof Annotation ann) { if (mod instanceof Annotation ann) {
annotations.add(ann.getTypeName().getFullyQualifiedName()); String name = ann.getTypeName().getFullyQualifiedName();
int lastDot = name.lastIndexOf('.');
if (lastDot >= 0) {
name = name.substring(lastDot + 1);
}
annotations.add(name);
} }
} }
parameters.add(EntryPoint.Parameter.builder() parameters.add(EntryPoint.Parameter.builder()

View File

@@ -3,8 +3,8 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge; 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.analysis.resolver.TruncatedEnumRefReconstructor;
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,17 +21,34 @@ 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<>();
/** Optional machine/event enum FQN used to disambiguate truncated package+CONSTANT refs. */
private String preferredEnumTypeFqn;
public PathBindingEvaluator( public PathBindingEvaluator(
CodebaseContext context, CodebaseContext context,
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;
}
public void setPreferredEnumTypeFqn(String preferredEnumTypeFqn) {
this.preferredEnumTypeFqn = preferredEnumTypeFqn;
} }
/** 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 +72,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) {
@@ -97,7 +119,8 @@ public class PathBindingEvaluator {
} }
if (edge.getConstraint() != null if (edge.getConstraint() != null
&& !edge.getConstraint().isBlank() && !edge.getConstraint().isBlank()
&& !BooleanConstraintEvaluator.isCompatibleWithBindings(edge.getConstraint(), bindings)) { && !BooleanConstraintEvaluator.isCompatibleWithBindings(
edge.getConstraint(), bindings, preferredEnumTypeFqn, context)) {
return false; return false;
} }
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings); enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings);
@@ -124,7 +147,8 @@ public class PathBindingEvaluator {
Map<String, String> bindings) { Map<String, String> bindings) {
if (edge.getConstraint() != null if (edge.getConstraint() != null
&& !edge.getConstraint().isBlank() && !edge.getConstraint().isBlank()
&& !BooleanConstraintEvaluator.isCompatibleWithBindings(edge.getConstraint(), bindings)) { && !BooleanConstraintEvaluator.isCompatibleWithBindings(
edge.getConstraint(), bindings, preferredEnumTypeFqn, context)) {
return false; return false;
} }
if (variableTracer == null || typeResolver == null) { if (variableTracer == null || typeResolver == null) {
@@ -143,7 +167,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,9 +194,20 @@ 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) {
String constant = maybeReconstruct(constants.get(0));
if (shouldStoreBinding(paramName, constant)) {
bindings.put(paramName, maybeReconstruct(constant));
}
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, maybeReconstruct(resolved));
} }
} }
} }
@@ -164,7 +216,15 @@ public class PathBindingEvaluator {
if (resolved == null || resolved.isBlank() || resolved.equals(paramName)) { if (resolved == null || resolved.isBlank() || resolved.equals(paramName)) {
return false; return false;
} }
if (resolved.contains("(") && !isEnumLikeConstant(resolved)) { if (BooleanConstraintEvaluator.isIncompleteDottedName(resolved)) {
return false;
}
// Reconstruct with preferred type before rejecting truncated package+CONSTANT refs.
String candidate = maybeReconstruct(resolved);
if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(candidate)) {
return false;
}
if (candidate.contains("(") && !isEnumLikeConstant(candidate)) {
return false; return false;
} }
return true; return true;
@@ -177,7 +237,7 @@ public class PathBindingEvaluator {
for (Map.Entry<String, String> entry : paramValues.entrySet()) { for (Map.Entry<String, String> entry : paramValues.entrySet()) {
String resolved = resolveBindingValue(caller, entry.getValue(), bindings); String resolved = resolveBindingValue(caller, entry.getValue(), bindings);
if (shouldStoreBinding(entry.getKey(), resolved)) { if (shouldStoreBinding(entry.getKey(), resolved)) {
bindings.put(entry.getKey(), resolved); bindings.put(entry.getKey(), maybeReconstruct(resolved));
} }
} }
} }
@@ -187,174 +247,32 @@ public class PathBindingEvaluator {
return null; return null;
} }
if (rawValue.startsWith("\"") || isEnumLikeConstant(rawValue)) { if (rawValue.startsWith("\"") || isEnumLikeConstant(rawValue)) {
return normalizeLiteral(rawValue); return maybeReconstruct(normalizeLiteral(rawValue));
} }
if (bindings.containsKey(rawValue)) { if (bindings.containsKey(rawValue)) {
return bindings.get(rawValue); return maybeReconstruct(bindings.get(rawValue));
} }
String traced = variableTracer.traceLocalVariable(callerFqn, rawValue, bindings); String traced = variableTracer.traceLocalVariable(callerFqn, rawValue, bindings);
if (traced != null && !traced.isBlank()) { if (traced != null && !traced.isBlank()) {
if (traced.startsWith("\"") || isEnumLikeConstant(traced)) { if (traced.startsWith("\"") || isEnumLikeConstant(traced)) {
return traced; return maybeReconstruct(traced);
} }
String fromCall = resolveMethodCallFromSource(callerFqn, rawValue, bindings); String fromCall = expressionResolver.resolveMethodCallFromSource(callerFqn, rawValue, bindings);
if (fromCall != null) { if (fromCall != null) {
return fromCall; return maybeReconstruct(fromCall);
} }
if (!traced.equals(rawValue)) { if (!traced.equals(rawValue)) {
return traced; return maybeReconstruct(traced);
} }
} }
return rawValue; return maybeReconstruct(rawValue);
} }
private String resolveMethodCallFromSource(String callerFqn, String varName, Map<String, String> bindings) { private String maybeReconstruct(String value) {
MethodDeclaration md = findMethodDeclaration(callerFqn); if (value == null || context == null) {
if (md == null || md.getBody() == null) { return value;
return null;
} }
final Expression[] initializer = new Expression[1]; return TruncatedEnumRefReconstructor.reconstruct(value, preferredEnumTypeFqn, context);
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) {

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

@@ -0,0 +1,249 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.SharedServiceRoutingPolicy;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.AbstractTypeDeclaration;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.ParameterizedType;
import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.TypeParameter;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Rebinds unbound {@code StateMachine<S,E>} type arguments on a trigger using the concrete
* subclass parameterization present on the call chain.
* <p>
* Detection stamps types at the {@code sendEvent} site. On a shared base
* {@code AbstractService.updateState}, those args are often unbound {@code S}/{@code E}.
* After a chain exists (e.g. {@code OrderService extends AbstractService<OrderState, OrderEvent>}),
* this binder substitutes the concrete types from that extends clause.
*/
public final class TriggerMachineTypeRebinder {
private TriggerMachineTypeRebinder() {
}
public static TriggerPoint rebind(TriggerPoint trigger, List<String> methodChain, CodebaseContext context) {
if (trigger == null || context == null) {
return trigger;
}
boolean stateConcrete = SharedServiceRoutingPolicy.isConcreteMachineType(trigger.getStateTypeFqn());
boolean eventConcrete = SharedServiceRoutingPolicy.isConcreteMachineType(trigger.getEventTypeFqn());
if (stateConcrete && eventConcrete) {
return trigger;
}
String ownerFqn = trigger.getClassName();
LinkedHashSet<String> stateCandidates = new LinkedHashSet<>();
LinkedHashSet<String> eventCandidates = new LinkedHashSet<>();
if (ownerFqn != null && methodChain != null && !methodChain.isEmpty()) {
TypeDeclaration ownerTd = context.getTypeDeclaration(ownerFqn);
List<String> ownerTypeParams = typeParameterNames(ownerTd);
int stateParamIndex = resolveStateParamIndex(trigger.getStateTypeFqn(), ownerTypeParams);
int eventParamIndex = resolveEventParamIndex(trigger.getEventTypeFqn(), ownerTypeParams);
TypeResolver typeResolver = new TypeResolver(context);
for (String methodFqn : methodChain) {
String chainClassFqn = classNameFromMethodFqn(methodFqn);
if (chainClassFqn == null || chainClassFqn.equals(ownerFqn)) {
continue;
}
AbstractTypeDeclaration chainType = context.getAbstractTypeDeclaration(chainClassFqn);
if (!(chainType instanceof TypeDeclaration chainTd)) {
continue;
}
collectFromSupertype(
chainTd.getSuperclassType(),
ownerFqn,
stateParamIndex,
eventParamIndex,
chainTd,
typeResolver,
stateCandidates,
eventCandidates);
for (Object ifaceObj : chainTd.superInterfaceTypes()) {
if (ifaceObj instanceof Type ifaceType) {
collectFromSupertype(
ifaceType,
ownerFqn,
stateParamIndex,
eventParamIndex,
chainTd,
typeResolver,
stateCandidates,
eventCandidates);
}
}
}
}
String newState = stateConcrete
? trigger.getStateTypeFqn()
: uniqueConcrete(stateCandidates);
String newEvent = eventConcrete
? trigger.getEventTypeFqn()
: uniqueConcrete(eventCandidates);
if (!eventConcrete && newEvent == null) {
newEvent = commonPolymorphicEventOwner(trigger.getPolymorphicEvents());
}
// Drop unresolved type-variable / erasure placeholders so routing treats them as absent.
if (!stateConcrete && !SharedServiceRoutingPolicy.isConcreteMachineType(newState)) {
newState = null;
}
if (!eventConcrete && !SharedServiceRoutingPolicy.isConcreteMachineType(newEvent)) {
newEvent = null;
}
if (Objects.equals(newState, trigger.getStateTypeFqn())
&& Objects.equals(newEvent, trigger.getEventTypeFqn())) {
return trigger;
}
return trigger.toBuilder()
.stateTypeFqn(newState)
.eventTypeFqn(newEvent)
.build();
}
private static void collectFromSupertype(
Type superType,
String ownerFqn,
int stateParamIndex,
int eventParamIndex,
TypeDeclaration chainTd,
TypeResolver typeResolver,
Set<String> stateCandidates,
Set<String> eventCandidates) {
if (!(superType instanceof ParameterizedType parameterized)) {
return;
}
CompilationUnit cu = chainTd.getRoot() instanceof CompilationUnit root ? root : null;
String baseFqn = typeResolver.resolveTypeToFqn(parameterized.getType(), cu);
if (!ownerMatches(ownerFqn, baseFqn, parameterized.getType())) {
return;
}
List<?> typeArgs = parameterized.typeArguments();
if (stateParamIndex >= 0 && stateParamIndex < typeArgs.size()) {
String resolved = typeResolver.resolveTypeToFqn((Type) typeArgs.get(stateParamIndex), cu);
if (SharedServiceRoutingPolicy.isConcreteMachineType(resolved)) {
stateCandidates.add(resolved);
}
}
if (eventParamIndex >= 0 && eventParamIndex < typeArgs.size()) {
String resolved = typeResolver.resolveTypeToFqn((Type) typeArgs.get(eventParamIndex), cu);
if (SharedServiceRoutingPolicy.isConcreteMachineType(resolved)) {
eventCandidates.add(resolved);
}
}
}
private static boolean ownerMatches(String ownerFqn, String resolvedBaseFqn, Type baseType) {
if (ownerFqn == null) {
return false;
}
if (ownerFqn.equals(resolvedBaseFqn)) {
return true;
}
String simpleOwner = ownerFqn.contains(".")
? ownerFqn.substring(ownerFqn.lastIndexOf('.') + 1)
: ownerFqn;
if (resolvedBaseFqn != null
&& (resolvedBaseFqn.equals(simpleOwner) || resolvedBaseFqn.endsWith("." + simpleOwner))) {
return true;
}
if (baseType instanceof SimpleType simpleType) {
String name = simpleType.getName().getFullyQualifiedName();
return simpleOwner.equals(name) || ownerFqn.endsWith("." + name);
}
return baseType != null && simpleOwner.equals(baseType.toString());
}
private static List<String> typeParameterNames(TypeDeclaration typeDeclaration) {
if (typeDeclaration == null) {
return List.of();
}
List<String> names = new ArrayList<>();
for (Object paramObj : typeDeclaration.typeParameters()) {
if (paramObj instanceof TypeParameter typeParameter) {
names.add(typeParameter.getName().getIdentifier());
}
}
return names;
}
/**
* StateMachine&lt;S,E&gt; convention: first type param is state, second is event, unless the
* trigger still carries the type-variable name (map by name).
*/
private static int resolveStateParamIndex(String stateTypeFqn, List<String> ownerTypeParams) {
if (stateTypeFqn != null && ownerTypeParams.contains(stateTypeFqn)) {
return ownerTypeParams.indexOf(stateTypeFqn);
}
return ownerTypeParams.isEmpty() ? -1 : 0;
}
private static int resolveEventParamIndex(String eventTypeFqn, List<String> ownerTypeParams) {
if (eventTypeFqn != null && ownerTypeParams.contains(eventTypeFqn)) {
return ownerTypeParams.indexOf(eventTypeFqn);
}
return ownerTypeParams.size() >= 2 ? 1 : -1;
}
private static String uniqueConcrete(Set<String> candidates) {
if (candidates == null || candidates.size() != 1) {
return null;
}
return candidates.iterator().next();
}
private static String commonPolymorphicEventOwner(List<String> polymorphicEvents) {
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return null;
}
String owner = null;
for (String event : polymorphicEvents) {
if (event == null || event.isBlank() || event.startsWith("<SYMBOLIC:") || event.startsWith("ENUM_SET:")) {
continue;
}
int lastDot = event.lastIndexOf('.');
if (lastDot <= 0) {
return null;
}
String candidateOwner = event.substring(0, lastDot);
if (!SharedServiceRoutingPolicy.isConcreteMachineType(candidateOwner)) {
return null;
}
if (owner == null) {
owner = candidateOwner;
} else if (!owner.equals(candidateOwner)) {
return null;
}
}
return owner;
}
private static String classNameFromMethodFqn(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) {
return null;
}
String clean = methodFqn;
if (clean.contains("(")) {
clean = clean.substring(0, clean.indexOf('('));
}
int lastDot = clean.lastIndexOf('.');
if (lastDot <= 0) {
return null;
}
return clean.substring(0, lastDot);
}
}

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

@@ -34,6 +34,24 @@ class CallChainLinkPolicyTest {
.isEqualTo(LinkResolution.UNRESOLVED_EXTERNAL); .isEqualTo(LinkResolution.UNRESOLVED_EXTERNAL);
} }
@Test
void shouldPreferMatchesOverExternalFlag() {
TriggerPoint trigger = TriggerPoint.builder()
.external(true)
.event("OrderEvent.PAY")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.build();
var matched = List.of(
click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder()
.event("com.example.order.OrderEvent.PAY")
.sourceState("com.example.order.OrderState.NEW")
.build());
assertThat(CallChainLinkPolicy.resolveLinkResolution(trigger, matched, false))
.isEqualTo(LinkResolution.RESOLVED);
}
@Test @Test
void shouldFailClosedOnValueOfAmbiguousWiden() { void shouldFailClosedOnValueOfAmbiguousWiden() {
TriggerPoint trigger = TriggerPoint.builder() TriggerPoint trigger = TriggerPoint.builder()
@@ -48,58 +66,123 @@ 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(false) // even when ambiguous flag was cleared upstream
.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.isEndpointNarrowPolyEvidence(
trigger, "com.example.OrderEvent")).isFalse();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden( assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
trigger, "com.example.OrderEvent")).isTrue(); trigger, "com.example.OrderEvent")).isTrue();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden( assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.OrderEvent")).isFalse(); 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() TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true) .ambiguous(true)
.event("OrderEvent.valueOf(eventStr)") .event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP")) .polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
.build(); .build();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isFalse(); var matched = List.of(
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isTrue(); click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder()
.event("com.example.order.OrderEvent.PAY")
.build());
assertThat(CallChainLinkPolicy.resolveLinkResolution(
trigger,
matched,
false,
"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();
}
@Test
void shouldTrustBareConstantPolymorphicCandidatesWhenMachineEventTypeIsKnown() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("PAY", "SHIP"))
.build();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
trigger, "com.example.order.OrderEvent")).isTrue();
// Multiple concrete events on a dynamic trigger still fail-closed for linking.
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.order.OrderEvent")).isTrue();
}
@Test
void shouldPreferMatchedTransitionsOverSoftAmbiguousSource() {
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.PAY")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.build();
var matched = List.of(
click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder()
.event("com.example.order.OrderEvent.PAY")
.sourceState("com.example.order.OrderState.NEW")
.build(),
click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder()
.event("com.example.order.OrderEvent.PAY")
.sourceState("com.example.order.OrderState.PENDING")
.build());
assertThat(CallChainLinkPolicy.resolveLinkResolution(
trigger, matched, true, "com.example.order.OrderEvent"))
.isEqualTo(LinkResolution.RESOLVED);
} }
} }

View File

@@ -0,0 +1,454 @@
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 shouldNotNarrowUsingRestPathSegmentAlone() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
// Wide poly + URL containing /PAY must NOT invent a narrow — that is not dataflow.
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.getMatchedTransitions()).isNullOrEmpty();
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldNarrowUsingConstraintBindingEvidence() {
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)")
.constraint("\"PAY\".equalsIgnoreCase(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, 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 shouldNotNarrowUsingMethodNameTokensAlone() {
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.getMatchedTransitions()).isNullOrEmpty();
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldNarrowUsingPathBindingsFromDataflow() {
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())
.pathBindings(java.util.Map.of("event", "com.example.OrderEvent.PAY"))
.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");
}
@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");
// Without eventTypeFqn, path text must not invent PAY — require dataflow bindings/constraints.
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/machine/ORDER/transition/PAY")
.className("com.example.MachineController")
.methodName("transition")
.build())
.pathBindings(java.util.Map.of("event", "PAY"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.eventTypeFqn("com.example.OrderEvent")
.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

@@ -78,6 +78,90 @@ class TransitionLinkerEnricherTest {
assertThat(updated.getMatchedTransitions()).hasSize(1); assertThat(updated.getMatchedTransitions()).hasSize(1);
} }
@Test
void shouldNotLinkAllTransitionsWhenNonEventEnumConstantIsBound() {
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "com.example.OrderState.NEW")));
pay.setTargetStates(List.of(State.of("PAID", "com.example.OrderState.PAID")));
pay.setEvent(Event.of("PAY", "com.example.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setSourceStates(List.of(State.of("PAID", "com.example.OrderState.PAID")));
ship.setTargetStates(List.of(State.of("SHIPPED", "com.example.OrderState.SHIPPED")));
ship.setEvent(Event.of("SHIP", "com.example.OrderEvent.SHIP"));
// adjust/LOG-style: dataflow proves a non-transition / non-event constant.
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/order/adjust")
.className("com.example.OrderController")
.methodName("adjust")
.build())
.pathBindings(java.util.Map.of("event", "com.example.OrderEvent.LOG"))
.triggerPoint(TriggerPoint.builder()
.event("com.example.OrderEvent.LOG")
.polymorphicEvents(List.of("com.example.OrderEvent.LOG"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updated = result.getMetadata().getCallChains().get(0);
assertThat(updated.getMatchedTransitions()).isNullOrEmpty();
assertThat(updated.getLinkResolution()).isIn(LinkResolution.NO_MATCH, LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldFailClosedWhenIsEventFilterLeavesMultipleTransitionEventsWithoutEndpointEvidence() {
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "com.example.OrderState.NEW")));
pay.setTargetStates(List.of(State.of("PAID", "com.example.OrderState.PAID")));
pay.setEvent(Event.of("PAY", "com.example.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setSourceStates(List.of(State.of("PAID", "com.example.OrderState.PAID")));
ship.setTargetStates(List.of(State.of("SHIPPED", "com.example.OrderState.SHIPPED")));
ship.setEvent(Event.of("SHIP", "com.example.OrderEvent.SHIP"));
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/order/adjust")
.className("com.example.OrderController")
.methodName("adjust")
.build())
.triggerPoint(TriggerPoint.builder()
.ambiguous(false)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP"))
// After dataflow predicate filter: only transition events remain, still multi.
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updated = result.getMetadata().getCallChains().get(0);
assertThat(updated.getMatchedTransitions()).isNullOrEmpty();
assertThat(updated.getLinkResolution()).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
}
@Test @Test
void shouldExportUnresolvedExternalForGenericPathVariableEndpoint() { void shouldExportUnresolvedExternalForGenericPathVariableEndpoint() {
CallChain chain = CallChain.builder() CallChain chain = CallChain.builder()
@@ -112,6 +196,129 @@ class TransitionLinkerEnricherTest {
assertThat(updated.getMatchedTransitions()).isNullOrEmpty(); assertThat(updated.getMatchedTransitions()).isNullOrEmpty();
} }
@Test
void shouldResolveDedicatedEndpointEvenWhenMisTaggedExternal() {
Transition payT = new Transition();
payT.setSourceStates(List.of(State.of("NEW", "com.example.OrderState.NEW")));
payT.setTargetStates(List.of(State.of("PAID", "com.example.OrderState.PAID")));
payT.setEvent(Event.of("PAY", "com.example.OrderEvent.PAY"));
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/machine/order/pay")
.className("com.example.Api")
.methodName("payOrder")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("userId")
.type("String")
.annotations(List.of("RequestParam"))
.build()))
.build())
.triggerPoint(TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.external(true)
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.transitions(List.of(payT))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updated = result.getMetadata().getCallChains().get(0);
assertThat(updated.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(updated.getMatchedTransitions()).hasSize(1);
assertThat(updated.getTriggerPoint().isExternal()).isFalse();
}
@Test
void shouldLinkDedicatedEventEndpointEvenWithResourceIdPlaceholder() {
Transition assign = new Transition();
assign.setSourceStates(List.of(State.of("OPEN", "com.example.TicketState.OPEN")));
assign.setTargetStates(List.of(State.of("ASSIGNED", "com.example.TicketState.ASSIGNED")));
assign.setEvent(Event.of("ASSIGN", "com.example.TicketEvent.ASSIGN"));
// /{id}/assign has a path placeholder, but it is a resource id — not an unbound event.
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/ticket/{id}/assign")
.className("com.example.WebController")
.methodName("assignTicket")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("id")
.type("String")
.annotations(List.of("PathVariable"))
.build(),
EntryPoint.Parameter.builder()
.name("userId")
.type("String")
.annotations(List.of("RequestParam"))
.build()))
.build())
.triggerPoint(TriggerPoint.builder()
.event("com.example.TicketEvent.ASSIGN")
.polymorphicEvents(List.of("com.example.TicketEvent.ASSIGN"))
.external(true)
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("TicketStateMachineConfig")
.eventTypeFqn("com.example.TicketEvent")
.transitions(List.of(assign))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updated = result.getMetadata().getCallChains().get(0);
assertThat(updated.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(updated.getMatchedTransitions()).hasSize(1);
assertThat(updated.getTriggerPoint().isExternal()).isFalse();
}
@Test
void shouldStillFailClosedOnUnboundEventPathTemplate() {
Transition payT = new Transition();
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/machine/{machineType}/transition/{event}")
.className("com.example.Api")
.methodName("transition")
.build())
.triggerPoint(TriggerPoint.builder()
.event("OrderEvent.valueOf(eventStr)")
.external(true)
.ambiguous(true)
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.transitions(List.of(payT))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updated = result.getMetadata().getCallChains().get(0);
assertThat(updated.getLinkResolution()).isEqualTo(LinkResolution.UNRESOLVED_EXTERNAL);
assertThat(updated.getMatchedTransitions()).isNullOrEmpty();
}
@Test @Test
void shouldLinkWhenSymbolicPolymorphicEventsExpandToMachineEnumConstants() { void shouldLinkWhenSymbolicPolymorphicEventsExpandToMachineEnumConstants() {
Transition payT = new Transition(); Transition payT = new Transition();
@@ -191,8 +398,10 @@ class TransitionLinkerEnricherTest {
enricher.enrich(result, null, null); enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0); CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty(); // Same event from multiple places — link all (baseline / 33ccc4a behavior).
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isTrue(); assertThat(updatedChain.getMatchedTransitions()).hasSize(2);
assertThat(updatedChain.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isFalse();
} }
@Test @Test
@@ -221,6 +430,83 @@ 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"));
// Known concrete event, no endpoint-narrow poly list required for multi-source linking.
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("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

@@ -0,0 +1,117 @@
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.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;
/**
* Regression for enterprise multi-module scans: a globally ambiguous enum simple name must not
* block machine-scoped {@code valueOf} widens from linking when polymorphic events use import-style
* labels and the machine event type is known.
*/
class TrustedEnumWidenLinkingRegressionTest {
@Test
void shouldLinkTrustedImportStyleWidenWhenEnumSimpleNameIsGloballyAmbiguous(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
"package a; public enum OrderEvent { PAY }");
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { CANCEL }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(context.isAmbiguousSimpleName("OrderEvent")).isTrue();
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"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("OrderEvent.valueOf(eventStr)")
.ambiguous(true)
.external(false)
.polymorphicEvents(List.of("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(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSizeGreaterThanOrEqualTo(1);
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
.containsExactly("com.example.order.OrderEvent.PAY");
}
@Test
void shouldKeepTrustedWidenCallChainWhenEnumSimpleNameIsGloballyAmbiguous(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
"package a; public enum OrderEvent { PAY }");
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { CANCEL }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(context.isAmbiguousSimpleName("OrderEvent")).isTrue();
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"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("OrderEvent.valueOf(eventStr)")
.ambiguous(true)
.external(false)
.polymorphicEvents(List.of("OrderEvent.PAY"))
.build())
.build();
List<CallChain> scoped = MachineScopeFilter.filterCallChainsForMachine(
List.of(chain),
"com.example.config.OrderStateMachineConfiguration",
context,
List.of(pay, ship));
assertThat(scoped).hasSize(1);
}
}

View File

@@ -340,7 +340,7 @@ class StrictFqnMatchingEngineTest {
} }
@Test @Test
void shouldNotMatchImportStyleEnumWhenSimpleNameIsAmbiguous(@TempDir java.nio.file.Path tempDir) throws Exception { void shouldMatchImportStyleEnumToMachineFqnWhenSimpleNamesAlign(@TempDir java.nio.file.Path tempDir) throws Exception {
java.nio.file.Files.createDirectories(tempDir.resolve("a")); java.nio.file.Files.createDirectories(tempDir.resolve("a"));
java.nio.file.Files.createDirectories(tempDir.resolve("b")); java.nio.file.Files.createDirectories(tempDir.resolve("b"));
java.nio.file.Files.writeString(tempDir.resolve("a/OrderEvent.java"), java.nio.file.Files.writeString(tempDir.resolve("a/OrderEvent.java"),
@@ -359,6 +359,6 @@ class StrictFqnMatchingEngineTest {
.polymorphicEvents(List.of("OrderEvent.PAY")) .polymorphicEvents(List.of("OrderEvent.PAY"))
.build(); .build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
} }
} }

View File

@@ -57,6 +57,87 @@ class SharedServiceRoutingPolicyTest {
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isFalse(); assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isFalse();
} }
@Test
void unboundGenericTypeVariablesAreTreatedAsAbsentForSharedInfrastructure() {
// Last resort only: when call-chain rebinding cannot prove concrete S/E (no subclass
// parameterization on the path), type-variable placeholders still count as absent.
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.eventTypeFqn("E")
.stateTypeFqn("S")
.className("com.example.SharedService")
.methodName("updateState")
.build())
.methodChain(List.of(
"com.example.CommonController.post()",
"com.example.SharedService.updateState()"))
.build();
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isTrue();
}
@Test
void sharedGenericBaseWithTypeVariablesRoutesViaPolymorphicEventsAsLastResort(
@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("OrderConfig.java"), """
package com.example.order;
@org.springframework.statemachine.config.EnableStateMachine
public class OrderStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<OrderState, OrderEvent> {}
enum OrderEvent { PAY }
enum OrderState { NEW, PAID }
""");
Files.writeString(tempDir.resolve("DocumentConfig.java"), """
package com.example.document;
@org.springframework.statemachine.config.EnableStateMachine
public class DocumentStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<DocumentState, DocumentEvent> {}
enum DocumentEvent { SUBMIT }
enum DocumentState { DRAFT }
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
// Fallback path: chain has no subclass extends SharedService<OrderState, OrderEvent>,
// so TriggerMachineTypeRebinder cannot stamp concrete FQNs. Shared-infra + poly remains.
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("com.example.order.OrderEvent.PAY")
.polymorphicEvents(List.of("com.example.order.OrderEvent.PAY"))
.eventTypeFqn("E")
.stateTypeFqn("S")
.className("com.example.SharedService")
.methodName("updateState")
.build())
.methodChain(List.of(
"com.example.CommonController.pay()",
"com.example.SharedService.updateState()"))
.build();
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "NEW")));
pay.setTargetStates(List.of(State.of("PAID", "PAID")));
pay.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
Transition submit = new Transition();
submit.setSourceStates(List.of(State.of("DRAFT", "DRAFT")));
submit.setTargetStates(List.of(State.of("SUBMITTED", "SUBMITTED")));
submit.setEvent(Event.of("SUBMIT", "com.example.document.DocumentEvent.SUBMIT"));
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isTrue();
assertThat(engine.hasProvenMachineAffinity(
chain, "com.example.order.OrderStateMachineConfig", context, List.of(pay)))
.as("poly PAY must prove affinity to the order machine despite eventTypeFqn=E")
.isTrue();
assertThat(engine.hasProvenMachineAffinity(
chain, "com.example.document.DocumentStateMachineConfig", context, List.of(submit)))
.as("PAY must not prove affinity to an unrelated document machine")
.isFalse();
}
@Test @Test
void sharedInfrastructureMayMultiAttachOnSharedEventInMultiMachineContext(@TempDir Path tempDir) throws Exception { void sharedInfrastructureMayMultiAttachOnSharedEventInMultiMachineContext(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("OrderConfig.java"), """ Files.writeString(tempDir.resolve("OrderConfig.java"), """

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

@@ -1,6 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.resolver; package click.kamil.springstatemachineexporter.analysis.resolver;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.util.Map; import java.util.Map;
@@ -75,4 +76,64 @@ class BooleanConstraintEvaluatorBindingsTest {
"java.util.Objects.equals(normalizeType(machineType), \"DOCUMENT\")", "java.util.Objects.equals(normalizeType(machineType), \"DOCUMENT\")",
Map.of("machineType", "DOCUMENT"))).isTrue(); Map.of("machineType", "DOCUMENT"))).isTrue();
} }
@Test
void shouldNotThrowWhenBindingValueEndsWithDot() {
// Unresolved/truncated FQNs can end with '.' — must not charAt past the end,
// and must not be treated as concrete (which would force a false match).
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"command == ORDER_PAY",
Map.of("command", "com.example.DomainCommand."))).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"key == \"x\"",
Map.of("key", "."))).isTrue();
}
@Test
void shouldRejectIncompleteDottedNamesAsConcreteBindings() {
assertThat(BooleanConstraintEvaluator.isIncompleteDottedName("com.example.DomainCommand.")).isTrue();
assertThat(BooleanConstraintEvaluator.isIncompleteDottedName(".PAY")).isTrue();
assertThat(BooleanConstraintEvaluator.isIncompleteDottedName("com..DomainCommand.PAY")).isTrue();
assertThat(BooleanConstraintEvaluator.isIncompleteDottedName("com.example.DomainCommand.PAY")).isFalse();
assertThat(BooleanConstraintEvaluator.isIncompleteDottedName("order.pay")).isFalse();
// Incomplete values must not be treated as concrete routing keys.
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"command == ORDER_PAY",
Map.of("command", "com.example.DomainCommand."))).isTrue();
}
@Test
void shouldReconstructTruncatedEnumBindingWithPreferredType(@TempDir java.nio.file.Path tempDir)
throws Exception {
java.nio.file.Path dir = tempDir.resolve("com/example");
java.nio.file.Files.createDirectories(dir);
java.nio.file.Files.writeString(dir.resolve("OrderEvent.java"),
"package com.example;\npublic enum OrderEvent { PAY, SHIP }\n");
java.nio.file.Files.writeString(dir.resolve("DocumentEvent.java"),
"package com.example;\npublic enum DocumentEvent { PAY, ARCHIVE }\n");
click.kamil.springstatemachineexporter.ast.common.CodebaseContext context =
new click.kamil.springstatemachineexporter.ast.common.CodebaseContext();
context.scan(tempDir);
// Without preferred type, truncated binding is not concrete → constraint left unevaluated (true).
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"event == PAY",
Map.of("event", "com.example.PAY"),
null,
context)).isTrue();
// With preferred type, truncated ref becomes OrderEvent.PAY and matches.
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"event == PAY",
Map.of("event", "com.example.PAY"),
"com.example.OrderEvent",
context)).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"event == SHIP",
Map.of("event", "com.example.PAY"),
"com.example.OrderEvent",
context)).isFalse();
}
} }

View File

@@ -528,6 +528,37 @@ public class ConstantResolverTest {
assertThat(result2).isEqualTo("com.example.OrderEvent.SHIPPED"); assertThat(result2).isEqualTo("com.example.OrderEvent.SHIPPED");
} }
@Test
void shouldResolveFullyQualifiedEnumValueOfWithoutTruncatingPackage(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("OrderEvent.java"),
"package com.example;\n" +
"public enum OrderEvent { PAY, SHIP }\n");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" OrderEvent e = com.example.OrderEvent.valueOf(\"PAY\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0];
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(0);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer();
String result = new ConstantResolver().resolve(mi, context);
assertThat(result)
.as("must not truncate com.example.OrderEvent to package prefix com.example")
.isEqualTo("com.example.OrderEvent.PAY")
.doesNotContain("com.example.PAY");
}
@Test @Test
void shouldResolveMapOfGetWithLiteralKey(@TempDir Path tempDir) throws IOException { void shouldResolveMapOfGetWithLiteralKey(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example"); Path dir = tempDir.resolve("com/example");
@@ -647,8 +678,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 +690,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

@@ -12,6 +12,7 @@ import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport; import java.util.stream.StreamSupport;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -74,22 +75,32 @@ class EnterpriseBooleanEnumPredicateExportTest {
assertThat(polyEvents.size()).isLessThan(4); assertThat(polyEvents.size()).isLessThan(4);
for (JsonNode dispatchChain : dispatchChains) { for (JsonNode dispatchChain : dispatchChains) {
long chainMatched = StreamSupport.stream(dispatchChain.get("matchedTransitions").spliterator(), false) JsonNode matched = dispatchChain.get("matchedTransitions");
.count(); String resolution = dispatchChain.path("linkResolution").asText();
// Wide predicate-filtered poly without per-endpoint dataflow must not
// highlight every transition — fail closed instead.
if ("AMBIGUOUS_WIDEN".equals(resolution) || "UNRESOLVED_EXTERNAL".equals(resolution)) {
assertThat(matched == null || matched.isNull() || (matched.isArray() && matched.isEmpty()))
.as("fail-closed/unresolved chains must not carry matches")
.isTrue();
continue;
}
long chainMatched = matched == null || matched.isNull()
? 0
: StreamSupport.stream(matched.spliterator(), false).count();
assertThat(chainMatched) assertThat(chainMatched)
.as("each dispatch chain must not cover all transitions") .as("each resolved dispatch chain must not cover all transitions without evidence")
.isLessThanOrEqualTo(transitionCount); .isLessThanOrEqualTo(1);
} }
int matchedCount = dispatchChains.stream()
.mapToInt(chain -> StreamSupport.stream(chain.get("matchedTransitions").spliterator(), false)
.mapToInt(node -> 1)
.sum())
.sum();
assertThat(matchedCount)
.as("matchedTransitions must not cover every transition × every enum constant")
.isLessThan(transitionCount * dispatchChains.size());
Set<String> matchedEvents = dispatchChains.stream() Set<String> matchedEvents = dispatchChains.stream()
.flatMap(chain -> StreamSupport.stream(chain.get("matchedTransitions").spliterator(), false)) .filter(chain -> "RESOLVED".equals(chain.path("linkResolution").asText()))
.flatMap(chain -> {
JsonNode matched = chain.get("matchedTransitions");
if (matched == null || matched.isNull() || !matched.isArray()) {
return Stream.empty();
}
return StreamSupport.stream(matched.spliterator(), false);
})
.map(node -> node.get("event").asText()) .map(node -> node.get("event").asText())
.collect(Collectors.toSet()); .collect(Collectors.toSet());
assertThat(matchedEvents) assertThat(matchedEvents)

View File

@@ -242,7 +242,10 @@ class EnumMemberPredicatePolymorphicInferenceTest {
assertThat(tp.getPolymorphicEvents()) assertThat(tp.getPolymorphicEvents())
.doesNotContain("com.example.order.OrderCommand.META"); .doesNotContain("com.example.order.OrderCommand.META");
assertThat(tp.isAmbiguous()).isTrue(); assertThat(tp.isAmbiguous()).isTrue();
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2); // Multiple predicate-true constants without per-endpoint dataflow must not highlight every transition.
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
assertThat(result.getMetadata().getCallChains().get(0).getLinkResolution())
.isEqualTo(click.kamil.springstatemachineexporter.analysis.model.LinkResolution.AMBIGUOUS_WIDEN);
} }
@Test @Test
@@ -405,7 +408,9 @@ class EnumMemberPredicatePolymorphicInferenceTest {
.containsExactlyInAnyOrder( .containsExactlyInAnyOrder(
"com.example.order.OrderCommand.PAY", "com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP"); "com.example.order.OrderCommand.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(click.kamil.springstatemachineexporter.analysis.model.LinkResolution.AMBIGUOUS_WIDEN);
} }
@Test @Test

View File

@@ -32,7 +32,7 @@ class MachineEnumCanonicalizerCrossPackageTest {
} }
@Test @Test
void shouldNotRewriteImportStyleWhenSimpleNameIsAmbiguous(@TempDir Path tempDir) throws Exception { void shouldCanonicalizeImportStyleWhenMachineEnumTypeIsKnown(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a")); Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b")); Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"), Files.writeString(tempDir.resolve("a/OrderEvent.java"),
@@ -44,7 +44,7 @@ class MachineEnumCanonicalizerCrossPackageTest {
context.scan(tempDir); context.scan(tempDir);
assertThat(MachineEnumCanonicalizer.canonicalizeLabel("OrderEvent.PAY", "a.OrderEvent", context)) assertThat(MachineEnumCanonicalizer.canonicalizeLabel("OrderEvent.PAY", "a.OrderEvent", context))
.isEqualTo("OrderEvent.PAY"); .isEqualTo("a.OrderEvent.PAY");
} }
@Test @Test
@@ -112,7 +112,7 @@ class MachineEnumCanonicalizerCrossPackageTest {
} }
@Test @Test
void shouldNotCanonicalizeAmbiguousImportStyleTransitionEvents(@TempDir Path tempDir) throws Exception { void shouldCanonicalizeAmbiguousImportStyleTransitionEventsWhenMachineTypeKnown(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a")); Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b")); Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"), Files.writeString(tempDir.resolve("a/OrderEvent.java"),
@@ -138,7 +138,7 @@ class MachineEnumCanonicalizerCrossPackageTest {
"a.OrderState", "a.OrderEvent"); "a.OrderState", "a.OrderEvent");
MachineEnumCanonicalizer.canonicalizeTransitions(List.of(transition), types, context); MachineEnumCanonicalizer.canonicalizeTransitions(List.of(transition), types, context);
assertThat(transition.getEvent().fullIdentifier()).isEqualTo("OrderEvent.PAY"); assertThat(transition.getEvent().fullIdentifier()).isEqualTo("a.OrderEvent.PAY");
assertThat(transition.getSourceStates().get(0).fullIdentifier()).isEqualTo("a.OrderState.NEW"); assertThat(transition.getSourceStates().get(0).fullIdentifier()).isEqualTo("a.OrderState.NEW");
} }
} }

View File

@@ -177,6 +177,25 @@ class MachineEnumCanonicalizerTest {
.isEqualTo("MyEvent.valueOf(event)"); .isEqualTo("MyEvent.valueOf(event)");
} }
@Test
void shouldNotCanonicalizeTrailingDotEnumRefs() {
String truncated = "com.example.order.OrderEvent.";
assertThat(MachineEnumCanonicalizer.canonicalizeLabel(
truncated, "com.example.order.OrderEvent"))
.isEqualTo(truncated);
}
@Test
void shouldReconstructPackageTruncatedEnumConstant(@TempDir Path tempDir) throws IOException {
writeSampleConfig(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(MachineEnumCanonicalizer.canonicalizeLabel(
"com.example.order.PAY", "com.example.order.OrderEvent", context))
.isEqualTo("com.example.order.OrderEvent.PAY");
}
@Test @Test
void shouldDenormalizeCorruptedPackageValueOfTriggerExpression() { void shouldDenormalizeCorruptedPackageValueOfTriggerExpression() {
String corrupted = "com.abcd.aaa.MyEvent.valueOf(event)"; String corrupted = "com.abcd.aaa.MyEvent.valueOf(event)";
@@ -433,6 +452,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,186 @@
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()).isNullOrEmpty();
assertThat(result.getMetadata().getCallChains().get(0).getLinkResolution())
.isEqualTo(click.kamil.springstatemachineexporter.analysis.model.LinkResolution.AMBIGUOUS_WIDEN);
}
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,77 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
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 static org.assertj.core.api.Assertions.assertThat;
class TruncatedEnumRefReconstructorTest {
@Test
void shouldReconstructPackageTruncatedConstantWhenUnique(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scanOrderAndDoc(tempDir, false);
assertThat(TruncatedEnumRefReconstructor.reconstruct("com.example.PAY", context))
.isEqualTo("com.example.OrderEvent.PAY");
assertThat(TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef("com.example.PAY"))
.isTrue();
assertThat(TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef("com.example.OrderEvent.PAY"))
.isFalse();
assertThat(TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef("order.pay"))
.isFalse();
}
@Test
void shouldUsePreferredTypeWhenConstantIsAmbiguousAcrossEnums(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scanOrderAndDoc(tempDir, true);
// Both OrderEvent and DocumentEvent have PAY under com.example
assertThat(TruncatedEnumRefReconstructor.reconstruct("com.example.PAY", context))
.isEqualTo("com.example.PAY");
assertThat(TruncatedEnumRefReconstructor.reconstruct(
"com.example.PAY", "com.example.OrderEvent", context))
.isEqualTo("com.example.OrderEvent.PAY");
assertThat(TruncatedEnumRefReconstructor.reconstruct(
"com.example.PAY", "com.example.DocumentEvent", context))
.isEqualTo("com.example.DocumentEvent.PAY");
}
@Test
void shouldNotInventConstantForTrailingDotTypeRef(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scanOrderAndDoc(tempDir, false);
assertThat(TruncatedEnumRefReconstructor.reconstruct("com.example.OrderEvent.", context))
.isEqualTo("com.example.OrderEvent.");
assertThat(TruncatedEnumRefReconstructor.reconstruct(
"com.example.OrderEvent.", "com.example.OrderEvent", context))
.isEqualTo("com.example.OrderEvent.");
}
@Test
void shouldCanonicalizeTruncatedPolyEventViaMachineEnumCanonicalizer(@TempDir Path tempDir) throws IOException {
CodebaseContext context = scanOrderAndDoc(tempDir, false);
assertThat(MachineEnumCanonicalizer.canonicalizeLabel(
"com.example.PAY", "com.example.OrderEvent", context))
.isEqualTo("com.example.OrderEvent.PAY");
}
private static CodebaseContext scanOrderAndDoc(Path tempDir, boolean documentAlsoHasPay) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("OrderEvent.java"),
"package com.example;\npublic enum OrderEvent { PAY, SHIP }\n");
String docConstants = documentAlsoHasPay ? "PAY, ARCHIVE" : "ARCHIVE";
Files.writeString(dir.resolve("DocumentEvent.java"),
"package com.example;\npublic enum DocumentEvent { " + docConstants + " }\n");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
return context;
}
}

View File

@@ -227,7 +227,7 @@ class AmbiguousSimpleEnumLinkingTest {
} }
@Test @Test
void shouldNotRewriteAmbiguousImportStylePolyDuringTriggerCanonicalization(@TempDir Path tempDir) throws Exception { void shouldCanonicalizeImportStylePolyWhenMachineEventTypeIsKnown(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a")); Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b")); Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"), Files.writeString(tempDir.resolve("a/OrderEvent.java"),
@@ -257,7 +257,7 @@ class AmbiguousSimpleEnumLinkingTest {
TriggerPoint canonical = result.getMetadata().getCallChains().get(0).getTriggerPoint(); TriggerPoint canonical = result.getMetadata().getCallChains().get(0).getTriggerPoint();
assertThat(canonical.getPolymorphicEvents()) assertThat(canonical.getPolymorphicEvents())
.containsExactly("OrderEvent.PAY", "OrderEvent.SHIP"); .containsExactly("a.OrderEvent.PAY", "a.OrderEvent.SHIP");
} }
} }

View File

@@ -124,7 +124,7 @@ class AnalysisResultFinalizerTest {
} }
@Test @Test
void shouldPreserveAmbiguousImportStyleMatchedTransitions(@TempDir Path tempDir) throws Exception { void shouldCanonicalizeAmbiguousImportStyleMatchedTransitions(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a")); Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b")); Files.createDirectories(tempDir.resolve("b"));
Files.createDirectories(tempDir.resolve("com/example/config")); Files.createDirectories(tempDir.resolve("com/example/config"));
@@ -165,7 +165,7 @@ class AnalysisResultFinalizerTest {
"a.OrderState", "a.OrderEvent")); "a.OrderState", "a.OrderEvent"));
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent()) assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent())
.isEqualTo("OrderEvent.PAY"); .isEqualTo("a.OrderEvent.PAY");
} }
private static void writeSampleConfig(Path tempDir) throws Exception { private static void writeSampleConfig(Path tempDir) throws Exception {

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

@@ -132,6 +132,58 @@ class ExternalTriggerPolicyTest {
entryPoint, trigger, "com.example.Api.pay", "event", context)).isFalse(); entryPoint, trigger, "com.example.Api.pay", "event", context)).isFalse();
} }
@Test
void dedicatedEndpointWithUnrelatedRequestParamShouldBeInternal() {
EntryPoint entryPoint = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/machine/order/pay")
.className("com.example.Api")
.methodName("payOrder")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("userId")
.type("String")
.annotations(List.of("RequestParam"))
.build()))
.build();
TriggerPoint trigger = TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.external(true) // call-graph may have mis-tagged via userId binding
.build();
assertThat(ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, "com.example.Api.payOrder", null, null)).isFalse();
}
@Test
void resourceIdPathVariableShouldNotMakeDedicatedEndpointExternal() {
EntryPoint entryPoint = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/ticket/{id}/assign")
.className("com.example.WebController")
.methodName("assignTicket")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("id")
.type("String")
.annotations(List.of("PathVariable"))
.build(),
EntryPoint.Parameter.builder()
.name("userId")
.type("String")
.annotations(List.of("RequestParam"))
.build()))
.build();
TriggerPoint trigger = TriggerPoint.builder()
.event("com.example.TicketEvent.ASSIGN")
.polymorphicEvents(List.of("com.example.TicketEvent.ASSIGN"))
.external(true)
.build();
assertThat(ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, "com.example.WebController.assignTicket", null, null)).isFalse();
}
@Test @Test
void concreteEnumLiteralFromDedicatedEndpointShouldBeInternal() { void concreteEnumLiteralFromDedicatedEndpointShouldBeInternal() {
EntryPoint entryPoint = EntryPoint.builder() EntryPoint entryPoint = EntryPoint.builder()

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

@@ -515,6 +515,68 @@ class HeuristicCallGraphEngineTypeTest {
.containsExactlyInAnyOrder("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: UserEvent.*>"); .containsExactlyInAnyOrder("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: UserEvent.*>");
} }
@Test
void shouldResolveFullyQualifiedValueOfWithoutTruncatingPackage(@TempDir Path tempDir) throws IOException {
// Regression: QualifiedName receivers must keep the type simple name.
// Old bug stripped com.example.OrderEvent → com.example, yielding com.example.PAY.
String source = """
package com.example;
public class Dispatcher {
public void dispatch() {
StateMachine sm = new StateMachine();
sm.sendEvent(com.example.OrderEvent.valueOf("PAY"));
}
public void dispatchVar(String event) {
StateMachine sm = new StateMachine();
sm.sendEvent(com.example.OrderEvent.valueOf(event));
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine {
public void sendEvent(Object e) {}
}
""";
Files.writeString(tempDir.resolve("Dispatcher.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
EntryPoint literalEntry = EntryPoint.builder()
.className("com.example.Dispatcher")
.methodName("dispatch")
.build();
EntryPoint varEntry = EntryPoint.builder()
.className("com.example.Dispatcher")
.methodName("dispatchVar")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("event")
.type("String")
.build()))
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("e")
.build();
List<CallChain> literalChains = builder.findChains(List.of(literalEntry), List.of(trigger));
assertThat(literalChains).hasSize(1);
assertThat(literalChains.get(0).getTriggerPoint().getPolymorphicEvents())
.as("must keep OrderEvent type segment (not package-only com.example.PAY)")
.isNotEmpty()
.allSatisfy(e -> assertThat(e).contains("OrderEvent"))
.noneMatch(e -> e.equals("com.example.PAY") || e.endsWith("."));
List<CallChain> varChains = builder.findChains(List.of(varEntry), List.of(trigger));
assertThat(varChains).isNotEmpty();
assertThat(varChains.stream()
.flatMap(c -> c.getTriggerPoint().getPolymorphicEvents().stream())
.toList())
.as("variable valueOf must not truncate FQN type to package prefix")
.noneMatch(e -> e.equals("com.example.PAY") || e.startsWith("com.example.PAY") || e.endsWith("."));
}
@Test @Test
void testEnterpriseDispatcherResolution() throws IOException { void testEnterpriseDispatcherResolution() throws IOException {
CodebaseContext context = new CodebaseContext(); CodebaseContext context = new CodebaseContext();

View File

@@ -0,0 +1,176 @@
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 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;
/**
* Implicit-{@code this} helpers that return enum constants must not be peeled by
* {@code unwrapConverterMethods} — otherwise ConstantExtractor never widens to subclass overrides.
*/
class ImplicitThisHelperEventResolutionTest {
@Test
void shouldResolveEventFromImplicitThisHelperOverrides(@TempDir Path tempDir) throws Exception {
Path javaRoot = tempDir.resolve("src/main/java/com/example");
Files.createDirectories(javaRoot);
Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }");
Files.writeString(javaRoot.resolve("OrderEvent.java"), """
package com.example;
public enum OrderEvent { PAY, SHIP, LOG }
""");
Files.writeString(javaRoot.resolve("StateMachine.java"), """
package com.example;
public class StateMachine {
void sendEvent(OrderEvent event) {}
}
""");
Files.writeString(javaRoot.resolve("AbstractDispatcher.java"), """
package com.example;
public abstract class AbstractDispatcher {
private final StateMachine sm = new StateMachine();
protected abstract OrderEvent toEvent(String message);
public void handle(String message) {
sm.sendEvent(toEvent(message));
}
}
""");
Files.writeString(javaRoot.resolve("PayDispatcher.java"), """
package com.example;
public class PayDispatcher extends AbstractDispatcher {
@Override
protected OrderEvent toEvent(String message) {
return OrderEvent.PAY;
}
}
""");
Files.writeString(javaRoot.resolve("ShipDispatcher.java"), """
package com.example;
public class ShipDispatcher extends AbstractDispatcher {
@Override
protected OrderEvent toEvent(String message) {
return OrderEvent.SHIP;
}
}
""");
Files.writeString(javaRoot.resolve("OrderController.java"), """
package com.example;
public class OrderController {
private final AbstractDispatcher dispatcher;
public OrderController(AbstractDispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public void onCommand(String message) {
dispatcher.handle(message);
}
}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString()));
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> chains = engine.findChains(
List.of(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /commands")
.className("com.example.OrderController")
.methodName("onCommand")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("message")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build()),
List.of(TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build()));
assertThat(chains).isNotEmpty();
List<String> poly = chains.stream()
.flatMap(chain -> {
List<String> events = chain.getTriggerPoint().getPolymorphicEvents();
return events == null ? java.util.stream.Stream.<String>empty() : events.stream();
})
.distinct()
.toList();
assertThat(poly)
.as("toEvent(message) must stay intact so override returns are collected")
.anyMatch(e -> e.endsWith(".PAY") || e.equals("PAY") || e.endsWith("OrderEvent.PAY"))
.anyMatch(e -> e.endsWith(".SHIP") || e.equals("SHIP") || e.endsWith("OrderEvent.SHIP"));
assertThat(poly)
.as("must not collapse to the raw message parameter after destructive unwrap")
.noneMatch(e -> "message".equals(e));
}
@Test
void shouldStillPeelValueOfForParameterTracing(@TempDir Path tempDir) throws Exception {
Path javaRoot = tempDir.resolve("src/main/java/com/example");
Files.createDirectories(javaRoot);
Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }");
Files.writeString(javaRoot.resolve("OrderEvent.java"), """
package com.example;
public enum OrderEvent { PAY, SHIP }
""");
Files.writeString(javaRoot.resolve("StateMachine.java"), """
package com.example;
public class StateMachine {
void sendEvent(OrderEvent event) {}
}
""");
Files.writeString(javaRoot.resolve("OrderController.java"), """
package com.example;
public class OrderController {
private final StateMachine sm = new StateMachine();
public void onCommand(String eventStr) {
sm.sendEvent(OrderEvent.valueOf(eventStr));
}
}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString()));
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> chains = engine.findChains(
List.of(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /commands/{eventStr}")
.className("com.example.OrderController")
.methodName("onCommand")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("eventStr")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build()),
List.of(TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build()));
assertThat(chains).isNotEmpty();
// valueOf peel must still allow tracing back to the path variable name.
assertThat(chains.stream()
.map(c -> c.getTriggerPoint().getEvent())
.anyMatch(e -> e != null && (e.contains("eventStr") || e.contains("valueOf"))))
.isTrue();
}
}

View File

@@ -0,0 +1,189 @@
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.ast.common.CodebaseContext;
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;
/**
* Template-method bases call abstract hooks as plain {@code doPMessage(m)} (no {@code this.}).
* That null-receiver form must still expand to concrete subclass overrides in the call graph —
* otherwise JMS listeners that share the base never reach subclass triggers.
*/
class ImplicitThisTemplateMethodCallGraphTest {
@Test
void plainHookCallWithoutThisExpandsToSubclassOverrides(@TempDir Path tempDir) throws Exception {
writeTemplateMethodFixture(tempDir);
CodebaseContext context = scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<CallEdge>> graph = engine.buildCallGraph();
List<CallEdge> fromProcess = graph.get("com.example.AbstractListener.processMessage");
assertThat(fromProcess)
.extracting(CallEdge::getTargetMethod)
.as("doPMessage(m) must expand like this.doPMessage(m) for abstract hooks")
.contains(
"com.example.OrderListener.doPMessage",
"com.example.DocumentListener.doPMessage")
.doesNotContain("com.example.AbstractListener.doPMessage");
List<CallEdge> fromOnMessage = graph.get("com.example.OrderListener.onMessage");
assertThat(fromOnMessage)
.extracting(CallEdge::getTargetMethod)
.as("concrete template method processMessage must not invent subclass keys")
.containsExactly("com.example.AbstractListener.processMessage");
}
@Test
void jmsEntryReachesSubclassTriggerThroughPlainTemplateHook(@TempDir Path tempDir) throws Exception {
writeTemplateMethodFixture(tempDir);
CodebaseContext context = scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> orderChains = engine.findChains(
List.of(EntryPoint.builder()
.type(EntryPoint.Type.JMS)
.name("JMS: orders")
.className("com.example.OrderListener")
.methodName("onMessage")
.build()),
List.of(TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build()));
assertThat(orderChains)
.as("OrderListener.onMessage -> inherited processMessage -> OrderListener.doPMessage -> sendEvent")
.isNotEmpty();
assertThat(orderChains)
.as("at least one path must go through this listener's hook, not only sibling subclasses")
.anyMatch(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("OrderListener.doPMessage")));
assertThat(orderChains.stream()
.filter(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("OrderListener.doPMessage")))
.flatMap(chain -> {
List<String> events = chain.getTriggerPoint().getPolymorphicEvents();
return events == null ? java.util.stream.Stream.<String>empty() : events.stream();
}))
.anyMatch(e -> e.contains("PAY") || e.endsWith(".PAY"));
List<CallChain> documentChains = engine.findChains(
List.of(EntryPoint.builder()
.type(EntryPoint.Type.JMS)
.name("JMS: documents")
.className("com.example.DocumentListener")
.methodName("onMessage")
.build()),
List.of(TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build()));
assertThat(documentChains).isNotEmpty();
assertThat(documentChains)
.anyMatch(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("DocumentListener.doPMessage")));
assertThat(documentChains.stream()
.filter(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("DocumentListener.doPMessage")))
.flatMap(chain -> {
List<String> events = chain.getTriggerPoint().getPolymorphicEvents();
return events == null ? java.util.stream.Stream.<String>empty() : events.stream();
}))
.anyMatch(e -> e.contains("SUBMIT") || e.endsWith(".SUBMIT"));
}
@Test
void explicitThisHookCallStillExpandsTheSameWay(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("Listener.java"), """
package com.example;
abstract class AbstractListener {
void processMessage(String m) {
this.doPMessage(m);
}
abstract void doPMessage(String m);
}
class OrderListener extends AbstractListener {
StateMachine sm = new StateMachine();
void onMessage(String m) { processMessage(m); }
@Override
void doPMessage(String m) { sm.sendEvent(OrderEvent.PAY); }
}
class StateMachine { void sendEvent(OrderEvent event) {} }
enum OrderEvent { PAY }
""");
CodebaseContext context = scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<CallEdge>> graph = engine.buildCallGraph();
assertThat(graph.get("com.example.AbstractListener.processMessage"))
.extracting(CallEdge::getTargetMethod)
.containsExactly("com.example.OrderListener.doPMessage");
}
private static CodebaseContext scan(Path tempDir) throws Exception {
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
return context;
}
private static void writeTemplateMethodFixture(Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("Listeners.java"), """
package com.example;
abstract class AbstractListener {
/** Template method: hook invoked without an explicit this. receiver. */
void processMessage(String m) {
doPMessage(m);
}
abstract void doPMessage(String m);
}
class OrderListener extends AbstractListener {
private final StateMachine sm = new StateMachine();
void onMessage(String m) {
processMessage(m);
}
@Override
void doPMessage(String m) {
sm.sendEvent(OrderEvent.PAY);
}
}
class DocumentListener extends AbstractListener {
private final StateMachine sm = new StateMachine();
void onMessage(String m) {
processMessage(m);
}
@Override
void doPMessage(String m) {
sm.sendEvent(DocumentEvent.SUBMIT);
}
}
class StateMachine {
void sendEvent(Object event) {}
}
enum OrderEvent { PAY }
enum DocumentEvent { SUBMIT }
""");
}
}

View File

@@ -0,0 +1,201 @@
package click.kamil.springstatemachineexporter.analysis.service;
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.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 java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* JMS payload params must enter the same binding-expansion path as REST PathVariables.
*/
class JmsPayloadBindingTest {
@Test
void shouldExpandJmsMessagePayloadFromStringSwitchMapper(@TempDir Path tempDir) throws Exception {
writeJmsSwitchProject(tempDir);
CodebaseContext context = scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> graph =
engine.buildCallGraph();
EntryPoint entryPoint = jmsEntryPoint();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph);
assertThat(variants).extracting(map -> map.get("message"))
.containsExactlyInAnyOrder("PAY", "SHIP");
}
@Test
void shouldProduceSeparateChainsForJmsPayloadSwitchCases(@TempDir Path tempDir) throws Exception {
writeJmsSwitchProject(tempDir);
CodebaseContext context = scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> chains = engine.findChains(
List.of(jmsEntryPoint()),
List.of(TriggerPoint.builder()
.className("com.example.OrderService")
.methodName("fire")
.event("event")
.build()));
assertThat(chains).hasSize(2);
assertThat(chains.stream().map(chain -> chain.getEntryPoint().getName()))
.containsExactlyInAnyOrder(
"JMS: order.commands?message=PAY",
"JMS: order.commands?message=SHIP");
}
@Test
void shouldLinkExpandedJmsPayloadChainsToMatchingTransitions(@TempDir Path tempDir) throws Exception {
writeJmsSwitchProject(tempDir);
CodebaseContext context = scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> chains = engine.findChains(
List.of(jmsEntryPoint()),
List.of(TriggerPoint.builder()
.className("com.example.OrderService")
.methodName("fire")
.event("event")
.build()));
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.transitions(List.of(
transition("PAY", "NEW", "PAID"),
transition("SHIP", "PAID", "SHIPPED")))
.metadata(CodebaseMetadata.builder().callChains(chains).build())
.build();
new TransitionLinkerEnricher().enrich(result, context, null);
List<CallChain> linked = result.getMetadata().getCallChains();
assertThat(linked).hasSize(2);
assertThat(linked).allSatisfy(chain -> {
assertThat(chain.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(chain.getMatchedTransitions()).hasSize(1);
});
assertThat(linked.stream()
.flatMap(chain -> chain.getMatchedTransitions().stream())
.map(mt -> mt.getEvent())
.toList())
.containsExactlyInAnyOrder(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP");
}
@Test
void shouldDetectJmsListenerEntryPoint(@TempDir Path tempDir) throws Exception {
writeJmsSwitchProject(tempDir);
CodebaseContext context = scan(tempDir);
MessagingDetector detector = new MessagingDetector(context);
List<EntryPoint> entryPoints = context.getCompilationUnits().stream()
.flatMap(cu -> detector.detect(cu).stream())
.toList();
assertThat(entryPoints).anySatisfy(ep -> {
assertThat(ep.getType()).isEqualTo(EntryPoint.Type.JMS);
assertThat(ep.getName()).isEqualTo("JMS: order.commands");
assertThat(ep.getMethodName()).isEqualTo("onMessage");
});
}
private static EntryPoint jmsEntryPoint() {
return EntryPoint.builder()
.type(EntryPoint.Type.JMS)
.name("JMS: order.commands")
.className("com.example.OrderJmsListener")
.methodName("onMessage")
.metadata(Map.of("destination", "order.commands", "protocol", "JMS"))
.parameters(List.of(EntryPoint.Parameter.builder()
.name("message")
.type("String")
.annotations(List.of())
.build()))
.build();
}
private static void writeJmsSwitchProject(Path tempDir) throws Exception {
Path javaRoot = tempDir.resolve("src/main/java/com/example");
Files.createDirectories(javaRoot);
Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }");
Files.writeString(javaRoot.resolve("OrderEvent.java"), """
package com.example;
public enum OrderEvent { PAY, SHIP, LOG }
""");
Files.writeString(javaRoot.resolve("OrderService.java"), """
package com.example;
public class OrderService {
void fire(OrderEvent event) {}
void dispatch(String message) {
OrderEvent event = switch (message) {
case "PAY" -> OrderEvent.PAY;
case "SHIP" -> OrderEvent.SHIP;
default -> throw new IllegalArgumentException(message);
};
fire(event);
}
}
""");
Files.writeString(javaRoot.resolve("OrderJmsListener.java"), """
package com.example;
import org.springframework.jms.annotation.JmsListener;
public class OrderJmsListener {
private final OrderService orderService = new OrderService();
@JmsListener(destination = "order.commands")
public void onMessage(String message) {
orderService.dispatch(message);
}
}
""");
Files.writeString(javaRoot.resolve("JmsListener.java"), """
package org.springframework.jms.annotation;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface JmsListener {
String destination() default "";
}
""");
}
private static CodebaseContext scan(Path tempDir) throws Exception {
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
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, "com.example.OrderEvent." + event));
transition.setSourceStates(List.of(State.of(source, "com.example.OrderState." + source)));
transition.setTargetStates(List.of(State.of(target, "com.example.OrderState." + target)));
return transition;
}
}

View File

@@ -883,4 +883,227 @@ class MultiModulePathBindingExpanderTest {
assertThat(variants).extracting(map -> map.get("commandKey")) assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship"); .containsExactlyInAnyOrder("order.pay", "order.ship");
} }
@Test
void shouldExpandCommandKeyFromQualifiedInheritedStaticMapInSiblingModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/BaseRoutes.java"), """
package com.example.routes;
import java.util.Map;
public abstract class BaseRoutes {
protected static final Map<String, String> ROUTES = Map.of(
"order.pay", "OrderEvent.PAY",
"order.ship", "OrderEvent.SHIP");
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
package com.example;
import com.example.routes.BaseRoutes;
public class GenericCommandController {
OrderGateway gateway;
public void execute(String commandKey) {
gateway.trigger(commandKey);
}
}
class OrderGateway extends BaseRoutes {
void trigger(String commandKey) {
OrderEvent event = OrderEvent.valueOf(BaseRoutes.ROUTES.get(commandKey));
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.GenericCommandController")
.methodName("execute")
.name("POST /api/commands/{commandKey}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("commandKey")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
}
@Test
void shouldExpandCommandKeyFromStaticMethodMapReceiverInSiblingModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/CommandRoutes.java"), """
package com.example.routes;
import java.util.Map;
public class CommandRoutes {
public static Map<String, String> routes() {
return Map.of(
"order.pay", "OrderEvent.PAY",
"order.ship", "OrderEvent.SHIP");
}
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
package com.example;
import com.example.routes.CommandRoutes;
public class GenericCommandController {
OrderGateway gateway;
public void execute(String commandKey) {
gateway.trigger(commandKey);
}
}
class OrderGateway {
void trigger(String commandKey) {
OrderEvent event = OrderEvent.valueOf(CommandRoutes.routes().get(commandKey));
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.GenericCommandController")
.methodName("execute")
.name("POST /api/commands/{commandKey}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("commandKey")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
}
@Test
void shouldExpandRequestParamFromStaticMapLookupInSiblingModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/CommandRoutes.java"), """
package com.example.routes;
import java.util.Map;
public class CommandRoutes {
public static final Map<String, String> ROUTES = Map.of(
"order.pay", "OrderEvent.PAY",
"order.ship", "OrderEvent.SHIP");
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
package com.example;
import com.example.routes.CommandRoutes;
public class GenericCommandController {
OrderGateway gateway;
public void execute(String commandKey) {
gateway.trigger(commandKey);
}
}
class OrderGateway {
void trigger(String commandKey) {
OrderEvent event = OrderEvent.valueOf(CommandRoutes.ROUTES.get(commandKey));
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.GenericCommandController")
.methodName("execute")
.name("POST /api/commands")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("commandKey")
.type("String")
.annotations(List.of("RequestParam"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
assertThat(EntryPointBindingExpander.withResolvedPath(entryPoint, variants.get(0)).getName())
.isEqualTo("POST /api/commands?commandKey=order.pay");
}
} }

View File

@@ -93,7 +93,7 @@ class PathBindingEvaluatorTest {
Map<String, String> payBindings = evaluator.traceBindings(rawPaths.get(0), graph, pathFinder); Map<String, String> payBindings = evaluator.traceBindings(rawPaths.get(0), graph, pathFinder);
assertThat(payBindings) assertThat(payBindings)
.as("pay path bindings, compatible=%s", payPathCompatible) .as("pay path bindings, compatible=%s", payPathCompatible)
.containsEntry("command", "DomainCommand.ORDER_PAY"); .containsEntry("command", "com.example.DomainCommand.ORDER_PAY");
EntryPoint entry = EntryPoint.builder().className("com.example.ApiController").methodName("pay").build(); EntryPoint entry = EntryPoint.builder().className("com.example.ApiController").methodName("pay").build();
TriggerPoint trigger = TriggerPoint.builder() TriggerPoint trigger = TriggerPoint.builder()

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

@@ -0,0 +1,271 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.SharedServiceRoutingPolicy;
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;
/**
* End-to-end: {@code SharedService&lt;S,E&gt;} sendEvent sites start unbound; after call-chain
* resolution the subclass parameterization rebinds concrete state/event FQNs so routing uses
* type affinity instead of shared-infra fallback.
*/
class SharedGenericBaseTypeRebindingTest {
private final HeuristicBeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
@Test
void orderAndDocumentChainsRebindConcreteTypesAndStayIsolated(@TempDir Path tempDir) throws Exception {
writeProject(tempDir);
CodebaseContext context = scan(tempDir);
List<TriggerPoint> triggers = detectTriggers(context);
assertThat(triggers).isNotEmpty();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> orderChains = engine.findChains(
List.of(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /orders/pay")
.className("com.example.OrderController")
.methodName("pay")
.build()),
triggers);
assertThat(orderChains).isNotEmpty();
TriggerPoint orderTrigger = orderChains.get(0).getTriggerPoint();
assertThat(orderTrigger.getEventTypeFqn())
.as("OrderService extends SharedService<OrderState, OrderEvent> must rebind E")
.isEqualTo("com.example.OrderEvent");
assertThat(orderTrigger.getStateTypeFqn())
.as("OrderService extends SharedService<OrderState, OrderEvent> must rebind S")
.isEqualTo("com.example.OrderState");
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(orderChains.get(0)))
.as("concrete S/E must demote shared-infra")
.isFalse();
List<CallChain> documentChains = engine.findChains(
List.of(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /documents/submit")
.className("com.example.DocumentController")
.methodName("submit")
.build()),
triggers);
assertThat(documentChains).isNotEmpty();
TriggerPoint documentTrigger = documentChains.get(0).getTriggerPoint();
assertThat(documentTrigger.getEventTypeFqn()).isEqualTo("com.example.DocumentEvent");
assertThat(documentTrigger.getStateTypeFqn()).isEqualTo("com.example.DocumentState");
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(documentChains.get(0)))
.isFalse();
Transition pay = transition("PAY", "com.example.OrderEvent.PAY", "NEW", "PAID");
Transition submit = transition("SUBMIT", "com.example.DocumentEvent.SUBMIT", "DRAFT", "SUBMITTED");
assertThat(routingEngine.hasProvenMachineAffinity(
orderChains.get(0), "com.example.OrderStateMachineConfig", context, List.of(pay)))
.isTrue();
assertThat(routingEngine.hasProvenMachineAffinity(
orderChains.get(0), "com.example.DocumentStateMachineConfig", context, List.of(submit)))
.isFalse();
assertThat(routingEngine.hasProvenMachineAffinity(
documentChains.get(0), "com.example.DocumentStateMachineConfig", context, List.of(submit)))
.isTrue();
assertThat(routingEngine.hasProvenMachineAffinity(
documentChains.get(0), "com.example.OrderStateMachineConfig", context, List.of(pay)))
.isFalse();
AnalysisResult orderResult = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay))
.metadata(CodebaseMetadata.builder().callChains(orderChains).build())
.build();
new TransitionLinkerEnricher().enrich(orderResult, context, null);
CallChain linkedOrder = orderResult.getMetadata().getCallChains().get(0);
assertThat(linkedOrder.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linkedOrder.getMatchedTransitions()).hasSize(1);
assertThat(linkedOrder.getMatchedTransitions().get(0).getEvent())
.contains("PAY");
AnalysisResult documentResult = AnalysisResult.builder()
.name("com.example.DocumentStateMachineConfig")
.eventTypeFqn("com.example.DocumentEvent")
.stateTypeFqn("com.example.DocumentState")
.transitions(List.of(submit))
.metadata(CodebaseMetadata.builder().callChains(documentChains).build())
.build();
new TransitionLinkerEnricher().enrich(documentResult, context, null);
CallChain linkedDocument = documentResult.getMetadata().getCallChains().get(0);
assertThat(linkedDocument.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linkedDocument.getMatchedTransitions()).hasSize(1);
assertThat(linkedDocument.getMatchedTransitions().get(0).getEvent())
.contains("SUBMIT");
}
@Test
void stillUnboundWithoutSubclassOnPathKeepsSharedInfraFallback(@TempDir Path tempDir) throws Exception {
writeProject(tempDir);
CodebaseContext context = scan(tempDir);
// No OrderService/DocumentService on the chain — rebinding cannot prove a unique S/E.
CallChain unboundChain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.className("com.example.SharedService")
.methodName("updateState")
.event("com.example.OrderEvent.PAY")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.eventTypeFqn("E")
.stateTypeFqn("S")
.build())
.methodChain(List.of(
"com.example.CommonController.dispatch()",
"com.example.SharedService.updateState()"))
.build();
TriggerPoint rebound = TriggerMachineTypeRebinder.rebind(
unboundChain.getTriggerPoint(), unboundChain.getMethodChain(), context);
// Poly-owner may recover event type; state stays unbound without subclass args.
assertThat(rebound.getStateTypeFqn()).isNull();
CallChain afterRebind = unboundChain.toBuilder().triggerPoint(rebound).build();
Transition pay = transition("PAY", "com.example.OrderEvent.PAY", "NEW", "PAID");
// With only event type rebound (or still absent types + poly), affinity to order machine
// remains via poly / shared-infra — not via invented Document types.
assertThat(routingEngine.hasProvenMachineAffinity(
afterRebind, "com.example.OrderStateMachineConfig", context, List.of(pay)))
.isTrue();
}
private static List<TriggerPoint> detectTriggers(CodebaseContext context) {
GenericEventDetector detector = new GenericEventDetector(
context, context.getConstantResolver(), List.of());
return context.getCompilationUnits().stream()
.flatMap(cu -> detector.detect(cu).stream())
.filter(tp -> "updateState".equals(tp.getMethodName())
|| "sendEvent".equals(tp.getMethodName()))
.toList();
}
private static CodebaseContext scan(Path tempDir) throws Exception {
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString()));
context.scan(tempDir);
return context;
}
private static void writeProject(Path tempDir) throws Exception {
Path javaRoot = tempDir.resolve("src/main/java/com/example");
Files.createDirectories(javaRoot);
Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }");
Files.writeString(javaRoot.resolve("OrderEvent.java"), """
package com.example;
public enum OrderEvent { PAY }
""");
Files.writeString(javaRoot.resolve("OrderState.java"), """
package com.example;
public enum OrderState { NEW, PAID }
""");
Files.writeString(javaRoot.resolve("DocumentEvent.java"), """
package com.example;
public enum DocumentEvent { SUBMIT }
""");
Files.writeString(javaRoot.resolve("DocumentState.java"), """
package com.example;
public enum DocumentState { DRAFT, SUBMITTED }
""");
Files.writeString(javaRoot.resolve("SharedService.java"), """
package com.example;
public abstract class SharedService<S, E> {
private org.springframework.statemachine.StateMachine<S, E> sm;
protected void updateState(E event) {
sm.sendEvent(event);
}
}
""");
Files.writeString(javaRoot.resolve("OrderService.java"), """
package com.example;
public class OrderService extends SharedService<OrderState, OrderEvent> {
public void pay() {
updateState(OrderEvent.PAY);
}
}
""");
Files.writeString(javaRoot.resolve("DocumentService.java"), """
package com.example;
public class DocumentService extends SharedService<DocumentState, DocumentEvent> {
public void submit() {
updateState(DocumentEvent.SUBMIT);
}
}
""");
Files.writeString(javaRoot.resolve("OrderController.java"), """
package com.example;
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
public void pay() {
orderService.pay();
}
}
""");
Files.writeString(javaRoot.resolve("DocumentController.java"), """
package com.example;
public class DocumentController {
private final DocumentService documentService;
public DocumentController(DocumentService documentService) {
this.documentService = documentService;
}
public void submit() {
documentService.submit();
}
}
""");
Files.writeString(javaRoot.resolve("OrderStateMachineConfig.java"), """
package com.example;
@org.springframework.statemachine.config.EnableStateMachine
public class OrderStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<OrderState, OrderEvent> {}
""");
Files.writeString(javaRoot.resolve("DocumentStateMachineConfig.java"), """
package com.example;
@org.springframework.statemachine.config.EnableStateMachine
public class DocumentStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<DocumentState, DocumentEvent> {}
""");
}
private static Transition transition(String simple, String fqn, String from, String to) {
Transition t = new Transition();
t.setSourceStates(List.of(State.of(from, from)));
t.setTargetStates(List.of(State.of(to, to)));
t.setEvent(Event.of(simple, fqn));
return t;
}
}

View File

@@ -0,0 +1,149 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
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 TriggerMachineTypeRebinderTest {
@Test
void rebindsUnboundTypeVariablesFromSubclassParameterizationOnChain(@TempDir Path tempDir) throws Exception {
writeSharedBaseFixture(tempDir);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
TriggerPoint unbound = TriggerPoint.builder()
.className("com.example.SharedService")
.methodName("updateState")
.event("event")
.stateTypeFqn("S")
.eventTypeFqn("E")
.build();
TriggerPoint rebound = TriggerMachineTypeRebinder.rebind(
unbound,
List.of(
"com.example.OrderController.pay()",
"com.example.OrderService.pay()",
"com.example.SharedService.updateState()"),
context);
assertThat(rebound.getStateTypeFqn()).isEqualTo("com.example.OrderState");
assertThat(rebound.getEventTypeFqn()).isEqualTo("com.example.OrderEvent");
}
@Test
void doesNotInventTypeWhenChainHasConflictingSubclassParameterizations(@TempDir Path tempDir) throws Exception {
writeSharedBaseFixture(tempDir);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
TriggerPoint unbound = TriggerPoint.builder()
.className("com.example.SharedService")
.methodName("updateState")
.event("event")
.stateTypeFqn("S")
.eventTypeFqn("E")
.build();
// Both concrete subclasses on one chain → conflict → leave unbound (cleared to null).
TriggerPoint rebound = TriggerMachineTypeRebinder.rebind(
unbound,
List.of(
"com.example.OrderService.pay()",
"com.example.DocumentService.submit()",
"com.example.SharedService.updateState()"),
context);
assertThat(rebound.getStateTypeFqn()).isNull();
assertThat(rebound.getEventTypeFqn()).isNull();
}
@Test
void infersEventTypeOwnerFromConcretePolymorphicEventsWhenStillUnbound(@TempDir Path tempDir) throws Exception {
writeSharedBaseFixture(tempDir);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
TriggerPoint unbound = TriggerPoint.builder()
.className("com.example.SharedService")
.methodName("updateState")
.event("event")
.stateTypeFqn("S")
.eventTypeFqn("E")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.build();
// Chain has no subclass extends clause (controller only) → poly-owner fallback for event.
TriggerPoint rebound = TriggerMachineTypeRebinder.rebind(
unbound,
List.of(
"com.example.OrderController.pay()",
"com.example.SharedService.updateState()"),
context);
assertThat(rebound.getEventTypeFqn()).isEqualTo("com.example.OrderEvent");
assertThat(rebound.getStateTypeFqn()).isNull();
}
@Test
void leavesConcreteTypesUntouched(@TempDir Path tempDir) throws Exception {
writeSharedBaseFixture(tempDir);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
TriggerPoint concrete = TriggerPoint.builder()
.className("com.example.SharedService")
.methodName("updateState")
.event("com.example.OrderEvent.PAY")
.stateTypeFqn("com.example.OrderState")
.eventTypeFqn("com.example.OrderEvent")
.build();
TriggerPoint rebound = TriggerMachineTypeRebinder.rebind(
concrete,
List.of("com.example.DocumentService.submit()", "com.example.SharedService.updateState()"),
context);
assertThat(rebound).isSameAs(concrete);
}
private static void writeSharedBaseFixture(Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("Shared.java"), """
package com.example;
abstract class SharedService<S, E> {
org.springframework.statemachine.StateMachine<S, E> sm;
void updateState(E event) { sm.sendEvent(event); }
}
class OrderService extends SharedService<OrderState, OrderEvent> {
void pay() { updateState(OrderEvent.PAY); }
}
class DocumentService extends SharedService<DocumentState, DocumentEvent> {
void submit() { updateState(DocumentEvent.SUBMIT); }
}
class OrderController {
OrderService orderService;
void pay() { orderService.pay(); }
}
enum OrderEvent { PAY }
enum OrderState { NEW, PAID }
enum DocumentEvent { SUBMIT }
enum DocumentState { DRAFT }
""");
}
}

View File

@@ -291,6 +291,28 @@ class CodebaseContextTest {
assertThat(context.areClassesPolymorphicallyCompatible("a.Dispatcher", "b.Dispatcher")).isFalse(); assertThat(context.areClassesPolymorphicallyCompatible("a.Dispatcher", "b.Dispatcher")).isFalse();
} }
@Test
void getSuperclassFqnShouldPreferImportAwareResolutionWhenBindingMisResolvesPackage() throws IOException {
Files.createDirectories(tempDir.resolve("com/example/routes"));
Files.writeString(tempDir.resolve("com/example/routes/BaseRoutes.java"), """
package com.example.routes;
public abstract class BaseRoutes {}
""");
Files.writeString(tempDir.resolve("com/example/App.java"), """
package com.example;
import com.example.routes.BaseRoutes;
class OrderGateway extends BaseRoutes {}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
assertThat(context.getTypeDeclaration("com.example.routes.BaseRoutes")).isNotNull();
assertThat(context.getSuperclassFqn(context.getTypeDeclaration("com.example.OrderGateway")))
.isEqualTo("com.example.routes.BaseRoutes");
}
@Test @Test
void shouldFindClassWithFullyQualifiedAnnotation() throws IOException { void shouldFindClassWithFullyQualifiedAnnotation() throws IOException {
String source = """ String source = """

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

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" : { }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

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" : {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

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" : { }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Some files were not shown because too many files have changed in this diff Show More