Compare commits
23 Commits
6ca8e64750
...
ai-branch-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5300fdb881 | |||
| 2fc5ad53d5 | |||
| 961873d29c | |||
| 08adbcb5ff | |||
| 139eee0a43 | |||
| 4b5de0e167 | |||
| 1d9c986793 | |||
| e067412db9 | |||
| 598b504873 | |||
| 9250e97055 | |||
| 4809423004 | |||
| b4a7575bb9 | |||
|
|
109957b5fa | ||
| 342efaef8e | |||
| 9a3bd3394f | |||
| d6754b7464 | |||
| 0558fd2bb7 | |||
| d5a6adafcc | |||
| 9a54616e5f | |||
| dbc22bf1af | |||
| 758d433726 | |||
| 1957266a98 | |||
| 33ccc4a6c5 |
63
AGENTS.md
Normal 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
|
||||||
@@ -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,24 +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);
|
|
||||||
}
|
}
|
||||||
return MachineEnumCanonicalizer.isDynamicTriggerExpression(event)
|
|
||||||
&& event != null
|
/**
|
||||||
&& !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;
|
||||||
|
}
|
||||||
|
String eventTypeFqn = trigger.getEventTypeFqn();
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,13 +202,11 @@ 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);
|
||||||
return false;
|
if (concrete.isEmpty()) {
|
||||||
}
|
|
||||||
for (String pe : poly) {
|
|
||||||
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:") || !pe.contains(".")) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
for (String pe : concrete) {
|
||||||
if (!MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(pe, eventTypeFqn, context)) {
|
if (!MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(pe, eventTypeFqn, context)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -95,6 +214,34 @@ public final class CallChainLinkPolicy {
|
|||||||
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,
|
||||||
@@ -116,15 +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)) {
|
||||||
|| 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;
|
||||||
}
|
}
|
||||||
@@ -135,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,31 +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);
|
||||||
if (machineTypes != null) {
|
|
||||||
tp = tp.toBuilder()
|
tp = tp.toBuilder()
|
||||||
.eventTypeFqn(tp.getEventTypeFqn() != null
|
.eventTypeFqn(concreteOrFallback(tp.getEventTypeFqn(), effectiveEventTypeFqn))
|
||||||
? tp.getEventTypeFqn()
|
.stateTypeFqn(concreteOrFallback(tp.getStateTypeFqn(), effectiveMachineTypes.stateTypeFqn()))
|
||||||
: machineTypes.eventTypeFqn())
|
|
||||||
.stateTypeFqn(tp.getStateTypeFqn() != null
|
|
||||||
? tp.getStateTypeFqn()
|
|
||||||
: machineTypes.stateTypeFqn())
|
|
||||||
.build();
|
.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)
|
||||||
@@ -96,12 +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(
|
&& isRoutedToCorrectMachine(
|
||||||
chain, result.getName(), context, stateMachineTransitions, tp,
|
chain, result.getName(), context, stateMachineTransitions, tp,
|
||||||
machineTypes != null ? machineTypes.eventTypeFqn() : null)
|
effectiveEventTypeFqn)
|
||||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||||
String smEventForLink = canonicalEvent(t.getEvent());
|
String smEventForLink = canonicalEvent(t.getEvent());
|
||||||
for (State smSourceState : t.getSourceStates()) {
|
for (State smSourceState : t.getSourceStates()) {
|
||||||
@@ -109,12 +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);
|
||||||
@@ -145,7 +182,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
.build();
|
.build();
|
||||||
if (isRoutedToCorrectMachine(
|
if (isRoutedToCorrectMachine(
|
||||||
chain, result.getName(), context, stateMachineTransitions, tp,
|
chain, result.getName(), context, stateMachineTransitions, tp,
|
||||||
machineTypes != null ? machineTypes.eventTypeFqn() : null)
|
effectiveEventTypeFqn)
|
||||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||||
matched.add(mt);
|
matched.add(mt);
|
||||||
}
|
}
|
||||||
@@ -159,7 +196,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
.build();
|
.build();
|
||||||
if (isRoutedToCorrectMachine(
|
if (isRoutedToCorrectMachine(
|
||||||
chain, result.getName(), context, stateMachineTransitions, tp,
|
chain, result.getName(), context, stateMachineTransitions, tp,
|
||||||
machineTypes != null ? machineTypes.eventTypeFqn() : null)
|
effectiveEventTypeFqn)
|
||||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||||
matched.add(mt);
|
matched.add(mt);
|
||||||
}
|
}
|
||||||
@@ -179,7 +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()) {
|
||||||
@@ -250,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) {
|
||||||
@@ -293,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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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];
|
||||||
@@ -134,6 +134,8 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
|
|||||||
return false;
|
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())) {
|
||||||
@@ -142,8 +144,13 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
|
|||||||
if (injectionMatch != null) {
|
if (injectionMatch != null) {
|
||||||
return injectionMatch;
|
return injectionMatch;
|
||||||
}
|
}
|
||||||
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Boolean packageMatch = PackageNameRoutingHeuristics.matches(chain, currentMachineName, explicitTarget);
|
Boolean packageMatch = PackageNameRoutingHeuristics.matches(chain, currentMachineName, explicitTarget);
|
||||||
if (packageMatch != null) {
|
if (packageMatch != null) {
|
||||||
@@ -154,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;
|
||||||
}
|
}
|
||||||
@@ -193,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,
|
||||||
@@ -226,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) {
|
||||||
@@ -326,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
if ("true".equalsIgnoreCase(boundValue) || "false".equalsIgnoreCase(boundValue)) {
|
candidate = TruncatedEnumRefReconstructor.reconstruct(
|
||||||
return true;
|
boundValue, preferredEnumTypeFqn, context);
|
||||||
|
if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(candidate)) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
if (boundValue.contains(".") && Character.isUpperCase(boundValue.charAt(boundValue.lastIndexOf('.') + 1))) {
|
}
|
||||||
return true;
|
if (candidate.contains("(") && !candidate.contains("valueOf")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (candidate.startsWith("\"") && candidate.endsWith("\"")) {
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
if (constantStart >= canonical.length()) {
|
||||||
|
String fnRaw = toFnForm(canonical, enumTypeFqn);
|
||||||
|
raw = fnRaw != null ? fnRaw : resolvePlaceholder(state.rawName());
|
||||||
|
} else {
|
||||||
|
String constant = canonical.substring(constantStart);
|
||||||
raw = "\"" + constant + "\"";
|
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,9 +882,26 @@ 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 + ".")) {
|
||||||
|
String suffix = stripped.substring(enumTypeFqn.length() + 1);
|
||||||
|
if (!suffix.isEmpty()) {
|
||||||
return stripped;
|
return stripped;
|
||||||
}
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
String constant = constantName(stripped);
|
String constant = constantName(stripped);
|
||||||
String typePart = enumTypeFromRef(stripped);
|
String typePart = enumTypeFromRef(stripped);
|
||||||
@@ -847,11 +911,19 @@ public final class MachineEnumCanonicalizer {
|
|||||||
return enumTypeFqn + "." + constant;
|
return enumTypeFqn + "." + constant;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typePart != null && enumTypesMatch(enumTypeFqn, typePart, context)) {
|
if (typePart != null && !constant.isEmpty() && enumTypesMatch(enumTypeFqn, typePart, context)) {
|
||||||
return enumTypeFqn + "." + constant;
|
return enumTypeFqn + "." + constant;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typePart != null && context != null && context.isAmbiguousSimpleName(typePart)) {
|
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;
|
return stripped;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1064,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;
|
||||||
}
|
}
|
||||||
@@ -1075,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);
|
||||||
@@ -1100,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 -> {
|
|
||||||
ASTParser parser = ASTParser.newParser(AST.JLS17);
|
|
||||||
Map<String, String> options = org.eclipse.jdt.core.JavaCore.getOptions();
|
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
return parsedNodeCache.computeIfAbsent(expr, click.kamil.springstatemachineexporter.ast.common.AstUtils::parseExpression);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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) {
|
||||||
|
String literal = sl.getLiteralValue();
|
||||||
|
if (literal != null && !literal.isBlank()) {
|
||||||
if (enumTypeName != null) {
|
if (enumTypeName != null) {
|
||||||
polymorphicEvents.add(enumTypeName + "." + sl.getLiteralValue());
|
polymorphicEvents.add(reconstructEnumRef(enumTypeName + "." + literal));
|
||||||
} else {
|
} else {
|
||||||
polymorphicEvents.add(sl.getLiteralValue());
|
polymorphicEvents.add(reconstructEnumRef(literal));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
methodName = null;
|
methodName = null;
|
||||||
handledStaticEnum = true;
|
handledStaticEnum = true;
|
||||||
} else if (arg instanceof QualifiedName qn) {
|
} else if (arg instanceof QualifiedName qn) {
|
||||||
|
String constant = qn.getName().getIdentifier();
|
||||||
|
if (constant != null && !constant.isBlank()) {
|
||||||
if (enumTypeName != null) {
|
if (enumTypeName != null) {
|
||||||
polymorphicEvents.add(enumTypeName + "." + qn.getName().getIdentifier());
|
polymorphicEvents.add(reconstructEnumRef(enumTypeName + "." + constant));
|
||||||
} else {
|
} else {
|
||||||
polymorphicEvents.add(qn.getName().getIdentifier());
|
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,7 +1044,26 @@ 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) {
|
||||||
|
ownerCandidates.add(context.getFqn(enclosingType));
|
||||||
|
}
|
||||||
|
// String-parsed expressions have no enclosing AST type — walk the call path instead.
|
||||||
|
for (String methodFqn : path) {
|
||||||
|
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);
|
List<String> switchConstants = resolveLocalSwitchMethodConstants(enclosingType, fallbackMi);
|
||||||
for (String switchConstant : switchConstants) {
|
for (String switchConstant : switchConstants) {
|
||||||
if (switchConstant != null && !switchConstant.startsWith("<SYMBOLIC:")
|
if (switchConstant != null && !switchConstant.startsWith("<SYMBOLIC:")
|
||||||
@@ -1036,8 +1071,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
polymorphicEvents.add(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,45 +2767,120 @@ 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;
|
||||||
}
|
}
|
||||||
boolean shouldUnpack = false;
|
if (expressionAccessClassifier.isKeyedLookup(miArg, scopeMethodFqn)) {
|
||||||
org.eclipse.jdt.core.dom.Expression recv = miArg.getExpression();
|
break;
|
||||||
if (recv == null || recv instanceof org.eclipse.jdt.core.dom.ThisExpression) {
|
|
||||||
shouldUnpack = true;
|
|
||||||
} else if (recv instanceof org.eclipse.jdt.core.dom.SimpleName sn) {
|
|
||||||
shouldUnpack = !Character.isLowerCase(sn.getIdentifier().charAt(0));
|
|
||||||
} else if (recv instanceof org.eclipse.jdt.core.dom.QualifiedName qn) {
|
|
||||||
shouldUnpack = !Character.isLowerCase(qn.getName().getIdentifier().charAt(0));
|
|
||||||
}
|
}
|
||||||
if (shouldUnpack) {
|
String methodName = miArg.getName().getIdentifier();
|
||||||
org.eclipse.jdt.core.dom.Expression innerArg = (org.eclipse.jdt.core.dom.Expression) miArg.arguments().get(0);
|
Expression recv = miArg.getExpression();
|
||||||
if (miArg.getName().getIdentifier().equals("valueOf") && miArg.arguments().size() == 2) {
|
|
||||||
innerArg = (org.eclipse.jdt.core.dom.Expression) miArg.arguments().get(1);
|
// 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);
|
||||||
}
|
}
|
||||||
current = innerArg.toString();
|
current = innerArg.toString();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional.of / Mono.just / MessageBuilder.withPayload — peel library factories only.
|
||||||
|
if (recv != null
|
||||||
|
&& LibraryUnwrapRegistry.isUnwrapReceiverType(recv.toString())
|
||||||
|
&& LibraryUnwrapRegistry.isUnwrapMethodName(methodName)
|
||||||
|
&& !LibraryUnwrapRegistry.shouldStopUnwrap(methodName)) {
|
||||||
|
current = ((Expression) miArg.arguments().get(0)).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
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('.'));
|
|
||||||
if (isResolvedEnumTypeFqn(typePart)) {
|
|
||||||
return typePart;
|
|
||||||
}
|
|
||||||
return full.substring(full.lastIndexOf('.') + 1);
|
|
||||||
}
|
|
||||||
return full;
|
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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,44 +112,22 @@ 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()) {
|
|
||||||
extractConstantsFromExpression((Expression) setterMi.arguments().get(0), constants);
|
|
||||||
return;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Expression> receivers = new ArrayList<>();
|
List<Expression> receivers = new ArrayList<>();
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")))) {
|
||||||
for (String annotation : parameter.getAnnotations()) {
|
|
||||||
if ("PathVariable".equals(annotation) || "RequestParam".equals(annotation)) {
|
|
||||||
return parameter.getName();
|
return parameter.getName();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
MethodInvocation current = mi;
|
if (factoryPayload != null) {
|
||||||
while (current != null) {
|
String extracted = extractEventFromMessageBuilder(factoryPayload);
|
||||||
String name = current.getName().getIdentifier();
|
|
||||||
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) {
|
if (extracted != null) {
|
||||||
return extracted;
|
return extracted;
|
||||||
}
|
}
|
||||||
return sn.getIdentifier(); // Fall back to returning the parameter name
|
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();
|
return payloadExpr.toString();
|
||||||
} else if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) {
|
}
|
||||||
|
|
||||||
|
MethodInvocation current = mi;
|
||||||
|
while (current != null) {
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,6 +72,8 @@ 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) {
|
||||||
|
context.setAnalysisCallPath(path);
|
||||||
|
try {
|
||||||
Map<String, String> bindings = new HashMap<>();
|
Map<String, String> bindings = new HashMap<>();
|
||||||
for (int i = 0; i < path.size() - 1; i++) {
|
for (int i = 0; i < path.size() - 1; i++) {
|
||||||
String caller = path.get(i);
|
String caller = path.get(i);
|
||||||
@@ -66,6 +85,9 @@ public class PathBindingEvaluator {
|
|||||||
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings);
|
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings);
|
||||||
}
|
}
|
||||||
return bindings;
|
return bindings;
|
||||||
|
} finally {
|
||||||
|
context.clearAnalysisCallPath();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<S,E> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,23 +541,32 @@ 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;
|
List<String> args = selectedEdge.getArguments();
|
||||||
|
if (args == null || args.isEmpty()) {
|
||||||
|
return paramValues;
|
||||||
|
}
|
||||||
|
|
||||||
int lastDot = target.lastIndexOf('.');
|
int lastDot = target.lastIndexOf('.');
|
||||||
if (lastDot < 0) break;
|
if (lastDot < 0) {
|
||||||
|
return paramValues;
|
||||||
|
}
|
||||||
String className = target.substring(0, lastDot);
|
String className = target.substring(0, lastDot);
|
||||||
String methodName = target.substring(lastDot + 1);
|
String methodName = target.substring(lastDot + 1);
|
||||||
|
|
||||||
TypeDeclaration td = context.getTypeDeclaration(className);
|
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||||
if (td == null) break;
|
if (td == null) {
|
||||||
|
return paramValues;
|
||||||
|
}
|
||||||
|
|
||||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||||
if (md == null) break;
|
if (md == null) {
|
||||||
|
return paramValues;
|
||||||
|
}
|
||||||
|
|
||||||
List<String> paramNames = new ArrayList<>();
|
List<String> paramNames = new ArrayList<>();
|
||||||
for (Object paramObj : md.parameters()) {
|
for (Object paramObj : md.parameters()) {
|
||||||
@@ -537,11 +578,15 @@ public class VariableTracer {
|
|||||||
for (int j = 0; j < minSize; j++) {
|
for (int j = 0; j < minSize; j++) {
|
||||||
String argValue = args.get(j);
|
String argValue = args.get(j);
|
||||||
String resolvedArgValue = resolveArgumentValue(argValue, caller, path, pathIndex, callGraph);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
paramValues.put(paramNames.get(j), resolvedArgValue);
|
paramValues.put(paramNames.get(j), resolvedArgValue);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,77 +66,57 @@ class CallChainLinkPolicyTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldAllowTrustedMachineScopedValueOfWiden() {
|
void shouldAllowEndpointNarrowSingleConcretePoly() {
|
||||||
TriggerPoint trigger = TriggerPoint.builder()
|
TriggerPoint trigger = TriggerPoint.builder()
|
||||||
.ambiguous(true)
|
.ambiguous(true)
|
||||||
.eventTypeFqn("com.example.OrderEvent")
|
.eventTypeFqn("com.example.OrderEvent")
|
||||||
.event("OrderEvent.valueOf(eventStr)")
|
.event("OrderEvent.valueOf(eventStr)")
|
||||||
.polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP"))
|
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
assertThat(CallChainLinkPolicy.isEndpointNarrowPolyEvidence(trigger)).isTrue();
|
||||||
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isTrue();
|
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isTrue();
|
||||||
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isFalse();
|
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldAllowTrustedWidenUsingMachineEventTypeWhenTriggerTypeIsNull() {
|
void shouldFailClosedWhenMultipleConcretePolyCandidatesRemain() {
|
||||||
TriggerPoint trigger = TriggerPoint.builder()
|
TriggerPoint trigger = TriggerPoint.builder()
|
||||||
.ambiguous(true)
|
.ambiguous(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()
|
|
||||||
.ambiguous(true)
|
|
||||||
.event("OrderEvent.valueOf(eventStr)")
|
|
||||||
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isFalse();
|
|
||||||
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldTrustImportStylePolymorphicWidenWhenMachineEventTypeIsKnown() {
|
|
||||||
TriggerPoint trigger = TriggerPoint.builder()
|
|
||||||
.ambiguous(true)
|
|
||||||
.event("OrderEvent.valueOf(eventStr)")
|
|
||||||
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
|
|
||||||
trigger, "com.example.order.OrderEvent")).isTrue();
|
|
||||||
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
|
|
||||||
trigger, "com.example.order.OrderEvent")).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldResolveWhenTrustedWidenHasMatchesDespiteAmbiguousCallGraphFlag() {
|
|
||||||
TriggerPoint trigger = TriggerPoint.builder()
|
TriggerPoint trigger = TriggerPoint.builder()
|
||||||
.ambiguous(true)
|
.ambiguous(true)
|
||||||
.event("OrderEvent.valueOf(eventStr)")
|
.event("OrderEvent.valueOf(eventStr)")
|
||||||
@@ -134,6 +132,57 @@ class CallChainLinkPolicyTest {
|
|||||||
trigger,
|
trigger,
|
||||||
matched,
|
matched,
|
||||||
false,
|
false,
|
||||||
"com.example.order.OrderEvent")).isEqualTo(LinkResolution.RESOLVED);
|
"com.example.order.OrderEvent")).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldTrustImportStyleSingleConcretePolyWhenMachineEventTypeIsKnown() {
|
||||||
|
TriggerPoint trigger = TriggerPoint.builder()
|
||||||
|
.ambiguous(true)
|
||||||
|
.event("OrderEvent.valueOf(eventStr)")
|
||||||
|
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
|
||||||
|
trigger, "com.example.order.OrderEvent")).isTrue();
|
||||||
|
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
|
||||||
|
trigger, "com.example.order.OrderEvent")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class TrustedEnumWidenLinkingRegressionTest {
|
|||||||
.event("OrderEvent.valueOf(eventStr)")
|
.event("OrderEvent.valueOf(eventStr)")
|
||||||
.ambiguous(true)
|
.ambiguous(true)
|
||||||
.external(false)
|
.external(false)
|
||||||
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
|
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||||
.build())
|
.build())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class TrustedEnumWidenLinkingRegressionTest {
|
|||||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||||
assertThat(linked.getMatchedTransitions()).hasSizeGreaterThanOrEqualTo(1);
|
assertThat(linked.getMatchedTransitions()).hasSizeGreaterThanOrEqualTo(1);
|
||||||
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
|
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
|
||||||
.containsExactly("com.example.order.OrderEvent.PAY", "com.example.order.OrderEvent.SHIP");
|
.containsExactly("com.example.order.OrderEvent.PAY");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -102,7 +102,7 @@ class TrustedEnumWidenLinkingRegressionTest {
|
|||||||
.event("OrderEvent.valueOf(eventStr)")
|
.event("OrderEvent.valueOf(eventStr)")
|
||||||
.ambiguous(true)
|
.ambiguous(true)
|
||||||
.external(false)
|
.external(false)
|
||||||
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
|
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||||
.build())
|
.build())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
@@ -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"), """
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
assertThat(chainMatched)
|
// Wide predicate-filtered poly without per-endpoint dataflow must not
|
||||||
.as("each dispatch chain must not cover all transitions")
|
// highlight every transition — fail closed instead.
|
||||||
.isLessThanOrEqualTo(transitionCount);
|
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)
|
||||||
|
.as("each resolved dispatch chain must not cover all transitions without evidence")
|
||||||
|
.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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<S,E>} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
@@ -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"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
@@ -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"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
@@ -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" : { }
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -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" : {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
@@ -738,16 +738,8 @@
|
|||||||
"ambiguous" : true
|
"ambiguous" : true
|
||||||
},
|
},
|
||||||
"contextMachineId" : null,
|
"contextMachineId" : null,
|
||||||
"matchedTransitions" : [ {
|
"matchedTransitions" : null,
|
||||||
"sourceState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.SUBMITTED",
|
"linkResolution" : "AMBIGUOUS_WIDEN"
|
||||||
"targetState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.PAID",
|
|
||||||
"event" : "click.kamil.examples.statemachine.polymorphic.OrderEvents.PAY"
|
|
||||||
}, {
|
|
||||||
"sourceState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.PAID",
|
|
||||||
"targetState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.CANCELED",
|
|
||||||
"event" : "click.kamil.examples.statemachine.polymorphic.OrderEvents.ABCD"
|
|
||||||
} ],
|
|
||||||
"linkResolution" : "RESOLVED"
|
|
||||||
} ],
|
} ],
|
||||||
"properties" : {
|
"properties" : {
|
||||||
"default" : {
|
"default" : {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -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" : { }
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |