Compare commits
90 Commits
e6effa3dcd
...
ai-branch-
| Author | SHA1 | Date | |
|---|---|---|---|
| 342efaef8e | |||
| 9a3bd3394f | |||
| d6754b7464 | |||
| 0558fd2bb7 | |||
| d5a6adafcc | |||
| 9a54616e5f | |||
| dbc22bf1af | |||
| 758d433726 | |||
| 1957266a98 | |||
| 33ccc4a6c5 | |||
| 6ca8e64750 | |||
| 5b0778301a | |||
| d19d4f20ef | |||
| 3e0f8f18b8 | |||
| 9455a2c8a9 | |||
| 13de69862b | |||
| bb28e72bbe | |||
| a675a773da | |||
| 01c8c58277 | |||
| dd25db0109 | |||
| 280c346761 | |||
| a9dbf17ced | |||
| 60cd6511d4 | |||
| 85af06c89d | |||
| 73f047e0aa | |||
| 24cd248a22 | |||
| b9db614cb1 | |||
| fba7a0c90c | |||
| 12db9dbef4 | |||
| 79bb4e2aba | |||
| 80b784da29 | |||
| 610c83a7ec | |||
| fd21cfa440 | |||
| 8d7e04cc33 | |||
| 2d478d5779 | |||
| 252255f8b0 | |||
| a70059331b | |||
| 8ef6e5ef8f | |||
| 5348dafb69 | |||
| a3789ceb3c | |||
| 2e7afd6d42 | |||
| e3f3350398 | |||
| 5d432e8893 | |||
| 24fe8dfe00 | |||
| 00d1f24709 | |||
| 32fe96041c | |||
| 7799f8ce5f | |||
| d11f8afd79 | |||
| dbeab64e1b | |||
| d589f65cc1 | |||
| 4a8b6ae9d9 | |||
| be1ac91beb | |||
| 8c4f15133e | |||
| 57686c575e | |||
| 5bc65be30d | |||
| 5c0d9c466d | |||
| bd9136a45c | |||
| cef9dd1f9a | |||
| 26560fee6d | |||
| 8869ebc706 | |||
| 6401a58c20 | |||
| 82f4e5c087 | |||
| 828f1260cd | |||
| fb3218e1a2 | |||
| 5972b9df50 | |||
| a94490712f | |||
| dcae599d21 | |||
| 4ca42ae3dc | |||
| f3c82d5d7f | |||
| bbfb54c51b | |||
| 44497afd32 | |||
| 69361e5a60 | |||
| c1085a2bd3 | |||
| 308dac557e | |||
| be5386b972 | |||
| 4dd64c6cdf | |||
| 73754cf6ea | |||
| 1146cdbae6 | |||
| 4f3d6c40e7 | |||
| 4a7b3ff124 | |||
| 60b5ceea8c | |||
| 423bb4e819 | |||
| c7e826c0fb | |||
| 024458d638 | |||
| c3c5d66031 | |||
| ad567ace0c | |||
| 169fae88ab | |||
| f8f64487d1 | |||
| 1bf75c8a34 | |||
| 2f853fb971 |
63
AGENTS.md
Normal file
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
|
||||
@@ -56,11 +56,17 @@ Define sequence of events in `src/main/resources/flows.json`:
|
||||
{
|
||||
"name": "Order Success",
|
||||
"description": "Happy path for order placement",
|
||||
"steps": ["PAY", "CHECK_AVAILABILITY->PENDING", "SHIP"]
|
||||
"steps": [
|
||||
"PAY",
|
||||
"CHECK_AVAILABILITY->PENDING",
|
||||
{ "source": "com.example.order.OrderState.PAID", "event": "com.example.order.OrderEvent.SHIP" }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Event-only strings remain supported but do not highlight in the HTML explorer unless paired with a source state. Use `{source, event}` objects for precise transition highlighting.
|
||||
|
||||
## JSON Structure
|
||||
- `metadata.entryPoints`: REST, WebFlux, and JMS entry points.
|
||||
- `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`).
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ExternalTriggerPolicy;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Central rules for transition linking outcomes derived from existing trigger evidence only.
|
||||
*/
|
||||
public final class 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) {
|
||||
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, null, null);
|
||||
}
|
||||
|
||||
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(
|
||||
TriggerPoint trigger,
|
||||
String machineEventTypeFqn) {
|
||||
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, null);
|
||||
}
|
||||
|
||||
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(
|
||||
TriggerPoint trigger,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null || trigger.isExternal() || !trigger.isAmbiguous()) {
|
||||
return false;
|
||||
}
|
||||
String eventTypeFqn = trigger.getEventTypeFqn();
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||
eventTypeFqn = machineEventTypeFqn;
|
||||
}
|
||||
List<String> concrete = concretePolymorphicCandidates(
|
||||
trigger.getPolymorphicEvents(), eventTypeFqn, context);
|
||||
if (concrete.size() <= 1) {
|
||||
return false;
|
||||
}
|
||||
String event = trigger.getEvent();
|
||||
if (event != null && event.startsWith("ENUM_SET:")) {
|
||||
return true;
|
||||
}
|
||||
return MachineEnumCanonicalizer.isDynamicTriggerExpression(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every concrete polymorphic candidate belongs to the trigger's declared event type.
|
||||
* Used to allow machine-scoped symbolic expansion for generic dispatchers while rejecting
|
||||
* cross-package or unqualified widens.
|
||||
*/
|
||||
static boolean isTrustedEnumPolymorphicWiden(TriggerPoint trigger) {
|
||||
return isTrustedEnumPolymorphicWiden(trigger, null, null);
|
||||
}
|
||||
|
||||
static boolean isTrustedEnumPolymorphicWiden(TriggerPoint trigger, String machineEventTypeFqn) {
|
||||
return isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, null);
|
||||
}
|
||||
|
||||
static boolean isTrustedEnumPolymorphicWiden(
|
||||
TriggerPoint trigger,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null) {
|
||||
return false;
|
||||
}
|
||||
String eventTypeFqn = trigger.getEventTypeFqn();
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||
eventTypeFqn = machineEventTypeFqn;
|
||||
}
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
List<String> poly = trigger.getPolymorphicEvents();
|
||||
List<String> concrete = concretePolymorphicCandidates(poly, eventTypeFqn, context);
|
||||
if (concrete.size() != 1) {
|
||||
return false;
|
||||
}
|
||||
return MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(
|
||||
concrete.get(0), eventTypeFqn, context);
|
||||
}
|
||||
|
||||
static List<String> concretePolymorphicCandidates(
|
||||
List<String> poly,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (poly == null || poly.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> concrete = new ArrayList<>();
|
||||
for (String pe : poly) {
|
||||
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(
|
||||
TriggerPoint trigger,
|
||||
List<MatchedTransition> matched,
|
||||
boolean ambiguousSource) {
|
||||
return resolveLinkResolution(trigger, matched, ambiguousSource, null, null);
|
||||
}
|
||||
|
||||
public static LinkResolution resolveLinkResolution(
|
||||
TriggerPoint trigger,
|
||||
List<MatchedTransition> matched,
|
||||
boolean ambiguousSource,
|
||||
String machineEventTypeFqn) {
|
||||
return resolveLinkResolution(trigger, matched, ambiguousSource, machineEventTypeFqn, null);
|
||||
}
|
||||
|
||||
public static LinkResolution resolveLinkResolution(
|
||||
TriggerPoint trigger,
|
||||
List<MatchedTransition> matched,
|
||||
boolean ambiguousSource,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (trigger != null && trigger.isExternal()) {
|
||||
return LinkResolution.UNRESOLVED_EXTERNAL;
|
||||
}
|
||||
if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) {
|
||||
return LinkResolution.AMBIGUOUS_WIDEN;
|
||||
}
|
||||
if (ambiguousSource) {
|
||||
return LinkResolution.AMBIGUOUS_WIDEN;
|
||||
}
|
||||
if (matched != null && !matched.isEmpty()) {
|
||||
return LinkResolution.RESOLVED;
|
||||
}
|
||||
return LinkResolution.NO_MATCH;
|
||||
}
|
||||
|
||||
public static TriggerPoint applyRestExternalPolicy(TriggerPoint trigger, CallChain chain, CodebaseContext context) {
|
||||
if (trigger == null || chain.getEntryPoint() == null) {
|
||||
return trigger;
|
||||
}
|
||||
EntryPoint entryPoint = chain.getEntryPoint();
|
||||
String entryMethod = entryPoint.getClassName() + "." + entryPoint.getMethodName();
|
||||
boolean external = ExternalTriggerPolicy.isExternalFromSource(
|
||||
entryPoint, trigger, entryMethod, trigger.getEvent(), context);
|
||||
if (external == trigger.isExternal()) {
|
||||
return trigger;
|
||||
}
|
||||
return trigger.toBuilder().external(external).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.path.SymbolicPathValueEstimator;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||
import org.eclipse.jdt.core.dom.QualifiedName;
|
||||
import org.eclipse.jdt.core.dom.SimpleName;
|
||||
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Narrows wide call-graph polymorphic widens to endpoint-specific enum constants.
|
||||
*
|
||||
* <p>Uses multiple independent evidence sources (REST path, command keys, constraints, static trigger
|
||||
* events, method-chain literals, camelCase method names) and only narrows when evidence converges.
|
||||
*/
|
||||
public final class CallChainPolyNarrower {
|
||||
|
||||
private static final int STRONG = 3;
|
||||
private static final int MEDIUM = 2;
|
||||
private static final int WEAK = 1;
|
||||
|
||||
private static final Pattern QUOTED_EQUALS_PARAM = Pattern.compile(
|
||||
"\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)");
|
||||
private static final Pattern PARAM_EQUALS_LITERAL = Pattern.compile(
|
||||
"(\\w+)\\.equals(?:IgnoreCase)?\\(\"([^\"]+)\"\\)");
|
||||
private static final Pattern ENUM_EQ = Pattern.compile(
|
||||
"([A-Za-z_][\\w.]*)\\s*==\\s*([A-Za-z_][\\w.]*)");
|
||||
private static final Pattern ROUTE_LITERAL = Pattern.compile(
|
||||
"route\\(\\s*\"([^\"]+)\"\\s*,\\s*\"([^\"]+)\"\\s*\\)");
|
||||
|
||||
private static final Set<String> FIRE_METHOD_NAMES = Set.of(
|
||||
"sendEvent", "fire", "fireEvent", "dispatchEvent", "send", "trigger");
|
||||
|
||||
private CallChainPolyNarrower() {
|
||||
}
|
||||
|
||||
public static TriggerPoint narrowForCallChain(
|
||||
TriggerPoint trigger,
|
||||
CallChain chain,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
return narrowForCallChain(trigger, chain, machineTypes, null, machineTransitions, context);
|
||||
}
|
||||
|
||||
public static TriggerPoint narrowForCallChain(
|
||||
TriggerPoint trigger,
|
||||
CallChain chain,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
String embeddedEventTypeFqn,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null) {
|
||||
return trigger;
|
||||
}
|
||||
List<String> poly = trigger.getPolymorphicEvents();
|
||||
if (poly == null || poly.size() <= 1) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
String eventTypeFqn = CallChainLinkPolicy.resolveMachineEventTypeFqn(
|
||||
null,
|
||||
embeddedEventTypeFqn != null ? embeddedEventTypeFqn : trigger.getEventTypeFqn(),
|
||||
machineTypes,
|
||||
machineTransitions,
|
||||
context);
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||
return trigger;
|
||||
}
|
||||
Set<String> configuredConstants = configuredEventConstants(machineTransitions, eventTypeFqn, context);
|
||||
if (configuredConstants.isEmpty()) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
HintVotes votes = new HintVotes();
|
||||
collectHints(trigger, chain, configuredConstants, context, votes);
|
||||
|
||||
String chosen = votes.choose(configuredConstants);
|
||||
if (chosen == null) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
List<String> narrowed = filterPolyToConstant(poly, chosen, eventTypeFqn, context);
|
||||
if (narrowed.size() == 1) {
|
||||
return trigger.toBuilder()
|
||||
.polymorphicEvents(narrowed)
|
||||
.ambiguous(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
return trigger.toBuilder()
|
||||
.polymorphicEvents(List.of(eventTypeFqn + "." + chosen))
|
||||
.ambiguous(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static void collectHints(
|
||||
TriggerPoint trigger,
|
||||
CallChain chain,
|
||||
Set<String> configuredConstants,
|
||||
CodebaseContext context,
|
||||
HintVotes votes) {
|
||||
collectFromStaticTriggerEvent(trigger, configuredConstants, votes);
|
||||
collectFromEntryPoint(chain != null ? chain.getEntryPoint() : null, configuredConstants, votes);
|
||||
collectFromConstraint(trigger.getConstraint(), configuredConstants, votes);
|
||||
if (chain != null) {
|
||||
collectFromMethodChain(chain.getMethodChain(), configuredConstants, votes);
|
||||
collectFromSendEventLiterals(chain.getMethodChain(), configuredConstants, context, votes);
|
||||
collectFromPathEstimator(chain, configuredConstants, context, votes);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromStaticTriggerEvent(
|
||||
TriggerPoint trigger,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes) {
|
||||
String event = trigger.getEvent();
|
||||
if (event == null || event.isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (MachineEnumCanonicalizer.isDynamicTriggerExpression(event)) {
|
||||
return;
|
||||
}
|
||||
addEnumReference(event, configuredConstants, votes, STRONG);
|
||||
}
|
||||
|
||||
private static void collectFromEntryPoint(
|
||||
EntryPoint entryPoint,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes) {
|
||||
if (entryPoint == null) {
|
||||
return;
|
||||
}
|
||||
if (entryPoint.getName() != null) {
|
||||
collectFromPath(entryPoint.getName(), configuredConstants, votes);
|
||||
}
|
||||
if (entryPoint.getMetadata() != null) {
|
||||
Object path = entryPoint.getMetadata().get("path");
|
||||
if (path instanceof String pathValue) {
|
||||
collectFromPath(pathValue, configuredConstants, votes);
|
||||
}
|
||||
}
|
||||
if (entryPoint.getMethodName() != null) {
|
||||
addUniqueIdentifierMatch(entryPoint.getMethodName(), configuredConstants, votes, WEAK);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromPath(String rawPath, Set<String> configuredConstants, HintVotes votes) {
|
||||
if (rawPath == null || rawPath.isBlank()) {
|
||||
return;
|
||||
}
|
||||
int space = rawPath.indexOf(' ');
|
||||
String path = space >= 0 ? rawPath.substring(space + 1).trim() : rawPath.trim();
|
||||
if (path.isBlank()) {
|
||||
return;
|
||||
}
|
||||
for (String segment : path.split("/")) {
|
||||
if (segment.isBlank() || segment.contains("{")) {
|
||||
continue;
|
||||
}
|
||||
addPathToken(segment, configuredConstants, votes, STRONG);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromConstraint(
|
||||
String constraint,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes) {
|
||||
if (constraint == null || constraint.isBlank()) {
|
||||
return;
|
||||
}
|
||||
Matcher quoted = QUOTED_EQUALS_PARAM.matcher(constraint);
|
||||
while (quoted.find()) {
|
||||
String literal = quoted.group(1);
|
||||
String param = quoted.group(2);
|
||||
if ("event".equals(param) || "eventString".equals(param) || "actionKey".equals(param)
|
||||
|| "action".equals(param)) {
|
||||
addPathToken(literal, configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
if ("commandKey".equals(param) || literal.contains(".")) {
|
||||
addPathToken(literal, configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
}
|
||||
Matcher route = ROUTE_LITERAL.matcher(constraint);
|
||||
while (route.find()) {
|
||||
addPathToken(route.group(2), configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
Matcher enumEq = ENUM_EQ.matcher(constraint);
|
||||
while (enumEq.find()) {
|
||||
addEnumReference(enumEq.group(1), configuredConstants, votes, MEDIUM);
|
||||
addEnumReference(enumEq.group(2), configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
Matcher reverseEquals = PARAM_EQUALS_LITERAL.matcher(constraint);
|
||||
while (reverseEquals.find()) {
|
||||
String param = reverseEquals.group(1);
|
||||
String literal = reverseEquals.group(2);
|
||||
if ("event".equals(param) || "eventString".equals(param) || "actionKey".equals(param)
|
||||
|| "action".equals(param) || "commandKey".equals(param)) {
|
||||
addPathToken(literal, configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromMethodChain(
|
||||
List<String> methodChain,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes) {
|
||||
if (methodChain == null) {
|
||||
return;
|
||||
}
|
||||
for (String methodFqn : methodChain) {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
continue;
|
||||
}
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
addUniqueIdentifierMatch(methodName, configuredConstants, votes, WEAK);
|
||||
for (String token : splitCamelCase(methodName)) {
|
||||
addPathToken(token, configuredConstants, votes, WEAK);
|
||||
}
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
int nestedDot = className.lastIndexOf('.');
|
||||
if (nestedDot >= 0) {
|
||||
addUniqueIdentifierMatch(className.substring(nestedDot + 1), configuredConstants, votes, WEAK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromSendEventLiterals(
|
||||
List<String> methodChain,
|
||||
Set<String> configuredConstants,
|
||||
CodebaseContext context,
|
||||
HintVotes votes) {
|
||||
if (context == null || methodChain == null) {
|
||||
return;
|
||||
}
|
||||
ConstantResolver resolver = new ConstantResolver();
|
||||
for (String methodFqn : methodChain) {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
continue;
|
||||
}
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
|
||||
if (typeDeclaration == null) {
|
||||
continue;
|
||||
}
|
||||
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
|
||||
if (methodDeclaration == null) {
|
||||
continue;
|
||||
}
|
||||
for (Object parameter : methodDeclaration.parameters()) {
|
||||
if (parameter instanceof SingleVariableDeclaration variable) {
|
||||
addUniqueIdentifierMatch(variable.getType().toString(), configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
}
|
||||
if (methodDeclaration.getBody() == null) {
|
||||
continue;
|
||||
}
|
||||
methodDeclaration.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if (!FIRE_METHOD_NAMES.contains(node.getName().getIdentifier())) {
|
||||
return super.visit(node);
|
||||
}
|
||||
for (Object argument : node.arguments()) {
|
||||
if (!(argument instanceof Expression expression)) {
|
||||
continue;
|
||||
}
|
||||
if (expression instanceof QualifiedName qualifiedName) {
|
||||
addEnumReference(qualifiedName.getFullyQualifiedName(), configuredConstants, votes, STRONG);
|
||||
} else if (expression instanceof SimpleName simpleName) {
|
||||
addEnumReference(simpleName.getIdentifier(), configuredConstants, votes, STRONG);
|
||||
} else {
|
||||
String resolved = resolver.resolve(expression, context);
|
||||
if (resolved != null) {
|
||||
addEnumReference(resolved, configuredConstants, votes, STRONG);
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromPathEstimator(
|
||||
CallChain chain,
|
||||
Set<String> configuredConstants,
|
||||
CodebaseContext context,
|
||||
HintVotes votes) {
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
List<String> estimated = new SymbolicPathValueEstimator().estimatePossibleValues(chain, context);
|
||||
if (estimated == null || estimated.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int weight = estimated.size() == 1 ? MEDIUM : WEAK;
|
||||
for (String value : estimated) {
|
||||
addEnumReference(value, configuredConstants, votes, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addPathToken(
|
||||
String token,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes,
|
||||
int weight) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return;
|
||||
}
|
||||
addPathTokenVariants(token, configuredConstants, votes, weight);
|
||||
if (token.contains(".")) {
|
||||
addPathTokenVariants(token.substring(token.lastIndexOf('.') + 1), configuredConstants, votes, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addPathTokenVariants(
|
||||
String token,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes,
|
||||
int weight) {
|
||||
maybeVoteConstant(votes, normalizeToken(token), configuredConstants, weight);
|
||||
for (String part : token.split("[-_.]")) {
|
||||
if (!part.isBlank()) {
|
||||
maybeVoteConstant(votes, normalizeToken(part), configuredConstants, weight);
|
||||
}
|
||||
}
|
||||
if (token.toUpperCase(Locale.ROOT).contains("_")) {
|
||||
String tail = token.substring(token.lastIndexOf('_') + 1);
|
||||
maybeVoteConstant(votes, normalizeToken(tail), configuredConstants, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addEnumReference(
|
||||
String reference,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes,
|
||||
int weight) {
|
||||
if (reference == null || reference.isBlank()) {
|
||||
return;
|
||||
}
|
||||
maybeVoteConstant(votes, constantName(reference), configuredConstants, weight);
|
||||
if (reference.contains(".")) {
|
||||
maybeVoteConstant(votes, constantName(reference), configuredConstants, weight);
|
||||
}
|
||||
String upper = reference.toUpperCase(Locale.ROOT);
|
||||
if (upper.contains("_")) {
|
||||
maybeVoteConstant(votes, upper.substring(upper.lastIndexOf('_') + 1), configuredConstants, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addUniqueIdentifierMatch(
|
||||
String identifier,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes,
|
||||
int weight) {
|
||||
if (identifier == null || identifier.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String upper = identifier.toUpperCase(Locale.ROOT);
|
||||
List<String> matched = new ArrayList<>();
|
||||
for (String constant : configuredConstants) {
|
||||
if (upper.contains(constant)) {
|
||||
matched.add(constant);
|
||||
}
|
||||
}
|
||||
if (matched.size() == 1) {
|
||||
votes.add(matched.get(0), weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void maybeVoteConstant(
|
||||
HintVotes votes,
|
||||
String candidate,
|
||||
Set<String> configuredConstants,
|
||||
int weight) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String normalized = candidate.toUpperCase(Locale.ROOT);
|
||||
if (configuredConstants.contains(normalized)) {
|
||||
votes.add(normalized, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> filterPolyToConstant(
|
||||
List<String> poly,
|
||||
String constant,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
List<String> narrowed = new ArrayList<>();
|
||||
for (String candidate : poly) {
|
||||
if (candidate == null || candidate.startsWith("<SYMBOLIC:") || candidate.startsWith("ENUM_SET:")) {
|
||||
continue;
|
||||
}
|
||||
if (!constant.equalsIgnoreCase(constantName(candidate))) {
|
||||
continue;
|
||||
}
|
||||
String canonical = candidate.contains(".")
|
||||
? MachineEnumCanonicalizer.canonicalizeLabel(candidate, eventTypeFqn, context)
|
||||
: eventTypeFqn + "." + constant;
|
||||
if (!narrowed.contains(canonical)) {
|
||||
narrowed.add(canonical);
|
||||
}
|
||||
}
|
||||
return narrowed;
|
||||
}
|
||||
|
||||
private static String normalizeToken(String token) {
|
||||
return token.replace('-', '_').toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static List<String> splitCamelCase(String identifier) {
|
||||
if (identifier == null || identifier.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> tokens = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
for (int i = 0; i < identifier.length(); i++) {
|
||||
char ch = identifier.charAt(i);
|
||||
if (Character.isUpperCase(ch) && !current.isEmpty()) {
|
||||
tokens.add(current.toString());
|
||||
current.setLength(0);
|
||||
}
|
||||
current.append(ch);
|
||||
}
|
||||
if (!current.isEmpty()) {
|
||||
tokens.add(current.toString());
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static Set<String> configuredEventConstants(
|
||||
List<Transition> transitions,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
Set<String> constants = new LinkedHashSet<>();
|
||||
for (String event : MachineEnumCanonicalizer.polymorphicEventsFromTransitions(
|
||||
transitions, eventTypeFqn, context)) {
|
||||
constants.add(constantName(event).toUpperCase(Locale.ROOT));
|
||||
}
|
||||
return constants;
|
||||
}
|
||||
|
||||
private static String constantName(String ref) {
|
||||
int dot = ref.lastIndexOf('.');
|
||||
return (dot >= 0 ? ref.substring(dot + 1) : ref).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static final class HintVotes {
|
||||
private final Map<String, Integer> scores = new HashMap<>();
|
||||
private final Map<String, Integer> maxWeight = new HashMap<>();
|
||||
|
||||
void add(String constant, int weight) {
|
||||
if (constant == null || constant.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String key = constant.toUpperCase(Locale.ROOT);
|
||||
scores.merge(key, weight, Integer::sum);
|
||||
maxWeight.merge(key, weight, Math::max);
|
||||
}
|
||||
|
||||
String choose(Set<String> configured) {
|
||||
String strongUnique = uniqueAboveWeight(configured, STRONG);
|
||||
if (strongUnique != null) {
|
||||
return strongUnique;
|
||||
}
|
||||
String mediumUnique = uniqueAboveWeight(configured, MEDIUM);
|
||||
if (mediumUnique != null) {
|
||||
return mediumUnique;
|
||||
}
|
||||
String best = null;
|
||||
int bestScore = 0;
|
||||
for (String constant : configured) {
|
||||
int score = scores.getOrDefault(constant, 0);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = constant;
|
||||
}
|
||||
}
|
||||
if (best == null || bestScore < MEDIUM) {
|
||||
return null;
|
||||
}
|
||||
final int winningScore = bestScore;
|
||||
List<String> tied = new ArrayList<>();
|
||||
for (String constant : configured) {
|
||||
if (scores.getOrDefault(constant, 0) == winningScore) {
|
||||
tied.add(constant);
|
||||
}
|
||||
}
|
||||
if (tied.size() == 1) {
|
||||
return best;
|
||||
}
|
||||
String maxWeightWinner = null;
|
||||
int bestMaxWeight = 0;
|
||||
for (String constant : tied) {
|
||||
int weight = maxWeight.getOrDefault(constant, 0);
|
||||
if (weight > bestMaxWeight) {
|
||||
bestMaxWeight = weight;
|
||||
maxWeightWinner = constant;
|
||||
} else if (weight == bestMaxWeight && weight > 0) {
|
||||
maxWeightWinner = null;
|
||||
}
|
||||
}
|
||||
return maxWeightWinner;
|
||||
}
|
||||
|
||||
private String uniqueAboveWeight(Set<String> configured, int minWeight) {
|
||||
String chosen = null;
|
||||
for (String constant : configured) {
|
||||
if (maxWeight.getOrDefault(constant, 0) >= minWeight) {
|
||||
if (chosen != null) {
|
||||
return null;
|
||||
}
|
||||
chosen = constant;
|
||||
}
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,9 @@ public class EntryPointEnricher implements AnalysisEnricher {
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
log.info("Enriching {} with entry points", result.getName());
|
||||
|
||||
// Keep all entry points; machine scoping is applied after call-chain resolution
|
||||
// when source evidence (trigger type FQNs, literals, constraints) is available.
|
||||
List<EntryPoint> entryPoints = intelligence.findEntryPoints();
|
||||
entryPoints = MachineScopeFilter.filterEntryPointsForMachine(entryPoints, result.getName(), context);
|
||||
|
||||
result.addMetadata(CodebaseMetadata.builder()
|
||||
.entryPoints(entryPoints)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.GenericEventDetector;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.JdtCallGraphEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Scopes REST entry points to a machine using call-graph reachability and trigger type evidence,
|
||||
* not URL path segment guessing.
|
||||
*/
|
||||
public final class EntryPointScopeResolver {
|
||||
|
||||
private static final BeanResolutionEngine ROUTING = new HeuristicBeanResolutionEngine();
|
||||
private static final String TRIGGERS_CACHE = "entryPointScope.triggers";
|
||||
|
||||
public enum Affinity {
|
||||
FOR_MACHINE,
|
||||
AGAINST_MACHINE,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
private EntryPointScopeResolver() {
|
||||
}
|
||||
|
||||
public static List<EntryPoint> scopeFromCallChains(List<CallChain> chains, List<EntryPoint> allEntryPoints) {
|
||||
Map<String, EntryPoint> scoped = new LinkedHashMap<>();
|
||||
if (chains != null) {
|
||||
for (CallChain chain : chains) {
|
||||
EntryPoint entryPoint = chain.getEntryPoint();
|
||||
if (entryPoint != null) {
|
||||
scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allEntryPoints != null) {
|
||||
for (EntryPoint entryPoint : allEntryPoints) {
|
||||
if (hasPathVariablePlaceholder(entryPoint)) {
|
||||
scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(scoped.values());
|
||||
}
|
||||
|
||||
public static Affinity resolveAffinity(EntryPoint entryPoint, String machineName, CodebaseContext context) {
|
||||
if (entryPoint == null || machineName == null) {
|
||||
return Affinity.UNKNOWN;
|
||||
}
|
||||
if (entryPoint.getClassName() == null || entryPoint.getMethodName() == null) {
|
||||
return Affinity.UNKNOWN;
|
||||
}
|
||||
if (context == null || context.getCompilationUnits().isEmpty()) {
|
||||
return Affinity.UNKNOWN;
|
||||
}
|
||||
|
||||
CallGraphEngine engine = createCallGraphEngine(context);
|
||||
List<CallChain> chains = engine.findChains(List.of(entryPoint), getOrCollectTriggers(context));
|
||||
if (chains.isEmpty()) {
|
||||
return Affinity.UNKNOWN;
|
||||
}
|
||||
|
||||
boolean matched = false;
|
||||
boolean mismatched = false;
|
||||
for (CallChain chain : chains) {
|
||||
TriggerPoint trigger = chain.getTriggerPoint();
|
||||
if (trigger == null || !hasTypeEvidence(trigger)) {
|
||||
continue;
|
||||
}
|
||||
if (ROUTING.isRoutedToCorrectMachine(chain, machineName, context)) {
|
||||
matched = true;
|
||||
} else {
|
||||
mismatched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
return Affinity.FOR_MACHINE;
|
||||
}
|
||||
if (mismatched) {
|
||||
return Affinity.AGAINST_MACHINE;
|
||||
}
|
||||
return Affinity.UNKNOWN;
|
||||
}
|
||||
|
||||
public static boolean hasPathVariablePlaceholder(EntryPoint entryPoint) {
|
||||
if (entryPoint == null) {
|
||||
return false;
|
||||
}
|
||||
String name = entryPoint.getName();
|
||||
if (name != null && name.contains("{")) {
|
||||
return true;
|
||||
}
|
||||
Map<String, String> metadata = entryPoint.getMetadata();
|
||||
if (metadata != null) {
|
||||
String path = metadata.get("path");
|
||||
return path != null && path.contains("{");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasTypeEvidence(TriggerPoint trigger) {
|
||||
if (trigger.getEventTypeFqn() != null || trigger.getStateTypeFqn() != null) {
|
||||
return true;
|
||||
}
|
||||
return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
|
||||
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<TriggerPoint> getOrCollectTriggers(CodebaseContext context) {
|
||||
Object cached = context.getCache().get(TRIGGERS_CACHE);
|
||||
if (cached instanceof List<?> list) {
|
||||
return (List<TriggerPoint>) list;
|
||||
}
|
||||
List<TriggerPoint> triggers = new ArrayList<>();
|
||||
GenericEventDetector detector =
|
||||
new GenericEventDetector(context, context.getConstantResolver(), context.getLibraryHints());
|
||||
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||
triggers.addAll(detector.detect(cu));
|
||||
}
|
||||
context.getCache().put(TRIGGERS_CACHE, triggers);
|
||||
return triggers;
|
||||
}
|
||||
|
||||
private static CallGraphEngine createCallGraphEngine(CodebaseContext context) {
|
||||
SpringBeanRegistry registry = new SpringBeanRegistry();
|
||||
SpringContextScanner scanner = new SpringContextScanner(registry);
|
||||
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||
cu.accept(scanner);
|
||||
}
|
||||
SpringDependencyResolver dependencyResolver = new SpringDependencyResolver(registry);
|
||||
InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(dependencyResolver);
|
||||
return new JdtCallGraphEngine(context, injectionAnalyzer);
|
||||
}
|
||||
|
||||
private static String entryPointKey(EntryPoint entryPoint) {
|
||||
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
|
||||
return entryPoint.getName();
|
||||
}
|
||||
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import click.kamil.springstatemachineexporter.analysis.enricher.routing.Heuristi
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
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.Locale;
|
||||
|
||||
/**
|
||||
* Filters codebase-wide analysis artifacts to those relevant for a single state machine config.
|
||||
@@ -23,12 +25,20 @@ public final class MachineScopeFilter {
|
||||
|
||||
public static List<TriggerPoint> filterTriggersForMachine(
|
||||
List<TriggerPoint> triggers, String machineName, CodebaseContext context) {
|
||||
return filterTriggersForMachine(triggers, machineName, context, null);
|
||||
}
|
||||
|
||||
public static List<TriggerPoint> filterTriggersForMachine(
|
||||
List<TriggerPoint> triggers,
|
||||
String machineName,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
if (triggers == null || machineName == null) {
|
||||
return triggers == null ? List.of() : triggers;
|
||||
}
|
||||
List<TriggerPoint> filtered = new ArrayList<>();
|
||||
for (TriggerPoint trigger : triggers) {
|
||||
if (isTriggerForMachine(trigger, machineName, context)) {
|
||||
if (isTriggerForMachine(trigger, machineName, context, machineTransitions)) {
|
||||
filtered.add(trigger);
|
||||
}
|
||||
}
|
||||
@@ -37,27 +47,68 @@ public final class MachineScopeFilter {
|
||||
|
||||
public static List<CallChain> filterCallChainsForMachine(
|
||||
List<CallChain> chains, String machineName, CodebaseContext context) {
|
||||
return filterCallChainsForMachine(chains, machineName, context, null);
|
||||
}
|
||||
|
||||
public static List<CallChain> filterCallChainsForMachine(
|
||||
List<CallChain> chains,
|
||||
String machineName,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
if (chains == null || machineName == null) {
|
||||
return chains == null ? List.of() : chains;
|
||||
}
|
||||
List<CallChain> filtered = new ArrayList<>();
|
||||
String machineEventTypeFqn = resolveMachineEventTypeFqn(machineName, context, machineTransitions);
|
||||
for (CallChain chain : chains) {
|
||||
if (ROUTING.isRoutedToCorrectMachine(chain, machineName, context)) {
|
||||
if (ROUTING.hasProvenMachineAffinity(chain, machineName, context, machineTransitions)) {
|
||||
filtered.add(chain);
|
||||
continue;
|
||||
}
|
||||
TriggerPoint trigger = chain.getTriggerPoint();
|
||||
if (trigger != null
|
||||
&& machineEventTypeFqn != null
|
||||
&& CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context)) {
|
||||
filtered.add(chain);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private static String resolveMachineEventTypeFqn(
|
||||
String machineName,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
if (context != null && machineName != null) {
|
||||
String[] types = StateMachineTypeResolver.resolve(machineName, context);
|
||||
if (types != null && types.length > 1 && types[1] != null && !types[1].isBlank()) {
|
||||
return types[1];
|
||||
}
|
||||
}
|
||||
if (machineTransitions == null) {
|
||||
return null;
|
||||
}
|
||||
for (Transition transition : machineTransitions) {
|
||||
Event event = transition.getEvent();
|
||||
if (event == null) {
|
||||
continue;
|
||||
}
|
||||
String fullIdentifier = event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
|
||||
if (fullIdentifier != null && fullIdentifier.contains(".")) {
|
||||
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<EntryPoint> filterEntryPointsForMachine(
|
||||
List<EntryPoint> entryPoints, String machineName, CodebaseContext context) {
|
||||
if (entryPoints == null || machineName == null) {
|
||||
return entryPoints == null ? List.of() : entryPoints;
|
||||
}
|
||||
String machineDomain = MachineDomainKeys.extractMachineDomainKey(machineName);
|
||||
List<EntryPoint> filtered = new ArrayList<>();
|
||||
for (EntryPoint entryPoint : entryPoints) {
|
||||
if (isEntryPointForMachine(entryPoint, machineDomain, machineName, context)) {
|
||||
if (isEntryPointForMachine(entryPoint, machineName, context)) {
|
||||
filtered.add(entryPoint);
|
||||
}
|
||||
}
|
||||
@@ -65,61 +116,33 @@ public final class MachineScopeFilter {
|
||||
}
|
||||
|
||||
private static boolean isEntryPointForMachine(
|
||||
EntryPoint entryPoint, String machineDomain, String machineName, CodebaseContext context) {
|
||||
EntryPoint entryPoint, String machineName, CodebaseContext context) {
|
||||
if (entryPoint == null) {
|
||||
return false;
|
||||
}
|
||||
String path = entryPoint.getMetadata() != null ? entryPoint.getMetadata().get("path") : null;
|
||||
if (path != null) {
|
||||
String pathDomain = inferDomainFromPath(path);
|
||||
if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain)
|
||||
&& domainsMatch(pathDomain, machineDomain)) {
|
||||
if (EntryPointScopeResolver.hasPathVariablePlaceholder(entryPoint)) {
|
||||
EntryPointScopeResolver.Affinity affinity =
|
||||
EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
|
||||
return affinity != EntryPointScopeResolver.Affinity.AGAINST_MACHINE;
|
||||
}
|
||||
|
||||
EntryPointScopeResolver.Affinity affinity =
|
||||
EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
|
||||
if (affinity == EntryPointScopeResolver.Affinity.FOR_MACHINE) {
|
||||
return true;
|
||||
}
|
||||
if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain)
|
||||
&& !domainsMatch(pathDomain, machineDomain)) {
|
||||
if (affinity == EntryPointScopeResolver.Affinity.AGAINST_MACHINE) {
|
||||
return false;
|
||||
}
|
||||
if (pathDomain == null && path.contains("{")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
CallChain probe = CallChain.builder()
|
||||
.entryPoint(entryPoint)
|
||||
.methodChain(List.of(entryPoint.getClassName() + "." + entryPoint.getMethodName()))
|
||||
.build();
|
||||
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context);
|
||||
}
|
||||
|
||||
private static boolean isKnownDomainScopedMachine(String machineDomain) {
|
||||
return "ORDER".equals(machineDomain) || "DOCUMENT".equals(machineDomain) || "USER".equals(machineDomain);
|
||||
}
|
||||
|
||||
private static String inferDomainFromPath(String path) {
|
||||
String lower = path.toLowerCase(Locale.ROOT);
|
||||
if (lower.contains("/orders") || lower.contains("order.")) {
|
||||
return "ORDER";
|
||||
}
|
||||
if (lower.contains("/documents") || lower.contains("document.")) {
|
||||
return "DOCUMENT";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean domainsMatch(String pathDomain, String machineDomain) {
|
||||
if (pathDomain == null || machineDomain == null) {
|
||||
return false;
|
||||
}
|
||||
return pathDomain.equalsIgnoreCase(machineDomain)
|
||||
|| machineDomain.startsWith(pathDomain)
|
||||
|| pathDomain.startsWith(machineDomain);
|
||||
}
|
||||
|
||||
private static boolean isTriggerForMachine(TriggerPoint trigger, String machineName, CodebaseContext context) {
|
||||
private static boolean isTriggerForMachine(
|
||||
TriggerPoint trigger, String machineName, CodebaseContext context, List<Transition> machineTransitions) {
|
||||
if (trigger == null) {
|
||||
return false;
|
||||
}
|
||||
CallChain probe = CallChain.builder().triggerPoint(trigger).methodChain(List.of()).build();
|
||||
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context);
|
||||
return ROUTING.hasProvenMachineAffinity(probe, machineName, context, machineTransitions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
@@ -14,7 +15,11 @@ import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
|
||||
@@ -27,7 +32,6 @@ import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineType
|
||||
public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
|
||||
private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
|
||||
private final EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine();
|
||||
|
||||
@Override
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
@@ -35,6 +39,8 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
return;
|
||||
}
|
||||
|
||||
EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine(context);
|
||||
|
||||
List<CallChain> updatedChains = new ArrayList<>();
|
||||
List<Transition> stateMachineTransitions = result.getTransitions();
|
||||
StateMachineTypeResolver.MachineTypes machineTypes =
|
||||
@@ -43,6 +49,15 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
result.getStateTypeFqn(),
|
||||
result.getEventTypeFqn(),
|
||||
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()) {
|
||||
TriggerPoint tp = chain.getTriggerPoint();
|
||||
@@ -51,25 +66,104 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
continue;
|
||||
}
|
||||
|
||||
tp = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints(
|
||||
tp, effectiveMachineTypes, context, chain.getEntryPoint());
|
||||
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
tp, machineTypes, context, stateMachineTransitions, true);
|
||||
tp = markRestEndpointExternal(tp, chain);
|
||||
tp, effectiveMachineTypes, context, stateMachineTransitions, true);
|
||||
tp = CallChainPolyNarrower.narrowForCallChain(
|
||||
tp, chain, effectiveMachineTypes, result.getEventTypeFqn(), stateMachineTransitions, context);
|
||||
tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, effectiveMachineTypes, context);
|
||||
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
|
||||
tp = tp.toBuilder()
|
||||
.eventTypeFqn(tp.getEventTypeFqn() != null
|
||||
? tp.getEventTypeFqn()
|
||||
: effectiveEventTypeFqn)
|
||||
.stateTypeFqn(tp.getStateTypeFqn() != null
|
||||
? tp.getStateTypeFqn()
|
||||
: effectiveMachineTypes.stateTypeFqn())
|
||||
.build();
|
||||
|
||||
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
|
||||
updatedChains.add(chain);
|
||||
continue;
|
||||
}
|
||||
|
||||
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null;
|
||||
if (tp.isExternal() && isUnresolvedGenericRestEndpoint(chain.getEntryPoint())) {
|
||||
updatedChains.add(chain.toBuilder()
|
||||
.triggerPoint(tp)
|
||||
.matchedTransitions(null)
|
||||
.linkResolution(LinkResolution.UNRESOLVED_EXTERNAL)
|
||||
.build());
|
||||
continue;
|
||||
}
|
||||
|
||||
String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null;
|
||||
if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
|
||||
tp,
|
||||
effectiveEventTypeFqn,
|
||||
context)) {
|
||||
updatedChains.add(chain.toBuilder()
|
||||
.triggerPoint(tp)
|
||||
.matchedTransitions(null)
|
||||
.linkResolution(LinkResolution.AMBIGUOUS_WIDEN)
|
||||
.build());
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean ambiguousSource = false;
|
||||
|
||||
if (triggerSource == null) {
|
||||
Map<String, Set<String>> sourcesByEvent = new LinkedHashMap<>();
|
||||
Map<String, Set<String>> stateTypesByEvent = new LinkedHashMap<>();
|
||||
Map<String, String> canonicalSourceBySimplified = new LinkedHashMap<>();
|
||||
for (Transition t : stateMachineTransitions) {
|
||||
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)
|
||||
&& isRoutedToCorrectMachine(
|
||||
chain, result.getName(), context, stateMachineTransitions, tp,
|
||||
effectiveEventTypeFqn)
|
||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||
String smEventForLink = canonicalEvent(t.getEvent());
|
||||
for (State smSourceState : t.getSourceStates()) {
|
||||
String smSourceForLink = canonicalState(smSourceState);
|
||||
String smSource = simplifySourceState(smSourceForLink);
|
||||
sourcesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
|
||||
.add(smSource);
|
||||
stateTypesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
|
||||
.add(extractStateEnumType(smSourceForLink));
|
||||
canonicalSourceBySimplified.putIfAbsent(smSource, smSourceForLink);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean crossStateTypeConflict = stateTypesByEvent.values().stream()
|
||||
.anyMatch(types -> types.size() > 1);
|
||||
if (crossStateTypeConflict) {
|
||||
ambiguousSource = true;
|
||||
} else if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) {
|
||||
ambiguousSource = !CallChainLinkPolicy.isEndpointNarrowPolyEvidence(
|
||||
tp,
|
||||
effectiveEventTypeFqn,
|
||||
context);
|
||||
} else {
|
||||
Set<String> allDistinctSources = new LinkedHashSet<>();
|
||||
sourcesByEvent.values().forEach(allDistinctSources::addAll);
|
||||
if (allDistinctSources.size() == 1) {
|
||||
String inferredSimplifiedSource = allDistinctSources.iterator().next();
|
||||
String inferredCanonicalSource = canonicalSourceBySimplified.get(inferredSimplifiedSource);
|
||||
tp = tp.toBuilder().sourceState(inferredCanonicalSource).build();
|
||||
triggerSource = inferredSimplifiedSource;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<MatchedTransition> matched = new ArrayList<>();
|
||||
|
||||
if (!ambiguousSource) {
|
||||
for (Transition t : stateMachineTransitions) {
|
||||
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
|
||||
String smEventForLink = canonicalEvent(t.getEvent());
|
||||
for (State smSourceState : t.getSourceStates()) {
|
||||
String smSourceForLink = canonicalState(smSourceState);
|
||||
String smSource = simplify(smSourceForLink);
|
||||
String smSource = simplifySourceState(smSourceForLink);
|
||||
if (triggerSource == null || triggerSource.equals(smSource)) {
|
||||
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
|
||||
MatchedTransition mt = MatchedTransition.builder()
|
||||
@@ -77,7 +171,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
.targetState(smSourceForLink)
|
||||
.event(smEventForLink)
|
||||
.build();
|
||||
if (isRoutedToCorrectMachine(chain, result.getName(), context)
|
||||
if (isRoutedToCorrectMachine(
|
||||
chain, result.getName(), context, stateMachineTransitions, tp,
|
||||
effectiveEventTypeFqn)
|
||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||
matched.add(mt);
|
||||
}
|
||||
@@ -89,7 +185,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
.targetState(targetForLink)
|
||||
.event(smEventForLink)
|
||||
.build();
|
||||
if (isRoutedToCorrectMachine(chain, result.getName(), context)
|
||||
if (isRoutedToCorrectMachine(
|
||||
chain, result.getName(), context, stateMachineTransitions, tp,
|
||||
effectiveEventTypeFqn)
|
||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||
matched.add(mt);
|
||||
}
|
||||
@@ -99,30 +197,46 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ambiguousSource) {
|
||||
tp = tp.toBuilder().ambiguous(true).build();
|
||||
}
|
||||
|
||||
LinkResolution linkResolution = CallChainLinkPolicy.resolveLinkResolution(
|
||||
tp,
|
||||
matched,
|
||||
ambiguousSource,
|
||||
effectiveEventTypeFqn,
|
||||
context);
|
||||
|
||||
if (!matched.isEmpty()) {
|
||||
CallChain newChain = chain.toBuilder()
|
||||
.triggerPoint(tp)
|
||||
.matchedTransitions(matched)
|
||||
.linkResolution(linkResolution)
|
||||
.build();
|
||||
updatedChains.add(newChain);
|
||||
} else {
|
||||
updatedChains.add(chain.toBuilder()
|
||||
.triggerPoint(tp)
|
||||
.matchedTransitions(null)
|
||||
.linkResolution(linkResolution)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
|
||||
result.getMetadata().getTriggers(), result.getName(), context);
|
||||
List<EntryPoint> scopedEntryPoints = MachineScopeFilter.filterEntryPointsForMachine(
|
||||
result.getMetadata().getEntryPoints(), result.getName(), context);
|
||||
result.getMetadata().getTriggers(), result.getName(), context, stateMachineTransitions);
|
||||
List<CallChain> scopedChains = MachineScopeFilter.filterCallChainsForMachine(
|
||||
updatedChains, result.getName(), context, stateMachineTransitions);
|
||||
List<EntryPoint> scopedEntryPoints = EntryPointScopeResolver.scopeFromCallChains(
|
||||
scopedChains, result.getMetadata().getEntryPoints());
|
||||
|
||||
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
|
||||
.triggers(scopedTriggers)
|
||||
.entryPoints(scopedEntryPoints)
|
||||
.callChains(updatedChains)
|
||||
.callChains(scopedChains)
|
||||
.properties(result.getMetadata().getProperties())
|
||||
.build();
|
||||
|
||||
@@ -130,35 +244,77 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
}
|
||||
|
||||
|
||||
private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
|
||||
return routingEngine.isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||
private boolean isRoutedToCorrectMachine(
|
||||
CallChain chain,
|
||||
String currentMachineName,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions,
|
||||
TriggerPoint triggerPoint,
|
||||
String machineEventTypeFqn) {
|
||||
if (triggerPoint != null
|
||||
&& machineEventTypeFqn != null
|
||||
&& CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(triggerPoint, machineEventTypeFqn, context)) {
|
||||
return true;
|
||||
}
|
||||
return routingEngine.hasProvenMachineAffinity(chain, currentMachineName, context, machineTransitions);
|
||||
}
|
||||
|
||||
private boolean isRoutedToCorrectMachine(
|
||||
CallChain chain, String currentMachineName, CodebaseContext context, List<Transition> machineTransitions) {
|
||||
return isRoutedToCorrectMachine(
|
||||
chain, currentMachineName, context, machineTransitions, chain.getTriggerPoint(), null);
|
||||
}
|
||||
|
||||
private boolean isConstraintCompatible(String constraint, String machineName) {
|
||||
if (constraint == null || machineName == null) {
|
||||
return true;
|
||||
}
|
||||
String machineConstraint = stripEventBindingClauses(constraint);
|
||||
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
constraint, MachineDomainKeys.extractMachineDomainKey(machineName));
|
||||
machineConstraint, MachineDomainKeys.extractMachineDomainKey(machineName));
|
||||
}
|
||||
|
||||
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>();
|
||||
private static final java.util.regex.Pattern SUFFIX_PATTERN = java.util.regex.Pattern.compile("(?i)(.+)(Event|Action|Transition|Command)(s)?$");
|
||||
private static final java.util.regex.Pattern FQN_PATTERN = java.util.regex.Pattern.compile("^.*\\.([A-Z0-9_]+)$");
|
||||
private static String stripEventBindingClauses(String constraint) {
|
||||
if (constraint == null || constraint.isBlank()) {
|
||||
return constraint;
|
||||
}
|
||||
String stripped = constraint.replaceAll("\"[^\"]+\"\\.equalsIgnoreCase\\(event\\)", "true");
|
||||
stripped = stripped.replaceAll("&&\\s*&&+", "&&");
|
||||
stripped = stripped.replaceAll("^\\s*&&\\s*", "");
|
||||
stripped = stripped.replaceAll("\\s*&&\\s*$", "");
|
||||
return stripped.trim();
|
||||
}
|
||||
|
||||
private String simplify(String name) {
|
||||
private String extractStateEnumType(String stateIdentifier) {
|
||||
if (stateIdentifier == null || stateIdentifier.isBlank()) {
|
||||
return stateIdentifier;
|
||||
}
|
||||
int lastDot = stateIdentifier.lastIndexOf('.');
|
||||
if (lastDot <= 0) {
|
||||
return stateIdentifier;
|
||||
}
|
||||
return stateIdentifier.substring(0, lastDot);
|
||||
}
|
||||
|
||||
private final java.util.Map<String, String> simplifySourceCache = new java.util.HashMap<>();
|
||||
|
||||
private String simplifySourceState(String name) {
|
||||
if (name == null) return null;
|
||||
if (simplifyCache.containsKey(name)) return simplifyCache.get(name);
|
||||
if (simplifySourceCache.containsKey(name)) return simplifySourceCache.get(name);
|
||||
|
||||
// Strip common suffixes
|
||||
String simplified = SUFFIX_PATTERN.matcher(name).replaceAll("$1");
|
||||
if (simplified.isEmpty()) {
|
||||
simplified = name;
|
||||
// For source states we must preserve enum type context to avoid collisions between
|
||||
// different state enums that share a constant name (e.g. OrderState.NEW vs InvoiceState.NEW).
|
||||
// Use EnumFormat.fn-like suffix (Type.CONST) when possible.
|
||||
String simplified = name;
|
||||
int lastDot = name.lastIndexOf('.');
|
||||
if (lastDot > 0) {
|
||||
int prevDot = name.lastIndexOf('.', lastDot - 1);
|
||||
if (prevDot > 0) {
|
||||
simplified = name.substring(prevDot + 1);
|
||||
}
|
||||
}
|
||||
// Simplify full identifiers to just the last part (enum name)
|
||||
simplified = FQN_PATTERN.matcher(simplified).replaceAll("$1");
|
||||
|
||||
simplifyCache.put(name, simplified);
|
||||
simplifySourceCache.put(name, simplified);
|
||||
return simplified;
|
||||
}
|
||||
|
||||
@@ -176,23 +332,20 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
|
||||
}
|
||||
|
||||
private TriggerPoint markRestEndpointExternal(TriggerPoint trigger, CallChain chain) {
|
||||
if (trigger == null || trigger.isExternal() || chain.getEntryPoint() == null) {
|
||||
return trigger;
|
||||
private static boolean isUnresolvedGenericRestEndpoint(EntryPoint entryPoint) {
|
||||
if (entryPoint == null) {
|
||||
return false;
|
||||
}
|
||||
EntryPoint entryPoint = chain.getEntryPoint();
|
||||
if (entryPoint.getType() != EntryPoint.Type.REST) {
|
||||
return trigger;
|
||||
if (entryPoint.getName() != null && entryPoint.getName().contains("{")) {
|
||||
return true;
|
||||
}
|
||||
String endpointName = entryPoint.getName();
|
||||
if (endpointName == null || !endpointName.contains("{")) {
|
||||
return trigger;
|
||||
if (entryPoint.getMetadata() != null) {
|
||||
Object path = entryPoint.getMetadata().get("path");
|
||||
if (path instanceof String pathValue && pathValue.contains("{")) {
|
||||
return true;
|
||||
}
|
||||
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
|
||||
!= MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
|
||||
return trigger;
|
||||
}
|
||||
return trigger.toBuilder().external(true).build();
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -45,11 +45,14 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
|
||||
if (chain.getTriggerPoint() == null) {
|
||||
return chain;
|
||||
}
|
||||
TriggerPoint canonical = MachineEnumCanonicalizer.canonicalizeTriggerPoint(
|
||||
chain.getTriggerPoint(), machineTypes);
|
||||
TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
canonical, machineTypes, context, result.getTransitions(), true);
|
||||
return chain.toBuilder().triggerPoint(enriched).build();
|
||||
TriggerPoint expanded = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
chain.getTriggerPoint(), machineTypes, context, result.getTransitions(), true);
|
||||
TriggerPoint canonical = expanded.getEventTypeFqn() == null && expanded.getStateTypeFqn() == null
|
||||
? MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(
|
||||
expanded, machineTypes, context)
|
||||
: MachineEnumCanonicalizer.canonicalizeTriggerPoint(
|
||||
expanded, machineTypes, context);
|
||||
return chain.toBuilder().triggerPoint(canonical).build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
|
||||
@@ -2,11 +2,22 @@ package click.kamil.springstatemachineexporter.analysis.enricher.matching;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import java.util.List;
|
||||
|
||||
public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
|
||||
private final CodebaseContext context;
|
||||
|
||||
public StrictFqnMatchingEngine() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
public StrictFqnMatchingEngine(CodebaseContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint) {
|
||||
if (stateMachineEvent == null || triggerPoint == null || triggerPoint.getEvent() == null) {
|
||||
@@ -52,7 +63,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null
|
||||
&& !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
||||
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
||||
return MachineEnumCanonicalizer.enumTypesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
|
||||
return typesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +76,11 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
if (triggerPoint.getEventTypeFqn() != null && !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
||||
return false;
|
||||
}
|
||||
// Getter/method-call expressions (e.g. richEvent.getId(), getType()) without resolved
|
||||
// polymorphic events must not wildcard-match bare or enum transition events.
|
||||
if (rawTriggerEvent.contains(".") || rawTriggerEvent.endsWith("()")) {
|
||||
return false;
|
||||
}
|
||||
if (triggerPoint.getEventTypeFqn() != null && isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
||||
return !smEventRaw.contains(".");
|
||||
}
|
||||
@@ -83,9 +99,10 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
|
||||
if (!tConst.equals(smConst)) return false;
|
||||
|
||||
// Prevent matching string/primitive triggers to enum transitions
|
||||
// String-typed triggers may match configured string transition events by constant name.
|
||||
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
|
||||
return false;
|
||||
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
|
||||
return isStringTypeOrPrimitive(smEnumType);
|
||||
}
|
||||
|
||||
// Prevent matching enum triggers to string transitions
|
||||
@@ -97,12 +114,12 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
|
||||
|
||||
if (eventTypeFqn != null) {
|
||||
return MachineEnumCanonicalizer.enumTypesMatch(eventTypeFqn, smEnumType);
|
||||
return typesMatch(eventTypeFqn, smEnumType);
|
||||
}
|
||||
|
||||
if (triggerEvent.contains(".")) {
|
||||
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
||||
return MachineEnumCanonicalizer.enumTypesMatch(triggerEnumType, smEnumType);
|
||||
return typesMatch(triggerEnumType, smEnumType);
|
||||
}
|
||||
|
||||
// Bare constant name (e.g. after Enum.name()) with no type context must not match every enum
|
||||
@@ -113,6 +130,18 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
return !triggerEvent.contains(".");
|
||||
}
|
||||
|
||||
private boolean typesMatch(String type1, String type2) {
|
||||
if (MachineEnumCanonicalizer.enumTypesMatch(type1, type2, context)) {
|
||||
return true;
|
||||
}
|
||||
if (type1 != null && type2 != null) {
|
||||
String machineFqn = type1.contains(".") ? type1 : type2;
|
||||
String candidate = type1.contains(".") ? type2 : type1;
|
||||
return MachineEnumCanonicalizer.enumTypesMatchForMachine(machineFqn, candidate, context);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String constantName(String event) {
|
||||
return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,19 @@ package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface BeanResolutionEngine {
|
||||
boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context);
|
||||
|
||||
default boolean hasProvenMachineAffinity(
|
||||
CallChain chain,
|
||||
String currentMachineName,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
return isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.Annotation;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
/**
|
||||
* Maps {@code @EnableStateMachine(name=…)} bean names to configuration class FQNs.
|
||||
*/
|
||||
final class EnableStateMachineBeanRouting {
|
||||
|
||||
private EnableStateMachineBeanRouting() {
|
||||
}
|
||||
|
||||
static Boolean matchesNamedBean(String beanName, String currentMachineConfigFqn, CodebaseContext context) {
|
||||
String normalized = stripQuotes(beanName);
|
||||
if (normalized == null || normalized.isEmpty() || context == null || currentMachineConfigFqn == null) {
|
||||
return null;
|
||||
}
|
||||
String matchingConfig = findConfigFqnByBeanName(normalized, context);
|
||||
if (matchingConfig == null) {
|
||||
return null;
|
||||
}
|
||||
return currentMachineConfigFqn.equals(matchingConfig);
|
||||
}
|
||||
|
||||
static String findConfigFqnByBeanName(String beanName, CodebaseContext context) {
|
||||
for (TypeDeclaration configType : context.findEntryPointClasses(java.util.List.of("EnableStateMachine"))) {
|
||||
String configFqn = context.getFqn(configType);
|
||||
String configuredName = extractEnableStateMachineName(configType);
|
||||
if (configuredName == null || configuredName.isEmpty()) {
|
||||
configuredName = defaultBeanName(configType.getName().getIdentifier());
|
||||
}
|
||||
if (beanName.equals(configuredName)) {
|
||||
return configFqn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String extractEnableStateMachineName(TypeDeclaration configType) {
|
||||
for (Object modifierObj : configType.modifiers()) {
|
||||
if (!(modifierObj instanceof Annotation annotation)) {
|
||||
continue;
|
||||
}
|
||||
if (!annotation.getTypeName().toString().endsWith("EnableStateMachine")) {
|
||||
continue;
|
||||
}
|
||||
String name = AstUtils.extractAnnotationMember(annotation, "name");
|
||||
if (name != null && !name.isBlank()) {
|
||||
return stripQuotes(name);
|
||||
}
|
||||
String value = AstUtils.extractAnnotationMember(annotation, "value");
|
||||
if (value != null && !value.isBlank()) {
|
||||
return stripQuotes(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String defaultBeanNameFromFqn(String fqn) {
|
||||
if (fqn == null || !fqn.contains(".")) {
|
||||
return fqn;
|
||||
}
|
||||
String simple = fqn.substring(fqn.lastIndexOf('.') + 1);
|
||||
return defaultBeanName(simple);
|
||||
}
|
||||
|
||||
private static String defaultBeanName(String simpleClassName) {
|
||||
if (simpleClassName == null || simpleClassName.isEmpty()) {
|
||||
return simpleClassName;
|
||||
}
|
||||
return Character.toLowerCase(simpleClassName.charAt(0)) + simpleClassName.substring(1);
|
||||
}
|
||||
|
||||
private static String stripQuotes(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = raw.trim();
|
||||
if (trimmed.length() >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
|
||||
return trimmed.substring(1, trimmed.length() - 1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -1,128 +1,261 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
|
||||
|
||||
private enum DistinctTypeAffinity {
|
||||
FOR_MACHINE,
|
||||
AGAINST_MACHINE,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasProvenMachineAffinity(
|
||||
CallChain chain,
|
||||
String currentMachineName,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
if (chain == null) {
|
||||
return false;
|
||||
}
|
||||
if (currentMachineName == null) {
|
||||
return isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||
}
|
||||
|
||||
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
|
||||
|
||||
String explicitTarget = evidence.explicitBeanName();
|
||||
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null) {
|
||||
Boolean namedTarget = EnableStateMachineBeanRouting.matchesNamedBean(
|
||||
explicitTarget, currentMachineName, context);
|
||||
if (namedTarget != null) {
|
||||
return namedTarget;
|
||||
}
|
||||
}
|
||||
|
||||
if (chain.getTriggerPoint() != null && context != null) {
|
||||
DistinctTypeAffinity typeAffinity =
|
||||
resolveDistinctTypeAffinity(chain.getTriggerPoint(), currentMachineName, context);
|
||||
if (typeAffinity == DistinctTypeAffinity.FOR_MACHINE) {
|
||||
return true;
|
||||
}
|
||||
if (typeAffinity == DistinctTypeAffinity.AGAINST_MACHINE) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (context == null || isSingleStateMachine(context)) {
|
||||
return isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||
}
|
||||
|
||||
TriggerPoint trigger = chain.getTriggerPoint();
|
||||
if (trigger != null
|
||||
&& SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)
|
||||
&& matchesConfiguredTransitionEvent(trigger, machineTransitions, context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
|
||||
// Precise FQN Type argument match
|
||||
if (chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
|
||||
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
|
||||
|
||||
String explicitTarget = evidence.explicitBeanName();
|
||||
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null && currentMachineName != null) {
|
||||
Boolean namedTarget = EnableStateMachineBeanRouting.matchesNamedBean(
|
||||
explicitTarget, currentMachineName, context);
|
||||
if (namedTarget != null) {
|
||||
return namedTarget;
|
||||
}
|
||||
}
|
||||
|
||||
boolean hasExplicitBeanTarget = explicitTarget != null && !explicitTarget.isEmpty();
|
||||
|
||||
// Precise FQN Type argument match from JDT bindings at sendEvent site
|
||||
if (!hasExplicitBeanTarget && chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
|
||||
String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn();
|
||||
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn();
|
||||
if (triggerEventFqn != null || triggerStateFqn != null) {
|
||||
String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context);
|
||||
String machineStateFqn = machineTypes[0];
|
||||
String machineEventFqn = machineTypes[1];
|
||||
boolean matched = false;
|
||||
boolean mismatched = false;
|
||||
|
||||
if (triggerEventFqn != null && machineEventFqn != null) {
|
||||
if (eraseGenerics(triggerEventFqn).equals(eraseGenerics(machineEventFqn))) {
|
||||
return true;
|
||||
}
|
||||
if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
|
||||
return false;
|
||||
}
|
||||
if (context != null && isSingleStateMachine(context)
|
||||
if (typesEquivalent(triggerEventFqn, machineEventFqn)) {
|
||||
matched = true;
|
||||
} else if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
|
||||
mismatched = true;
|
||||
} else if (isSingleStateMachine(context)
|
||||
&& (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) {
|
||||
return true;
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
if (triggerStateFqn != null && machineStateFqn != null) {
|
||||
if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) {
|
||||
return true; // Match!
|
||||
}
|
||||
if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
|
||||
return false; // Mismatch!
|
||||
}
|
||||
if (context != null && isSingleStateMachine(context)
|
||||
if (typesEquivalent(triggerStateFqn, machineStateFqn)) {
|
||||
matched = true;
|
||||
} else if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
|
||||
mismatched = true;
|
||||
} else if (isSingleStateMachine(context)
|
||||
&& (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) {
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
if (matched) {
|
||||
if (!isAmbiguousSharedGenericMatch(
|
||||
triggerEventFqn, triggerStateFqn, machineStateFqn, machineEventFqn)
|
||||
|| isSingleStateMachine(context)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (mismatched) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String targetVar = chain.getContextMachineId();
|
||||
if (targetVar == null && chain.getTriggerPoint() != null) {
|
||||
targetVar = chain.getTriggerPoint().getStateMachineId();
|
||||
}
|
||||
|
||||
String simplifiedMachineName = currentMachineName != null ? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase() : "";
|
||||
|
||||
if (targetVar != null && !targetVar.isEmpty()) {
|
||||
targetVar = targetVar.toLowerCase();
|
||||
if (targetVar.endsWith("statemachine")) {
|
||||
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
|
||||
if (!prefix.isEmpty()) {
|
||||
if (simplifiedMachineName.contains(prefix)) {
|
||||
return true; // Explicit positive match
|
||||
} else {
|
||||
return false; // Explicit negative match
|
||||
}
|
||||
}
|
||||
} else if (simplifiedMachineName.contains(targetVar)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) {
|
||||
String smPackage = getPackageName(currentMachineName);
|
||||
String smSimple = getSimpleClassName(currentMachineName);
|
||||
String smPrefix = getFirstCamelCaseWord(smSimple);
|
||||
|
||||
boolean hasPositiveMatch = false;
|
||||
boolean hasStrongMismatch = false;
|
||||
|
||||
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
|
||||
String chainClass = chain.getTriggerPoint().getClassName();
|
||||
String chainPackage = getPackageName(chainClass);
|
||||
String chainSimple = getSimpleClassName(chainClass);
|
||||
String chainPrefix = getFirstCamelCaseWord(chainSimple);
|
||||
|
||||
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
|
||||
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (String method : chain.getMethodChain()) {
|
||||
String chainClass = getClassNameOnly(method);
|
||||
String chainPackage = getPackageName(chainClass);
|
||||
String chainSimple = getSimpleClassName(chainClass);
|
||||
String chainPrefix = getFirstCamelCaseWord(chainSimple);
|
||||
|
||||
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
|
||||
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
|
||||
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix, smSimple, chainSimple)) {
|
||||
hasStrongMismatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPositiveMatch) {
|
||||
return true;
|
||||
}
|
||||
if (hasStrongMismatch) {
|
||||
// Fail closed only when both trigger and machine generic types were resolved but
|
||||
// could not be reconciled. When machine config AST does not expose type args (stub
|
||||
// config classes in tests, generated configs), fall through to single-SM / package routing.
|
||||
boolean machineTypesKnown = machineEventFqn != null || machineStateFqn != null;
|
||||
if (machineTypesKnown) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSingleStateMachine(context) && isAmbiguousSharedGenericTrigger(chain.getTriggerPoint())) {
|
||||
Boolean injectionMatch = SpringInjectionRouting.matchesMachineConfig(
|
||||
chain.getTriggerPoint(), currentMachineName, context);
|
||||
if (injectionMatch != null) {
|
||||
return injectionMatch;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Boolean packageMatch = PackageNameRoutingHeuristics.matches(chain, currentMachineName, explicitTarget);
|
||||
if (packageMatch != null) {
|
||||
return packageMatch;
|
||||
}
|
||||
return context == null || isSingleStateMachine(context);
|
||||
}
|
||||
|
||||
private DistinctTypeAffinity resolveDistinctTypeAffinity(
|
||||
TriggerPoint trigger, String machineName, CodebaseContext context) {
|
||||
String triggerEventFqn = trigger.getEventTypeFqn();
|
||||
String triggerStateFqn = trigger.getStateTypeFqn();
|
||||
if (triggerEventFqn == null && triggerStateFqn == null) {
|
||||
return DistinctTypeAffinity.UNKNOWN;
|
||||
}
|
||||
|
||||
String[] machineTypes = StateMachineTypeResolver.resolve(machineName, context);
|
||||
String machineStateFqn = machineTypes[0];
|
||||
String machineEventFqn = machineTypes[1];
|
||||
|
||||
boolean matched = false;
|
||||
boolean mismatched = false;
|
||||
|
||||
if (triggerEventFqn != null && machineEventFqn != null) {
|
||||
if (typesEquivalent(triggerEventFqn, machineEventFqn)
|
||||
&& !isAmbiguousSharedGenericType(triggerEventFqn)) {
|
||||
matched = true;
|
||||
} else if (isDistinctTypeMismatched(triggerEventFqn, machineEventFqn)) {
|
||||
mismatched = true;
|
||||
}
|
||||
}
|
||||
if (triggerStateFqn != null && machineStateFqn != null) {
|
||||
if (typesEquivalent(triggerStateFqn, machineStateFqn)
|
||||
&& !isAmbiguousSharedGenericType(triggerStateFqn)) {
|
||||
matched = true;
|
||||
} else if (isDistinctTypeMismatched(triggerStateFqn, machineStateFqn)) {
|
||||
mismatched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
return DistinctTypeAffinity.FOR_MACHINE;
|
||||
}
|
||||
if (mismatched) {
|
||||
return DistinctTypeAffinity.AGAINST_MACHINE;
|
||||
}
|
||||
return DistinctTypeAffinity.UNKNOWN;
|
||||
}
|
||||
|
||||
private boolean matchesConfiguredTransitionEvent(
|
||||
TriggerPoint trigger,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null || machineTransitions == null || machineTransitions.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine(context);
|
||||
for (Transition transition : machineTransitions) {
|
||||
if (transition.getEvent() != null && matchingEngine.matches(transition.getEvent(), trigger)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isAmbiguousSharedGenericMatch(
|
||||
String triggerEventFqn,
|
||||
String triggerStateFqn,
|
||||
String machineStateFqn,
|
||||
String machineEventFqn) {
|
||||
boolean eventShared = triggerEventFqn != null
|
||||
&& machineEventFqn != null
|
||||
&& typesEquivalent(triggerEventFqn, machineEventFqn)
|
||||
&& isAmbiguousSharedGenericType(triggerEventFqn);
|
||||
boolean stateShared = triggerStateFqn != null
|
||||
&& machineStateFqn != null
|
||||
&& typesEquivalent(triggerStateFqn, machineStateFqn)
|
||||
&& isAmbiguousSharedGenericType(triggerStateFqn);
|
||||
return eventShared || stateShared;
|
||||
}
|
||||
|
||||
private boolean isAmbiguousSharedGenericType(String typeFqn) {
|
||||
return isErasedOrOpaqueType(typeFqn) || isStringType(typeFqn);
|
||||
}
|
||||
|
||||
private boolean isDistinctTypeMismatched(String type1, String type2) {
|
||||
if (isAmbiguousSharedGenericType(type1) || isAmbiguousSharedGenericType(type2)) {
|
||||
return false;
|
||||
}
|
||||
return isTypeMismatched(type1, type2);
|
||||
}
|
||||
|
||||
private boolean isStringType(String typeFqn) {
|
||||
if (typeFqn == null) {
|
||||
return false;
|
||||
}
|
||||
String erased = eraseGenerics(typeFqn);
|
||||
return "java.lang.String".equals(erased) || "String".equals(erased);
|
||||
}
|
||||
|
||||
private Boolean resolveNamedBeanTarget(String beanName, String currentMachineName, CodebaseContext context) {
|
||||
return EnableStateMachineBeanRouting.matchesNamedBean(beanName, currentMachineName, context);
|
||||
}
|
||||
|
||||
private boolean isAmbiguousSharedGenericTrigger(TriggerPoint trigger) {
|
||||
if (trigger == null) {
|
||||
return false;
|
||||
}
|
||||
return isAmbiguousSharedGenericType(trigger.getEventTypeFqn())
|
||||
|| isAmbiguousSharedGenericType(trigger.getStateTypeFqn());
|
||||
}
|
||||
|
||||
private int countStateMachines(CodebaseContext context) {
|
||||
if (context == null) {
|
||||
return 0;
|
||||
@@ -153,160 +286,6 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
|
||||
java.util.Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory")).size();
|
||||
}
|
||||
|
||||
private boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
|
||||
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (smPackage.equals(chainPackage)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!chainPackage.startsWith(smPackage + ".") && !smPackage.startsWith(chainPackage + ".")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find deepest common package root
|
||||
String[] p1 = smPackage.split("\\.");
|
||||
String[] p2 = chainPackage.split("\\.");
|
||||
int matchIdx = -1;
|
||||
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
|
||||
if (p1[i].equals(p2[i])) {
|
||||
matchIdx = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the state machine's prefix is part of the shared common package,
|
||||
// then the state machine represents the parent domain of the caller.
|
||||
if (smPrefix != null && matchIdx >= 0) {
|
||||
String lowerPrefix = smPrefix.toLowerCase();
|
||||
for (int i = 0; i <= matchIdx; i++) {
|
||||
if (p1[i].toLowerCase().contains(lowerPrefix) || lowerPrefix.contains(p1[i].toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDomainMismatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix, String smSimple, String chainSimple) {
|
||||
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
|
||||
if (smPrefix == null || chainPrefix == null) return false;
|
||||
|
||||
String smLower = smPrefix.toLowerCase();
|
||||
String chainLower = chainPrefix.toLowerCase();
|
||||
|
||||
// Ignore generic/technical class prefixes
|
||||
if (smLower.equals("state") || smLower.equals("statemachine") || smLower.equals("config") || smLower.equals("configuration") || smLower.equals("adapter") ||
|
||||
smLower.equals("enterprise") || smLower.equals("app") || smLower.equals("global") || smLower.equals("core") || smLower.equals("project") || smLower.equals("main") || smLower.equals("base") || smLower.equals("common") || smLower.equals("shared") ||
|
||||
chainLower.equals("state") || chainLower.equals("statemachine") || chainLower.equals("config") || chainLower.equals("configuration") || chainLower.equals("adapter") ||
|
||||
chainLower.equals("enterprise") || chainLower.equals("app") || chainLower.equals("global") || chainLower.equals("core") || chainLower.equals("project") || chainLower.equals("main") || chainLower.equals("base") || chainLower.equals("common") || chainLower.equals("shared")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If class prefixes diverge, check if they are from different domains
|
||||
if (!smLower.equals(chainLower)) {
|
||||
// If one prefix is present as a domain/feature segment in the other package, they are related
|
||||
if (chainPackage.toLowerCase().contains(smLower) || smPackage.toLowerCase().contains(chainLower)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Otherwise, if they diverge under a shared project package root, it is a mismatch
|
||||
String[] p1 = smPackage.split("\\.");
|
||||
String[] p2 = chainPackage.split("\\.");
|
||||
int matchIdx = -1;
|
||||
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
|
||||
if (p1[i].equals(p2[i])) {
|
||||
matchIdx = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchIdx >= 1) {
|
||||
// If they diverge under a shared root, check if the divergence is only on generic package layers
|
||||
boolean onlyGenericDivergence = true;
|
||||
Set<String> genericLayers = Set.of(
|
||||
"config", "configuration", "service", "services", "impl", "api", "web",
|
||||
"controller", "controllers", "messaging", "listener", "listeners",
|
||||
"repository", "repositories", "db", "model", "models", "domain",
|
||||
"constants", "utils", "helper", "helpers", "event", "events",
|
||||
"state", "states", "transition", "transitions"
|
||||
);
|
||||
for (int i = matchIdx + 1; i < p1.length; i++) {
|
||||
if (!genericLayers.contains(p1[i].toLowerCase())) {
|
||||
onlyGenericDivergence = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (onlyGenericDivergence) {
|
||||
for (int i = matchIdx + 1; i < p2.length; i++) {
|
||||
if (!genericLayers.contains(p2[i].toLowerCase())) {
|
||||
onlyGenericDivergence = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!onlyGenericDivergence) {
|
||||
return true; // Mismatch under a shared root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private Set<String> extractDomainTerms(String pkg, String prefix) {
|
||||
Set<String> terms = new HashSet<>();
|
||||
if (pkg != null && !pkg.isEmpty()) {
|
||||
String[] segments = pkg.split("\\.");
|
||||
// Take segments from index 2 onwards if length > 2
|
||||
int start = segments.length > 2 ? 2 : 0;
|
||||
for (int i = start; i < segments.length; i++) {
|
||||
terms.add(segments[i].toLowerCase());
|
||||
}
|
||||
}
|
||||
if (prefix != null && !prefix.isEmpty()) {
|
||||
terms.add(prefix.toLowerCase());
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
|
||||
private String getPackageName(String fqn) {
|
||||
if (fqn == null || !fqn.contains(".")) return "";
|
||||
return fqn.substring(0, fqn.lastIndexOf('.'));
|
||||
}
|
||||
|
||||
private String getSimpleClassName(String fqn) {
|
||||
if (fqn == null) return "";
|
||||
if (!fqn.contains(".")) return fqn;
|
||||
return fqn.substring(fqn.lastIndexOf('.') + 1);
|
||||
}
|
||||
|
||||
private String getClassNameOnly(String methodFqn) {
|
||||
if (methodFqn == null) return "";
|
||||
String clean = methodFqn;
|
||||
if (clean.contains("(")) {
|
||||
clean = clean.substring(0, clean.indexOf('('));
|
||||
}
|
||||
if (clean.contains(".")) {
|
||||
clean = clean.substring(0, clean.lastIndexOf('.'));
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private String getFirstCamelCaseWord(String simpleName) {
|
||||
if (simpleName == null || simpleName.isEmpty()) return null;
|
||||
String[] words = simpleName.split("(?<!^)(?=[A-Z])");
|
||||
if (words.length > 0) {
|
||||
return words[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String eraseGenerics(String type) {
|
||||
if (type == null) return null;
|
||||
int idx = type.indexOf('<');
|
||||
@@ -316,6 +295,27 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
|
||||
return type;
|
||||
}
|
||||
|
||||
private boolean typesEquivalent(String type1, String type2) {
|
||||
if (type1 == null || type2 == null) {
|
||||
return false;
|
||||
}
|
||||
String erased1 = normalizePrimitiveFqn(eraseGenerics(type1));
|
||||
String erased2 = normalizePrimitiveFqn(eraseGenerics(type2));
|
||||
return erased1.equals(erased2);
|
||||
}
|
||||
|
||||
private String normalizePrimitiveFqn(String type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (type) {
|
||||
case "java.lang.String" -> "String";
|
||||
case "java.lang.Object" -> "Object";
|
||||
case "java.io.Serializable" -> "Serializable";
|
||||
default -> type;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isTypeMismatched(String type1, String type2) {
|
||||
if (type1 == null || type2 == null) return false;
|
||||
type1 = eraseGenerics(type1);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
|
||||
/**
|
||||
* Source-derived evidence used to decide whether a call chain belongs to a state-machine export.
|
||||
*/
|
||||
public record MachineRoutingEvidence(
|
||||
String explicitBeanName,
|
||||
String stateMachineId,
|
||||
String eventTypeFqn,
|
||||
String stateTypeFqn,
|
||||
String triggerClassFqn,
|
||||
String triggerMethod,
|
||||
Integer triggerLine) {
|
||||
|
||||
public static MachineRoutingEvidence from(CallChain chain) {
|
||||
if (chain == null) {
|
||||
return new MachineRoutingEvidence(null, null, null, null, null, null, null);
|
||||
}
|
||||
TriggerPoint trigger = chain.getTriggerPoint();
|
||||
String explicit = chain.getContextMachineId();
|
||||
String stateMachineId = null;
|
||||
Integer line = null;
|
||||
if (trigger != null) {
|
||||
if (explicit == null) {
|
||||
explicit = trigger.getStateMachineId();
|
||||
}
|
||||
stateMachineId = trigger.getStateMachineId();
|
||||
if (trigger.getLineNumber() > 0) {
|
||||
line = trigger.getLineNumber();
|
||||
}
|
||||
}
|
||||
return new MachineRoutingEvidence(
|
||||
explicit,
|
||||
stateMachineId,
|
||||
trigger != null ? trigger.getEventTypeFqn() : null,
|
||||
trigger != null ? trigger.getStateTypeFqn() : null,
|
||||
trigger != null ? trigger.getClassName() : null,
|
||||
trigger != null ? trigger.getMethodName() : null,
|
||||
line);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Legacy package-prefix and domain-name matching for single-machine / null-context fallback routing.
|
||||
*/
|
||||
final class PackageNameRoutingHeuristics {
|
||||
|
||||
private static final Set<String> GENERIC_CLASS_PREFIXES = Set.of(
|
||||
"state", "statemachine", "config", "configuration", "adapter",
|
||||
"enterprise", "app", "global", "core", "project", "main",
|
||||
"base", "common", "shared");
|
||||
|
||||
private static final Set<String> GENERIC_PACKAGE_SEGMENTS = Set.of(
|
||||
"config", "configuration", "service", "services", "impl", "api", "web",
|
||||
"controller", "controllers", "messaging", "listener", "listeners",
|
||||
"repository", "repositories", "db", "model", "models", "domain",
|
||||
"constants", "utils", "helper", "helpers", "event", "events",
|
||||
"state", "states", "transition", "transitions", "shared", "common",
|
||||
"global", "core", "base", "main", "app", "enterprise");
|
||||
|
||||
private PackageNameRoutingHeuristics() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true when any class in the call chain carries a vertical domain prefix or package segment
|
||||
*/
|
||||
static boolean hasVerticalDomainOwnership(CallChain chain) {
|
||||
if (chain == null) {
|
||||
return false;
|
||||
}
|
||||
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
|
||||
if (classHasVerticalDomainOwnership(chain.getTriggerPoint().getClassName())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (chain.getMethodChain() != null) {
|
||||
for (String method : chain.getMethodChain()) {
|
||||
if (classHasVerticalDomainOwnership(getClassNameOnly(method))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true/false if package heuristics decide, null if inconclusive
|
||||
*/
|
||||
static Boolean matches(CallChain chain, String currentMachineName, String explicitBeanTarget) {
|
||||
if (chain == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean hasExplicitBeanTarget = explicitBeanTarget != null && !explicitBeanTarget.isEmpty();
|
||||
String targetVar = hasExplicitBeanTarget ? explicitBeanTarget : chain.getContextMachineId();
|
||||
if (targetVar == null && chain.getTriggerPoint() != null) {
|
||||
targetVar = chain.getTriggerPoint().getStateMachineId();
|
||||
}
|
||||
|
||||
String simplifiedMachineName = currentMachineName != null
|
||||
? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase()
|
||||
: "";
|
||||
|
||||
if (targetVar != null && !targetVar.isEmpty()) {
|
||||
targetVar = targetVar.toLowerCase();
|
||||
if (targetVar.endsWith("statemachine")) {
|
||||
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
|
||||
if (!prefix.isEmpty()) {
|
||||
if (simplifiedMachineName.contains(prefix)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if (simplifiedMachineName.contains(targetVar)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) {
|
||||
String smPackage = getPackageName(currentMachineName);
|
||||
String smSimple = getSimpleClassName(currentMachineName);
|
||||
String smPrefix = getFirstCamelCaseWord(smSimple);
|
||||
|
||||
boolean hasPositiveMatch = false;
|
||||
boolean hasStrongMismatch = false;
|
||||
|
||||
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
|
||||
String chainClass = chain.getTriggerPoint().getClassName();
|
||||
String chainPackage = getPackageName(chainClass);
|
||||
String chainSimple = getSimpleClassName(chainClass);
|
||||
String chainPrefix = getFirstCamelCaseWord(chainSimple);
|
||||
|
||||
if (hasMatchingDomainPrefix(smPrefix, chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
|
||||
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (String method : chain.getMethodChain()) {
|
||||
String chainClass = getClassNameOnly(method);
|
||||
String chainPackage = getPackageName(chainClass);
|
||||
String chainSimple = getSimpleClassName(chainClass);
|
||||
String chainPrefix = getFirstCamelCaseWord(chainSimple);
|
||||
|
||||
if (hasMatchingDomainPrefix(smPrefix, chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
|
||||
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
|
||||
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix, smSimple, chainSimple)) {
|
||||
hasStrongMismatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPositiveMatch) {
|
||||
return true;
|
||||
}
|
||||
if (hasStrongMismatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
|
||||
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (smPackage.equals(chainPackage)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!chainPackage.startsWith(smPackage + ".") && !smPackage.startsWith(chainPackage + ".")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] p1 = smPackage.split("\\.");
|
||||
String[] p2 = chainPackage.split("\\.");
|
||||
int matchIdx = -1;
|
||||
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
|
||||
if (p1[i].equals(p2[i])) {
|
||||
matchIdx = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (smPrefix != null && matchIdx >= 0) {
|
||||
String lowerPrefix = smPrefix.toLowerCase();
|
||||
for (int i = 0; i <= matchIdx; i++) {
|
||||
if (p1[i].toLowerCase().contains(lowerPrefix) || lowerPrefix.contains(p1[i].toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isDomainMismatch(
|
||||
String smPackage,
|
||||
String chainPackage,
|
||||
String smPrefix,
|
||||
String chainPrefix,
|
||||
String smSimple,
|
||||
String chainSimple) {
|
||||
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (smPrefix == null || chainPrefix == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String smLower = smPrefix.toLowerCase();
|
||||
String chainLower = chainPrefix.toLowerCase();
|
||||
|
||||
if (isGenericClassPrefix(smLower) || isGenericClassPrefix(chainLower)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!smLower.equals(chainLower)) {
|
||||
if (chainPackage.toLowerCase().contains(smLower) || smPackage.toLowerCase().contains(chainLower)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] p1 = smPackage.split("\\.");
|
||||
String[] p2 = chainPackage.split("\\.");
|
||||
int matchIdx = -1;
|
||||
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
|
||||
if (p1[i].equals(p2[i])) {
|
||||
matchIdx = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchIdx >= 1) {
|
||||
boolean onlyGenericDivergence = true;
|
||||
for (int i = matchIdx + 1; i < p1.length; i++) {
|
||||
if (!isGenericPackageSegment(p1[i])) {
|
||||
onlyGenericDivergence = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (onlyGenericDivergence) {
|
||||
for (int i = matchIdx + 1; i < p2.length; i++) {
|
||||
if (!isGenericPackageSegment(p2[i])) {
|
||||
onlyGenericDivergence = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!onlyGenericDivergence) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String getPackageName(String fqn) {
|
||||
if (fqn == null || !fqn.contains(".")) {
|
||||
return "";
|
||||
}
|
||||
return fqn.substring(0, fqn.lastIndexOf('.'));
|
||||
}
|
||||
|
||||
private static String getSimpleClassName(String fqn) {
|
||||
if (fqn == null) {
|
||||
return "";
|
||||
}
|
||||
if (!fqn.contains(".")) {
|
||||
return fqn;
|
||||
}
|
||||
return fqn.substring(fqn.lastIndexOf('.') + 1);
|
||||
}
|
||||
|
||||
private static String getClassNameOnly(String methodFqn) {
|
||||
if (methodFqn == null) {
|
||||
return "";
|
||||
}
|
||||
String clean = methodFqn;
|
||||
if (clean.contains("(")) {
|
||||
clean = clean.substring(0, clean.indexOf('('));
|
||||
}
|
||||
if (clean.contains(".")) {
|
||||
clean = clean.substring(0, clean.lastIndexOf('.'));
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private static String getFirstCamelCaseWord(String simpleName) {
|
||||
if (simpleName == null || simpleName.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String[] words = simpleName.split("(?<!^)(?=[A-Z])");
|
||||
if (words.length > 0) {
|
||||
return words[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean classHasVerticalDomainOwnership(String classFqn) {
|
||||
if (classFqn == null || classFqn.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String prefix = getFirstCamelCaseWord(getSimpleClassName(classFqn));
|
||||
if (prefix != null && !isGenericClassPrefix(prefix)) {
|
||||
return true;
|
||||
}
|
||||
return hasVerticalPackageSegment(getPackageName(classFqn));
|
||||
}
|
||||
|
||||
private static boolean hasVerticalPackageSegment(String packageName) {
|
||||
if (packageName == null || packageName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String[] segments = packageName.split("\\.");
|
||||
for (int i = 2; i < segments.length; i++) {
|
||||
if (!isGenericPackageSegment(segments[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasMatchingDomainPrefix(String smPrefix, String chainPrefix) {
|
||||
return smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix);
|
||||
}
|
||||
|
||||
private static boolean isGenericClassPrefix(String prefix) {
|
||||
return prefix != null && GENERIC_CLASS_PREFIXES.contains(prefix.toLowerCase());
|
||||
}
|
||||
|
||||
private static boolean isGenericPackageSegment(String segment) {
|
||||
return segment != null && GENERIC_PACKAGE_SEGMENTS.contains(segment.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
|
||||
/**
|
||||
* Decides whether a call chain is provably shared infrastructure that may intentionally
|
||||
* attach to multiple state machines when they share the same transition event.
|
||||
* <p>
|
||||
* Shared infrastructure requires source-derived evidence only: no distinct machine enum 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.
|
||||
*/
|
||||
final class SharedServiceRoutingPolicy {
|
||||
|
||||
private SharedServiceRoutingPolicy() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true when the chain may multi-attach to every machine that shares its transition event
|
||||
*/
|
||||
static boolean isProvablySharedInfrastructure(CallChain chain) {
|
||||
if (chain == null) {
|
||||
return false;
|
||||
}
|
||||
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
|
||||
if (evidence.eventTypeFqn() != null || evidence.stateTypeFqn() != null) {
|
||||
return false;
|
||||
}
|
||||
return !PackageNameRoutingHeuristics.hasVerticalDomainOwnership(chain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Resolves which {@code @EnableStateMachine} configuration owns a trigger's {@code StateMachine} receiver
|
||||
* using Spring injection analysis (JDT bindings), not package-name guessing.
|
||||
*/
|
||||
final class SpringInjectionRouting {
|
||||
|
||||
private static final Set<String> TRIGGER_METHODS = Set.of(
|
||||
"sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger");
|
||||
|
||||
private SpringInjectionRouting() {
|
||||
}
|
||||
|
||||
static Boolean matchesMachineConfig(TriggerPoint trigger, String machineConfigFqn, CodebaseContext context) {
|
||||
if (trigger == null || machineConfigFqn == null || context == null || !context.isResolveBindings()) {
|
||||
return null;
|
||||
}
|
||||
String configFqn = resolveConfigurationFqn(trigger, context);
|
||||
if (configFqn == null) {
|
||||
return null;
|
||||
}
|
||||
return machineConfigFqn.equals(configFqn);
|
||||
}
|
||||
|
||||
static String resolveConfigurationFqn(TriggerPoint trigger, CodebaseContext context) {
|
||||
if (trigger == null || trigger.getClassName() == null || trigger.getMethodName() == null) {
|
||||
return null;
|
||||
}
|
||||
if (!context.isResolveBindings()) {
|
||||
return null;
|
||||
}
|
||||
InjectionPointAnalyzer analyzer = createAnalyzer(context);
|
||||
TypeDeclaration type = context.getTypeDeclaration(trigger.getClassName());
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration method = context.findMethodDeclaration(type, trigger.getMethodName(), true);
|
||||
if (method == null || method.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
CompilationUnit cu = (CompilationUnit) type.getRoot();
|
||||
final IVariableBinding[] receiverBinding = new IVariableBinding[1];
|
||||
method.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if (trigger.getLineNumber() > 0 && cu != null
|
||||
&& cu.getLineNumber(node.getStartPosition()) != trigger.getLineNumber()) {
|
||||
return true;
|
||||
}
|
||||
if (!TRIGGER_METHODS.contains(node.getName().getIdentifier())) {
|
||||
return true;
|
||||
}
|
||||
Expression receiver = node.getExpression();
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
IBinding binding = sn.resolveBinding();
|
||||
if (binding instanceof IVariableBinding vb) {
|
||||
receiverBinding[0] = vb;
|
||||
}
|
||||
} else if (receiver instanceof FieldAccess fa) {
|
||||
receiverBinding[0] = fa.resolveFieldBinding();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (receiverBinding[0] == null) {
|
||||
return null;
|
||||
}
|
||||
String injectedFqn = analyzer.resolveInjectedBeanFqn(receiverBinding[0]);
|
||||
if (injectedFqn == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration injectedType = context.getTypeDeclaration(injectedFqn);
|
||||
if (injectedType != null && context.extendsStateMachineConfigurerAdapter(injectedType)) {
|
||||
return injectedFqn;
|
||||
}
|
||||
return EnableStateMachineBeanRouting.findConfigFqnByBeanName(
|
||||
EnableStateMachineBeanRouting.defaultBeanNameFromFqn(injectedFqn), context);
|
||||
}
|
||||
|
||||
private static InjectionPointAnalyzer createAnalyzer(CodebaseContext context) {
|
||||
SpringBeanRegistry registry = new SpringBeanRegistry();
|
||||
SpringContextScanner scanner = new SpringContextScanner(registry);
|
||||
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||
cu.accept(scanner);
|
||||
}
|
||||
return new InjectionPointAnalyzer(new SpringDependencyResolver(registry));
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
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 lombok.Builder;
|
||||
import lombok.Getter;
|
||||
@@ -45,6 +47,10 @@ public class AnalysisResult {
|
||||
private CodebaseMetadata metadata = CodebaseMetadata.empty();
|
||||
|
||||
public void applyResolution(Map<String, String> properties) {
|
||||
applyResolution(properties, null);
|
||||
}
|
||||
|
||||
public void applyResolution(Map<String, String> properties, CodebaseContext context) {
|
||||
var resolver = new click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver();
|
||||
|
||||
// 1. Resolve start/end states strings
|
||||
@@ -88,12 +94,12 @@ public class AnalysisResult {
|
||||
if (metadata != null) {
|
||||
List<TriggerPoint> resolvedTriggers = metadata.getTriggers() == null ? null
|
||||
: metadata.getTriggers().stream()
|
||||
.map(trigger -> resolveTrigger(trigger, resolver, properties))
|
||||
.map(trigger -> resolveTrigger(trigger, resolver, properties, context))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
List<CallChain> resolvedCallChains = metadata.getCallChains() == null ? null
|
||||
: metadata.getCallChains().stream()
|
||||
.map(chain -> resolveCallChain(chain, resolver, properties))
|
||||
.map(chain -> resolveCallChain(chain, resolver, properties, context))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
if (metadata.getEntryPoints() != null) {
|
||||
@@ -135,15 +141,26 @@ public class AnalysisResult {
|
||||
private static TriggerPoint resolveTrigger(
|
||||
TriggerPoint trigger,
|
||||
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
|
||||
Map<String, String> properties) {
|
||||
Map<String, String> properties,
|
||||
CodebaseContext context) {
|
||||
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
|
||||
: trigger.getPolymorphicEvents().stream()
|
||||
.map(value -> resolver.resolveValue(value, properties))
|
||||
.map(value -> MachineEnumCanonicalizer.qualifyEventIdentifier(
|
||||
value, trigger.getEventTypeFqn(), context))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
return trigger.toBuilder()
|
||||
.event(trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null)
|
||||
.sourceState(trigger.getSourceState() != null
|
||||
String resolvedEvent = trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null;
|
||||
String resolvedSource = trigger.getSourceState() != null
|
||||
? resolver.resolveValue(trigger.getSourceState(), properties)
|
||||
: null;
|
||||
return trigger.toBuilder()
|
||||
.event(resolvedEvent != null
|
||||
? MachineEnumCanonicalizer.qualifyEventIdentifier(
|
||||
resolvedEvent, trigger.getEventTypeFqn(), context)
|
||||
: null)
|
||||
.sourceState(resolvedSource != null
|
||||
? MachineEnumCanonicalizer.canonicalizeLabel(
|
||||
resolvedSource, trigger.getStateTypeFqn(), context)
|
||||
: null)
|
||||
.polymorphicEvents(polymorphicEvents)
|
||||
.build();
|
||||
@@ -152,10 +169,11 @@ public class AnalysisResult {
|
||||
private static CallChain resolveCallChain(
|
||||
CallChain chain,
|
||||
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
|
||||
Map<String, String> properties) {
|
||||
Map<String, String> properties,
|
||||
CodebaseContext context) {
|
||||
TriggerPoint trigger = chain.getTriggerPoint() == null
|
||||
? null
|
||||
: resolveTrigger(chain.getTriggerPoint(), resolver, properties);
|
||||
: resolveTrigger(chain.getTriggerPoint(), resolver, properties, context);
|
||||
List<MatchedTransition> matchedTransitions = chain.getMatchedTransitions() == null ? null
|
||||
: chain.getMatchedTransitions().stream()
|
||||
.map(matched -> MatchedTransition.builder()
|
||||
|
||||
@@ -8,11 +8,11 @@ import lombok.extern.jackson.Jacksonized;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@Builder(toBuilder = true)
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class BusinessFlow {
|
||||
private final String name;
|
||||
private final String description;
|
||||
private final List<String> steps; // List of Event names in order
|
||||
private final List<FlowStep> steps;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder(toBuilder = true)
|
||||
@@ -17,4 +19,8 @@ public class CallChain {
|
||||
private final TriggerPoint triggerPoint;
|
||||
private final String contextMachineId;
|
||||
private final List<MatchedTransition> matchedTransitions;
|
||||
/** How this chain's trigger was linked; derived from trigger flags and matchedTransitions. */
|
||||
private final LinkResolution linkResolution;
|
||||
@JsonIgnore
|
||||
private final Map<String, String> pathBindings;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder(toBuilder = true)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonDeserialize(using = FlowStepDeserializer.class)
|
||||
@JsonSerialize(using = FlowStepSerializer.class)
|
||||
public class FlowStep {
|
||||
/** Source state identifier (package-canonical FQN or short form). */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private final String source;
|
||||
/** Event identifier or {@code Source->Target} for anonymous transitions. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private final String event;
|
||||
/** Precomputed {@code #link_*} suffix for HTML/SVG highlight. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private final String linkKey;
|
||||
|
||||
public static FlowStep ofEvent(String event) {
|
||||
return FlowStep.builder().event(event).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
class FlowStepDeserializer extends JsonDeserializer<FlowStep> {
|
||||
|
||||
@Override
|
||||
public FlowStep deserialize(JsonParser parser, DeserializationContext context) throws IOException {
|
||||
JsonNode node = parser.getCodec().readTree(parser);
|
||||
if (node.isTextual()) {
|
||||
return FlowStep.builder().event(node.asText()).build();
|
||||
}
|
||||
return FlowStep.builder()
|
||||
.source(textOrNull(node, "source"))
|
||||
.event(textOrNull(node, "event"))
|
||||
.linkKey(textOrNull(node, "linkKey"))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String textOrNull(JsonNode node, String field) {
|
||||
JsonNode value = node.get(field);
|
||||
if (value == null || value.isNull()) {
|
||||
return null;
|
||||
}
|
||||
String text = value.asText();
|
||||
return text.isBlank() ? null : text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
class FlowStepSerializer extends JsonSerializer<FlowStep> {
|
||||
|
||||
@Override
|
||||
public void serialize(FlowStep step, JsonGenerator generator, SerializerProvider serializers) throws IOException {
|
||||
if (step == null) {
|
||||
generator.writeNull();
|
||||
return;
|
||||
}
|
||||
boolean structured = step.getSource() != null && !step.getSource().isBlank()
|
||||
|| step.getLinkKey() != null && !step.getLinkKey().isBlank();
|
||||
if (!structured && step.getEvent() != null && !step.getEvent().isBlank()) {
|
||||
generator.writeString(step.getEvent());
|
||||
return;
|
||||
}
|
||||
generator.writeStartObject();
|
||||
if (step.getSource() != null && !step.getSource().isBlank()) {
|
||||
generator.writeStringField("source", step.getSource());
|
||||
}
|
||||
if (step.getEvent() != null && !step.getEvent().isBlank()) {
|
||||
generator.writeStringField("event", step.getEvent());
|
||||
}
|
||||
if (step.getLinkKey() != null && !step.getLinkKey().isBlank()) {
|
||||
generator.writeStringField("linkKey", step.getLinkKey());
|
||||
}
|
||||
generator.writeEndObject();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
/**
|
||||
* Export metadata describing how a call-chain trigger was linked to state-machine transitions.
|
||||
* Derived from existing trigger flags only — no additional heuristics.
|
||||
*/
|
||||
public enum LinkResolution {
|
||||
/** Concrete trigger linked to one or more matched transitions. */
|
||||
RESOLVED,
|
||||
/** REST or other external trigger that cannot be statically linked. */
|
||||
UNRESOLVED_EXTERNAL,
|
||||
/** Call-graph widening or source-state ambiguity prevented linking. */
|
||||
AMBIGUOUS_WIDEN,
|
||||
/** Static trigger with no matching transition link. */
|
||||
NO_MATCH
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@Builder(toBuilder = true)
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class MatchedTransition {
|
||||
private final String sourceState;
|
||||
private final String targetState;
|
||||
private final String event;
|
||||
/** Precomputed {@code #link_*} suffix for HTML/SVG highlight; optional in JSON exports. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private final String linkKey;
|
||||
}
|
||||
|
||||
@@ -50,18 +50,12 @@ public class TriggerPoint {
|
||||
this.sourceFile = sourceFile;
|
||||
this.sourceModule = sourceModule;
|
||||
this.stateMachineId = stateMachineId;
|
||||
this.sourceState = MachineEnumCanonicalizer.canonicalizeLabel(sourceState, stateTypeFqn);
|
||||
this.sourceState = sourceState;
|
||||
this.lineNumber = lineNumber;
|
||||
this.stateTypeFqn = stateTypeFqn;
|
||||
this.eventTypeFqn = eventTypeFqn;
|
||||
this.event = MachineEnumCanonicalizer.qualifyEventIdentifier(event, eventTypeFqn);
|
||||
if (polymorphicEvents != null) {
|
||||
this.polymorphicEvents = polymorphicEvents.stream()
|
||||
.map(pe -> MachineEnumCanonicalizer.qualifyEventIdentifier(pe, eventTypeFqn))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
} else {
|
||||
this.polymorphicEvents = null;
|
||||
}
|
||||
this.event = event;
|
||||
this.polymorphicEvents = polymorphicEvents;
|
||||
this.external = external;
|
||||
this.constraint = constraint;
|
||||
this.ambiguous = ambiguous;
|
||||
|
||||
@@ -14,6 +14,10 @@ public final class LibraryUnwrapRegistry {
|
||||
private static final Set<String> UNWRAP_METHOD_NAMES = Set.of(
|
||||
"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 = {
|
||||
"map", "parse", "convert", "to", "resolve", "build", "create", "from",
|
||||
"transform", "translate", "derive", "determine", "calculate", "decode",
|
||||
@@ -31,6 +35,14 @@ public final class LibraryUnwrapRegistry {
|
||||
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) {
|
||||
if (methodName == null) {
|
||||
return true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||
|
||||
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.Block;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
@@ -10,15 +11,11 @@ import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||
import org.eclipse.jdt.core.dom.SimpleName;
|
||||
import org.eclipse.jdt.core.dom.ASTNode;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Extracts terminal payload literals from reactive factory chains ({@code Mono.just}, nested {@code flatMap}).
|
||||
* Extracts terminal payload literals from reactive factory chains ({@code Mono.just}, nested transforms).
|
||||
*/
|
||||
public final class ReactiveExpressionSupport {
|
||||
|
||||
private static final Set<String> REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success");
|
||||
|
||||
private ReactiveExpressionSupport() {
|
||||
}
|
||||
|
||||
@@ -27,7 +24,46 @@ public final class ReactiveExpressionSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites {@code p.getEvent()} inside a {@code flatMap(p -> ...)} lambda to {@code payload.getEvent()}
|
||||
* 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()}
|
||||
* when {@code p} is fed by {@code Mono.just(payload)} (or a chained reactive source).
|
||||
*/
|
||||
public static String remapLambdaParameterGetter(
|
||||
@@ -44,14 +80,14 @@ public final class ReactiveExpressionSupport {
|
||||
if (lambda == null) {
|
||||
return null;
|
||||
}
|
||||
MethodInvocation flatMapCall = findEnclosingFlatMap(lambda);
|
||||
if (flatMapCall == null) {
|
||||
MethodInvocation transformCall = findEnclosingReactiveTransform(lambda);
|
||||
if (transformCall == null) {
|
||||
return null;
|
||||
}
|
||||
String mappedReceiver = mapLambdaParameterToSource(
|
||||
lambda,
|
||||
paramName.getIdentifier(),
|
||||
flatMapCall.getExpression(),
|
||||
transformCall.getExpression(),
|
||||
constantResolver,
|
||||
context);
|
||||
if (mappedReceiver == null) {
|
||||
@@ -69,7 +105,7 @@ public final class ReactiveExpressionSupport {
|
||||
String methodFqn,
|
||||
ConstantResolver constantResolver,
|
||||
CodebaseContext context) {
|
||||
MethodInvocation getter = findMethodInvocationInMethod(methodFqn, expressionText, context);
|
||||
MethodInvocation getter = AstUtils.findMethodInvocationInMethod(methodFqn, expressionText, context);
|
||||
if (getter == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -80,37 +116,6 @@ public final class ReactiveExpressionSupport {
|
||||
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) {
|
||||
ASTNode current = node.getParent();
|
||||
while (current != null) {
|
||||
@@ -122,10 +127,11 @@ public final class ReactiveExpressionSupport {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MethodInvocation findEnclosingFlatMap(ASTNode node) {
|
||||
private static MethodInvocation findEnclosingReactiveTransform(ASTNode node) {
|
||||
ASTNode current = node.getParent();
|
||||
while (current != null) {
|
||||
if (current instanceof MethodInvocation mi && "flatMap".equals(mi.getName().getIdentifier())) {
|
||||
if (current instanceof MethodInvocation mi
|
||||
&& LibraryUnwrapRegistry.isReactiveTransformMethod(mi.getName().getIdentifier())) {
|
||||
return mi;
|
||||
}
|
||||
current = current.getParent();
|
||||
@@ -143,14 +149,14 @@ public final class ReactiveExpressionSupport {
|
||||
}
|
||||
if (expression instanceof MethodInvocation mi) {
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
if ("flatMap".equals(methodName) && !mi.arguments().isEmpty()) {
|
||||
String fromArgument = extractFlatMapArgumentPayload(
|
||||
if (LibraryUnwrapRegistry.isReactiveTransformMethod(methodName) && !mi.arguments().isEmpty()) {
|
||||
String fromArgument = extractTransformArgumentPayload(
|
||||
(Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context);
|
||||
if (fromArgument != null) {
|
||||
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);
|
||||
if (constantResolver != null && context != null) {
|
||||
String resolved = constantResolver.resolve(arg, context);
|
||||
@@ -167,35 +173,35 @@ public final class ReactiveExpressionSupport {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String extractFlatMapArgumentPayload(
|
||||
private static String extractTransformArgumentPayload(
|
||||
Expression argument,
|
||||
Expression flatMapReceiver,
|
||||
Expression transformReceiver,
|
||||
ConstantResolver constantResolver,
|
||||
CodebaseContext context) {
|
||||
if (argument instanceof LambdaExpression lambda) {
|
||||
Expression bodyExpression = lambdaBodyExpression(lambda);
|
||||
if (bodyExpression != null) {
|
||||
String lambdaPayload = extractLambdaBodyPayload(
|
||||
bodyExpression, lambda, flatMapReceiver, constantResolver, context);
|
||||
bodyExpression, lambda, transformReceiver, constantResolver, context);
|
||||
if (lambdaPayload != null) {
|
||||
return lambdaPayload;
|
||||
}
|
||||
return extractPayload(bodyExpression, flatMapReceiver, constantResolver, context);
|
||||
return extractPayload(bodyExpression, transformReceiver, constantResolver, context);
|
||||
}
|
||||
}
|
||||
return extractPayload(argument, flatMapReceiver, constantResolver, context);
|
||||
return extractPayload(argument, transformReceiver, constantResolver, context);
|
||||
}
|
||||
|
||||
private static String extractLambdaBodyPayload(
|
||||
Expression bodyExpression,
|
||||
LambdaExpression lambda,
|
||||
Expression flatMapReceiver,
|
||||
Expression transformReceiver,
|
||||
ConstantResolver constantResolver,
|
||||
CodebaseContext context) {
|
||||
if (!(bodyExpression instanceof MethodInvocation factoryCall)) {
|
||||
return null;
|
||||
}
|
||||
if (!REACTIVE_FACTORY_METHODS.contains(factoryCall.getName().getIdentifier())
|
||||
if (!LibraryUnwrapRegistry.isReactiveFactoryMethod(factoryCall.getName().getIdentifier())
|
||||
|| factoryCall.arguments().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
@@ -207,7 +213,7 @@ public final class ReactiveExpressionSupport {
|
||||
Expression getterReceiver = getterCall.getExpression();
|
||||
if (getterReceiver instanceof SimpleName paramName) {
|
||||
String mappedReceiver = mapLambdaParameterToSource(
|
||||
lambda, paramName.getIdentifier(), flatMapReceiver, constantResolver, context);
|
||||
lambda, paramName.getIdentifier(), transformReceiver, constantResolver, context);
|
||||
if (mappedReceiver != null) {
|
||||
return mappedReceiver + getterSuffix;
|
||||
}
|
||||
@@ -221,7 +227,7 @@ public final class ReactiveExpressionSupport {
|
||||
private static String mapLambdaParameterToSource(
|
||||
LambdaExpression lambda,
|
||||
String paramName,
|
||||
Expression flatMapReceiver,
|
||||
Expression transformReceiver,
|
||||
ConstantResolver constantResolver,
|
||||
CodebaseContext context) {
|
||||
if (lambda.parameters().size() != 1) {
|
||||
@@ -234,36 +240,36 @@ public final class ReactiveExpressionSupport {
|
||||
if (!paramName.equals(variableDeclaration.getName().getIdentifier())) {
|
||||
return null;
|
||||
}
|
||||
if (flatMapReceiver instanceof MethodInvocation receiverFlatMap
|
||||
&& "flatMap".equals(receiverFlatMap.getName().getIdentifier())
|
||||
&& !receiverFlatMap.arguments().isEmpty()
|
||||
&& receiverFlatMap.arguments().get(0) instanceof LambdaExpression feederLambda
|
||||
if (transformReceiver instanceof MethodInvocation receiverTransform
|
||||
&& LibraryUnwrapRegistry.isReactiveTransformMethod(receiverTransform.getName().getIdentifier())
|
||||
&& !receiverTransform.arguments().isEmpty()
|
||||
&& receiverTransform.arguments().get(0) instanceof LambdaExpression feederLambda
|
||||
&& feederLambda != lambda) {
|
||||
Expression feederBody = lambdaBodyExpression(feederLambda);
|
||||
if (feederBody != null) {
|
||||
String feederPayload = extractLambdaBodyPayload(
|
||||
feederBody, feederLambda, receiverFlatMap.getExpression(), constantResolver, context);
|
||||
feederBody, feederLambda, receiverTransform.getExpression(), constantResolver, context);
|
||||
if (feederPayload != null) {
|
||||
return feederPayload;
|
||||
}
|
||||
String extracted = extractPayload(
|
||||
feederBody, receiverFlatMap.getExpression(), constantResolver, context);
|
||||
feederBody, receiverTransform.getExpression(), constantResolver, context);
|
||||
if (extracted != null) {
|
||||
return extracted;
|
||||
}
|
||||
}
|
||||
}
|
||||
Expression source = peelJustArgument(flatMapReceiver);
|
||||
Expression source = peelJustArgument(transformReceiver);
|
||||
if (source instanceof MethodInvocation getterMi
|
||||
&& getterMi.getExpression() instanceof SimpleName innerParamName) {
|
||||
LambdaExpression outerLambda = findEnclosingLambda(lambda);
|
||||
if (outerLambda != null && outerLambda != lambda) {
|
||||
MethodInvocation outerFlatMap = findEnclosingFlatMap(outerLambda);
|
||||
if (outerFlatMap != null) {
|
||||
MethodInvocation outerTransform = findEnclosingReactiveTransform(outerLambda);
|
||||
if (outerTransform != null) {
|
||||
String mappedBase = mapLambdaParameterToSource(
|
||||
outerLambda,
|
||||
innerParamName.getIdentifier(),
|
||||
outerFlatMap.getExpression(),
|
||||
outerTransform.getExpression(),
|
||||
constantResolver,
|
||||
context);
|
||||
if (mappedBase != null) {
|
||||
@@ -281,33 +287,17 @@ public final class ReactiveExpressionSupport {
|
||||
}
|
||||
return source.toString();
|
||||
}
|
||||
return extractPayload(flatMapReceiver, null, constantResolver, context);
|
||||
return extractPayload(transformReceiver, null, constantResolver, context);
|
||||
}
|
||||
|
||||
private static Expression peelJustArgument(Expression expression) {
|
||||
if (expression instanceof MethodInvocation mi
|
||||
&& REACTIVE_FACTORY_METHODS.contains(mi.getName().getIdentifier())
|
||||
&& !mi.arguments().isEmpty()) {
|
||||
return (Expression) mi.arguments().get(0);
|
||||
Expression peeled = peelFactoryPayloadExpression(expression);
|
||||
if (peeled != null) {
|
||||
return peeled;
|
||||
}
|
||||
if (expression instanceof MethodInvocation mi && mi.getExpression() != null) {
|
||||
return peelJustArgument(mi.getExpression());
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
@@ -58,6 +59,7 @@ public final class BooleanConstraintEvaluator {
|
||||
try {
|
||||
return parseExpression(expression.replaceAll("\\s+", ""));
|
||||
} catch (Exception e) {
|
||||
// Be conservative in pruning: if we can't parse the expression, do not discard paths.
|
||||
return !expression.contains("false");
|
||||
}
|
||||
}
|
||||
@@ -82,11 +84,191 @@ public final class BooleanConstraintEvaluator {
|
||||
}
|
||||
String expr = constraint;
|
||||
for (Map.Entry<String, String> entry : bindings.entrySet()) {
|
||||
if (!isConcreteBindingValue(entry.getKey(), entry.getValue())) {
|
||||
continue;
|
||||
}
|
||||
expr = substituteVariableBindings(expr, entry.getKey(), entry.getValue());
|
||||
expr = substituteEqualsLiteralBindings(expr, entry.getKey(), entry.getValue());
|
||||
}
|
||||
return evaluateBooleanExpression(expr);
|
||||
}
|
||||
|
||||
private static boolean isConcreteBindingValue(String paramName, String boundValue) {
|
||||
if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) {
|
||||
return false;
|
||||
}
|
||||
if (boundValue.contains("(") && !boundValue.contains("valueOf")) {
|
||||
return false;
|
||||
}
|
||||
if (boundValue.startsWith("\"") && boundValue.endsWith("\"")) {
|
||||
return true;
|
||||
}
|
||||
if ("true".equalsIgnoreCase(boundValue) || "false".equalsIgnoreCase(boundValue)) {
|
||||
return true;
|
||||
}
|
||||
if (boundValue.contains(".") && Character.isUpperCase(boundValue.charAt(boundValue.lastIndexOf('.') + 1))) {
|
||||
return true;
|
||||
}
|
||||
// Treat simple routing keys (e.g. order.pay) as concrete string bindings, but avoid
|
||||
// mistaking variable names (e.g. machineType) for concrete values.
|
||||
if ((boundValue.contains(".") || boundValue.contains("/") || boundValue.contains("-"))
|
||||
&& boundValue.matches("^[a-zA-Z0-9._/-]+$")) {
|
||||
return true;
|
||||
}
|
||||
return boundValue.equals(boundValue.toUpperCase(Locale.ROOT)) && boundValue.chars().allMatch(ch ->
|
||||
Character.isUpperCase(ch) || ch == '_');
|
||||
}
|
||||
|
||||
private static String substituteEqualsLiteralBindings(String expr, String varName, String boundValue) {
|
||||
if (boundValue == null || boundValue.isBlank()) {
|
||||
return expr;
|
||||
}
|
||||
String cleanValue = boundValue;
|
||||
if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) {
|
||||
cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
|
||||
}
|
||||
|
||||
String literalPattern = Pattern.quote(cleanValue);
|
||||
String varToken = Pattern.quote(varName);
|
||||
String argumentEquals = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||
+ literalPattern + "[\"']?\\s*\\)";
|
||||
expr = replaceReceiverEqualsForVariable(expr, varName, cleanValue, "true");
|
||||
expr = replaceObjectsEqualsForVariable(expr, varName, cleanValue, "true");
|
||||
expr = expr.replaceAll(argumentEquals, "true");
|
||||
|
||||
Set<String> allLiterals = extractStringLiterals(expr);
|
||||
for (String literal : allLiterals) {
|
||||
if (literal.equalsIgnoreCase(cleanValue)) {
|
||||
continue;
|
||||
}
|
||||
String otherLiteral = Pattern.quote(literal);
|
||||
String otherArgument = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||
+ otherLiteral + "[\"']?\\s*\\)";
|
||||
expr = replaceReceiverEqualsForVariable(expr, varName, literal, "false");
|
||||
expr = replaceObjectsEqualsForVariable(expr, varName, literal, "false");
|
||||
expr = expr.replaceAll(otherArgument, "false");
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
private static String replaceObjectsEqualsForVariable(
|
||||
String expr, String varName, String literalValue, String replacement) {
|
||||
Pattern head = Pattern.compile("(?is)(?:java\\.util\\.)?Objects\\.equals\\s*\\(");
|
||||
Matcher matcher = head.matcher(expr);
|
||||
StringBuilder result = new StringBuilder();
|
||||
int cursor = 0;
|
||||
while (matcher.find()) {
|
||||
int matchStart = matcher.start();
|
||||
int openParen = matcher.end() - 1;
|
||||
int closeParen = findMatchingCloseParen(expr, openParen);
|
||||
if (closeParen < 0) {
|
||||
break;
|
||||
}
|
||||
String[] args = splitTopLevelComma(expr.substring(openParen + 1, closeParen));
|
||||
result.append(expr, cursor, matchStart);
|
||||
if (args != null
|
||||
&& args.length == 2
|
||||
&& objectsEqualsArgumentMatches(args[0], args[1], varName, literalValue)) {
|
||||
result.append(replacement);
|
||||
} else {
|
||||
result.append(expr, matchStart, closeParen + 1);
|
||||
}
|
||||
cursor = closeParen + 1;
|
||||
}
|
||||
result.append(expr.substring(cursor));
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static boolean objectsEqualsArgumentMatches(
|
||||
String left, String right, String varName, String literalValue) {
|
||||
String leftLiteral = stripConstraintLiteral(left);
|
||||
String rightLiteral = stripConstraintLiteral(right);
|
||||
if (leftLiteral != null
|
||||
&& leftLiteral.equalsIgnoreCase(literalValue)
|
||||
&& constraintArgumentReferencesVariable(right, varName)) {
|
||||
return true;
|
||||
}
|
||||
return rightLiteral != null
|
||||
&& rightLiteral.equalsIgnoreCase(literalValue)
|
||||
&& constraintArgumentReferencesVariable(left, varName);
|
||||
}
|
||||
|
||||
private static String stripConstraintLiteral(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.length() >= 2
|
||||
&& ((trimmed.startsWith("\"") && trimmed.endsWith("\""))
|
||||
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
|
||||
return trimmed.substring(1, trimmed.length() - 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean constraintArgumentReferencesVariable(String argument, String varName) {
|
||||
return argument != null && argument.matches("(?s).*\\b" + Pattern.quote(varName) + "\\b.*");
|
||||
}
|
||||
|
||||
private static String[] splitTopLevelComma(String args) {
|
||||
int depth = 0;
|
||||
for (int i = 0; i < args.length(); i++) {
|
||||
char c = args.charAt(i);
|
||||
if (c == '(') {
|
||||
depth++;
|
||||
} else if (c == ')') {
|
||||
depth--;
|
||||
} else if (c == ',' && depth == 0) {
|
||||
return new String[] {args.substring(0, i), args.substring(i + 1)};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String replaceReceiverEqualsForVariable(
|
||||
String expr, String varName, String literalValue, String replacement) {
|
||||
Pattern head = Pattern.compile(
|
||||
"(?is)[\"']?" + Pattern.quote(literalValue) + "[\"']?\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(");
|
||||
Matcher matcher = head.matcher(expr);
|
||||
StringBuilder result = new StringBuilder();
|
||||
int cursor = 0;
|
||||
while (matcher.find()) {
|
||||
int matchStart = matcher.start();
|
||||
int openParen = matcher.end() - 1;
|
||||
int closeParen = findMatchingCloseParen(expr, openParen);
|
||||
if (closeParen < 0) {
|
||||
break;
|
||||
}
|
||||
String argument = expr.substring(openParen + 1, closeParen).trim();
|
||||
result.append(expr, cursor, matchStart);
|
||||
if (argument.equals(varName)
|
||||
|| argument.matches("(?s).*\\b" + Pattern.quote(varName) + "\\b.*")) {
|
||||
result.append(replacement);
|
||||
} else {
|
||||
result.append(expr, matchStart, closeParen + 1);
|
||||
}
|
||||
cursor = closeParen + 1;
|
||||
}
|
||||
result.append(expr.substring(cursor));
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static int findMatchingCloseParen(String expr, int openIdx) {
|
||||
int depth = 0;
|
||||
for (int i = openIdx; i < expr.length(); i++) {
|
||||
char c = expr.charAt(i);
|
||||
if (c == '(') {
|
||||
depth++;
|
||||
} else if (c == ')') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static Set<String> extractEqualityVariables(String constraint) {
|
||||
Set<String> vars = new HashSet<>();
|
||||
Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*==");
|
||||
@@ -105,7 +287,6 @@ public final class BooleanConstraintEvaluator {
|
||||
if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) {
|
||||
cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
|
||||
}
|
||||
String suffix = cleanValue.contains(".") ? cleanValue.substring(cleanValue.lastIndexOf('.') + 1) : cleanValue;
|
||||
|
||||
Pattern eqPattern = Pattern.compile(
|
||||
"(?i)" + Pattern.quote(varName) + "\\s*==\\s*([\\w.\"']+)");
|
||||
@@ -113,16 +294,83 @@ public final class BooleanConstraintEvaluator {
|
||||
StringBuffer sb = new StringBuffer();
|
||||
while (matcher.find()) {
|
||||
String rhs = matcher.group(1).replace("\"", "").replace("'", "");
|
||||
boolean matches = cleanValue.equals(rhs)
|
||||
|| cleanValue.endsWith("." + rhs)
|
||||
|| suffix.equalsIgnoreCase(rhs)
|
||||
|| cleanValue.equalsIgnoreCase(rhs);
|
||||
boolean matches = constraintValuesMatch(cleanValue, rhs);
|
||||
matcher.appendReplacement(sb, matches ? "true" : "false");
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
if ("true".equalsIgnoreCase(cleanValue) || "false".equalsIgnoreCase(cleanValue)) {
|
||||
Pattern bareVar = Pattern.compile("(?<![\\w.])" + Pattern.quote(varName) + "(?![\\w])");
|
||||
return bareVar.matcher(expr).replaceAll(cleanValue.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static boolean constraintValuesMatch(String boundValue, String rhs) {
|
||||
if (boundValue == null || rhs == null) {
|
||||
return false;
|
||||
}
|
||||
String bound = stripOuterQuotes(boundValue);
|
||||
String rhsClean = stripOuterQuotes(rhs);
|
||||
if (bound.equals(rhsClean) || bound.equalsIgnoreCase(rhsClean)) {
|
||||
return true;
|
||||
}
|
||||
if (bound.endsWith("." + rhsClean) || rhsClean.endsWith("." + bound)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String boundConstant = enumConstantName(bound);
|
||||
String rhsConstant = enumConstantName(rhsClean);
|
||||
if (!boundConstant.equalsIgnoreCase(rhsConstant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String boundType = enumTypePart(bound);
|
||||
String rhsType = enumTypePart(rhsClean);
|
||||
if (boundType == null || rhsType == null) {
|
||||
return true;
|
||||
}
|
||||
if (boundType.equals(rhsType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String boundSimple = simpleTypeName(boundType);
|
||||
String rhsSimple = simpleTypeName(rhsType);
|
||||
if (!boundSimple.equals(rhsSimple)) {
|
||||
return false;
|
||||
}
|
||||
boolean boundImportStyle = !boundType.contains(".");
|
||||
boolean rhsImportStyle = !rhsType.contains(".");
|
||||
return boundImportStyle || rhsImportStyle;
|
||||
}
|
||||
|
||||
private static String stripOuterQuotes(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
|
||||
return value.substring(1, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String enumConstantName(String ref) {
|
||||
int dot = ref.lastIndexOf('.');
|
||||
return dot >= 0 ? ref.substring(dot + 1) : ref;
|
||||
}
|
||||
|
||||
private static String enumTypePart(String ref) {
|
||||
int dot = ref.lastIndexOf('.');
|
||||
if (dot <= 0) {
|
||||
return null;
|
||||
}
|
||||
return ref.substring(0, dot);
|
||||
}
|
||||
|
||||
private static String simpleTypeName(String typePart) {
|
||||
int dot = typePart.lastIndexOf('.');
|
||||
return dot >= 0 ? typePart.substring(dot + 1) : typePart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true}/{@code false} when both sides are compile-time string literals; otherwise {@code null}.
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
@@ -850,7 +849,7 @@ public class ConstantResolver {
|
||||
}
|
||||
}
|
||||
|
||||
for (String classFqn : classesFromCurrentCallPath()) {
|
||||
for (String classFqn : classesFromCurrentCallPath(context)) {
|
||||
TypeDeclaration pathTd = context.getTypeDeclaration(classFqn);
|
||||
if (pathTd != null) {
|
||||
String result = resolveFieldInType(pathTd, sn.getIdentifier(), classFqn, context, visited);
|
||||
@@ -922,7 +921,7 @@ public class ConstantResolver {
|
||||
if (td != null) {
|
||||
return td;
|
||||
}
|
||||
String entryClass = getCurrentCallPathEntryClass();
|
||||
String entryClass = getCurrentCallPathEntryClass(context);
|
||||
if (entryClass == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -934,15 +933,15 @@ public class ConstantResolver {
|
||||
}
|
||||
|
||||
private TypeDeclaration typeFromCurrentCallPath(CodebaseContext context) {
|
||||
List<String> classes = classesFromCurrentCallPath();
|
||||
List<String> classes = classesFromCurrentCallPath(context);
|
||||
if (classes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return context.getTypeDeclaration(classes.get(0));
|
||||
}
|
||||
|
||||
private List<String> classesFromCurrentCallPath() {
|
||||
List<String> path = JdtDataFlowModel.getCurrentPath();
|
||||
private List<String> classesFromCurrentCallPath(CodebaseContext context) {
|
||||
List<String> path = context.getAnalysisCallPath();
|
||||
if (path == null || path.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -960,8 +959,8 @@ public class ConstantResolver {
|
||||
return classes;
|
||||
}
|
||||
|
||||
private String getCurrentCallPathEntryClass() {
|
||||
List<String> classes = classesFromCurrentCallPath();
|
||||
private String getCurrentCallPathEntryClass(CodebaseContext context) {
|
||||
List<String> classes = classesFromCurrentCallPath(context);
|
||||
return classes.isEmpty() ? null : classes.get(classes.size() - 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,34 @@ public final class EnumMemberPredicateEvaluator {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every predicate method in the constraint exists on the enum (or its interfaces).
|
||||
* Used to distinguish inconclusive filtering from an intentional empty result.
|
||||
*/
|
||||
public static boolean hasResolvablePredicateMethods(
|
||||
String constraint,
|
||||
String enumTypeFqn,
|
||||
CodebaseContext context) {
|
||||
List<PredicateCall> predicates = extractPredicateCalls(constraint);
|
||||
if (predicates.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
EnumDeclaration enumDecl = findEnumDeclaration(enumTypeFqn, context);
|
||||
if (enumDecl == null) {
|
||||
return false;
|
||||
}
|
||||
for (PredicateCall predicate : predicates) {
|
||||
MethodDeclaration method = findParameterlessMethod(enumDecl, predicate.methodName());
|
||||
if (method == null && context != null) {
|
||||
method = findInterfaceParameterlessMethod(enumDecl, predicate.methodName(), context);
|
||||
}
|
||||
if (method == null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean satisfiesPredicates(
|
||||
String canonicalConstantFqn,
|
||||
List<PredicateCall> predicates,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
@@ -29,38 +30,53 @@ public final class MachineEnumCanonicalizer {
|
||||
public static void canonicalizeTransitions(
|
||||
List<Transition> transitions,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
canonicalizeTransitions(transitions, machineTypes, null);
|
||||
}
|
||||
|
||||
public static void canonicalizeTransitions(
|
||||
List<Transition> transitions,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context) {
|
||||
if (transitions == null || machineTypes == null) {
|
||||
return;
|
||||
}
|
||||
for (Transition transition : transitions) {
|
||||
if (transition.getEvent() != null) {
|
||||
transition.setEvent(canonicalizeEvent(transition.getEvent(), machineTypes.eventTypeFqn()));
|
||||
transition.setEvent(canonicalizeEvent(transition.getEvent(), machineTypes.eventTypeFqn(), context));
|
||||
}
|
||||
if (transition.getSourceStates() != null) {
|
||||
transition.setSourceStates(canonicalizeStates(transition.getSourceStates(), machineTypes.stateTypeFqn()));
|
||||
transition.setSourceStates(canonicalizeStates(transition.getSourceStates(), machineTypes.stateTypeFqn(), context));
|
||||
}
|
||||
if (transition.getTargetStates() != null) {
|
||||
transition.setTargetStates(canonicalizeStates(transition.getTargetStates(), machineTypes.stateTypeFqn()));
|
||||
transition.setTargetStates(canonicalizeStates(transition.getTargetStates(), machineTypes.stateTypeFqn(), context));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static Set<String> canonicalizeStateLabels(Set<String> labels, String stateTypeFqn) {
|
||||
return canonicalizeStateLabels(labels, stateTypeFqn, null);
|
||||
}
|
||||
|
||||
public static Set<String> canonicalizeStateLabels(Set<String> labels, String stateTypeFqn, CodebaseContext context) {
|
||||
if (labels == null || labels.isEmpty()) {
|
||||
return labels;
|
||||
}
|
||||
return labels.stream()
|
||||
.map(label -> canonicalizeLabel(label, stateTypeFqn))
|
||||
.map(label -> canonicalizeLabel(label, stateTypeFqn, context))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
public static Set<State> canonicalizeStates(Set<State> states, String stateTypeFqn) {
|
||||
return canonicalizeStates(states, stateTypeFqn, null);
|
||||
}
|
||||
|
||||
public static Set<State> canonicalizeStates(Set<State> states, String stateTypeFqn, CodebaseContext context) {
|
||||
if (states == null || states.isEmpty()) {
|
||||
return states;
|
||||
}
|
||||
LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>();
|
||||
for (State state : states) {
|
||||
State canonical = canonicalizeState(state, stateTypeFqn);
|
||||
State canonical = canonicalizeState(state, stateTypeFqn, context);
|
||||
byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical);
|
||||
}
|
||||
return new LinkedHashSet<>(byFullIdentifier.values());
|
||||
@@ -69,6 +85,13 @@ public final class MachineEnumCanonicalizer {
|
||||
public static TriggerPoint canonicalizeTriggerPoint(
|
||||
TriggerPoint trigger,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
return canonicalizeTriggerPoint(trigger, machineTypes, null);
|
||||
}
|
||||
|
||||
public static TriggerPoint canonicalizeTriggerPoint(
|
||||
TriggerPoint trigger,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -81,14 +104,45 @@ public final class MachineEnumCanonicalizer {
|
||||
|
||||
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
|
||||
: trigger.getPolymorphicEvents().stream()
|
||||
.map(event -> canonicalizeLabel(event, eventTypeFqn))
|
||||
.map(event -> canonicalizeLabel(event, eventTypeFqn, context))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return trigger.toBuilder()
|
||||
.eventTypeFqn(eventTypeFqn)
|
||||
.stateTypeFqn(stateTypeFqn)
|
||||
.event(canonicalizeLabel(trigger.getEvent(), eventTypeFqn))
|
||||
.sourceState(canonicalizeLabel(trigger.getSourceState(), stateTypeFqn))
|
||||
.event(canonicalizeLabel(trigger.getEvent(), eventTypeFqn, context))
|
||||
.sourceState(canonicalizeLabel(trigger.getSourceState(), stateTypeFqn, context))
|
||||
.polymorphicEvents(polymorphicEvents)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalizes trigger labels for linking using machine types, but preserves the trigger's own
|
||||
* {@code eventTypeFqn}/{@code stateTypeFqn} so routing evidence (e.g. shared-infrastructure
|
||||
* detection) is not overwritten before machine affinity is resolved.
|
||||
*/
|
||||
public static TriggerPoint canonicalizeTriggerLabelsForLinking(
|
||||
TriggerPoint trigger,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null) {
|
||||
return null;
|
||||
}
|
||||
String eventTypeFqnForLabels = preferFullTypeFqn(
|
||||
trigger.getEventTypeFqn(),
|
||||
machineTypes != null ? machineTypes.eventTypeFqn() : null);
|
||||
String stateTypeFqnForLabels = preferFullTypeFqn(
|
||||
trigger.getStateTypeFqn(),
|
||||
machineTypes != null ? machineTypes.stateTypeFqn() : null);
|
||||
|
||||
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
|
||||
: trigger.getPolymorphicEvents().stream()
|
||||
.map(event -> canonicalizeLabel(event, eventTypeFqnForLabels, context))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return trigger.toBuilder()
|
||||
.event(canonicalizeLabel(trigger.getEvent(), eventTypeFqnForLabels, context))
|
||||
.sourceState(canonicalizeLabel(trigger.getSourceState(), stateTypeFqnForLabels, context))
|
||||
.polymorphicEvents(polymorphicEvents)
|
||||
.build();
|
||||
}
|
||||
@@ -101,6 +155,14 @@ public final class MachineEnumCanonicalizer {
|
||||
List<String> polymorphicEvents,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
return expandSymbolicPolymorphicEvents(polymorphicEvents, machineEventTypeFqn, context, null);
|
||||
}
|
||||
|
||||
public static List<String> expandSymbolicPolymorphicEvents(
|
||||
List<String> polymorphicEvents,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
|
||||
return polymorphicEvents;
|
||||
}
|
||||
@@ -108,6 +170,8 @@ public final class MachineEnumCanonicalizer {
|
||||
return polymorphicEvents;
|
||||
}
|
||||
|
||||
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, machineEventTypeFqn, context);
|
||||
|
||||
List<String> expanded = new ArrayList<>();
|
||||
for (String pe : polymorphicEvents) {
|
||||
if (pe == null) {
|
||||
@@ -115,7 +179,18 @@ public final class MachineEnumCanonicalizer {
|
||||
}
|
||||
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
|
||||
String symbolicType = pe.substring("<SYMBOLIC: ".length(), pe.length() - 3).trim();
|
||||
if (!enumTypesMatch(machineEventTypeFqn, symbolicType)) {
|
||||
if (context != null && context.isAmbiguousSimpleName(symbolicType)) {
|
||||
continue;
|
||||
}
|
||||
if (!enumTypesMatch(machineEventTypeFqn, symbolicType, context)) {
|
||||
continue;
|
||||
}
|
||||
if (transitionEvents.size() == 1) {
|
||||
expanded.addAll(transitionEvents);
|
||||
continue;
|
||||
}
|
||||
if (!transitionEvents.isEmpty()) {
|
||||
// Multiple configured transitions: do not widen symbolic placeholders to all events.
|
||||
continue;
|
||||
}
|
||||
if (context != null) {
|
||||
@@ -141,7 +216,7 @@ public final class MachineEnumCanonicalizer {
|
||||
TriggerPoint trigger,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context) {
|
||||
TriggerPoint canonical = canonicalizeTriggerPoint(trigger, machineTypes);
|
||||
TriggerPoint canonical = canonicalizeTriggerPoint(trigger, machineTypes, context);
|
||||
if (canonical == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
|
||||
return canonical;
|
||||
}
|
||||
@@ -172,7 +247,7 @@ public final class MachineEnumCanonicalizer {
|
||||
return polymorphicEvents;
|
||||
}
|
||||
return narrowPolymorphicCandidates(
|
||||
polymorphicEvents, constraint, eventTypeFqn, machineTransitions, context);
|
||||
polymorphicEvents, constraint, eventTypeFqn, machineTransitions, context, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,7 +277,7 @@ public final class MachineEnumCanonicalizer {
|
||||
List<Transition> machineTransitions,
|
||||
boolean skipCanonicalization) {
|
||||
TriggerPoint expanded = skipCanonicalization
|
||||
? expandSymbolicOnly(trigger, machineTypes, context)
|
||||
? expandSymbolicOnly(trigger, machineTypes, context, machineTransitions)
|
||||
: canonicalizeAndExpandTriggerPoint(trigger, machineTypes, context);
|
||||
if (expanded == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
|
||||
return expanded;
|
||||
@@ -210,7 +285,7 @@ public final class MachineEnumCanonicalizer {
|
||||
List<String> postExpand = expanded.getPolymorphicEvents();
|
||||
if (postExpand != null && postExpand.stream().anyMatch(pe -> pe != null && pe.startsWith("<SYMBOLIC:"))) {
|
||||
postExpand = expandSymbolicPolymorphicEvents(
|
||||
postExpand, machineTypes.eventTypeFqn(), context);
|
||||
postExpand, machineTypes.eventTypeFqn(), context, machineTransitions);
|
||||
postExpand = narrowExpandedPolymorphicEvents(
|
||||
postExpand,
|
||||
expanded.getConstraint(),
|
||||
@@ -221,27 +296,30 @@ public final class MachineEnumCanonicalizer {
|
||||
expanded = expanded.toBuilder().polymorphicEvents(postExpand).build();
|
||||
}
|
||||
}
|
||||
if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())) {
|
||||
if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())
|
||||
|| (expanded.getPolymorphicEvents() != null && !expanded.getPolymorphicEvents().isEmpty())) {
|
||||
List<String> narrowed = narrowPolymorphicCandidates(
|
||||
expanded.getPolymorphicEvents(),
|
||||
expanded.getConstraint(),
|
||||
machineTypes.eventTypeFqn(),
|
||||
machineTransitions,
|
||||
context);
|
||||
context,
|
||||
expanded);
|
||||
if (!narrowed.equals(expanded.getPolymorphicEvents())) {
|
||||
return expanded.toBuilder()
|
||||
.polymorphicEvents(narrowed)
|
||||
.ambiguous(narrowed.size() > 1)
|
||||
.ambiguous(narrowed.size() > 1 && expanded.isAmbiguous())
|
||||
.build();
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
if (shouldInferPolymorphicEvents(expanded, machineTransitions)) {
|
||||
if (shouldInferPolymorphicEvents(expanded, machineTransitions, context)) {
|
||||
List<String> machineEvents = inferPolymorphicCandidates(
|
||||
expanded.getConstraint(),
|
||||
machineTypes.eventTypeFqn(),
|
||||
machineTransitions,
|
||||
context);
|
||||
context,
|
||||
expanded);
|
||||
if (!machineEvents.isEmpty()) {
|
||||
return expanded.toBuilder()
|
||||
.polymorphicEvents(machineEvents)
|
||||
@@ -255,12 +333,13 @@ public final class MachineEnumCanonicalizer {
|
||||
private static TriggerPoint expandSymbolicOnly(
|
||||
TriggerPoint trigger,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context) {
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
if (trigger == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
|
||||
return trigger;
|
||||
}
|
||||
List<String> expanded = expandSymbolicPolymorphicEvents(
|
||||
trigger.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context);
|
||||
trigger.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context, machineTransitions);
|
||||
if (java.util.Objects.equals(expanded, trigger.getPolymorphicEvents())) {
|
||||
return trigger;
|
||||
}
|
||||
@@ -269,11 +348,15 @@ public final class MachineEnumCanonicalizer {
|
||||
|
||||
private static boolean shouldInferPolymorphicEvents(
|
||||
TriggerPoint trigger,
|
||||
List<Transition> machineTransitions) {
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null || machineTransitions == null || machineTransitions.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (hasOnlySymbolicPolymorphicEvents(trigger.getPolymorphicEvents())) {
|
||||
if (context != null && hasAmbiguousSymbolicPolymorphicType(trigger.getPolymorphicEvents(), context)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
List<String> polyEvents = trigger.getPolymorphicEvents();
|
||||
@@ -283,12 +366,32 @@ public final class MachineEnumCanonicalizer {
|
||||
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(trigger.getConstraint())) {
|
||||
return true;
|
||||
}
|
||||
if (trigger.isExternal() || trigger.isAmbiguous()) {
|
||||
if (trigger.isExternal()) {
|
||||
return true;
|
||||
}
|
||||
if (trigger.isAmbiguous()) {
|
||||
return false;
|
||||
}
|
||||
return classifyTriggerEvent(trigger.getEvent()) == TriggerEventKind.DYNAMIC_EXPRESSION;
|
||||
}
|
||||
|
||||
private static boolean hasAmbiguousSymbolicPolymorphicType(
|
||||
List<String> polymorphicEvents,
|
||||
CodebaseContext context) {
|
||||
if (polymorphicEvents == null) {
|
||||
return false;
|
||||
}
|
||||
for (String pe : polymorphicEvents) {
|
||||
if (pe != null && pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
|
||||
String symbolicType = pe.substring("<SYMBOLIC: ".length(), pe.length() - 3).trim();
|
||||
if (context.isAmbiguousSimpleName(symbolicType)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasOnlySymbolicPolymorphicEvents(List<String> polymorphicEvents) {
|
||||
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
|
||||
return false;
|
||||
@@ -313,24 +416,29 @@ public final class MachineEnumCanonicalizer {
|
||||
String constraint,
|
||||
String eventTypeFqn,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn);
|
||||
if (!transitionEvents.isEmpty()) {
|
||||
CodebaseContext context,
|
||||
TriggerPoint trigger) {
|
||||
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context);
|
||||
if (transitionEvents.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) {
|
||||
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
|
||||
transitionEvents, constraint, eventTypeFqn, context);
|
||||
return filtered.isEmpty() ? transitionEvents : filtered;
|
||||
}
|
||||
List<String> enumConstants = allPackageCanonicalEnumConstants(eventTypeFqn, context);
|
||||
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
|
||||
enumConstants, constraint, eventTypeFqn, context);
|
||||
if (!filtered.isEmpty()) {
|
||||
return filtered;
|
||||
}
|
||||
if (!transitionEvents.isEmpty()) {
|
||||
return transitionEvents;
|
||||
if (trigger != null) {
|
||||
List<String> richFiltered = RichEventPredicateEvaluator.filterEnumConstants(
|
||||
transitionEvents, constraint, eventTypeFqn, trigger, context);
|
||||
if (!richFiltered.isEmpty()) {
|
||||
return richFiltered;
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
return transitionEvents.size() == 1 ? transitionEvents : List.of();
|
||||
}
|
||||
|
||||
private static List<String> narrowPolymorphicCandidates(
|
||||
List<String> current,
|
||||
@@ -338,62 +446,124 @@ public final class MachineEnumCanonicalizer {
|
||||
String eventTypeFqn,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn);
|
||||
List<String> result = EnumMemberPredicateEvaluator.filterEnumConstants(
|
||||
current, constraint, eventTypeFqn, context);
|
||||
if (result.isEmpty() && !transitionEvents.isEmpty()) {
|
||||
result = new ArrayList<>(transitionEvents);
|
||||
return narrowPolymorphicCandidates(
|
||||
current, constraint, eventTypeFqn, machineTransitions, context, null);
|
||||
}
|
||||
if (!looksOverBroad(result, transitionEvents, constraint)) {
|
||||
return result;
|
||||
}
|
||||
if (!transitionEvents.isEmpty()) {
|
||||
List<String> intersected = new ArrayList<>();
|
||||
for (String event : result) {
|
||||
if (transitionEvents.contains(event)) {
|
||||
intersected.add(event);
|
||||
}
|
||||
}
|
||||
if (!intersected.isEmpty()) {
|
||||
return intersected;
|
||||
}
|
||||
List<String> transitionFiltered = EnumMemberPredicateEvaluator.filterEnumConstants(
|
||||
|
||||
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> result = current == null ? List.of() : new ArrayList<>(current);
|
||||
|
||||
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) {
|
||||
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
|
||||
result, constraint, eventTypeFqn, context);
|
||||
if (!filtered.isEmpty()) {
|
||||
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);
|
||||
return transitionFiltered.isEmpty() ? transitionEvents : transitionFiltered;
|
||||
if (!filtered.isEmpty()) {
|
||||
result = filtered;
|
||||
} else {
|
||||
result = List.of();
|
||||
}
|
||||
} else {
|
||||
result = List.of();
|
||||
}
|
||||
} else if (!transitionEvents.isEmpty()) {
|
||||
filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
|
||||
transitionEvents, constraint, eventTypeFqn, context);
|
||||
if (!filtered.isEmpty()) {
|
||||
result = filtered;
|
||||
} else {
|
||||
result = List.of();
|
||||
}
|
||||
} else {
|
||||
result = List.of();
|
||||
}
|
||||
}
|
||||
|
||||
if (!transitionEvents.isEmpty()) {
|
||||
result = capToConfiguredTransitionEvents(result, transitionEvents, eventTypeFqn, context);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean looksOverBroad(
|
||||
List<String> current,
|
||||
/**
|
||||
* Fail-closed ceiling: polymorphic candidates must never exceed events configured on the machine.
|
||||
*/
|
||||
static List<String> capToConfiguredTransitionEvents(
|
||||
List<String> candidates,
|
||||
List<String> transitionEvents,
|
||||
String constraint) {
|
||||
if (current == null || current.isEmpty()) {
|
||||
return false;
|
||||
String eventTypeFqn) {
|
||||
return capToConfiguredTransitionEvents(candidates, transitionEvents, eventTypeFqn, null);
|
||||
}
|
||||
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) {
|
||||
return true;
|
||||
|
||||
static List<String> capToConfiguredTransitionEvents(
|
||||
List<String> candidates,
|
||||
List<String> transitionEvents,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (transitionEvents == null || transitionEvents.isEmpty()) {
|
||||
return candidates == null ? List.of() : candidates;
|
||||
}
|
||||
if (current.size() <= 1) {
|
||||
return false;
|
||||
if (candidates == null || candidates.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
if (transitionEvents.isEmpty()) {
|
||||
return current.size() > 1;
|
||||
List<String> capped = new ArrayList<>();
|
||||
for (String candidate : candidates) {
|
||||
if (candidate == null) {
|
||||
continue;
|
||||
}
|
||||
if (current.size() > transitionEvents.size()) {
|
||||
return true;
|
||||
if (transitionEvents.contains(candidate)) {
|
||||
capped.add(candidate);
|
||||
continue;
|
||||
}
|
||||
for (String event : current) {
|
||||
if (!transitionEvents.contains(event)) {
|
||||
return true;
|
||||
String candidateType = enumTypeFromRef(candidate);
|
||||
String constant = constantName(candidate);
|
||||
for (String transitionEvent : transitionEvents) {
|
||||
if (!constantName(transitionEvent).equals(constant)) {
|
||||
continue;
|
||||
}
|
||||
String transitionType = enumTypeFromRef(transitionEvent);
|
||||
if (candidate.contains(".") && transitionEvent.contains(".")) {
|
||||
if (candidateType != null && transitionType != null
|
||||
&& enumTypesMatch(candidateType, transitionType, context)) {
|
||||
capped.add(transitionEvent);
|
||||
break;
|
||||
}
|
||||
} else if (eventTypeFqn != null && transitionType != null
|
||||
&& enumTypesMatch(eventTypeFqn, transitionType, context)) {
|
||||
capped.add(transitionEvent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return capped;
|
||||
}
|
||||
|
||||
public static List<String> polymorphicEventsFromTransitions(
|
||||
List<Transition> machineTransitions,
|
||||
String eventTypeFqn) {
|
||||
return polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, null);
|
||||
}
|
||||
|
||||
public static List<String> polymorphicEventsFromTransitions(
|
||||
List<Transition> machineTransitions,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (machineTransitions == null || machineTransitions.isEmpty() || eventTypeFqn == null) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -408,7 +578,7 @@ public final class MachineEnumCanonicalizer {
|
||||
if (identifier == null || identifier.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String canonical = canonicalizeLabel(identifier, eventTypeFqn);
|
||||
String canonical = canonicalizeLabel(identifier, eventTypeFqn, context);
|
||||
if (classifyTriggerEvent(canonical) == TriggerEventKind.CANONICAL_ENUM
|
||||
&& !events.contains(canonical)) {
|
||||
events.add(canonical);
|
||||
@@ -423,24 +593,11 @@ public final class MachineEnumCanonicalizer {
|
||||
.anyMatch(pe -> classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM);
|
||||
}
|
||||
|
||||
private static List<String> allPackageCanonicalEnumConstants(String eventTypeFqn, CodebaseContext context) {
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank() || context == null) {
|
||||
return List.of();
|
||||
}
|
||||
String enumType = stripGenerics(eventTypeFqn);
|
||||
List<String> enumValues = context.getEnumValues(enumType);
|
||||
if (enumValues == null || enumValues.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> canonical = new ArrayList<>();
|
||||
for (String value : enumValues) {
|
||||
String constant = value.contains(".") ? value.substring(value.lastIndexOf('.') + 1) : value;
|
||||
canonical.add(enumType + "." + constant);
|
||||
}
|
||||
return canonical;
|
||||
public static String qualifyEventIdentifier(String event, String eventTypeFqn) {
|
||||
return qualifyEventIdentifier(event, eventTypeFqn, null);
|
||||
}
|
||||
|
||||
public static String qualifyEventIdentifier(String event, String eventTypeFqn) {
|
||||
public static String qualifyEventIdentifier(String event, String eventTypeFqn, CodebaseContext context) {
|
||||
if (event == null || eventTypeFqn == null || event.isEmpty()) {
|
||||
return event;
|
||||
}
|
||||
@@ -462,8 +619,8 @@ public final class MachineEnumCanonicalizer {
|
||||
if (isStringOrPrimitiveType(eventTypeFqn)) {
|
||||
return event;
|
||||
}
|
||||
if (isMachineEnumReference(event, eventTypeFqn)) {
|
||||
return canonicalizeLabel(event, stripGenerics(eventTypeFqn));
|
||||
if (isMachineEnumReference(event, eventTypeFqn, context)) {
|
||||
return canonicalizeLabel(event, stripGenerics(eventTypeFqn), context);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
@@ -589,25 +746,30 @@ public final class MachineEnumCanonicalizer {
|
||||
return !event.equals(denormalized);
|
||||
}
|
||||
|
||||
private static List<State> canonicalizeStates(List<State> states, String stateTypeFqn) {
|
||||
private static List<State> canonicalizeStates(List<State> states, String stateTypeFqn, CodebaseContext context) {
|
||||
if (states == null) {
|
||||
return null;
|
||||
}
|
||||
LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>();
|
||||
for (State state : states) {
|
||||
State canonical = canonicalizeState(state, stateTypeFqn);
|
||||
State canonical = canonicalizeState(state, stateTypeFqn, context);
|
||||
byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical);
|
||||
}
|
||||
return new ArrayList<>(byFullIdentifier.values());
|
||||
}
|
||||
|
||||
static Event canonicalizeEvent(Event event, String enumTypeFqn) {
|
||||
return canonicalizeEvent(event, enumTypeFqn, null);
|
||||
}
|
||||
|
||||
static Event canonicalizeEvent(Event event, String enumTypeFqn, CodebaseContext context) {
|
||||
if (event == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
|
||||
return event;
|
||||
}
|
||||
String canonical = canonicalizeLabel(
|
||||
event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(),
|
||||
enumTypeFqn);
|
||||
enumTypeFqn,
|
||||
context);
|
||||
String fnRaw = toFnForm(canonical, enumTypeFqn);
|
||||
if (fnRaw == null) {
|
||||
fnRaw = event.rawName();
|
||||
@@ -619,17 +781,27 @@ public final class MachineEnumCanonicalizer {
|
||||
}
|
||||
|
||||
static State canonicalizeState(State state, String enumTypeFqn) {
|
||||
return canonicalizeState(state, enumTypeFqn, null);
|
||||
}
|
||||
|
||||
static State canonicalizeState(State state, String enumTypeFqn, CodebaseContext context) {
|
||||
if (state == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
|
||||
return state;
|
||||
}
|
||||
String full = resolvePlaceholder(
|
||||
state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName());
|
||||
String canonical = canonicalizeLabel(full, enumTypeFqn);
|
||||
String canonical = canonicalizeLabel(full, enumTypeFqn, context);
|
||||
|
||||
String raw;
|
||||
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 + "\"";
|
||||
}
|
||||
} else {
|
||||
String fnRaw = toFnForm(canonical, enumTypeFqn);
|
||||
raw = fnRaw != null ? fnRaw : resolvePlaceholder(state.rawName());
|
||||
@@ -650,6 +822,10 @@ public final class MachineEnumCanonicalizer {
|
||||
}
|
||||
|
||||
public static boolean isMachineEnumReference(String value, String enumTypeFqn) {
|
||||
return isMachineEnumReference(value, enumTypeFqn, null);
|
||||
}
|
||||
|
||||
public static boolean isMachineEnumReference(String value, String enumTypeFqn, CodebaseContext context) {
|
||||
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
@@ -673,10 +849,17 @@ public final class MachineEnumCanonicalizer {
|
||||
if (typePart == null) {
|
||||
return true;
|
||||
}
|
||||
return enumTypesMatch(enumTypeFqn, typePart);
|
||||
if (context != null && context.isAmbiguousSimpleName(typePart)) {
|
||||
return false;
|
||||
}
|
||||
return enumTypesMatch(enumTypeFqn, typePart, context);
|
||||
}
|
||||
|
||||
public static String canonicalizeLabel(String value, String enumTypeFqn) {
|
||||
return canonicalizeLabel(value, enumTypeFqn, null);
|
||||
}
|
||||
|
||||
public static String canonicalizeLabel(String value, String enumTypeFqn, CodebaseContext context) {
|
||||
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
@@ -703,16 +886,19 @@ public final class MachineEnumCanonicalizer {
|
||||
String constant = constantName(stripped);
|
||||
String typePart = enumTypeFromRef(stripped);
|
||||
|
||||
if (typePart != null && enumTypesMatch(enumTypeFqn, typePart)) {
|
||||
if (typePart != null && !typePart.contains(".") && importStyleEnumTypeMatches(typePart, enumTypeFqn)
|
||||
&& constant.matches("[A-Z_][A-Z0-9_]*")) {
|
||||
return enumTypeFqn + "." + constant;
|
||||
}
|
||||
|
||||
if (typePart != null && !typePart.contains(".") && !enumTypesMatch(enumTypeFqn, typePart)
|
||||
&& importStyleEnumTypeMatches(typePart, enumTypeFqn)
|
||||
&& constant.matches("[A-Z_][A-Z0-9_]*")) {
|
||||
if (typePart != null && enumTypesMatch(enumTypeFqn, typePart, context)) {
|
||||
return enumTypeFqn + "." + constant;
|
||||
}
|
||||
|
||||
if (typePart != null && context != null && context.isAmbiguousSimpleName(typePart)) {
|
||||
return stripped;
|
||||
}
|
||||
|
||||
if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) {
|
||||
return enumTypeFqn + "." + stripped;
|
||||
}
|
||||
@@ -763,6 +949,58 @@ public final class MachineEnumCanonicalizer {
|
||||
return !type1.contains(".") || !type2.contains(".");
|
||||
}
|
||||
|
||||
public static boolean enumTypesMatch(String type1, String type2, CodebaseContext context) {
|
||||
if (context != null && type1 != null && type2 != null) {
|
||||
String simple1 = simpleName(type1);
|
||||
String simple2 = simpleName(type2);
|
||||
if (simple1.equals(simple2) && context.isAmbiguousSimpleName(simple1)) {
|
||||
return type1.equals(type2);
|
||||
}
|
||||
}
|
||||
return enumTypesMatch(type1, type2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a concrete polymorphic enum constant belongs to the configured machine event type.
|
||||
* Uses machine context to accept import-style labels even when the enum simple name is
|
||||
* ambiguous across the scanned codebase.
|
||||
*/
|
||||
public static boolean polymorphicEventMatchesMachineEventType(
|
||||
String polymorphicEvent,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (polymorphicEvent == null || machineEventTypeFqn == null || !polymorphicEvent.contains(".")) {
|
||||
return false;
|
||||
}
|
||||
if (polymorphicEvent.startsWith("<SYMBOLIC:") || polymorphicEvent.startsWith("ENUM_SET:")) {
|
||||
return false;
|
||||
}
|
||||
String enumType = polymorphicEvent.substring(0, polymorphicEvent.lastIndexOf('.'));
|
||||
if (machineEventTypeFqn.equals(enumType)) {
|
||||
return true;
|
||||
}
|
||||
if (importStyleEnumTypeMatches(enumType, machineEventTypeFqn)) {
|
||||
return true;
|
||||
}
|
||||
return enumType.contains(".") && enumTypesMatch(machineEventTypeFqn, enumType, context);
|
||||
}
|
||||
|
||||
public static boolean enumTypesMatchForMachine(
|
||||
String machineEnumFqn,
|
||||
String candidateType,
|
||||
CodebaseContext context) {
|
||||
if (machineEnumFqn == null || candidateType == null) {
|
||||
return false;
|
||||
}
|
||||
if (machineEnumFqn.equals(candidateType)) {
|
||||
return true;
|
||||
}
|
||||
if (importStyleEnumTypeMatches(candidateType, machineEnumFqn)) {
|
||||
return true;
|
||||
}
|
||||
return candidateType.contains(".") && enumTypesMatch(machineEnumFqn, candidateType, context);
|
||||
}
|
||||
|
||||
private static String simpleName(String fqn) {
|
||||
return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
|
||||
}
|
||||
@@ -846,4 +1084,113 @@ public final class MachineEnumCanonicalizer {
|
||||
}
|
||||
return simpleName(enumTypeFqn) + "." + canonicalFqn.substring(lastDot + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* When path bindings prove a single {@code event} literal (e.g. {@code "PAY".equalsIgnoreCase(event)})
|
||||
* and the trigger is {@code Enum.valueOf(...)}, synthesize one concrete polymorphic event for linking.
|
||||
*/
|
||||
public static TriggerPoint expandBoundValueOfFromConstraints(
|
||||
TriggerPoint trigger,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context) {
|
||||
return expandBoundValueOfFromConstraints(trigger, machineTypes, context, null);
|
||||
}
|
||||
|
||||
public static TriggerPoint expandBoundValueOfFromConstraints(
|
||||
TriggerPoint trigger,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context,
|
||||
EntryPoint entryPoint) {
|
||||
if (trigger == null || trigger.getEvent() == null || machineTypes == null) {
|
||||
return trigger;
|
||||
}
|
||||
if (!isDynamicTriggerExpression(trigger.getEvent()) || !trigger.getEvent().contains(".valueOf(")) {
|
||||
return trigger;
|
||||
}
|
||||
String constraint = trigger.getConstraint();
|
||||
List<String> boundEventLiterals = constraint != null && !constraint.isBlank()
|
||||
? extractBoundEventLiteralsFromConstraint(constraint)
|
||||
: List.of();
|
||||
if (boundEventLiterals.size() != 1) {
|
||||
boundEventLiterals = extractEventLiteralsFromEntryPointPath(entryPoint);
|
||||
}
|
||||
if (boundEventLiterals.size() != 1) {
|
||||
return trigger;
|
||||
}
|
||||
String eventTypeFqn = machineTypes.eventTypeFqn();
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||
return trigger;
|
||||
}
|
||||
String literal = boundEventLiterals.get(0).toUpperCase();
|
||||
if (entryPoint != null && entryPoint.getName() != null
|
||||
&& !entryPoint.getName().toUpperCase().contains("/" + literal + "/")
|
||||
&& !entryPoint.getName().toUpperCase().endsWith("/" + literal)) {
|
||||
return trigger;
|
||||
}
|
||||
String machineTypeLiteral = constraint != null
|
||||
? extractBoundParamLiteralFromConstraint(constraint, "machineType")
|
||||
: null;
|
||||
if (machineTypeLiteral != null && entryPoint != null && entryPoint.getName() != null
|
||||
&& !entryPoint.getName().toUpperCase().contains("/" + machineTypeLiteral.toUpperCase() + "/")) {
|
||||
return trigger;
|
||||
}
|
||||
String constantFqn = eventTypeFqn + "." + literal;
|
||||
if (context != null) {
|
||||
List<String> machineEnumValues = context.getEnumValues(eventTypeFqn);
|
||||
if (machineEnumValues == null || machineEnumValues.stream().noneMatch(constantFqn::equals)) {
|
||||
return trigger;
|
||||
}
|
||||
}
|
||||
return trigger.toBuilder()
|
||||
.polymorphicEvents(List.of(constantFqn))
|
||||
.ambiguous(false)
|
||||
.external(false)
|
||||
.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) {
|
||||
List<String> literals = new ArrayList<>();
|
||||
java.util.regex.Matcher matcher = java.util.regex.Pattern
|
||||
.compile("\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)")
|
||||
.matcher(constraint);
|
||||
while (matcher.find()) {
|
||||
if ("event".equals(matcher.group(2))) {
|
||||
literals.add(matcher.group(1));
|
||||
}
|
||||
}
|
||||
return literals;
|
||||
}
|
||||
|
||||
private static List<String> extractEventLiteralsFromEntryPointPath(EntryPoint entryPoint) {
|
||||
if (entryPoint == null || entryPoint.getName() == null || entryPoint.getName().isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
String rawPath = entryPoint.getName();
|
||||
int space = rawPath.indexOf(' ');
|
||||
String path = space >= 0 ? rawPath.substring(space + 1).trim() : rawPath.trim();
|
||||
if (path.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
String[] segments = path.split("/");
|
||||
for (int i = segments.length - 1; i >= 0; i--) {
|
||||
String segment = segments[i];
|
||||
if (segment.isBlank() || segment.contains("{")) {
|
||||
continue;
|
||||
}
|
||||
if (segment.matches("[A-Z_][A-Z0-9_]*")) {
|
||||
return List.of(segment);
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.TypeResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
import org.eclipse.jdt.core.dom.InfixExpression;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||
import org.eclipse.jdt.core.dom.QualifiedName;
|
||||
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Evaluates boolean predicates on rich/domain event types (e.g. {@code myEvent.isCorrect()})
|
||||
* and maps matching implementations to machine enum constants via {@code getType()}.
|
||||
*/
|
||||
public final class RichEventPredicateEvaluator {
|
||||
|
||||
private RichEventPredicateEvaluator() {
|
||||
}
|
||||
|
||||
public static List<String> filterEnumConstants(
|
||||
List<String> candidates,
|
||||
String constraint,
|
||||
String machineEnumFqn,
|
||||
TriggerPoint trigger,
|
||||
CodebaseContext context) {
|
||||
if (candidates == null || candidates.isEmpty() || constraint == null || context == null || trigger == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<EnumMemberPredicateEvaluator.PredicateCall> predicates =
|
||||
EnumMemberPredicateEvaluator.extractPredicateCalls(constraint);
|
||||
if (predicates.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
EnumMemberPredicateEvaluator.PredicateCall predicate = predicates.get(0);
|
||||
String receiverTypeFqn = resolveReceiverTypeFqn(trigger, predicate.receiverName(), context);
|
||||
if (receiverTypeFqn == null) {
|
||||
return List.of();
|
||||
}
|
||||
Set<String> ownerTypes = new LinkedHashSet<>();
|
||||
ownerTypes.add(receiverTypeFqn);
|
||||
ownerTypes.addAll(context.getImplementations(receiverTypeFqn));
|
||||
|
||||
Set<String> matched = new LinkedHashSet<>();
|
||||
for (String ownerFqn : ownerTypes) {
|
||||
if (!predicateMatches(ownerFqn, predicate, context)) {
|
||||
continue;
|
||||
}
|
||||
String typeConstant = resolveDirectEnumFromPredicateMethod(ownerFqn, "getType", context);
|
||||
if (typeConstant != null && matchesCandidate(typeConstant, candidates, machineEnumFqn, context)) {
|
||||
matched.add(canonicalize(typeConstant, machineEnumFqn, context));
|
||||
}
|
||||
String directConstant = resolveDirectEnumFromPredicateMethod(ownerFqn, predicate.methodName(), context);
|
||||
if (directConstant != null && matchesCandidate(directConstant, candidates, machineEnumFqn, context)) {
|
||||
matched.add(canonicalize(directConstant, machineEnumFqn, context));
|
||||
}
|
||||
}
|
||||
List<String> filtered = new ArrayList<>();
|
||||
for (String candidate : candidates) {
|
||||
String canonical = canonicalize(candidate, machineEnumFqn, context);
|
||||
if (matched.contains(canonical)) {
|
||||
filtered.add(candidate);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private static String resolveReceiverTypeFqn(TriggerPoint trigger, String receiverName, CodebaseContext context) {
|
||||
if (trigger.getClassName() == null || trigger.getMethodName() == null || receiverName == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration owner = context.getTypeDeclaration(trigger.getClassName());
|
||||
if (owner == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration method = context.findMethodDeclaration(owner, trigger.getMethodName(), true);
|
||||
if (method == null) {
|
||||
return null;
|
||||
}
|
||||
TypeResolver typeResolver = new TypeResolver(context);
|
||||
for (Object parameter : method.parameters()) {
|
||||
if (parameter instanceof org.eclipse.jdt.core.dom.SingleVariableDeclaration variable
|
||||
&& receiverName.equals(variable.getName().getIdentifier())) {
|
||||
return typeResolver.resolveTypeToFqn(variable.getType(), owner.getRoot());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean predicateMatches(
|
||||
String ownerFqn,
|
||||
EnumMemberPredicateEvaluator.PredicateCall predicate,
|
||||
CodebaseContext context) {
|
||||
TypeDeclaration type = context.getTypeDeclaration(ownerFqn);
|
||||
if (type == null) {
|
||||
return false;
|
||||
}
|
||||
MethodDeclaration method = context.findMethodDeclaration(type, predicate.methodName(), true);
|
||||
if (method == null || method.getBody() == null) {
|
||||
return false;
|
||||
}
|
||||
Boolean[] result = new Boolean[1];
|
||||
method.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(ReturnStatement node) {
|
||||
if (result[0] == null && node.getExpression() != null) {
|
||||
result[0] = evaluateBoolean(node.getExpression());
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
if (result[0] == null) {
|
||||
return false;
|
||||
}
|
||||
return predicate.negated() ? !result[0] : result[0];
|
||||
}
|
||||
|
||||
private static Boolean evaluateBoolean(Expression expression) {
|
||||
if (expression instanceof org.eclipse.jdt.core.dom.BooleanLiteral literal) {
|
||||
return literal.booleanValue();
|
||||
}
|
||||
if (expression instanceof MethodInvocation invocation
|
||||
&& "isEvent".equals(invocation.getName().getIdentifier())
|
||||
&& invocation.getExpression() instanceof MethodInvocation getType
|
||||
&& "getType".equals(getType.getName().getIdentifier())) {
|
||||
return true;
|
||||
}
|
||||
if (expression instanceof InfixExpression infix && infix.getOperator() == InfixExpression.Operator.EQUALS) {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String resolveDirectEnumFromPredicateMethod(
|
||||
String ownerFqn,
|
||||
String predicateMethod,
|
||||
CodebaseContext context) {
|
||||
TypeDeclaration type = context.getTypeDeclaration(ownerFqn);
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration method = context.findMethodDeclaration(type, predicateMethod, true);
|
||||
if (method == null || method.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
final String[] constant = new String[1];
|
||||
method.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(ReturnStatement node) {
|
||||
if (constant[0] != null || node.getExpression() == null) {
|
||||
return super.visit(node);
|
||||
}
|
||||
constant[0] = extractEnumConstant(node.getExpression());
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return constant[0];
|
||||
}
|
||||
|
||||
private static String extractEnumConstant(Expression expression) {
|
||||
if (expression instanceof QualifiedName qualifiedName) {
|
||||
return qualifiedName.getFullyQualifiedName();
|
||||
}
|
||||
if (expression instanceof InfixExpression infix && infix.getOperator() == InfixExpression.Operator.EQUALS) {
|
||||
String left = extractEnumConstant(infix.getLeftOperand());
|
||||
if (left != null) {
|
||||
return left;
|
||||
}
|
||||
return extractEnumConstant(infix.getRightOperand());
|
||||
}
|
||||
if (expression instanceof MethodInvocation invocation
|
||||
&& invocation.getExpression() instanceof MethodInvocation getType
|
||||
&& "getType".equals(getType.getName().getIdentifier())) {
|
||||
if (invocation.getExpression() instanceof MethodInvocation inner
|
||||
&& inner.getExpression() instanceof ClassInstanceCreation creation
|
||||
&& !creation.arguments().isEmpty()) {
|
||||
return extractEnumConstant((Expression) creation.arguments().get(0));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean matchesCandidate(
|
||||
String constant,
|
||||
List<String> candidates,
|
||||
String machineEnumFqn,
|
||||
CodebaseContext context) {
|
||||
if (constant == null) {
|
||||
return false;
|
||||
}
|
||||
String canonical = canonicalize(constant, machineEnumFqn, context);
|
||||
for (String candidate : candidates) {
|
||||
if (canonical.equals(canonicalize(candidate, machineEnumFqn, context))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String canonicalize(String constant, String machineEnumFqn, CodebaseContext context) {
|
||||
return MachineEnumCanonicalizer.canonicalizeLabel(constant, machineEnumFqn, context);
|
||||
}
|
||||
}
|
||||
@@ -34,39 +34,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
protected final PathBindingEvaluator pathBindingEvaluator;
|
||||
private final Map<String, ASTNode> parsedNodeCache = 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;
|
||||
|
||||
private ASTNode parseExpressionString(String expr) {
|
||||
if (expr == null) return 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) {
|
||||
if (expr == null) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
return parsedNodeCache.computeIfAbsent(expr, click.kamil.springstatemachineexporter.ast.common.AstUtils::parseExpression);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
@@ -86,6 +61,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
// Wire up cross dependencies
|
||||
this.variableTracer.setConstantExtractor(this.constantExtractor);
|
||||
this.variableTracer.setTypeResolver(this.typeResolver);
|
||||
this.constantExtractor.setVariableTracer(this.variableTracer);
|
||||
this.constantExtractor.setConstructorAnalyzer(this.constructorAnalyzer);
|
||||
this.constructorAnalyzer.setVariableTracer(this.variableTracer);
|
||||
@@ -107,6 +83,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
context.clearAnalysisCaches();
|
||||
pathFinder.clearAnalysisCaches();
|
||||
pathBindingEvaluator.clearAnalysisCaches();
|
||||
pathBindingsCache.clear();
|
||||
parsedNodeCache.clear();
|
||||
Map<String, List<CallEdge>> callGraph = buildCallGraph();
|
||||
List<CallChain> chains = new ArrayList<>();
|
||||
@@ -117,7 +94,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
startMethods.add(startMethod);
|
||||
|
||||
List<Map<String, String>> bindingVariants =
|
||||
EntryPointBindingExpander.expandPathVariableBindings(ep, context, callGraph);
|
||||
EntryPointBindingExpander.expandEntryPointBindings(ep, context, callGraph);
|
||||
if (bindingVariants.isEmpty()) {
|
||||
bindingVariants = List.of(Map.of());
|
||||
}
|
||||
@@ -132,17 +109,26 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
|
||||
List<List<String>> allPaths = new ArrayList<>();
|
||||
for (String sMethod : startMethods) {
|
||||
if (initialBindings.isEmpty()) {
|
||||
allPaths.addAll(pathFinder.findAllPaths(
|
||||
sMethod, targetMethod, callGraph, new HashSet<>()));
|
||||
} else {
|
||||
allPaths.addAll(pathFinder.findAllPaths(
|
||||
sMethod, targetMethod, callGraph, new HashSet<>(),
|
||||
pathBindingEvaluator, initialBindings));
|
||||
}
|
||||
}
|
||||
Set<List<String>> uniquePaths = new LinkedHashSet<>(allPaths);
|
||||
for (List<String> path : uniquePaths) {
|
||||
if (!pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder, initialBindings)) {
|
||||
boolean compatible = initialBindings.isEmpty()
|
||||
? pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder)
|
||||
: pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder, initialBindings);
|
||||
if (!compatible) {
|
||||
continue;
|
||||
}
|
||||
foundAny = true;
|
||||
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph);
|
||||
Map<String, String> pathBindings = resolvePathBindings(path, callGraph, initialBindings);
|
||||
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph, initialBindings);
|
||||
if (resolvedTp != null) {
|
||||
String contextMachineId = pathFinder.extractContextMachineId(path, callGraph);
|
||||
chains.add(CallChain.builder()
|
||||
@@ -150,6 +136,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
.triggerPoint(resolvedTp)
|
||||
.methodChain(path)
|
||||
.contextMachineId(contextMachineId)
|
||||
.pathBindings(pathBindings.isEmpty() ? null : Map.copyOf(pathBindings))
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -164,6 +151,41 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
|
||||
protected TriggerPoint resolveTriggerPointParameters(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> callGraph) {
|
||||
return resolveTriggerPointParameters(tp, path, callGraph, Map.of());
|
||||
}
|
||||
|
||||
protected 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(
|
||||
TriggerPoint tp,
|
||||
List<String> path,
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
Map<String, String> initialBindings) {
|
||||
if (path.size() < 2) {
|
||||
boolean isExternal = isExternalParameter(tp.getClassName() + "." + tp.getMethodName(), tp.getEvent());
|
||||
return tp.toBuilder().external(isExternal).constraint(tp.getConstraint()).build();
|
||||
@@ -175,27 +197,74 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
|
||||
String[] finalParamNameRef = { event };
|
||||
TriggerPoint resolved = resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef);
|
||||
TriggerPoint resolved = resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef, initialBindings);
|
||||
if (resolved == null) return null;
|
||||
String entryMethod = path.isEmpty() ? (tp.getClassName() + "." + tp.getMethodName()) : path.get(0);
|
||||
boolean isExternal = isExternalTrigger(entryMethod, finalParamNameRef[0], path, callGraph);
|
||||
boolean isExternal = isExternalTrigger(entryMethod, finalParamNameRef[0], path, callGraph, initialBindings);
|
||||
|
||||
String pathConstraint = extractPathConstraints(path, callGraph);
|
||||
String pathConstraint = extractPathConstraints(path, callGraph, initialBindings);
|
||||
String triggerConstraint = resolved.getConstraint();
|
||||
String finalConstraint = null;
|
||||
if (pathConstraint != null && triggerConstraint != null) {
|
||||
finalConstraint = pathConstraint + " && " + triggerConstraint;
|
||||
} else if (pathConstraint != null) {
|
||||
finalConstraint = pathConstraint;
|
||||
} else {
|
||||
finalConstraint = triggerConstraint;
|
||||
}
|
||||
String finalConstraint = mergeConstraints(pathConstraint, triggerConstraint);
|
||||
|
||||
return resolved.toBuilder().external(isExternal).constraint(finalConstraint).build();
|
||||
}
|
||||
|
||||
protected TriggerPoint resolveTriggerPointParametersOriginal(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> callGraph, String[] finalParamNameRef) {
|
||||
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath(path);
|
||||
private static String mergeConstraints(String pathConstraint, String triggerConstraint) {
|
||||
if (pathConstraint != null && triggerConstraint != null) {
|
||||
return pathConstraint + " && " + triggerConstraint;
|
||||
}
|
||||
if (pathConstraint != null) {
|
||||
return pathConstraint;
|
||||
}
|
||||
return triggerConstraint;
|
||||
}
|
||||
|
||||
private static Expression selectTernaryBranch(ConditionalExpression cond, Map<String, String> bindings) {
|
||||
if (cond == null || bindings == null || bindings.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Expression condition = cond.getExpression();
|
||||
if (condition instanceof PrefixExpression prefixExpression
|
||||
&& prefixExpression.getOperator() == PrefixExpression.Operator.NOT
|
||||
&& prefixExpression.getOperand() instanceof SimpleName negatedName) {
|
||||
String bound = bindings.get(negatedName.getIdentifier());
|
||||
if ("true".equals(bound)) {
|
||||
return cond.getElseExpression();
|
||||
}
|
||||
if ("false".equals(bound)) {
|
||||
return cond.getThenExpression();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!(condition instanceof SimpleName simpleName)) {
|
||||
return null;
|
||||
}
|
||||
String bound = bindings.get(simpleName.getIdentifier());
|
||||
if ("true".equals(bound)) {
|
||||
return cond.getThenExpression();
|
||||
}
|
||||
if ("false".equals(bound)) {
|
||||
return cond.getElseExpression();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected TriggerPoint resolveTriggerPointParametersOriginal(
|
||||
TriggerPoint tp,
|
||||
List<String> path,
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
String[] finalParamNameRef) {
|
||||
return resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef, Map.of());
|
||||
}
|
||||
|
||||
protected TriggerPoint resolveTriggerPointParametersOriginal(
|
||||
TriggerPoint tp,
|
||||
List<String> path,
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
String[] finalParamNameRef,
|
||||
Map<String, String> initialBindings) {
|
||||
click.kamil.springstatemachineexporter.ast.common.CodebaseContext contextRef = context;
|
||||
contextRef.setAnalysisCallPath(path);
|
||||
final boolean debug = log.isDebugEnabled();
|
||||
try {
|
||||
String event = tp.getEvent();
|
||||
@@ -243,7 +312,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
|
||||
if (paramIndex < 0) {
|
||||
Map<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i);
|
||||
Map<String, String> parameterValues = mergeBindings(
|
||||
initialBindings,
|
||||
variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i));
|
||||
String tracedVar = variableTracer.traceLocalVariable(target, currentParamName, parameterValues);
|
||||
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
|
||||
String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix, target);
|
||||
@@ -264,9 +335,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (edges != null) {
|
||||
for (CallEdge edge : edges) {
|
||||
if (edge.getTargetMethod().equals(target) || pathFinder.isHeuristicMatch(edge.getTargetMethod(), target)) {
|
||||
String expectedType = typeResolver.getParameterType(target, paramIndex);
|
||||
String expectedType = typeResolver.getParameterTypeAtCallSite(caller, target, paramIndex);
|
||||
if (expectedType == null) {
|
||||
expectedType = typeResolver.getParameterType(target, paramIndex);
|
||||
}
|
||||
if (expectedType != null && paramIndex < edge.getArguments().size()) {
|
||||
String argValue = edge.getArguments().get(paramIndex);
|
||||
if (!FunctionalInterfaceTypes.isProvablyResolvedCallSiteArgument(argValue, expectedType)) {
|
||||
String actualType = null;
|
||||
if (argValue.contains(".") && !argValue.contains("(")) {
|
||||
String prefix = argValue.substring(0, argValue.lastIndexOf('.'));
|
||||
@@ -281,6 +356,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (paramIndex < edge.getArguments().size()) {
|
||||
String arg = edge.getArguments().get(paramIndex);
|
||||
if (arg != null) {
|
||||
@@ -390,25 +466,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (debug) {
|
||||
log.debug("Early return 2: getterEvents = {}", getterEvents);
|
||||
}
|
||||
return TriggerPoint.builder()
|
||||
.event(resolvedValue)
|
||||
.className(tp.getClassName())
|
||||
.methodName(tp.getMethodName())
|
||||
.sourceFile(tp.getSourceFile())
|
||||
.sourceModule(tp.getSourceModule())
|
||||
.stateMachineId(tp.getStateMachineId())
|
||||
.sourceState(tp.getSourceState())
|
||||
.lineNumber(tp.getLineNumber())
|
||||
.polymorphicEvents(getterEvents)
|
||||
.constraint(tp.getConstraint())
|
||||
.stateTypeFqn(tp.getStateTypeFqn())
|
||||
.eventTypeFqn(tp.getEventTypeFqn())
|
||||
.external(tp.isExternal())
|
||||
.build();
|
||||
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, getterEvents);
|
||||
}
|
||||
}
|
||||
|
||||
String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName);
|
||||
String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName, initialBindings);
|
||||
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
|
||||
String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix, entryMethod);
|
||||
tracedVar = extractedFinalTraced[0];
|
||||
@@ -443,6 +505,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
.stateTypeFqn(tp.getStateTypeFqn())
|
||||
.eventTypeFqn(tp.getEventTypeFqn())
|
||||
.external(tp.isExternal())
|
||||
.ambiguous(polymorphicEvents.size() > 1)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -655,9 +718,16 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
polymorphicEvents.add(declaredType);
|
||||
}
|
||||
} else if (exprNode instanceof ConditionalExpression cond) {
|
||||
Expression selectedBranch = selectTernaryBranch(cond, initialBindings);
|
||||
List<Expression> branches = new ArrayList<>();
|
||||
if (selectedBranch != null) {
|
||||
branches.add(selectedBranch);
|
||||
resolvedValue = selectedBranch.toString();
|
||||
exprNode = selectedBranch;
|
||||
} else {
|
||||
branches.add(cond.getThenExpression());
|
||||
branches.add(cond.getElseExpression());
|
||||
}
|
||||
for (Expression branch : branches) {
|
||||
if (branch instanceof ConditionalExpression nestedCond) {
|
||||
// traceVariableAll collapses literal "true"/"false" conditions to a single branch;
|
||||
@@ -924,6 +994,20 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (polymorphicEvents.size() > 1) {
|
||||
isAmbiguous = true;
|
||||
}
|
||||
if (polymorphicEvents.isEmpty()
|
||||
&& context.isAmbiguousSimpleName(declaredType)
|
||||
&& !tp.isExternal()
|
||||
&& !isRuntimeEnumParameter(exprNode instanceof Expression expression ? expression : null)
|
||||
&& !(keyedMapLookupOnInitializer[0] && pathBoundMapKeyOnInitializer[0])) {
|
||||
String symbolic = "<SYMBOLIC: " + declaredType + ".*>";
|
||||
if (!polymorphicEvents.contains(symbolic)) {
|
||||
polymorphicEvents.add(symbolic);
|
||||
}
|
||||
isAmbiguous = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -971,14 +1055,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
polymorphicEvents.add(methodReturn);
|
||||
}
|
||||
}
|
||||
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
|
||||
&& polymorphicEvents.stream().anyMatch(pe -> pe != null && pe.startsWith("<SYMBOLIC:"))
|
||||
&& declaredType != null) {
|
||||
List<String> expandedEnumValues = expandDeclaredEnumValues(declaredType);
|
||||
if (!expandedEnumValues.isEmpty()) {
|
||||
polymorphicEvents = new ArrayList<>(expandedEnumValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -994,29 +1070,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
polymorphicEvents = newPolyEvents;
|
||||
|
||||
polymorphicEvents.removeIf(e -> {
|
||||
if (e.contains(".")) return false;
|
||||
String val = e;
|
||||
boolean isKnownEnumVal = false;
|
||||
for (List<String> vals : context.getEnumValuesMap().values()) {
|
||||
for (String v : vals) {
|
||||
if (v.endsWith("." + val)) {
|
||||
isKnownEnumVal = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isKnownEnumVal) break;
|
||||
}
|
||||
if (isKnownEnumVal) return false;
|
||||
return !val.equals(val.toUpperCase()) || val.length() <= 1;
|
||||
});
|
||||
|
||||
String targetMethod = path.get(path.size() - 1);
|
||||
int eventParamIndex = typeResolver.getParameterIndex(targetMethod, event, true);
|
||||
if (eventParamIndex < 0) {
|
||||
eventParamIndex = 0;
|
||||
}
|
||||
String expectedType = typeResolver.getParameterType(targetMethod, eventParamIndex);
|
||||
|
||||
polymorphicEvents.removeIf(e -> shouldDropBarePolymorphicCandidate(e, expectedType, context));
|
||||
if (expectedType != null) {
|
||||
final String expType = expectedType;
|
||||
boolean isExpectedEnum = context.getEnumValues(expType) != null;
|
||||
@@ -1087,6 +1148,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
.stateTypeFqn(tp.getStateTypeFqn())
|
||||
.eventTypeFqn(tp.getEventTypeFqn())
|
||||
.external(tp.isExternal())
|
||||
.ambiguous(isAmbiguous || polymorphicEvents.size() > 1)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -1115,7 +1177,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
return tp;
|
||||
} finally {
|
||||
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath();
|
||||
context.clearAnalysisCallPath();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1151,6 +1213,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (expr instanceof LambdaExpression) {
|
||||
String lambdaText = expr.toString();
|
||||
String val = constantResolver.resolve(expr, context);
|
||||
return val != null ? val : lambdaText;
|
||||
}
|
||||
}
|
||||
|
||||
if (expr instanceof ExpressionMethodReference emr) {
|
||||
@@ -1641,6 +1708,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
* and, when possible, a {@code new ...()} instance from the getter body.
|
||||
*/
|
||||
private TriggerPoint buildTriggerPointWithPolymorphicEvents(TriggerPoint tp, String resolvedValue, List<String> polymorphicEvents) {
|
||||
boolean ambiguous = tp.isAmbiguous()
|
||||
|| (polymorphicEvents != null && polymorphicEvents.size() > 1);
|
||||
return TriggerPoint.builder()
|
||||
.event(resolvedValue)
|
||||
.className(tp.getClassName())
|
||||
@@ -1655,6 +1724,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
.stateTypeFqn(tp.getStateTypeFqn())
|
||||
.eventTypeFqn(tp.getEventTypeFqn())
|
||||
.external(tp.isExternal())
|
||||
.ambiguous(ambiguous)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -1795,6 +1865,47 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
|
||||
protected List<String> evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List<String> path, Map<String, List<CallEdge>> callGraph) {
|
||||
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);
|
||||
while (exprNode instanceof ParenthesizedExpression pe) {
|
||||
exprNode = pe.getExpression();
|
||||
@@ -1895,7 +2006,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
mi.toString(), scopeMethod, constantResolver, context);
|
||||
}
|
||||
if (remapped != null) {
|
||||
return evaluateGetterOnReceiver(remapped, "", entryMethod, path, callGraph);
|
||||
return evaluateGetterOnReceiver(remapped, "", entryMethod, path, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1998,6 +2109,35 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
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) {
|
||||
int lastDot = methodFqn.lastIndexOf('.');
|
||||
if (lastDot < 0) return null;
|
||||
@@ -2112,10 +2252,17 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
for (Object modifier : svd.modifiers()) {
|
||||
if (modifier instanceof Annotation annotation) {
|
||||
String typeName = annotation.getTypeName().getFullyQualifiedName();
|
||||
if (typeName.endsWith("PathVariable") || typeName.endsWith("RequestBody") || typeName.endsWith("RequestParam")
|
||||
if (typeName.endsWith("PathVariable") || typeName.endsWith("RequestParam")
|
||||
|| typeName.endsWith("PathParam") || typeName.endsWith("QueryParam")) {
|
||||
return true;
|
||||
}
|
||||
if (typeName.endsWith("RequestBody")) {
|
||||
ITypeBinding typeBinding = svd.getType().resolveBinding();
|
||||
if (typeBinding != null && typeBinding.isEnum()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2126,8 +2273,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return false;
|
||||
}
|
||||
|
||||
private String extractPathConstraints(List<String> path, Map<String, List<CallEdge>> callGraph) {
|
||||
private String extractPathConstraints(
|
||||
List<String> path, Map<String, List<CallEdge>> callGraph, Map<String, String> initialBindings) {
|
||||
List<String> constraints = new ArrayList<>();
|
||||
String bindingConstraint = constraintFromBindings(initialBindings);
|
||||
if (bindingConstraint != null) {
|
||||
constraints.add(bindingConstraint);
|
||||
}
|
||||
for (int i = 0; i < path.size() - 1; i++) {
|
||||
String caller = path.get(i);
|
||||
String target = path.get(i + 1);
|
||||
@@ -2147,6 +2299,36 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return String.join(" && ", constraints);
|
||||
}
|
||||
|
||||
private static String constraintFromBindings(Map<String, String> bindings) {
|
||||
if (bindings == null || bindings.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (Map.Entry<String, String> entry : bindings.entrySet()) {
|
||||
String value = entry.getValue();
|
||||
if (value == null || value.isBlank() || value.contains("(")) {
|
||||
continue;
|
||||
}
|
||||
if ("true".equals(value) || "false".equals(value)) {
|
||||
continue;
|
||||
}
|
||||
parts.add("\"" + value + "\".equalsIgnoreCase(" + entry.getKey() + ")");
|
||||
}
|
||||
return parts.isEmpty() ? null : String.join(" && ", parts);
|
||||
}
|
||||
|
||||
private static Map<String, String> mergeBindings(
|
||||
Map<String, String> initialBindings, Map<String, String> tracedBindings) {
|
||||
Map<String, String> merged = new HashMap<>();
|
||||
if (tracedBindings != null) {
|
||||
merged.putAll(tracedBindings);
|
||||
}
|
||||
if (initialBindings != null) {
|
||||
merged.putAll(initialBindings);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
protected String resolveConstraint(MethodInvocation node, String calledMethod, String baseConstraint) {
|
||||
String resolvedConstraint = baseConstraint;
|
||||
if (node.getExpression() instanceof MethodInvocation miReceiver) {
|
||||
@@ -2309,7 +2491,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
protected abstract String resolveCalledMethod(MethodInvocation node);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
protected Map<String, List<CallEdge>> buildCallGraph() {
|
||||
public Map<String, List<CallEdge>> buildCallGraph() {
|
||||
Map<String, List<CallEdge>> cached =
|
||||
(Map<String, List<CallEdge>>) context.getCache().get(callGraphCacheKey());
|
||||
if (cached != null) {
|
||||
@@ -2403,18 +2585,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (tdOuter != null) {
|
||||
String callerFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
|
||||
String methodName = node.getName().getIdentifier();
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
if (td != null) {
|
||||
String superFqn = context.getSuperclassFqn(td);
|
||||
if (superFqn != null) {
|
||||
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
||||
String calledMethod = null;
|
||||
if (superTd != null) {
|
||||
calledMethod = resolveMethodInType(superTd, methodName);
|
||||
}
|
||||
if (calledMethod == null) {
|
||||
calledMethod = superFqn + "." + methodName;
|
||||
}
|
||||
String calledMethod = InheritanceCallTargetResolver.resolveSuperMethod(
|
||||
context, tdOuter, methodName);
|
||||
if (calledMethod != null) {
|
||||
List<String> args = resolveArguments(node.arguments());
|
||||
String receiver = "super";
|
||||
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils
|
||||
@@ -2425,7 +2598,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
@@ -2592,6 +2764,41 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return current;
|
||||
}
|
||||
|
||||
private boolean shouldDropBarePolymorphicCandidate(String candidate, String expectedType, CodebaseContext context) {
|
||||
if (candidate == null || candidate.contains(".")
|
||||
|| candidate.startsWith("<SYMBOLIC:") || candidate.startsWith("ENUM_SET:")) {
|
||||
return false;
|
||||
}
|
||||
if (!candidate.equals(candidate.toUpperCase()) || candidate.length() <= 1) {
|
||||
return true;
|
||||
}
|
||||
if (expectedType != null) {
|
||||
if (context.isAmbiguousSimpleName(expectedType)) {
|
||||
return true;
|
||||
}
|
||||
List<String> enumValues = context.getEnumValues(expectedType);
|
||||
if (enumValues == null || enumValues.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (String enumValue : enumValues) {
|
||||
if (enumValue.endsWith("." + candidate)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
int matchingEnumTypes = 0;
|
||||
for (List<String> enumValues : context.getEnumValuesMap().values()) {
|
||||
for (String enumValue : enumValues) {
|
||||
if (enumValue.endsWith("." + candidate)) {
|
||||
matchingEnumTypes++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matchingEnumTypes != 1;
|
||||
}
|
||||
|
||||
private void qualifyBareEnumConstants(List<String> polymorphicEvents, String expectedType) {
|
||||
if (expectedType == null || polymorphicEvents == null || polymorphicEvents.isEmpty()) {
|
||||
return;
|
||||
@@ -2631,16 +2838,52 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
|
||||
private String resolveValueOfEnumTypeName(MethodInvocation mi) {
|
||||
if (mi.getExpression() instanceof SimpleName sn) {
|
||||
if (mi == null || !"valueOf".equals(mi.getName().getIdentifier())) {
|
||||
return null;
|
||||
}
|
||||
org.eclipse.jdt.core.dom.IMethodBinding binding = mi.resolveMethodBinding();
|
||||
if (binding != null && binding.getDeclaringClass() != null) {
|
||||
String fqn = binding.getDeclaringClass().getErasure().getQualifiedName();
|
||||
if (isResolvedEnumTypeFqn(fqn)) {
|
||||
return fqn;
|
||||
}
|
||||
}
|
||||
Expression receiver = mi.getExpression();
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
org.eclipse.jdt.core.dom.ITypeBinding typeBinding = sn.resolveTypeBinding();
|
||||
if (typeBinding != null) {
|
||||
String fqn = typeBinding.getErasure().getQualifiedName();
|
||||
if (isResolvedEnumTypeFqn(fqn)) {
|
||||
return fqn;
|
||||
}
|
||||
}
|
||||
return sn.getIdentifier();
|
||||
}
|
||||
if (mi.getExpression() instanceof QualifiedName qn) {
|
||||
if (receiver instanceof QualifiedName qn) {
|
||||
org.eclipse.jdt.core.dom.IBinding nameBinding = qn.resolveBinding();
|
||||
if (nameBinding instanceof org.eclipse.jdt.core.dom.ITypeBinding typeBinding) {
|
||||
String fqn = typeBinding.getErasure().getQualifiedName();
|
||||
if (isResolvedEnumTypeFqn(fqn)) {
|
||||
return fqn;
|
||||
}
|
||||
}
|
||||
String full = qn.getFullyQualifiedName();
|
||||
return full.contains(".") ? full.substring(full.lastIndexOf('.') + 1) : full;
|
||||
if (full.contains(".")) {
|
||||
String typePart = full.substring(0, full.lastIndexOf('.'));
|
||||
if (isResolvedEnumTypeFqn(typePart)) {
|
||||
return typePart;
|
||||
}
|
||||
return full.substring(full.lastIndexOf('.') + 1);
|
||||
}
|
||||
return full;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isResolvedEnumTypeFqn(String fqn) {
|
||||
return fqn != null && !fqn.isBlank() && fqn.contains(".") && !fqn.startsWith("<");
|
||||
}
|
||||
|
||||
private String resolveEnumConstantFromArgument(String argName, List<String> path, Map<String, List<CallEdge>> callGraph) {
|
||||
if (argName == null || path == null || path.size() < 2) {
|
||||
return null;
|
||||
@@ -2819,21 +3062,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if ((enumValues == null || enumValues.isEmpty()) && declaredType.contains(".")) {
|
||||
enumValues = context.getEnumValues(declaredType.substring(declaredType.lastIndexOf('.') + 1));
|
||||
}
|
||||
if ((enumValues == null || enumValues.isEmpty())) {
|
||||
String simpleDeclared = declaredType.contains(".")
|
||||
? declaredType.substring(declaredType.lastIndexOf('.') + 1)
|
||||
: declaredType;
|
||||
for (Map.Entry<String, List<String>> entry : context.getEnumValuesMap().entrySet()) {
|
||||
String enumType = entry.getKey();
|
||||
String simpleType = enumType.contains(".")
|
||||
? enumType.substring(enumType.lastIndexOf('.') + 1)
|
||||
: enumType;
|
||||
if (simpleDeclared.equals(simpleType)) {
|
||||
enumValues = entry.getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enumValues == null || enumValues.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -2862,11 +3090,12 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
String entryMethod,
|
||||
String finalParamName,
|
||||
List<String> path,
|
||||
Map<String, List<CallEdge>> callGraph) {
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
Map<String, String> initialBindings) {
|
||||
if (isExternalParameter(entryMethod, finalParamName)) {
|
||||
return true;
|
||||
}
|
||||
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
|
||||
Map<String, String> bindings = resolvePathBindings(path, callGraph, initialBindings);
|
||||
for (String boundName : bindings.keySet()) {
|
||||
if (isExternalParameter(entryMethod, boundName)) {
|
||||
return true;
|
||||
@@ -2903,7 +3132,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return sl.getLiteralValue();
|
||||
}
|
||||
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());
|
||||
if (bound != null) {
|
||||
return unwrapStringLiteral(bound);
|
||||
@@ -2931,7 +3160,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (!(keyArg instanceof SimpleName sn)) {
|
||||
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())) {
|
||||
return true;
|
||||
}
|
||||
@@ -2990,7 +3219,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
|
||||
return polymorphicEvents;
|
||||
}
|
||||
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
|
||||
Map<String, String> bindings = resolvePathBindings(path, callGraph, Map.of());
|
||||
if (bindings.isEmpty()) {
|
||||
if (hasMultiClassPolymorphicPath(path)
|
||||
&& polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) {
|
||||
@@ -3031,6 +3260,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (tp.isExternal() && hasConcrete) {
|
||||
narrowed.removeIf(pe -> pe != null && pe.startsWith("<SYMBOLIC:"));
|
||||
}
|
||||
narrowed.removeIf(pe -> pe != null && (pe.equals("*>") || pe.equals(".*>")));
|
||||
return narrowed;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,16 +86,16 @@ public final class AnalysisResultFinalizer {
|
||||
AnalysisResult result,
|
||||
CodebaseContext context,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
MachineEnumCanonicalizer.canonicalizeTransitions(result.getTransitions(), machineTypes);
|
||||
MachineEnumCanonicalizer.canonicalizeTransitions(result.getTransitions(), machineTypes, context);
|
||||
|
||||
if (result.getStates() != null) {
|
||||
result.setStates(MachineEnumCanonicalizer.canonicalizeStates(
|
||||
result.getStates(), machineTypes.stateTypeFqn()));
|
||||
result.getStates(), machineTypes.stateTypeFqn(), context));
|
||||
}
|
||||
result.setStartStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
|
||||
result.getStartStates(), machineTypes.stateTypeFqn()));
|
||||
result.getStartStates(), machineTypes.stateTypeFqn(), context));
|
||||
result.setEndStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
|
||||
result.getEndStates(), machineTypes.stateTypeFqn()));
|
||||
result.getEndStates(), machineTypes.stateTypeFqn(), context));
|
||||
|
||||
if (result.getMetadata() != null) {
|
||||
CodebaseMetadata metadata = result.getMetadata();
|
||||
@@ -137,7 +137,7 @@ public final class AnalysisResultFinalizer {
|
||||
: MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
|
||||
chain.getTriggerPoint(), machineTypes, context);
|
||||
List<MatchedTransition> matched = canonicalizeMatchedTransitions(
|
||||
chain.getMatchedTransitions(), machineTypes);
|
||||
chain.getMatchedTransitions(), machineTypes, context);
|
||||
return chain.toBuilder()
|
||||
.triggerPoint(trigger)
|
||||
.matchedTransitions(matched)
|
||||
@@ -148,18 +148,19 @@ public final class AnalysisResultFinalizer {
|
||||
|
||||
private static List<MatchedTransition> canonicalizeMatchedTransitions(
|
||||
List<MatchedTransition> matchedTransitions,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context) {
|
||||
if (matchedTransitions == null) {
|
||||
return null;
|
||||
}
|
||||
return matchedTransitions.stream()
|
||||
.map(matched -> MatchedTransition.builder()
|
||||
.event(MachineEnumCanonicalizer.canonicalizeLabel(
|
||||
matched.getEvent(), machineTypes.eventTypeFqn()))
|
||||
matched.getEvent(), machineTypes.eventTypeFqn(), context))
|
||||
.sourceState(MachineEnumCanonicalizer.canonicalizeLabel(
|
||||
matched.getSourceState(), machineTypes.stateTypeFqn()))
|
||||
matched.getSourceState(), machineTypes.stateTypeFqn(), context))
|
||||
.targetState(MachineEnumCanonicalizer.canonicalizeLabel(
|
||||
matched.getTargetState(), machineTypes.stateTypeFqn()))
|
||||
matched.getTargetState(), machineTypes.stateTypeFqn(), context))
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@@ -361,10 +361,7 @@ public class CallGraphPathFinder {
|
||||
|
||||
if (classNeighbor.equals(classTarget)) return true;
|
||||
|
||||
String simpleClassNeighbor = classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
|
||||
String simpleClassTarget = classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
|
||||
|
||||
if (simpleClassNeighbor.equals(simpleClassTarget)) return true;
|
||||
if (context.areSameTypeOrUnambiguousSimpleMatch(classNeighbor, classTarget)) return true;
|
||||
|
||||
if (context.areClassesPolymorphicallyCompatible(classNeighbor, classTarget)) {
|
||||
return true;
|
||||
@@ -383,13 +380,22 @@ public class CallGraphPathFinder {
|
||||
String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
|
||||
|
||||
if (simpleClassNeighbor != null && simpleClassTarget != null) {
|
||||
if (simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) return true;
|
||||
String leftClass = classNeighbor != null ? classNeighbor : simpleClassNeighbor;
|
||||
String rightClass = classTarget != null ? classTarget : simpleClassTarget;
|
||||
if (context.areSameTypeOrUnambiguousSimpleMatch(leftClass, rightClass)) return true;
|
||||
if (!context.isAmbiguousSimpleName(simpleClassNeighbor)
|
||||
&& !context.isAmbiguousSimpleName(simpleClassTarget)
|
||||
&& simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) {
|
||||
return true;
|
||||
}
|
||||
// e.g. "this" vs "com.example.Machine"
|
||||
if (simpleClassNeighbor.equals("this") || simpleClassNeighbor.equals("super")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
// If we have no class ownership information for either side, we can't prove a match beyond the
|
||||
// method name. Fail closed to avoid accidental cross-class linking.
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isFullyQualifiedMethod(String methodFqn) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
} 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();
|
||||
|
||||
if (log.isTraceEnabled()) {
|
||||
@@ -100,44 +112,22 @@ public class ConstantExtractor {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((methodName.startsWith("get") || methodName.equals("type") || methodName.equals("event")) && mi.getExpression() instanceof SimpleName sn) {
|
||||
String varName = sn.getIdentifier();
|
||||
String propName = methodName.startsWith("get") ? methodName.substring(3) : methodName;
|
||||
|
||||
Block block = findEnclosingBlock(mi);
|
||||
if (block != null) {
|
||||
for (Object stmtObj : block.statements()) {
|
||||
if (stmtObj == mi.getParent() || stmtObj == mi) break;
|
||||
if (stmtObj instanceof ExpressionStatement es) {
|
||||
if (es.getExpression() instanceof MethodInvocation setterMi) {
|
||||
if (setterMi.getName().getIdentifier().equalsIgnoreCase("set" + propName) || setterMi.getName().getIdentifier().equalsIgnoreCase(propName)) {
|
||||
if (setterMi.getExpression() instanceof SimpleName setterSn && setterSn.getIdentifier().equals(varName)) {
|
||||
if (!setterMi.arguments().isEmpty()) {
|
||||
extractConstantsFromExpression((Expression) setterMi.arguments().get(0), constants);
|
||||
if ((methodName.startsWith("get") || methodName.equals("type") || methodName.equals("event"))
|
||||
&& mi.getExpression() instanceof SimpleName sn
|
||||
&& variableTracer != null) {
|
||||
org.eclipse.jdt.core.dom.MethodDeclaration md =
|
||||
click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(mi);
|
||||
TypeDeclaration td = findEnclosingType(mi);
|
||||
if (md != null && td != null) {
|
||||
String methodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
|
||||
Expression setterArg = variableTracer.traceLocalSetter(methodFqn, sn.getIdentifier(), methodName);
|
||||
if (setterArg != null) {
|
||||
extractConstantsFromExpression(setterArg, constants);
|
||||
if (!constants.isEmpty()) {
|
||||
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<>();
|
||||
@@ -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) {
|
||||
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,348 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Single source-derived policy for marking REST triggers as external vs internal.
|
||||
*/
|
||||
public final class ExternalTriggerPolicy {
|
||||
|
||||
private ExternalTriggerPolicy() {
|
||||
}
|
||||
|
||||
public static boolean isExternalFromSource(
|
||||
EntryPoint entryPoint,
|
||||
TriggerPoint trigger,
|
||||
String entryMethodFqn,
|
||||
String resolvedEventParamName,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null) {
|
||||
return false;
|
||||
}
|
||||
if (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())
|
||||
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) {
|
||||
return false;
|
||||
}
|
||||
if (trigger.getPolymorphicEvents() != null
|
||||
&& !trigger.getPolymorphicEvents().isEmpty()
|
||||
&& MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) {
|
||||
return false;
|
||||
}
|
||||
if (entryPoint != null && entryPoint.getType() == EntryPoint.Type.REST) {
|
||||
String paramName = resolveRestEventParameterName(entryPoint, resolvedEventParamName);
|
||||
if (paramName != null) {
|
||||
RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context);
|
||||
if (binding != null) {
|
||||
if (binding.pathOrQueryVariable() && !binding.enumType()) {
|
||||
if (binding.pathVariable() && !entryPointHasUnboundPlaceholders(entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (binding.requestBodyEnum()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) {
|
||||
RestParamBinding requestBodyField = findRequestBodyEnumFieldBinding(
|
||||
entryPoint.getClassName() + "." + entryPoint.getMethodName(), paramName, context);
|
||||
if (requestBodyField != null && requestBodyField.requestBodyEnum()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entryPoint.getName() != null && entryPoint.getName().contains("{")
|
||||
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
|
||||
if (paramName != null) {
|
||||
RestParamBinding pathBinding = findRestParameterBinding(entryPoint, paramName, context);
|
||||
if (pathBinding != null && pathBinding.pathVariable() && pathBinding.enumType()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (entryMethodFqn != null && resolvedEventParamName != null) {
|
||||
RestParamBinding binding = findMethodParameterBinding(entryMethodFqn, resolvedEventParamName, context);
|
||||
if (binding != null) {
|
||||
if (binding.pathOrQueryVariable() && !binding.enumType()) {
|
||||
return true;
|
||||
}
|
||||
if (binding.requestBodyEnum()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
RestParamBinding requestBodyField = findRequestBodyEnumFieldBinding(
|
||||
entryMethodFqn, resolvedEventParamName, context);
|
||||
if (requestBodyField != null && requestBodyField.requestBodyEnum()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (entryPoint != null && entryPoint.getType() == EntryPoint.Type.REST
|
||||
&& !entryPointHasUnboundPlaceholders(entryPoint)
|
||||
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
|
||||
return false;
|
||||
}
|
||||
return trigger.isExternal();
|
||||
}
|
||||
|
||||
private static boolean entryPointHasUnboundPlaceholders(EntryPoint entryPoint) {
|
||||
if (entryPoint == null) {
|
||||
return false;
|
||||
}
|
||||
if (entryPoint.getName() != null && entryPoint.getName().contains("{")) {
|
||||
return true;
|
||||
}
|
||||
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) {
|
||||
if (resolvedEventParamName != null && !resolvedEventParamName.isBlank()) {
|
||||
return resolvedEventParamName;
|
||||
}
|
||||
if (entryPoint.getParameters() == null) {
|
||||
return null;
|
||||
}
|
||||
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||
if (parameter.getAnnotations() == null) {
|
||||
continue;
|
||||
}
|
||||
for (String annotation : parameter.getAnnotations()) {
|
||||
if ("PathVariable".equals(annotation) || "RequestParam".equals(annotation)) {
|
||||
return parameter.getName();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RestParamBinding findRestParameterBinding(
|
||||
EntryPoint entryPoint, String paramName, CodebaseContext context) {
|
||||
if (entryPoint.getParameters() == null || paramName == null) {
|
||||
return null;
|
||||
}
|
||||
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||
if (!paramName.equals(parameter.getName())) {
|
||||
continue;
|
||||
}
|
||||
return RestParamBinding.fromEntryPointParameter(parameter);
|
||||
}
|
||||
if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) {
|
||||
return findMethodParameterBinding(
|
||||
entryPoint.getClassName() + "." + entryPoint.getMethodName(), paramName, context);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RestParamBinding findMethodParameterBinding(
|
||||
String methodFqn, String paramName, CodebaseContext context) {
|
||||
if (methodFqn == null || !methodFqn.contains(".") || paramName == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
|
||||
if (typeDeclaration == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration method = context.findMethodDeclaration(typeDeclaration, methodName, true);
|
||||
if (method == null) {
|
||||
return null;
|
||||
}
|
||||
for (Object paramObj : method.parameters()) {
|
||||
if (!(paramObj instanceof SingleVariableDeclaration param)) {
|
||||
continue;
|
||||
}
|
||||
if (!paramName.equals(param.getName().getIdentifier())) {
|
||||
continue;
|
||||
}
|
||||
return RestParamBinding.fromAstParameter(param);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RestParamBinding findRequestBodyEnumFieldBinding(
|
||||
String methodFqn, String fieldName, CodebaseContext context) {
|
||||
if (methodFqn == null || !methodFqn.contains(".") || fieldName == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
|
||||
if (typeDeclaration == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration method = context.findMethodDeclaration(typeDeclaration, methodName, true);
|
||||
if (method == null) {
|
||||
return null;
|
||||
}
|
||||
for (Object paramObj : method.parameters()) {
|
||||
if (!(paramObj instanceof SingleVariableDeclaration param)) {
|
||||
continue;
|
||||
}
|
||||
if (!hasParameterAstAnnotation(param, "RequestBody") || param.getType() == null) {
|
||||
continue;
|
||||
}
|
||||
ITypeBinding bodyBinding = param.getType().resolveBinding();
|
||||
if (bodyBinding == null || bodyBinding.isEnum()) {
|
||||
continue;
|
||||
}
|
||||
if (isEnumRecordComponent(bodyBinding, fieldName)) {
|
||||
return new RestParamBinding(false, false, true, true);
|
||||
}
|
||||
if (isEnumDtoField(bodyBinding, fieldName, context)) {
|
||||
return new RestParamBinding(false, false, true, true);
|
||||
}
|
||||
if (containsNestedEnumField(bodyBinding, fieldName, context)) {
|
||||
return new RestParamBinding(false, false, true, true);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isEnumRecordComponent(ITypeBinding bodyBinding, String fieldName) {
|
||||
if (bodyBinding == null || fieldName == null) {
|
||||
return false;
|
||||
}
|
||||
for (IVariableBinding component : bodyBinding.getDeclaredFields()) {
|
||||
if (!fieldName.equals(component.getName())) {
|
||||
continue;
|
||||
}
|
||||
ITypeBinding fieldType = component.getType();
|
||||
return fieldType != null && fieldType.isEnum();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isEnumDtoField(ITypeBinding bodyBinding, String fieldName, CodebaseContext context) {
|
||||
if (bodyBinding == null || fieldName == null || context == null) {
|
||||
return false;
|
||||
}
|
||||
String bodyFqn = bodyBinding.getQualifiedName();
|
||||
if (bodyFqn == null) {
|
||||
return false;
|
||||
}
|
||||
AbstractTypeDeclaration bodyType = context.getAbstractTypeDeclaration(bodyFqn);
|
||||
if (!(bodyType instanceof TypeDeclaration td)) {
|
||||
return false;
|
||||
}
|
||||
for (FieldDeclaration field : td.getFields()) {
|
||||
for (Object fragObj : field.fragments()) {
|
||||
if (!(fragObj instanceof VariableDeclarationFragment fragment)) {
|
||||
continue;
|
||||
}
|
||||
if (!fieldName.equals(fragment.getName().getIdentifier())) {
|
||||
continue;
|
||||
}
|
||||
ITypeBinding fieldBinding = field.getType().resolveBinding();
|
||||
return fieldBinding != null && fieldBinding.isEnum();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean containsNestedEnumField(
|
||||
ITypeBinding bodyBinding, String fieldName, CodebaseContext context) {
|
||||
return containsNestedEnumField(bodyBinding, fieldName, context, new HashSet<>());
|
||||
}
|
||||
|
||||
private static boolean containsNestedEnumField(
|
||||
ITypeBinding bodyBinding,
|
||||
String fieldName,
|
||||
CodebaseContext context,
|
||||
Set<String> visited) {
|
||||
if (bodyBinding == null || fieldName == null || context == null) {
|
||||
return false;
|
||||
}
|
||||
String bodyFqn = bodyBinding.getQualifiedName();
|
||||
if (bodyFqn == null || !visited.add(bodyFqn)) {
|
||||
return false;
|
||||
}
|
||||
for (IVariableBinding component : bodyBinding.getDeclaredFields()) {
|
||||
ITypeBinding nestedType = component.getType();
|
||||
if (nestedType == null || nestedType.isPrimitive() || nestedType.isEnum()) {
|
||||
continue;
|
||||
}
|
||||
if (isEnumRecordComponent(nestedType, fieldName) || isEnumDtoField(nestedType, fieldName, context)) {
|
||||
return true;
|
||||
}
|
||||
if (containsNestedEnumField(nestedType, fieldName, context, visited)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasParameterAstAnnotation(SingleVariableDeclaration param, String simpleName) {
|
||||
for (Object modifier : param.modifiers()) {
|
||||
if (modifier instanceof Annotation annotation
|
||||
&& annotation.getTypeName().getFullyQualifiedName().endsWith(simpleName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private record RestParamBinding(boolean pathVariable, boolean requestParam, boolean requestBody, boolean enumType) {
|
||||
boolean pathOrQueryVariable() {
|
||||
return pathVariable || requestParam;
|
||||
}
|
||||
|
||||
boolean requestBodyEnum() {
|
||||
return requestBody && enumType;
|
||||
}
|
||||
|
||||
static RestParamBinding fromEntryPointParameter(EntryPoint.Parameter parameter) {
|
||||
boolean pathVariable = hasAnnotation(parameter.getAnnotations(), "PathVariable");
|
||||
boolean requestParam = hasAnnotation(parameter.getAnnotations(), "RequestParam");
|
||||
boolean requestBody = hasAnnotation(parameter.getAnnotations(), "RequestBody");
|
||||
boolean enumType = isEnumTypeName(parameter.getType());
|
||||
return new RestParamBinding(pathVariable, requestParam, requestBody, enumType);
|
||||
}
|
||||
|
||||
static RestParamBinding fromAstParameter(SingleVariableDeclaration param) {
|
||||
boolean pathVariable = hasParameterAstAnnotation(param, "PathVariable");
|
||||
boolean requestParam = hasParameterAstAnnotation(param, "RequestParam");
|
||||
boolean requestBody = hasParameterAstAnnotation(param, "RequestBody");
|
||||
boolean enumType = param.getType() != null && param.getType().resolveBinding() != null
|
||||
&& param.getType().resolveBinding().isEnum();
|
||||
return new RestParamBinding(pathVariable, requestParam, requestBody, enumType);
|
||||
}
|
||||
|
||||
private static boolean hasAnnotation(java.util.List<String> annotations, String name) {
|
||||
return annotations != null && annotations.stream().anyMatch(name::equals);
|
||||
}
|
||||
|
||||
private static boolean isEnumTypeName(String typeName) {
|
||||
if (typeName == null || typeName.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
int lastDot = typeName.lastIndexOf('.');
|
||||
String simple = lastDot >= 0 ? typeName.substring(lastDot + 1) : typeName;
|
||||
return Character.isUpperCase(simple.charAt(0)) && !simple.endsWith("String");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Recognizes JDK functional interface parameters used as delayed event providers
|
||||
* ({@code Supplier.get()}, {@code Runnable.run()}, {@code Consumer.accept()}, etc.)
|
||||
* in call-graph argument tracing.
|
||||
*/
|
||||
public final class FunctionalInterfaceTypes {
|
||||
|
||||
private static final Set<String> SAM_METHOD_NAMES = Set.of(
|
||||
"run", "get", "call", "accept", "apply", "test");
|
||||
|
||||
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) {
|
||||
if (typeName == null || typeName.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String simple = typeName.contains(".") ? typeName.substring(typeName.lastIndexOf('.') + 1) : typeName;
|
||||
if (simple.contains("<")) {
|
||||
simple = simple.substring(0, simple.indexOf('<'));
|
||||
}
|
||||
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) {
|
||||
if (argValue == null) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = argValue.trim();
|
||||
return trimmed.contains("->")
|
||||
&& (trimmed.startsWith("(") || trimmed.matches("^\\w+\\s*->.*"));
|
||||
}
|
||||
|
||||
/**
|
||||
* When the caller passes a lambda or enum literal into a functional parameter, skip
|
||||
* incompatible-type rejection between {@code Supplier<T>} and resolved {@code T}.
|
||||
*/
|
||||
public static boolean isProvablyResolvedCallSiteArgument(String argValue, String expectedType) {
|
||||
if (argValue == null || argValue.isBlank() || expectedType == null) {
|
||||
return false;
|
||||
}
|
||||
if (!isFunctionalInterface(expectedType)) {
|
||||
return false;
|
||||
}
|
||||
if (looksLikeEnumConstant(argValue)) {
|
||||
return true;
|
||||
}
|
||||
return isLambdaArgument(argValue);
|
||||
}
|
||||
|
||||
static boolean looksLikeEnumConstant(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String name = value.contains(".") ? value.substring(value.lastIndexOf('.') + 1) : value;
|
||||
return name.matches("[A-Z_][A-Z0-9_]*");
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,11 @@ public class GenericEventDetector {
|
||||
private static final Set<String> TRIGGER_METHOD_NAMES = Set.of(
|
||||
"sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger");
|
||||
|
||||
/** Built-in source patterns for Spring State Machine and messaging APIs (no hints.json required). */
|
||||
private static final List<LibraryHint> BUILTIN_SOURCE_PATTERNS = List.of(
|
||||
LibraryHint.builder().methodFqn("org.springframework.messaging.support.MessageBuilder.build").eventArgumentIndex(0).build(),
|
||||
LibraryHint.builder().methodFqn("org.springframework.messaging.support.GenericMessage.getPayload").eventArgumentMethod("getPayload").build());
|
||||
|
||||
public List<TriggerPoint> detect(CompilationUnit cu) {
|
||||
List<TriggerPoint> triggers = new ArrayList<>();
|
||||
String fileName = "unknown";
|
||||
@@ -68,6 +73,7 @@ public class GenericEventDetector {
|
||||
}
|
||||
}
|
||||
|
||||
processBuiltInPatterns(node, cu, triggers);
|
||||
processHints(node, cu, triggers);
|
||||
|
||||
return super.visit(node);
|
||||
@@ -88,6 +94,10 @@ public class GenericEventDetector {
|
||||
if (type == null) return;
|
||||
|
||||
String sourceState = extractSourceState(node);
|
||||
if (sourceState == null) {
|
||||
sourceState = extractSourceStateFromArguments(node);
|
||||
}
|
||||
String stateMachineId = extractStateMachineId(node);
|
||||
String[] smTypes = resolveStateMachineTypeArgumentsForExpression(emr.getExpression(), node);
|
||||
|
||||
boolean external = false;
|
||||
@@ -101,6 +111,7 @@ public class GenericEventDetector {
|
||||
triggers.add(TriggerPoint.builder()
|
||||
.event(eventValue)
|
||||
.sourceState(sourceState)
|
||||
.stateMachineId(stateMachineId)
|
||||
.className(context.getFqn(type))
|
||||
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
|
||||
.sourceFile(context.getRelativePath(context.getFqn(type)))
|
||||
@@ -119,16 +130,10 @@ public class GenericEventDetector {
|
||||
return reactivePayload;
|
||||
}
|
||||
if (receiver == null) return null;
|
||||
if (receiver instanceof MethodInvocation mi) {
|
||||
String mName = mi.getName().getIdentifier();
|
||||
if (("just".equals(mName) || "withPayload".equals(mName) || "success".equals(mName)) && !mi.arguments().isEmpty()) {
|
||||
Expression arg = (Expression) mi.arguments().get(0);
|
||||
String resolved = constantResolver.resolve(arg, context);
|
||||
return resolved != null ? resolved : arg.toString();
|
||||
}
|
||||
if (mi.getExpression() != null) {
|
||||
return extractEventFromReceiver(mi.getExpression());
|
||||
}
|
||||
Expression factoryPayload = ReactiveExpressionSupport.peelFactoryPayloadExpression(receiver);
|
||||
if (factoryPayload != null) {
|
||||
String resolved = constantResolver.resolve(factoryPayload, context);
|
||||
return resolved != null ? resolved : factoryPayload.toString();
|
||||
}
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
return sn.getIdentifier();
|
||||
@@ -176,25 +181,50 @@ public class GenericEventDetector {
|
||||
}
|
||||
}
|
||||
|
||||
private void processBuiltInPatterns(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
|
||||
String methodName = node.getName().getIdentifier();
|
||||
if (TRIGGER_METHOD_NAMES.contains(methodName)) {
|
||||
return;
|
||||
}
|
||||
String calledMethod = resolveCalledMethodName(node);
|
||||
if (calledMethod == null) {
|
||||
return;
|
||||
}
|
||||
for (LibraryHint pattern : BUILTIN_SOURCE_PATTERNS) {
|
||||
if (!matchesHint(calledMethod, pattern.getMethodFqn())) {
|
||||
continue;
|
||||
}
|
||||
String eventToUse = resolveHintEvent(node, pattern);
|
||||
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
|
||||
if (builtTriggers != null) {
|
||||
for (TriggerPoint trigger : builtTriggers) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Built trigger from built-in source pattern {}: {}", pattern.getMethodFqn(), trigger.getEvent());
|
||||
}
|
||||
triggers.add(trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processHints(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
|
||||
if (hints == null || hints.isEmpty()) return;
|
||||
if (hints == null || hints.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String calledMethod = resolveCalledMethodName(node);
|
||||
if (calledMethod == null) return;
|
||||
if (calledMethod == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (LibraryHint hint : hints) {
|
||||
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) {
|
||||
String eventToUse = hint.getEvent();
|
||||
if (eventToUse == null && hint.getEventArgumentIndex() != null) {
|
||||
if (node.arguments().size() > hint.getEventArgumentIndex()) {
|
||||
Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex());
|
||||
eventToUse = argExpr.toString();
|
||||
if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) {
|
||||
eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()";
|
||||
if (!matchesHint(calledMethod, hint.getMethodFqn())) {
|
||||
continue;
|
||||
}
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Applying optional hints.json override for {}", hint.getMethodFqn());
|
||||
}
|
||||
}
|
||||
|
||||
String eventToUse = resolveHintEvent(node, hint);
|
||||
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
|
||||
if (builtTriggers != null) {
|
||||
for (TriggerPoint trigger : builtTriggers) {
|
||||
@@ -206,10 +236,23 @@ public class GenericEventDetector {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveHintEvent(MethodInvocation node, LibraryHint hint) {
|
||||
String eventToUse = hint.getEvent();
|
||||
if (eventToUse == null && hint.getEventArgumentIndex() != null) {
|
||||
if (node.arguments().size() > hint.getEventArgumentIndex()) {
|
||||
Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex());
|
||||
eventToUse = argExpr.toString();
|
||||
if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) {
|
||||
eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()";
|
||||
}
|
||||
}
|
||||
}
|
||||
return eventToUse;
|
||||
}
|
||||
|
||||
private boolean isHintMatch(String called, String hintFqn) {
|
||||
return hintFqn.endsWith("." + called);
|
||||
private static boolean matchesHint(String calledMethod, String hintFqn) {
|
||||
return calledMethod.equals(hintFqn) || hintFqn.endsWith("." + calledMethod);
|
||||
}
|
||||
|
||||
private String resolveCalledMethodName(MethodInvocation node) {
|
||||
@@ -261,6 +304,10 @@ public class GenericEventDetector {
|
||||
if (type == null) return Collections.emptyList();
|
||||
|
||||
String sourceState = extractSourceState(node);
|
||||
if (sourceState == null && node instanceof MethodInvocation mi) {
|
||||
sourceState = extractSourceStateFromArguments(mi);
|
||||
}
|
||||
String stateMachineId = extractStateMachineId(node);
|
||||
String[] smTypes = resolveStateMachineTypeArguments(node);
|
||||
|
||||
boolean external = false;
|
||||
@@ -280,6 +327,7 @@ public class GenericEventDetector {
|
||||
results.add(TriggerPoint.builder()
|
||||
.event(part.trim())
|
||||
.sourceState(sourceState)
|
||||
.stateMachineId(stateMachineId)
|
||||
.className(context.getFqn(type))
|
||||
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
|
||||
.sourceFile(context.getRelativePath(context.getFqn(type)))
|
||||
@@ -295,6 +343,7 @@ public class GenericEventDetector {
|
||||
results.add(TriggerPoint.builder()
|
||||
.event(eventValue)
|
||||
.sourceState(sourceState)
|
||||
.stateMachineId(stateMachineId)
|
||||
.className(context.getFqn(type))
|
||||
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
|
||||
.sourceFile(context.getRelativePath(context.getFqn(type)))
|
||||
@@ -333,6 +382,21 @@ public class GenericEventDetector {
|
||||
String state = extractStateFromSiblings(current, switchStmt.statements());
|
||||
if (state != null) return state;
|
||||
}
|
||||
} else if (parent instanceof SwitchExpression switchExpr) {
|
||||
if (!isRoutingParameter(switchExpr.getExpression())) {
|
||||
String state = extractStateFromSiblings(current, switchExpr.statements());
|
||||
if (state != null) return state;
|
||||
}
|
||||
} else if (parent instanceof SwitchCase switchCase && !switchCase.isDefault()) {
|
||||
Expression selector = resolveSwitchSelector(switchCase);
|
||||
if (selector != null && !isRoutingParameter(selector) && !switchCase.expressions().isEmpty()) {
|
||||
Expression caseExpr = (Expression) switchCase.expressions().get(0);
|
||||
String resolved = resolveProvableStateLiteral(caseExpr);
|
||||
if (resolved != null) {
|
||||
return resolved;
|
||||
}
|
||||
return getSimpleNameString(caseExpr);
|
||||
}
|
||||
}
|
||||
|
||||
current = parent;
|
||||
@@ -340,6 +404,251 @@ public class GenericEventDetector {
|
||||
return null;
|
||||
}
|
||||
|
||||
private Expression resolveSwitchSelector(SwitchCase switchCase) {
|
||||
ASTNode switchParent = switchCase.getParent();
|
||||
if (switchParent instanceof SwitchStatement switchStatement) {
|
||||
return switchStatement.getExpression();
|
||||
}
|
||||
if (switchParent instanceof SwitchExpression switchExpression) {
|
||||
return switchExpression.getExpression();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractStateMachineId(MethodInvocation node) {
|
||||
if (!TRIGGER_METHOD_NAMES.contains(node.getName().getIdentifier())) {
|
||||
return null;
|
||||
}
|
||||
Expression receiver = node.getExpression();
|
||||
while (receiver instanceof MethodInvocation methodInvocation) {
|
||||
receiver = methodInvocation.getExpression();
|
||||
}
|
||||
if (receiver == null) {
|
||||
return null;
|
||||
}
|
||||
AbstractTypeDeclaration enclosing = findEnclosingAbstractType(node);
|
||||
if (enclosing == null) {
|
||||
return null;
|
||||
}
|
||||
return resolveQualifierForReceiver(receiver, enclosing);
|
||||
}
|
||||
|
||||
private String resolveQualifierForReceiver(Expression receiver, AbstractTypeDeclaration enclosing) {
|
||||
String fieldName = null;
|
||||
if (receiver instanceof SimpleName simpleName) {
|
||||
fieldName = simpleName.getIdentifier();
|
||||
IBinding binding = simpleName.resolveBinding();
|
||||
if (binding instanceof IVariableBinding variableBinding) {
|
||||
String fromBinding = extractQualifierFromBinding(variableBinding);
|
||||
if (fromBinding != null) {
|
||||
return fromBinding;
|
||||
}
|
||||
}
|
||||
} else if (receiver instanceof FieldAccess fieldAccess) {
|
||||
fieldName = fieldAccess.getName().getIdentifier();
|
||||
}
|
||||
if (fieldName == null) {
|
||||
return null;
|
||||
}
|
||||
return findQualifierOnField(enclosing, fieldName);
|
||||
}
|
||||
|
||||
private String findQualifierOnField(AbstractTypeDeclaration enclosing, String fieldName) {
|
||||
for (FieldDeclaration fieldDeclaration : getFieldDeclarations(enclosing)) {
|
||||
for (Object fragmentObj : fieldDeclaration.fragments()) {
|
||||
if (fragmentObj instanceof VariableDeclarationFragment fragment
|
||||
&& fragment.getName().getIdentifier().equals(fieldName)) {
|
||||
String qualifier = extractQualifierAnnotation(fieldDeclaration.modifiers());
|
||||
if (qualifier != null) {
|
||||
return qualifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enclosing instanceof TypeDeclaration typeDeclaration) {
|
||||
for (MethodDeclaration method : typeDeclaration.getMethods()) {
|
||||
if (!hasAutowiredAnnotation(method) || method.parameters().size() != 1) {
|
||||
continue;
|
||||
}
|
||||
SingleVariableDeclaration param = (SingleVariableDeclaration) method.parameters().get(0);
|
||||
if (!matchesInjectionTarget(method, fieldName, param)) {
|
||||
continue;
|
||||
}
|
||||
String qualifier = extractQualifierAnnotation(param.modifiers());
|
||||
if (qualifier != null) {
|
||||
return qualifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean hasAutowiredAnnotation(MethodDeclaration method) {
|
||||
for (Object modifierObj : method.modifiers()) {
|
||||
if (modifierObj instanceof Annotation annotation
|
||||
&& annotation.getTypeName().toString().endsWith("Autowired")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean matchesInjectionTarget(
|
||||
MethodDeclaration method, String fieldName, SingleVariableDeclaration param) {
|
||||
if (fieldName.equals(param.getName().getIdentifier())) {
|
||||
return true;
|
||||
}
|
||||
String methodName = method.getName().getIdentifier();
|
||||
if (methodName.startsWith("set") && methodName.length() > 3) {
|
||||
String propertyName = methodName.substring(3);
|
||||
return propertyName.equalsIgnoreCase(fieldName)
|
||||
|| (Character.toLowerCase(propertyName.charAt(0)) + propertyName.substring(1)).equals(fieldName);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String extractQualifierFromBinding(IVariableBinding binding) {
|
||||
if (binding == null) {
|
||||
return null;
|
||||
}
|
||||
String qualifier = readQualifierFromAnnotations(binding.getAnnotations());
|
||||
if (qualifier != null) {
|
||||
return qualifier;
|
||||
}
|
||||
if (binding.isField() && binding.getDeclaringClass() != null) {
|
||||
for (IMethodBinding method : binding.getDeclaringClass().getDeclaredMethods()) {
|
||||
boolean isAutowiredMethod = false;
|
||||
for (IAnnotationBinding ann : method.getAnnotations()) {
|
||||
if (ann.getAnnotationType() != null
|
||||
&& "org.springframework.beans.factory.annotation.Autowired"
|
||||
.equals(ann.getAnnotationType().getQualifiedName())) {
|
||||
isAutowiredMethod = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!method.isConstructor() && !isAutowiredMethod) {
|
||||
continue;
|
||||
}
|
||||
for (int i = 0; i < method.getParameterTypes().length; i++) {
|
||||
if (!method.getParameterTypes()[i].isEqualTo(binding.getType().getErasure())) {
|
||||
continue;
|
||||
}
|
||||
if (method.isConstructor() && !binding.getName().equals(method.getParameterNames()[i])) {
|
||||
continue;
|
||||
}
|
||||
if (!method.isConstructor() && !matchesSetterParameterName(method.getName(), binding.getName())) {
|
||||
continue;
|
||||
}
|
||||
String paramQualifier = readQualifierFromAnnotations(method.getParameterAnnotations(i));
|
||||
if (paramQualifier != null) {
|
||||
return paramQualifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean matchesSetterParameterName(String methodName, String fieldName) {
|
||||
if (methodName == null || fieldName == null) {
|
||||
return false;
|
||||
}
|
||||
if (methodName.startsWith("set") && methodName.length() > 3) {
|
||||
String propertyName = methodName.substring(3);
|
||||
return propertyName.equalsIgnoreCase(fieldName)
|
||||
|| (Character.toLowerCase(propertyName.charAt(0)) + propertyName.substring(1)).equals(fieldName);
|
||||
}
|
||||
return fieldName.equals(methodName);
|
||||
}
|
||||
|
||||
private String readQualifierFromAnnotations(IAnnotationBinding[] annotations) {
|
||||
if (annotations == null) {
|
||||
return null;
|
||||
}
|
||||
for (IAnnotationBinding annotation : annotations) {
|
||||
if (annotation.getAnnotationType() == null
|
||||
|| !"org.springframework.beans.factory.annotation.Qualifier"
|
||||
.equals(annotation.getAnnotationType().getQualifiedName())) {
|
||||
continue;
|
||||
}
|
||||
for (IMemberValuePairBinding pair : annotation.getDeclaredMemberValuePairs()) {
|
||||
Object value = pair.getValue();
|
||||
if (value instanceof String stringValue) {
|
||||
return stringValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractQualifierAnnotation(List<?> modifiers) {
|
||||
if (modifiers == null) {
|
||||
return null;
|
||||
}
|
||||
for (Object modifierObj : modifiers) {
|
||||
if (!(modifierObj instanceof Annotation annotation)) {
|
||||
continue;
|
||||
}
|
||||
String typeName = annotation.getTypeName().toString();
|
||||
if (!typeName.endsWith("Qualifier")) {
|
||||
continue;
|
||||
}
|
||||
String value = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(
|
||||
annotation, "value");
|
||||
if (value == null || value.isBlank()) {
|
||||
value = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(
|
||||
annotation, "name");
|
||||
}
|
||||
return stripAnnotationQuotes(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String stripAnnotationQuotes(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = raw.trim();
|
||||
if (trimmed.length() >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
|
||||
return trimmed.substring(1, trimmed.length() - 1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers source state from a literal second argument to sendEvent(event, sourceState) when provable from AST.
|
||||
*/
|
||||
private String extractSourceStateFromArguments(MethodInvocation node) {
|
||||
if (node.arguments().size() < 2) {
|
||||
return null;
|
||||
}
|
||||
Expression stateExpr = (Expression) node.arguments().get(1);
|
||||
return resolveProvableStateLiteral(stateExpr);
|
||||
}
|
||||
|
||||
private String resolveProvableStateLiteral(Expression expr) {
|
||||
if (expr == null) {
|
||||
return null;
|
||||
}
|
||||
Expression peeled = expr;
|
||||
while (peeled instanceof ParenthesizedExpression pe) {
|
||||
peeled = pe.getExpression();
|
||||
}
|
||||
while (peeled instanceof CastExpression ce) {
|
||||
peeled = ce.getExpression();
|
||||
}
|
||||
if (peeled instanceof QualifiedName || peeled instanceof FieldAccess || peeled instanceof StringLiteral) {
|
||||
String resolved = constantResolver.resolve(peeled, context);
|
||||
if (resolved != null && !resolved.isBlank()) {
|
||||
return getSimpleNameString(peeled);
|
||||
}
|
||||
if (peeled instanceof StringLiteral sl) {
|
||||
return sl.getLiteralValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractStateFromSiblings(ASTNode currentNode, List<?> statements) {
|
||||
int index = statements.indexOf(currentNode);
|
||||
if (index <= 0) return null; // No previous siblings
|
||||
@@ -394,19 +703,14 @@ public class GenericEventDetector {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Usually one is a method call like getState() or a variable like `state`
|
||||
// and the other is the constant enum like `OrderState.PENDING` or `"PENDING"`
|
||||
|
||||
// If one is a QualifiedName (enum constant) or StringLiteral, it's likely the state
|
||||
if (left instanceof QualifiedName || left instanceof StringLiteral || left instanceof FieldAccess) {
|
||||
return getSimpleNameString(left);
|
||||
String leftState = resolveProvableStateLiteral(left);
|
||||
if (leftState != null) {
|
||||
return leftState;
|
||||
}
|
||||
if (right instanceof QualifiedName || right instanceof StringLiteral || right instanceof FieldAccess) {
|
||||
return getSimpleNameString(right);
|
||||
String rightState = resolveProvableStateLiteral(right);
|
||||
if (rightState != null) {
|
||||
return rightState;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return getSimpleNameString(right);
|
||||
}
|
||||
} else if (expr instanceof MethodInvocation mi) {
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
@@ -418,21 +722,11 @@ public class GenericEventDetector {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If receiver is null (e.g., implicit this), fall back to arg
|
||||
if (receiver == null) {
|
||||
return getSimpleNameString(arg);
|
||||
String receiverState = resolveProvableStateLiteral(receiver);
|
||||
if (receiverState != null) {
|
||||
return receiverState;
|
||||
}
|
||||
|
||||
// Prioritize the one that looks like a constant (QualifiedName or StringLiteral)
|
||||
if (receiver instanceof QualifiedName || receiver instanceof StringLiteral || receiver instanceof FieldAccess) {
|
||||
return getSimpleNameString(receiver);
|
||||
}
|
||||
if (arg instanceof QualifiedName || arg instanceof StringLiteral || arg instanceof FieldAccess) {
|
||||
return getSimpleNameString(arg);
|
||||
}
|
||||
|
||||
// Fallback to receiver
|
||||
return getSimpleNameString(receiver);
|
||||
return resolveProvableStateLiteral(arg);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -504,36 +798,33 @@ public class GenericEventDetector {
|
||||
|
||||
if (!(expr instanceof MethodInvocation mi)) return null;
|
||||
|
||||
// Trace back chain: MessageBuilder.withPayload(event).setHeader(...).build()
|
||||
MethodInvocation current = mi;
|
||||
while (current != null) {
|
||||
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);
|
||||
Expression factoryPayload = ReactiveExpressionSupport.peelFactoryPayloadExpression(mi);
|
||||
if (factoryPayload != null) {
|
||||
String extracted = extractEventFromMessageBuilder(factoryPayload);
|
||||
if (extracted != null) {
|
||||
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();
|
||||
} 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,
|
||||
// return the provider's name so the call-graph engine can trace the lambda argument
|
||||
String traced = extractEventFromMessageBuilder(sn);
|
||||
|
||||
@@ -28,12 +28,14 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
|
||||
}
|
||||
}
|
||||
|
||||
if (receiver == null) {
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
if (td != null) {
|
||||
return resolveMethodInTypeHierarchy(td, methodName);
|
||||
TypeDeclaration enclosingType = findEnclosingType(node);
|
||||
|
||||
if (receiver instanceof SuperMethodInvocation) {
|
||||
return InheritanceCallTargetResolver.resolveSuperMethod(context, enclosingType, methodName);
|
||||
}
|
||||
return null;
|
||||
|
||||
if (receiver == null) {
|
||||
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
|
||||
}
|
||||
|
||||
ITypeBinding binding = receiver.resolveTypeBinding();
|
||||
@@ -42,10 +44,7 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
|
||||
}
|
||||
|
||||
if (receiver instanceof ThisExpression) {
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
if (td != null) {
|
||||
return context.getFqn(td) + "." + methodName;
|
||||
}
|
||||
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
|
||||
}
|
||||
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.ASTNode;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
/**
|
||||
* Resolves call-graph target FQNs for {@code this}, implicit, and {@code super} dispatch hops.
|
||||
* Uses the declaring type of the resolved method (parent class for inherited members).
|
||||
*/
|
||||
final class InheritanceCallTargetResolver {
|
||||
|
||||
private InheritanceCallTargetResolver() {
|
||||
}
|
||||
|
||||
static String resolveInstanceMethod(
|
||||
CodebaseContext context,
|
||||
TypeDeclaration enclosingType,
|
||||
String methodName) {
|
||||
if (context == null || enclosingType == null || methodName == null || methodName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration method = context.findMethodDeclaration(enclosingType, methodName, true);
|
||||
if (method == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration declaringType = findEnclosingTypeDeclaration(method);
|
||||
if (declaringType == null) {
|
||||
return null;
|
||||
}
|
||||
String fqn = context.getFqn(declaringType);
|
||||
return fqn == null ? null : fqn + "." + methodName;
|
||||
}
|
||||
|
||||
static String resolveSuperMethod(
|
||||
CodebaseContext context,
|
||||
TypeDeclaration enclosingType,
|
||||
String methodName) {
|
||||
if (context == null || enclosingType == null || methodName == null || methodName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String superFqn = context.getSuperclassFqn(enclosingType);
|
||||
if (superFqn == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
|
||||
if (superType == null) {
|
||||
return superFqn + "." + methodName;
|
||||
}
|
||||
String resolved = resolveInstanceMethod(context, superType, methodName);
|
||||
return resolved != null ? resolved : superFqn + "." + methodName;
|
||||
}
|
||||
|
||||
private static TypeDeclaration findEnclosingTypeDeclaration(ASTNode node) {
|
||||
ASTNode current = node;
|
||||
while (current != null) {
|
||||
if (current instanceof TypeDeclaration typeDeclaration) {
|
||||
return typeDeclaration;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -56,12 +57,14 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
||||
}
|
||||
}
|
||||
|
||||
if (receiver == null) {
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
if (td != null) {
|
||||
return resolveMethodInType(td, methodName);
|
||||
TypeDeclaration enclosingType = findEnclosingType(node);
|
||||
|
||||
if (receiver instanceof SuperMethodInvocation) {
|
||||
return InheritanceCallTargetResolver.resolveSuperMethod(context, enclosingType, methodName);
|
||||
}
|
||||
return null;
|
||||
|
||||
if (receiver == null) {
|
||||
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
|
||||
}
|
||||
|
||||
if (injectionAnalyzer != null) {
|
||||
@@ -122,10 +125,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
||||
}
|
||||
|
||||
if (receiver instanceof ThisExpression) {
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
if (td != null) {
|
||||
return context.getFqn(td) + "." + methodName;
|
||||
}
|
||||
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
|
||||
}
|
||||
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
@@ -140,21 +140,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
||||
}
|
||||
|
||||
private Expression unwrapMessageBuilder(Expression expr) {
|
||||
if (expr instanceof MethodInvocation mi) {
|
||||
MethodInvocation current = mi;
|
||||
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;
|
||||
Expression peeled = ReactiveExpressionSupport.peelFactoryPayloadExpression(expr);
|
||||
return peeled != null ? peeled : expr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -21,6 +20,7 @@ public class PathBindingEvaluator {
|
||||
private final VariableTracer variableTracer;
|
||||
private final ConstantResolver constantResolver;
|
||||
private final TypeResolver typeResolver;
|
||||
private final PathBindingExpressionResolver expressionResolver;
|
||||
private final Map<String, Map<String, String>> traceBindingsCache = new HashMap<>();
|
||||
|
||||
public PathBindingEvaluator(
|
||||
@@ -28,10 +28,20 @@ public class PathBindingEvaluator {
|
||||
VariableTracer variableTracer,
|
||||
ConstantResolver constantResolver,
|
||||
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.variableTracer = variableTracer;
|
||||
this.constantResolver = constantResolver;
|
||||
this.typeResolver = typeResolver;
|
||||
this.expressionResolver = expressionResolver;
|
||||
}
|
||||
|
||||
/** Visible for tests: bindings accumulated while walking a path forward (ignores constraint rejection). */
|
||||
@@ -55,6 +65,8 @@ public class PathBindingEvaluator {
|
||||
|
||||
private Map<String, String> computeTraceBindings(
|
||||
List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
|
||||
context.setAnalysisCallPath(path);
|
||||
try {
|
||||
Map<String, String> bindings = new HashMap<>();
|
||||
for (int i = 0; i < path.size() - 1; i++) {
|
||||
String caller = path.get(i);
|
||||
@@ -66,6 +78,9 @@ public class PathBindingEvaluator {
|
||||
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings);
|
||||
}
|
||||
return bindings;
|
||||
} finally {
|
||||
context.clearAnalysisCallPath();
|
||||
}
|
||||
}
|
||||
|
||||
private static String pathCacheKey(List<String> path) {
|
||||
@@ -143,7 +158,24 @@ public class PathBindingEvaluator {
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
CallGraphPathFinder pathFinder,
|
||||
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);
|
||||
|
||||
List<String> targetParams = typeResolver.getParameterNames(target);
|
||||
@@ -153,6 +185,14 @@ public class PathBindingEvaluator {
|
||||
for (int i = 0; i < edge.getArguments().size() && i < targetParams.size(); i++) {
|
||||
String arg = edge.getArguments().get(i);
|
||||
String paramName = targetParams.get(i);
|
||||
if (typeResolver != null
|
||||
&& FunctionalInterfaceTypes.isFunctionalInterface(typeResolver.getParameterType(target, i))) {
|
||||
List<String> constants = variableTracer.resolveConstantsFromCallSiteArgument(caller, target, i, edge);
|
||||
if (constants.size() == 1) {
|
||||
bindings.put(paramName, constants.get(0));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
String resolved = resolveBindingValue(caller, arg, bindings);
|
||||
if (shouldStoreBinding(paramName, resolved)) {
|
||||
bindings.put(paramName, resolved);
|
||||
@@ -197,7 +237,7 @@ public class PathBindingEvaluator {
|
||||
if (traced.startsWith("\"") || isEnumLikeConstant(traced)) {
|
||||
return traced;
|
||||
}
|
||||
String fromCall = resolveMethodCallFromSource(callerFqn, rawValue, bindings);
|
||||
String fromCall = expressionResolver.resolveMethodCallFromSource(callerFqn, rawValue, bindings);
|
||||
if (fromCall != null) {
|
||||
return fromCall;
|
||||
}
|
||||
@@ -208,155 +248,6 @@ public class PathBindingEvaluator {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
private String resolveMethodCallFromSource(String callerFqn, String varName, Map<String, String> bindings) {
|
||||
MethodDeclaration md = findMethodDeclaration(callerFqn);
|
||||
if (md == null || md.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
final Expression[] initializer = new Expression[1];
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(VariableDeclarationFragment node) {
|
||||
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
|
||||
initializer[0] = node.getInitializer();
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
if (initializer[0] == null) {
|
||||
return null;
|
||||
}
|
||||
return evaluateExpressionValue(initializer[0], callerFqn, bindings);
|
||||
}
|
||||
|
||||
private String evaluateExpressionValue(Expression expr, String callerFqn, Map<String, String> bindings) {
|
||||
if (expr instanceof StringLiteral sl) {
|
||||
return "\"" + sl.getLiteralValue() + "\"";
|
||||
}
|
||||
if (expr instanceof QualifiedName qn) {
|
||||
return qn.getFullyQualifiedName();
|
||||
}
|
||||
if (expr instanceof SimpleName sn) {
|
||||
return bindings.getOrDefault(sn.getIdentifier(), sn.getIdentifier());
|
||||
}
|
||||
if (expr instanceof MethodInvocation mi) {
|
||||
return evaluateMethodInvocationReturn(mi, callerFqn, bindings);
|
||||
}
|
||||
if (expr instanceof SwitchExpression se) {
|
||||
return constantResolver.evaluateSwitchWithParams(se, bindings, context);
|
||||
}
|
||||
String resolved = constantResolver.resolve(expr, context);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private String evaluateMethodInvocationReturn(MethodInvocation mi, String callerFqn, Map<String, String> bindings) {
|
||||
String ownerFqn = resolveMethodOwnerFqn(mi, callerFqn);
|
||||
if (ownerFqn == null) {
|
||||
return null;
|
||||
}
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
TypeDeclaration owner = context.getTypeDeclaration(ownerFqn);
|
||||
if (owner == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true);
|
||||
if (md == null || md.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, String> callBindings = new HashMap<>(bindings);
|
||||
for (int i = 0; i < md.parameters().size() && i < mi.arguments().size(); i++) {
|
||||
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i);
|
||||
String paramName = param.getName().getIdentifier();
|
||||
String argValue = evaluateExpressionValue((Expression) mi.arguments().get(i), callerFqn, callBindings);
|
||||
if (argValue != null) {
|
||||
callBindings.put(paramName, argValue);
|
||||
}
|
||||
}
|
||||
|
||||
final String[] result = new String[1];
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(ReturnStatement node) {
|
||||
if (node.getExpression() instanceof SwitchExpression se) {
|
||||
result[0] = constantResolver.evaluateSwitchWithParams(se, callBindings, context);
|
||||
} else if (node.getExpression() != null) {
|
||||
result[0] = evaluateExpressionValue(node.getExpression(), ownerFqn + "." + methodName, callBindings);
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return result[0];
|
||||
}
|
||||
|
||||
private String resolveFieldTypeFqn(String callerFqn, String fieldName) {
|
||||
if (callerFqn == null || !callerFqn.contains(".")) {
|
||||
return null;
|
||||
}
|
||||
String className = callerFqn.substring(0, callerFqn.lastIndexOf('.'));
|
||||
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
for (FieldDeclaration field : td.getFields()) {
|
||||
for (Object fragmentObj : field.fragments()) {
|
||||
VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj;
|
||||
if (fragment.getName().getIdentifier().equals(fieldName)) {
|
||||
IVariableBinding binding = fragment.resolveBinding();
|
||||
if (binding != null && binding.getType() != null) {
|
||||
return binding.getType().getErasure().getQualifiedName();
|
||||
}
|
||||
String simpleType = field.getType().toString();
|
||||
TypeDeclaration resolved = context.getTypeDeclaration(simpleType);
|
||||
if (resolved != null) {
|
||||
return context.getFqn(resolved);
|
||||
}
|
||||
if (td.getRoot() instanceof CompilationUnit cu && cu.getPackage() != null) {
|
||||
String pkg = cu.getPackage().getName().getFullyQualifiedName();
|
||||
resolved = context.getTypeDeclaration(pkg + "." + simpleType);
|
||||
if (resolved != null) {
|
||||
return context.getFqn(resolved);
|
||||
}
|
||||
}
|
||||
return simpleType;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveMethodOwnerFqn(MethodInvocation mi, String callerFqn) {
|
||||
IMethodBinding methodBinding = mi.resolveMethodBinding();
|
||||
if (methodBinding != null && methodBinding.getDeclaringClass() != null) {
|
||||
return methodBinding.getDeclaringClass().getErasure().getQualifiedName();
|
||||
}
|
||||
Expression receiver = mi.getExpression();
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
return resolveFieldTypeFqn(callerFqn, sn.getIdentifier());
|
||||
}
|
||||
if (receiver instanceof FieldAccess fa) {
|
||||
IVariableBinding fieldBinding = fa.resolveFieldBinding();
|
||||
if (fieldBinding != null && fieldBinding.getType() != null) {
|
||||
return fieldBinding.getType().getErasure().getQualifiedName();
|
||||
}
|
||||
return resolveFieldTypeFqn(callerFqn, fa.getName().getIdentifier());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private MethodDeclaration findMethodDeclaration(String methodFqn) {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
return null;
|
||||
}
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
return context.findMethodDeclaration(td, methodName, true);
|
||||
}
|
||||
|
||||
private static String normalizeLiteral(String value) {
|
||||
if (value != null && value.startsWith("\"") && value.endsWith("\"")) {
|
||||
return value.substring(1, value.length() - 1);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,25 @@ public class TypeResolver {
|
||||
}
|
||||
}
|
||||
|
||||
return simpleName;
|
||||
return preferBindingFqn(type, simpleName);
|
||||
}
|
||||
|
||||
private String preferBindingFqn(Type type, String heuristic) {
|
||||
if (!context.isResolveBindings() || heuristic == null || heuristic.contains(".")) {
|
||||
return heuristic;
|
||||
}
|
||||
ITypeBinding binding = type.resolveBinding();
|
||||
if (binding == null || binding.isRecovered()) {
|
||||
return heuristic;
|
||||
}
|
||||
String bindingFqn = binding.getErasure().getQualifiedName();
|
||||
if (bindingFqn == null || bindingFqn.isBlank() || !bindingFqn.contains(".") || bindingFqn.startsWith("<")) {
|
||||
return heuristic;
|
||||
}
|
||||
if (bindingFqn.startsWith("java.") || bindingFqn.startsWith("javax.") || bindingFqn.startsWith("jakarta.")) {
|
||||
return heuristic;
|
||||
}
|
||||
return bindingFqn;
|
||||
}
|
||||
|
||||
public int getParameterIndex(String methodFqn, String paramName) {
|
||||
@@ -170,6 +188,43 @@ public class TypeResolver {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves parameter type from the actual call site in {@code callerFqn}, so overloaded
|
||||
* targets (e.g. {@code send(OrderEvent)} vs {@code send(DocumentEvent)}) use the invoked overload.
|
||||
*/
|
||||
public String getParameterTypeAtCallSite(String callerFqn, String calleeFqn, int paramIndex) {
|
||||
if (callerFqn == null || calleeFqn == null || paramIndex < 0
|
||||
|| !callerFqn.contains(".") || !calleeFqn.contains(".")) {
|
||||
return null;
|
||||
}
|
||||
String callerClass = callerFqn.substring(0, callerFqn.lastIndexOf('.'));
|
||||
String callerMethod = callerFqn.substring(callerFqn.lastIndexOf('.') + 1);
|
||||
String calleeMethod = calleeFqn.substring(calleeFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration td = context.getTypeDeclaration(callerClass);
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, callerMethod, true);
|
||||
if (md == null || md.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
final String[] result = new String[1];
|
||||
md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(org.eclipse.jdt.core.dom.MethodInvocation node) {
|
||||
if (!calleeMethod.equals(node.getName().getIdentifier())) {
|
||||
return super.visit(node);
|
||||
}
|
||||
org.eclipse.jdt.core.dom.IMethodBinding binding = node.resolveMethodBinding();
|
||||
if (binding != null && binding.getParameterTypes().length > paramIndex) {
|
||||
result[0] = binding.getParameterTypes()[paramIndex].getErasure().getQualifiedName();
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return result[0];
|
||||
}
|
||||
|
||||
public boolean isTypeCompatible(String actualType, String expectedType) {
|
||||
if (actualType == null || expectedType == null) return true;
|
||||
if (expectedType.equals("Object") || expectedType.equals("java.lang.Object")) return true;
|
||||
@@ -208,6 +263,17 @@ public class TypeResolver {
|
||||
}
|
||||
|
||||
if (actualType.equals(expectedType)) return true;
|
||||
|
||||
String expectedSimple = expectedType.contains(".")
|
||||
? expectedType.substring(expectedType.lastIndexOf('.') + 1)
|
||||
: expectedType;
|
||||
String actualSimple = actualType.contains(".")
|
||||
? actualType.substring(actualType.lastIndexOf('.') + 1)
|
||||
: actualType;
|
||||
if (expectedSimple.equals(actualSimple) && context.isAmbiguousSimpleName(expectedSimple)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actualType.endsWith("." + expectedType) || expectedType.endsWith("." + actualType)) return true;
|
||||
|
||||
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(actualType);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
|
||||
@@ -8,6 +9,7 @@ import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver
|
||||
import click.kamil.springstatemachineexporter.analysis.index.AccessorNaming;
|
||||
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
|
||||
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.JdtDataFlowModel;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
@@ -17,6 +19,7 @@ public class VariableTracer {
|
||||
private final CodebaseContext context;
|
||||
private final ConstantResolver constantResolver;
|
||||
private ConstantExtractor constantExtractor;
|
||||
private TypeResolver typeResolver;
|
||||
|
||||
private final click.kamil.springstatemachineexporter.ast.common.DataFlowModel dataFlowModel;
|
||||
private final Map<String, String> variableDeclaredTypeCache = new HashMap<>();
|
||||
@@ -33,6 +36,10 @@ public class VariableTracer {
|
||||
this.constantExtractor = constantExtractor;
|
||||
}
|
||||
|
||||
public void setTypeResolver(TypeResolver typeResolver) {
|
||||
this.typeResolver = typeResolver;
|
||||
}
|
||||
|
||||
public void clearAnalysisCaches() {
|
||||
variableDeclaredTypeCache.clear();
|
||||
if (dataFlowModel instanceof JdtDataFlowModel jdtDataFlowModel) {
|
||||
@@ -280,6 +287,21 @@ public class VariableTracer {
|
||||
}
|
||||
|
||||
public String traceLocalVariable(String methodFqn, String varName) {
|
||||
return traceLocalVariable(methodFqn, varName, null);
|
||||
}
|
||||
|
||||
public String traceLocalVariable(String methodFqn, String varName, Map<String, String> branchBindings) {
|
||||
String result = traceLocalVariableInternal(methodFqn, varName, branchBindings);
|
||||
if (result != null && branchBindings != null && !branchBindings.isEmpty()) {
|
||||
String switchEvaluated = evaluateSwitchAssignment(methodFqn, varName, branchBindings);
|
||||
if (switchEvaluated != null) {
|
||||
return switchEvaluated;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String traceLocalVariableInternal(String methodFqn, String varName, Map<String, String> branchBindings) {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) return null;
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
@@ -287,27 +309,33 @@ public class VariableTracer {
|
||||
if (td != null) {
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md != null && md.getBody() != null) {
|
||||
List<Expression> initializers = new ArrayList<>();
|
||||
List<BranchAssignment> assignments = new ArrayList<>();
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(VariableDeclarationFragment node) {
|
||||
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
|
||||
initializers.add(peelExpression(node.getInitializer()));
|
||||
assignments.add(new BranchAssignment(
|
||||
peelExpression(node.getInitializer()),
|
||||
click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node)));
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
@Override
|
||||
public boolean visit(Assignment node) {
|
||||
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
|
||||
initializers.add(peelExpression(node.getRightHandSide()));
|
||||
assignments.add(new BranchAssignment(
|
||||
peelExpression(node.getRightHandSide()),
|
||||
click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node)));
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
|
||||
if (!initializers.isEmpty()) {
|
||||
List<BranchAssignment> filtered = filterAssignmentsByBindings(assignments, branchBindings);
|
||||
if (!filtered.isEmpty()) {
|
||||
List<String> stringified = new ArrayList<>();
|
||||
for (Expression expr : initializers) {
|
||||
for (BranchAssignment assignment : filtered) {
|
||||
Expression expr = assignment.expression();
|
||||
Expression traced = traceVariable(expr);
|
||||
if (traced instanceof MethodInvocation mi) {
|
||||
Expression innerMost = unwrapMethodInvocation(mi, 0, methodFqn);
|
||||
@@ -380,40 +408,35 @@ public class VariableTracer {
|
||||
return null;
|
||||
}
|
||||
|
||||
public String traceLocalVariable(String methodFqn, String varName, Map<String, String> parameterValues) {
|
||||
String result = traceLocalVariable(methodFqn, varName);
|
||||
if (result != null && parameterValues != null && !parameterValues.isEmpty()) {
|
||||
private String evaluateSwitchAssignment(String methodFqn, String varName, Map<String, String> parameterValues) {
|
||||
TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
|
||||
if (td != null) {
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md != null && md.getBody() != null) {
|
||||
if (md == null || md.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
final ASTNode[] switchNode = new ASTNode[1];
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(VariableDeclarationFragment node) {
|
||||
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
|
||||
Expression init = node.getInitializer();
|
||||
if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION) {
|
||||
switchNode[0] = init;
|
||||
return false;
|
||||
}
|
||||
if (init.getNodeType() == ASTNode.SWITCH_STATEMENT) {
|
||||
if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION || init.getNodeType() == ASTNode.SWITCH_STATEMENT) {
|
||||
switchNode[0] = init;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean visit(Assignment node) {
|
||||
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
|
||||
Expression rhs = node.getRightHandSide();
|
||||
if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION) {
|
||||
switchNode[0] = rhs;
|
||||
return false;
|
||||
}
|
||||
if (rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
|
||||
if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION || rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
|
||||
switchNode[0] = rhs;
|
||||
return false;
|
||||
}
|
||||
@@ -421,23 +444,71 @@ public class VariableTracer {
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
if (switchNode[0] != null) {
|
||||
if (switchNode[0] instanceof SwitchExpression se) {
|
||||
String evaluated = constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
|
||||
if (evaluated != null) return evaluated;
|
||||
} else if (switchNode[0] instanceof SwitchStatement ss) {
|
||||
String evaluated = constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
|
||||
if (evaluated != null) return evaluated;
|
||||
return constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
|
||||
}
|
||||
if (switchNode[0] instanceof SwitchStatement ss) {
|
||||
return constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<BranchAssignment> filterAssignmentsByBindings(
|
||||
List<BranchAssignment> assignments, Map<String, String> branchBindings) {
|
||||
if (assignments.isEmpty()) {
|
||||
return assignments;
|
||||
}
|
||||
if (branchBindings == null || branchBindings.isEmpty()) {
|
||||
return assignments;
|
||||
}
|
||||
List<BranchAssignment> compatible = new ArrayList<>();
|
||||
List<BranchAssignment> unconstrained = new ArrayList<>();
|
||||
for (BranchAssignment assignment : assignments) {
|
||||
String constraint = assignment.constraint();
|
||||
if (constraint == null || constraint.isBlank()) {
|
||||
unconstrained.add(assignment);
|
||||
continue;
|
||||
}
|
||||
if (BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, branchBindings)) {
|
||||
compatible.add(assignment);
|
||||
}
|
||||
}
|
||||
if (!compatible.isEmpty()) {
|
||||
return compatible;
|
||||
}
|
||||
return unconstrained;
|
||||
}
|
||||
|
||||
private record BranchAssignment(Expression expression, String constraint) {}
|
||||
|
||||
public Map<String, String> buildParameterValuesMap(String caller, String target, Map<String, List<CallEdge>> callGraph, List<String> path, int pathIndex) {
|
||||
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<>();
|
||||
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);
|
||||
|
||||
boolean hasEdge = false;
|
||||
@@ -470,23 +541,32 @@ public class VariableTracer {
|
||||
return paramValues;
|
||||
}
|
||||
|
||||
// We also want to support heuristic matches using a helper if isHeuristicMatch is not directly available, but let's check
|
||||
// We can check if name equals or if it resolves to target
|
||||
for (CallEdge edge : edges) {
|
||||
if (edge.getTargetMethod().equals(target) || isHeuristicMatch(edge.getTargetMethod(), target)) {
|
||||
List<String> args = edge.getArguments();
|
||||
if (args == null || args.isEmpty()) break;
|
||||
CallEdge selectedEdge = CallSiteMatcher.selectEdge(caller, target, edges, bindings);
|
||||
if (selectedEdge == null) {
|
||||
return paramValues;
|
||||
}
|
||||
|
||||
List<String> args = selectedEdge.getArguments();
|
||||
if (args == null || args.isEmpty()) {
|
||||
return paramValues;
|
||||
}
|
||||
|
||||
int lastDot = target.lastIndexOf('.');
|
||||
if (lastDot < 0) break;
|
||||
if (lastDot < 0) {
|
||||
return paramValues;
|
||||
}
|
||||
String className = target.substring(0, lastDot);
|
||||
String methodName = target.substring(lastDot + 1);
|
||||
|
||||
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||
if (td == null) break;
|
||||
if (td == null) {
|
||||
return paramValues;
|
||||
}
|
||||
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md == null) break;
|
||||
if (md == null) {
|
||||
return paramValues;
|
||||
}
|
||||
|
||||
List<String> paramNames = new ArrayList<>();
|
||||
for (Object paramObj : md.parameters()) {
|
||||
@@ -498,11 +578,15 @@ public class VariableTracer {
|
||||
for (int j = 0; j < minSize; j++) {
|
||||
String argValue = args.get(j);
|
||||
String resolvedArgValue = resolveArgumentValue(argValue, caller, path, pathIndex, callGraph);
|
||||
if (typeResolver != null
|
||||
&& FunctionalInterfaceTypes.isFunctionalInterface(typeResolver.getParameterType(target, j))) {
|
||||
List<String> constants = resolveConstantsFromCallSiteArgument(caller, target, j, selectedEdge);
|
||||
if (constants.size() == 1) {
|
||||
resolvedArgValue = constants.get(0);
|
||||
}
|
||||
}
|
||||
paramValues.put(paramNames.get(j), resolvedArgValue);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return paramValues;
|
||||
}
|
||||
|
||||
@@ -593,9 +677,7 @@ public class VariableTracer {
|
||||
String classTarget = target.substring(0, target.lastIndexOf('.'));
|
||||
if (classNeighbor.equals(classTarget)) return true;
|
||||
|
||||
String simpleClassNeighbor = classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
|
||||
String simpleClassTarget = classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
|
||||
if (simpleClassNeighbor.equals(simpleClassTarget)) return true;
|
||||
if (context.areSameTypeOrUnambiguousSimpleMatch(classNeighbor, classTarget)) return true;
|
||||
|
||||
List<String> impls = context.getImplementations(classTarget);
|
||||
if (impls != null) {
|
||||
@@ -623,7 +705,14 @@ public class VariableTracer {
|
||||
String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
|
||||
|
||||
if (simpleClassNeighbor != null && simpleClassTarget != null) {
|
||||
if (simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) return true;
|
||||
String leftClass = classNeighbor != null ? classNeighbor : simpleClassNeighbor;
|
||||
String rightClass = classTarget != null ? classTarget : simpleClassTarget;
|
||||
if (context.areSameTypeOrUnambiguousSimpleMatch(leftClass, rightClass)) return true;
|
||||
if (!context.isAmbiguousSimpleName(simpleClassNeighbor)
|
||||
&& !context.isAmbiguousSimpleName(simpleClassTarget)
|
||||
&& simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) {
|
||||
return true;
|
||||
}
|
||||
if (simpleClassNeighbor.equals("this") || simpleClassNeighbor.equals("super")) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -654,4 +743,86 @@ public class VariableTracer {
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +62,9 @@ public class InjectionPointAnalyzer {
|
||||
try {
|
||||
IAnnotationBinding[] paramAnns = method.getParameterAnnotations(i);
|
||||
String paramQual = getQualifierValue(paramAnns);
|
||||
if (paramQual != null && paramType.getErasure().isEqualTo(binding.getType().getErasure())) {
|
||||
// For setters, verify it roughly matches the field name or just rely on type.
|
||||
// We will rely on type equality for now as a heuristic.
|
||||
if (paramQual != null
|
||||
&& paramType.getErasure().isEqualTo(binding.getType().getErasure())
|
||||
&& parameterMatchesField(method, i, binding.getName())) {
|
||||
return paramQual;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -90,4 +90,29 @@ public class InjectionPointAnalyzer {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean parameterMatchesField(
|
||||
org.eclipse.jdt.core.dom.IMethodBinding method,
|
||||
int parameterIndex,
|
||||
String fieldName) {
|
||||
if (method == null || fieldName == null || fieldName.isBlank() || parameterIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
String[] names;
|
||||
try {
|
||||
names = method.getParameterNames();
|
||||
} catch (Exception ignored) {
|
||||
names = null;
|
||||
}
|
||||
if (names != null && parameterIndex < names.length) {
|
||||
return fieldName.equals(names[parameterIndex]);
|
||||
}
|
||||
// If parameter names aren't available, only accept clear setter-like methods.
|
||||
String methodName = method.getName();
|
||||
if (methodName != null && methodName.startsWith("set") && method.getParameterTypes().length == 1) {
|
||||
String expected = "set" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1);
|
||||
return expected.equals(methodName);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ public final class AnalysisCanonicalFormValidator {
|
||||
return violations;
|
||||
}
|
||||
|
||||
validateFields(result, machineTypes, violations);
|
||||
validateFields(result, machineTypes, violations, context);
|
||||
return violations;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ public final class AnalysisCanonicalFormValidator {
|
||||
return violations;
|
||||
}
|
||||
|
||||
validateFields(result, machineTypes, violations);
|
||||
validateFields(result, machineTypes, violations, null);
|
||||
return violations;
|
||||
}
|
||||
|
||||
@@ -89,17 +89,18 @@ public final class AnalysisCanonicalFormValidator {
|
||||
private static void validateFields(
|
||||
AnalysisResult result,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
List<Violation> violations) {
|
||||
validateTransitions(result.getTransitions(), machineTypes, violations);
|
||||
validateStateCollection(result.getStates(), machineTypes.stateTypeFqn(), "states", violations);
|
||||
List<Violation> violations,
|
||||
CodebaseContext context) {
|
||||
validateTransitions(result.getTransitions(), machineTypes, violations, context);
|
||||
validateStateCollection(result.getStates(), machineTypes.stateTypeFqn(), "states", violations, context);
|
||||
validateStateRawNameSync(result.getStates(), machineTypes.stateTypeFqn(), "states", violations);
|
||||
validateStateLabels(result.getStartStates(), machineTypes.stateTypeFqn(), "startStates", violations);
|
||||
validateStateLabels(result.getEndStates(), machineTypes.stateTypeFqn(), "endStates", violations);
|
||||
validateStateLabels(result.getStartStates(), machineTypes.stateTypeFqn(), "startStates", violations, context);
|
||||
validateStateLabels(result.getEndStates(), machineTypes.stateTypeFqn(), "endStates", violations, context);
|
||||
|
||||
if (result.getMetadata() != null) {
|
||||
validateTriggers(result.getMetadata().getTriggers(), machineTypes, violations);
|
||||
validateTriggers(result.getMetadata().getTriggers(), machineTypes, violations, context);
|
||||
validateCallChains(
|
||||
result.getMetadata().getCallChains(), result.getTransitions(), machineTypes, violations);
|
||||
result.getMetadata().getCallChains(), result.getTransitions(), machineTypes, violations, context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +115,8 @@ public final class AnalysisCanonicalFormValidator {
|
||||
private static void validateTransitions(
|
||||
List<Transition> transitions,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
List<Violation> violations) {
|
||||
List<Violation> violations,
|
||||
CodebaseContext context) {
|
||||
if (transitions == null) {
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +128,8 @@ public final class AnalysisCanonicalFormValidator {
|
||||
prefix + ".event.fullIdentifier",
|
||||
transition.getEvent().fullIdentifier(),
|
||||
machineTypes.eventTypeFqn(),
|
||||
violations);
|
||||
violations,
|
||||
context);
|
||||
requireRawNameSync(
|
||||
prefix + ".event.rawName",
|
||||
transition.getEvent().rawName(),
|
||||
@@ -135,9 +138,9 @@ public final class AnalysisCanonicalFormValidator {
|
||||
violations);
|
||||
}
|
||||
validateStateListWithRawSync(
|
||||
transition.getSourceStates(), machineTypes.stateTypeFqn(), prefix + ".sourceStates", violations);
|
||||
transition.getSourceStates(), machineTypes.stateTypeFqn(), prefix + ".sourceStates", violations, context);
|
||||
validateStateListWithRawSync(
|
||||
transition.getTargetStates(), machineTypes.stateTypeFqn(), prefix + ".targetStates", violations);
|
||||
transition.getTargetStates(), machineTypes.stateTypeFqn(), prefix + ".targetStates", violations, context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,13 +148,14 @@ public final class AnalysisCanonicalFormValidator {
|
||||
List<State> states,
|
||||
String stateTypeFqn,
|
||||
String pathPrefix,
|
||||
List<Violation> violations) {
|
||||
List<Violation> violations,
|
||||
CodebaseContext context) {
|
||||
if (states == null) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < states.size(); i++) {
|
||||
State state = states.get(i);
|
||||
requireCanonical(pathPrefix + "[" + i + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations);
|
||||
requireCanonical(pathPrefix + "[" + i + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations, context);
|
||||
requireRawNameSync(
|
||||
pathPrefix + "[" + i + "].rawName",
|
||||
state.rawName(),
|
||||
@@ -183,13 +187,14 @@ public final class AnalysisCanonicalFormValidator {
|
||||
Set<State> states,
|
||||
String stateTypeFqn,
|
||||
String pathPrefix,
|
||||
List<Violation> violations) {
|
||||
List<Violation> violations,
|
||||
CodebaseContext context) {
|
||||
if (states == null) {
|
||||
return;
|
||||
}
|
||||
int i = 0;
|
||||
for (State state : states) {
|
||||
requireCanonical(pathPrefix + "[" + i++ + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations);
|
||||
requireCanonical(pathPrefix + "[" + i++ + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations, context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,20 +202,22 @@ public final class AnalysisCanonicalFormValidator {
|
||||
Set<String> labels,
|
||||
String stateTypeFqn,
|
||||
String pathPrefix,
|
||||
List<Violation> violations) {
|
||||
List<Violation> violations,
|
||||
CodebaseContext context) {
|
||||
if (labels == null) {
|
||||
return;
|
||||
}
|
||||
int i = 0;
|
||||
for (String label : labels) {
|
||||
requireCanonical(pathPrefix + "[" + i++ + "]", label, stateTypeFqn, violations);
|
||||
requireCanonical(pathPrefix + "[" + i++ + "]", label, stateTypeFqn, violations, context);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateTriggers(
|
||||
List<TriggerPoint> triggers,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
List<Violation> violations) {
|
||||
List<Violation> violations,
|
||||
CodebaseContext context) {
|
||||
if (triggers == null) {
|
||||
return;
|
||||
}
|
||||
@@ -220,16 +227,16 @@ public final class AnalysisCanonicalFormValidator {
|
||||
String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn());
|
||||
String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn());
|
||||
|
||||
requireCanonical(prefix + ".event", trigger.getEvent(), eventTypeFqn, violations);
|
||||
requireCanonical(prefix + ".event", trigger.getEvent(), eventTypeFqn, violations, context);
|
||||
validateTriggerEventForm(prefix + ".event", trigger.getEvent(), violations);
|
||||
requireCanonical(prefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations);
|
||||
requireCanonical(prefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations, context);
|
||||
validateDynamicIdentifierForm(prefix + ".sourceState", trigger.getSourceState(), violations);
|
||||
|
||||
if (trigger.getPolymorphicEvents() != null) {
|
||||
for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) {
|
||||
String polyPath = prefix + ".polymorphicEvents[" + j + "]";
|
||||
String polyEvent = trigger.getPolymorphicEvents().get(j);
|
||||
requireCanonical(polyPath, polyEvent, eventTypeFqn, violations);
|
||||
requireCanonical(polyPath, polyEvent, eventTypeFqn, violations, context);
|
||||
validatePolymorphicEventForm(polyPath, polyEvent, violations);
|
||||
}
|
||||
}
|
||||
@@ -240,7 +247,8 @@ public final class AnalysisCanonicalFormValidator {
|
||||
List<CallChain> callChains,
|
||||
List<Transition> transitions,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
List<Violation> violations) {
|
||||
List<Violation> violations,
|
||||
CodebaseContext context) {
|
||||
if (callChains == null) {
|
||||
return;
|
||||
}
|
||||
@@ -252,30 +260,30 @@ public final class AnalysisCanonicalFormValidator {
|
||||
String triggerPrefix = prefix + ".triggerPoint";
|
||||
String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn());
|
||||
String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn());
|
||||
requireCanonical(triggerPrefix + ".event", trigger.getEvent(), eventTypeFqn, violations);
|
||||
requireCanonical(triggerPrefix + ".event", trigger.getEvent(), eventTypeFqn, violations, context);
|
||||
validateTriggerEventForm(triggerPrefix + ".event", trigger.getEvent(), violations);
|
||||
requireCanonical(triggerPrefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations);
|
||||
requireCanonical(triggerPrefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations, context);
|
||||
validateDynamicIdentifierForm(triggerPrefix + ".sourceState", trigger.getSourceState(), violations);
|
||||
if (trigger.getPolymorphicEvents() != null) {
|
||||
for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) {
|
||||
String polyPath = triggerPrefix + ".polymorphicEvents[" + j + "]";
|
||||
String polyEvent = trigger.getPolymorphicEvents().get(j);
|
||||
requireCanonical(polyPath, polyEvent, eventTypeFqn, violations);
|
||||
requireCanonical(polyPath, polyEvent, eventTypeFqn, violations, context);
|
||||
validatePolymorphicEventForm(polyPath, polyEvent, violations);
|
||||
}
|
||||
}
|
||||
validateMatchedTransitionsWhenResolvable(
|
||||
prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations);
|
||||
prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations, context);
|
||||
validateOverLinkedPolymorphicEvents(
|
||||
prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations);
|
||||
prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations, context);
|
||||
}
|
||||
if (chain.getMatchedTransitions() != null) {
|
||||
for (int j = 0; j < chain.getMatchedTransitions().size(); j++) {
|
||||
MatchedTransition matched = chain.getMatchedTransitions().get(j);
|
||||
String matchedPrefix = prefix + ".matchedTransitions[" + j + "]";
|
||||
requireCanonical(matchedPrefix + ".event", matched.getEvent(), machineTypes.eventTypeFqn(), violations);
|
||||
requireCanonical(matchedPrefix + ".sourceState", matched.getSourceState(), machineTypes.stateTypeFqn(), violations);
|
||||
requireCanonical(matchedPrefix + ".targetState", matched.getTargetState(), machineTypes.stateTypeFqn(), violations);
|
||||
requireCanonical(matchedPrefix + ".event", matched.getEvent(), machineTypes.eventTypeFqn(), violations, context);
|
||||
requireCanonical(matchedPrefix + ".sourceState", matched.getSourceState(), machineTypes.stateTypeFqn(), violations, context);
|
||||
requireCanonical(matchedPrefix + ".targetState", matched.getTargetState(), machineTypes.stateTypeFqn(), violations, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,11 +300,12 @@ public final class AnalysisCanonicalFormValidator {
|
||||
String path,
|
||||
String value,
|
||||
String enumTypeFqn,
|
||||
List<Violation> violations) {
|
||||
if (!MachineEnumCanonicalizer.isMachineEnumReference(value, enumTypeFqn)) {
|
||||
List<Violation> violations,
|
||||
CodebaseContext context) {
|
||||
if (!MachineEnumCanonicalizer.isMachineEnumReference(value, enumTypeFqn, context)) {
|
||||
return;
|
||||
}
|
||||
String expected = MachineEnumCanonicalizer.canonicalizeLabel(value, enumTypeFqn);
|
||||
String expected = MachineEnumCanonicalizer.canonicalizeLabel(value, enumTypeFqn, context);
|
||||
if (!expected.equals(value)) {
|
||||
violations.add(new Violation(path, value, expected));
|
||||
}
|
||||
@@ -347,7 +356,8 @@ public final class AnalysisCanonicalFormValidator {
|
||||
TriggerPoint trigger,
|
||||
List<Transition> transitions,
|
||||
String eventTypeFqn,
|
||||
List<Violation> violations) {
|
||||
List<Violation> violations,
|
||||
CodebaseContext context) {
|
||||
if (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -355,7 +365,7 @@ public final class AnalysisCanonicalFormValidator {
|
||||
return;
|
||||
}
|
||||
List<String> transitionEvents =
|
||||
MachineEnumCanonicalizer.polymorphicEventsFromTransitions(transitions, eventTypeFqn);
|
||||
MachineEnumCanonicalizer.polymorphicEventsFromTransitions(transitions, eventTypeFqn, context);
|
||||
if (transitionEvents.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -373,7 +383,7 @@ public final class AnalysisCanonicalFormValidator {
|
||||
return;
|
||||
}
|
||||
int matchingTransitionCount = countMatchingConfiguredTransitions(
|
||||
transitions, trigger.getPolymorphicEvents(), eventTypeFqn);
|
||||
transitions, trigger.getPolymorphicEvents(), eventTypeFqn, context);
|
||||
if (matchingTransitionCount > 0
|
||||
&& chain.getMatchedTransitions() != null
|
||||
&& chain.getMatchedTransitions().size() > matchingTransitionCount) {
|
||||
@@ -387,7 +397,8 @@ public final class AnalysisCanonicalFormValidator {
|
||||
private static int countMatchingConfiguredTransitions(
|
||||
List<Transition> transitions,
|
||||
List<String> polymorphicEvents,
|
||||
String eventTypeFqn) {
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (transitions == null || transitions.isEmpty() || polymorphicEvents == null) {
|
||||
return 0;
|
||||
}
|
||||
@@ -400,7 +411,7 @@ public final class AnalysisCanonicalFormValidator {
|
||||
? transition.getEvent().fullIdentifier()
|
||||
: transition.getEvent().rawName();
|
||||
for (String polyEvent : polymorphicEvents) {
|
||||
if (eventsMatch(polyEvent, smEvent, eventTypeFqn)) {
|
||||
if (eventsMatch(polyEvent, smEvent, eventTypeFqn, context)) {
|
||||
count++;
|
||||
break;
|
||||
}
|
||||
@@ -415,21 +426,42 @@ public final class AnalysisCanonicalFormValidator {
|
||||
TriggerPoint trigger,
|
||||
List<Transition> transitions,
|
||||
String eventTypeFqn,
|
||||
List<Violation> violations) {
|
||||
List<Violation> violations,
|
||||
CodebaseContext context) {
|
||||
if (LifecycleTriggerMarkers.isLifecycle(trigger.getEvent())) {
|
||||
return;
|
||||
}
|
||||
if (!MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) {
|
||||
return;
|
||||
}
|
||||
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
|
||||
!= MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
|
||||
if (isIntentionalAmbiguousFailClosed(trigger)) {
|
||||
return;
|
||||
}
|
||||
if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!polymorphicEventsMatchAnyTransition(trigger.getPolymorphicEvents(), transitions, eventTypeFqn)) {
|
||||
if (chain.getLinkResolution() == click.kamil.springstatemachineexporter.analysis.model.LinkResolution.NO_MATCH
|
||||
|| chain.getLinkResolution()
|
||||
== click.kamil.springstatemachineexporter.analysis.model.LinkResolution.UNRESOLVED_EXTERNAL) {
|
||||
return;
|
||||
}
|
||||
|
||||
MachineEnumCanonicalizer.TriggerEventKind eventKind =
|
||||
MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent());
|
||||
if (eventKind == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) {
|
||||
if (eventMatchesAnyTransition(trigger.getEvent(), transitions, eventTypeFqn, context)) {
|
||||
violations.add(new Violation(
|
||||
chainPrefix + ".matchedTransitions",
|
||||
"empty",
|
||||
"non-empty when trigger has concrete machine enum event matching transitions"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) {
|
||||
return;
|
||||
}
|
||||
if (eventKind != MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
|
||||
return;
|
||||
}
|
||||
if (!polymorphicEventsMatchAnyTransition(trigger.getPolymorphicEvents(), transitions, eventTypeFqn, context)) {
|
||||
return;
|
||||
}
|
||||
violations.add(new Violation(
|
||||
@@ -438,10 +470,53 @@ public final class AnalysisCanonicalFormValidator {
|
||||
"non-empty when dynamic trigger has concrete polymorphicEvents matching transitions"));
|
||||
}
|
||||
|
||||
private static boolean isIntentionalAmbiguousFailClosed(TriggerPoint trigger) {
|
||||
if (trigger == null) {
|
||||
return false;
|
||||
}
|
||||
if (trigger.isAmbiguous() && !trigger.isExternal()) {
|
||||
return true;
|
||||
}
|
||||
String event = trigger.getEvent();
|
||||
if (event != null && event.startsWith("ENUM_SET:")) {
|
||||
return true;
|
||||
}
|
||||
if (trigger.isExternal()
|
||||
&& trigger.isAmbiguous()
|
||||
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(event)
|
||||
&& (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty())) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean eventMatchesAnyTransition(
|
||||
String triggerEvent,
|
||||
List<Transition> transitions,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (transitions == null || transitions.isEmpty() || triggerEvent == null) {
|
||||
return false;
|
||||
}
|
||||
for (Transition transition : transitions) {
|
||||
if (transition.getEvent() == null) {
|
||||
continue;
|
||||
}
|
||||
String smEvent = transition.getEvent().fullIdentifier() != null
|
||||
? transition.getEvent().fullIdentifier()
|
||||
: transition.getEvent().rawName();
|
||||
if (eventsMatch(triggerEvent, smEvent, eventTypeFqn, context)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean polymorphicEventsMatchAnyTransition(
|
||||
List<String> polymorphicEvents,
|
||||
List<Transition> transitions,
|
||||
String eventTypeFqn) {
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (transitions == null || transitions.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
@@ -453,7 +528,7 @@ public final class AnalysisCanonicalFormValidator {
|
||||
String smEvent = transition.getEvent().fullIdentifier() != null
|
||||
? transition.getEvent().fullIdentifier()
|
||||
: transition.getEvent().rawName();
|
||||
if (eventsMatch(polyEvent, smEvent, eventTypeFqn)) {
|
||||
if (eventsMatch(polyEvent, smEvent, eventTypeFqn, context)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -461,7 +536,11 @@ public final class AnalysisCanonicalFormValidator {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean eventsMatch(String triggerEvent, String smEvent, String eventTypeFqn) {
|
||||
private static boolean eventsMatch(
|
||||
String triggerEvent,
|
||||
String smEvent,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (triggerEvent == null || smEvent == null) {
|
||||
return false;
|
||||
}
|
||||
@@ -473,8 +552,8 @@ public final class AnalysisCanonicalFormValidator {
|
||||
if (!triggerConst.equals(smConst)) {
|
||||
return false;
|
||||
}
|
||||
if (MachineEnumCanonicalizer.isMachineEnumReference(triggerEvent, eventTypeFqn)
|
||||
&& MachineEnumCanonicalizer.isMachineEnumReference(smEvent, eventTypeFqn)) {
|
||||
if (MachineEnumCanonicalizer.isMachineEnumReference(triggerEvent, eventTypeFqn, context)
|
||||
&& MachineEnumCanonicalizer.isMachineEnumReference(smEvent, eventTypeFqn, context)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
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.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.MethodDeclaration;
|
||||
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.TypeDeclaration;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class AstUtils {
|
||||
private AstUtils() {
|
||||
}
|
||||
@@ -263,4 +273,145 @@ public final class AstUtils {
|
||||
combined.append(')');
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,9 +139,9 @@ public class CodebaseContext {
|
||||
|
||||
public void loadLibraryHints(Path hintsFile) throws IOException {
|
||||
if (Files.exists(hintsFile)) {
|
||||
System.out.println("Loading hints from " + hintsFile.toAbsolutePath());
|
||||
log.debug("Loading optional hints.json override from {}", hintsFile.toAbsolutePath());
|
||||
this.libraryHints = objectMapper.readValue(hintsFile.toFile(), new TypeReference<List<LibraryHint>>() {});
|
||||
System.out.println("Loaded " + libraryHints.size() + " library hints");
|
||||
log.debug("Loaded {} optional library hints from hints.json", libraryHints.size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,32 @@ public class CodebaseContext {
|
||||
private String[] classpath = new String[0];
|
||||
private String[] sourcepath = new String[0];
|
||||
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() {
|
||||
return Collections.unmodifiableMap(allProperties);
|
||||
@@ -194,6 +220,10 @@ public class CodebaseContext {
|
||||
this.resolveBindings = resolveBindings;
|
||||
}
|
||||
|
||||
public boolean isResolveBindings() {
|
||||
return resolveBindings;
|
||||
}
|
||||
|
||||
public void setActiveProfiles(List<String> profiles) {
|
||||
this.activeProfiles.clear();
|
||||
if (profiles != null) {
|
||||
@@ -438,6 +468,12 @@ public class CodebaseContext {
|
||||
}
|
||||
if (!visited.add(cleanName)) return;
|
||||
|
||||
// Fail closed on ambiguous simple type names: we must not pick an arbitrary FQN and widen
|
||||
// to unrelated implementations across packages.
|
||||
if (cleanName != null && !cleanName.contains(".") && ambiguousSimpleNames.contains(cleanName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try direct match
|
||||
List<String> directImpls = interfaceToImpls.get(cleanName);
|
||||
|
||||
@@ -450,9 +486,23 @@ public class CodebaseContext {
|
||||
}
|
||||
|
||||
// Try simple name match if input was FQN
|
||||
if (directImpls == null && typeName.contains(".")) {
|
||||
String simpleName = typeName.substring(typeName.lastIndexOf('.') + 1);
|
||||
directImpls = interfaceToImpls.get(simpleName);
|
||||
if (directImpls == null && cleanName.contains(".")) {
|
||||
String simpleName = cleanName.substring(cleanName.lastIndexOf('.') + 1);
|
||||
List<String> simpleImpls = interfaceToImpls.get(simpleName);
|
||||
if (simpleImpls != null) {
|
||||
if (ambiguousSimpleNames.contains(simpleName)) {
|
||||
String pkg = cleanName.substring(0, cleanName.lastIndexOf('.'));
|
||||
List<String> pkgScoped = new ArrayList<>();
|
||||
for (String impl : simpleImpls) {
|
||||
if (impl.startsWith(pkg + ".")) {
|
||||
pkgScoped.add(impl);
|
||||
}
|
||||
}
|
||||
directImpls = pkgScoped.isEmpty() ? null : pkgScoped;
|
||||
} else {
|
||||
directImpls = simpleImpls;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (directImpls != null) {
|
||||
@@ -478,7 +528,12 @@ public class CodebaseContext {
|
||||
}
|
||||
}
|
||||
enumValues.put(fqn, values);
|
||||
if (!simpleNameToFqn.containsKey(simpleName)) {
|
||||
if (simpleNameToFqn.containsKey(simpleName)) {
|
||||
String existingFqn = simpleNameToFqn.get(simpleName);
|
||||
if (existingFqn != null && !existingFqn.equals(fqn)) {
|
||||
ambiguousSimpleNames.add(simpleName);
|
||||
}
|
||||
} else {
|
||||
simpleNameToFqn.put(simpleName, fqn);
|
||||
}
|
||||
|
||||
@@ -498,6 +553,36 @@ public class CodebaseContext {
|
||||
return enumValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when multiple types share the same simple name across packages, so simple-name
|
||||
* lookup must fail closed.
|
||||
*/
|
||||
public boolean isAmbiguousSimpleName(String name) {
|
||||
if (name == null || name.isBlank() || name.contains(".")) {
|
||||
return false;
|
||||
}
|
||||
return ambiguousSimpleNames.contains(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when two type references denote the same type, or share an unambiguous simple name.
|
||||
* Fails closed when the shared simple name exists in multiple packages.
|
||||
*/
|
||||
public boolean areSameTypeOrUnambiguousSimpleMatch(String leftFqn, String rightFqn) {
|
||||
if (leftFqn == null || rightFqn == null) {
|
||||
return false;
|
||||
}
|
||||
if (leftFqn.equals(rightFqn)) {
|
||||
return true;
|
||||
}
|
||||
String leftSimple = simpleTypeName(leftFqn);
|
||||
String rightSimple = simpleTypeName(rightFqn);
|
||||
if (!leftSimple.equals(rightSimple)) {
|
||||
return false;
|
||||
}
|
||||
return !isAmbiguousSimpleName(leftSimple);
|
||||
}
|
||||
|
||||
public List<String> getEnumValues(String fqnOrSimpleName) {
|
||||
if (fqnOrSimpleName == null) return null;
|
||||
|
||||
@@ -512,6 +597,9 @@ public class CodebaseContext {
|
||||
|
||||
List<String> values = enumValues.get(cleanName);
|
||||
if (values == null) {
|
||||
if (!cleanName.contains(".") && ambiguousSimpleNames.contains(cleanName)) {
|
||||
return null;
|
||||
}
|
||||
String fqn = simpleNameToFqn.get(cleanName);
|
||||
if (fqn != null) values = enumValues.get(fqn);
|
||||
}
|
||||
@@ -652,21 +740,36 @@ public class CodebaseContext {
|
||||
}
|
||||
|
||||
private String resolveSuperclassFqn(TypeDeclaration td) {
|
||||
String bindingSuper = null;
|
||||
ITypeBinding binding = td.resolveBinding();
|
||||
if (binding != null) {
|
||||
ITypeBinding superBinding = binding.getSuperclass();
|
||||
if (superBinding != null) {
|
||||
return superBinding.getErasure().getQualifiedName();
|
||||
bindingSuper = superBinding.getErasure().getQualifiedName();
|
||||
}
|
||||
}
|
||||
|
||||
Type superType = td.getSuperclassType();
|
||||
if (superType == null) return null;
|
||||
if (superType == null) {
|
||||
return bindingSuper;
|
||||
}
|
||||
|
||||
String superName = extractTypeName(superType);
|
||||
CompilationUnit cu = (td.getRoot() instanceof CompilationUnit) ? (CompilationUnit) td.getRoot() : null;
|
||||
TypeDeclaration superTd = getTypeDeclaration(superName, cu);
|
||||
return superTd != null ? getFqn(superTd) : superName;
|
||||
String astSuper = superTd != null ? getFqn(superTd) : null;
|
||||
|
||||
if (astSuper != null) {
|
||||
if (bindingSuper == null || !astSuper.equals(bindingSuper)) {
|
||||
if (bindingSuper == null
|
||||
|| getTypeDeclaration(bindingSuper) == null
|
||||
|| classes.containsKey(astSuper)) {
|
||||
return astSuper;
|
||||
}
|
||||
}
|
||||
return astSuper;
|
||||
}
|
||||
return bindingSuper != null ? bindingSuper : superName;
|
||||
}
|
||||
|
||||
private String extractTypeName(Type type) {
|
||||
@@ -783,9 +886,7 @@ public class CodebaseContext {
|
||||
if (classNeighbor.equals(classTarget)) {
|
||||
return true;
|
||||
}
|
||||
String simpleClassNeighbor = simpleTypeName(classNeighbor);
|
||||
String simpleClassTarget = simpleTypeName(classTarget);
|
||||
if (simpleClassNeighbor.equals(simpleClassTarget)) {
|
||||
if (areSameTypeOrUnambiguousSimpleMatch(classNeighbor, classTarget)) {
|
||||
return true;
|
||||
}
|
||||
return classCompatibilityCache.areClassesCompatible(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package click.kamil.springstatemachineexporter.ast.common;
|
||||
|
||||
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.GetterChainEdge;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
import java.util.*;
|
||||
@@ -15,20 +20,6 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
private final Map<MethodDeclaration, ReachingDefinitions> rdCache = 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) {
|
||||
this.context = context;
|
||||
}
|
||||
@@ -64,6 +55,13 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
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
|
||||
if (expr instanceof ConditionalExpression ce) {
|
||||
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
|
||||
MethodDeclaration md = findEnclosingMethod(sn);
|
||||
ReachingDefinitions rd = getReachingDefinitionsForMethod(md);
|
||||
@@ -193,13 +203,59 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
|
||||
// 4. Handle Method Invocations (Static Factory methods / Transforming Getters)
|
||||
if (expr instanceof MethodInvocation mi) {
|
||||
String mName = mi.getName().getIdentifier();
|
||||
if (("just".equals(mName) || "withPayload".equals(mName) || "success".equals(mName)) && !mi.arguments().isEmpty()) {
|
||||
List<Expression> resolved = getReachingDefinitions((Expression) mi.arguments().get(0), visited, paramBindings, instanceFieldBindings, depth + 1);
|
||||
Expression factoryPayload = ReactiveExpressionSupport.peelFactoryPayloadExpression(mi);
|
||||
if (factoryPayload != null) {
|
||||
List<Expression> resolved = getReachingDefinitions(
|
||||
factoryPayload, visited, paramBindings, instanceFieldBindings, depth + 1);
|
||||
visited.remove(expr);
|
||||
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(
|
||||
mi, new HashSet<>(visited), paramBindings, instanceFieldBindings, depth);
|
||||
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);
|
||||
accessor = AccessorInlining.lookupAccessor(context, mi, receiverDefs);
|
||||
if (accessor.isEmpty()) {
|
||||
List<Expression> anonymous = inlineAnonymousFromReceiverDefs(
|
||||
methodName, receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
|
||||
if (!anonymous.isEmpty()) {
|
||||
return anonymous;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -380,6 +444,11 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
}
|
||||
|
||||
if (accessor.isEmpty()) {
|
||||
List<Expression> anonymous = inlineAnonymousFromReceiverDefs(
|
||||
methodName, receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
|
||||
if (!anonymous.isEmpty()) {
|
||||
return anonymous;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
if (!accessor.get().isGetter()) {
|
||||
@@ -394,9 +463,72 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
AccessorSummary accessor,
|
||||
Set<ASTNode> visited,
|
||||
@@ -623,11 +755,257 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
if (fieldValue != null) {
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
SimpleName receiverName,
|
||||
MethodInvocation useSite,
|
||||
@@ -1110,7 +1488,7 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
if (declaringClassFqn != null && !declaringClassFqn.isEmpty()) {
|
||||
// Find all known implementation subclasses in the codebase
|
||||
List<String> implClassFqns = context.getImplementations(declaringClassFqn);
|
||||
List<String> currentPath = CURRENT_PATH.get();
|
||||
List<String> currentPath = activeAnalysisPath();
|
||||
if (currentPath != null && implClassFqns != null && !implClassFqns.isEmpty()) {
|
||||
Set<String> contextTypes = getCompatibleContextTypes(currentPath, declaringClassFqn);
|
||||
List<String> filteredImpls = new ArrayList<>();
|
||||
@@ -1168,6 +1546,10 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
return targets;
|
||||
}
|
||||
|
||||
private List<String> activeAnalysisPath() {
|
||||
return context.getAnalysisCallPath();
|
||||
}
|
||||
|
||||
private boolean isExpressionWrapping(Expression outer, Expression inner) {
|
||||
Expression current = outer;
|
||||
while (current != null) {
|
||||
@@ -1360,6 +1742,9 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
if (cfg == null) return;
|
||||
|
||||
ControlFlowGraph.CfgNode defCfgNode = findCfgNode(cfg, defNode);
|
||||
if (defCfgNode == null && defNode instanceof SingleVariableDeclaration) {
|
||||
defCfgNode = cfg.getEntryNode();
|
||||
}
|
||||
ControlFlowGraph.CfgNode useCfgNode = findCfgNode(cfg, useNode);
|
||||
|
||||
if (defCfgNode != null && useCfgNode != null) {
|
||||
@@ -1463,6 +1848,145 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
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) {
|
||||
List<ReturnStatement> returns = new ArrayList<>();
|
||||
node.accept(new ASTVisitor() {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package click.kamil.springstatemachineexporter.exporter;
|
||||
|
||||
/**
|
||||
* Builds SVG/HTML transition link keys ({@code Source__Event}) using the same formatting rules as
|
||||
* {@link PlantUml} embedded identifiers and the HTML explorer {@code buildLinkKey} helper.
|
||||
*/
|
||||
public final class TransitionLinkKey {
|
||||
|
||||
private TransitionLinkKey() {
|
||||
}
|
||||
|
||||
public static String build(String sourceStateIdentifier, String eventIdentifier) {
|
||||
return build(sourceStateIdentifier, eventIdentifier, EnumFormat.fn, EnumFormat.fn);
|
||||
}
|
||||
|
||||
public static String build(
|
||||
String sourceStateIdentifier,
|
||||
String eventIdentifier,
|
||||
EnumFormat stateFormat,
|
||||
EnumFormat eventFormat) {
|
||||
if (eventIdentifier == null || eventIdentifier.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String formattedEvent = formatIdentifier(eventIdentifier, eventFormat);
|
||||
if (sourceStateIdentifier == null || sourceStateIdentifier.isBlank()) {
|
||||
return "__" + normalize(formattedEvent);
|
||||
}
|
||||
return normalize(formatIdentifier(sourceStateIdentifier, stateFormat))
|
||||
+ "__"
|
||||
+ normalize(formattedEvent);
|
||||
}
|
||||
|
||||
static String formatIdentifier(String identifier, EnumFormat format) {
|
||||
if (identifier == null || identifier.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return switch (format) {
|
||||
case fqn -> identifier;
|
||||
case sn -> {
|
||||
int lastDot = identifier.lastIndexOf('.');
|
||||
yield lastDot >= 0 ? identifier.substring(lastDot + 1) : identifier;
|
||||
}
|
||||
case fn -> {
|
||||
int lastDot = identifier.lastIndexOf('.');
|
||||
if (lastDot <= 0) {
|
||||
yield identifier;
|
||||
}
|
||||
int prevDot = identifier.lastIndexOf('.', lastDot - 1);
|
||||
yield prevDot > 0 ? identifier.substring(prevDot + 1) : identifier;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static String normalize(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
return value.replaceAll("[^a-zA-Z0-9]", "_");
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ public class ExportService {
|
||||
}
|
||||
|
||||
if (Files.exists(hintsFile)) {
|
||||
log.info("Loading hints from {}", hintsFile.toAbsolutePath());
|
||||
log.debug("Loading optional hints.json override from {}", hintsFile.toAbsolutePath());
|
||||
context.loadLibraryHints(hintsFile);
|
||||
}
|
||||
|
||||
@@ -167,8 +167,6 @@ public class ExportService {
|
||||
JsonImportService jsonImportService = new JsonImportService();
|
||||
AnalysisResult result = jsonImportService.importAnalysisResult(jsonFile);
|
||||
|
||||
resolveProperties(result, activeProfiles);
|
||||
|
||||
CodebaseContext context = null;
|
||||
Path sourceRoot = optionalSourceDir;
|
||||
if (sourceRoot == null) {
|
||||
@@ -187,6 +185,8 @@ public class ExportService {
|
||||
log.info("JSON re-export: no source project found; using embedded machine types if present");
|
||||
}
|
||||
|
||||
resolveProperties(result, activeProfiles, context);
|
||||
|
||||
StateMachineTypeResolver.MachineTypes machineTypes =
|
||||
click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory.resolveMachineTypes(
|
||||
result.getName(),
|
||||
@@ -215,7 +215,7 @@ public class ExportService {
|
||||
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
|
||||
}
|
||||
|
||||
private void resolveProperties(AnalysisResult result, List<String> activeProfiles) {
|
||||
private void resolveProperties(AnalysisResult result, List<String> activeProfiles, CodebaseContext context) {
|
||||
Map<String, Map<String, String>> allProps = result.getMetadata().getProperties();
|
||||
if (allProps == null || allProps.isEmpty()) return;
|
||||
|
||||
@@ -233,7 +233,7 @@ public class ExportService {
|
||||
}
|
||||
|
||||
// 2. Delegate to result for resolution
|
||||
result.applyResolution(merged);
|
||||
result.applyResolution(merged, context);
|
||||
}
|
||||
|
||||
private void exportAll(CodebaseContext context, CodebaseIntelligenceProvider intelligence, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows, String machineFilter, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
|
||||
@@ -272,13 +272,13 @@ public class ExportService {
|
||||
List<Transition> transitions = aggregator.aggregateTransitions(td);
|
||||
|
||||
StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.resolveTypes(className, context);
|
||||
MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes);
|
||||
MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes, context);
|
||||
|
||||
aggregator.aggregateStates(td);
|
||||
Set<String> initialStatesAst = MachineEnumCanonicalizer.canonicalizeStateLabels(
|
||||
aggregator.getInitialStates(), machineTypes.stateTypeFqn());
|
||||
aggregator.getInitialStates(), machineTypes.stateTypeFqn(), context);
|
||||
Set<String> endStatesAst = MachineEnumCanonicalizer.canonicalizeStateLabels(
|
||||
aggregator.getEndStates(), machineTypes.stateTypeFqn());
|
||||
aggregator.getEndStates(), machineTypes.stateTypeFqn(), context);
|
||||
|
||||
log.debug("Start States Ast: {}", initialStatesAst);
|
||||
log.debug("End States Ast: {}", endStatesAst);
|
||||
@@ -287,7 +287,7 @@ public class ExportService {
|
||||
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst);
|
||||
Set<click.kamil.springstatemachineexporter.model.State> allStates = MachineEnumCanonicalizer.canonicalizeStates(
|
||||
TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst),
|
||||
machineTypes.stateTypeFqn());
|
||||
machineTypes.stateTypeFqn(), context);
|
||||
|
||||
if (allStates.isEmpty() && transitions.isEmpty()) {
|
||||
log.info("Skipping empty state machine config: {}", className);
|
||||
@@ -305,7 +305,7 @@ public class ExportService {
|
||||
.build();
|
||||
|
||||
enrichmentService.enrichPreProperty(result, context, intelligence);
|
||||
resolveProperties(result, activeProfiles);
|
||||
resolveProperties(result, activeProfiles, context);
|
||||
enrichmentService.enrichPostProperty(result, context, intelligence);
|
||||
AnalysisResultFinalizer.finalizeResult(result, context);
|
||||
|
||||
@@ -323,13 +323,13 @@ public class ExportService {
|
||||
List<Transition> transitions = AstTransitionParser.parseTransitions(m, context);
|
||||
|
||||
StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.resolveTypes(parentFqn, context);
|
||||
MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes);
|
||||
MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes, context);
|
||||
|
||||
Set<String> startStates = TransitionStateUtils.findStartStates(transitions, null);
|
||||
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, null);
|
||||
Set<click.kamil.springstatemachineexporter.model.State> allStates = MachineEnumCanonicalizer.canonicalizeStates(
|
||||
TransitionStateUtils.findAllStates(transitions, null, null),
|
||||
machineTypes.stateTypeFqn());
|
||||
machineTypes.stateTypeFqn(), context);
|
||||
|
||||
if (allStates.isEmpty() && transitions.isEmpty()) {
|
||||
log.info("Skipping empty state machine bean: {}", uniqueName);
|
||||
@@ -347,7 +347,7 @@ public class ExportService {
|
||||
.build();
|
||||
|
||||
enrichmentService.enrichPreProperty(result, context, intelligence);
|
||||
resolveProperties(result, activeProfiles);
|
||||
resolveProperties(result, activeProfiles, context);
|
||||
enrichmentService.enrichPostProperty(result, context, intelligence);
|
||||
AnalysisResultFinalizer.finalizeResult(result, context);
|
||||
|
||||
|
||||
@@ -172,6 +172,12 @@ public class GoldenUpdater {
|
||||
Path.of("src/test/resources/golden/ExtendedStateMachineConfig"),
|
||||
"ExtendedStateMachineConfig"
|
||||
),
|
||||
new TestScenario(
|
||||
"Payment State Machine (Extended Sample)",
|
||||
Path.of("state_machines/extended_analysis_sample"),
|
||||
Path.of("src/test/resources/golden/PaymentStateMachineConfig"),
|
||||
"PaymentStateMachineConfig"
|
||||
),
|
||||
new TestScenario(
|
||||
"Extended Analysis Sample (PROD)",
|
||||
Path.of("state_machines/extended_analysis_sample"),
|
||||
|
||||
@@ -113,6 +113,12 @@ public class PlantUmlE2ETest {
|
||||
Path.of("src/test/resources/golden/ExtendedStateMachineConfig"),
|
||||
"ExtendedStateMachineConfig"
|
||||
),
|
||||
new TestScenario(
|
||||
"Payment State Machine (Extended Sample)",
|
||||
root.resolve("state_machines/extended_analysis_sample"),
|
||||
Path.of("src/test/resources/golden/PaymentStateMachineConfig"),
|
||||
"PaymentStateMachineConfig"
|
||||
),
|
||||
new TestScenario(
|
||||
"Extended Analysis Sample (PROD)",
|
||||
root.resolve("state_machines/extended_analysis_sample"),
|
||||
|
||||
@@ -113,6 +113,12 @@ public class RegressionTest {
|
||||
Path.of("src/test/resources/golden/ExtendedStateMachineConfig"),
|
||||
"ExtendedStateMachineConfig"
|
||||
),
|
||||
new TestScenario(
|
||||
"Payment State Machine (Extended Sample)",
|
||||
root.resolve("state_machines/extended_analysis_sample"),
|
||||
Path.of("src/test/resources/golden/PaymentStateMachineConfig"),
|
||||
"PaymentStateMachineConfig"
|
||||
),
|
||||
new TestScenario(
|
||||
"Extended Analysis Sample (PROD)",
|
||||
root.resolve("state_machines/extended_analysis_sample"),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CallChainLinkPolicyTest {
|
||||
|
||||
@Test
|
||||
void shouldFailClosedOnEnumSetAmbiguousWiden() {
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("ENUM_SET:com.example.OrderEvent.PAY,com.example.OrderEvent.SHIP")
|
||||
.polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP"))
|
||||
.build();
|
||||
|
||||
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isTrue();
|
||||
assertThat(CallChainLinkPolicy.resolveLinkResolution(trigger, List.of(), false))
|
||||
.isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMarkExternalTriggersUnresolved() {
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.external(true)
|
||||
.event("eventString")
|
||||
.build();
|
||||
|
||||
assertThat(CallChainLinkPolicy.resolveLinkResolution(trigger, List.of(), false))
|
||||
.isEqualTo(LinkResolution.UNRESOLVED_EXTERNAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedOnValueOfAmbiguousWiden() {
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of("a.OrderEvent.PAY", "a.OrderEvent.SHIP"))
|
||||
.build();
|
||||
|
||||
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isTrue();
|
||||
assertThat(CallChainLinkPolicy.resolveLinkResolution(trigger, List.of(), false))
|
||||
.isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowEndpointNarrowSingleConcretePoly() {
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
|
||||
.build();
|
||||
|
||||
assertThat(CallChainLinkPolicy.isEndpointNarrowPolyEvidence(trigger)).isTrue();
|
||||
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isTrue();
|
||||
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenMultipleConcretePolyCandidatesRemain() {
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP"))
|
||||
.build();
|
||||
|
||||
assertThat(CallChainLinkPolicy.isEndpointNarrowPolyEvidence(
|
||||
trigger, "com.example.OrderEvent")).isFalse();
|
||||
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
|
||||
trigger, "com.example.OrderEvent")).isFalse();
|
||||
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
|
||||
trigger, "com.example.OrderEvent")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveLinkWhenEndpointNarrowPolyHasMatches() {
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||
.build();
|
||||
|
||||
var matched = List.of(
|
||||
click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder()
|
||||
.event("com.example.order.OrderEvent.PAY")
|
||||
.build());
|
||||
|
||||
assertThat(CallChainLinkPolicy.resolveLinkResolution(
|
||||
trigger,
|
||||
matched,
|
||||
false,
|
||||
"com.example.order.OrderEvent")).isEqualTo(LinkResolution.RESOLVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotResolveWhenBroadPolyWidenHasMatches() {
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
|
||||
.build();
|
||||
|
||||
var matched = List.of(
|
||||
click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder()
|
||||
.event("com.example.order.OrderEvent.PAY")
|
||||
.build());
|
||||
|
||||
assertThat(CallChainLinkPolicy.resolveLinkResolution(
|
||||
trigger,
|
||||
matched,
|
||||
false,
|
||||
"com.example.order.OrderEvent")).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTrustImportStyleSingleConcretePolyWhenMachineEventTypeIsKnown() {
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||
.build();
|
||||
|
||||
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
|
||||
trigger, "com.example.order.OrderEvent")).isTrue();
|
||||
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
|
||||
trigger, "com.example.order.OrderEvent")).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CallChainPolyNarrowerTest {
|
||||
|
||||
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
|
||||
|
||||
@Test
|
||||
void shouldNarrowWidePolyUsingExpandedRestPathSegment() {
|
||||
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.name("POST /api/machine/ORDER/transition/PAY")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.build())
|
||||
.methodChain(List.of(
|
||||
"com.example.MachineController.transition",
|
||||
"com.example.StateMachineDispatcher.fireOrder",
|
||||
"com.example.StateMachine.send"))
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.OrderEvent.PAY",
|
||||
"com.example.OrderEvent.SHIP",
|
||||
"com.example.OrderEvent.CANCEL"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||
assertThat(linked.getMatchedTransitions()).hasSize(1);
|
||||
assertThat(linked.getMatchedTransitions().get(0).getEvent())
|
||||
.isEqualTo("com.example.OrderEvent.PAY");
|
||||
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactly("com.example.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNarrowWidePolyUsingRichDispatcherMethodName() {
|
||||
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.name("POST /api/orders/rich/pay")
|
||||
.className("com.example.RichOrderController")
|
||||
.methodName("payViaRichEvent")
|
||||
.build())
|
||||
.methodChain(List.of(
|
||||
"com.example.RichOrderController.payViaRichEvent",
|
||||
"com.example.CommandGateway.payOrderViaRichEvent",
|
||||
"com.example.CentralEventDispatcher.orderPayViaRichEvent",
|
||||
"com.example.CentralEventDispatcher.sendOrderEvent"))
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("richEvent.getType()")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.OrderEvent.PAY",
|
||||
"com.example.OrderEvent.SHIP",
|
||||
"com.example.OrderEvent.CANCEL"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||
assertThat(linked.getMatchedTransitions()).hasSize(1);
|
||||
assertThat(linked.getMatchedTransitions().get(0).getEvent())
|
||||
.isEqualTo("com.example.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNarrowUsingDottedCommandKeyPath() {
|
||||
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.name("POST /api/commands/order.pay")
|
||||
.className("com.example.GenericCommandController")
|
||||
.methodName("execute")
|
||||
.build())
|
||||
.methodChain(List.of(
|
||||
"com.example.GenericCommandController.execute",
|
||||
"com.example.CommandGateway.executeViaMapper",
|
||||
"com.example.CentralEventDispatcher.orderPay",
|
||||
"com.example.CentralEventDispatcher.sendOrderEvent"))
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("OrderEvent.valueOf(actionKey)")
|
||||
.constraint("\"order.pay\".equalsIgnoreCase(commandKey) && command == ORDER_PAY")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.OrderEvent.PAY",
|
||||
"com.example.OrderEvent.SHIP",
|
||||
"com.example.OrderEvent.CANCEL"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||
assertThat(linked.getMatchedTransitions()).hasSize(1);
|
||||
assertThat(linked.getMatchedTransitions().get(0).getEvent())
|
||||
.isEqualTo("com.example.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNarrowWidePolyUsingSendEventLiteralInCallerMethod(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
CentralEventDispatcher dispatcher;
|
||||
public void pay() { dispatcher.orderPay(); }
|
||||
}
|
||||
class CentralEventDispatcher {
|
||||
void orderPay() { sendOrderEvent(OrderEvent.PAY); }
|
||||
void orderShip() { sendOrderEvent(OrderEvent.SHIP); }
|
||||
private void sendOrderEvent(OrderEvent event) {
|
||||
StateMachine sm = new StateMachine();
|
||||
sm.sendEvent(event);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.name("POST /api/orders/pay")
|
||||
.className("com.example.OrderController")
|
||||
.methodName("pay")
|
||||
.build())
|
||||
.methodChain(List.of(
|
||||
"com.example.OrderController.pay",
|
||||
"com.example.CentralEventDispatcher.orderPay",
|
||||
"com.example.CentralEventDispatcher.sendOrderEvent",
|
||||
"com.example.StateMachine.sendEvent"))
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("event")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.OrderEvent.PAY",
|
||||
"com.example.OrderEvent.SHIP",
|
||||
"com.example.OrderEvent.CANCEL"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, context, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||
assertThat(linked.getMatchedTransitions()).hasSize(1);
|
||||
assertThat(linked.getMatchedTransitions().get(0).getEvent())
|
||||
.isEqualTo("com.example.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNarrowWidePolyUsingRichEventParameterType(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class RichOrderController {
|
||||
CentralEventDispatcher dispatcher;
|
||||
public void payViaRichEvent() {
|
||||
dispatcher.orderPayViaRichEvent(new PayRichOrderEvent());
|
||||
}
|
||||
}
|
||||
class PayRichOrderEvent {
|
||||
OrderEvent getType() { return OrderEvent.PAY; }
|
||||
}
|
||||
class CentralEventDispatcher {
|
||||
void orderPayViaRichEvent(PayRichOrderEvent event) {
|
||||
sendOrderEvent(event.getType());
|
||||
}
|
||||
private void sendOrderEvent(OrderEvent event) {
|
||||
StateMachine sm = new StateMachine();
|
||||
sm.sendEvent(event);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.name("POST /api/orders/rich/pay")
|
||||
.className("com.example.RichOrderController")
|
||||
.methodName("payViaRichEvent")
|
||||
.build())
|
||||
.methodChain(List.of(
|
||||
"com.example.RichOrderController.payViaRichEvent",
|
||||
"com.example.CentralEventDispatcher.orderPayViaRichEvent",
|
||||
"com.example.CentralEventDispatcher.sendOrderEvent",
|
||||
"com.example.StateMachine.sendEvent"))
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("event.getType()")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.OrderEvent.PAY",
|
||||
"com.example.OrderEvent.SHIP",
|
||||
"com.example.OrderEvent.CANCEL"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, context, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||
assertThat(linked.getMatchedTransitions()).hasSize(1);
|
||||
assertThat(linked.getMatchedTransitions().get(0).getEvent())
|
||||
.isEqualTo("com.example.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNarrowWidePolyWhenConfigEventTypeFqnIsMissing() {
|
||||
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.name("POST /api/machine/ORDER/transition/PAY")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.build())
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.OrderEvent.PAY",
|
||||
"com.example.OrderEvent.SHIP",
|
||||
"com.example.OrderEvent.CANCEL"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||
assertThat(linked.getMatchedTransitions()).hasSize(1);
|
||||
assertThat(linked.getMatchedTransitions().get(0).getEvent())
|
||||
.isEqualTo("com.example.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenWidePolyHasNoEndpointSpecificHint() {
|
||||
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.name("POST /api/machine/{machineType}/transition/{event}")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.build())
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.external(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.OrderEvent.PAY",
|
||||
"com.example.OrderEvent.SHIP"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
|
||||
assertThat(linked.getLinkResolution()).isIn(
|
||||
LinkResolution.AMBIGUOUS_WIDEN,
|
||||
LinkResolution.UNRESOLVED_EXTERNAL);
|
||||
}
|
||||
|
||||
private static Transition transition(String source, String target, String eventFqn) {
|
||||
Transition transition = new Transition();
|
||||
transition.setSourceStates(List.of(State.of(source, "com.example.OrderState." + source)));
|
||||
transition.setTargetStates(List.of(State.of(target, "com.example.OrderState." + target)));
|
||||
transition.setEvent(Event.of(eventFqn.substring(eventFqn.lastIndexOf('.') + 1), eventFqn));
|
||||
return transition;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
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 java.util.Map;
|
||||
|
||||
@@ -12,77 +17,232 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class MachineScopeFilterEntryPointTest {
|
||||
|
||||
@Test
|
||||
void shouldKeepOrderEndpointsOnOrderMachineOnly() {
|
||||
void shouldDeriveScopedEntryPointsFromCallChainsAndKeepGenericPathVariables() {
|
||||
EntryPoint orderPay = EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/orders/pay")
|
||||
.className("click.kamil.examples.statemachine.layered.web.OrderController")
|
||||
.methodName("pay")
|
||||
.metadata(Map.of("path", "/api/orders/pay"))
|
||||
.name("POST /api/machine/order/pay")
|
||||
.className("com.example.StateMachineController")
|
||||
.methodName("payOrder")
|
||||
.build();
|
||||
EntryPoint documentSubmit = EntryPoint.builder()
|
||||
EntryPoint genericTransition = EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/documents/submit")
|
||||
.className("click.kamil.examples.statemachine.layered.web.DocumentController")
|
||||
.methodName("submit")
|
||||
.metadata(Map.of("path", "/api/documents/submit"))
|
||||
.build();
|
||||
EntryPoint genericCommand = EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/commands/{commandKey}")
|
||||
.className("click.kamil.examples.statemachine.layered.web.GenericCommandController")
|
||||
.methodName("execute")
|
||||
.metadata(Map.of("path", "/api/commands/{commandKey}"))
|
||||
.name("POST /api/machine/{machineType}/transition/{event}")
|
||||
.className("com.example.StateMachineController")
|
||||
.methodName("transition")
|
||||
.metadata(Map.of("path", "/api/machine/{machineType}/transition/{event}"))
|
||||
.build();
|
||||
|
||||
CallChain payChain = CallChain.builder()
|
||||
.entryPoint(orderPay)
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("com.example.OrderEvent.PAY")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.build())
|
||||
.build();
|
||||
|
||||
List<EntryPoint> scoped = EntryPointScopeResolver.scopeFromCallChains(
|
||||
List.of(payChain),
|
||||
List.of(orderPay, genericTransition));
|
||||
|
||||
assertThat(scoped).extracting(EntryPoint::getName)
|
||||
.containsExactlyInAnyOrder(
|
||||
"POST /api/machine/order/pay",
|
||||
"POST /api/machine/{machineType}/transition/{event}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldScopeEnterpriseDedicatedPayEndpointToOrderMachine(@TempDir Path tempDir) throws Exception {
|
||||
writeEnterpriseWebLayer(tempDir);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
EntryPoint payOrder = EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/machine/order/pay")
|
||||
.className("com.example.StateMachineController")
|
||||
.methodName("payOrder")
|
||||
.metadata(Map.of("path", "/api/machine/order/pay"))
|
||||
.build();
|
||||
EntryPoint submitDocument = EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/machine/document/submit")
|
||||
.className("com.example.StateMachineController")
|
||||
.methodName("submitDocument")
|
||||
.metadata(Map.of("path", "/api/machine/document/submit"))
|
||||
.build();
|
||||
|
||||
List<EntryPoint> orderScoped = MachineScopeFilter.filterEntryPointsForMachine(
|
||||
List.of(orderPay, documentSubmit, genericCommand),
|
||||
"click.kamil.examples.statemachine.layered.order.config.StandardOrderStateMachineConfiguration",
|
||||
List.of(payOrder, submitDocument),
|
||||
"com.example.OrderStateMachineConfiguration",
|
||||
context);
|
||||
|
||||
assertThat(orderScoped).extracting(EntryPoint::getName)
|
||||
.contains("POST /api/orders/pay", "POST /api/commands/{commandKey}")
|
||||
.doesNotContain("POST /api/documents/submit");
|
||||
.containsExactly("POST /api/machine/order/pay");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepDedicatedOrderEndpointOnStandardOrderMachineInMultiModuleCodebase() {
|
||||
EntryPoint orderPay = EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/orders/pay")
|
||||
.className("click.kamil.examples.statemachine.layered.web.OrderController")
|
||||
.methodName("pay")
|
||||
.metadata(Map.of("path", "/api/orders/pay"))
|
||||
.build();
|
||||
void shouldExcludeDocumentDedicatedEndpointFromOrderMachine(@TempDir Path tempDir) throws Exception {
|
||||
writeEnterpriseWebLayer(tempDir);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
List<EntryPoint> scoped = MachineScopeFilter.filterEntryPointsForMachine(
|
||||
List.of(orderPay),
|
||||
"click.kamil.examples.statemachine.layered.order.config.StandardOrderStateMachineConfiguration",
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
EntryPoint submitDocument = EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/machine/document/submit")
|
||||
.className("com.example.StateMachineController")
|
||||
.methodName("submitDocument")
|
||||
.metadata(Map.of("path", "/api/machine/document/submit"))
|
||||
.build();
|
||||
|
||||
List<EntryPoint> orderScoped = MachineScopeFilter.filterEntryPointsForMachine(
|
||||
List.of(submitDocument),
|
||||
"com.example.OrderStateMachineConfiguration",
|
||||
context);
|
||||
|
||||
assertThat(scoped).extracting(EntryPoint::getName)
|
||||
.containsExactly("POST /api/orders/pay");
|
||||
assertThat(orderScoped).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepOrdersPathOnNonDomainNamedMachineConfig() {
|
||||
EntryPoint ordersSubmit = EntryPoint.builder()
|
||||
void shouldFilterSharedDispatcherCallChainsByProvenMachineTypes(@TempDir Path tempDir) throws Exception {
|
||||
writeEnterpriseWebLayer(tempDir);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
CallChain payChain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("com.example.OrderEvent.PAY")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.className("com.example.StateMachineDispatcher")
|
||||
.methodName("payOrder")
|
||||
.build())
|
||||
.methodChain(List.of("com.example.StateMachineController.payOrder()"))
|
||||
.build();
|
||||
CallChain documentChain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("com.example.DocumentEvent.SUBMIT")
|
||||
.eventTypeFqn("com.example.DocumentEvent")
|
||||
.stateTypeFqn("com.example.DocumentState")
|
||||
.className("com.example.StateMachineDispatcher")
|
||||
.methodName("submitDocument")
|
||||
.build())
|
||||
.methodChain(List.of("com.example.StateMachineController.submitDocument()"))
|
||||
.build();
|
||||
|
||||
List<CallChain> orderScoped = MachineScopeFilter.filterCallChainsForMachine(
|
||||
List.of(payChain, documentChain),
|
||||
"com.example.OrderStateMachineConfiguration",
|
||||
context);
|
||||
|
||||
assertThat(orderScoped).hasSize(1);
|
||||
assertThat(orderScoped.get(0).getTriggerPoint().getEvent()).isEqualTo("com.example.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExcludeOrderAndAuditChainsFromPaymentMachineExport(@TempDir Path tempDir) throws Exception {
|
||||
writeExtendedStringMachineSample(tempDir);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
CallChain orderSubmit = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/v2/orders/submit")
|
||||
.className("click.kamil.examples.statemachine.inheritance.api.OrderControllerImpl")
|
||||
.name("POST /api/orders/submit")
|
||||
.className("com.example.OrderController")
|
||||
.methodName("submitOrder")
|
||||
.metadata(Map.of("path", "/api/v2/orders/submit"))
|
||||
.build())
|
||||
.methodChain(List.of(
|
||||
"com.example.OrderController.submitOrder",
|
||||
"com.example.OrderService.processSubmit"))
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("java.lang.String.SUBMIT_EVENT")
|
||||
.eventTypeFqn("java.lang.String")
|
||||
.stateTypeFqn("java.lang.String")
|
||||
.className("com.example.OrderService")
|
||||
.methodName("processSubmit")
|
||||
.build())
|
||||
.build();
|
||||
CallChain paymentAuthorize = CallChain.builder()
|
||||
.methodChain(List.of("com.example.PaymentService.processPayment"))
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("java.lang.String.AUTHORIZE")
|
||||
.eventTypeFqn("java.lang.String")
|
||||
.stateTypeFqn("java.lang.String")
|
||||
.stateMachineId("paymentStateMachine")
|
||||
.className("com.example.PaymentService")
|
||||
.methodName("processPayment")
|
||||
.build())
|
||||
.build();
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
List<EntryPoint> scoped = MachineScopeFilter.filterEntryPointsForMachine(
|
||||
List.of(ordersSubmit),
|
||||
"click.kamil.examples.statemachine.inheritance.config.InheritanceStateMachineConfig",
|
||||
context);
|
||||
List<CallChain> paymentScoped = MachineScopeFilter.filterCallChainsForMachine(
|
||||
List.of(orderSubmit, paymentAuthorize),
|
||||
"com.example.PaymentStateMachineConfig",
|
||||
context,
|
||||
List.of());
|
||||
|
||||
assertThat(scoped).extracting(EntryPoint::getName)
|
||||
.containsExactly("POST /api/v2/orders/submit");
|
||||
assertThat(paymentScoped).hasSize(1);
|
||||
assertThat(paymentScoped.get(0).getTriggerPoint().getEvent()).isEqualTo("java.lang.String.AUTHORIZE");
|
||||
}
|
||||
|
||||
private static void writeExtendedStringMachineSample(Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("Machines.java"), """
|
||||
package com.example;
|
||||
@org.springframework.statemachine.config.EnableStateMachine(name = "paymentStateMachine")
|
||||
public class PaymentStateMachineConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
@org.springframework.statemachine.config.EnableStateMachine(name = "extendedStateMachine")
|
||||
public class ExtendedStateMachineConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
class OrderController {
|
||||
private final OrderService orderService = new OrderService();
|
||||
public void submitOrder() { orderService.processSubmit(); }
|
||||
}
|
||||
class OrderService {
|
||||
org.springframework.statemachine.StateMachine<String, String> stateMachine;
|
||||
public void processSubmit() { stateMachine.sendEvent("SUBMIT_EVENT"); }
|
||||
}
|
||||
class PaymentService {
|
||||
@org.springframework.beans.factory.annotation.Qualifier("paymentStateMachine")
|
||||
org.springframework.statemachine.StateMachine<String, String> stateMachine;
|
||||
public void processPayment() { stateMachine.sendEvent("AUTHORIZE"); }
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static void writeEnterpriseWebLayer(Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("App.java"), """
|
||||
package com.example;
|
||||
public class StateMachineController {
|
||||
private final StateMachineDispatcher dispatcher = new StateMachineDispatcher();
|
||||
public void payOrder() { dispatcher.payOrder(); }
|
||||
public void submitDocument() { dispatcher.submitDocument(); }
|
||||
}
|
||||
class StateMachineDispatcher {
|
||||
void payOrder() {
|
||||
org.springframework.statemachine.StateMachine<OrderState, OrderEvent> machine = null;
|
||||
machine.sendEvent(OrderEvent.PAY);
|
||||
}
|
||||
void submitDocument() {
|
||||
org.springframework.statemachine.StateMachine<DocumentState, DocumentEvent> machine = null;
|
||||
machine.sendEvent(DocumentEvent.SUBMIT);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY }
|
||||
enum OrderState { NEW, PAID }
|
||||
enum DocumentEvent { SUBMIT }
|
||||
enum DocumentState { DRAFT }
|
||||
class OrderStateMachineConfiguration
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<OrderState, OrderEvent> {}
|
||||
class DocumentStateMachineConfiguration
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<DocumentState, DocumentEvent> {}
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ 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.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
@@ -42,6 +44,74 @@ class TransitionLinkerEnricherTest {
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExportResolvedLinkResolutionForDedicatedLiteralEndpoint() {
|
||||
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/order/pay")
|
||||
.className("com.example.Api")
|
||||
.methodName("pay")
|
||||
.build())
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("OrderEvent.PAY")
|
||||
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||
.external(false)
|
||||
.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.RESOLVED);
|
||||
assertThat(updated.getMatchedTransitions()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExportUnresolvedExternalForGenericPathVariableEndpoint() {
|
||||
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("eventString.toUpperCase()")
|
||||
.external(true)
|
||||
.ambiguous(true)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
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"));
|
||||
|
||||
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
|
||||
void shouldLinkWhenSymbolicPolymorphicEventsExpandToMachineEnumConstants() {
|
||||
Transition payT = new Transition();
|
||||
@@ -120,11 +190,143 @@ class TransitionLinkerEnricherTest {
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
|
||||
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLinkTransitionByEventWhenSourceStateIsUniquelyDeterminedByTransitionTable() {
|
||||
Transition t1 = new Transition();
|
||||
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
|
||||
t1.setTargetStates(List.of(State.of("PAID", "PAID")));
|
||||
t1.setEvent(Event.of("PAY", "PAY"));
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder().event("PAY").build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.transitions(List.of(t1))
|
||||
.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(1);
|
||||
assertThat(updatedChain.getMatchedTransitions().get(0).getSourceState()).isEqualTo("NEW");
|
||||
assertThat(updatedChain.getMatchedTransitions().get(0).getTargetState()).isEqualTo("PAID");
|
||||
assertThat(updatedChain.getTriggerPoint().getSourceState()).isEqualTo("NEW");
|
||||
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLinkWhenSameEventAppearsFromMultipleSourcesWithinSameStateEnum() {
|
||||
Transition payFromNew = new Transition();
|
||||
payFromNew.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
|
||||
payFromNew.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
|
||||
payFromNew.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
|
||||
|
||||
Transition payFromPending = new Transition();
|
||||
payFromPending.setSourceStates(List.of(State.of("PENDING", "com.example.order.OrderState.PENDING")));
|
||||
payFromPending.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
|
||||
payFromPending.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of("com.example.order.OrderEvent.PAY"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfiguration")
|
||||
.eventTypeFqn("com.example.order.OrderEvent")
|
||||
.stateTypeFqn("com.example.order.OrderState")
|
||||
.transitions(List.of(payFromNew, payFromPending))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(updatedChain.getMatchedTransitions()).hasSize(2);
|
||||
assertThat(updatedChain.getMatchedTransitions())
|
||||
.extracting("sourceState")
|
||||
.containsExactlyInAnyOrder("NEW", "FAILED");
|
||||
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
|
||||
void shouldNotInferSourceStateWhenDifferentEnumsShareSameConstantName() {
|
||||
Transition t1 = new Transition();
|
||||
t1.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
|
||||
t1.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
|
||||
t1.setEvent(Event.of("PAY", "PAY"));
|
||||
|
||||
Transition t2 = new Transition();
|
||||
t2.setSourceStates(List.of(State.of("NEW", "com.example.invoice.InvoiceState.NEW")));
|
||||
t2.setTargetStates(List.of(State.of("PAID", "com.example.invoice.InvoiceState.PAID")));
|
||||
t2.setEvent(Event.of("PAY", "PAY"));
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder().event("PAY").build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.transitions(List.of(t1, t2))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(updatedChain.getTriggerPoint().getSourceState()).isNull();
|
||||
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isTrue();
|
||||
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
|
||||
assertThat(updatedChain.getLinkResolution()).isEqualTo(click.kamil.springstatemachineexporter.analysis.model.LinkResolution.AMBIGUOUS_WIDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -307,7 +509,7 @@ class TransitionLinkerEnricherTest {
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
|
||||
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -419,8 +621,7 @@ class TransitionLinkerEnricherTest {
|
||||
.build();
|
||||
|
||||
enricher.enrich(resultComputer, null, null);
|
||||
CallChain updatedChainComp = resultComputer.getMetadata().getCallChains().get(0);
|
||||
assertThat(updatedChainComp.getMatchedTransitions()).isNullOrEmpty();
|
||||
assertThat(resultComputer.getMetadata().getCallChains()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -521,27 +722,22 @@ class TransitionLinkerEnricherTest {
|
||||
|
||||
// Test Electronics SM - Should only keep Electronics Chain
|
||||
enricher.enrich(resElec, null, null);
|
||||
assertThat(resElec.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); // Elec
|
||||
assertThat(resElec.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn
|
||||
assertThat(resElec.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc
|
||||
assertThat(resElec.getMetadata().getCallChains()).hasSize(1);
|
||||
assertThat(resElec.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
|
||||
|
||||
// Test Furniture SM - Should only keep Furniture Chain
|
||||
enricher.enrich(resFurn, null, null);
|
||||
assertThat(resFurn.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec
|
||||
assertThat(resFurn.getMetadata().getCallChains().get(1).getMatchedTransitions()).hasSize(1); // Furn
|
||||
assertThat(resFurn.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc
|
||||
assertThat(resFurn.getMetadata().getCallChains()).hasSize(1);
|
||||
assertThat(resFurn.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
|
||||
|
||||
// Test Groceries SM - Should only keep Groceries Chain
|
||||
enricher.enrich(resGroc, null, null);
|
||||
assertThat(resGroc.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec
|
||||
assertThat(resGroc.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn
|
||||
assertThat(resGroc.getMetadata().getCallChains().get(2).getMatchedTransitions()).hasSize(1); // Groc
|
||||
assertThat(resGroc.getMetadata().getCallChains()).hasSize(1);
|
||||
assertThat(resGroc.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
|
||||
|
||||
// Test ComputerStore SM - Should reject ALL because none belong to computerstore
|
||||
enricher.enrich(resComp, null, null);
|
||||
assertThat(resComp.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec
|
||||
assertThat(resComp.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn
|
||||
assertThat(resComp.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc
|
||||
assertThat(resComp.getMetadata().getCallChains()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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.TriggerPoint;
|
||||
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 java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TransitionLinkerMultiSourceStateTest {
|
||||
|
||||
@Test
|
||||
void shouldLinkEachSourceStatePairWithoutCrossMultiplication() {
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.event("com.example.OrderEvent.CANCEL")
|
||||
.sourceState("com.example.OrderState.PENDING")
|
||||
.build();
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(click.kamil.springstatemachineexporter.analysis.model.EntryPoint.builder()
|
||||
.type(click.kamil.springstatemachineexporter.analysis.model.EntryPoint.Type.REST)
|
||||
.name("POST /cancel")
|
||||
.className("com.example.Api")
|
||||
.methodName("cancel")
|
||||
.build())
|
||||
.triggerPoint(trigger)
|
||||
.methodChain(List.of("com.example.Api.cancel", "com.example.Service.fire"))
|
||||
.build();
|
||||
|
||||
Transition fromPending = transition(
|
||||
"com.example.OrderEvent.CANCEL",
|
||||
"com.example.OrderState.PENDING",
|
||||
"com.example.OrderState.CANCELLED");
|
||||
Transition fromPaid = transition(
|
||||
"com.example.OrderEvent.CANCEL",
|
||||
"com.example.OrderState.PAID",
|
||||
"com.example.OrderState.CANCELLED");
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfiguration")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.transitions(List.of(fromPending, fromPaid))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
new TransitionLinkerEnricher().enrich(result, null, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions())
|
||||
.hasSize(1)
|
||||
.first()
|
||||
.satisfies(mt -> {
|
||||
assertThat(mt.getSourceState()).isEqualTo("com.example.OrderState.PENDING");
|
||||
assertThat(mt.getEvent()).isEqualTo("com.example.OrderEvent.CANCEL");
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
State.of("com.example.OrderState.OTHER", "com.example.OrderState.OTHER")));
|
||||
transition.setTargetStates(List.of(State.of(target, target)));
|
||||
return transition;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Regression for enterprise multi-module scans: a globally ambiguous enum simple name must not
|
||||
* block machine-scoped {@code valueOf} widens from linking when polymorphic events use import-style
|
||||
* labels and the machine event type is known.
|
||||
*/
|
||||
class TrustedEnumWidenLinkingRegressionTest {
|
||||
|
||||
@Test
|
||||
void shouldLinkTrustedImportStyleWidenWhenEnumSimpleNameIsGloballyAmbiguous(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { CANCEL }");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
assertThat(context.isAmbiguousSimpleName("OrderEvent")).isTrue();
|
||||
|
||||
Transition pay = new Transition();
|
||||
pay.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
|
||||
pay.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
|
||||
pay.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
|
||||
|
||||
Transition ship = new Transition();
|
||||
ship.setSourceStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
|
||||
ship.setTargetStates(List.of(State.of("SHIPPED", "com.example.order.OrderState.SHIPPED")));
|
||||
ship.setEvent(Event.of("SHIP", "com.example.order.OrderEvent.SHIP"));
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.ambiguous(true)
|
||||
.external(false)
|
||||
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfiguration")
|
||||
.eventTypeFqn("com.example.order.OrderEvent")
|
||||
.stateTypeFqn("com.example.order.OrderState")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
new TransitionLinkerEnricher().enrich(result, null, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||
assertThat(linked.getMatchedTransitions()).hasSizeGreaterThanOrEqualTo(1);
|
||||
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactly("com.example.order.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepTrustedWidenCallChainWhenEnumSimpleNameIsGloballyAmbiguous(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { CANCEL }");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
assertThat(context.isAmbiguousSimpleName("OrderEvent")).isTrue();
|
||||
|
||||
Transition pay = new Transition();
|
||||
pay.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
|
||||
pay.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
|
||||
pay.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
|
||||
|
||||
Transition ship = new Transition();
|
||||
ship.setSourceStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
|
||||
ship.setTargetStates(List.of(State.of("SHIPPED", "com.example.order.OrderState.SHIPPED")));
|
||||
ship.setEvent(Event.of("SHIP", "com.example.order.OrderEvent.SHIP"));
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.ambiguous(true)
|
||||
.external(false)
|
||||
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
List<CallChain> scoped = MachineScopeFilter.filterCallChainsForMachine(
|
||||
List.of(chain),
|
||||
"com.example.config.OrderStateMachineConfiguration",
|
||||
context,
|
||||
List.of(pay, ship));
|
||||
|
||||
assertThat(scoped).hasSize(1);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.enricher.matching;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -130,6 +131,28 @@ class StrictFqnMatchingEngineTest {
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMatchStringLiteralTriggerToConfiguredStringTransition() {
|
||||
Event smEvent = Event.of("\"AUDIT_EVENT\"", "String.AUDIT_EVENT");
|
||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
||||
.event("AUDIT_EVENT")
|
||||
.eventTypeFqn("java.lang.String")
|
||||
.build();
|
||||
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMatchCanonicalStringLiteralTriggerToConfiguredStringTransition() {
|
||||
Event smEvent = Event.of("\"AUDIT_EVENT\"", "String.AUDIT_EVENT");
|
||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
||||
.event("java.lang.String.AUDIT_EVENT")
|
||||
.eventTypeFqn("java.lang.String")
|
||||
.build();
|
||||
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotMatchStringTypeToEnumFqn() {
|
||||
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
|
||||
@@ -284,4 +307,58 @@ class StrictFqnMatchingEngineTest {
|
||||
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotMatchGetterChainWithoutPolymorphicEventsAgainstBareStringEvent() {
|
||||
Event smEvent = Event.of("PAY", "PAY");
|
||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
||||
.event("richEvent.getId()")
|
||||
.build();
|
||||
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotMatchGetterChainWithoutPolymorphicEventsAgainstEnumEvent() {
|
||||
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
|
||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
||||
.event("payload.getType()")
|
||||
.eventTypeFqn("com.example.OrderEvents")
|
||||
.build();
|
||||
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotMatchMethodCallWithoutPolymorphicEventsAgainstBareStringEvent() {
|
||||
Event smEvent = Event.of("PAY", "PAY");
|
||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
||||
.event("getType()")
|
||||
.build();
|
||||
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMatchImportStyleEnumToMachineFqnWhenSimpleNamesAlign(@TempDir java.nio.file.Path tempDir) throws Exception {
|
||||
java.nio.file.Files.createDirectories(tempDir.resolve("a"));
|
||||
java.nio.file.Files.createDirectories(tempDir.resolve("b"));
|
||||
java.nio.file.Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY }");
|
||||
java.nio.file.Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { PAY }");
|
||||
|
||||
click.kamil.springstatemachineexporter.ast.common.CodebaseContext context =
|
||||
new click.kamil.springstatemachineexporter.ast.common.CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
StrictFqnMatchingEngine engine = new StrictFqnMatchingEngine(context);
|
||||
Event smEvent = Event.of("PAY", "a.OrderEvent.PAY");
|
||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
||||
.event("OrderEvent.PAY")
|
||||
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||
.build();
|
||||
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
|
||||
@@ -35,6 +36,65 @@ class HeuristicBeanResolutionEngineRoutingTest {
|
||||
context)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedServiceWithProvenOrderTypesShouldOnlyRouteToOrderMachine(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("SharedDispatcher.java"), """
|
||||
package com.example;
|
||||
public class SharedDispatcher {
|
||||
void payOrder() {
|
||||
org.springframework.statemachine.StateMachine<OrderState, OrderEvent> machine = null;
|
||||
machine.sendEvent(OrderEvent.PAY);
|
||||
}
|
||||
void submitDocument() {
|
||||
org.springframework.statemachine.StateMachine<DocumentState, DocumentEvent> machine = null;
|
||||
machine.sendEvent(DocumentEvent.SUBMIT);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY }
|
||||
enum OrderState { NEW, PAID }
|
||||
enum DocumentEvent { SUBMIT }
|
||||
enum DocumentState { DRAFT }
|
||||
class OrderStateMachineConfiguration
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<OrderState, OrderEvent> {}
|
||||
class DocumentStateMachineConfiguration
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<DocumentState, DocumentEvent> {}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
CallChain orderChain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("com.example.OrderEvent.PAY")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.className("com.example.SharedDispatcher")
|
||||
.methodName("payOrder")
|
||||
.build())
|
||||
.methodChain(List.of("com.example.SharedDispatcher.payOrder()"))
|
||||
.build();
|
||||
CallChain documentChain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("com.example.DocumentEvent.SUBMIT")
|
||||
.eventTypeFqn("com.example.DocumentEvent")
|
||||
.stateTypeFqn("com.example.DocumentState")
|
||||
.className("com.example.SharedDispatcher")
|
||||
.methodName("submitDocument")
|
||||
.build())
|
||||
.methodChain(List.of("com.example.SharedDispatcher.submitDocument()"))
|
||||
.build();
|
||||
|
||||
assertThat(engine.isRoutedToCorrectMachine(
|
||||
orderChain, "com.example.OrderStateMachineConfiguration", context)).isTrue();
|
||||
assertThat(engine.isRoutedToCorrectMachine(
|
||||
orderChain, "com.example.DocumentStateMachineConfiguration", context)).isFalse();
|
||||
assertThat(engine.isRoutedToCorrectMachine(
|
||||
documentChain, "com.example.DocumentStateMachineConfiguration", context)).isTrue();
|
||||
assertThat(engine.isRoutedToCorrectMachine(
|
||||
documentChain, "com.example.OrderStateMachineConfiguration", context)).isFalse();
|
||||
}
|
||||
|
||||
private static void writeTwoMachineSample(Path tempDir) throws Exception {
|
||||
Path orderPkg = tempDir.resolve("com/example/order");
|
||||
Path invoicePkg = tempDir.resolve("com/example/invoice");
|
||||
|
||||
@@ -168,6 +168,35 @@ public class HeuristicBeanResolutionEngineTest {
|
||||
"Ambiguous shared gateway should not attach to every machine");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveTriggerStateMachineIdAgainstEnableStateMachineName(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("PaymentConfig.java"), """
|
||||
package com.example;
|
||||
@org.springframework.statemachine.config.EnableStateMachine(name = "paymentStateMachine")
|
||||
public class PaymentStateMachineConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
""");
|
||||
Files.writeString(tempDir.resolve("OrderConfig.java"), """
|
||||
package com.example;
|
||||
@org.springframework.statemachine.config.EnableStateMachine(name = "orderStateMachine")
|
||||
public class OrderStateMachineConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.stateMachineId("paymentStateMachine")
|
||||
.build())
|
||||
.methodChain(List.of("com.example.PaymentService.capture()"))
|
||||
.build();
|
||||
|
||||
assertTrue(engine.isRoutedToCorrectMachine(chain, "com.example.PaymentStateMachineConfig", context));
|
||||
assertFalse(engine.isRoutedToCorrectMachine(chain, "com.example.OrderStateMachineConfig", context));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptTriggerWhenSpringErasureDiffersFromStringMachineTypes(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("InheritanceStateMachineConfig.java"), """
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
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 SharedServiceRoutingPolicyTest {
|
||||
|
||||
private final HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
|
||||
|
||||
@Test
|
||||
void commonOrderServiceChainIsProvablySharedInfrastructure() {
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder().event("PROCESS").build())
|
||||
.methodChain(List.of(
|
||||
"com.example.CommonOrderController.post()",
|
||||
"com.example.CommonOrderService.processOrderEvent()"))
|
||||
.build();
|
||||
|
||||
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void electronicsVerticalChainIsNotProvablySharedInfrastructure() {
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
|
||||
.methodChain(List.of(
|
||||
"com.example.electronics.ElectronicsOrderController.post()",
|
||||
"com.example.electronics.ElectronicsOrderService.processOrderEvent()"))
|
||||
.build();
|
||||
|
||||
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void chainWithProvenTypeFqnsIsNotProvablySharedInfrastructure() {
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("com.example.OrderEvent.PAY")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.build())
|
||||
.methodChain(List.of("com.example.SharedDispatcher.payOrder()"))
|
||||
.build();
|
||||
|
||||
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedInfrastructureMayMultiAttachOnSharedEventInMultiMachineContext(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("OrderConfig.java"), """
|
||||
package com.example;
|
||||
@org.springframework.statemachine.config.EnableStateMachine
|
||||
public class OrderStateMachineConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
""");
|
||||
Files.writeString(tempDir.resolve("DocumentConfig.java"), """
|
||||
package com.example;
|
||||
@org.springframework.statemachine.config.EnableStateMachine
|
||||
public class DocumentStateMachineConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder().event("PROCESS").build())
|
||||
.methodChain(List.of(
|
||||
"com.example.CommonOrderController.post()",
|
||||
"com.example.CommonOrderService.processOrderEvent()"))
|
||||
.build();
|
||||
|
||||
Transition transition = new Transition();
|
||||
transition.setSourceStates(List.of(State.of("NEW", "NEW")));
|
||||
transition.setTargetStates(List.of(State.of("PROCESSED", "PROCESSED")));
|
||||
transition.setEvent(Event.of("PROCESS", "PROCESS"));
|
||||
List<Transition> machineTransitions = List.of(transition);
|
||||
|
||||
assertThat(engine.hasProvenMachineAffinity(
|
||||
chain, "com.example.OrderStateMachineConfig", context, machineTransitions)).isTrue();
|
||||
assertThat(engine.hasProvenMachineAffinity(
|
||||
chain, "com.example.DocumentStateMachineConfig", context, machineTransitions)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void verticalChainFailsClosedOnEventMatchInMultiMachineContext(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("ElectronicsConfig.java"), """
|
||||
package com.example.electronics;
|
||||
@org.springframework.statemachine.config.EnableStateMachine
|
||||
public class ElectronicsStateMachineConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
""");
|
||||
Files.writeString(tempDir.resolve("ComputerConfig.java"), """
|
||||
package com.example.computerstore;
|
||||
@org.springframework.statemachine.config.EnableStateMachine
|
||||
public class ComputerStateMachineConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
|
||||
.methodChain(List.of(
|
||||
"com.example.electronics.ElectronicsOrderController.post()",
|
||||
"com.example.electronics.ElectronicsOrderService.processOrderEvent()"))
|
||||
.build();
|
||||
|
||||
Transition transition = new Transition();
|
||||
transition.setSourceStates(List.of(State.of("NEW", "NEW")));
|
||||
transition.setTargetStates(List.of(State.of("ASSEMBLED", "ASSEMBLED")));
|
||||
transition.setEvent(Event.of("ASSEMBLE", "ASSEMBLE"));
|
||||
List<Transition> machineTransitions = List.of(transition);
|
||||
|
||||
assertThat(engine.hasProvenMachineAffinity(
|
||||
chain, "com.example.electronics.ElectronicsStateMachineConfig", context, machineTransitions))
|
||||
.isTrue();
|
||||
assertThat(engine.hasProvenMachineAffinity(
|
||||
chain, "com.example.computerstore.ComputerStateMachineConfig", context, machineTransitions))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void provenDistinctTypesStillRouteToSingleMachine(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("SharedDispatcher.java"), """
|
||||
package com.example;
|
||||
public class SharedDispatcher {
|
||||
void payOrder() {
|
||||
org.springframework.statemachine.StateMachine<OrderState, OrderEvent> machine = null;
|
||||
machine.sendEvent(OrderEvent.PAY);
|
||||
}
|
||||
void submitDocument() {
|
||||
org.springframework.statemachine.StateMachine<DocumentState, DocumentEvent> machine = null;
|
||||
machine.sendEvent(DocumentEvent.SUBMIT);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY }
|
||||
enum OrderState { NEW, PAID }
|
||||
enum DocumentEvent { SUBMIT }
|
||||
enum DocumentState { DRAFT }
|
||||
class OrderStateMachineConfiguration
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<OrderState, OrderEvent> {}
|
||||
class DocumentStateMachineConfiguration
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<DocumentState, DocumentEvent> {}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
CallChain orderChain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("com.example.OrderEvent.PAY")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.className("com.example.SharedDispatcher")
|
||||
.methodName("payOrder")
|
||||
.build())
|
||||
.methodChain(List.of("com.example.SharedDispatcher.payOrder()"))
|
||||
.build();
|
||||
|
||||
Transition payTransition = new Transition();
|
||||
payTransition.setSourceStates(List.of(State.of("NEW", "NEW")));
|
||||
payTransition.setTargetStates(List.of(State.of("PAID", "PAID")));
|
||||
payTransition.setEvent(Event.of("PAY", "PAY"));
|
||||
|
||||
assertThat(engine.hasProvenMachineAffinity(
|
||||
orderChain, "com.example.OrderStateMachineConfiguration", context, List.of(payTransition)))
|
||||
.isTrue();
|
||||
assertThat(engine.hasProvenMachineAffinity(
|
||||
orderChain, "com.example.DocumentStateMachineConfiguration", context, List.of(payTransition)))
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
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 SpringInjectionRoutingTest {
|
||||
|
||||
@Test
|
||||
void shouldRouteByQualifierWithoutPackageNameHeuristics(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("PaymentService.java"), """
|
||||
package com.example;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.statemachine.StateMachine;
|
||||
import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class PaymentService {
|
||||
@Qualifier("paymentStateMachine")
|
||||
private final StateMachine<String, String> stateMachine;
|
||||
public PaymentService(StateMachine<String, String> stateMachine) {
|
||||
this.stateMachine = stateMachine;
|
||||
}
|
||||
public void capture() {
|
||||
stateMachine.sendEvent("CAPTURE");
|
||||
}
|
||||
}
|
||||
@org.springframework.context.annotation.Configuration
|
||||
@org.springframework.statemachine.config.EnableStateMachine(name = "paymentStateMachine")
|
||||
class PaymentStateMachineConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
@org.springframework.context.annotation.Configuration
|
||||
@org.springframework.statemachine.config.EnableStateMachine(name = "orderStateMachine")
|
||||
class OrderStateMachineConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.className("com.example.PaymentService")
|
||||
.methodName("capture")
|
||||
.lineNumber(12)
|
||||
.event("CAPTURE")
|
||||
.eventTypeFqn("java.lang.String")
|
||||
.stateTypeFqn("java.lang.String")
|
||||
.stateMachineId("paymentStateMachine")
|
||||
.build())
|
||||
.methodChain(List.of("com.example.PaymentService.capture()"))
|
||||
.build();
|
||||
|
||||
assertThat(engine.isRoutedToCorrectMachine(
|
||||
chain, "com.example.PaymentStateMachineConfig", context)).isTrue();
|
||||
assertThat(engine.isRoutedToCorrectMachine(
|
||||
chain, "com.example.OrderStateMachineConfig", context)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotBorrowConstructorQualifierFromUnrelatedParameter(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("PaymentService.java"), """
|
||||
package com.example;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.statemachine.StateMachine;
|
||||
import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class PaymentService {
|
||||
private final StateMachine<String, String> orderMachine;
|
||||
private final StateMachine<String, String> paymentMachine;
|
||||
public PaymentService(StateMachine<String, String> orderMachine,
|
||||
@Qualifier("paymentStateMachine") StateMachine<String, String> paymentMachine) {
|
||||
this.orderMachine = orderMachine;
|
||||
this.paymentMachine = paymentMachine;
|
||||
}
|
||||
public void captureOnOrder() {
|
||||
orderMachine.sendEvent("CAPTURE");
|
||||
}
|
||||
}
|
||||
@org.springframework.context.annotation.Configuration
|
||||
@org.springframework.statemachine.config.EnableStateMachine(name = "paymentStateMachine")
|
||||
class PaymentStateMachineConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
@org.springframework.context.annotation.Configuration
|
||||
@org.springframework.statemachine.config.EnableStateMachine(name = "orderStateMachine")
|
||||
class OrderStateMachineConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.className("com.example.PaymentService")
|
||||
.methodName("captureOnOrder")
|
||||
.lineNumber(18)
|
||||
.event("CAPTURE")
|
||||
.eventTypeFqn("java.lang.String")
|
||||
.stateTypeFqn("java.lang.String")
|
||||
.build())
|
||||
.methodChain(List.of("com.example.PaymentService.captureOnOrder()"))
|
||||
.build();
|
||||
|
||||
// Both are String,String machines and receiver has no explicit qualifier -> should fail closed.
|
||||
assertThat(engine.isRoutedToCorrectMachine(chain, "com.example.PaymentStateMachineConfig", context)).isFalse();
|
||||
assertThat(engine.isRoutedToCorrectMachine(chain, "com.example.OrderStateMachineConfig", context)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedForAmbiguousStringStateMachineWithoutQualifier(@TempDir Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("OrderService.java"), """
|
||||
package com.example;
|
||||
import org.springframework.statemachine.StateMachine;
|
||||
import org.springframework.stereotype.Service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OrderService {
|
||||
private final StateMachine<String, String> stateMachine;
|
||||
public void finish() {
|
||||
stateMachine.sendEvent("FINISH");
|
||||
}
|
||||
}
|
||||
@org.springframework.context.annotation.Configuration
|
||||
@org.springframework.statemachine.config.EnableStateMachine(name = "machineA")
|
||||
class MachineAConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
@org.springframework.context.annotation.Configuration
|
||||
@org.springframework.statemachine.config.EnableStateMachine(name = "machineB")
|
||||
class MachineBConfig
|
||||
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.className("com.example.OrderService")
|
||||
.methodName("finish")
|
||||
.lineNumber(11)
|
||||
.event("FINISH")
|
||||
.eventTypeFqn("java.lang.String")
|
||||
.stateTypeFqn("java.lang.String")
|
||||
.build())
|
||||
.methodChain(List.of("com.example.OrderService.finish()"))
|
||||
.build();
|
||||
|
||||
assertThat(engine.isRoutedToCorrectMachine(chain, "com.example.MachineAConfig", context)).isFalse();
|
||||
assertThat(engine.isRoutedToCorrectMachine(chain, "com.example.MachineBConfig", context)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FlowStepJsonTest {
|
||||
|
||||
private final com.fasterxml.jackson.databind.ObjectMapper mapper =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
@Test
|
||||
void shouldDeserializeLegacyStringStep() throws Exception {
|
||||
FlowStep step = mapper.readValue("\"PAY\"", FlowStep.class);
|
||||
assertThat(step.getEvent()).isEqualTo("PAY");
|
||||
assertThat(step.getSource()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDeserializeStructuredStep() throws Exception {
|
||||
FlowStep step = mapper.readValue(
|
||||
"{\"source\":\"com.example.order.OrderState.PAID\",\"event\":\"com.example.order.OrderEvent.SHIP\"}",
|
||||
FlowStep.class);
|
||||
assertThat(step.getSource()).isEqualTo("com.example.order.OrderState.PAID");
|
||||
assertThat(step.getEvent()).isEqualTo("com.example.order.OrderEvent.SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSerializeEventOnlyStepAsString() throws Exception {
|
||||
String json = mapper.writeValueAsString(FlowStep.ofEvent("PAY"));
|
||||
assertThat(json).isEqualTo("\"PAY\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSerializeStructuredStepAsObject() throws Exception {
|
||||
FlowStep step = FlowStep.builder()
|
||||
.source("com.example.order.OrderState.PAID")
|
||||
.event("com.example.order.OrderEvent.SHIP")
|
||||
.linkKey("OrderState_PAID__OrderEvent_SHIP")
|
||||
.build();
|
||||
String json = mapper.writeValueAsString(step);
|
||||
assertThat(json).contains("\"source\"");
|
||||
assertThat(json).contains("\"event\"");
|
||||
assertThat(json).contains("\"linkKey\"");
|
||||
}
|
||||
}
|
||||
@@ -603,5 +603,95 @@ class InheritanceAndNestedResolutionTest {
|
||||
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactly("OrderEvent.SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void callGraphShouldResolveGetterInsideMapLambda() throws IOException {
|
||||
writeJava("com/example/Api.java", """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
StateMachine sm;
|
||||
Payload payload;
|
||||
public void handle() {
|
||||
Mono.just(payload).map(p -> sm.sendEvent(p.getEvent()));
|
||||
}
|
||||
}
|
||||
class Payload {
|
||||
OrderEvent event = OrderEvent.PAY;
|
||||
OrderEvent getEvent() { return event; }
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
class StateMachine { void sendEvent(OrderEvent event) {} }
|
||||
class Mono {
|
||||
static Mono just(Object o) { return new Mono(); }
|
||||
Mono map(Object fn) { return this; }
|
||||
}
|
||||
class OrderStateMachineConfig {}
|
||||
""");
|
||||
scan();
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entry = EntryPoint.builder()
|
||||
.className("com.example.ApiController")
|
||||
.methodName("handle")
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
|
||||
assertThat(chains).hasSize(1);
|
||||
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactly("OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void callGraphShouldResolveGetterThroughNestedSwitchMap() throws IOException {
|
||||
writeJava("com/example/Api.java", """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
StateMachine sm;
|
||||
Payload payload;
|
||||
public void handle() {
|
||||
Mono.just(payload)
|
||||
.switchMap(x -> Mono.just(x.getInner()))
|
||||
.switchMap(p -> sm.sendEvent(p.getEvent()));
|
||||
}
|
||||
}
|
||||
class Payload {
|
||||
Inner inner = new Inner();
|
||||
Inner getInner() { return inner; }
|
||||
}
|
||||
class Inner {
|
||||
OrderEvent event = OrderEvent.SHIP;
|
||||
OrderEvent getEvent() { return event; }
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
class StateMachine { void sendEvent(OrderEvent event) {} }
|
||||
class Mono {
|
||||
static Mono just(Object o) { return new Mono(); }
|
||||
Mono switchMap(Object fn) { return this; }
|
||||
}
|
||||
class OrderStateMachineConfig {}
|
||||
""");
|
||||
scan();
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entry = EntryPoint.builder()
|
||||
.className("com.example.ApiController")
|
||||
.methodName("handle")
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
|
||||
assertThat(chains).hasSize(1);
|
||||
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactly("OrderEvent.SHIP");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ class PipelineRefactorTest {
|
||||
void shouldRecognizeReactiveReceiversAndFactoryMethods() {
|
||||
assertThat(LibraryUnwrapRegistry.isUnwrapReceiverType("Mono")).isTrue();
|
||||
assertThat(LibraryUnwrapRegistry.isUnwrapMethodName("just")).isTrue();
|
||||
assertThat(LibraryUnwrapRegistry.isReactiveFactoryMethod("withPayload")).isTrue();
|
||||
assertThat(LibraryUnwrapRegistry.isReactiveTransformMethod("flatMap")).isTrue();
|
||||
assertThat(LibraryUnwrapRegistry.shouldStopUnwrap("buildPayload")).isTrue();
|
||||
assertThat(LibraryUnwrapRegistry.isEventSetterMethodName("eventType")).isTrue();
|
||||
}
|
||||
|
||||
@@ -96,4 +96,46 @@ class ReactiveExpressionSupportTest {
|
||||
eventExpr, context.getConstantResolver(), context);
|
||||
assertThat(remapped).isEqualTo("payload.getEvent()");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRemapLambdaParameterGetterInsideMap(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class Runner {
|
||||
Payload payload;
|
||||
StateMachine sm;
|
||||
void run() {
|
||||
Mono.just(payload).map(p -> p.getEvent());
|
||||
}
|
||||
}
|
||||
class Payload { String getEvent() { return "X"; } }
|
||||
class StateMachine { void sendEvent(String e) {} }
|
||||
class Mono {
|
||||
static Mono just(Object o) { return new Mono(); }
|
||||
Mono map(Object fn) { return this; }
|
||||
}
|
||||
""";
|
||||
Path file = tempDir.resolve("Runner.java");
|
||||
Files.writeString(file, source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
org.eclipse.jdt.core.dom.TypeDeclaration runner = context.getTypeDeclaration("com.example.Runner");
|
||||
CompilationUnit cu = (CompilationUnit) runner.getRoot();
|
||||
final MethodInvocation[] getEventCall = new MethodInvocation[1];
|
||||
cu.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if ("getEvent".equals(node.getName().getIdentifier()) && getEventCall[0] == null) {
|
||||
getEventCall[0] = node;
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
|
||||
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetter(
|
||||
getEventCall[0], context.getConstantResolver(), context);
|
||||
assertThat(remapped).isEqualTo("payload.getEvent()");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class BooleanConstraintEvaluatorBindingTest {
|
||||
|
||||
@Test
|
||||
void shouldEvaluateEqualsIgnoreCaseAgainstPathVariableBinding() {
|
||||
String constraint = "\"ORDER\".equalsIgnoreCase(machineType)";
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, Map.of("machineType", "ORDER")))
|
||||
.isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, Map.of("machineType", "DOCUMENT")))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEvaluateDocumentBranchAgainstBinding() {
|
||||
String constraint = "\"DOCUMENT\".equalsIgnoreCase(machineType)";
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, Map.of("machineType", "DOCUMENT")))
|
||||
.isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, Map.of("machineType", "ORDER")))
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,50 @@ class BooleanConstraintEvaluatorBindingsTest {
|
||||
Map.of("commandKey", "order.pay"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectCrossPackageQualifiedEnumSwitchConstraint() {
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||
"command == a.OrderEvent.PAY",
|
||||
Map.of("command", "b.OrderEvent.PAY"))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptPackageQualifiedRhsAgainstImportStyleBinding() {
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||
"command == com.example.DomainCommand.ORDER_PAY",
|
||||
Map.of("command", "DomainCommand.ORDER_PAY"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectConstraintWhenBindingIsMissing() {
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||
"command == ORDER_PAY",
|
||||
Map.of("commandKey", "order.pay"))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEvaluateEqualsIgnoreCaseWithHelperWrappedParameter() {
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||
"\"ORDER\".equalsIgnoreCase(normalizeType(machineType))",
|
||||
Map.of("machineType", "ORDER"))).isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||
"\"ORDER\".equalsIgnoreCase(normalizeType(machineType))",
|
||||
Map.of("machineType", "DOCUMENT"))).isFalse();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||
"\"DOCUMENT\".equalsIgnoreCase(normalizeType(machineType))",
|
||||
Map.of("machineType", "DOCUMENT"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEvaluateObjectsEqualsWithHelperWrappedParameter() {
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||
"java.util.Objects.equals(\"ORDER\", normalizeType(machineType))",
|
||||
Map.of("machineType", "ORDER"))).isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||
"java.util.Objects.equals(\"ORDER\", normalizeType(machineType))",
|
||||
Map.of("machineType", "DOCUMENT"))).isFalse();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||
"java.util.Objects.equals(normalizeType(machineType), \"DOCUMENT\")",
|
||||
Map.of("machineType", "DOCUMENT"))).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,8 +647,7 @@ public class ConstantResolverTest {
|
||||
MethodInvocation getCall = (MethodInvocation) ((VariableDeclarationFragment) fd.fragments().get(0)).getInitializer();
|
||||
|
||||
ConstantExtractor extractor = new ConstantExtractor(context, new ConstantResolver(), mi -> null);
|
||||
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath(
|
||||
List.of("com.example.ApiController.dispatch"));
|
||||
context.setAnalysisCallPath(List.of("com.example.ApiController.dispatch"));
|
||||
try {
|
||||
ConstantResolver resolver = new ConstantResolver();
|
||||
SimpleName routesName = (SimpleName) getCall.getExpression();
|
||||
@@ -660,7 +659,7 @@ public class ConstantResolverTest {
|
||||
extractor.extractConstantsFromExpression(getCall, constants);
|
||||
assertThat(constants).contains("OrderEvent.PAY", "OrderEvent.SHIP");
|
||||
} finally {
|
||||
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath();
|
||||
context.clearAnalysisCallPath();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,14 +51,16 @@ class EnterpriseBooleanEnumPredicateExportTest {
|
||||
.toList();
|
||||
assertThat(callChains).isNotEmpty();
|
||||
|
||||
JsonNode dynamicChain = callChains.stream()
|
||||
List<JsonNode> dispatchChains = callChains.stream()
|
||||
.filter(chain -> chain.get("methodChain").toString().contains("OrderDispatcher.dispatch"))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("missing OrderDispatcher.dispatch chain: " + callChains));
|
||||
.toList();
|
||||
assertThat(dispatchChains).isNotEmpty();
|
||||
|
||||
JsonNode trigger = dynamicChain.get("triggerPoint");
|
||||
List<String> polyEvents = StreamSupport.stream(trigger.get("polymorphicEvents").spliterator(), false)
|
||||
List<String> polyEvents = dispatchChains.stream()
|
||||
.flatMap(chain -> StreamSupport.stream(
|
||||
chain.get("triggerPoint").get("polymorphicEvents").spliterator(), false))
|
||||
.map(JsonNode::asText)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
assertThat(polyEvents)
|
||||
@@ -71,12 +73,23 @@ class EnterpriseBooleanEnumPredicateExportTest {
|
||||
"com.example.order.OrderEvent.SHIP");
|
||||
assertThat(polyEvents.size()).isLessThan(4);
|
||||
|
||||
List<JsonNode> matched = StreamSupport.stream(dynamicChain.get("matchedTransitions").spliterator(), false)
|
||||
.toList();
|
||||
assertThat(matched.size())
|
||||
.as("matchedTransitions must not cover every transition × every enum constant")
|
||||
for (JsonNode dispatchChain : dispatchChains) {
|
||||
long chainMatched = StreamSupport.stream(dispatchChain.get("matchedTransitions").spliterator(), false)
|
||||
.count();
|
||||
assertThat(chainMatched)
|
||||
.as("each dispatch chain must not cover all transitions")
|
||||
.isLessThanOrEqualTo(transitionCount);
|
||||
Set<String> matchedEvents = matched.stream()
|
||||
}
|
||||
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()
|
||||
.flatMap(chain -> StreamSupport.stream(chain.get("matchedTransitions").spliterator(), false))
|
||||
.map(node -> node.get("event").asText())
|
||||
.collect(Collectors.toSet());
|
||||
assertThat(matchedEvents)
|
||||
|
||||
@@ -282,7 +282,7 @@ class EnumMemberPredicatePolymorphicInferenceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFallBackToTransitionEventsWhenPredicateEvaluationIsInconclusive(@TempDir Path tempDir) throws Exception {
|
||||
void shouldFailClosedWhenPredicateMethodCannotBeResolved(@TempDir Path tempDir) throws Exception {
|
||||
CodebaseContext context = scanEnumProject(tempDir, """
|
||||
package com.example.order;
|
||||
public enum OrderCommand {
|
||||
@@ -304,10 +304,7 @@ class EnumMemberPredicatePolymorphicInferenceTest {
|
||||
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.getPolymorphicEvents()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MachineEnumCanonicalizerBoundValueOfTest {
|
||||
|
||||
@Test
|
||||
void shouldExpandSingleBoundEventLiteralForValueOfTrigger() {
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.event("OrderEvent.valueOf(eventString.toUpperCase())")
|
||||
.constraint("\"ORDER\".equalsIgnoreCase(machineType) && \"PAY\".equalsIgnoreCase(event)")
|
||||
.external(true)
|
||||
.build();
|
||||
StateMachineTypeResolver.MachineTypes machineTypes =
|
||||
new StateMachineTypeResolver.MachineTypes(
|
||||
"com.example.order.OrderState",
|
||||
"com.example.order.OrderEvent");
|
||||
|
||||
TriggerPoint expanded = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints(
|
||||
trigger, machineTypes, null);
|
||||
|
||||
assertThat(expanded.getPolymorphicEvents())
|
||||
.containsExactly("com.example.order.OrderEvent.PAY");
|
||||
assertThat(expanded.isExternal()).isFalse();
|
||||
assertThat(expanded.isAmbiguous()).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
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;
|
||||
|
||||
@@ -23,4 +30,115 @@ class MachineEnumCanonicalizerCrossPackageTest {
|
||||
"OrderEvents.PAY", "com.bar.OrderEvents"))
|
||||
.isEqualTo("com.bar.OrderEvents.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCanonicalizeImportStyleWhenMachineEnumTypeIsKnown(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { CANCEL }");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(MachineEnumCanonicalizer.canonicalizeLabel("OrderEvent.PAY", "a.OrderEvent", context))
|
||||
.isEqualTo("a.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotCapImportStyleWhenSimpleNameIsAmbiguous(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { CANCEL }");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(MachineEnumCanonicalizer.capToConfiguredTransitionEvents(
|
||||
List.of("OrderEvent.PAY"),
|
||||
List.of("a.OrderEvent.PAY"),
|
||||
"a.OrderEvent",
|
||||
context)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCanonicalizeLabelsForLinkingWithoutOverwritingTypeFqns(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("com/example/order"));
|
||||
Files.writeString(tempDir.resolve("com/example/order/OrderEvent.java"),
|
||||
"package com.example.order; public enum OrderEvent { PAY, SHIP }");
|
||||
Files.writeString(tempDir.resolve("App.java"), "package com.example; class App {}");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.event("OrderEvent.valueOf(action)")
|
||||
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
|
||||
.build();
|
||||
StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes(
|
||||
"com.example.order.OrderState", "com.example.order.OrderEvent");
|
||||
|
||||
TriggerPoint linked = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(
|
||||
trigger, types, context);
|
||||
|
||||
assertThat(linked.getEventTypeFqn()).isNull();
|
||||
assertThat(linked.getStateTypeFqn()).isNull();
|
||||
assertThat(linked.getPolymorphicEvents()).containsExactly(
|
||||
"com.example.order.OrderEvent.PAY",
|
||||
"com.example.order.OrderEvent.SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotQualifyAmbiguousImportStyleEventReference(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { CANCEL }");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(MachineEnumCanonicalizer.qualifyEventIdentifier(
|
||||
"OrderEvent.PAY", "a.OrderEvent", context)).isEqualTo("OrderEvent.PAY");
|
||||
assertThat(MachineEnumCanonicalizer.isMachineEnumReference(
|
||||
"OrderEvent.PAY", "a.OrderEvent", context)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCanonicalizeAmbiguousImportStyleTransitionEventsWhenMachineTypeKnown(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { CANCEL }");
|
||||
Files.writeString(tempDir.resolve("a/OrderState.java"),
|
||||
"package a; public enum OrderState { NEW, PAID }");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
click.kamil.springstatemachineexporter.model.Transition transition =
|
||||
new click.kamil.springstatemachineexporter.model.Transition();
|
||||
transition.setEvent(click.kamil.springstatemachineexporter.model.Event.of(
|
||||
"OrderEvent.PAY", "OrderEvent.PAY"));
|
||||
transition.setSourceStates(List.of(click.kamil.springstatemachineexporter.model.State.of(
|
||||
"OrderState.NEW", "OrderState.NEW")));
|
||||
transition.setTargetStates(List.of(click.kamil.springstatemachineexporter.model.State.of(
|
||||
"OrderState.PAID", "OrderState.PAID")));
|
||||
|
||||
StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes(
|
||||
"a.OrderState", "a.OrderEvent");
|
||||
MachineEnumCanonicalizer.canonicalizeTransitions(List.of(transition), types, context);
|
||||
|
||||
assertThat(transition.getEvent().fullIdentifier()).isEqualTo("a.OrderEvent.PAY");
|
||||
assertThat(transition.getSourceStates().get(0).fullIdentifier()).isEqualTo("a.OrderState.NEW");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,68 @@ class MachineEnumCanonicalizerTest {
|
||||
"com.example.order.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotExpandSymbolicPlaceholderWhenSimpleEnumNameIsAmbiguous(@TempDir Path tempDir) throws IOException {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY, SHIP }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { CANCEL }");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
List<String> expanded = MachineEnumCanonicalizer.expandSymbolicPolymorphicEvents(
|
||||
List.of("<SYMBOLIC: OrderEvent.*>"),
|
||||
"a.OrderEvent",
|
||||
context);
|
||||
|
||||
assertThat(expanded).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotInferMachineTransitionsForAmbiguousTriggerWithoutConstraint(@TempDir Path tempDir)
|
||||
throws IOException {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY, SHIP }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { CANCEL }");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.ambiguous(true)
|
||||
.polymorphicEvents(List.of())
|
||||
.build();
|
||||
StateMachineTypeResolver.MachineTypes types =
|
||||
new StateMachineTypeResolver.MachineTypes(null, "a.OrderEvent");
|
||||
|
||||
Transition pay = new Transition();
|
||||
pay.setEvent(Event.of("PAY", "a.OrderEvent.PAY"));
|
||||
pay.setSourceStates(List.of(State.of("NEW", "NEW")));
|
||||
pay.setTargetStates(List.of(State.of("PAID", "PAID")));
|
||||
|
||||
TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
trigger, types, context, List.of(pay), true);
|
||||
|
||||
assertThat(enriched.getPolymorphicEvents()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotCapForeignPackageEnumConstantToMachineTransition() {
|
||||
List<String> capped = MachineEnumCanonicalizer.capToConfiguredTransitionEvents(
|
||||
List.of("b.OrderEvent.PAY"),
|
||||
List.of("a.OrderEvent.PAY"),
|
||||
"a.OrderEvent");
|
||||
|
||||
assertThat(capped).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldValidateRawNameConsistencyForCanonicalHelpers() {
|
||||
assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier(
|
||||
@@ -248,7 +310,52 @@ class MachineEnumCanonicalizerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldInferPolymorphicEventsFromTransitionsWhenContextIsUnavailable() {
|
||||
void shouldCapFullEnumPolymorphicListToConfiguredTransitionsOnly() {
|
||||
Transition pay = new Transition();
|
||||
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
|
||||
Transition ship = new Transition();
|
||||
ship.setEvent(Event.of("OrderEvent.SHIP", "com.example.order.OrderEvent.SHIP"));
|
||||
|
||||
List<String> fullEnum = List.of(
|
||||
"com.example.order.OrderEvent.PAY",
|
||||
"com.example.order.OrderEvent.SHIP",
|
||||
"com.example.order.OrderEvent.CANCEL",
|
||||
"com.example.order.OrderEvent.LOG");
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.event("eventType")
|
||||
.polymorphicEvents(fullEnum)
|
||||
.external(true)
|
||||
.build();
|
||||
|
||||
StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes(
|
||||
"com.example.order.OrderState", "com.example.order.OrderEvent");
|
||||
|
||||
TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
trigger, types, null, List.of(pay, ship));
|
||||
|
||||
assertThat(linked.getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder(
|
||||
"com.example.order.OrderEvent.PAY",
|
||||
"com.example.order.OrderEvent.SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExpandSymbolicPolymorphicEventsToConfiguredTransitionsWhenAvailable() {
|
||||
Transition pay = new Transition();
|
||||
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
|
||||
|
||||
List<String> expanded = MachineEnumCanonicalizer.expandSymbolicPolymorphicEvents(
|
||||
List.of("<SYMBOLIC: com.example.order.OrderEvent.*>"),
|
||||
"com.example.order.OrderEvent",
|
||||
null,
|
||||
List.of(pay));
|
||||
|
||||
assertThat(expanded).containsExactly("com.example.order.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotInferMultipleTransitionEventsForUnresolvedValueOf() {
|
||||
Transition pay = new Transition();
|
||||
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
|
||||
Transition ship = new Transition();
|
||||
@@ -264,11 +371,44 @@ class MachineEnumCanonicalizerTest {
|
||||
TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
trigger, types, null, List.of(pay, ship));
|
||||
|
||||
assertThat(linked.getPolymorphicEvents()).isNullOrEmpty();
|
||||
assertThat(linked.isAmbiguous()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldInferSingleTransitionEventForValueOfWhenOnlyOneConfigured() {
|
||||
Transition pay = new Transition();
|
||||
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.event("OrderEvent.valueOf(eventString)")
|
||||
.build();
|
||||
|
||||
StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes(
|
||||
"com.example.order.OrderState", "com.example.order.OrderEvent");
|
||||
|
||||
TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
trigger, types, null, List.of(pay));
|
||||
|
||||
assertThat(linked.getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder(
|
||||
"com.example.order.OrderEvent.PAY",
|
||||
"com.example.order.OrderEvent.SHIP");
|
||||
assertThat(linked.isAmbiguous()).isTrue();
|
||||
.containsExactly("com.example.order.OrderEvent.PAY");
|
||||
assertThat(linked.isAmbiguous()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotExpandSymbolicToMultipleConfiguredTransitionEvents() {
|
||||
Transition pay = new Transition();
|
||||
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
|
||||
Transition ship = new Transition();
|
||||
ship.setEvent(Event.of("OrderEvent.SHIP", "com.example.order.OrderEvent.SHIP"));
|
||||
|
||||
List<String> expanded = MachineEnumCanonicalizer.expandSymbolicPolymorphicEvents(
|
||||
List.of("<SYMBOLIC: com.example.order.OrderEvent.*>"),
|
||||
"com.example.order.OrderEvent",
|
||||
null,
|
||||
List.of(pay, ship));
|
||||
|
||||
assertThat(expanded).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -293,6 +433,17 @@ class MachineEnumCanonicalizerTest {
|
||||
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 {
|
||||
Path orderPkg = tempDir.resolve("com/example/order");
|
||||
Path configPkg = tempDir.resolve("com/example/config");
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class RichEventIsCorrectPredicateTest {
|
||||
|
||||
@Test
|
||||
void shouldFilterEnumConstantsByRichEventIsCorrectPredicate(@TempDir Path tempDir) throws Exception {
|
||||
CodebaseContext context = scanProject(tempDir);
|
||||
|
||||
List<String> all = List.of(
|
||||
"com.example.order.OrderCommand.PAY",
|
||||
"com.example.order.OrderCommand.SHIP",
|
||||
"com.example.order.OrderCommand.META");
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.service.AbstractOrderService")
|
||||
.methodName("dispatch")
|
||||
.build();
|
||||
|
||||
List<String> filtered = RichEventPredicateEvaluator.filterEnumConstants(
|
||||
all, "myEvent.isCorrect()", "com.example.order.OrderCommand", trigger, context);
|
||||
|
||||
assertThat(filtered).containsExactly(
|
||||
"com.example.order.OrderCommand.PAY",
|
||||
"com.example.order.OrderCommand.SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldInferPolymorphicEventsForRichEventConstraint(@TempDir Path tempDir) throws Exception {
|
||||
CodebaseContext context = scanProject(tempDir);
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.service.AbstractOrderService")
|
||||
.methodName("dispatch")
|
||||
.event("myEvent.getType()")
|
||||
.constraint("myEvent.isCorrect()")
|
||||
.external(true)
|
||||
.ambiguous(true)
|
||||
.build();
|
||||
MachineTypes types = new MachineTypes(null, "com.example.order.OrderCommand");
|
||||
List<Transition> transitions = List.of(
|
||||
transition("OrderCommand.PAY", "NEW", "PAID"),
|
||||
transition("OrderCommand.SHIP", "PAID", "SHIPPED"));
|
||||
|
||||
TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
trigger, types, context, transitions, true);
|
||||
|
||||
assertThat(enriched.getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder(
|
||||
"com.example.order.OrderCommand.PAY",
|
||||
"com.example.order.OrderCommand.SHIP");
|
||||
assertThat(enriched.isAmbiguous()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLinkTransitionsWhenConstraintUsesRichEventPredicate(@TempDir Path tempDir) throws Exception {
|
||||
CodebaseContext context = scanProject(tempDir);
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.className("com.example.service.AbstractOrderService")
|
||||
.methodName("dispatch")
|
||||
.event("myEvent.getType()")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.order.OrderCommand.PAY",
|
||||
"com.example.order.OrderCommand.SHIP",
|
||||
"com.example.order.OrderCommand.META"))
|
||||
.constraint("myEvent.isCorrect()")
|
||||
.ambiguous(true)
|
||||
.external(true)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
Transition pay = transition("OrderCommand.PAY", "NEW", "PAID");
|
||||
Transition ship = transition("OrderCommand.SHIP", "PAID", "SHIPPED");
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfiguration")
|
||||
.eventTypeFqn("com.example.order.OrderCommand")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
new TransitionLinkerEnricher().enrich(result, context, null);
|
||||
|
||||
TriggerPoint tp = result.getMetadata().getCallChains().get(0).getTriggerPoint();
|
||||
assertThat(tp.getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder(
|
||||
"com.example.order.OrderCommand.PAY",
|
||||
"com.example.order.OrderCommand.SHIP");
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2);
|
||||
}
|
||||
|
||||
private static CodebaseContext scanProject(Path tempDir) throws Exception {
|
||||
Path javaRoot = tempDir.resolve("src/main/java/com/example");
|
||||
Files.createDirectories(javaRoot.resolve("order"));
|
||||
Files.createDirectories(javaRoot.resolve("domain"));
|
||||
Files.createDirectories(javaRoot.resolve("service"));
|
||||
Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }");
|
||||
Files.writeString(javaRoot.resolve("order/OrderCommand.java"), """
|
||||
package com.example.order;
|
||||
public enum OrderCommand { PAY, SHIP, META }
|
||||
""");
|
||||
Files.writeString(javaRoot.resolve("domain/DomainEvent.java"), """
|
||||
package com.example.domain;
|
||||
import com.example.order.OrderCommand;
|
||||
public interface DomainEvent {
|
||||
OrderCommand getType();
|
||||
boolean isCorrect();
|
||||
}
|
||||
""");
|
||||
Files.writeString(javaRoot.resolve("domain/PayEvent.java"), """
|
||||
package com.example.domain;
|
||||
import com.example.order.OrderCommand;
|
||||
public class PayEvent implements DomainEvent {
|
||||
@Override
|
||||
public OrderCommand getType() { return OrderCommand.PAY; }
|
||||
@Override
|
||||
public boolean isCorrect() { return true; }
|
||||
}
|
||||
""");
|
||||
Files.writeString(javaRoot.resolve("domain/ShipEvent.java"), """
|
||||
package com.example.domain;
|
||||
import com.example.order.OrderCommand;
|
||||
public class ShipEvent implements DomainEvent {
|
||||
@Override
|
||||
public OrderCommand getType() { return OrderCommand.SHIP; }
|
||||
@Override
|
||||
public boolean isCorrect() { return true; }
|
||||
}
|
||||
""");
|
||||
Files.writeString(javaRoot.resolve("domain/MetaEvent.java"), """
|
||||
package com.example.domain;
|
||||
import com.example.order.OrderCommand;
|
||||
public class MetaEvent implements DomainEvent {
|
||||
@Override
|
||||
public OrderCommand getType() { return OrderCommand.META; }
|
||||
@Override
|
||||
public boolean isCorrect() { return false; }
|
||||
}
|
||||
""");
|
||||
Files.writeString(javaRoot.resolve("service/AbstractOrderService.java"), """
|
||||
package com.example.service;
|
||||
import com.example.domain.DomainEvent;
|
||||
import com.example.order.OrderCommand;
|
||||
public abstract class AbstractOrderService {
|
||||
protected void dispatch(DomainEvent myEvent) {
|
||||
if (myEvent.isCorrect()) {
|
||||
fire(myEvent.getType());
|
||||
}
|
||||
}
|
||||
protected abstract void fire(OrderCommand event);
|
||||
}
|
||||
""");
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString()));
|
||||
context.scan(tempDir);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static Transition transition(String event, String source, String target) {
|
||||
Transition transition = new Transition();
|
||||
transition.setEvent(Event.of(event, event));
|
||||
transition.setSourceStates(List.of(State.of(source, source)));
|
||||
transition.setTargetStates(List.of(State.of(target, target)));
|
||||
return transition;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Regression: when two enums share the same simple name across packages, we must not expand or link
|
||||
* by guessing which enum {@code OrderEvent} refers to when bindings are unavailable.
|
||||
*/
|
||||
class AmbiguousSimpleEnumLinkingTest {
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenEnumSimpleNameIsAmbiguousAcrossPackages(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY, SHIP }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { CANCEL }");
|
||||
Files.writeString(tempDir.resolve("App.java"), """
|
||||
package com.example;
|
||||
import a.OrderEvent;
|
||||
public class OrderController {
|
||||
Dispatcher dispatcher = new Dispatcher();
|
||||
public void transition(String eventStr) {
|
||||
dispatcher.dispatch(eventStr);
|
||||
}
|
||||
}
|
||||
class Dispatcher {
|
||||
void dispatch(String eventStr) {
|
||||
StateMachine sm = new StateMachine();
|
||||
// Unresolved valueOf(arg) must not expand to a concrete enum set via simple-name lookup.
|
||||
sm.sendEvent(OrderEvent.valueOf(eventStr));
|
||||
}
|
||||
}
|
||||
class StateMachine { void sendEvent(OrderEvent e) {} }
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
// Keep bindings off so type printing stays import-style and we rely on scan-time indexes.
|
||||
context.setResolveBindings(false);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entry = EntryPoint.builder()
|
||||
.className("com.example.OrderController")
|
||||
.methodName("transition")
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("e")
|
||||
.build();
|
||||
|
||||
CallChain chain = engine.findChains(List.of(entry), List.of(trigger)).get(0);
|
||||
|
||||
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", "a.OrderEvent.PAY"));
|
||||
|
||||
Transition ship = new Transition();
|
||||
ship.setSourceStates(List.of(State.of("PAID", "PAID")));
|
||||
ship.setTargetStates(List.of(State.of("SHIPPED", "SHIPPED")));
|
||||
ship.setEvent(Event.of("SHIP", "a.OrderEvent.SHIP"));
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.OrderStateMachineConfig")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
new TransitionLinkerEnricher().enrich(result, context, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
|
||||
assertThat(linked.getLinkResolution()).isIn(LinkResolution.NO_MATCH, LinkResolution.AMBIGUOUS_WIDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenEnumExpansionHintUsesAmbiguousSimpleName(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY, SHIP }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { CANCEL }");
|
||||
Files.writeString(tempDir.resolve("App.java"), """
|
||||
package com.example;
|
||||
import a.OrderEvent;
|
||||
public class OrderController {
|
||||
Dispatcher dispatcher = new Dispatcher();
|
||||
public void transition(String eventStr) {
|
||||
dispatcher.dispatch(eventStr);
|
||||
}
|
||||
}
|
||||
class Dispatcher {
|
||||
void dispatch(String eventStr) {
|
||||
StateMachine sm = new StateMachine();
|
||||
sm.sendEvent(OrderEvent.valueOf(normalize(eventStr)));
|
||||
}
|
||||
String normalize(String value) {
|
||||
return value.trim().toUpperCase();
|
||||
}
|
||||
}
|
||||
class StateMachine { void sendEvent(OrderEvent e) {} }
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(false);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entry = EntryPoint.builder()
|
||||
.className("com.example.OrderController")
|
||||
.methodName("transition")
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("e")
|
||||
.build();
|
||||
|
||||
CallChain chain = engine.findChains(List.of(entry), List.of(trigger)).get(0);
|
||||
|
||||
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", "a.OrderEvent.PAY"));
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.OrderStateMachineConfig")
|
||||
.transitions(List.of(pay))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
new TransitionLinkerEnricher().enrich(result, context, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
|
||||
assertThat(linked.getLinkResolution()).isIn(LinkResolution.NO_MATCH, LinkResolution.AMBIGUOUS_WIDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenMachineEventTypeWouldExpandAmbiguousSymbolic(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY, SHIP }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { CANCEL }");
|
||||
Files.writeString(tempDir.resolve("App.java"), """
|
||||
package com.example;
|
||||
import a.OrderEvent;
|
||||
public class OrderController {
|
||||
Dispatcher dispatcher = new Dispatcher();
|
||||
public void transition(String eventStr) {
|
||||
dispatcher.dispatch(eventStr);
|
||||
}
|
||||
}
|
||||
class Dispatcher {
|
||||
void dispatch(String eventStr) {
|
||||
StateMachine sm = new StateMachine();
|
||||
sm.sendEvent(OrderEvent.valueOf(eventStr));
|
||||
}
|
||||
}
|
||||
class StateMachine { void sendEvent(OrderEvent e) {} }
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(false);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entry = EntryPoint.builder()
|
||||
.className("com.example.OrderController")
|
||||
.methodName("transition")
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("e")
|
||||
.build();
|
||||
|
||||
CallChain chain = engine.findChains(List.of(entry), List.of(trigger)).get(0);
|
||||
|
||||
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", "a.OrderEvent.PAY"));
|
||||
|
||||
Transition ship = new Transition();
|
||||
ship.setSourceStates(List.of(State.of("PAID", "PAID")));
|
||||
ship.setTargetStates(List.of(State.of("SHIPPED", "SHIPPED")));
|
||||
ship.setEvent(Event.of("SHIP", "a.OrderEvent.SHIP"));
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.OrderStateMachineConfig")
|
||||
.eventTypeFqn("a.OrderEvent")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
new TransitionLinkerEnricher().enrich(result, context, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
|
||||
assertThat(linked.getLinkResolution()).isIn(LinkResolution.NO_MATCH, LinkResolution.AMBIGUOUS_WIDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCanonicalizeImportStylePolyWhenMachineEventTypeIsKnown(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
|
||||
"package a; public enum OrderEvent { PAY }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
|
||||
"package b; public enum OrderEvent { CANCEL }");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
|
||||
.ambiguous(true)
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.OrderStateMachineConfig")
|
||||
.eventTypeFqn("a.OrderEvent")
|
||||
.transitions(List.of())
|
||||
.metadata(CodebaseMetadata.builder()
|
||||
.callChains(List.of(CallChain.builder().triggerPoint(trigger).build()))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
new TriggerCanonicalizationEnricher().enrich(result, context, null);
|
||||
|
||||
TriggerPoint canonical = result.getMetadata().getCallChains().get(0).getTriggerPoint();
|
||||
assertThat(canonical.getPolymorphicEvents())
|
||||
.containsExactly("a.OrderEvent.PAY", "a.OrderEvent.SHIP");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,9 +110,12 @@ class AnalysisResultFinalizerTest {
|
||||
.metadata(CodebaseMetadata.builder().triggers(List.of(trigger)).build())
|
||||
.build();
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
result.applyResolution(Map.of(
|
||||
"app.event", "OrderEvent.PAY",
|
||||
"app.state", "OrderState.NEW"));
|
||||
"app.state", "OrderState.NEW"), context);
|
||||
|
||||
TriggerPoint resolved = result.getMetadata().getTriggers().get(0);
|
||||
assertThat(resolved.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY");
|
||||
@@ -120,6 +123,51 @@ class AnalysisResultFinalizerTest {
|
||||
assertThat(resolved.getPolymorphicEvents()).containsExactly("com.example.order.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCanonicalizeAmbiguousImportStyleMatchedTransitions(@TempDir Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("a"));
|
||||
Files.createDirectories(tempDir.resolve("b"));
|
||||
Files.createDirectories(tempDir.resolve("com/example/config"));
|
||||
Files.writeString(tempDir.resolve("a/OrderEvent.java"), "package a; public enum OrderEvent { PAY }");
|
||||
Files.writeString(tempDir.resolve("b/OrderEvent.java"), "package b; public enum OrderEvent { CANCEL }");
|
||||
Files.writeString(tempDir.resolve("a/OrderState.java"), "package a; public enum OrderState { NEW, PAID }");
|
||||
Files.writeString(tempDir.resolve("com/example/config/OrderStateMachineConfiguration.java"),
|
||||
"""
|
||||
package com.example.config;
|
||||
import a.OrderEvent;
|
||||
import a.OrderState;
|
||||
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
|
||||
public class OrderStateMachineConfiguration
|
||||
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
|
||||
}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfiguration")
|
||||
.transitions(List.of())
|
||||
.metadata(CodebaseMetadata.builder()
|
||||
.callChains(List.of(click.kamil.springstatemachineexporter.analysis.model.CallChain.builder()
|
||||
.matchedTransitions(List.of(
|
||||
click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder()
|
||||
.event("OrderEvent.PAY")
|
||||
.sourceState("a.OrderState.NEW")
|
||||
.targetState("a.OrderState.PAID")
|
||||
.build()))
|
||||
.build()))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResultFinalizer.applyCanonicalization(result, context,
|
||||
new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes(
|
||||
"a.OrderState", "a.OrderEvent"));
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent())
|
||||
.isEqualTo("a.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
private static void writeSampleConfig(Path tempDir) throws Exception {
|
||||
Path orderPkg = tempDir.resolve("com/example/order");
|
||||
Path configPkg = tempDir.resolve("com/example/config");
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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 java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class BooleanTernaryBranchSplitTest {
|
||||
|
||||
@Test
|
||||
void bindingExpanderShouldProduceBooleanVariantsForTernaryParameter(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class PolymorphicController {
|
||||
OrderService orderService;
|
||||
public void payTernary(boolean isPay) {
|
||||
orderService.processEvent(isPay ? new PayEvent() : new CancelEvent());
|
||||
}
|
||||
}
|
||||
class OrderService {
|
||||
void processEvent(BaseEvent event) {}
|
||||
}
|
||||
class BaseEvent {}
|
||||
class PayEvent extends BaseEvent {}
|
||||
class CancelEvent extends BaseEvent {}
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.PolymorphicController")
|
||||
.methodName("payTernary")
|
||||
.name("POST /pay-ternary")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("isPay")
|
||||
.type("boolean")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants = EntryPointBindingExpander.expandEntryPointBindings(
|
||||
entryPoint, context, engine.buildCallGraph());
|
||||
|
||||
assertThat(variants).extracting(map -> map.get("isPay"))
|
||||
.containsExactlyInAnyOrder("true", "false");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveSingleEventPerBooleanBinding(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class PolymorphicController {
|
||||
OrderService orderService;
|
||||
public void payTernary(boolean isPay) {
|
||||
orderService.processEvent(isPay ? new PayEvent() : new CancelEvent());
|
||||
}
|
||||
}
|
||||
class OrderService {
|
||||
void processEvent(BaseEvent event) {}
|
||||
}
|
||||
class BaseEvent {}
|
||||
class PayEvent extends BaseEvent {}
|
||||
class CancelEvent extends BaseEvent {}
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.PolymorphicController")
|
||||
.methodName("payTernary")
|
||||
.name("POST /pay-ternary")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("isPay")
|
||||
.type("boolean")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.OrderService")
|
||||
.methodName("processEvent")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
|
||||
|
||||
CallChain payChain = chains.stream()
|
||||
.filter(c -> c.getEntryPoint().getName().contains("isPay=true"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
CallChain cancelChain = chains.stream()
|
||||
.filter(c -> c.getEntryPoint().getName().contains("isPay=false"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
assertThat(payChain.getTriggerPoint().isAmbiguous()).isFalse();
|
||||
assertThat(cancelChain.getTriggerPoint().isAmbiguous()).isFalse();
|
||||
assertThat(payChain.getTriggerPoint().getEvent()).contains("PayEvent").doesNotContain("CancelEvent");
|
||||
assertThat(cancelChain.getTriggerPoint().getEvent()).contains("CancelEvent").doesNotContain("PayEvent");
|
||||
if (payChain.getTriggerPoint().getPolymorphicEvents() != null) {
|
||||
assertThat(payChain.getTriggerPoint().getPolymorphicEvents()).hasSizeLessThanOrEqualTo(1);
|
||||
}
|
||||
if (cancelChain.getTriggerPoint().getPolymorphicEvents() != null) {
|
||||
assertThat(cancelChain.getTriggerPoint().getPolymorphicEvents()).hasSizeLessThanOrEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
@@ -43,7 +44,7 @@ final class CentralDispatcherTestSupport {
|
||||
String controllerClass,
|
||||
String controllerMethod,
|
||||
String stateMachineClass) {
|
||||
return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, EngineKind.HEURISTIC);
|
||||
return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, "sendEvent", EngineKind.HEURISTIC);
|
||||
}
|
||||
|
||||
static CallChain resolveChain(
|
||||
@@ -52,26 +53,64 @@ final class CentralDispatcherTestSupport {
|
||||
String controllerMethod,
|
||||
String stateMachineClass,
|
||||
EngineKind engineKind) {
|
||||
return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, "sendEvent", engineKind);
|
||||
}
|
||||
|
||||
static CallChain resolveChain(
|
||||
CodebaseContext context,
|
||||
String entryClass,
|
||||
String entryMethod,
|
||||
String triggerClass,
|
||||
String triggerMethod,
|
||||
EngineKind engineKind) {
|
||||
AbstractCallGraphEngine engine = engineKind == EngineKind.JDT
|
||||
? new JdtCallGraphEngine(context, null)
|
||||
: new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className(controllerClass)
|
||||
.methodName(controllerMethod)
|
||||
.className(entryClass)
|
||||
.methodName(entryMethod)
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className(stateMachineClass)
|
||||
.methodName("sendEvent")
|
||||
.className(triggerClass)
|
||||
.methodName(triggerMethod)
|
||||
.event("e")
|
||||
.build();
|
||||
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
|
||||
assertThat(chains)
|
||||
.as("expected one call chain from %s.%s to %s.sendEvent via %s",
|
||||
controllerClass, controllerMethod, stateMachineClass, engineKind)
|
||||
.as("expected one call chain from %s.%s to %s.%s via %s",
|
||||
entryClass, entryMethod, triggerClass, triggerMethod, engineKind)
|
||||
.hasSize(1);
|
||||
return chains.get(0);
|
||||
}
|
||||
|
||||
static void assertParity(
|
||||
CodebaseContext context,
|
||||
String entryClass,
|
||||
String entryMethod,
|
||||
String triggerClass,
|
||||
String triggerMethod) {
|
||||
CallChain heuristic = resolveChain(
|
||||
context, entryClass, entryMethod, triggerClass, triggerMethod, EngineKind.HEURISTIC);
|
||||
CallChain jdt = resolveChain(
|
||||
context, entryClass, entryMethod, triggerClass, triggerMethod, EngineKind.JDT);
|
||||
assertThat(jdt.getMethodChain())
|
||||
.containsExactlyElementsOf(heuristic.getMethodChain());
|
||||
assertThat(jdt.getTriggerPoint().getEvent()).isEqualTo(heuristic.getTriggerPoint().getEvent());
|
||||
assertThat(jdt.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrderElementsOf(
|
||||
heuristic.getTriggerPoint().getPolymorphicEvents() == null
|
||||
? List.of()
|
||||
: heuristic.getTriggerPoint().getPolymorphicEvents());
|
||||
}
|
||||
|
||||
static void assertMethodChainContains(CallChain chain, String... methodFqns) {
|
||||
assertThat(chain.getMethodChain()).contains(methodFqns);
|
||||
}
|
||||
|
||||
static void assertMethodChainContainsExact(CallChain chain, String... methodFqns) {
|
||||
assertThat(chain.getMethodChain()).containsExactly(methodFqns);
|
||||
}
|
||||
|
||||
static void assertPolyEvents(CallChain chain, String... expectedEvents) {
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder(expectedEvents);
|
||||
@@ -105,4 +144,76 @@ final class CentralDispatcherTestSupport {
|
||||
static void assertLinkedEvent(MatchedTransition matched, String expectedEvent) {
|
||||
assertThat(matched.getEvent()).isEqualTo(expectedEvent);
|
||||
}
|
||||
|
||||
static CallChain linkChain(
|
||||
CodebaseContext context,
|
||||
CallChain rawChain,
|
||||
String machineConfig,
|
||||
String eventTypeFqn,
|
||||
String stateTypeFqn,
|
||||
Transition... machineTransitions) {
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name(machineConfig)
|
||||
.eventTypeFqn(eventTypeFqn)
|
||||
.stateTypeFqn(stateTypeFqn)
|
||||
.transitions(List.of(machineTransitions))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(rawChain)).build())
|
||||
.build();
|
||||
new TransitionLinkerEnricher().enrich(result, context, null);
|
||||
return result.getMetadata().getCallChains().get(0);
|
||||
}
|
||||
|
||||
static CallChain resolveLinkAndAssertSingleMatch(
|
||||
CodebaseContext context,
|
||||
String entryClass,
|
||||
String entryMethod,
|
||||
String triggerClass,
|
||||
String triggerMethod,
|
||||
String machineConfig,
|
||||
String eventTypeFqn,
|
||||
String stateTypeFqn,
|
||||
String expectedLinkedEvent,
|
||||
EngineKind engineKind,
|
||||
Transition... machineTransitions) {
|
||||
CallChain raw = resolveChain(context, entryClass, entryMethod, triggerClass, triggerMethod, engineKind);
|
||||
CallChain linked = linkChain(context, raw, machineConfig, eventTypeFqn, stateTypeFqn, machineTransitions);
|
||||
assertPolyWithinTransitions(linked, machineTransitions.length);
|
||||
assertMatchedWithinTransitions(linked, machineTransitions.length);
|
||||
assertThat(linked.getMatchedTransitions())
|
||||
.as("expected exactly one matched transition")
|
||||
.hasSize(1);
|
||||
assertLinkedEvent(linked.getMatchedTransitions().get(0), expectedLinkedEvent);
|
||||
return linked;
|
||||
}
|
||||
|
||||
static void assertPolyWithinTransitions(CallChain chain, int configuredTransitionCount) {
|
||||
List<String> poly = chain.getTriggerPoint().getPolymorphicEvents();
|
||||
assertThat(poly)
|
||||
.as("polymorphicEvents must be present after linking")
|
||||
.isNotNull();
|
||||
assertThat(poly.size())
|
||||
.as("polymorphicEvents must not exceed configured transition count")
|
||||
.isLessThanOrEqualTo(configuredTransitionCount);
|
||||
assertThat(MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(poly)).isTrue();
|
||||
}
|
||||
|
||||
static void assertMatchedWithinTransitions(CallChain chain, int configuredTransitionCount) {
|
||||
assertThat(chain.getMatchedTransitions())
|
||||
.as("matchedTransitions should be populated after linking")
|
||||
.isNotNull();
|
||||
assertThat(chain.getMatchedTransitions().size())
|
||||
.as("matchedTransitions must not exceed configured transition count")
|
||||
.isLessThanOrEqualTo(configuredTransitionCount);
|
||||
}
|
||||
|
||||
static void assertPolyCappedToTransitionEvents(
|
||||
CallChain chain,
|
||||
String eventTypeFqn,
|
||||
Transition... machineTransitions) {
|
||||
List<String> allowed = MachineEnumCanonicalizer.polymorphicEventsFromTransitions(
|
||||
List.of(machineTransitions), eventTypeFqn);
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.isNotNull()
|
||||
.allSatisfy(pe -> assertThat(allowed).contains(pe));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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.analysis.spring.InjectionPointAnalyzer;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ComplexMultiModuleCancelJmsTest {
|
||||
|
||||
private CodebaseContext context;
|
||||
private JdtCallGraphEngine engine;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
Path projectRoot = Path.of("../state_machines/complex_multi_module_sm").toAbsolutePath().normalize();
|
||||
|
||||
context = new CodebaseContext();
|
||||
context.setProjectRoot(projectRoot);
|
||||
context.setSourcepath(List.of(
|
||||
projectRoot.resolve("domain/src/main/java").toString(),
|
||||
projectRoot.resolve("service/src/main/java").toString(),
|
||||
projectRoot.resolve("web/src/main/java").toString()));
|
||||
context.setClasspath(ToolingClasspath.currentJvmJarEntries());
|
||||
context.setResolveBindings(true);
|
||||
context.scan(Set.of(projectRoot), Collections.emptySet());
|
||||
|
||||
SpringBeanRegistry registry = new SpringBeanRegistry();
|
||||
SpringContextScanner scanner = new SpringContextScanner(registry);
|
||||
for (var cu : context.getCompilationUnits()) {
|
||||
cu.accept(scanner);
|
||||
}
|
||||
InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(new SpringDependencyResolver(registry));
|
||||
engine = new JdtCallGraphEngine(context, injectionAnalyzer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveCancelEventFromJmsSupplierLambda() {
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("click.kamil.web.JmsOrderListener")
|
||||
.methodName("receiveCancelCommand")
|
||||
.build();
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("click.kamil.service.StateMachineServiceImpl")
|
||||
.methodName("sendMessageWithProvider")
|
||||
.event("eventProvider")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
|
||||
assertThat(chains).hasSize(1);
|
||||
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
|
||||
.contains("OrderEvent.CANCEL");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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 CrossClassSupplierLambdaTest {
|
||||
|
||||
@Test
|
||||
void shouldResolveEventThroughCrossClassSupplierLambda() throws IOException {
|
||||
String webSource = """
|
||||
package com.example.web;
|
||||
|
||||
import com.example.service.StateMachineService;
|
||||
import com.example.domain.OrderEvent;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class JmsOrderListener {
|
||||
private final StateMachineService stateMachineService;
|
||||
|
||||
public JmsOrderListener(StateMachineService stateMachineService) {
|
||||
this.stateMachineService = stateMachineService;
|
||||
}
|
||||
|
||||
public void receiveCancelCommand(String message) {
|
||||
stateMachineService.sendMessageWithProvider(() -> OrderEvent.CANCEL);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
String serviceSource = """
|
||||
package com.example.service;
|
||||
|
||||
import com.example.domain.OrderEvent;
|
||||
import org.springframework.statemachine.StateMachine;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
import reactor.core.publisher.Mono;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public class StateMachineServiceImpl implements StateMachineService {
|
||||
private StateMachine<OrderEvent, OrderEvent> stateMachine;
|
||||
|
||||
@Override
|
||||
public <T extends OrderEvent> void sendMessageWithProvider(Supplier<T> eventProvider) {
|
||||
T event = eventProvider.get();
|
||||
if (event != null) {
|
||||
stateMachine.sendEvent(Mono.just(MessageBuilder.withPayload(event).build())).subscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
String ifaceSource = """
|
||||
package com.example.service;
|
||||
|
||||
import com.example.domain.OrderEvent;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public interface StateMachineService {
|
||||
<T extends OrderEvent> void sendMessageWithProvider(Supplier<T> eventProvider);
|
||||
}
|
||||
""";
|
||||
|
||||
String domainSource = """
|
||||
package com.example.domain;
|
||||
|
||||
public enum OrderEvent { CANCEL, PROCESS }
|
||||
""";
|
||||
|
||||
Path tempDir = Files.createTempDirectory("callgraph_cross_class_lambda");
|
||||
Files.writeString(tempDir.resolve("JmsOrderListener.java"), webSource);
|
||||
Files.writeString(tempDir.resolve("StateMachineServiceImpl.java"), serviceSource);
|
||||
Files.writeString(tempDir.resolve("StateMachineService.java"), ifaceSource);
|
||||
Files.writeString(tempDir.resolve("OrderEvent.java"), domainSource);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.web.JmsOrderListener")
|
||||
.methodName("receiveCancelCommand")
|
||||
.build();
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.service.StateMachineServiceImpl")
|
||||
.methodName("sendMessageWithProvider")
|
||||
.event("eventProvider")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
|
||||
assertThat(chains).hasSize(1);
|
||||
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactly("OrderEvent.CANCEL");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Deep inheritance dispatcher stacks: multi-level {@code super} and template-method patterns.
|
||||
*/
|
||||
class DeepDispatcherHierarchyTest {
|
||||
|
||||
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
|
||||
private static final String EVENT_TYPE = "com.example.OrderEvent";
|
||||
private static final String STATE_TYPE = "com.example.OrderState";
|
||||
|
||||
@Test
|
||||
void jdtShouldMatchHeuristicForMultiLevelSuperDelegation(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
GrandChildHandler handler;
|
||||
public void pay() { handler.entry(OrderEvent.PAY); }
|
||||
}
|
||||
class GrandChildHandler extends MiddleHandler {
|
||||
void entry(OrderEvent event) { super.route(event); }
|
||||
}
|
||||
class MiddleHandler extends BaseHandler {
|
||||
protected void route(OrderEvent event) { super.dispatch(event); }
|
||||
}
|
||||
class BaseHandler {
|
||||
StateMachine machine;
|
||||
protected void dispatch(OrderEvent event) { machine.sendEvent(event); }
|
||||
}
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
enum OrderEvent { PAY, SHIP, LOG, META }
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
assertParity(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent");
|
||||
|
||||
CallChain chain = resolveChain(
|
||||
context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent", EngineKind.JDT);
|
||||
assertMethodChainContains(chain,
|
||||
"com.example.GrandChildHandler.entry",
|
||||
"com.example.MiddleHandler.route",
|
||||
"com.example.BaseHandler.dispatch");
|
||||
assertPolyEvents(chain, "OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void jdtShouldMatchHeuristicForTemplateMethodProtectedDispatch(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
OrderHandler handler;
|
||||
public void pay() { handler.pay(); }
|
||||
}
|
||||
abstract class AbstractHandler {
|
||||
StateMachine machine;
|
||||
public void pay() { dispatch(buildPayEvent()); }
|
||||
protected abstract OrderEvent buildPayEvent();
|
||||
protected void dispatch(OrderEvent event) { machine.sendEvent(event); }
|
||||
}
|
||||
class OrderHandler extends AbstractHandler {
|
||||
@Override protected OrderEvent buildPayEvent() { return OrderEvent.PAY; }
|
||||
}
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
enum OrderEvent { PAY, SHIP, LOG, META }
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
assertParity(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent");
|
||||
assertPolyEvents(
|
||||
resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent", EngineKind.HEURISTIC),
|
||||
"OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pipelineShouldLinkMultiLevelSuperToSingleConfiguredTransition(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
GrandChildHandler handler;
|
||||
public void pay() { handler.entry(OrderEvent.PAY); }
|
||||
}
|
||||
class GrandChildHandler extends MiddleHandler {
|
||||
void entry(OrderEvent event) { super.route(event); }
|
||||
}
|
||||
class MiddleHandler extends BaseHandler {
|
||||
protected void route(OrderEvent event) { super.dispatch(event); }
|
||||
}
|
||||
class BaseHandler {
|
||||
StateMachine machine;
|
||||
protected void dispatch(OrderEvent event) { machine.sendEvent(event); }
|
||||
}
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
enum OrderEvent { PAY, SHIP, LOG, META }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||
|
||||
CallChain linked = resolveLinkAndAssertSingleMatch(
|
||||
context,
|
||||
"com.example.ApiController",
|
||||
"pay",
|
||||
"com.example.StateMachine",
|
||||
"sendEvent",
|
||||
MACHINE_CONFIG,
|
||||
EVENT_TYPE,
|
||||
STATE_TYPE,
|
||||
EVENT_TYPE + ".PAY",
|
||||
EngineKind.JDT,
|
||||
pay,
|
||||
ship);
|
||||
|
||||
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
|
||||
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactly(EVENT_TYPE + ".PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pipelineShouldLinkTemplateMethodDispatchToSingleConfiguredTransition(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
OrderHandler handler;
|
||||
public void pay() { handler.pay(); }
|
||||
}
|
||||
abstract class AbstractHandler {
|
||||
StateMachine machine;
|
||||
public void pay() { dispatch(buildPayEvent()); }
|
||||
protected abstract OrderEvent buildPayEvent();
|
||||
protected void dispatch(OrderEvent event) { machine.sendEvent(event); }
|
||||
}
|
||||
class OrderHandler extends AbstractHandler {
|
||||
@Override protected OrderEvent buildPayEvent() { return OrderEvent.PAY; }
|
||||
}
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
enum OrderEvent { PAY, SHIP, LOG, META }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||
|
||||
CallChain linked = resolveLinkAndAssertSingleMatch(
|
||||
context,
|
||||
"com.example.ApiController",
|
||||
"pay",
|
||||
"com.example.StateMachine",
|
||||
"sendEvent",
|
||||
MACHINE_CONFIG,
|
||||
EVENT_TYPE,
|
||||
STATE_TYPE,
|
||||
EVENT_TYPE + ".PAY",
|
||||
EngineKind.HEURISTIC,
|
||||
pay,
|
||||
ship);
|
||||
|
||||
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,8 @@ class DispatcherEndpointTest {
|
||||
TriggerPoint linkedTrigger = result.getMetadata().getCallChains().get(0).getTriggerPoint();
|
||||
assertThat(linkedTrigger.getPolymorphicEvents())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Ensures linker-stage polymorphic narrowing caps over-broad call-graph enum lists to configured transitions.
|
||||
*/
|
||||
class DispatcherPolyCeilingPipelineTest {
|
||||
|
||||
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
|
||||
private static final String EVENT_TYPE = "com.example.OrderEvent";
|
||||
private static final String STATE_TYPE = "com.example.OrderState";
|
||||
|
||||
@Test
|
||||
void linkerShouldCapInjectedFullEnumPolymorphicListToConfiguredTransitions(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
CentralDispatcher dispatcher;
|
||||
public void pay() { dispatcher.route("PAY"); }
|
||||
}
|
||||
class CentralDispatcher {
|
||||
StateMachine machine;
|
||||
public void route(String action) { machine.sendEvent(OrderEvent.valueOf(action)); }
|
||||
}
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
enum OrderEvent { PAY, SHIP, LOG, META }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain raw = resolveChain(
|
||||
context,
|
||||
"com.example.ApiController",
|
||||
"pay",
|
||||
"com.example.StateMachine",
|
||||
"sendEvent",
|
||||
EngineKind.JDT);
|
||||
|
||||
List<String> bloatedEnum = List.of(
|
||||
EVENT_TYPE + ".PAY",
|
||||
EVENT_TYPE + ".SHIP",
|
||||
EVENT_TYPE + ".LOG",
|
||||
EVENT_TYPE + ".META");
|
||||
|
||||
TriggerPoint bloatedTrigger = raw.getTriggerPoint().toBuilder()
|
||||
.polymorphicEvents(bloatedEnum)
|
||||
.external(true)
|
||||
.build();
|
||||
CallChain bloatedChain = raw.toBuilder().triggerPoint(bloatedTrigger).build();
|
||||
|
||||
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||
|
||||
CallChain linked = linkChain(
|
||||
context, bloatedChain, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship);
|
||||
|
||||
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactly(EVENT_TYPE + ".PAY");
|
||||
assertMatchedWithinTransitions(linked, 1);
|
||||
assertThat(linked.getMatchedTransitions())
|
||||
.extracting(MatchedTransition::getEvent)
|
||||
.containsExactly(EVENT_TYPE + ".PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkerShouldNotInferAllTransitionEventsForUnresolvedValueOf(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
CentralDispatcher dispatcher;
|
||||
public void handle(String action) { dispatcher.route(action); }
|
||||
}
|
||||
class CentralDispatcher {
|
||||
StateMachine machine;
|
||||
public void route(String action) { machine.sendEvent(OrderEvent.valueOf(action)); }
|
||||
}
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
enum OrderEvent { PAY, SHIP, LOG, META }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain raw = resolveChain(
|
||||
context,
|
||||
"com.example.ApiController",
|
||||
"handle",
|
||||
"com.example.StateMachine",
|
||||
"sendEvent",
|
||||
EngineKind.JDT);
|
||||
|
||||
TriggerPoint stripped = raw.getTriggerPoint().toBuilder()
|
||||
.polymorphicEvents(null)
|
||||
.build();
|
||||
CallChain chain = raw.toBuilder().triggerPoint(stripped).build();
|
||||
|
||||
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||
|
||||
CallChain linked = linkChain(
|
||||
context, chain, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship);
|
||||
|
||||
assertThat(linked.getTriggerPoint().getPolymorphicEvents()).isNullOrEmpty();
|
||||
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkerShouldCapCallGraphPolyForDeepDispatcherToTransitionCeiling(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
OrderService orderService;
|
||||
public void pay() { orderService.handlePay(); }
|
||||
}
|
||||
class OrderService {
|
||||
CentralDispatcher dispatcher;
|
||||
protected void handlePay() { dispatcher.route("PAY"); }
|
||||
}
|
||||
class CentralDispatcher {
|
||||
StateMachine machine;
|
||||
public void route(String action) { machine.sendEvent(OrderEvent.valueOf(action)); }
|
||||
}
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
enum OrderEvent { PAY, SHIP, LOG, META }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||
|
||||
CallChain linked = resolveLinkAndAssertSingleMatch(
|
||||
context,
|
||||
"com.example.ApiController",
|
||||
"pay",
|
||||
"com.example.StateMachine",
|
||||
"sendEvent",
|
||||
MACHINE_CONFIG,
|
||||
EVENT_TYPE,
|
||||
STATE_TYPE,
|
||||
EVENT_TYPE + ".PAY",
|
||||
EngineKind.JDT,
|
||||
pay,
|
||||
ship);
|
||||
|
||||
assertThat(linked.getTriggerPoint().getPolymorphicEvents().size()).isLessThan(4);
|
||||
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Enterprise dedicated REST endpoints must link to transitions when call-graph resolution
|
||||
* proves a concrete machine enum literal on the dispatcher branch.
|
||||
*/
|
||||
class EnterpriseDedicatedEndpointLinkingTest {
|
||||
|
||||
private static final String PAY_ENDPOINT = "POST /api/machine/order/pay";
|
||||
|
||||
@Test
|
||||
void astExportDedicatedPayEndpointShouldLinkToOrderPayTransition(@TempDir Path tempDir) throws Exception {
|
||||
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
|
||||
ExportService exportService = new ExportService(List.of(new JsonExporter()));
|
||||
exportService.runExporter(enterprise, tempDir, List.of("json"), true, List.of(), null, null,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
|
||||
JsonNode json = readMachineJson(tempDir, "OrderStateMachineConfiguration", new ObjectMapper());
|
||||
JsonNode chain = findPayEndpointChain(json);
|
||||
|
||||
assertThat(chain.path("triggerPoint").path("event").asText())
|
||||
.isEqualTo("click.kamil.enterprise.machines.order.OrderEvent.PAY");
|
||||
assertThat(chain.path("matchedTransitions").isArray()).isTrue();
|
||||
assertThat(chain.path("matchedTransitions")).isNotEmpty();
|
||||
assertThat(chain.path("matchedTransitions").get(0).path("event").asText())
|
||||
.isEqualTo("click.kamil.enterprise.machines.order.OrderEvent.PAY");
|
||||
assertThat(chain.path("linkResolution").asText()).isEqualTo("RESOLVED");
|
||||
}
|
||||
|
||||
private static JsonNode findPayEndpointChain(JsonNode machineJson) {
|
||||
for (JsonNode chain : machineJson.path("metadata").path("callChains")) {
|
||||
if (PAY_ENDPOINT.equals(chain.path("entryPoint").path("name").asText())) {
|
||||
return chain;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("pay endpoint chain not found");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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 java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Per-branch dispatcher splitting: if-else machineType arms must resolve to branch-specific
|
||||
* valueOf triggers and constraints, not a collapsed ternary across machines.
|
||||
*/
|
||||
class EnterpriseDispatcherBranchSplitTest {
|
||||
|
||||
@Test
|
||||
void shouldResolveBranchSpecificValueOfForEachMachineTypeBinding(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class MachineController {
|
||||
StateMachineDispatcher dispatcher;
|
||||
public void transition(String machineType, String event) {
|
||||
dispatcher.dispatch(machineType, event);
|
||||
}
|
||||
}
|
||||
class StateMachineDispatcher {
|
||||
void dispatch(String machineType, String eventString) {
|
||||
if ("ORDER".equalsIgnoreCase(machineType)) {
|
||||
fireOrder(eventString);
|
||||
} else if ("DOCUMENT".equalsIgnoreCase(machineType)) {
|
||||
fireDocument(eventString);
|
||||
} else if ("USER".equalsIgnoreCase(machineType)) {
|
||||
fireUser(eventString);
|
||||
}
|
||||
}
|
||||
void fireOrder(String eventString) {
|
||||
OrderEvent event = OrderEvent.valueOf(eventString.toUpperCase());
|
||||
send(event);
|
||||
}
|
||||
void fireDocument(String eventString) {
|
||||
DocumentEvent event = DocumentEvent.valueOf(eventString.toUpperCase());
|
||||
send(event);
|
||||
}
|
||||
void fireUser(String eventString) {
|
||||
UserEvent event = UserEvent.valueOf(eventString.toUpperCase());
|
||||
send(event);
|
||||
}
|
||||
void send(OrderEvent event) {}
|
||||
void send(DocumentEvent event) {}
|
||||
void send(UserEvent event) {}
|
||||
}
|
||||
enum OrderEvent { PAY }
|
||||
enum DocumentEvent { SUBMIT }
|
||||
enum UserEvent { VERIFY }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.name("POST /api/machine/{machineType}/transition/{event}")
|
||||
.parameters(List.of(
|
||||
EntryPoint.Parameter.builder()
|
||||
.name("machineType")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build(),
|
||||
EntryPoint.Parameter.builder()
|
||||
.name("event")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
TriggerPoint orderTrigger = TriggerPoint.builder()
|
||||
.className("com.example.StateMachineDispatcher")
|
||||
.methodName("send")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(orderTrigger));
|
||||
|
||||
CallChain orderChain = chains.stream()
|
||||
.filter(c -> c.getEntryPoint().getName().contains("/ORDER/"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
List<CallChain> documentChains = chains.stream()
|
||||
.filter(c -> c.getEntryPoint().getName().contains("/DOCUMENT/"))
|
||||
.toList();
|
||||
assertThat(documentChains).isNotEmpty();
|
||||
CallChain documentChain = documentChains.stream()
|
||||
.filter(c -> c.getMethodChain().contains("com.example.StateMachineDispatcher.fireDocument"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
assertThat(orderChain.getTriggerPoint().getEvent())
|
||||
.contains("OrderEvent.valueOf")
|
||||
.doesNotContain("DocumentEvent")
|
||||
.doesNotContain("UserEvent")
|
||||
.doesNotContain("true ?");
|
||||
assertThat(orderChain.getTriggerPoint().getConstraint())
|
||||
.contains("ORDER")
|
||||
.doesNotContain("DOCUMENT");
|
||||
|
||||
assertThat(documentChain.getMethodChain())
|
||||
.contains("com.example.StateMachineDispatcher.fireDocument")
|
||||
.doesNotContain("com.example.StateMachineDispatcher.fireOrder");
|
||||
assertThat(documentChain.getTriggerPoint().getEvent())
|
||||
.satisfiesAnyOf(
|
||||
e -> assertThat(e).contains("DocumentEvent.valueOf"),
|
||||
e -> assertThat(e).isEqualTo("event"));
|
||||
assertThat(documentChain.getTriggerPoint().getConstraint())
|
||||
.contains("DOCUMENT")
|
||||
.doesNotContain("USER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindingExpanderShouldProduceMachineTypeVariants(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class MachineController {
|
||||
StateMachineDispatcher dispatcher;
|
||||
public void transition(String machineType, String event) {
|
||||
dispatcher.dispatch(machineType, event);
|
||||
}
|
||||
}
|
||||
class StateMachineDispatcher {
|
||||
void dispatch(String machineType, String eventString) {
|
||||
if ("ORDER".equalsIgnoreCase(machineType)) {
|
||||
order(eventString);
|
||||
} else if ("DOCUMENT".equalsIgnoreCase(machineType)) {
|
||||
document(eventString);
|
||||
}
|
||||
}
|
||||
void order(String eventString) {}
|
||||
void document(String eventString) {}
|
||||
}
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.parameters(List.of(
|
||||
EntryPoint.Parameter.builder()
|
||||
.name("machineType")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants = EntryPointBindingExpander.expandPathVariableBindings(
|
||||
entryPoint, context, engine.buildCallGraph());
|
||||
|
||||
assertThat(variants).extracting(map -> map.get("machineType"))
|
||||
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
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.exporter.ExportOptions;
|
||||
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
|
||||
import click.kamil.springstatemachineexporter.service.ExportService;
|
||||
import click.kamil.springstatemachineexporter.service.JsonImportService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -15,96 +10,18 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Regression for REST dispatcher chains that lost {@code polymorphicEvents} during JSON round-trip.
|
||||
* Regression for REST dispatcher chains: expanded concrete endpoints must not re-infer all
|
||||
* transition events; unresolved {@code {event}} templates stay external.
|
||||
*/
|
||||
class EnterpriseDispatcherMatchedTransitionsRegressionTest {
|
||||
|
||||
@Test
|
||||
void jsonReExportWithSourceShouldRestoreDispatcherMatchedTransitions(@TempDir Path tempDir) throws Exception {
|
||||
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
|
||||
|
||||
Path astOut = tempDir.resolve("ast");
|
||||
ExportService exportService = new ExportService(List.of(new JsonExporter()));
|
||||
exportService.runExporter(enterprise, astOut, List.of("json"), true, List.of(), null, null,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
|
||||
Path astJson = astOut.resolve(
|
||||
"click.kamil.enterprise.machines.order.OrderStateMachineConfiguration/click.kamil.enterprise.machines.order.OrderStateMachineConfiguration.json");
|
||||
|
||||
AnalysisResult imported = new JsonImportService().importAnalysisResult(astJson);
|
||||
List<CallChain> corruptedChains = imported.getMetadata().getCallChains().stream()
|
||||
.map(chain -> {
|
||||
if (chain.getTriggerPoint() == null
|
||||
|| chain.getMethodChain() == null
|
||||
|| !chain.getMethodChain().stream()
|
||||
.anyMatch(m -> m.contains("StateMachineDispatcher.dispatch"))) {
|
||||
return chain;
|
||||
}
|
||||
return chain.toBuilder()
|
||||
.triggerPoint(chain.getTriggerPoint().toBuilder()
|
||||
.event("event")
|
||||
.external(false)
|
||||
.polymorphicEvents(null)
|
||||
.build())
|
||||
.matchedTransitions(null)
|
||||
.build();
|
||||
})
|
||||
.toList();
|
||||
imported.setMetadata(CodebaseMetadata.builder()
|
||||
.triggers(imported.getMetadata().getTriggers())
|
||||
.entryPoints(imported.getMetadata().getEntryPoints())
|
||||
.callChains(corruptedChains)
|
||||
.properties(imported.getMetadata().getProperties())
|
||||
.build());
|
||||
|
||||
Path brokenJson = tempDir.resolve("broken-order-machine.json");
|
||||
Files.writeString(brokenJson, new JsonExporter().export(imported, ExportOptions.builder().build()));
|
||||
|
||||
Path outputDir = tempDir.resolve("out");
|
||||
exportService.runJsonExporter(
|
||||
brokenJson, outputDir, List.of("json"), List.of(),
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
enterprise);
|
||||
|
||||
Path finalized = outputDir.resolve(
|
||||
"click.kamil.enterprise.machines.order.OrderStateMachineConfiguration/click.kamil.enterprise.machines.order.OrderStateMachineConfiguration.json");
|
||||
AnalysisResult roundTripped = new JsonImportService().importAnalysisResult(finalized);
|
||||
|
||||
assertThat(roundTripped.getMetadata().getCallChains())
|
||||
.as("call chains after re-export")
|
||||
.isNotEmpty();
|
||||
|
||||
CallChain chain = roundTripped.getMetadata().getCallChains().stream()
|
||||
.filter(c -> c.getMethodChain() != null && c.getMethodChain().stream()
|
||||
.anyMatch(m -> m.contains("StateMachineDispatcher.dispatch")))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"no dispatcher chain among: "
|
||||
+ roundTripped.getMetadata().getCallChains().stream()
|
||||
.map(c -> c.getEntryPoint() != null ? c.getEntryPoint().getName() : "null")
|
||||
.toList()));
|
||||
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.as("polymorphicEvents on %s", chain.getEntryPoint().getName())
|
||||
.isNotNull()
|
||||
.isNotEmpty();
|
||||
assertThat(chain.getTriggerPoint().isExternal())
|
||||
.as("external flag on %s", chain.getEntryPoint().getName())
|
||||
.isTrue();
|
||||
assertThat(chain.getMatchedTransitions())
|
||||
.as("matchedTransitions on %s", chain.getEntryPoint().getName())
|
||||
.isNotNull()
|
||||
.isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void astExportTransitionEndpointShouldLinkMatchedTransitions(@TempDir Path tempDir) throws Exception {
|
||||
void astExportExpandedTransitionEndpointsShouldNotOverLink(@TempDir Path tempDir) throws Exception {
|
||||
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
|
||||
ExportService exportService = new ExportService(List.of(new JsonExporter()));
|
||||
exportService.runExporter(enterprise, tempDir, List.of("json"), true, List.of(), null, null,
|
||||
@@ -112,23 +29,31 @@ class EnterpriseDispatcherMatchedTransitionsRegressionTest {
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
|
||||
JsonNode json = readMachineJson(tempDir, "OrderStateMachineConfiguration", new ObjectMapper());
|
||||
JsonNode chain = findTransitionEndpointChain(json);
|
||||
int transitionCount = json.path("transitions").size();
|
||||
|
||||
assertThat(chain.path("triggerPoint").path("external").asBoolean()).isTrue();
|
||||
assertThat(chain.path("triggerPoint").path("polymorphicEvents").isArray()).isTrue();
|
||||
assertThat(chain.path("triggerPoint").path("polymorphicEvents")).isNotEmpty();
|
||||
assertThat(chain.path("matchedTransitions").isArray()).isTrue();
|
||||
assertThat(chain.path("matchedTransitions")).isNotEmpty();
|
||||
StreamSupport.stream(json.path("metadata").path("callChains").spliterator(), false)
|
||||
.filter(chain -> chain.path("entryPoint").path("name").asText().contains("/transition/{event}"))
|
||||
.forEach(chain -> {
|
||||
JsonNode matched = chain.path("matchedTransitions");
|
||||
assertThat(matched.isNull() || (matched.isArray() && matched.isEmpty())).isTrue();
|
||||
assertThat(chain.path("linkResolution").asText()).isEqualTo("UNRESOLVED_EXTERNAL");
|
||||
});
|
||||
|
||||
JsonNode orderPay = findChainByEndpoint(json, "POST /api/machine/ORDER/transition/PAY");
|
||||
assertThat(orderPay.path("linkResolution").asText()).isEqualTo("RESOLVED");
|
||||
JsonNode matched = orderPay.path("matchedTransitions");
|
||||
assertThat(matched.isArray()).isTrue();
|
||||
assertThat(matched.size()).isLessThanOrEqualTo(transitionCount);
|
||||
assertThat(matched.size()).isGreaterThan(0);
|
||||
assertThat(matched.get(0).path("event").asText())
|
||||
.contains("OrderEvent.PAY");
|
||||
}
|
||||
|
||||
private static JsonNode findTransitionEndpointChain(JsonNode machineJson) {
|
||||
for (JsonNode chain : machineJson.path("metadata").path("callChains")) {
|
||||
if ("POST /api/machine/{machineType}/transition/{event}".equals(
|
||||
chain.path("entryPoint").path("name").asText())) {
|
||||
return chain;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("transition endpoint chain not found");
|
||||
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()
|
||||
.orElseThrow(() -> new IllegalStateException("chain not found for " + endpointName));
|
||||
}
|
||||
|
||||
private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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.List;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* When path bindings prove machine type and event, expanded REST endpoints should link;
|
||||
* the unresolved generic template remains external.
|
||||
*/
|
||||
class EnterpriseExpandedGenericEndpointLinkingTest {
|
||||
|
||||
private static final String ORDER_PAY = "POST /api/machine/ORDER/transition/PAY";
|
||||
|
||||
@Test
|
||||
void astExportExpandedOrderPayEndpointShouldLink(@TempDir Path tempDir) throws Exception {
|
||||
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
|
||||
ExportService exportService = new ExportService(List.of(new JsonExporter()));
|
||||
exportService.runExporter(enterprise, tempDir, List.of("json"), true, List.of(), null, null,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
|
||||
JsonNode json = readMachineJson(tempDir, "OrderStateMachineConfiguration", new ObjectMapper());
|
||||
|
||||
JsonNode expandedPayChain = findChainByEndpoint(json, ORDER_PAY);
|
||||
assertThat(expandedPayChain.path("linkResolution").asText()).isEqualTo("RESOLVED");
|
||||
assertThat(expandedPayChain.path("matchedTransitions").isArray()).isTrue();
|
||||
assertThat(expandedPayChain.path("matchedTransitions")).isNotEmpty();
|
||||
}
|
||||
|
||||
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()
|
||||
.orElseThrow(() -> new IllegalStateException("chain not found for " + endpointName));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user