126 Commits

Author SHA1 Message Date
bb97285906 kotlin 2026-07-07 17:39:09 +02:00
9a5f122bd2 ai idea 2026-07-07 04:50:18 +02:00
a7bf07e8b2 another ai fix 2026-07-06 22:09:23 +02:00
4f8c35adef resolve path 2026-07-06 22:01:41 +02:00
31adae0a4b fix! from ai multi project 2026-07-06 21:57:54 +02:00
2caaec5419 from ai two 2026-07-06 21:41:26 +02:00
56588a835d from ai 2026-07-06 21:21:35 +02:00
0a8f9720c9 10 2026-07-06 19:13:12 +02:00
6472f01026 9 2026-07-06 18:54:58 +02:00
e041cb9a80 8 2026-07-06 18:29:09 +02:00
9bea5c4687 7 2026-07-06 18:25:48 +02:00
475084aed4 7 2026-07-06 18:17:37 +02:00
28df9fc99f 6 2026-07-06 18:07:02 +02:00
e3dd26c0d4 5 2026-07-06 18:05:41 +02:00
89736a4aa1 4 2026-07-06 17:59:58 +02:00
092fbe49fa 4 2026-07-06 17:56:19 +02:00
61f5549589 3 2026-07-06 17:53:42 +02:00
f5067490a3 2 updated 2026-07-06 17:50:41 +02:00
3a442c8890 2 tests 2026-07-06 17:49:11 +02:00
7807df57ca 2 2026-07-06 17:48:21 +02:00
82719489aa 1 2026-07-06 17:43:34 +02:00
c390f02389 heuristic code 2026-07-05 22:00:26 +02:00
180a283383 siblings 2026-07-05 21:17:25 +02:00
1c852005c8 remove tons of logs 2026-07-05 21:13:07 +02:00
34c53b1097 update to fix that 2026-07-05 20:37:21 +02:00
23af434504 update 2026-07-05 20:02:02 +02:00
cc352432a4 fix on fix 2026-07-05 19:28:01 +02:00
78c0f3e705 apparently perfomance fix that doesn't bring regreesions, NOT SURE 2026-07-05 16:29:42 +02:00
27005da678 update number 6 2026-07-05 16:15:52 +02:00
0cb78e5dfd update number 5 2026-07-05 15:57:53 +02:00
bcdbbffa13 update number 4 2026-07-05 15:11:09 +02:00
3988864494 update number 3 2026-07-05 14:46:38 +02:00
b07b7855a1 update number 2 2026-07-05 14:34:52 +02:00
baa33a887c up 2026-07-05 09:46:32 +02:00
0165da5f51 up 2026-07-04 07:25:11 +02:00
4d7cda56f5 up 2026-07-04 06:30:30 +02:00
2717a01f13 up 2026-06-28 15:24:44 +02:00
f84043f26e up 2026-06-28 15:23:22 +02:00
4d9fee716b updates 2026-06-28 15:17:16 +02:00
f3d030b19a another test state machine 2026-06-28 11:57:01 +02:00
16dccbf81b another test state machine 2026-06-28 11:42:18 +02:00
20b4ed780b update tranistions 2026-06-28 11:23:24 +02:00
f4188f4384 update tranistions 3 2026-06-28 10:33:08 +02:00
96fc975170 update tranistions 2 2026-06-28 10:13:11 +02:00
8d3fb0d90c update tranistions 2 2026-06-28 09:56:05 +02:00
fc9d6f1f1c update tranistions 2026-06-28 09:45:09 +02:00
858e4524c8 update argument resolution to 2026-06-28 07:05:36 +02:00
93688ef59b update argument resolution to 2026-06-28 07:02:36 +02:00
ff2c8f8cfb update argument resolution to 2026-06-28 03:04:17 +02:00
901b43195a update 2026-06-27 20:25:29 +02:00
ed411f22d8 update 2026-06-27 20:20:49 +02:00
c9d41de13f update 2026-06-27 20:19:48 +02:00
df2a596e26 update 2026-06-27 20:16:20 +02:00
a75b7acfe4 update 2026-06-27 20:12:31 +02:00
74a4e66a33 update 2026-06-27 20:09:14 +02:00
4511e0e02f stage next 2026-06-27 20:07:24 +02:00
a7c3b08164 stage 3 2026-06-27 19:51:26 +02:00
8d66af6d24 stage 2 update loop 2026-06-27 19:33:44 +02:00
642f6f8926 stage 1 update loop 2026-06-27 19:19:59 +02:00
76bb88e065 stage 1 2026-06-27 19:18:32 +02:00
5c7cf3639b more tests fixes 2026-06-27 18:57:30 +02:00
016f0e9b21 more tests 2026-06-27 15:40:22 +02:00
22a0d3f5aa more bugs 2 2026-06-27 15:35:20 +02:00
7008f162b5 more bugs 2026-06-27 15:24:07 +02:00
60c666f7a1 better tests 2026-06-27 15:10:36 +02:00
8a56e92a65 more reorg 2026-06-27 14:50:06 +02:00
e8543ace38 tests reorg 2026-06-27 14:42:33 +02:00
101f2b5dc9 refactor 2026-06-27 14:32:10 +02:00
f525f3d3ce more fixes 2026-06-27 14:17:54 +02:00
5ae233eb75 claude bugs (question mark) 2026-06-27 07:19:18 +02:00
0bda5a0aeb gemini back 4 2026-06-27 05:56:23 +02:00
c35c75efa3 gemini back 3 2026-06-27 05:42:15 +02:00
bd55a66985 gemini back 2 2026-06-27 05:37:10 +02:00
0ac70bd3c6 gemini back 2026-06-27 05:29:12 +02:00
b45513c5fb heuristics updated enum collision 2026-06-25 06:39:31 +02:00
75fd45d983 heuristics updated 2026-06-25 05:45:49 +02:00
b35effd05c heuristics update 2026-06-25 05:45:49 +02:00
f30538e8c9 forward analysis nemotron3 ultra 2026-06-23 22:33:34 +02:00
131ccbeda0 forward analysis 2 2026-06-22 18:51:20 +02:00
14b311be2b forward analysis 2026-06-22 18:38:19 +02:00
1b690582d6 fixes 2 2026-06-22 14:11:35 +02:00
3ca834fa71 fixes 2026-06-22 13:53:27 +02:00
c6b4a4be1e better things extraction of constants 2 2026-06-22 13:12:48 +02:00
24a9be3a4e better things extraction of constants 2026-06-22 07:30:05 +02:00
07d241a060 better things 2026-06-21 22:55:39 +02:00
32de0a51c6 edge case fixes 2026-06-21 18:45:24 +02:00
cbb00e8a0f Fix AST constructor tracking gap for variables and fields 2026-06-21 18:36:37 +02:00
6596303b7d Replace fallback string scraper with precise AST traversal 2026-06-21 18:20:30 +02:00
c903b079d3 refactor 2026-06-21 17:53:26 +02:00
23d79d1930 event maching heuristic update 4 2026-06-21 15:54:36 +02:00
2720296d14 event maching heuristic update 3 2026-06-21 15:24:36 +02:00
b8b180ab3d event maching heuristic update 3 2026-06-21 14:58:28 +02:00
fc267c43c6 event maching heuristic update 2 2026-06-21 14:06:08 +02:00
968601eefc event maching heuristic update 2026-06-21 12:44:00 +02:00
e00f4dca81 beans fix more 2026-06-21 11:36:32 +02:00
bf82cc3562 beans fix 2026-06-21 09:00:36 +02:00
24b67be64b beans 2026-06-21 08:51:42 +02:00
9f651e7dc9 heuristics classpath 2026-06-21 07:51:25 +02:00
e06429cb02 heuristic call graph 2026-06-21 07:33:01 +02:00
9cf2d063b3 matching egnine extraction 2026-06-21 07:20:38 +02:00
073df21395 heauristic bean resolution 2026-06-21 06:55:55 +02:00
f9e6247eb3 fix 6 2026-06-20 13:20:05 +02:00
76ac143370 more fixes 5 2026-06-20 13:11:54 +02:00
9ca8b2fe37 more fixes 4 2026-06-20 12:31:11 +02:00
56cdf96f2d more fixes 3 2026-06-20 12:21:11 +02:00
c37aa92c78 more fixes 2 2026-06-20 07:57:03 +02:00
3ac057618f more fixes 2026-06-20 07:21:19 +02:00
d59f4ac166 fix attempt 1 2026-06-20 07:04:44 +02:00
19547d3c5f better html tooltip styling 2026-06-20 06:16:12 +02:00
ab37eb5d40 call graph fix + tests 2026-06-20 05:51:42 +02:00
bf9208d529 better enricher attempt 2026-06-19 21:21:44 +02:00
7077214c81 better enricher 2026-06-19 20:56:14 +02:00
344e295106 enricher 2026-06-19 19:18:23 +02:00
d6b1571f18 test1 2026-06-19 17:45:57 +02:00
e617fb1754 update 1 test 2026-06-19 17:37:08 +02:00
06dc0ba641 update 1 2026-06-19 17:33:32 +02:00
ba412b905e moar 2026-06-19 17:25:01 +02:00
198c5d830e test 2026-06-19 17:23:27 +02:00
a383de5a5f fix html attempt 2 2026-06-19 17:19:26 +02:00
ef9ebe3512 fix html 2026-06-19 17:11:34 +02:00
b480dc83ef transition enricher rabbit 2026-06-19 09:22:27 +02:00
0a23daae05 transition enricher 2026-06-19 09:06:37 +02:00
e894566112 transition enricher 2026-06-19 07:44:54 +02:00
8d4ba0697e transition enricher 2026-06-18 23:02:39 +02:00
d93d36e8ad html exporter fixed 2026-06-18 22:06:57 +02:00
5894303510 fqn names of events and states 2026-06-18 22:02:42 +02:00
326 changed files with 34382 additions and 3028 deletions

22
InspectGolden.java Normal file
View File

@@ -0,0 +1,22 @@
import java.nio.file.*;
import java.util.*;
import click.kamil.springstatemachineexporter.service.ExportService;
import click.kamil.springstatemachineexporter.exporter.*;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
public class InspectGolden {
public static void main(String[] args) throws Exception {
Path inputDir = Path.of("state_machines/complex_state_machine");
Path tempDir = Files.createTempDirectory("golden-update-");
List<StateMachineExporter> exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList());
ExportService exportService = new ExportService(exporters, enrichmentService);
exportService.runExporter(inputDir, tempDir, null, true);
try (var stream = Files.list(tempDir)) {
stream.forEach(System.out::println);
}
}
}

View File

@@ -1,10 +1,9 @@
1.extract from all possible formats (ask chatgpt) 1.extract from all possible formats (ask chatgpt)
[DONE] json
[TODO] xml, maybe yaml [TODO] xml, maybe yaml
2. [PLAN] Support external dependencies (JARs/compiled classes): 2. [PLAN] Support external dependencies (JARs/compiled classes):
- Enable binding resolution in ASTParser (`setResolveBindings(true)`). - Enable binding resolution in ASTParser (`setResolveBindings(true)`).
- Configure ASTParser with project classpath (including JARs). - Configure ASTParser with project classpath (including JARs).
- Refactor `CodebaseContext` to handle resolved bindings, not just local AST nodes. - Refactor `CodebaseContext` to handle resolved bindings, not just local AST nodes.
- Implement tests using external libraries as base classes. - Implement tests using external libraries as base classes.

BIN
TestHeuristic.class Normal file

Binary file not shown.

17
TestHeuristic.java Normal file
View File

@@ -0,0 +1,17 @@
import java.util.*;
public class TestHeuristic {
public static void main(String[] args) {
System.out.println(isFullyQualifiedMethod("TransformerImpl.transform"));
System.out.println(isFullyQualifiedMethod("Transformer.transform"));
}
private static boolean isFullyQualifiedMethod(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) return false;
int lastDot = methodFqn.lastIndexOf('.');
String classPart = methodFqn.substring(0, lastDot);
if (classPart.contains(".")) return true;
if (!classPart.isEmpty() && Character.isUpperCase(classPart.charAt(0))) return true;
return false;
}
}

View File

@@ -1,71 +0,0 @@
# Execution Plan for Extended Analysis (Revised for Robustness)
## Phase 0: Surgical Refactoring (The Enrichment Hook)
- [x] Create `AnalysisResult` and `CodebaseMetadata` models.
- [x] Update `ExportService.generateOutputs` to accept `AnalysisResult`.
- [x] Implement `EnrichmentService` and `AnalysisEnricher` interface.
- [x] Wrap current parser outputs in `AnalysisResult` within `ExportService`.
## Phase 1: Foundation & Semantic Core
- [ ] **JDT Binding Resolution**: Configure `ASTParser` with project classpath and `setResolveBindings(true)`.
- [ ] **Workspace Scoping**: Implement scanner with configurable exclude patterns (`test/`, `build/`, `node_modules/`).
- [ ] **ConstantResolver**: Implement lookup for `public static final` constants across class boundaries using JDT bindings.
- [ ] **Basic PropertyResolver**: Scan `application.properties/yml` for simple key-value pairs.
- [ ] Implement `JdtIntelligenceProvider` as the primary semantic analyzer.
## Phase 2: Trigger Discovery & Instance Identity
- [ ] **GenericEventDetector**: Find `sendEvent` calls using `ASTVisitor`.
- [ ] **InstanceIdentifier**: Detect which State Machine is being targeted (via `@Qualifier`, bean names, or generic types `StateMachine<S, E>`).
- [ ] **Trigger Filter**: Link `sendEvent` calls to specific SM instances to avoid noise in multi-SM projects.
- [ ] Enhance `CodebaseContext` to provide mapping from `MethodDeclaration` to its callers using bindings.
## Phase 3: Entry Point Mapping
- [ ] **SpringMvcDetector**: Detect REST endpoints (using `ConstantResolver` for paths).
- [ ] **MessageListenerDetector**: Detect Kafka/JMS listeners (using `ConstantResolver` for topics/queues).
- [ ] **ConfigurableTriggerDetector**: Support user-defined patterns for custom frameworks.
## Phase 4: Call Graph Integration
- [x] Develop a call graph builder to link `Entry Points` to `Trigger Points` via intermediate service calls.
- [ ] Support inheritance in call graph resolution (using JDT bindings to resolve method overrides).
## Phase 4.5: Hierarchical Robustness (Inheritance Support)
- [ ] **Hierarchical Annotation Lookup**: Update `SpringMvcDetector` to scan interfaces and superclasses for inherited mapping annotations.
- [ ] **Interface Implementation Mapping**: Enhance `CodebaseContext` to index "Class -> Interfaces" and "Interface -> Classes" relationships.
- [ ] **Polymorphic Call Trace**: Update `CallGraphBuilder` to follow calls from an interface method to all known implementations in the project.
- [ ] **Base Class Trigger Discovery**: Ensure triggers in abstract base classes are correctly attributed to their concrete subclasses.
## Phase 5: Visualization & Diagnostics
- [ ] Update `ExportService` to include trigger information.
- [ ] Update DOT/SCXML exporters to show entry points.
- [ ] **Diagnostic Nodes**: Show "Potential/Unresolved Trigger" nodes when a `sendEvent` is found but the event name or SM instance cannot be resolved.
## Phase 6: Validation (Milestone 1)
- [ ] Create a "Complex Sample Project" with REST controllers, services, and multiple state machines.
- [ ] Add unit tests for each detector and the aggregator.
- [ ] Verify that inheritance in both state machine config and controllers is correctly handled.
## Phase 7: Advanced Resolution & Data Flow
- [ ] **Variable Propagation**: Track values from fields/constructors to their usage in the State Machine configuration (e.g., resolving `initial(initialState)` where `initialState` is a field).
- [ ] **@Value Resolution**: Link `@Value` annotations to the `PropertyResolver` to substitute placeholders with literal values in the diagram.
- [ ] **Spring Profile Support**: Update `PropertyResolver` to handle `application-{profile}.properties` and prioritize them based on an "active profiles" setting.
- [ ] **Complex ValueResolver**: Handle string concatenations (e.g., `"PREFIX_" + MyConstants.SUFFIX`) and variable references.
- [ ] **Payload Analysis**: Extract payload types from entry points and track field usage in `sendEvent` calls.
## Phase 8: Persistence & Lifecycle Mapping
- [ ] Detect persistence restoration logic (`persister.restore`).
- [ ] Map "Resume" points where the machine state is loaded from a DB based on external identifiers.
- [ ] Update metadata to distinguish between "New Instance" and "Restored Instance" triggers.
## Phase 9: Advanced Ecosystem Support
- [ ] **Reactive Chains**: Analyze WebFlux/Project Reactor lambdas (`doOnNext`, `flatMap`) for hidden triggers.
- [ ] **Interceptors & Filters**: Detect `HandlerInterceptor` and `Filter` implementations and map them to endpoints.
## Phase 10: External Dependency Interop
- [x] **Library Hints**: Implementation complete. Supports `hints.json` in project root or `src/main/resources/`.
- **Format**: `[{"methodFqn": "class.method", "event": "EVENT"}]`
- **Purpose**: Maps external/compiled library calls to State Machine events when source code is unavailable.
- [ ] **External JAR Analysis**: (Optional) Configure JDT to scan source-attachments of dependencies if available.
**Note on Library Hints**: This feature is an optional "Power User" escape hatch. The analyzer works automatically for all local source code; `hints.json` is only required to bridge the gap for third-party libraries.

View File

@@ -1,42 +0,0 @@
# Advanced Context Extraction: Payloads and Interceptors
## 1. Incoming Payload Analysis
To understand *what* data drives the state machine, we need to capture metadata about the incoming parameters at entry points.
### REST Endpoints
For methods annotated with `@PostMapping`, `@PutMapping`, etc.:
1. **Extract Parameters**: Scan method parameters for `@RequestBody`, `@PathVariable`, or `@RequestParam`.
2. **Type Extraction**: Capture the FQN of the parameter type (e.g., `com.example.OrderRequest`).
3. **Data Linkage**: If a field from this payload is used in a `sendEvent(payload.getEvent())` call, mark the transition as "Dynamic based on Payload".
### Message Listeners
For `@RabbitListener` or `@JmsListener`:
1. Identify the parameter receiving the message body.
2. Extract its type and any `@Header` or `@Payload` annotations.
## 2. Interceptor and Filter Analysis
Interceptors often perform cross-cutting logic (authentication, context injection) that affects how state machines are driven.
### Discovery Strategy
1. **Find Interceptors**: Scan for classes implementing `HandlerInterceptor` or `WebRequestInterceptor`.
2. **Registration Mapping**: Look for `WebMvcConfigurer.addInterceptors()` calls to identify which URL patterns each interceptor applies to.
3. **Context Injection**: Analyze if the interceptor modifies the request (e.g., `request.setAttribute("userContext", ...)`) or populates a `ThreadLocal` (like `SecurityContextHolder`).
### Linking to State Machines
If a controller uses a value from a known Interceptor-injected context to decide which event to send or which state machine to restore, we can link the Interceptor's logic to the State Machine flow.
## 3. Implementation in `EnrichmentService`
We will introduce a `ContextEnricher` that:
1. Builds a map of **Active Interceptors** per path.
2. Augments `EntryPoint` metadata with **Payload Type** information.
## 4. Modeling Updates
Update `EntryPoint` and `TriggerPoint` to include `payloadType` and `interceptors`.
```java
public record EntryPoint(
// ... existing fields ...
String payloadType,
List<String> activeInterceptors
) {}
```

View File

@@ -1,63 +0,0 @@
# Decoupled Analysis Architecture: The "Modular Provider" Approach
## Core Philosophy
We decouple the **State Machine Definition** (what the machine IS) from the **Codebase Intelligence** (how the machine IS USED). This allows for swappable analysis tools and prevents the state machine parser from becoming a monolithic "all-knowing" beast.
## 1. The Intelligence Interface (The Bridge)
We introduce an abstraction layer that provides information about the code without tying us to a specific implementation (like JDT).
```java
public interface CodebaseIntelligenceProvider {
// Event Triggers
List<TriggerPoint> findTriggerPoints();
// Entry Points
List<EntryPoint> findEntryPoints();
// Call Graphs
List<CallChain> traceCallChain(MethodReference target);
// Resolution
String resolveValue(ExpressionReference ref);
}
```
## 2. Swappable Implementations
This allows us to offer different "tiers" of analysis or even different tools:
- **JdtProvider (Current)**: High-precision, understands hierarchy, but heavy.
- **RegexProvider**: Lightweight, fast, good for simple projects or quick scans.
- **ExternalLspProvider (Future)**: Could query a running Language Server (like Eclipse JDT.LS) for ultra-accurate cross-project resolution.
## 3. The "State Machine Model" is the Sink
The State Machine Exporter now becomes a "Consumer" of intelligence.
1. **SM Parser**: Extracts the core states and transitions.
2. **Intelligence Service**: Discovers where events are triggered.
3. **Correlation Engine**: Merges the two models based on Event IDs.
## 4. Benefits
- **Testability**: We can mock the `CodebaseIntelligenceProvider` to test state machine rendering without needing a full Java project on disk.
- **Maintainability**: Adding support for a new framework (e.g., Micronaut) only requires updating the `IntelligenceProvider`, not touching the core state machine logic.
- **Performance**: We can run the SM Parser and the Intelligence Scanner in parallel since they are now independent.
## 5. Monorepo and Multi-Module Support
Applications are often split into multiple modules (e.g., `core`, `api`, `workers`).
- **Workspace Scanning**: The analyzer should treat the entire monorepo as a single codebase context.
- **Source Tracking**: Each metadata item (`TriggerPoint`, `EntryPoint`) includes a `sourceModule` identifier to show exactly where it was found.
- **Cross-Module Resolution**: Properties defined in one module's `application.yml` and used in another should be resolved globally.
- **Internal Dependency Following**: If the analyzer finds a call to a method in another module, it should continue the call-chain analysis into that module's source.
## Challenges
- **Multiple State Machines**: How to know which `StateMachine` instance is being used?
- Initial heuristic: If there's only one, assume it's that one.
- If multiple, check generic types `StateMachine<S, E>` or variable names.
- **Indirect Calls**: Method `A` (Controller) calls `B` (Service), and `B` calls `sendEvent`.
- Static analysis might need to follow call graphs.
- Use JDT's cross-reference capabilities if possible, or build a simple call graph.
- **Inheritance**:
- Controllers might extend base classes with common mappings.
- State machine configurations already handle inheritance; Trigger detection should too.
## Next Steps
1. Create a PoC `TriggerDetector` for Spring MVC.
2. Integrate `TriggerAggregator` into the main analysis pipeline.
3. Update the `Exporter` to visualize these links.

View File

@@ -1,40 +0,0 @@
# The Analysis Contract: Decoupling Metadata from Implementation
## 1. Unified Model
Instead of each parser having its own internal model, we move to a "Context-Rich Model".
```java
public record CodebaseMetadata(
List<TriggerPoint> triggers,
List<EntryPoint> entryPoints,
Map<String, String> properties,
CallGraph callGraph
) {}
```
## 2. Intelligence Provider Lifecycle
1. **Initialize**: Provide root paths and configuration.
2. **Scan**: The provider builds its internal index (e.g., JDT AST, Regex indices).
3. **Extract**: The provider returns a `CodebaseMetadata` object.
4. **Dispose**: Cleanup resources.
## 3. Tool-Specific Strengths
### JDT Provider (Advanced)
- **Strengths**: True semantic understanding, inheritance resolution, precise constant lookups.
- **Usage**: Deep analysis where we need to know that `BaseService.notify()` is actually what is being called in `ChildService`.
### Regex / Tree-Sitter Provider (Light)
- **Strengths**: Extremely fast, works even on broken code (doesn't need full classpath), very low memory overhead.
- **Usage**: Large monorepos where full JDT analysis would take minutes.
## 4. Aggregation Logic
The `IntelligenceService` can actually combine results from multiple providers!
- Use JDT for the State Machine Config (where we need high precision).
- Use Regex for a quick scan of 1000+ controllers to find mapping patterns.
## 5. Refactoring Strategy
1. **Extract**: Move current JDT-based transition parsing into a `JdtStateMachineProvider`.
2. **Define**: Create the `CodebaseMetadata` model.
3. **Bridge**: Update the `ExportService` to accept both a `StateMachineModel` and a `CodebaseMetadata` object.
4. **Implement**: Create the `CodebaseIntelligenceProvider` interface and its first implementation (wrapping the existing JDT logic).

View File

@@ -1,67 +0,0 @@
# Low-Impact Abstraction: The Enrichment Pipeline
## 1. Goal
Support enhanced codebase analysis (Endpoints, Listeners, etc.) without a massive refactor of the existing JDT state machine parsers.
## 2. The Core Model: `AnalysisResult`
We wrap the existing output into a container that can hold optional metadata.
```java
public class AnalysisResult {
// Current Core Data
private final String name;
private final List<Transition> transitions;
private final Set<String> startStates;
private final Set<String> endStates;
// Optional Enhanced Data
private CodebaseMetadata metadata = CodebaseMetadata.empty();
public AnalysisResult(String name, List<Transition> transitions, Set<String> startStates, Set<String> endStates) {
this.name = name;
this.transitions = transitions;
this.startStates = startStates;
this.endStates = endStates;
}
public void setMetadata(CodebaseMetadata metadata) {
this.metadata = metadata;
}
}
```
## 3. The "Enricher" Abstraction
Instead of modifying the parser, we "enrich" the result after it's produced.
```java
public interface AnalysisEnricher {
/**
* Examines the core analysis result and the codebase context
* to add extra metadata (triggers, endpoints, etc.).
*/
void enrich(AnalysisResult result, CodebaseContext context);
}
```
## 4. Implementation Tiers
### Tier 1: Current State (No-Op)
The `ExportService` calls `enrich()`, but since no enrichers are registered, it just returns the core data. This is zero-impact refactoring.
### Tier 2: Enhanced Analysis
Once we implement `SpringMvcEnricher` or `RabbitMqEnricher`, we register them. They will:
1. Scan the `CodebaseContext`.
2. Find `TriggerPoints`.
3. Link them to the `transitions` already present in the `AnalysisResult`.
4. Update the `metadata` field.
## 5. Why this is "Good":
- **Non-Breaking**: `StateMachineAggregator` remains untouched.
- **Pluggable**: You can add/remove "Intelligence" without changing the core parser.
- **Future-Proof**: If you switch from JDT to another tool for code analysis, you only change the `Enricher` implementation.
## 6. Refactoring Steps
1. **Create `AnalysisResult`** and **`CodebaseMetadata`** classes.
2. **Update `ExportService.generateOutputs`** to take an `AnalysisResult` object instead of 4 separate parameters.
3. **Introduce `EnrichmentService`** that holds a list of `AnalysisEnricher`s.
4. **Modify `ExportService`** to call `enrichmentService.enrich(result, context)` before passing the result to exporters.

View File

@@ -1,92 +0,0 @@
# Generic and Extensible Endpoint Finder
## The Need for Genericity
Applications use various frameworks and patterns to trigger state machine events. While Spring MVC and Kafka are common, others like Micronuat, Jakarta EE, or custom internal event buses might be used.
## Configuration-Driven Detection
We can allow users to define "Entry Point" patterns via a configuration file (e.g., `analysis-config.json`).
```json
{
"entryPoints": [
{
"name": "SpringMVC",
"classAnnotations": ["RestController", "Controller"],
"methodAnnotations": ["PostMapping", "GetMapping", "PutMapping", "DeleteMapping", "RequestMapping"],
"metadataExtractors": {
"path": ["value", "path"]
}
},
{
"name": "Kafka",
"methodAnnotations": ["KafkaListener"],
"metadataExtractors": {
"topic": ["topics", "topicPartitions"]
}
}
]
}
```
## Abstract EntryPoint Detector
A generic detector that uses the above configuration to identify entry points.
```java
public class ConfigurableTriggerDetector implements TriggerDetector {
private final AnalysisConfig config;
@Override
public List<TriggerPoint> detect(CompilationUnit cu, CodebaseContext context) {
// 1. Find all classes
// 2. If class has any of config.classAnnotations
// 3. Scan methods for any of config.methodAnnotations
// 4. If found, look for sendEvent calls inside the method or its call tree
}
}
```
## Indirect Flow Detection (Call Tree Analysis)
Oftentimes, the `sendEvent` call is not directly in the controller method but in a service called by the controller.
### Simple Call Tree Analysis
1. Find all methods that call `sendEvent(E)`. Let's call them "Direct Triggers".
2. Find all methods that call "Direct Triggers".
3. Recursively build this "Trigger Path".
4. If a "Trigger Path" starts at an "Entry Point" (Controller/Listener), we have a complete flow.
Example:
`OrderController.submitOrder()` -> `OrderService.processOrder()` -> `StateMachine.sendEvent(SUBMIT)`
Analysis:
1. `OrderService.processOrder()` is a Direct Trigger for `SUBMIT`.
2. `OrderController.submitOrder()` calls `OrderService.processOrder()`.
3. `OrderController.submitOrder()` is an Entry Point (annotated with `@PostMapping`).
4. Result: `POST /order` -> `SUBMIT` transition.
## Handling Reactive Chains Statically
In WebFlux/Project Reactor, the logic is decoupled into a pipeline. Static analysis must "connect the dots" across operators.
**Strategy**:
1. When analyzing a method, identify if it returns a reactive type (`Mono`, `Flux`).
2. If so, inspect all lambdas within that method.
3. If a lambda calls `sendEvent`, treat the method as a "Trigger Point".
4. If the method is also an "Entry Point" (via annotations or `RouterFunction`), we have a direct reactive flow.
## Supporting Inheritance in Listeners
Often, base classes or interfaces define generic listeners:
```java
public interface BaseConsumer<T> {
@RabbitListener(queues = "${app.queue}")
void handle(T payload);
}
```
The `CodebaseContext` must resolve the `${app.queue}` if possible (via `application.properties` scanning) or at least note that it's a parameterized listener.
## Advanced Call Graph: The "Data Flow"
For a truly detailed analysis, we should track NOT just method calls, but also how the "Event" object is passed around.
- Is the event hardcoded? `sm.sendEvent(Events.CREATE)`
- Is it derived from a payload field? `sm.sendEvent(payload.getEvent())`
- Is it mapped via a `switch` or `Map`?
**Static Mapping Inference**:
If we see a `switch` statement on a payload field that leads to different `sendEvent` calls, we can report multiple possible flows from the same endpoint.

View File

@@ -1,102 +0,0 @@
# Implementation Details: Trigger Detection with JDT
## 1. Finding `sendEvent` Calls
We can use an `ASTVisitor` to find all `MethodInvocation` nodes.
```java
public class SendEventVisitor extends ASTVisitor {
@Override
public boolean visit(MethodInvocation node) {
if ("sendEvent".equals(node.getName().getIdentifier())) {
// Found a call!
// 1. Extract event (first argument)
// 2. Identify the enclosing method and class
}
return super.visit(node);
}
}
```
### Challenges in Event Extraction
Events can be:
- Enum constants: `Events.SUBMIT`
- Strings: `"SUBMIT"`
- Variables: `sm.sendEvent(eventFromPayload)` (Hard to resolve statically)
We should reuse `CodebaseContext.resolveState` logic (which is basically resolving an expression to a value/fqn).
## 2. Identifying Enclosing Context
Once a `sendEvent` is found, we can traverse up the AST to find the `MethodDeclaration` and `TypeDeclaration`.
```java
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
// parent is now the MethodDeclaration
```
### 2. Extracting Annotations
From `MethodDeclaration`, we can check for mappings:
- `@PostMapping`, `@GetMapping`, etc.
- `@KafkaListener`: Extract `topics`, `groupId`.
- `@RabbitListener`: Extract `queues`, `bindings` (Exchange/RoutingKey).
From `TypeDeclaration`, we can check for:
- `@RestController`, `@Controller`
- `@RequestMapping` at class level (to get base path)
## 3. Specialized WebFlux Analysis
### Annotation-based WebFlux
This is largely identical to Spring MVC. The challenge is if the `sendEvent` is wrapped in a reactive operator.
```java
public Mono<Void> submit(Order order) {
return service.save(order)
.doOnNext(o -> stateMachine.sendEvent(Events.SUBMIT))
.then();
}
```
**Static Strategy**: We need to look inside `LambdaExpression` nodes passed to reactive operators (`doOnNext`, `flatMap`, `map`, `subscribe`). The `ASTVisitor` should traverse into these lambdas.
### Functional WebFlux (RouterFunctions)
Functional endpoints are often defined as Beans returning `RouterFunction`.
```java
@Bean
public RouterFunction<ServerResponse> route(OrderHandler handler) {
return RouterFunctions.route(POST("/orders"), handler::submitOrder);
}
```
**Static Strategy**:
1. Find methods returning `RouterFunction`.
2. Analyze the `MethodInvocation` chain (`route`, `andRoute`, `nest`).
3. Extract the URI pattern and the `HandlerFunction` reference.
4. If the handler is a method reference (`handler::submitOrder`), link it to the corresponding `MethodDeclaration`.
## 4. Specialized RabbitMQ Analysis
`@RabbitListener` can be complex:
```java
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = "orderQueue", durable = "true"),
exchange = @Exchange(value = "orderExchange"),
key = "order.created"
))
public void onOrder(Order order) { ... }
```
**Static Strategy**:
1. Find `@RabbitListener`.
2. If it has `bindings`, drill down into `@QueueBinding`, `@Queue`, `@Exchange` to extract the topology.
3. If it only has `queues`, resolve the queue name (might be a SpEL expression or property placeholder, which we can try to resolve or just keep as-is).
## 5. Indirect Flow Detection (The "Service Link")
If the project has multiple state machines, we need to know which one is being targeted.
Usually, this is done via:
- Autowiring by type: `StateMachine<States, Events> sm;`
- Autowiring by name: `@Qualifier("mySm") StateMachine sm;`
We can look at the fields of the class where `sendEvent` is called.
## 6. Output Enhancement
The `Exporter` should be modified to:
- In DOT: Add nodes for Endpoints/Listeners and link them to the Events/Transitions.
- In SCXML: Add metadata to transitions.

View File

@@ -1,73 +0,0 @@
# State Machine Instance Identification & Persistence Mapping
## 1. The Multi-SM Problem
In large applications, multiple State Machines often coexist. A "Trigger Point" (like a REST Controller) must be linked to the correct State Machine definition.
**Common Identification Patterns**:
- **Unique Types**: `StateMachine<OrderState, OrderEvent>` vs `StateMachine<UserState, UserEvent>`.
- **Bean Qualifiers**: `@Qualifier("orderStateMachine")`.
- **Factory IDs**: `factory.getStateMachine("order-123")`.
- **Persistence Restoration**: `persister.restore(sm, orderId)`.
## 2. Analysis Strategy: "Instance Tracking"
### 1. Definition Discovery (Existing)
Identify all configurations (classes with `@EnableStateMachineFactory` or `@EnableStateMachine`). Store their **Bean Names** and **Generic Types**.
### 2. Dependency Analysis
When a class (e.g., `OrderController`) uses a state machine:
1. **Identify the Field/Parameter**: Look for `StateMachine<S, E>`.
2. **Resolve Generic Types**: Match `S` and `E` against known SM definitions.
3. **Resolve Qualifiers**: Check for `@Qualifier` or variable names that match a SM bean name.
### 3. Loading & Persistence Analysis
A dedicated "Loading Detector" will look for persistence logic.
**Pattern: Persister Restore**
```java
persister.restore(stateMachine, id);
stateMachine.sendEvent(E);
```
**Static Strategy**:
- Find calls to `Persister.restore(sm, ...)` or `PersistStateChangeListener`.
- Link the variable `sm` to the restoration event.
- Mark the `TriggerPoint` as "Restored from Persistence".
**Pattern: Factory Creation**
```java
StateMachine sm = factory.getStateMachine(smId);
```
**Static Strategy**:
- Find `factory.getStateMachine(...)`.
- If the argument is a literal (e.g., `"order"`), map it to the SM definition named "order".
## 3. Implementation: `InstanceIdentifier`
We will introduce an `InstanceIdentifier` that works alongside the `ValueResolver`.
```java
public class InstanceIdentifier {
public StateMachineReference identify(VariableDeclaration var, CodebaseContext context) {
// 1. Check type generics
// 2. Check @Qualifier
// 3. Trace back to factory or persister calls
}
}
```
## 4. Modeling in `AnalysisResult`
The `TriggerPoint` will be enhanced with a `stateMachineId` or `configFqn` field.
```java
public record TriggerPoint(
String className,
String methodName,
String event,
String stateMachineId, // Links back to the specific SM
boolean isRestoredFromPersistence,
Map<String, String> metadata
) {}
```
## 5. Challenges
- **Generic Controllers**: A single base controller that handles multiple SMs via generics. We might need to report "Multiple Potential SMs".
- **Dynamic Factory IDs**: `factory.getStateMachine(payload.getType())`. Hard to resolve statically without data flow analysis.

View File

@@ -1,55 +0,0 @@
# Polyglot Analysis: Supporting Mixed Java and Kotlin
## 1. The Need for Language Abstraction
Professional JVM codebases are increasingly "Mixed Mode" (Java + Kotlin). Tying our analysis logic directly to JDT (Java AST) creates a bottleneck for Kotlin support.
## 2. The "Driver" Architecture
We decouple the **Intelligence Logic** (e.g., "Find all REST controllers") from the **AST Parser** (e.g., "Walk the JDT tree").
### 1. Language Driver Interface
```java
public interface LanguageDriver {
boolean supports(Path filePath);
// Unified Extraction Methods
List<RawClassMetadata> extractClasses(Path filePath);
List<RawMethodMetadata> extractMethods(RawClassMetadata cls);
List<RawInvocationMetadata> findInvocations(RawMethodMetadata method, String targetMethodName);
}
```
### 2. Unified Metadata Model (Intermediate Representation)
We move to a "Generic JVM Model" before performing the final state machine mapping.
```java
public record RawClassMetadata(
String fqn,
String language, // "java", "kotlin"
List<String> annotations,
String superclass,
List<String> interfaces
) {}
public record RawMethodMetadata(
String name,
List<String> annotations,
String returnType,
List<String> parameterTypes
) {}
```
## 3. Polyglot Analysis Flow
1. **File Discovery**: Find all `.java` and `.kt` files.
2. **Driver Assignment**: Delegate each file to the appropriate `LanguageDriver`.
3. **Cross-Language Resolution**: The `CodebaseContext` stores metadata for ALL classes.
- A Java controller calling a Kotlin service is resolved by looking up the Kotlin class's unified metadata.
4. **Enricher Execution**: Enrichers (like `SpringMvcEnricher`) now operate on the **Unified Metadata Model**, making them language-agnostic!
## 4. Why this is "Future Proof":
- **Enrichers are written once**: The logic to find `@PostMapping` only needs to know how to query the `RawClassMetadata` annotations, not how to traverse JDT vs. Kotlin PSI.
- **Easy Expansion**: To support Scala, Groovy, or even a newer Java version, you just add a new `LanguageDriver`.
## 5. Refactoring Plan
1. **Define Intermediate Models**: Create `RawClassMetadata` and `RawMethodMetadata`.
2. **Extract JDT Logic**: Move the current JDT-specific code into `JavaLanguageDriver`.
3. **Kotlin PoC (Phase 12)**: Implement a basic `KotlinLanguageDriver` using Regex or a lightweight parser (like Tree-Sitter) to prove the abstraction works.

View File

@@ -1,80 +0,0 @@
# Spring Property and Value Resolution Strategy
## 1. The Challenge of "Injected" Metadata
In Spring, metadata (like Queue names, Topic names, or even Event names) is often not hardcoded but injected using various mechanisms. To provide an accurate map, the analyzer must resolve these values.
## 2. Supported Injection Patterns
### Field Injection
```java
@Value("${app.order-queue}")
private String orderQueue;
```
**Static Strategy**:
1. Scan all fields for `@Value`.
2. Map `FieldName` -> `Placeholder/Value`.
3. If `${...}` is found, resolve it via the `PropertyResolver`.
### Constructor and Setter Injection
```java
public OrderService(@Value("${app.event.submit}") String submitEvent) {
this.submitEvent = submitEvent;
}
```
**Static Strategy**:
1. Scan constructor/method parameters for `@Value`.
2. Trace the assignment to a class field (`this.submitEvent = ...`).
3. Store the mapping for that field.
### @ConfigurationProperties
```java
@ConfigurationProperties(prefix = "app.messaging")
public class AppProps {
private String queueName;
}
```
**Static Strategy**:
1. Identify `@ConfigurationProperties` classes.
2. Index their fields with the prefix (e.g., `app.messaging.queueName`).
3. When these beans are injected into a service, link the service's usage to the resolved property value.
## 3. Profile-Aware Property Resolver
A component dedicated to building a multi-dimensional map of available properties.
1. **File Scanning**: Parse `application.properties`, `application.yml`, and `bootstrap.yml`.
2. **Profile Identification**:
- From filenames: `application-{profile}.properties`.
- From YAML documents: `spring.config.activate.on-profile` (Spring Boot 2.4+).
3. **The Multi-Profile Map**:
- Instead of one map, we store: `Map<ProfileName, Map<Key, Value>>`.
- The "default" profile is the base.
## 4. Smart Value Propagation
When a trigger uses a property, we want to know it's profile-dependent.
**Example Trace**:
1. `application.yml` -> `app.queue: "orders"`
2. `application-prod.yml` -> `app.queue: "orders-prod"`
3. `TriggerPoint` stores:
- `placeholder: "${app.queue}"`
- `defaultResolvedValue: "orders"`
4. **Rendering Strategy**: The renderer can show "orders" by default, but provide a "Profile Toggle" to switch to "prod" and see the labels update to "orders-prod".
## 5. Metadata Retention
We should never "squash" profiles during analysis. The `CodebaseMetadata` will carry the full profile matrix to the exporter.
## 5. SpEL (Spring Expression Language) Lite
Full SpEL support is hard for static analysis, but we can support "Common Patterns":
- Simple bean property access: `#{myBean.name}`
- System properties: `#{systemProperties['user.dir']}`
- Ternary operators: `${app.enabled ? 'active' : 'inactive'}`
## 6. Constant Resolution
If an annotation uses a reference to a constant:
```java
@RabbitListener(queues = MyConstants.ORDER_QUEUE)
```
**Static Strategy**:
1. Use JDT to resolve the `QualifiedName` (`MyConstants.ORDER_QUEUE`).
2. Fetch the `TypeDeclaration` for `MyConstants`.
3. Find the `VariableDeclarationFragment` for `ORDER_QUEUE` and extract its literal initializer.

View File

@@ -1,53 +0,0 @@
# Advanced String and Variable Resolution Strategy
## 1. The Problem
Annotations often use non-literal values for metadata:
- **Constants**: `@PostMapping(ApiConstants.SUBMIT_PATH)`
- **Concatenation**: `@RequestMapping(BASE + "/orders")`
- **Inherited Variables**: Use of fields defined in base classes.
Simple `StringLiteral` extraction fails in these cases. We need a recursive **Expression Evaluator**.
## 2. Evaluation Logic
### Constant Resolution
If an expression is a `SimpleName` or `QualifiedName`:
1. Use `CodebaseContext.getTypeDeclaration()` to find the class where the variable is defined.
2. Search for the `VariableDeclarationFragment`.
3. If it has an initializer that is a literal or another evaluatable expression, resolve it.
### Concatenation (Infix Expressions)
If an expression is an `InfixExpression` with the `+` operator:
1. Recursively resolve the left operand.
2. Recursively resolve the right operand.
3. Concatenate the results if both are strings.
### Spring Expression Language (SpEL) in Annotations
For values like `@Value("#{systemProperties['path.base'] + '/api'}")`:
1. Identify the SpEL string.
2. Use a "Lite" SpEL parser to extract keys.
3. Match against the `profiles` map in `CodebaseMetadata`.
## 3. Implementation: `ValueResolver`
We will introduce a central `ValueResolver` utility.
```java
public class ValueResolver {
public String resolveString(Expression expr, CodebaseContext context) {
if (expr instanceof StringLiteral sl) return sl.getLiteralValue();
if (expr instanceof InfixExpression ie) return resolveInfix(ie, context);
if (expr instanceof Name name) return resolveVariable(name, context);
// ... fallback to toString() or empty
}
}
```
## 4. Challenges
- **Circular Dependencies**: A + B, where B = A + C. We need a "visited" set to prevent infinite recursion.
- **Runtime-only values**: Some values cannot be resolved statically (e.g., values from a DB). In these cases, we should return the placeholder or a "Runtime Value" marker.
- **Method Calls**: `getPath() + "/orders"`. Resolving method return values is complex; initially, we will only support simple getters returning literals.
## 5. Integration
1. Update `AstUtils` or create `ValueResolver`.
2. Refactor `SpringMvcEnricher`, `RabbitMqEnricher`, and `JmsEnricher` to use the resolver for all path/queue/destination fields.
3. Update `integration_test_state_machine` with complex path examples.

5
run_golden.gradle Normal file
View File

@@ -0,0 +1,5 @@
task runGolden(type: JavaExec) {
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
classpath = sourceSets.test.runtimeClasspath
workingDir = rootProject.projectDir
}

View File

@@ -19,3 +19,6 @@ include ':state_machines:ultimate_ecosystem_sm'
include ':state_machines:enterprise_order_system' include ':state_machines:enterprise_order_system'
include ':state_machines:multi_module_sample:api-module' include ':state_machines:multi_module_sample:api-module'
include ':state_machines:multi_module_sample:core-module' include ':state_machines:multi_module_sample:core-module'
include ':state_machines:state_machine_enterprise'
include 'state_machine_exporter_kotlin'
include ':state_machines:kotlin_simple_state_machine'

View File

@@ -32,10 +32,14 @@ dependencies {
// deps for parsing java AST // deps for parsing java AST
implementation 'org.eclipse.jdt:org.eclipse.jdt.core:3.36.0' implementation 'org.eclipse.jdt:org.eclipse.jdt.core:3.36.0'
// Kotlin plugin integration via ServiceLoader
runtimeOnly project(':state_machine_exporter_kotlin')
// Jackson for JSON support // Jackson for JSON support
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.17.1' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.17.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1'
compileOnly 'org.projectlombok:lombok:1.18.46' compileOnly 'org.projectlombok:lombok:1.18.46'
annotationProcessor 'org.projectlombok:lombok:1.18.46' annotationProcessor 'org.projectlombok:lombok:1.18.46'
@@ -53,3 +57,9 @@ tasks.named('test') {
useJUnitPlatform() useJUnitPlatform()
systemProperty "updateGolden", System.getProperty("updateGolden") systemProperty "updateGolden", System.getProperty("updateGolden")
} }
task runGolden(type: JavaExec) {
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
classpath = sourceSets.test.runtimeClasspath
workingDir = rootProject.projectDir
}

View File

@@ -14,6 +14,14 @@ import java.util.List;
public class Main { public class Main {
public static void main(String[] args) { public static void main(String[] args) {
// Enable diagnostic mode early before SLF4J initializes
for (String arg : args) {
if ("--debug".equals(arg)) {
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug");
break;
}
}
// Manual DI / Wiring // Manual DI / Wiring
var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter()); var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
var exportService = new ExportService(exporters); var exportService = new ExportService(exporters);

View File

@@ -0,0 +1,256 @@
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.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
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) {
if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
return;
}
List<CallChain> updatedChains = new ArrayList<>();
List<Transition> stateMachineTransitions = result.getTransitions();
for (CallChain chain : result.getMetadata().getCallChains()) {
TriggerPoint tp = chain.getTriggerPoint();
if (tp == null || tp.getEvent() == null) {
updatedChains.add(chain);
continue;
}
String triggerEvent = simplify(tp.getEvent());
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null;
List<MatchedTransition> matched = new ArrayList<>();
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName();
// Event matches
for (State smSourceState : t.getSourceStates()) {
String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
String smSource = simplify(smSourceRaw);
if (triggerSource == null || triggerSource.equals(smSource)) {
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceRaw)
.targetState(smSourceRaw)
.event(smEventRaw)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt);
}
} else {
for (State smTargetState : t.getTargetStates()) {
String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName();
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceRaw)
.targetState(targetRaw)
.event(smEventRaw)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt);
}
}
}
}
}
}
}
if (!matched.isEmpty()) {
CallChain newChain = chain.toBuilder().matchedTransitions(matched).build();
updatedChains.add(newChain);
} else {
updatedChains.add(chain);
}
}
// Update the metadata with the new call chains
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
.triggers(result.getMetadata().getTriggers())
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(updatedChains)
.properties(result.getMetadata().getProperties())
.build();
result.setMetadata(updatedMetadata);
}
private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
return routingEngine.isRoutedToCorrectMachine(chain, currentMachineName, context);
}
private boolean isConstraintCompatible(String constraint, String machineName) {
if (constraint == null || machineName == null) return true;
String cleanMachine = machineName.substring(machineName.lastIndexOf('.') + 1).toUpperCase();
if (cleanMachine.endsWith("STATEMACHINECONFIGURATION")) {
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "STATEMACHINECONFIGURATION".length());
}
if (cleanMachine.endsWith("STATEMACHINE")) {
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "STATEMACHINE".length());
}
if (cleanMachine.endsWith("CONFIG")) {
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "CONFIG".length());
}
String expr = constraint;
java.util.regex.Pattern p = java.util.regex.Pattern.compile("[\"']([a-zA-Z0-9_-]+)[\"']");
java.util.regex.Matcher matcher = p.matcher(constraint);
java.util.Set<String> literals = new java.util.HashSet<>();
while (matcher.find()) {
literals.add(matcher.group(1));
}
for (String m : literals) {
boolean isCurrent = m.equalsIgnoreCase(cleanMachine);
String replacement = isCurrent ? "true" : "false";
String regex1 = "(?i)[\"']?" + m + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\([^)]*\\)";
String regex2 = "(?i)[a-zA-Z0-9._]+\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?" + m + "[\"']?\\s*\\)";
String regex3 = "(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + m + "[\"']?";
String regex4 = "(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + m + "[\"']?";
expr = expr.replaceAll(regex1, replacement);
expr = expr.replaceAll(regex2, replacement);
expr = expr.replaceAll(regex3, replacement);
expr = expr.replaceAll(regex4, isCurrent ? "false" : "true");
}
try {
return BooleanEvaluator.evaluate(expr);
} catch (Exception e) {
return !expr.contains("false");
}
}
private static class BooleanEvaluator {
public static boolean evaluate(String expr) {
expr = expr.replaceAll("\\s+", "");
return parseExpr(expr);
}
private static boolean parseExpr(String s) {
if (s.isEmpty()) return true;
int parenDepth = 0;
for (int i = s.length() - 1; i >= 0; i--) {
char c = s.charAt(i);
if (c == ')') parenDepth++;
else if (c == '(') parenDepth--;
else if (parenDepth == 0 && i > 0 && s.charAt(i) == '|' && s.charAt(i - 1) == '|') {
return parseExpr(s.substring(0, i - 1)) || parseExpr(s.substring(i + 1));
}
}
parenDepth = 0;
for (int i = s.length() - 1; i >= 0; i--) {
char c = s.charAt(i);
if (c == ')') parenDepth++;
else if (c == '(') parenDepth--;
else if (parenDepth == 0 && i > 0 && s.charAt(i) == '&' && s.charAt(i - 1) == '&') {
return parseExpr(s.substring(0, i - 1)) && parseExpr(s.substring(i + 1));
}
}
if (s.startsWith("!")) {
return !parseExpr(s.substring(1));
}
if (s.startsWith("(") && s.endsWith(")")) {
return parseExpr(s.substring(1, s.length() - 1));
}
if (s.equalsIgnoreCase("true")) return true;
if (s.equalsIgnoreCase("false")) return false;
return true;
}
}
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>();
private final java.util.Map<java.util.Map.Entry<String, String>, Boolean> guardedCache = 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 String simplify(String name) {
if (name == null) return null;
if (simplifyCache.containsKey(name)) return simplifyCache.get(name);
// Strip common suffixes
String simplified = SUFFIX_PATTERN.matcher(name).replaceAll("$1");
if (simplified.isEmpty()) {
simplified = name;
}
// Simplify full identifiers to just the last part (enum name)
simplified = FQN_PATTERN.matcher(simplified).replaceAll("$1");
simplifyCache.put(name, simplified);
return simplified;
}
private boolean isGuardedContains(String smEvent, String triggerEvent) {
if (smEvent == null || triggerEvent == null) return false;
// Use a strictly immutable pair to guarantee 100% collision-free caching regardless of string content
java.util.Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(smEvent, triggerEvent);
if (guardedCache.containsKey(cacheKey)) return guardedCache.get(cacheKey);
boolean result = calculateGuardedContains(smEvent, triggerEvent);
guardedCache.put(cacheKey, result);
return result;
}
private boolean calculateGuardedContains(String smEvent, String triggerEvent) {
// Only fire if it looks like a simple identifier (no spaces, no parens)
// Prevents unresolved AST nodes (e.g. "event.getPayload()") from accidentally matching "Event"
if (smEvent.contains(" ") || smEvent.contains("(") || smEvent.contains(")") ||
triggerEvent.contains(" ") || triggerEvent.contains("(") || triggerEvent.contains(")")) {
return false;
}
String shorter = smEvent.length() < triggerEvent.length() ? smEvent : triggerEvent;
String longer = smEvent.length() >= triggerEvent.length() ? smEvent : triggerEvent;
shorter = shorter.toLowerCase();
longer = longer.toLowerCase();
if (longer.contains(shorter)) {
// Guard: If the matching part is very short (< 4 chars), it must be bounded by word separators
// to avoid matching completely unrelated words (e.g., "PAY" matching "PAYMENT" or "E" matching "UPDATE")
if (shorter.length() < 4) {
return longer.matches(".*(^|_|-)" + java.util.regex.Pattern.quote(shorter) + "(_|-|$).*");
}
// For longer strings, a straight contains is usually safe enough
return true;
}
return false;
}
}

View File

@@ -0,0 +1,11 @@
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event;
public interface EventMatchingEngine {
/**
* Determines whether the given trigger point is a match for the state machine event.
*/
boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint);
}

View File

@@ -0,0 +1,192 @@
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event;
import java.util.List;
public class HeuristicEventMatchingEngine implements EventMatchingEngine {
private final java.util.Map<java.util.Map.Entry<Event, TriggerPoint>, Boolean> matchesCache = new java.util.HashMap<>();
@Override
public boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint) {
if (stateMachineEvent == null || triggerPoint == null || triggerPoint.getEvent() == null) {
return false;
}
java.util.Map.Entry<Event, TriggerPoint> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(stateMachineEvent, triggerPoint);
if (matchesCache.containsKey(cacheKey)) return matchesCache.get(cacheKey);
boolean result = calculateMatches(stateMachineEvent, triggerPoint);
matchesCache.put(cacheKey, result);
return result;
}
private boolean calculateMatches(Event stateMachineEvent, TriggerPoint triggerPoint) {
String rawTriggerEvent = triggerPoint.getEvent();
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
String smEvent = simplify(smEventRaw);
boolean isWildcard = isWildcardVariable(rawTriggerEvent);
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
boolean hasPolyMatch = false;
for (String pe : polyEvents) {
if (pe.contains(".") && smEventRaw.contains(".")) {
if (smEventRaw.equals(pe) || smEventRaw.endsWith("." + pe) || pe.endsWith("." + smEventRaw)) {
hasPolyMatch = true;
break;
}
String classPe = pe.substring(0, pe.lastIndexOf('.'));
String classSm = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (classPe.contains(".") && classSm.contains(".")) {
String p1 = classPe.substring(0, classPe.lastIndexOf('.'));
String p2 = classSm.substring(0, classSm.lastIndexOf('.'));
if (!p1.equals(p2) && !p1.startsWith(p2 + ".") && !p2.startsWith(p1 + ".")) {
continue;
}
}
String simpleClassPe = classPe.contains(".") ? classPe.substring(classPe.lastIndexOf('.') + 1) : classPe;
String simpleClassSm = classSm.contains(".") ? classSm.substring(classSm.lastIndexOf('.') + 1) : classSm;
if (!simpleClassPe.equals(simpleClassSm)) {
continue;
}
}
String simplePe = pe;
if (pe.contains(".")) {
simplePe = pe.substring(pe.lastIndexOf('.') + 1);
}
String simplifiedPe = simplify(simplePe);
if (simplePe.equals(smEventRaw) || simplePe.equals(smEvent) ||
simplePe.equalsIgnoreCase(smEvent) || simplifiedPe.equalsIgnoreCase(smEvent) ||
isGuardedContains(smEvent, simplifiedPe)) {
hasPolyMatch = true;
break;
}
}
if (hasPolyMatch) return true;
if (polyEvents.isEmpty() && isWildcard) {
String eventTypeFqn = triggerPoint.getEventTypeFqn();
if (eventTypeFqn != null && smEventRaw.contains(".")) {
String smEventClass = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (smEventClass.equals(eventTypeFqn)) {
return true;
}
String simpleEventType = eventTypeFqn.contains(".") ? eventTypeFqn.substring(eventTypeFqn.lastIndexOf('.') + 1) : eventTypeFqn;
String simpleSmClass = smEventClass.contains(".") ? smEventClass.substring(smEventClass.lastIndexOf('.') + 1) : smEventClass;
if (simpleEventType.equalsIgnoreCase(simpleSmClass)) {
return true;
}
return false;
}
return true;
}
if (rawTriggerEvent.contains(".") && smEventRaw.contains(".")) {
if (smEventRaw.equals(rawTriggerEvent) || smEventRaw.endsWith("." + rawTriggerEvent) || rawTriggerEvent.endsWith("." + smEventRaw)) {
return true;
}
String classTrig = rawTriggerEvent.substring(0, rawTriggerEvent.lastIndexOf('.'));
String classSm = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (classTrig.contains(".") && classSm.contains(".")) {
String p1 = classTrig.substring(0, classTrig.lastIndexOf('.'));
String p2 = classSm.substring(0, classSm.lastIndexOf('.'));
if (!p1.equals(p2) && !p1.startsWith(p2 + ".") && !p2.startsWith(p1 + ".")) {
return false;
}
}
String simpleClassTrig = classTrig.contains(".") ? classTrig.substring(classTrig.lastIndexOf('.') + 1) : classTrig;
String simpleClassSm = classSm.contains(".") ? classSm.substring(classSm.lastIndexOf('.') + 1) : classSm;
if (!simpleClassTrig.equals(simpleClassSm)) {
return false;
}
}
String triggerEvent = simplify(rawTriggerEvent);
if (smEvent.equalsIgnoreCase(triggerEvent)) {
return true;
}
return isGuardedContains(smEvent, triggerEvent);
}
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>();
private final java.util.Map<java.util.Map.Entry<String, String>, Boolean> guardedCache = 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 boolean isGuardedContains(String smEvent, String triggerEvent) {
if (smEvent == null || triggerEvent == null) return false;
java.util.Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(smEvent, triggerEvent);
if (guardedCache.containsKey(cacheKey)) return guardedCache.get(cacheKey);
boolean result = calculateGuardedContains(smEvent, triggerEvent);
guardedCache.put(cacheKey, result);
return result;
}
private boolean calculateGuardedContains(String smEvent, String triggerEvent) {
// Only fire if it looks like a simple identifier (no spaces, no parens)
// Prevents unresolved AST nodes (e.g. "event.getPayload()") from accidentally matching "Event"
if (smEvent.contains(" ") || smEvent.contains("(") || smEvent.contains(")") ||
triggerEvent.contains(" ") || triggerEvent.contains("(") || triggerEvent.contains(")")) {
return false;
}
String smLower = smEvent.toLowerCase();
String triggerLower = triggerEvent.toLowerCase();
if (smLower.equals(triggerLower)) {
return true;
}
// Match if one contains the other as a whole word/segment.
// We quote the patterns to avoid regex syntax injection issues.
String pattern1 = "\\b" + java.util.regex.Pattern.quote(smLower) + "\\b";
String pattern2 = "\\b" + java.util.regex.Pattern.quote(triggerLower) + "\\b";
// Treat underscores and hyphens as word boundary separators
String smNormalized = smLower.replace('_', ' ').replace('-', ' ');
String triggerNormalized = triggerLower.replace('_', ' ').replace('-', ' ');
return smNormalized.matches(".*" + pattern2 + ".*") || triggerNormalized.matches(".*" + pattern1 + ".*");
}
private boolean isWildcardVariable(String eventStr) {
if (eventStr == null) return false;
if (eventStr.equals("event") || eventStr.equals("e") ||
eventStr.equals("msg") || eventStr.equals("message") ||
eventStr.equals("payload")) {
return true;
}
if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) {
return true;
}
if (eventStr.contains(" ? ") && eventStr.contains(" : ")) {
return true;
}
return false;
}
private String simplify(String name) {
if (name == null) return null;
if (simplifyCache.containsKey(name)) return simplifyCache.get(name);
String simplified = SUFFIX_PATTERN.matcher(name).replaceAll("$1");
if (simplified.isEmpty()) {
simplified = name;
}
simplified = FQN_PATTERN.matcher(simplified).replaceAll("$1");
simplifyCache.put(name, simplified);
return simplified;
}
}

View File

@@ -0,0 +1,150 @@
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event;
import java.util.List;
public class StrictFqnMatchingEngine implements EventMatchingEngine {
@Override
public boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint) {
if (stateMachineEvent == null || triggerPoint == null || triggerPoint.getEvent() == null) {
return false;
}
if (triggerPoint.isExternal()) {
return true;
}
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
String rawTriggerEvent = triggerPoint.getEvent();
if (rawTriggerEvent != null && rawTriggerEvent.startsWith("<SYMBOLIC: ") && rawTriggerEvent.endsWith(".*>")) {
String targetType = rawTriggerEvent.substring(11, rawTriggerEvent.length() - 3);
if (smEventRaw.startsWith(targetType + ".")) {
return true;
}
if (smEventRaw.contains(".")) {
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
return true;
}
}
return false;
}
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
for (String pe : polyEvents) {
if (matchEventNames(pe, smEventRaw, triggerPoint.getEventTypeFqn())) {
return true;
}
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
String targetType = pe.substring(11, pe.length() - 3);
if (smEventRaw.startsWith(targetType + ".")) {
return true;
}
if (smEventRaw.contains(".")) {
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
return true;
}
}
}
}
if (rawTriggerEvent != null && rawTriggerEvent.contains(".valueOf(")) {
String enumClass = rawTriggerEvent.substring(0, rawTriggerEvent.indexOf(".valueOf("));
if (enumClass.contains(" ")) {
enumClass = enumClass.substring(enumClass.lastIndexOf(" ") + 1);
}
String smClass = smEventRaw.contains(".") ? smEventRaw.substring(0, smEventRaw.lastIndexOf('.')) : smEventRaw;
if (enumClass.equals(smClass) || enumClass.endsWith("." + smClass) || smClass.endsWith("." + enumClass)) {
return true;
}
return false;
}
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
if (triggerPoint.getEventTypeFqn() != null) {
if (smEventRaw.startsWith(triggerPoint.getEventTypeFqn() + ".")) {
return true;
}
if (isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn()) && !smEventRaw.contains(".")) {
return true;
}
return false;
}
return true;
}
return matchEventNames(rawTriggerEvent, smEventRaw, triggerPoint.getEventTypeFqn());
}
private boolean matchEventNames(String triggerEvent, String smEvent, String eventTypeFqn) {
if (triggerEvent == null || smEvent == null) return false;
if (triggerEvent.equals(smEvent)) return true;
String tConst = triggerEvent.contains(".") ? triggerEvent.substring(triggerEvent.lastIndexOf('.') + 1) : triggerEvent;
String smConst = smEvent.contains(".") ? smEvent.substring(smEvent.lastIndexOf('.') + 1) : smEvent;
if (!tConst.equals(smConst)) return false;
// Prevent matching string/primitive triggers to enum transitions
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".")) {
return false;
}
// Prevent matching enum triggers to string transitions
if (!smEvent.contains(".") && triggerEvent.contains(".")) {
return false;
}
if (eventTypeFqn != null && eventTypeFqn.contains(".") && smEvent.contains(".")) {
String fullTriggerFqn = eventTypeFqn + "." + tConst;
return fullTriggerFqn.equals(smEvent);
}
if (triggerEvent.contains(".") && smEvent.contains(".")) {
String tClass = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
String smClass = smEvent.substring(0, smEvent.lastIndexOf('.'));
String tClassSimple = tClass.contains(".") ? tClass.substring(tClass.lastIndexOf('.') + 1) : tClass;
String smClassSimple = smClass.contains(".") ? smClass.substring(smClass.lastIndexOf('.') + 1) : smClass;
return tClassSimple.equals(smClassSimple);
}
return true;
}
private boolean isStringTypeOrPrimitive(String typeFqn) {
if (typeFqn == null) return false;
return typeFqn.equals("String") || typeFqn.equals("java.lang.String") ||
typeFqn.equals("int") || typeFqn.equals("long") || typeFqn.equals("char");
}
private boolean isDynamicVariable(String eventStr) {
if (eventStr == null || eventStr.isEmpty()) return false;
if (eventStr.contains(" ? ") && eventStr.contains(" : ")) {
return true;
}
if (eventStr.endsWith("()")) {
return true;
}
if (eventStr.contains(".")) {
String firstPart = eventStr.substring(0, eventStr.indexOf('.'));
if (!firstPart.isEmpty() && Character.isLowerCase(firstPart.charAt(0))) {
return true;
}
return false;
}
if (Character.isLowerCase(eventStr.charAt(0))) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,69 @@
package click.kamil.springstatemachineexporter.analysis.enricher.path;
import click.kamil.springstatemachineexporter.analysis.enricher.AnalysisEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
public class ForwardPathEstimationEnricher implements AnalysisEnricher {
private final boolean isEnabled;
private final PathValueEstimator estimator;
public ForwardPathEstimationEnricher() {
this.isEnabled = Boolean.parseBoolean(System.getProperty("analyzer.forward-path-estimation.enabled", "false"));
this.estimator = new SymbolicPathValueEstimator();
}
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (!isEnabled || result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
return;
}
log.info("Running Symbolic Forward Path Analysis for {}", result.getName());
List<CallChain> chains = result.getMetadata().getCallChains();
List<CallChain> updatedChains = chains.stream().map(chain -> {
if (chain.getTriggerPoint() == null || chain.getTriggerPoint().getEvent() == null) return chain;
// If the event looks like a dynamic variable (no dots, not an enum format)
if (!chain.getTriggerPoint().getEvent().contains(".")) {
List<String> estimatedValues = estimator.estimatePossibleValues(chain, context);
if (estimatedValues != null && !estimatedValues.isEmpty()) {
log.info("Symbolic Path Analysis estimated values for {} -> {}", chain.getTriggerPoint().getEvent(), estimatedValues);
List<String> polyEvents = new ArrayList<>();
if (chain.getTriggerPoint().getPolymorphicEvents() != null) {
polyEvents.addAll(chain.getTriggerPoint().getPolymorphicEvents());
}
polyEvents.addAll(estimatedValues);
TriggerPoint newTp = chain.getTriggerPoint().toBuilder()
.polymorphicEvents(polyEvents)
.build();
return chain.toBuilder().triggerPoint(newTp).build();
}
}
return chain;
}).collect(Collectors.toList());
click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata updatedMetadata =
click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.triggers(result.getMetadata().getTriggers())
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(updatedChains)
.properties(result.getMetadata().getProperties())
.build();
result.setMetadata(updatedMetadata);
}
}

View File

@@ -0,0 +1,10 @@
package click.kamil.springstatemachineexporter.analysis.enricher.path;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.List;
public interface PathValueEstimator {
List<String> estimatePossibleValues(CallChain chain, CodebaseContext context);
}

View File

@@ -0,0 +1,132 @@
package click.kamil.springstatemachineexporter.analysis.enricher.path;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class SymbolicPathValueEstimator implements PathValueEstimator {
@Override
public List<String> estimatePossibleValues(CallChain chain, CodebaseContext context) {
if (chain.getMethodChain() == null || chain.getMethodChain().isEmpty()) {
return null;
}
Set<String> collectedConstants = new HashSet<>();
for (String methodFqn : chain.getMethodChain()) {
int lastDot = methodFqn.lastIndexOf('.');
if (lastDot == -1) continue;
String className = methodFqn.substring(0, lastDot);
String methodName = methodFqn.substring(lastDot + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) continue;
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) continue;
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if ((node.getName().getIdentifier().equals("equals") || node.getName().getIdentifier().equals("equalsIgnoreCase"))
&& node.arguments().size() == 1) {
Expression caller = node.getExpression();
Expression arg = (Expression) node.arguments().get(0);
click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver resolver = new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver();
if (caller != null) {
String callerVal = resolver.resolve(caller, context);
if (callerVal != null && !callerVal.isEmpty()) {
addValue(collectedConstants, callerVal);
}
}
String argVal = resolver.resolve(arg, context);
if (argVal != null && !argVal.isEmpty()) {
addValue(collectedConstants, argVal);
}
}
return super.visit(node);
}
@Override
public boolean visit(InfixExpression node) {
if (node.getOperator() == InfixExpression.Operator.EQUALS) {
click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver resolver = new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver();
String left = resolver.resolve(node.getLeftOperand(), context);
if (left != null && !left.isEmpty()) {
addValue(collectedConstants, left);
}
String right = resolver.resolve(node.getRightOperand(), context);
if (right != null && !right.isEmpty()) {
addValue(collectedConstants, right);
}
}
return super.visit(node);
}
@Override
public boolean visit(SwitchStatement node) {
for (Object stmt : node.statements()) {
if (stmt instanceof SwitchCase sc && !sc.isDefault()) {
for (Object expr : sc.expressions()) {
click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver resolver = new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver();
String val = resolver.resolve((Expression) expr, context);
if (val != null && !val.isEmpty()) {
addValue(collectedConstants, val);
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SwitchExpression node) {
for (Object stmt : node.statements()) {
if (stmt instanceof SwitchCase sc && !sc.isDefault()) {
for (Object expr : sc.expressions()) {
click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver resolver = new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver();
String val = resolver.resolve((Expression) expr, context);
if (val != null && !val.isEmpty()) {
addValue(collectedConstants, val);
}
}
}
}
return super.visit(node);
}
});
}
if (collectedConstants.isEmpty()) return null;
return new ArrayList<>(collectedConstants);
}
private void addValue(Set<String> collectedConstants, String val) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal);
if (!collectedConstants.contains(parsed)) collectedConstants.add(parsed);
}
} else {
collectedConstants.add(val);
}
}
private String parseEnumSetElement(String element) {
if (element.contains(".")) {
return element.substring(element.lastIndexOf('.') + 1);
}
return element;
}
}

View File

@@ -0,0 +1,9 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
public interface BeanResolutionEngine {
boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context);
}

View File

@@ -0,0 +1,380 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.HashSet;
import java.util.Set;
public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
@Override
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
// Precise FQN Type argument match
if (chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn();
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn();
if (triggerEventFqn != null || triggerStateFqn != null) {
String[] machineTypes = getStateMachineTypeArguments(currentMachineName, context);
String machineStateFqn = machineTypes[0];
String machineEventFqn = machineTypes[1];
if (triggerEventFqn != null && machineEventFqn != null) {
if (eraseGenerics(triggerEventFqn).equals(eraseGenerics(machineEventFqn))) {
return true; // Match!
}
if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
return false; // Mismatch!
}
}
if (triggerStateFqn != null && machineStateFqn != null) {
if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) {
return true; // Match!
}
if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
return false; // Mismatch!
}
}
}
}
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) {
return false;
}
}
return true;
}
private boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
if (smPackage.startsWith(chainPackage) || chainPackage.startsWith(smPackage)) {
return true;
}
// 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[] getStateMachineTypeArguments(String machineName, CodebaseContext context) {
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration(machineName);
if (td != null) {
org.eclipse.jdt.core.dom.Type superclass = td.getSuperclassType();
Set<String> visited = new HashSet<>();
visited.add(machineName);
return findTypeArgumentsRecursively(superclass, context, (org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot(), visited);
}
return new String[]{null, null};
}
private String[] findTypeArgumentsRecursively(org.eclipse.jdt.core.dom.Type type, CodebaseContext context, org.eclipse.jdt.core.dom.CompilationUnit cu, Set<String> visited) {
if (type == null) return new String[]{null, null};
if (type instanceof org.eclipse.jdt.core.dom.ParameterizedType pt) {
String rawTypeName = pt.getType().toString();
if (rawTypeName.equals("StateMachineConfigurerAdapter") || rawTypeName.equals("EnumStateMachineConfigurerAdapter") ||
rawTypeName.endsWith(".StateMachineConfigurerAdapter") || rawTypeName.endsWith(".EnumStateMachineConfigurerAdapter")) {
java.util.List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
String stateType = resolveTypeToFqn((org.eclipse.jdt.core.dom.Type) typeArgs.get(0), cu, context);
String eventType = resolveTypeToFqn((org.eclipse.jdt.core.dom.Type) typeArgs.get(1), cu, context);
return new String[]{stateType, eventType};
}
}
// Recurse into the raw type declaration's superclass
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(rawTypeName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
org.eclipse.jdt.core.dom.Type superclass = (td instanceof org.eclipse.jdt.core.dom.TypeDeclaration) ? ((org.eclipse.jdt.core.dom.TypeDeclaration) td).getSuperclassType() : null;
String[] res = findTypeArgumentsRecursively(superclass, context, (org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot(), visited);
if (res[0] != null || res[1] != null) return res;
}
}
} else {
// It's a simple type (e.g. MyBaseConfig)
String simpleName = type.toString();
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
org.eclipse.jdt.core.dom.Type superclass = (td instanceof org.eclipse.jdt.core.dom.TypeDeclaration) ? ((org.eclipse.jdt.core.dom.TypeDeclaration) td).getSuperclassType() : null;
String[] res = findTypeArgumentsRecursively(superclass, context, (org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot(), visited);
if (res[0] != null || res[1] != null) return res;
}
}
}
return new String[]{null, null};
}
private String eraseGenerics(String type) {
if (type == null) return null;
int idx = type.indexOf('<');
if (idx != -1) {
return type.substring(0, idx);
}
return type;
}
private String resolveTypeToFqn(org.eclipse.jdt.core.dom.Type type, org.eclipse.jdt.core.dom.CompilationUnit cu, CodebaseContext context) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((org.eclipse.jdt.core.dom.SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((org.eclipse.jdt.core.dom.ParameterizedType) type).getType(), cu, context);
} else {
simpleName = type.toString();
}
if (simpleName.contains("<")) {
simpleName = simpleName.substring(0, simpleName.indexOf('<'));
}
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
for (Object impObj : cu.imports()) {
org.eclipse.jdt.core.dom.ImportDeclaration imp = (org.eclipse.jdt.core.dom.ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
org.eclipse.jdt.core.dom.AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) return context.getFqn(localTd);
}
return simpleName;
}
private boolean isTypeMismatched(String type1, String type2) {
if (type1 == null || type2 == null) return false;
type1 = eraseGenerics(type1);
type2 = eraseGenerics(type2);
if (type1.length() == 1 || type2.length() == 1) return false;
if (type1.equals("java.lang.String") || type1.equals("String") || type1.equals("java.lang.Object") || type1.equals("Object")) return false;
if (type2.equals("java.lang.String") || type2.equals("String") || type2.equals("java.lang.Object") || type2.equals("Object")) return false;
return !type1.equals(type2);
}
private boolean isSingleStateMachine(CodebaseContext context) {
if (context == null) return false;
int count = 0;
count += context.findEntryPointClasses(java.util.List.of("EnableStateMachineFactory", "EnableStateMachine")).size();
count += context.findBeanMethodsReturning(java.util.Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory")).size();
return count <= 1;
}
}

View File

@@ -60,7 +60,7 @@ public class AnalysisResult {
if (transitions != null) { if (transitions != null) {
for (var t : transitions) { for (var t : transitions) {
if (t.getEvent() != null) { if (t.getEvent() != null) {
t.setEvent(resolver.resolveValue(t.getEvent(), properties)); t.setEvent(click.kamil.springstatemachineexporter.model.Event.of(t.getEvent().rawName(), resolver.resolveValue(t.getEvent().fullIdentifier(), properties)));
} }
// Resolve states within transitions // Resolve states within transitions
t.setSourceStates(t.getSourceStates().stream() t.setSourceStates(t.getSourceStates().stream()

View File

@@ -8,11 +8,13 @@ import lombok.extern.jackson.Jacksonized;
import java.util.List; import java.util.List;
@Data @Data
@Builder @Builder(toBuilder = true)
@Jacksonized @Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public class CallChain { public class CallChain {
private final EntryPoint entryPoint; private final EntryPoint entryPoint;
private final List<String> methodChain; // e.g., ["Controller.submit", "Service.process", "Service.send"] private final List<String> methodChain; // e.g., ["Controller.submit", "Service.process", "Service.send"]
private final TriggerPoint triggerPoint; private final TriggerPoint triggerPoint;
private final String contextMachineId;
private final List<MatchedTransition> matchedTransitions;
} }

View File

@@ -0,0 +1,25 @@
package click.kamil.springstatemachineexporter.analysis.model;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CallEdge {
private String targetMethod;
private List<String> arguments;
private String receiver;
private String constraint;
public CallEdge(String targetMethod, List<String> arguments) {
this(targetMethod, arguments, null, null);
}
public CallEdge(String targetMethod, List<String> arguments, String receiver) {
this(targetMethod, arguments, receiver, null);
}
}

View File

@@ -13,5 +13,7 @@ import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public class LibraryHint { public class LibraryHint {
private final String methodFqn; // e.g., "com.thirdparty.Workflow.send" private final String methodFqn; // e.g., "com.thirdparty.Workflow.send"
private final String event; // The event it triggers private final String event; // The event it triggers (static)
private final Integer eventArgumentIndex; // e.g., 0 to extract from the 0th argument
private final String eventArgumentMethod; // e.g., "getType" to extract from argument method call
} }

View File

@@ -0,0 +1,16 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class MatchedTransition {
private final String sourceState;
private final String targetState;
private final String event;
}

View File

@@ -8,7 +8,7 @@ import lombok.extern.jackson.Jacksonized;
import java.util.Map; import java.util.Map;
@Data @Data
@Builder @Builder(toBuilder = true)
@Jacksonized @Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public class TriggerPoint { public class TriggerPoint {
@@ -18,5 +18,113 @@ public class TriggerPoint {
private final String sourceFile; private final String sourceFile;
private final String sourceModule; private final String sourceModule;
private final String stateMachineId; // Optional: to link to a specific SM instance private final String stateMachineId; // Optional: to link to a specific SM instance
private final String sourceState; // Optional: if we can determine the expected current state
private final int lineNumber; private final int lineNumber;
@com.fasterxml.jackson.annotation.JsonIgnore
private final String stateTypeFqn; // Type of State (e.g. OrderStates FQN)
@com.fasterxml.jackson.annotation.JsonIgnore
private final String eventTypeFqn; // Type of Event (e.g. OrderEvents FQN)
private final java.util.List<String> polymorphicEvents; // NEW: stores concrete events resolved via deep polymorphism
private final boolean external;
private final String constraint;
private final boolean ambiguous;
public TriggerPoint(
String event,
String className,
String methodName,
String sourceFile,
String sourceModule,
String stateMachineId,
String sourceState,
int lineNumber,
String stateTypeFqn,
String eventTypeFqn,
java.util.List<String> polymorphicEvents,
boolean external,
String constraint,
boolean ambiguous) {
this.className = className;
this.methodName = methodName;
this.sourceFile = sourceFile;
this.sourceModule = sourceModule;
this.stateMachineId = stateMachineId;
this.sourceState = sourceState;
this.lineNumber = lineNumber;
this.stateTypeFqn = stateTypeFqn;
this.eventTypeFqn = eventTypeFqn;
this.event = qualifyEvent(event, eventTypeFqn);
if (polymorphicEvents != null) {
this.polymorphicEvents = polymorphicEvents.stream()
.map(pe -> qualifyEvent(pe, eventTypeFqn))
.collect(java.util.stream.Collectors.toList());
} else {
this.polymorphicEvents = null;
}
this.external = external;
this.constraint = constraint;
this.ambiguous = ambiguous;
}
private static String qualifyEvent(String e, String eventTypeFqn) {
if (e == null || eventTypeFqn == null || e.isEmpty()) {
return e;
}
if (e.startsWith("<SYMBOLIC: ")) {
return e;
}
if (isWildcardVariable(e)) {
return e;
}
if (Character.isLowerCase(e.charAt(0))) {
return e;
}
if (!isEnumConstantCandidate(e)) {
return e;
}
String simpleEventType = eventTypeFqn.contains(".") ? eventTypeFqn.substring(eventTypeFqn.lastIndexOf('.') + 1) : eventTypeFqn;
if (e.contains(".")) {
String qualifier = e.substring(0, e.lastIndexOf('.'));
String lastSegment = e.substring(e.lastIndexOf('.') + 1);
String simpleQualifier = qualifier.contains(".") ? qualifier.substring(qualifier.lastIndexOf('.') + 1) : qualifier;
if (simpleQualifier.equals(simpleEventType)) {
return simpleEventType + "." + lastSegment;
}
return e;
}
if (eventTypeFqn.startsWith("java.lang.") || eventTypeFqn.equals("String") || eventTypeFqn.equals("int") || eventTypeFqn.equals("long") || eventTypeFqn.equals("char")) {
return e;
}
return simpleEventType + "." + e;
}
private static boolean isEnumConstantCandidate(String eventStr) {
if (eventStr == null) return false;
if (eventStr.startsWith("<SYMBOLIC: ")) {
return false;
}
if (eventStr.contains(" ") || eventStr.contains("(") || eventStr.contains(")") || eventStr.contains("?")) {
return false;
}
return true;
}
private static boolean isWildcardVariable(String eventStr) {
if (eventStr == null) return false;
if (eventStr.equals("event") || eventStr.equals("e") ||
eventStr.equals("msg") || eventStr.equals("message") ||
eventStr.equals("payload")) {
return true;
}
if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) {
return true;
}
if (eventStr.contains(" ? ") && eventStr.contains(" : ")) {
return true;
}
return false;
}
} }

View File

@@ -6,6 +6,9 @@ import org.eclipse.jdt.core.dom.*;
import java.util.HashSet; import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.LinkedHashSet;
import java.util.Arrays;
import java.util.List;
@Slf4j @Slf4j
public class ConstantResolver { public class ConstantResolver {
@@ -14,9 +17,30 @@ public class ConstantResolver {
return resolveInternal(expr, context, new HashSet<>()); return resolveInternal(expr, context, new HashSet<>());
} }
public String evaluateSwitchWithParams(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context) {
return evaluateSwitchExpression(se, paramValues, context, new HashSet<>());
}
public String evaluateSwitchWithParams(org.eclipse.jdt.core.dom.SwitchStatement ss, java.util.Map<String, String> paramValues, CodebaseContext context) {
return evaluateSwitchStatement(ss, paramValues, context, new HashSet<>());
}
private String resolveInternal(Expression expr, CodebaseContext context, Set<String> visited) { private String resolveInternal(Expression expr, CodebaseContext context, Set<String> visited) {
if (expr == null) return null; if (expr == null) return null;
// Unwrap common wrappers
if (expr instanceof CastExpression ce) {
return resolveInternal(ce.getExpression(), context, visited);
}
if (expr instanceof ParenthesizedExpression pe) {
return resolveInternal(pe.getExpression(), context, visited);
}
if (expr instanceof MethodInvocation mi && (mi.getName().getIdentifier().equals("requireNonNull") || mi.getName().getIdentifier().equals("notNull"))) {
if (!mi.arguments().isEmpty()) {
return resolveInternal((Expression) mi.arguments().get(0), context, visited);
}
}
// 1. Literal? // 1. Literal?
if (expr instanceof StringLiteral sl) { if (expr instanceof StringLiteral sl) {
return sl.getLiteralValue(); return sl.getLiteralValue();
@@ -48,15 +72,605 @@ public class ConstantResolver {
return resolveInfix(infix, context, visited); return resolveInfix(infix, context, visited);
} }
if (expr instanceof QualifiedName qn) { if (expr instanceof QualifiedName qn) {
return resolveManual(qn, context, visited); String val = resolveManual(qn, context, visited);
return val != null ? val : qn.toString();
}
if (expr instanceof FieldAccess fa) {
return resolveManual(fa.getName(), context, visited);
} }
if (expr instanceof SimpleName sn) { if (expr instanceof SimpleName sn) {
return resolveManual(sn, context, visited); return resolveManual(sn, context, visited);
} }
if (expr instanceof MethodInvocation mi) {
if ((mi.getName().getIdentifier().equals("name") || mi.getName().getIdentifier().equals("toString")) && mi.arguments().isEmpty()) {
String val = resolveInternal(mi.getExpression(), context, visited);
if (val != null) {
// If it was resolved as a fully qualified enum string like "MyEvents.ORDER_PAID", strip it
if (val.contains(".")) {
return val.substring(val.lastIndexOf('.') + 1);
}
return val;
}
// Fallback for unresolved AST nodes
if (mi.getExpression() instanceof QualifiedName qn) {
return qn.getName().getIdentifier();
} else if (mi.getExpression() instanceof SimpleName sn) {
return sn.getIdentifier();
}
}
if ("valueOf".equals(mi.getName().getIdentifier())) {
String enumFqn = null;
Expression arg = null;
if (mi.arguments().size() == 2) {
enumFqn = resolveEnumFqn((Expression) mi.arguments().get(0), context);
arg = (Expression) mi.arguments().get(1);
} else if (mi.arguments().size() == 1 && mi.getExpression() != null) {
enumFqn = resolveEnumFqn(mi.getExpression(), context);
arg = (Expression) mi.arguments().get(0);
}
if (enumFqn != null) {
if (arg != null) {
String resolvedArg = resolveInternal(arg, context, visited);
if (resolvedArg != null && resolvedArg.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
return enumFqn + "." + resolvedArg;
}
}
return "<SYMBOLIC: " + enumFqn + ".*>";
}
}
IMethodBinding mb = mi.resolveMethodBinding();
if (mb != null) {
if (!java.lang.reflect.Modifier.isStatic(mb.getModifiers())) {
Expression receiver = mi.getExpression();
if (!(receiver instanceof ClassInstanceCreation)) {
return null;
}
}
} else {
TypeDeclaration td = null;
if (mi.getExpression() != null) {
ITypeBinding typeBinding = mi.getExpression().resolveTypeBinding();
if (typeBinding != null) {
String typeName = typeBinding.getQualifiedName();
if (typeName != null && !typeName.isEmpty()) {
td = context.getTypeDeclaration(typeName);
}
}
}
if (td == null && mi.getExpression() instanceof SimpleName sn) {
String typeName = resolveLocalType(sn);
if (typeName != null) {
td = context.getTypeDeclaration(typeName);
if (td == null) {
CompilationUnit cu = (CompilationUnit) mi.getRoot();
td = context.getTypeDeclaration(typeName, cu);
}
}
}
if (td == null) {
td = findEnclosingType(mi);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, mi.getName().getIdentifier(), true);
if (md != null) {
String evaluated = evaluateMethodOutput(mi, md, context, visited);
if (evaluated != null) return evaluated;
if (mi.getName().getIdentifier().startsWith("get") && md.getReturnType2() != null) {
java.util.List<String> values = context.getEnumValues(md.getReturnType2().toString());
if (values != null && !values.isEmpty()) {
return "ENUM_SET:" + String.join(",", values);
}
}
}
}
}
}
return null; return null;
} }
private String evaluateMethodOutput(MethodInvocation mi, MethodDeclaration md, CodebaseContext context, Set<String> visited) {
if (md.getBody() == null || mi.arguments().size() != md.parameters().size()) return null;
TypeDeclaration td = findEnclosingType(md);
if (td == null) return null;
String methodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
if (!visited.add(methodFqn)) {
if (log.isDebugEnabled()) {
log.debug("Cycle detected in method evaluation for: {} (visited={})", methodFqn, visited);
}
return null; // Cycle detected in method evaluation
}
try {
java.util.Map<String, String> localVars = new java.util.HashMap<>();
java.util.Set<String> declaredLocals = new java.util.HashSet<>();
for (int i = 0; i < mi.arguments().size(); i++) {
Expression arg = (Expression) mi.arguments().get(i);
String val = resolveInternal(arg, context, visited);
if (val != null) {
org.eclipse.jdt.core.dom.SingleVariableDeclaration param =
(org.eclipse.jdt.core.dom.SingleVariableDeclaration) md.parameters().get(i);
String paramName = param.getName().getIdentifier();
localVars.put(paramName, val);
declaredLocals.add(paramName);
}
}
return evaluateBody(md.getBody(), localVars, declaredLocals, context, visited);
} finally {
visited.remove(methodFqn);
}
}
/**
* Evaluates a method body with pre-supplied local values (e.g. field values derived
* from the specific {@code ClassInstanceCreation} that created the receiver object).
* Used by {@link click.kamil.springstatemachineexporter.analysis.service.AbstractCallGraphEngine}
* to resolve transforming getters — methods that map a constructor-injected enum to a
* different state-machine enum via switch expressions.
*/
public String evaluateMethodBodyWithLocals(MethodDeclaration md,
java.util.Map<String, String> prebuiltLocals,
CodebaseContext context,
Set<String> visited) {
if (md.getBody() == null) return null;
TypeDeclaration td = findEnclosingType(md);
if (td == null) return null;
String methodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
if (!visited.add(methodFqn)) {
if (log.isDebugEnabled()) {
log.debug("Cycle detected in method body evaluation for: {} (visited={})", methodFqn, visited);
}
return null; // Cycle detected in method evaluation
}
try {
java.util.Set<String> declaredLocals = new java.util.HashSet<>(prebuiltLocals.keySet());
return evaluateBody(md.getBody(), prebuiltLocals, declaredLocals, context, visited);
} finally {
visited.remove(methodFqn);
}
}
/**
* Core body-evaluation logic shared by {@link #evaluateMethodOutput} and
* {@link #evaluateMethodBodyWithLocals}. Visits the block, tracks assignments,
* evaluates switch statements/expressions, and captures the first resolved return value.
*/
private String evaluateBody(org.eclipse.jdt.core.dom.Block body,
java.util.Map<String, String> localVars,
java.util.Set<String> declaredLocals,
CodebaseContext context,
Set<String> visited) {
String[] finalResult = new String[1];
body.accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement vds) {
for (Object fragmentObj : vds.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
String varName = fragment.getName().getIdentifier();
declaredLocals.add(varName);
if (fragment.getInitializer() != null) {
String rhsVal = resolveExpressionWithParams(fragment.getInitializer(), localVars, context);
if (rhsVal == null) rhsVal = resolveInternal(fragment.getInitializer(), context, visited);
if (rhsVal != null) {
localVars.put(varName, rhsVal);
} else {
java.util.Map<String, String> bFields = extractBuilderFields(fragment.getInitializer(), localVars, context, visited);
if (bFields != null) {
for (java.util.Map.Entry<String, String> e : bFields.entrySet()) {
localVars.put(varName + "." + e.getKey(), e.getValue());
}
}
}
}
}
}
return super.visit(vds);
}
@Override
public boolean visit(org.eclipse.jdt.core.dom.MethodInvocation node) {
MethodDeclaration sideEffectMd = findInvokedMethod(node, context);
if (sideEffectMd != null && sideEffectMd.getBody() != null) {
TypeDeclaration currentTd = findEnclosingType(sideEffectMd);
if (sideEffectMd != null && sideEffectMd.getBody() != null) {
java.util.Map<String, String> inlineLocals = new java.util.HashMap<>();
for (java.util.Map.Entry<String, String> entry : localVars.entrySet()) {
if (entry.getKey().startsWith("this.")) {
inlineLocals.put(entry.getKey(), entry.getValue());
} else if (context.hasField(currentTd, entry.getKey())) {
inlineLocals.put(entry.getKey(), entry.getValue());
inlineLocals.put("this." + entry.getKey(), entry.getValue());
}
}
for (int i = 0; i < node.arguments().size() && i < sideEffectMd.parameters().size(); i++) {
Expression arg = (Expression) node.arguments().get(i);
String val = resolveExpressionWithParams(arg, localVars, context);
if (val == null) val = resolveInternal(arg, context, visited);
if (val != null) {
org.eclipse.jdt.core.dom.SingleVariableDeclaration param = (org.eclipse.jdt.core.dom.SingleVariableDeclaration) sideEffectMd.parameters().get(i);
inlineLocals.put(param.getName().getIdentifier(), val);
}
}
evaluateMethodBodyWithLocals(sideEffectMd, inlineLocals, context, visited);
for (java.util.Map.Entry<String, String> entry : inlineLocals.entrySet()) {
if (entry.getKey().startsWith("this.")) {
localVars.put(entry.getKey(), entry.getValue());
String bareName = entry.getKey().substring(5);
if (!declaredLocals.contains(bareName)) localVars.put(bareName, entry.getValue());
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(Assignment assignment) {
Expression lhs = assignment.getLeftHandSide();
String varName = null;
if (lhs instanceof SimpleName sn) {
varName = sn.getIdentifier();
} else if (lhs instanceof FieldAccess fa) {
varName = "this." + fa.getName().getIdentifier();
}
if (varName != null) {
String rhsVal = resolveExpressionWithParams(assignment.getRightHandSide(), localVars, context);
if (rhsVal == null) rhsVal = resolveInternal(assignment.getRightHandSide(), context, visited);
if (rhsVal != null) {
localVars.put(varName, rhsVal);
} else {
java.util.Map<String, String> bFields = extractBuilderFields(assignment.getRightHandSide(), localVars, context, visited);
if (bFields != null) {
for (java.util.Map.Entry<String, String> e : bFields.entrySet()) {
localVars.put(varName + "." + e.getKey(), e.getValue());
}
}
}
if (varName.startsWith("this.")) {
localVars.put(varName, rhsVal);
String bareName = varName.substring(5);
if (!declaredLocals.contains(bareName)) localVars.put(bareName, rhsVal);
} else {
localVars.put(varName, rhsVal);
if (!declaredLocals.contains(varName)) localVars.put("this." + varName, rhsVal);
}
}
return super.visit(assignment);
}
@Override
public boolean visit(org.eclipse.jdt.core.dom.SwitchStatement ss) {
if (finalResult[0] == null) {
String result = evaluateSwitchStatement(ss, localVars, context, visited);
if (result != null) finalResult[0] = result;
}
return false; // children handled inside evaluateSwitchStatement
}
@Override
public boolean visit(org.eclipse.jdt.core.dom.ReturnStatement rs) {
if (finalResult[0] == null) {
if (rs.getExpression() instanceof org.eclipse.jdt.core.dom.SwitchExpression se) {
String result = evaluateSwitchExpression(se, localVars, context, visited);
if (result != null) finalResult[0] = result;
} else if (rs.getExpression() != null) {
String result = resolveExpressionWithParams(rs.getExpression(), localVars, context);
if (result == null) result = resolveInternal(rs.getExpression(), context, visited);
if (result != null) finalResult[0] = result;
}
}
return super.visit(rs);
}
});
return finalResult[0];
}
private String evaluateSwitchStatement(org.eclipse.jdt.core.dom.SwitchStatement ss, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
String switchVar = resolveExpressionWithParams(ss.getExpression(), paramValues, context);
if (switchVar == null) {
Set<String> values = new LinkedHashSet<>();
for (Object stmtObj : ss.statements()) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs && rs.getExpression() != null) {
String val = resolveInternal(rs.getExpression(), context, visited);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
values.addAll(Arrays.asList(val.substring(9).split(",")));
} else {
values.add(val);
}
}
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.YieldStatement ys && ys.getExpression() != null) {
String val = resolveInternal(ys.getExpression(), context, visited);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
values.addAll(Arrays.asList(val.substring(9).split(",")));
} else {
values.add(val);
}
}
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.ExpressionStatement es && es.getExpression() != null) {
String val = resolveInternal(es.getExpression(), context, visited);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
values.addAll(Arrays.asList(val.substring(9).split(",")));
} else {
values.add(val);
}
}
}
}
if (!values.isEmpty()) {
return "ENUM_SET:" + String.join(",", values);
}
return null;
}
String switchType = null;
if (switchVar.contains(".")) {
switchType = switchVar.substring(0, switchVar.lastIndexOf('.'));
}
boolean matchingCase = false;
for (Object stmtObj : ss.statements()) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.SwitchCase sc) {
matchingCase = false;
if (sc.isDefault()) {
matchingCase = true;
} else {
for (Object exprObj : sc.expressions()) {
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context);
// Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback
if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) {
caseVal = caseSn.getIdentifier();
if (switchType != null) {
caseVal = switchType + "." + caseVal;
}
}
if (caseVal != null) {
if (switchVar.contains(".") && caseVal.contains(".")) {
if (switchVar.equals(caseVal)) {
matchingCase = true;
break;
}
} else if (switchVar.endsWith(caseVal)) {
matchingCase = true;
break;
}
}
}
}
} else if (matchingCase) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) {
return resolveInternal(rs.getExpression(), context, visited);
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.YieldStatement ys) {
return resolveInternal(ys.getExpression(), context, visited);
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.ExpressionStatement es) {
return resolveInternal(es.getExpression(), context, visited);
}
}
}
return null;
}
private String evaluateSwitchExpression(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
String switchVar = resolveExpressionWithParams(se.getExpression(), paramValues, context);
if (switchVar == null) {
Set<String> values = new LinkedHashSet<>();
for (Object stmtObj : se.statements()) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.YieldStatement ys && ys.getExpression() != null) {
String val = resolveInternal(ys.getExpression(), context, visited);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
values.addAll(Arrays.asList(val.substring(9).split(",")));
} else {
values.add(val);
}
}
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.ExpressionStatement es && es.getExpression() != null) {
String val = resolveInternal(es.getExpression(), context, visited);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
values.addAll(Arrays.asList(val.substring(9).split(",")));
} else {
values.add(val);
}
}
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs && rs.getExpression() != null) {
String val = resolveInternal(rs.getExpression(), context, visited);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
values.addAll(Arrays.asList(val.substring(9).split(",")));
} else {
values.add(val);
}
}
}
}
if (!values.isEmpty()) {
return "ENUM_SET:" + String.join(",", values);
}
return null;
}
String switchType = null;
if (switchVar.contains(".")) {
switchType = switchVar.substring(0, switchVar.lastIndexOf('.'));
}
boolean matchingCase = false;
for (Object stmtObj : se.statements()) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.SwitchCase sc) {
matchingCase = false;
if (sc.isDefault()) {
matchingCase = true;
} else {
for (Object exprObj : sc.expressions()) {
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context);
// Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback
if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) {
caseVal = caseSn.getIdentifier();
if (switchType != null) {
caseVal = switchType + "." + caseVal;
}
}
if (caseVal != null) {
if (switchVar.contains(".") && caseVal.contains(".")) {
if (switchVar.equals(caseVal)) {
matchingCase = true;
break;
}
} else if (switchVar.endsWith(caseVal)) {
matchingCase = true;
break;
}
}
}
}
} else if (matchingCase) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.ExpressionStatement es) {
return resolveInternal(es.getExpression(), context, visited);
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.YieldStatement ys) {
return resolveInternal(ys.getExpression(), context, visited);
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) {
return resolveInternal(rs.getExpression(), context, visited);
}
}
}
return null;
}
private String resolveExpressionWithParams(Expression expr, java.util.Map<String, String> paramValues, CodebaseContext context) {
if (expr instanceof SimpleName sn) {
String name = sn.getIdentifier();
if (paramValues.containsKey(name)) return paramValues.get(name);
return null;
} else if (expr instanceof FieldAccess fa) {
String name = "this." + fa.getName().getIdentifier();
if (paramValues.containsKey(name)) return paramValues.get(name);
return null;
} else if (expr instanceof QualifiedName qn) {
return null;
} else if (expr instanceof org.eclipse.jdt.core.dom.StringLiteral sl) {
return sl.getLiteralValue();
} else if (expr instanceof MethodInvocation mi) {
if ("valueOf".equals(mi.getName().getIdentifier())) {
Expression arg = null;
if (mi.arguments().size() == 1) {
arg = (Expression) mi.arguments().get(0);
} else if (mi.arguments().size() == 2) {
arg = (Expression) mi.arguments().get(1);
}
if (arg != null) {
String resolvedArg = resolveExpressionWithParams(arg, paramValues, context);
if (resolvedArg != null && resolvedArg.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
String enumFqn = null;
if (mi.arguments().size() == 2) {
enumFqn = resolveEnumFqn((Expression) mi.arguments().get(0), context);
} else if (mi.arguments().size() == 1 && mi.getExpression() != null) {
enumFqn = resolveEnumFqn(mi.getExpression(), context);
}
if (enumFqn != null) {
return enumFqn + "." + resolvedArg;
} else {
// If we can't resolve the exact enum type here, we can just return the bare constant
return resolvedArg;
}
}
}
} else {
if (mi.arguments().isEmpty() && mi.getExpression() instanceof SimpleName sn) {
String objName = sn.getIdentifier();
String methodName = mi.getName().getIdentifier();
String propName = null;
if (methodName.startsWith("get") && methodName.length() > 3) {
propName = methodName.substring(3, 4).toLowerCase() + methodName.substring(4);
} else {
propName = methodName;
}
String val = paramValues.get(objName + "." + propName);
if (val == null && !propName.equals(methodName)) {
val = paramValues.get(objName + "." + methodName);
}
if (val != null) return val;
}
MethodDeclaration sideEffectMd = findInvokedMethod(mi, context);
if (sideEffectMd != null && sideEffectMd.getBody() != null) {
TypeDeclaration currentTd = findEnclosingType(sideEffectMd);
java.util.Map<String, String> inlineLocals = new java.util.HashMap<>();
for (java.util.Map.Entry<String, String> entry : paramValues.entrySet()) {
if (entry.getKey().startsWith("this.")) {
inlineLocals.put(entry.getKey(), entry.getValue());
} else if (context.hasField(currentTd, entry.getKey())) {
inlineLocals.put(entry.getKey(), entry.getValue());
inlineLocals.put("this." + entry.getKey(), entry.getValue());
}
}
for (int i = 0; i < mi.arguments().size() && i < sideEffectMd.parameters().size(); i++) {
Expression arg = (Expression) mi.arguments().get(i);
String val = resolveExpressionWithParams(arg, paramValues, context);
if (val == null) val = resolveInternal(arg, context, new java.util.HashSet<>());
if (val != null) {
org.eclipse.jdt.core.dom.SingleVariableDeclaration param = (org.eclipse.jdt.core.dom.SingleVariableDeclaration) sideEffectMd.parameters().get(i);
inlineLocals.put(param.getName().getIdentifier(), val);
}
}
String ret = evaluateMethodBodyWithLocals(sideEffectMd, inlineLocals, context, new java.util.HashSet<>());
for (java.util.Map.Entry<String, String> entry : inlineLocals.entrySet()) {
if (entry.getKey().startsWith("this.")) {
paramValues.put(entry.getKey(), entry.getValue());
String bareName = entry.getKey().substring(5);
paramValues.put(bareName, entry.getValue());
}
}
return ret;
}
}
}
return null;
}
private java.util.Map<String, String> extractBuilderFields(Expression expr, java.util.Map<String, String> localVars, CodebaseContext context, Set<String> visited) {
if (!(expr instanceof MethodInvocation mi)) return null;
java.util.Map<String, String> fields = new java.util.HashMap<>();
MethodInvocation current = mi;
while (current != null) {
String methodName = current.getName().getIdentifier();
if (!"build".equals(methodName) && !"builder".equals(methodName) && !"toBuilder".equals(methodName)) {
if (current.arguments().size() == 1) {
Expression arg = (Expression) current.arguments().get(0);
String val = resolveExpressionWithParams(arg, localVars, context);
if (val == null) val = resolveInternal(arg, context, visited);
if (val != null) fields.put(methodName, val);
}
}
if (current.getExpression() instanceof MethodInvocation next) {
current = next;
} else {
break;
}
}
return fields.isEmpty() ? null : fields;
}
private String resolveInfix(InfixExpression expr, CodebaseContext context, Set<String> visited) { private String resolveInfix(InfixExpression expr, CodebaseContext context, Set<String> visited) {
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null; if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
@@ -77,6 +691,32 @@ public class ConstantResolver {
} }
private String resolveManual(SimpleName sn, CodebaseContext context, Set<String> visited) { private String resolveManual(SimpleName sn, CodebaseContext context, Set<String> visited) {
MethodDeclaration enclosingMethod = findEnclosingMethod(sn);
if (enclosingMethod != null) {
for (Object paramObj : enclosingMethod.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(sn.getIdentifier())) {
return null;
}
}
}
if (enclosingMethod.getBody() != null) {
boolean[] isLocal = {false};
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(sn.getIdentifier())) {
isLocal[0] = true;
}
return super.visit(node);
}
});
if (isLocal[0]) {
return null;
}
}
}
TypeDeclaration td = findEnclosingType(sn); TypeDeclaration td = findEnclosingType(sn);
if (td != null) { if (td != null) {
String fqn = context.getFqn(td); String fqn = context.getFqn(td);
@@ -136,11 +776,72 @@ public class ConstantResolver {
} }
} }
} }
// Check constructors for assignments
for (MethodDeclaration md : td.getMethods()) {
if (md.isConstructor() && md.getBody() != null) {
for (Object stmtObj : md.getBody().statements()) {
if (stmtObj instanceof ExpressionStatement es) {
if (es.getExpression() instanceof Assignment assignment) {
Expression lhs = assignment.getLeftHandSide();
boolean matches = false;
if (lhs instanceof SimpleName snLhs && snLhs.getIdentifier().equals(fieldName)) {
matches = true;
} else if (lhs instanceof FieldAccess fa && fa.getName().getIdentifier().equals(fieldName) && fa.getExpression() instanceof ThisExpression) {
matches = true;
}
if (matches) {
return resolveInternal(assignment.getRightHandSide(), context, visited);
}
}
}
}
}
}
// Check superclass
if (td.getSuperclassType() != null) {
String superclassName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(td.getSuperclassType());
String resolvedSuperclass = resolveTypeFqn(superclassName, context, td);
if (resolvedSuperclass != null) {
TypeDeclaration superTd = context.getTypeDeclaration(resolvedSuperclass);
if (superTd != null) {
String result = resolveFieldInType(superTd, fieldName, resolvedSuperclass, context, visited);
if (result != null) return result;
}
}
}
// Check superinterfaces
for (Object intfObj : td.superInterfaceTypes()) {
Type intfType = (Type) intfObj;
String intfName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(intfType);
String resolvedIntf = resolveTypeFqn(intfName, context, td);
if (resolvedIntf != null) {
TypeDeclaration intfTd = context.getTypeDeclaration(resolvedIntf);
if (intfTd != null) {
String result = resolveFieldInType(intfTd, fieldName, resolvedIntf, context, visited);
if (result != null) return result;
}
}
}
} finally { } finally {
visited.remove(fieldId); visited.remove(fieldId);
} }
return null; return null;
} }
private String resolveTypeFqn(String simpleName, CodebaseContext context, TypeDeclaration td) {
CompilationUnit cu = (CompilationUnit) td.getRoot();
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String name = imp.getName().getFullyQualifiedName();
if (name.endsWith("." + simpleName)) {
return name;
}
}
return cu.getPackage().getName().getFullyQualifiedName() + "." + simpleName;
}
private String findValueFromAnnotation(FieldDeclaration fd) { private String findValueFromAnnotation(FieldDeclaration fd) {
for (Object mod : fd.modifiers()) { for (Object mod : fd.modifiers()) {
@@ -175,10 +876,150 @@ public class ConstantResolver {
} }
private TypeDeclaration findEnclosingType(ASTNode node) { private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode current = node;
while (current != null && !(current instanceof TypeDeclaration)) {
current = current.getParent();
}
return (TypeDeclaration) current;
}
private MethodDeclaration findInvokedMethod(org.eclipse.jdt.core.dom.MethodInvocation mi, CodebaseContext context) {
org.eclipse.jdt.core.dom.IMethodBinding methodBinding = mi.resolveMethodBinding();
if (methodBinding != null) {
org.eclipse.jdt.core.dom.ITypeBinding declaringClass = methodBinding.getDeclaringClass();
if (declaringClass != null) {
TypeDeclaration targetTd = context.getTypeDeclaration(declaringClass.getErasure().getQualifiedName());
if (targetTd != null) {
return context.findMethodDeclaration(targetTd, mi.getName().getIdentifier(), false);
}
}
}
TypeDeclaration currentTd = findEnclosingType(mi);
if (currentTd != null && (mi.getExpression() == null || mi.getExpression() instanceof org.eclipse.jdt.core.dom.ThisExpression)) {
return context.findMethodDeclaration(currentTd, mi.getName().getIdentifier(), true);
}
return null;
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent(); ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) { while (parent != null) {
if (parent instanceof MethodDeclaration md) {
return md;
}
parent = parent.getParent(); parent = parent.getParent();
} }
return (TypeDeclaration) parent; return null;
}
private String resolveLocalType(SimpleName sn) {
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(sn);
if (enclosingMethod != null && enclosingMethod.getBody() != null) {
String[] typeName = new String[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
typeName[0] = node.getType().toString();
}
}
return super.visit(node);
}
});
if (typeName[0] != null) return typeName[0];
}
TypeDeclaration enclosingType = findEnclosingType(sn);
if (enclosingType != null) {
for (FieldDeclaration field : enclosingType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return field.getType().toString();
}
}
}
}
return null;
}
private String resolveEnumFqn(Expression expr, CodebaseContext context) {
if (expr == null) return null;
CompilationUnit contextCu = (expr.getRoot() instanceof CompilationUnit) ? (CompilationUnit) expr.getRoot() : null;
if (expr instanceof TypeLiteral tl) {
ITypeBinding binding = tl.getType().resolveBinding();
if (binding != null) {
return binding.getQualifiedName();
}
String typeName = tl.getType().toString();
if (typeName.endsWith(".class")) {
typeName = typeName.substring(0, typeName.length() - 6);
}
String resolved = resolveTypeNameToFqn(typeName, contextCu, context);
if (resolved != null) return resolved;
return typeName;
}
ITypeBinding binding = expr.resolveTypeBinding();
if (binding != null) {
if (binding.isClass() && "java.lang.Class".equals(binding.getErasure().getQualifiedName())) {
ITypeBinding[] typeArgs = binding.getTypeArguments();
if (typeArgs.length > 0) {
return typeArgs[0].getQualifiedName();
}
}
return binding.getQualifiedName();
}
String exprStr = expr.toString();
if (exprStr.endsWith(".class")) {
exprStr = exprStr.substring(0, exprStr.length() - 6);
}
String resolved = resolveTypeNameToFqn(exprStr, contextCu, context);
if (resolved != null) return resolved;
return exprStr;
}
private String resolveTypeNameToFqn(String name, CompilationUnit contextCu, CodebaseContext context) {
if (name == null || name.isEmpty()) return null;
if (context.getTypeDeclaration(name) != null || context.getEnumValuesMap().containsKey(name)) {
return name;
}
if (contextCu != null) {
for (Object typeObj : contextCu.types()) {
if (typeObj instanceof AbstractTypeDeclaration atd) {
if (atd.getName().getIdentifier().equals(name)) {
String pkg = contextCu.getPackage() != null ? contextCu.getPackage().getName().getFullyQualifiedName() : "";
return pkg.isEmpty() ? name : pkg + "." + name;
}
}
}
for (Object impObj : contextCu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (!imp.isStatic() && !imp.isOnDemand()) {
if (impName.endsWith("." + name)) {
return impName;
}
}
}
if (contextCu.getPackage() != null) {
String pkg = contextCu.getPackage().getName().getFullyQualifiedName();
String pkgFqn = pkg + "." + name;
if (context.getEnumValuesMap().containsKey(pkgFqn) || context.getTypeDeclaration(pkgFqn) != null) {
return pkgFqn;
}
}
}
return null;
} }
} }

View File

@@ -0,0 +1,22 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.List;
public interface PolymorphicEventResolver {
/**
* Attempts to resolve a dynamically passed event variable into a set of
* concrete polymorphic event names.
*
* @param triggerPoint The original trigger point to resolve.
* @param resolvedValue The current String expression (e.g., "event.getType()" or "event")
* @param path The call path trace leading up to this variable.
* @param context The codebase context for deep type resolution.
* @return The updated TriggerPoint with polymorphic events set, or the original if unable to resolve.
*/
TriggerPoint resolvePolymorphicEvents(TriggerPoint triggerPoint, String resolvedValue, List<String> path, CodebaseContext context);
}

View File

@@ -105,25 +105,32 @@ public class PropertyResolver {
} }
private Map<String, String> loadYaml(Path path) { private Map<String, String> loadYaml(Path path) {
// Placeholder for future YAML support Map<String, String> props = new HashMap<>();
log.warn("YAML parsing not fully implemented yet for {}", path); try {
return new HashMap<>(); com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());
Map<String, Object> map = mapper.readValue(path.toFile(), new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
flattenYaml("", map, props);
} catch (IOException e) {
log.warn("Failed to load YAML from {}", path);
}
return props;
}
@SuppressWarnings("unchecked")
private void flattenYaml(String prefix, Map<String, Object> map, Map<String, String> result) {
if (map == null) return;
for (Map.Entry<String, Object> entry : map.entrySet()) {
String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey();
Object value = entry.getValue();
if (value instanceof Map) {
flattenYaml(key, (Map<String, Object>) value, result);
} else if (value != null) {
result.put(key, value.toString());
}
}
} }
public String resolveValue(String placeholder, Map<String, String> properties) { public String resolveValue(String placeholder, Map<String, String> properties) {
if (placeholder == null || !placeholder.contains("${")) return placeholder; return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(placeholder, properties);
// Very basic placeholder extraction: ${key} or ${key:default}
String content = placeholder.substring(placeholder.indexOf("${") + 2, placeholder.lastIndexOf("}"));
String key = content;
String defaultValue = null;
if (content.contains(":")) {
int colonIndex = content.indexOf(":");
key = content.substring(0, colonIndex);
defaultValue = content.substring(colonIndex + 1);
}
return properties.getOrDefault(key, defaultValue);
} }
} }

View File

@@ -18,14 +18,28 @@ public class SiblingDependencyResolver {
private static final Pattern MAVEN_DEPENDENCY = Pattern.compile("<dependency>\\s*<groupId>(.*?)</groupId>\\s*<artifactId>(.*?)</artifactId>\\s*<version>(.*?)</version>", Pattern.DOTALL); private static final Pattern MAVEN_DEPENDENCY = Pattern.compile("<dependency>\\s*<groupId>(.*?)</groupId>\\s*<artifactId>(.*?)</artifactId>\\s*<version>(.*?)</version>", Pattern.DOTALL);
public Set<Path> resolveSiblings(Path projectDir) { public Set<Path> resolveSiblings(Path projectDir) {
Set<Path> siblings = new HashSet<>(); Set<Path> allSiblings = new HashSet<>();
try { Queue<Path> queue = new LinkedList<>();
siblings.addAll(resolveGradleSiblings(projectDir)); queue.add(projectDir);
siblings.addAll(resolveMavenSiblings(projectDir));
} catch (Exception e) { while (!queue.isEmpty()) {
log.warn("Error resolving sibling dependencies for {}", projectDir, e); Path current = queue.poll();
Set<Path> currentSiblings = new HashSet<>();
try {
currentSiblings.addAll(resolveGradleSiblings(current));
currentSiblings.addAll(resolveMavenSiblings(current));
} catch (Exception e) {
log.warn("Error resolving sibling dependencies for {}", current, e);
}
for (Path sibling : currentSiblings) {
if (!allSiblings.contains(sibling) && !sibling.equals(projectDir)) {
allSiblings.add(sibling);
queue.add(sibling);
}
}
} }
return siblings; return allSiblings;
} }
private Set<Path> resolveGradleSiblings(Path projectDir) throws IOException { private Set<Path> resolveGradleSiblings(Path projectDir) throws IOException {
@@ -52,6 +66,23 @@ public class SiblingDependencyResolver {
for (String gradlePath : gradlePaths) { for (String gradlePath : gradlePaths) {
String relativePath = gradlePath.replace(':', '/'); String relativePath = gradlePath.replace(':', '/');
Path resolvedSibling = rootDir.resolve(relativePath).normalize(); Path resolvedSibling = rootDir.resolve(relativePath).normalize();
// Check for explicit projectDir mapping in settings.gradle or settings.gradle.kts
Path settingsFile = rootDir.resolve("settings.gradle");
if (!Files.exists(settingsFile)) {
settingsFile = rootDir.resolve("settings.gradle.kts");
}
if (Files.exists(settingsFile)) {
String settingsContent = Files.readString(settingsFile);
String regex = "project\\(['\"]:" + gradlePath + "['\"]\\)\\.projectDir\\s*=\\s*(?:file\\(['\"](.*?)['\"]\\)|new File\\(.*?,\\s*['\"](.*?)['\"]\\))";
Matcher settingsMatcher = Pattern.compile(regex).matcher(settingsContent);
if (settingsMatcher.find()) {
String customPath = settingsMatcher.group(1) != null ? settingsMatcher.group(1) : settingsMatcher.group(2);
resolvedSibling = rootDir.resolve(customPath).normalize();
log.info("Found custom projectDir in settings for {}: {}", gradlePath, resolvedSibling);
}
}
if (Files.exists(resolvedSibling)) { if (Files.exists(resolvedSibling)) {
log.info("Discovered local Gradle sibling dependency: {}", resolvedSibling); log.info("Discovered local Gradle sibling dependency: {}", resolvedSibling);
siblings.add(resolvedSibling); siblings.add(resolvedSibling);
@@ -117,7 +148,7 @@ public class SiblingDependencyResolver {
if (artifactId != null && targetArtifacts.contains(artifactId)) { if (artifactId != null && targetArtifacts.contains(artifactId)) {
Path siblingDir = p.getParent(); Path siblingDir = p.getParent();
if (!siblingDir.equals(projectDir)) { if (!siblingDir.equals(projectDir)) {
log.info("Discovered local Maven sibling dependency: {}", siblingDir); log.info("Discovered local Maven sibling dependency via artifact match: {}", siblingDir);
siblings.add(siblingDir); siblings.add(siblingDir);
} }
} }
@@ -128,6 +159,37 @@ public class SiblingDependencyResolver {
} }
} }
} }
// Also directly add aggregator <module> directories declared in this pom
Matcher moduleMatcher = Pattern.compile("<module>(.*?)</module>").matcher(content);
while (moduleMatcher.find()) {
String moduleName = moduleMatcher.group(1).trim();
Path moduleDir = projectDir.resolve(moduleName).normalize();
if (Files.exists(moduleDir) && Files.exists(moduleDir.resolve("pom.xml")) && !moduleDir.equals(projectDir)) {
log.info("Discovered local Maven sibling dependency via <module>: {}", moduleDir);
siblings.add(moduleDir);
}
}
// Also add parent dir if it has a pom.xml
Path parentDir = projectDir.getParent();
// Check for explicit <relativePath> in parent block
Matcher parentRelativePathMatcher = Pattern.compile("<parent>.*?<relativePath>(.*?)</relativePath>.*?</parent>", Pattern.DOTALL).matcher(content);
if (parentRelativePathMatcher.find()) {
String relPathStr = parentRelativePathMatcher.group(1).trim();
Path relPath = projectDir.resolve(relPathStr).normalize();
if (Files.isRegularFile(relPath) && relPath.getFileName().toString().equals("pom.xml")) {
parentDir = relPath.getParent();
} else if (Files.isDirectory(relPath)) {
parentDir = relPath;
}
}
if (parentDir != null && Files.exists(parentDir.resolve("pom.xml")) && !parentDir.equals(projectDir)) {
log.info("Discovered local Maven parent sibling: {}", parentDir);
siblings.add(parentDir);
}
} }
return siblings; return siblings;
} }

View File

@@ -1,253 +0,0 @@
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 click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@Slf4j
public class CallGraphBuilder {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private String currentMethodFqn;
private Map<String, List<CallEdge>> graph;
@lombok.Data
private static class CallEdge {
private final String targetMethod;
private final List<String> arguments;
}
public CallGraphBuilder(CodebaseContext context) {
this.context = context;
this.constantResolver = new ConstantResolver();
}
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
Map<String, List<CallEdge>> callGraph = buildCallGraph();
List<CallChain> chains = new ArrayList<>();
for (EntryPoint ep : entryPoints) {
String startMethod = ep.getClassName() + "." + ep.getMethodName();
for (TriggerPoint tp : triggers) {
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<String> path = findPath(startMethod, targetMethod, callGraph, new HashSet<>());
if (path != null) {
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph);
chains.add(CallChain.builder()
.entryPoint(ep)
.triggerPoint(resolvedTp)
.methodChain(path)
.build());
}
}
}
return chains;
}
private TriggerPoint resolveTriggerPointParameters(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> callGraph) {
if (path.size() < 2) return tp;
String event = tp.getEvent();
if (event == null || event.isEmpty() || event.contains("\"")) return tp;
TypeDeclaration td = context.getTypeDeclaration(tp.getClassName());
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, tp.getMethodName(), true);
if (md != null) {
int paramIndex = -1;
for (int i = 0; i < md.parameters().size(); i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) md.parameters().get(i);
if (svd.getName().getIdentifier().equals(event)) {
paramIndex = i;
break;
}
}
if (paramIndex >= 0) {
String caller = path.get(path.size() - 2);
String target = path.get(path.size() - 1);
List<CallEdge> edges = callGraph.get(caller);
if (edges != null) {
for (CallEdge edge : edges) {
if (edge.getTargetMethod().equals(target) || isHeuristicMatch(edge.getTargetMethod(), target)) {
if (paramIndex < edge.getArguments().size()) {
String resolvedValue = edge.getArguments().get(paramIndex);
if (resolvedValue != null) {
return TriggerPoint.builder()
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.lineNumber(tp.getLineNumber())
.build();
}
}
}
}
}
}
}
}
return tp;
}
private Map<String, List<CallEdge>> buildCallGraph() {
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodDeclaration node) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
currentMethodFqn = context.getFqn(td) + "." + node.getName().getIdentifier();
}
return super.visit(node);
}
@Override
public boolean visit(MethodInvocation node) {
if (currentMethodFqn != null) {
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
for (String calledMethod : calledMethods) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
if (currentMethodFqn != null) {
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);
if (superTd != null) {
String calledMethod = resolveMethodInType(superTd, methodName);
if (calledMethod != null) {
List<String> args = resolveArguments(node.arguments());
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
}
}
}
}
}
return super.visit(node);
}
});
}
return graph;
}
private List<String> resolveArguments(List<?> astArguments) {
List<String> args = new ArrayList<>();
for (Object argObj : astArguments) {
Expression expr = (Expression) argObj;
String val = constantResolver.resolve(expr, context);
if (val == null && expr instanceof SimpleName sn) {
val = sn.getIdentifier();
}
args.add(val);
}
return args;
}
private List<String> resolveCalledMethodsPolymorphic(MethodInvocation node) {
String baseCalled = resolveCalledMethod(node);
if (baseCalled == null) return Collections.emptyList();
List<String> allResolved = new ArrayList<>();
allResolved.add(baseCalled);
if (baseCalled.contains(".")) {
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1);
List<String> impls = context.getImplementations(className);
for (String impl : impls) {
allResolved.add(impl + "." + methodName);
}
}
return allResolved;
}
private String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInType(td, methodName);
}
return null;
}
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
return binding.getQualifiedName() + "." + methodName;
}
if (receiver instanceof SimpleName sn) {
String receiverName = sn.getIdentifier();
return receiverName + "." + methodName;
}
return null;
}
private String resolveMethodInType(TypeDeclaration td, String methodName) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
TypeDeclaration declaringTd = findEnclosingType(md);
return context.getFqn(declaringTd) + "." + methodName;
}
return null;
}
private List<String> findPath(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) {
if (start.equals(target)) return new ArrayList<>(List.of(start));
if (!visited.add(start)) return null;
List<CallEdge> neighbors = graph.get(start);
if (neighbors != null) {
for (CallEdge edge : neighbors) {
String neighbor = edge.getTargetMethod();
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
return new ArrayList<>(List.of(start, target));
}
List<String> path = findPath(neighbor, target, graph, visited);
if (path != null) {
path.add(0, start);
return path;
}
}
}
return null;
}
private boolean isHeuristicMatch(String neighbor, String target) {
if (target.endsWith("." + neighbor)) return true;
String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target;
String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor;
return simpleNeighbor.equals(simpleTarget);
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
}

View File

@@ -0,0 +1,15 @@
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 java.util.List;
public interface CallGraphEngine {
List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers);
default java.util.Map<String, java.util.List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> getCallGraph() {
return java.util.Collections.emptyMap();
}
}

View File

@@ -0,0 +1,276 @@
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.TypeDeclaration;
import lombok.extern.slf4j.Slf4j;
import java.util.*;
@Slf4j
public class CallGraphPathFinder {
private final CodebaseContext context;
private final Map<java.util.Map.Entry<String, String>, Boolean> heuristicCache = new HashMap<>();
private final Map<java.util.Map.Entry<String, String>, List<List<String>>> memoCache = new HashMap<>();
private final Map<java.util.Map.Entry<String, String>, Boolean> unreachableCache = new HashMap<>();
public CallGraphPathFinder(CodebaseContext context) {
this.context = context;
}
public List<String> findPath(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) {
if (start.equals(target)) return new ArrayList<>(List.of(start));
if (!visited.add(start)) {
if (log.isDebugEnabled()) {
log.debug("Cycle detected in call graph search: {} is already in visited path {}", start, visited);
}
return null; // Path-scoped cycle detection
}
List<CallEdge> neighbors = graph.get(start);
if (neighbors != null) {
for (CallEdge edge : neighbors) {
String neighbor = edge.getTargetMethod();
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
visited.remove(start);
return new ArrayList<>(List.of(start, target));
}
List<String> path = findPath(neighbor, target, graph, visited);
if (path != null) {
path.add(0, start);
visited.remove(start);
return path;
}
}
}
if (log.isDebugEnabled()) {
log.debug("Path search dead-end at {} when looking for {}", start, target);
}
visited.remove(start);
return null;
}
public List<List<String>> findAllPaths(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) {
return findAllPaths(start, target, graph, visited, new boolean[]{false});
}
public List<List<String>> findAllPaths(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited, boolean[] parentHitCycle) {
List<List<String>> result = new ArrayList<>();
java.util.Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(start, target);
if (unreachableCache.containsKey(cacheKey)) {
return result;
}
if (memoCache.containsKey(cacheKey)) {
List<List<String>> cached = memoCache.get(cacheKey);
for (List<String> path : cached) {
boolean hasCycle = false;
for (String node : path) {
if (visited.contains(node)) {
hasCycle = true;
parentHitCycle[0] = true;
break;
}
}
if (!hasCycle) {
result.add(new ArrayList<>(path));
}
}
return result;
}
if (start.equals(target)) {
result.add(new ArrayList<>(List.of(start)));
return result;
}
if (!visited.add(start)) {
parentHitCycle[0] = true;
return result;
}
boolean[] localHitCycle = new boolean[]{false};
List<CallEdge> neighbors = graph.get(start);
if (neighbors != null) {
for (CallEdge edge : neighbors) {
String neighbor = edge.getTargetMethod();
if (neighbor.startsWith("java.") || neighbor.startsWith("javax.") || neighbor.startsWith("jakarta.") || neighbor.startsWith("org.springframework.")) continue;
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
List<String> path = new ArrayList<>(List.of(start, target));
result.add(path);
} else {
List<List<String>> subPaths = findAllPaths(neighbor, target, graph, visited, localHitCycle);
for (List<String> subPath : subPaths) {
List<String> path = new ArrayList<>();
path.add(start);
path.addAll(subPath);
result.add(path);
}
}
}
}
// Handle inheritance fallback for concrete class methods not in the graph
if (result.isEmpty() && start.contains(".")) {
String className = start.substring(0, start.lastIndexOf('.'));
String methodName = start.substring(start.lastIndexOf('.') + 1);
if (className.contains("<")) {
className = className.substring(0, className.indexOf('<'));
}
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
String superclass = context.getSuperclassFqn(td);
if (superclass != null) {
String superMethod = superclass + "." + methodName;
if (graph.containsKey(superMethod)) {
List<List<String>> superPaths = findAllPaths(superMethod, target, graph, visited, localHitCycle);
for (List<String> superPath : superPaths) {
List<String> path = new ArrayList<>();
path.add(start);
path.addAll(superPath);
result.add(path);
}
}
}
}
}
// Handle implementation fallback for interfaces/superclasses (interface -> concrete class)
if (result.isEmpty() && start.contains(".")) {
String className = start.substring(0, start.lastIndexOf('.'));
String methodName = start.substring(start.lastIndexOf('.') + 1);
if (className.contains("<")) {
className = className.substring(0, className.indexOf('<'));
}
List<String> impls = context.getImplementations(className);
if (impls != null) {
for (String impl : impls) {
String implMethod = impl + "." + methodName;
if (graph.containsKey(implMethod)) {
List<List<String>> implPaths = findAllPaths(implMethod, target, graph, visited, localHitCycle);
for (List<String> implPath : implPaths) {
List<String> path = new ArrayList<>();
path.add(start);
path.addAll(implPath);
result.add(path);
}
}
}
}
}
visited.remove(start);
if (localHitCycle[0]) {
parentHitCycle[0] = true;
} else if (result.isEmpty()) {
unreachableCache.put(cacheKey, true);
} else {
List<List<String>> toCache = new ArrayList<>();
for (List<String> path : result) {
toCache.add(new ArrayList<>(path));
}
memoCache.put(cacheKey, toCache);
}
return result;
}
public String extractContextMachineId(List<String> path, Map<String, List<CallEdge>> callGraph) {
for (String node : path) {
List<CallEdge> edges = callGraph.get(node);
if (edges != null) {
for (CallEdge edge : edges) {
String target = edge.getTargetMethod();
if (target != null && (target.contains(".restore") || target.contains(".read"))) {
if (edge.getArguments().size() >= 2) {
return edge.getArguments().get(1); // The contextObj / machineId
}
}
}
}
}
return null;
}
public boolean isHeuristicMatch(String neighbor, String target) {
if (neighbor == null || target == null) return false;
if (neighbor.equals(target)) return true;
java.util.Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(neighbor, target);
Boolean cached = heuristicCache.get(cacheKey);
if (cached != null) return cached;
boolean result = calculateHeuristicMatch(neighbor, target);
heuristicCache.put(cacheKey, result);
return result;
}
private boolean calculateHeuristicMatch(String neighbor, String target) {
if (neighbor.equals(target)) return true;
if (isFullyQualifiedMethod(neighbor) && isFullyQualifiedMethod(target)) {
int lastDotNeighbor = neighbor.lastIndexOf('.');
int lastDotTarget = target.lastIndexOf('.');
if (lastDotNeighbor < 0 || lastDotTarget < 0) return false;
String methodNeighbor = neighbor.substring(lastDotNeighbor + 1);
String methodTarget = target.substring(lastDotTarget + 1);
if (!methodNeighbor.equals(methodTarget)) {
return false;
}
String classNeighbor = neighbor.substring(0, lastDotNeighbor);
String classTarget = target.substring(0, lastDotTarget);
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;
List<String> impls = context.getImplementations(classTarget);
if (impls != null) {
for (String impl : impls) {
if (impl.equals(classNeighbor) || impl.endsWith("." + classNeighbor)) return true;
}
}
List<String> implsN = context.getImplementations(classNeighbor);
if (implsN != null) {
for (String impl : implsN) {
if (impl.equals(classTarget) || impl.endsWith("." + classTarget)) return true;
}
}
return false;
}
String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target;
String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor;
if (!simpleNeighbor.equals(simpleTarget)) return false;
String classNeighbor = neighbor.contains(".") ? neighbor.substring(0, neighbor.lastIndexOf('.')) : null;
String classTarget = target.contains(".") ? target.substring(0, target.lastIndexOf('.')) : null;
String simpleClassTarget = classTarget != null && classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
if (simpleClassNeighbor != null && simpleClassTarget != null) {
if (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;
}
private boolean isFullyQualifiedMethod(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) return false;
int lastDot = methodFqn.lastIndexOf('.');
String classPart = methodFqn.substring(0, lastDot);
if (classPart.contains(".")) return true;
if (!classPart.isEmpty() && Character.isUpperCase(classPart.charAt(0))) return true;
return false;
}
}

View File

@@ -0,0 +1,118 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import java.util.*;
public class CompositeCallGraphEngine implements CallGraphEngine {
private final List<CallGraphEngine> engines;
private final Map<String, List<CallEdge>> mergedGraph = new HashMap<>();
public CompositeCallGraphEngine(List<CallGraphEngine> engines) {
this.engines = engines;
mergeGraphs();
}
private void mergeGraphs() {
for (CallGraphEngine engine : engines) {
Map<String, List<CallEdge>> graph = engine.getCallGraph();
if (graph != null) {
for (Map.Entry<String, List<CallEdge>> entry : graph.entrySet()) {
String caller = normalizeCompanionFqn(entry.getKey());
List<CallEdge> edges = mergedGraph.computeIfAbsent(caller, k -> new ArrayList<>());
for (CallEdge edge : entry.getValue()) {
String target = normalizeCompanionFqn(edge.getTargetMethod());
edges.add(new CallEdge(target, edge.getArguments(), edge.getReceiver()));
}
}
}
}
}
private String normalizeCompanionFqn(String fqn) {
if (fqn == null) return null;
return fqn.replace(".Companion.", ".");
}
@Override
public Map<String, List<CallEdge>> getCallGraph() {
return mergedGraph;
}
@Override
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
List<CallChain> chains = new ArrayList<>();
for (EntryPoint ep : entryPoints) {
String startMethod = normalizeCompanionFqn(ep.getClassName() + "." + ep.getMethodName());
for (TriggerPoint tp : triggers) {
String targetMethod = normalizeCompanionFqn(tp.getClassName() + "." + tp.getMethodName());
List<List<String>> paths = findAllPaths(startMethod, targetMethod, new LinkedHashSet<>());
for (List<String> path : paths) {
chains.add(CallChain.builder()
.entryPoint(ep)
.methodChain(path)
.triggerPoint(tp)
.build());
}
}
}
return chains;
}
private List<List<String>> findAllPaths(String start, String target, Set<String> visited) {
List<List<String>> result = new ArrayList<>();
if (start.equals(target)) {
result.add(new ArrayList<>(List.of(start)));
return result;
}
if (!visited.add(start)) {
return result;
}
List<CallEdge> neighbors = mergedGraph.get(start);
if (neighbors != null) {
for (CallEdge edge : neighbors) {
String neighbor = edge.getTargetMethod();
if (neighbor.startsWith("java.") || neighbor.startsWith("javax.") ||
neighbor.startsWith("jakarta.") || neighbor.startsWith("org.springframework.")) {
continue;
}
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
List<String> path = new ArrayList<>(List.of(start, target));
result.add(path);
} else {
List<List<String>> subPaths = findAllPaths(neighbor, target, visited);
for (List<String> subPath : subPaths) {
List<String> path = new ArrayList<>();
path.add(start);
path.addAll(subPath);
result.add(path);
}
}
}
}
visited.remove(start);
return result;
}
private boolean isHeuristicMatch(String neighbor, String target) {
if (neighbor == null || target == null) return false;
if (neighbor.equals(target)) return true;
if (neighbor.contains(".") && target.contains(".")) {
String methodNeighbor = neighbor.substring(neighbor.lastIndexOf('.') + 1);
String methodTarget = target.substring(target.lastIndexOf('.') + 1);
if (methodNeighbor.equals(methodTarget)) {
String classNeighbor = neighbor.substring(0, neighbor.lastIndexOf('.'));
String classTarget = target.substring(0, target.lastIndexOf('.'));
String simpleClassNeighbor = classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
String simpleClassTarget = classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
return simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget);
}
}
return false;
}
}

View File

@@ -0,0 +1,53 @@
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 java.util.*;
public class CompositeIntelligenceProvider implements CodebaseIntelligenceProvider {
private final List<CodebaseIntelligenceProvider> providers;
private final List<CallGraphEngine> engines;
public CompositeIntelligenceProvider(List<CodebaseIntelligenceProvider> providers, List<CallGraphEngine> engines) {
this.providers = providers;
this.engines = engines;
}
@Override
public List<TriggerPoint> findTriggerPoints() {
List<TriggerPoint> result = new ArrayList<>();
for (CodebaseIntelligenceProvider provider : providers) {
result.addAll(provider.findTriggerPoints());
}
return result;
}
@Override
public List<EntryPoint> findEntryPoints() {
List<EntryPoint> result = new ArrayList<>();
for (CodebaseIntelligenceProvider provider : providers) {
result.addAll(provider.findEntryPoints());
}
return result;
}
@Override
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
CompositeCallGraphEngine compositeEngine = new CompositeCallGraphEngine(engines);
return compositeEngine.findChains(entryPoints, triggers);
}
@Override
public Map<String, Map<String, String>> resolveProperties() {
Map<String, Map<String, String>> result = new HashMap<>();
for (CodebaseIntelligenceProvider provider : providers) {
Map<String, Map<String, String>> props = provider.resolveProperties();
if (props != null) {
result.putAll(props);
}
}
return result;
}
}

View File

@@ -0,0 +1,420 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import java.util.function.Function;
@Slf4j
public class ConstantExtractor {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private final Function<MethodInvocation, String> calledMethodResolver;
private VariableTracer variableTracer;
private ConstructorAnalyzer constructorAnalyzer;
public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function<MethodInvocation, String> calledMethodResolver) {
this.context = context;
this.constantResolver = constantResolver;
this.calledMethodResolver = calledMethodResolver;
}
public void setVariableTracer(VariableTracer variableTracer) {
this.variableTracer = variableTracer;
}
public void setConstructorAnalyzer(ConstructorAnalyzer constructorAnalyzer) {
this.constructorAnalyzer = constructorAnalyzer;
}
public void extractConstantsFromExpression(Expression expr, List<String> constants) {
if (constantResolver != null) {
String resolved = constantResolver.resolve(expr, context);
if (resolved != null) {
if (resolved.startsWith("ENUM_SET:")) {
for (String eVal : resolved.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal);
if (!constants.contains(parsed)) constants.add(parsed);
}
} else {
constants.add(resolved);
}
return;
}
}
if (expr instanceof QualifiedName qn) {
constants.add(qn.toString());
} else if (expr instanceof StringLiteral sl) {
constants.add(sl.getLiteralValue());
} else if (expr instanceof ConditionalExpression ce) {
extractConstantsFromExpression(ce.getThenExpression(), constants);
extractConstantsFromExpression(ce.getElseExpression(), constants);
} else if (expr instanceof ParenthesizedExpression pe) {
extractConstantsFromExpression(pe.getExpression(), constants);
} else if (expr instanceof CastExpression ce) {
extractConstantsFromExpression(ce.getExpression(), constants);
} else if (expr instanceof Assignment assignment) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
} else if (expr instanceof SwitchExpression se) {
se.accept(new ASTVisitor() {
@Override
public boolean visit(YieldStatement ys) {
extractConstantsFromExpression(ys.getExpression(), constants);
return super.visit(ys);
}
@Override
public boolean visit(ExpressionStatement es) {
extractConstantsFromExpression(es.getExpression(), constants);
return super.visit(es);
}
@Override
public boolean visit(ReturnStatement rs) {
extractConstantsFromExpression(rs.getExpression(), constants);
return super.visit(rs);
}
});
} else if (expr instanceof ArrayAccess aa) {
extractConstantsFromExpression(aa.getArray(), constants);
} else if (expr instanceof ArrayCreation ac && ac.getInitializer() != null) {
for (Object expObj : ac.getInitializer().expressions()) {
extractConstantsFromArgument((Expression) expObj, constants);
}
} else if (expr instanceof ArrayInitializer ai) {
for (Object expObj : ai.expressions()) {
extractConstantsFromArgument((Expression) expObj, constants);
}
} else if (expr instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier();
log.trace("Processing mi: {}", mi);
if (methodName.equals("get") || methodName.equals("getOrDefault")) {
if (mi.getExpression() != null) {
if (variableTracer != null) {
List<Expression> traced = variableTracer.traceVariableAll(mi.getExpression());
for (Expression t : traced) {
extractConstantsFromExpression(t, constants);
}
} else {
extractConstantsFromExpression(mi.getExpression(), constants);
}
}
return;
}
if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") ||
methodName.equals("just") || methodName.equals("withPayload") || methodName.equals("success")) {
for (Object argObj : mi.arguments()) {
extractConstantsFromExpression((Expression) argObj, constants);
}
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);
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<>();
if (mi.getExpression() != null) {
if (mi.getExpression() instanceof ClassInstanceCreation) {
receivers.add(mi.getExpression());
} else if (variableTracer != null) {
receivers.addAll(variableTracer.traceVariableAll(mi.getExpression()));
}
}
for (Expression receiverExpr : receivers) {
if (receiverExpr instanceof ClassInstanceCreation cic) {
if (cic.getAnonymousClassDeclaration() != null) {
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
if (declObj instanceof MethodDeclaration mdecl) {
if (mdecl.getName().getIdentifier().equals(methodName) && mdecl.getBody() != null) {
mdecl.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement rs) {
if (rs.getExpression() != null) {
extractConstantsFromExpression(rs.getExpression(), constants);
}
return super.visit(rs);
}
});
}
}
}
} else if (constructorAnalyzer != null) {
boolean handledByLombok = false;
if (methodName.startsWith("get") && methodName.length() > 3) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
TypeDeclaration typeDecl = context.getTypeDeclaration(typeName);
if (typeDecl != null) {
String fieldName = Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
int fieldIndex = constructorAnalyzer.findConstructorArgumentIndexForLombokField(typeDecl, fieldName);
if (fieldIndex >= 0 && fieldIndex < cic.arguments().size()) {
extractConstantsFromArgument((Expression) cic.arguments().get(fieldIndex), constants);
handledByLombok = true;
}
}
}
if (!handledByLombok && methodName != null && !methodName.isEmpty()) {
List<String> narrowed = constructorAnalyzer.resolveInlineInstantiationGetterResult(
cic, methodName, context, new HashSet<>());
if (narrowed.isEmpty()) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
CompilationUnit contextCu = expr.getRoot() instanceof CompilationUnit ? (CompilationUnit) expr.getRoot() : null;
TypeDeclaration typeDecl = contextCu != null ? context.getTypeDeclaration(typeName, contextCu) : context.getTypeDeclaration(typeName);
if (typeDecl == null) typeDecl = context.getTypeDeclaration(typeName);
if (typeDecl != null) {
narrowed = resolveMethodReturnConstant(context.getFqn(typeDecl), methodName, 0, new HashSet<>(), contextCu);
}
}
if (narrowed != null && !narrowed.isEmpty()) {
constants.addAll(narrowed);
}
}
}
}
}
Expression currentMi = mi;
while (currentMi instanceof MethodInvocation chainMi) {
String chainName = chainMi.getName().getIdentifier();
if (chainName.equals("setHeader") && chainMi.arguments().size() == 2) {
extractConstantsFromExpression((Expression) chainMi.arguments().get(1), constants);
} else if ((chainName.equalsIgnoreCase("event") || chainName.equalsIgnoreCase("type") || chainName.equalsIgnoreCase("eventType")) && chainMi.arguments().size() == 1) {
extractConstantsFromExpression((Expression) chainMi.arguments().get(0), constants);
}
currentMi = chainMi.getExpression();
}
TypeDeclaration td = findEnclosingType(mi);
if (td != null && (mi.getExpression() == null || mi.getExpression() instanceof ThisExpression)) {
List<String> values = resolveMethodReturnConstant(context.getFqn(td), methodName, 0, new HashSet<>(), null);
if (values != null) constants.addAll(values);
}
}
}
public void extractConstantsFromArgument(Expression argObj, List<String> constants) {
if (argObj instanceof ClassInstanceCreation innerCic) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(innerCic.getType());
if ("String".equals(typeName)) {
extractConstantsFromExpression(argObj, constants);
}
} else {
extractConstantsFromExpression(argObj, constants);
}
}
public List<String> resolveClassConstantReturns(String className, CodebaseContext context, CompilationUnit contextCu) {
if (className != null && className.contains(".")) {
String prefix = className.substring(0, className.lastIndexOf('.'));
String suffix = className.substring(className.lastIndexOf('.') + 1);
TypeDeclaration prefixTd = contextCu != null ? context.getTypeDeclaration(prefix, contextCu) : context.getTypeDeclaration(prefix);
if (prefixTd == null) prefixTd = context.getTypeDeclaration(prefix);
boolean isKnownType = prefixTd != null || context.getEnumValues(prefix) != null;
if (isKnownType) {
String simplePrefix = prefix.contains(".") ? prefix.substring(prefix.lastIndexOf('.') + 1) : prefix;
return Collections.singletonList(simplePrefix + "." + suffix);
}
}
TypeDeclaration td = contextCu != null ? context.getTypeDeclaration(className, contextCu) : context.getTypeDeclaration(className);
if (td == null) td = context.getTypeDeclaration(className);
if (td == null) return null;
final List<String> resolvedConstants = new ArrayList<>();
for (MethodDeclaration md : td.getMethods()) {
if (md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
public boolean visit(ReturnStatement node) {
if (variableTracer != null) {
List<Expression> tracedReturns = variableTracer.traceVariableAll(node.getExpression());
for (Expression tracedRet : tracedReturns) {
extractConstantsFromExpression(tracedRet, resolvedConstants);
}
}
return super.visit(node);
}
});
}
if (!resolvedConstants.isEmpty()) {
return resolvedConstants;
}
}
return null;
}
public List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) {
log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName);
log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth);
if (depth > 20) {
log.debug("Exiting resolveMethodReturnConstant early (depth limit) for {}.{}", className, methodName);
return Collections.emptyList();
}
String fqn = className + "." + methodName;
if (!visited.add(fqn)) {
log.debug("Exiting resolveMethodReturnConstant early (cycle detected) for fqn={}", fqn);
return Collections.emptyList();
}
List<String> constants = new ArrayList<>();
TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null;
if (tempTd == null) {
tempTd = context.getTypeDeclaration(className);
}
final TypeDeclaration td = tempTd;
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
Expression retExpr = node.getExpression();
if (retExpr != null) {
boolean handled = false;
if (retExpr instanceof MethodInvocation mi && calledMethodResolver != null) {
String called = calledMethodResolver.apply(mi);
if (called != null && called.contains(".")) {
if (visited.contains(called)) {
handled = true;
} else {
String cName = called.substring(0, called.lastIndexOf('.'));
String mName = called.substring(called.lastIndexOf('.') + 1);
TypeDeclaration targetTd = contextCu != null ? context.getTypeDeclaration(cName, contextCu) : context.getTypeDeclaration(cName);
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
handled = true;
}
}
}
} else if (retExpr instanceof SuperMethodInvocation smi) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
String called = superFqn + "." + smi.getName().getIdentifier();
if (visited.contains(called)) {
handled = true;
} else {
String cName = superFqn;
String mName = smi.getName().getIdentifier();
TypeDeclaration targetTd = contextCu != null ? context.getTypeDeclaration(cName, contextCu) : context.getTypeDeclaration(cName);
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
handled = true;
}
}
}
}
if (!handled && variableTracer != null && constructorAnalyzer != null) {
List<Expression> tracedRetExprs = variableTracer.traceVariableAll(retExpr);
if (tracedRetExprs.isEmpty() && retExpr != null) {
tracedRetExprs = List.of(retExpr);
}
for (Expression tracedRetExpr : tracedRetExprs) {
extractConstantsFromExpression(tracedRetExpr, constants);
String val = constantResolver.resolve(tracedRetExpr, context);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal);
if (!constants.contains(parsed)) constants.add(parsed);
}
} else {
if (!constants.contains(val)) constants.add(val);
}
} else if (tracedRetExpr instanceof SimpleName sn) {
List<String> consts = constructorAnalyzer.traceFieldInConstructors(td, sn.getIdentifier(), context, visited);
if (!consts.isEmpty()) {
constants.addAll(consts);
}
} else if (tracedRetExpr instanceof FieldAccess fa) {
List<String> consts = constructorAnalyzer.traceFieldInConstructors(td, fa.getName().getIdentifier(), context, visited);
if (!consts.isEmpty()) {
constants.addAll(consts);
}
}
}
}
}
return super.visit(node);
}
});
} else {
List<String> impls = context.getImplementations(className);
if (impls != null) {
for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant(implName, methodName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
}
}
}
}
visited.remove(fqn);
log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants);
log.debug("resolveMethodReturnConstant for class={}, method={} resolved constants: {}", className, methodName, constants);
return constants;
}
public String parseEnumSetElement(String eVal) {
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
}
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) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
}

View File

@@ -0,0 +1,793 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ConstructorAnalyzer {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private VariableTracer variableTracer;
private ConstantExtractor constantExtractor;
@FunctionalInterface
public interface RhsResolver {
String resolve(Expression rhs, Map<String, String> paramValues, TypeDeclaration td);
}
public ConstructorAnalyzer(CodebaseContext context, ConstantResolver constantResolver) {
this.context = context;
this.constantResolver = constantResolver;
}
public void setVariableTracer(VariableTracer variableTracer) {
this.variableTracer = variableTracer;
}
public void setConstantExtractor(ConstantExtractor constantExtractor) {
this.constantExtractor = constantExtractor;
}
public List<String> traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set<String> visited) {
if (td == null || fieldName == null) return Collections.emptyList();
String fqn = context.getFqn(td);
if (fqn == null) return Collections.emptyList();
String cycleKey = fqn + "#" + fieldName;
if (!visited.add(cycleKey)) {
return Collections.emptyList();
}
List<String> results = new ArrayList<>();
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName) && frag.getInitializer() != null) {
Expression right = frag.getInitializer();
if (variableTracer != null && constantExtractor != null) {
List<Expression> tracedRights = variableTracer.traceVariableAll(right);
for (Expression tracedRight : tracedRights) {
if (tracedRight instanceof MethodInvocation mi) {
String calledName = mi.getName().getIdentifier();
String targetClass = context.getFqn(td);
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
if (mi.getExpression() == null) {
TypeDeclaration targetTd = context.resolveStaticImport(calledName, cu);
if (targetTd != null) {
targetClass = context.getFqn(targetTd);
}
}
results.addAll(constantExtractor.resolveMethodReturnConstant(targetClass, calledName, 0, visited, cu));
} else {
String val = constantResolver.resolve(tracedRight, context);
if (val != null) results.add(val);
}
}
}
}
}
}
ASTVisitor assignmentVisitor = new ASTVisitor() {
@Override
public boolean visit(Assignment node) {
Expression left = node.getLeftHandSide();
if ((left instanceof SimpleName && ((SimpleName) left).getIdentifier().equals(fieldName)) ||
(left instanceof FieldAccess && ((FieldAccess) left).getName().getIdentifier().equals(fieldName))) {
Expression right = node.getRightHandSide();
if (variableTracer != null && constantExtractor != null) {
List<Expression> tracedRights = variableTracer.traceVariableAll(right);
for (Expression tracedRight : tracedRights) {
if (tracedRight instanceof MethodInvocation mi) {
String calledName = mi.getName().getIdentifier();
String targetClass = context.getFqn(td);
if (mi.getExpression() != null && mi.resolveMethodBinding() != null && mi.resolveMethodBinding().getDeclaringClass() != null) {
targetClass = mi.resolveMethodBinding().getDeclaringClass().getQualifiedName();
} else if (mi.getExpression() instanceof SimpleName) {
String exprName = ((SimpleName) mi.getExpression()).getIdentifier();
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
TypeDeclaration targetTd = context.resolveStaticImport(exprName, cu);
if (targetTd != null) {
targetClass = context.getFqn(targetTd);
}
} else if (mi.getExpression() == null) {
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
TypeDeclaration targetTd = context.resolveStaticImport(calledName, cu);
if (targetTd != null) {
targetClass = context.getFqn(targetTd);
}
}
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
results.addAll(constantExtractor.resolveMethodReturnConstant(targetClass, calledName, 0, visited, cu));
} else {
String val = constantResolver.resolve(tracedRight, context);
if (val != null) results.add(val);
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(ConstructorInvocation node) {
processConstructorInvocationArgs(node.arguments(), findEnclosingType(node), node, results, fieldName, context, visited);
return super.visit(node);
}
@Override
public boolean visit(SuperConstructorInvocation node) {
TypeDeclaration enclosingTd = findEnclosingType(node);
if (enclosingTd != null) {
String superFqn = context.getSuperclassFqn(enclosingTd);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
processConstructorInvocationArgs(node.arguments(), superTd, node, results, fieldName, context, visited);
}
}
return super.visit(node);
}
};
for (MethodDeclaration md : td.getMethods()) {
if (md.isConstructor() && md.getBody() != null) {
md.getBody().accept(assignmentVisitor);
}
}
for (Object bodyDecl : td.bodyDeclarations()) {
if (bodyDecl instanceof Initializer init && init.getBody() != null) {
init.getBody().accept(assignmentVisitor);
}
}
if (results.isEmpty()) {
// Context-Aware Fallback (Up): Check superclass constructors
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null && !superFqn.equals("java.lang.Object")) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
results.addAll(traceFieldInConstructors(superTd, fieldName, context, visited));
}
}
}
if (results.isEmpty()) {
// Context-Aware Fallback (Down): Check subclasses/implementations
List<String> impls = context.getImplementations(fqn);
if (impls != null) {
for (String implFqn : impls) {
TypeDeclaration implTd = context.getTypeDeclaration(implFqn);
if (implTd != null) {
results.addAll(traceFieldInConstructors(implTd, fieldName, context, visited));
}
}
}
}
if (results.isEmpty()) {
// Global Fallback: Search all parsed classes in the workspace
Collection<TypeDeclaration> allTypes = context.getTypeDeclarations();
if (allTypes != null) {
int matchedCount = 0;
for (TypeDeclaration typeDecl : allTypes) {
if (typeDecl == null) continue;
if (context.hasField(typeDecl, fieldName)) {
results.addAll(traceFieldInConstructors(typeDecl, fieldName, context, visited));
matchedCount++;
if (matchedCount >= 10) {
break;
}
}
}
}
}
visited.remove(cycleKey);
return results;
}
public Map<String, String> buildFieldValuesFromCIC(
TypeDeclaration td,
ClassInstanceCreation cic,
CodebaseContext context,
RhsResolver rhsResolver,
Map<String, String> initialLocals) {
String actualTypeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
TypeDeclaration actualTd = context.getTypeDeclaration(actualTypeName);
if (actualTd == null) {
actualTd = td;
}
Map<String, String> fieldValues = new HashMap<>();
Set<String> visitedCtors = new HashSet<>();
buildFieldValuesFromCICRecursive(actualTd, cic, context, fieldValues, visitedCtors, rhsResolver, initialLocals);
return fieldValues;
}
public Map<String, String> buildFieldValuesFromCIC(
TypeDeclaration td,
ClassInstanceCreation cic,
CodebaseContext context,
RhsResolver rhsResolver) {
return buildFieldValuesFromCIC(td, cic, context, rhsResolver, null);
}
public Map<String, String> buildFieldValuesFromCIC(
TypeDeclaration td,
ClassInstanceCreation cic,
CodebaseContext context) {
return buildFieldValuesFromCIC(td, cic, context, null, null);
}
private void buildFieldValuesFromCICRecursive(
TypeDeclaration td,
ClassInstanceCreation cic,
CodebaseContext context,
Map<String, String> fieldValues,
Set<String> visitedCtors,
RhsResolver rhsResolver,
Map<String, String> initialLocals) {
String tdKey = context.getFqn(td);
if (!visitedCtors.add(tdKey)) return;
IMethodBinding resolvedCtor = cic.resolveConstructorBinding();
for (MethodDeclaration ctor : td.getMethods()) {
if (!ctor.isConstructor() || ctor.getBody() == null) continue;
boolean matches = false;
if (resolvedCtor != null && ctor.resolveBinding() != null) {
matches = resolvedCtor.isEqualTo(ctor.resolveBinding()) || resolvedCtor.getKey().equals(ctor.resolveBinding().getKey());
}
if (!matches) {
matches = ctor.parameters().size() == cic.arguments().size();
}
if (!matches) continue;
Map<String, String> paramValues = new HashMap<>();
for (int i = 0; i < ctor.parameters().size(); i++) {
String pName = ((SingleVariableDeclaration) ctor.parameters().get(i)).getName().getIdentifier();
Expression argExpr = (Expression) cic.arguments().get(i);
List<String> consts = new ArrayList<>();
if (constantExtractor != null) {
constantExtractor.extractConstantsFromArgument(argExpr, consts);
}
if (!consts.isEmpty()) {
paramValues.put(pName, consts.get(0));
} else {
String resolved = null;
if (argExpr instanceof SimpleName sn && initialLocals != null && initialLocals.containsKey(sn.getIdentifier())) {
resolved = initialLocals.get(sn.getIdentifier());
}
if (resolved == null) {
resolved = constantResolver.resolve(argExpr, context);
}
if (resolved != null) paramValues.put(pName, resolved);
}
}
// Evaluate constructor body regardless of whether parameters could be resolved as constants
java.util.Map<String, String> localVars = new java.util.HashMap<>(paramValues);
constantResolver.evaluateMethodBodyWithLocals(ctor, localVars, context, new java.util.HashSet<>());
for (java.util.Map.Entry<String, String> entry : localVars.entrySet()) {
if (entry.getKey().startsWith("this.")) {
fieldValues.put(entry.getKey(), entry.getValue());
fieldValues.put(entry.getKey().substring(5), entry.getValue());
} else if (context.hasField(td, entry.getKey())) {
fieldValues.put(entry.getKey(), entry.getValue());
fieldValues.put("this." + entry.getKey(), entry.getValue());
}
}
ctor.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(SuperConstructorInvocation sci) {
IMethodBinding binding = sci.resolveConstructorBinding();
if (binding != null) {
ITypeBinding declaringClass = binding.getDeclaringClass();
if (declaringClass != null) {
TypeDeclaration superTd = context.getTypeDeclaration(declaringClass.getErasure().getQualifiedName());
if (superTd != null) {
processSuperConstructorArgs(superTd, binding, sci.arguments(), paramValues, context, fieldValues, visitedCtors, rhsResolver);
return super.visit(sci);
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
processSuperConstructorArgs(superTd, null, sci.arguments(), paramValues, context, fieldValues, visitedCtors, rhsResolver);
}
}
return super.visit(sci);
}
@Override
public boolean visit(ConstructorInvocation ci) {
IMethodBinding binding = ci.resolveConstructorBinding();
processSuperConstructorArgs(td, binding, ci.arguments(), paramValues, context, fieldValues, visitedCtors, rhsResolver);
return super.visit(ci);
}
});
break;
}
}
private void processSuperConstructorArgs(
TypeDeclaration superTd,
IMethodBinding resolvedBinding,
List<?> superArgs,
Map<String, String> subClassParamValues,
CodebaseContext context,
Map<String, String> fieldValues,
Set<String> visitedCtors,
RhsResolver rhsResolver) {
if (superTd == null) return;
for (MethodDeclaration superCtor : superTd.getMethods()) {
if (!superCtor.isConstructor() || superCtor.getBody() == null) continue;
boolean matches = false;
if (resolvedBinding != null && superCtor.resolveBinding() != null) {
matches = resolvedBinding.isEqualTo(superCtor.resolveBinding()) || resolvedBinding.getKey().equals(superCtor.resolveBinding().getKey());
} else {
matches = superCtor.parameters().size() == superArgs.size();
}
if (!matches) continue;
Map<String, String> superParamValues = new HashMap<>();
for (int i = 0; i < superCtor.parameters().size(); i++) {
String pName = ((SingleVariableDeclaration) superCtor.parameters().get(i)).getName().getIdentifier();
Expression argExpr = (Expression) superArgs.get(i);
String resolved = null;
if (argExpr instanceof SimpleName sn) {
resolved = subClassParamValues.get(sn.getIdentifier());
}
if (resolved == null) {
List<String> consts = new ArrayList<>();
if (constantExtractor != null) {
constantExtractor.extractConstantsFromArgument(argExpr, consts);
}
if (!consts.isEmpty()) {
resolved = consts.get(0);
} else {
resolved = constantResolver.resolve(argExpr, context);
}
}
if (resolved == null && rhsResolver != null) {
TypeDeclaration enclosingTd = findEnclosingType(argExpr);
if (enclosingTd != null) {
resolved = rhsResolver.resolve(argExpr, subClassParamValues, enclosingTd);
}
}
if (resolved != null) {
superParamValues.put(pName, resolved);
}
}
if (superParamValues.isEmpty()) continue;
java.util.Map<String, String> localVars = new java.util.HashMap<>(superParamValues);
constantResolver.evaluateMethodBodyWithLocals(superCtor, localVars, context, new java.util.HashSet<>());
for (java.util.Map.Entry<String, String> entry : localVars.entrySet()) {
if (entry.getKey().startsWith("this.")) {
fieldValues.put(entry.getKey(), entry.getValue());
fieldValues.put(entry.getKey().substring(5), entry.getValue());
} else if (context.hasField(superTd, entry.getKey())) {
fieldValues.put(entry.getKey(), entry.getValue());
fieldValues.put("this." + entry.getKey(), entry.getValue());
}
}
superCtor.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(SuperConstructorInvocation sci) {
IMethodBinding binding = sci.resolveConstructorBinding();
if (binding != null) {
ITypeBinding declaringClass = binding.getDeclaringClass();
if (declaringClass != null) {
TypeDeclaration nextSuperTd = context.getTypeDeclaration(declaringClass.getErasure().getQualifiedName());
if (nextSuperTd != null) {
processSuperConstructorArgs(nextSuperTd, binding, sci.arguments(), superParamValues, context, fieldValues, visitedCtors, rhsResolver);
return super.visit(sci);
}
}
}
String superFqn = context.getSuperclassFqn(superTd);
if (superFqn != null) {
TypeDeclaration nextSuperTd = context.getTypeDeclaration(superFqn);
if (nextSuperTd != null) {
processSuperConstructorArgs(nextSuperTd, null, sci.arguments(), superParamValues, context, fieldValues, visitedCtors, rhsResolver);
}
}
return super.visit(sci);
}
@Override
public boolean visit(ConstructorInvocation ci) {
IMethodBinding binding = ci.resolveConstructorBinding();
processSuperConstructorArgs(superTd, binding, ci.arguments(), superParamValues, context, fieldValues, visitedCtors, rhsResolver);
return super.visit(ci);
}
});
break;
}
}
public String evaluateMethodCallInConstructor(
Expression rhs,
Map<String, String> paramValues,
TypeDeclaration td) {
if (!(rhs instanceof MethodInvocation mi)) {
return null;
}
String methodName = mi.getName().getIdentifier();
Expression receiver = mi.getExpression();
if (receiver != null && !(receiver instanceof ThisExpression)) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(td, methodName, true);
if (method == null) {
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
TypeDeclaration targetTd = context.resolveStaticImport(methodName, cu);
if (targetTd != null) {
method = context.findMethodDeclaration(targetTd, methodName, true);
}
}
if (method == null || method.getBody() == null) {
return null;
}
Map<String, String> localVars = new HashMap<>(paramValues);
return constantResolver.evaluateMethodBodyWithLocals(method, localVars, context, new HashSet<>());
}
public ClassInstanceCreation findReturnedCIC(
String className, String methodName, CodebaseContext context) {
return findReturnedCIC(className, methodName, context, new HashSet<>(), 0);
}
private ClassInstanceCreation findReturnedCIC(
String className, String methodName, CodebaseContext context,
Set<String> visited, int depth) {
if (depth > 50 || !visited.add(className + "#" + methodName)) {
return null;
}
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
ClassInstanceCreation[] result = {null};
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement rs) {
if (rs.getExpression() instanceof ClassInstanceCreation cic) {
result[0] = cic;
}
return false;
}
});
if (result[0] != null) return result[0];
}
}
List<String> impls = context.getImplementations(className);
for (String impl : impls) {
ClassInstanceCreation cic = findReturnedCIC(impl, methodName, context, visited, depth + 1);
if (cic != null) return cic;
}
return null;
}
public List<String> resolveInlineInstantiationGetterResult(
ClassInstanceCreation cic,
String getterName,
CodebaseContext context,
Set<String> visited) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
CompilationUnit contextCu = cic.getRoot() instanceof CompilationUnit ? (CompilationUnit) cic.getRoot() : null;
AbstractTypeDeclaration atd = contextCu != null ? context.getAbstractTypeDeclaration(typeName, contextCu) : context.getAbstractTypeDeclaration(typeName);
if (atd == null) atd = context.getAbstractTypeDeclaration(typeName);
if (atd == null) return Collections.emptyList();
if (atd instanceof RecordDeclaration rd) {
int compIdx = -1;
List<?> components = rd.recordComponents();
for (int i = 0; i < components.size(); i++) {
Object compObj = components.get(i);
if (compObj != null) {
try {
java.lang.reflect.Method getNameMethod = compObj.getClass().getMethod("getName");
SimpleName nameObj = (SimpleName) getNameMethod.invoke(compObj);
if (nameObj != null && nameObj.getIdentifier().equals(getterName)) {
compIdx = i;
break;
}
} catch (Exception e) {
// ignore reflection errors
}
}
}
if (compIdx >= 0 && compIdx < cic.arguments().size()) {
Expression argExpr = (Expression) cic.arguments().get(compIdx);
List<String> consts = new ArrayList<>();
if (constantExtractor != null) {
constantExtractor.extractConstantsFromArgument(argExpr, consts);
}
if (!consts.isEmpty()) {
return consts;
} else {
String resolved = constantResolver.resolve(argExpr, context);
if (resolved != null) {
return List.of(resolved);
}
}
}
return Collections.emptyList();
}
if (!(atd instanceof TypeDeclaration td)) return Collections.emptyList();
MethodDeclaration getter = context.findMethodDeclaration(td, getterName, true);
if (getter == null || getter.getBody() == null) return Collections.emptyList();
String getterKey = typeName + "#" + getterName;
if (!visited.add(getterKey)) {
return Collections.emptyList();
}
try {
Map<String, String> fieldValues = buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor);
log.debug("RESOLVE_GETTER: td={}, fieldValues={}", context.getFqn(td), fieldValues);
if (fieldValues.isEmpty()) return Collections.emptyList();
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, visited);
log.debug("RESOLVE_GETTER: result={}", result);
return result != null ? List.of(result) : Collections.emptyList();
} finally {
visited.remove(getterKey);
}
}
public int findConstructorArgumentIndexForLombokField(TypeDeclaration td, String fieldName) {
boolean isAllArgsConstructor = false;
boolean isRequiredArgsConstructor = false;
boolean isData = false;
boolean isValue = false;
for (Object modObj : td.modifiers()) {
if (modObj instanceof MarkerAnnotation ma) {
String name = ma.getTypeName().getFullyQualifiedName();
if (name.equals("AllArgsConstructor") || name.equals("lombok.AllArgsConstructor")) isAllArgsConstructor = true;
if (name.equals("RequiredArgsConstructor") || name.equals("lombok.RequiredArgsConstructor")) isRequiredArgsConstructor = true;
if (name.equals("Data") || name.equals("lombok.Data")) isData = true;
if (name.equals("Value") || name.equals("lombok.Value")) isValue = true;
} else if (modObj instanceof NormalAnnotation na) {
String name = na.getTypeName().getFullyQualifiedName();
if (name.equals("AllArgsConstructor") || name.equals("lombok.AllArgsConstructor")) isAllArgsConstructor = true;
if (name.equals("RequiredArgsConstructor") || name.equals("lombok.RequiredArgsConstructor")) isRequiredArgsConstructor = true;
if (name.equals("Data") || name.equals("lombok.Data")) isData = true;
if (name.equals("Value") || name.equals("lombok.Value")) isValue = true;
}
}
if (!isAllArgsConstructor && !isRequiredArgsConstructor && !isData && !isValue) {
return -1;
}
int index = 0;
for (FieldDeclaration fd : td.getFields()) {
boolean isStatic = false;
boolean isFinal = false;
boolean hasNonNull = false;
for (Object modObj : fd.modifiers()) {
if (modObj instanceof Modifier mod) {
if (mod.isStatic()) isStatic = true;
if (mod.isFinal()) isFinal = true;
} else if (modObj instanceof MarkerAnnotation ma) {
if (ma.getTypeName().getFullyQualifiedName().endsWith("NonNull")) hasNonNull = true;
}
}
if (isStatic) continue;
if (isRequiredArgsConstructor && !isAllArgsConstructor && !isFinal && !hasNonNull) continue;
if (isData && !isAllArgsConstructor && !isFinal && !hasNonNull) continue;
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName)) {
return index;
}
index++;
}
}
return -1;
}
private void processConstructorInvocationArgs(List<?> arguments, TypeDeclaration targetTd, ASTNode callNode, List<String> results, String fieldName, CodebaseContext context, Set<String> visited) {
if (targetTd == null) return;
MethodDeclaration callerMd = findEnclosingMethod(callNode);
IMethodBinding resolvedBinding = null;
if (callNode instanceof ConstructorInvocation ci) {
resolvedBinding = ci.resolveConstructorBinding();
} else if (callNode instanceof SuperConstructorInvocation sci) {
resolvedBinding = sci.resolveConstructorBinding();
}
for (MethodDeclaration otherMd : targetTd.getMethods()) {
boolean matches = false;
if (resolvedBinding != null && otherMd.resolveBinding() != null) {
matches = resolvedBinding.isEqualTo(otherMd.resolveBinding()) || resolvedBinding.getKey().equals(otherMd.resolveBinding().getKey());
} else {
matches = otherMd.isConstructor() && otherMd.parameters().size() == arguments.size() && otherMd != callerMd;
}
if (matches) {
int targetIdx = findAssignedParameterIndex(otherMd, fieldName, context, new HashSet<>());
if (targetIdx >= 0 && targetIdx < arguments.size()) {
Expression arg = (Expression) arguments.get(targetIdx);
String val = constantResolver.resolve(arg, context);
if (val == null && arg instanceof MethodInvocation mi) {
TypeDeclaration enclosingTd = findEnclosingType(arg);
if (enclosingTd != null) {
val = evaluateMethodCallInConstructor(arg, new HashMap<>(), enclosingTd);
}
}
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
if (constantExtractor != null) {
results.add(constantExtractor.parseEnumSetElement(eVal));
}
}
} else {
results.add(val);
}
}
}
}
}
}
private int findAssignedParameterIndex(MethodDeclaration constructorMd, String fieldName, CodebaseContext context, Set<MethodDeclaration> visited) {
if (constructorMd == null || constructorMd.getBody() == null) return -1;
if (!visited.add(constructorMd)) return -1;
final int[] foundIdx = {-1};
constructorMd.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(Assignment asn) {
Expression left = asn.getLeftHandSide();
if ((left instanceof SimpleName sn && sn.getIdentifier().equals(fieldName)) ||
(left instanceof FieldAccess fa && fa.getName().getIdentifier().equals(fieldName)) ||
(left instanceof SuperFieldAccess sfa && sfa.getName().getIdentifier().equals(fieldName))) {
Expression right = asn.getRightHandSide();
String rightName = extractVariableName(right);
if (rightName != null) {
for (int i = 0; i < constructorMd.parameters().size(); i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) constructorMd.parameters().get(i);
if (svd.getName().getIdentifier().equals(rightName)) {
foundIdx[0] = i;
}
}
}
}
return super.visit(asn);
}
@Override
public boolean visit(ConstructorInvocation node) {
IMethodBinding resolvedBinding = node.resolveConstructorBinding();
TypeDeclaration enclosingTd = findEnclosingType(node);
if (enclosingTd != null) {
for (MethodDeclaration otherMd : enclosingTd.getMethods()) {
boolean matches = false;
if (resolvedBinding != null && otherMd.resolveBinding() != null) {
matches = resolvedBinding.isEqualTo(otherMd.resolveBinding()) || resolvedBinding.getKey().equals(otherMd.resolveBinding().getKey());
} else {
matches = otherMd.isConstructor() && otherMd != constructorMd && otherMd.parameters().size() == node.arguments().size();
}
if (matches) {
int targetIdx = findAssignedParameterIndex(otherMd, fieldName, context, visited);
if (targetIdx >= 0 && targetIdx < node.arguments().size()) {
Expression arg = (Expression) node.arguments().get(targetIdx);
String argName = extractVariableName(arg);
if (argName != null) {
for (int i = 0; i < constructorMd.parameters().size(); i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) constructorMd.parameters().get(i);
if (svd.getName().getIdentifier().equals(argName)) {
foundIdx[0] = i;
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperConstructorInvocation node) {
IMethodBinding resolvedBinding = node.resolveConstructorBinding();
TypeDeclaration enclosingTd = findEnclosingType(node);
if (enclosingTd != null) {
String superFqn = context.getSuperclassFqn(enclosingTd);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
for (MethodDeclaration otherMd : superTd.getMethods()) {
boolean matches = false;
if (resolvedBinding != null && otherMd.resolveBinding() != null) {
matches = resolvedBinding.isEqualTo(otherMd.resolveBinding()) || resolvedBinding.getKey().equals(otherMd.resolveBinding().getKey());
} else {
matches = otherMd.isConstructor() && otherMd.parameters().size() == node.arguments().size();
}
if (matches) {
int targetIdx = findAssignedParameterIndex(otherMd, fieldName, context, visited);
if (targetIdx >= 0 && targetIdx < node.arguments().size()) {
Expression arg = (Expression) node.arguments().get(targetIdx);
String argName = extractVariableName(arg);
if (argName != null) {
for (int i = 0; i < constructorMd.parameters().size(); i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) constructorMd.parameters().get(i);
if (svd.getName().getIdentifier().equals(argName)) {
foundIdx[0] = i;
}
}
}
}
}
}
}
}
}
return super.visit(node);
}
});
return foundIdx[0];
}
private String extractVariableName(Expression expr) {
if (expr instanceof SimpleName sn) return sn.getIdentifier();
if (expr instanceof FieldAccess fa) return fa.getName().getIdentifier();
if (expr instanceof SuperFieldAccess sfa) return sfa.getName().getIdentifier();
if (expr instanceof CastExpression ce) return extractVariableName(ce.getExpression());
if (expr instanceof ParenthesizedExpression pe) return extractVariableName(pe.getExpression());
if (expr instanceof MethodInvocation mi && (mi.getName().getIdentifier().equals("requireNonNull") || mi.getName().getIdentifier().equals("notNull"))) {
if (!mi.arguments().isEmpty()) return extractVariableName((Expression)mi.arguments().get(0));
}
return null;
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
}

View File

@@ -0,0 +1,173 @@
package click.kamil.springstatemachineexporter.analysis.service;
import lombok.extern.slf4j.Slf4j;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@Slf4j
public class DynamicClasspathResolver {
public List<String> resolveClasspath(Path projectRoot) {
log.info("Attempting dynamic classpath resolution for project at {}", projectRoot);
if (projectRoot == null || !Files.exists(projectRoot)) {
return Collections.emptyList();
}
if (Files.exists(projectRoot.resolve("pom.xml"))) {
return resolveMaven(projectRoot);
} else if (Files.exists(projectRoot.resolve("build.gradle")) || Files.exists(projectRoot.resolve("build.gradle.kts"))) {
return resolveGradle(projectRoot);
}
log.info("No Maven or Gradle configuration found at {}. Proceeding without dynamic classpath.", projectRoot);
return Collections.emptyList();
}
protected List<String> resolveMaven(Path projectRoot) {
log.info("Maven project detected. Resolving dependencies...");
try {
String mvnCmd = getCommand(projectRoot, "mvnw", "mvn");
if (mvnCmd == null) {
log.warn("Maven executable not found.");
return Collections.emptyList();
}
ProcessBuilder pb = new ProcessBuilder(
mvnCmd,
"-q",
"dependency:build-classpath",
"-DincludeScope=compile",
"-Dmdep.outputFile=sme_cp.txt"
);
pb.directory(projectRoot.toFile());
runProcess(pb);
java.util.Set<String> allPaths = new java.util.LinkedHashSet<>();
try (java.util.stream.Stream<Path> stream = Files.walk(projectRoot)) {
stream.filter(p -> p.getFileName().toString().equals("sme_cp.txt"))
.forEach(cpFile -> {
try {
String cpString = Files.readString(cpFile).trim();
if (!cpString.isEmpty()) {
allPaths.addAll(Arrays.asList(cpString.split(File.pathSeparator)));
}
Files.deleteIfExists(cpFile);
} catch (IOException e) {
log.warn("Failed to read or delete sme_cp.txt at {}", cpFile, e);
}
});
}
if (!allPaths.isEmpty()) {
return new ArrayList<>(allPaths);
}
} catch (Exception e) {
log.error("Failed to dynamically resolve Maven classpath", e);
}
return Collections.emptyList();
}
protected List<String> resolveGradle(Path projectRoot) {
log.info("Gradle project detected. Resolving dependencies...");
Path initScript = null;
try {
initScript = Files.createTempFile("state_machine_exporter_init", ".gradle");
String gradleCmd = getCommand(projectRoot, "gradlew", "gradle");
if (gradleCmd == null) {
log.warn("Gradle executable not found.");
return Collections.emptyList();
}
String scriptContent = """
allprojects {
task smePrintClasspath {
doLast {
def cp = []
if (configurations.findByName("compileClasspath")) {
cp = configurations.compileClasspath.files
}
println "SME_CLASSPATH_MARKER:" + cp.join(File.pathSeparator)
}
}
}
""";
Files.writeString(initScript, scriptContent);
ProcessBuilder pb = new ProcessBuilder(
gradleCmd,
"-q",
"-I", initScript.toAbsolutePath().toString(),
"smePrintClasspath"
);
pb.directory(projectRoot.toFile());
String output = runProcessAndGetOutput(pb);
java.util.Set<String> allPaths = new java.util.LinkedHashSet<>();
for (String line : output.split("\\r?\\n")) {
if (line.startsWith("SME_CLASSPATH_MARKER:")) {
String cpString = line.substring("SME_CLASSPATH_MARKER:".length()).trim();
if (!cpString.isEmpty()) {
allPaths.addAll(Arrays.asList(cpString.split(File.pathSeparator)));
}
}
}
if (!allPaths.isEmpty()) {
return new ArrayList<>(allPaths);
}
} catch (Exception e) {
log.error("Failed to dynamically resolve Gradle classpath", e);
} finally {
if (initScript != null) {
try {
Files.deleteIfExists(initScript);
} catch (IOException ignored) {}
}
}
return Collections.emptyList();
}
private String getCommand(Path projectRoot, String wrapper, String systemBinary) {
Path wrapperUnix = projectRoot.resolve(wrapper);
Path wrapperWin = projectRoot.resolve(wrapper + ".bat");
boolean isWin = System.getProperty("os.name").toLowerCase().contains("win");
if (isWin && Files.exists(wrapperWin)) return wrapperWin.toAbsolutePath().toString();
if (!isWin && Files.exists(wrapperUnix)) {
// Need absolute path like ./gradlew
return wrapperUnix.toAbsolutePath().toString();
}
return systemBinary;
}
protected void runProcess(ProcessBuilder pb) throws IOException, InterruptedException {
pb.redirectErrorStream(true);
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
Process p = pb.start();
p.waitFor();
}
protected String runProcessAndGetOutput(ProcessBuilder pb) throws IOException, InterruptedException {
pb.redirectErrorStream(true);
Process p = pb.start();
StringBuilder out = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
String line;
while ((line = br.readLine()) != null) {
out.append(line).append("\n");
}
}
p.waitFor();
return out.toString();
}
}

View File

@@ -33,9 +33,20 @@ public class GenericEventDetector {
public boolean visit(MethodInvocation node) { public boolean visit(MethodInvocation node) {
String methodName = node.getName().getIdentifier(); String methodName = node.getName().getIdentifier();
if ("sendEvent".equals(methodName)) { if ("sendEvent".equals(methodName) || "sendEvents".equals(methodName) ||
"sendEventCollect".equals(methodName) || "sendEventMono".equals(methodName)) {
processSendEvent(node, cu, triggers); processSendEvent(node, cu, triggers);
} } else {
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String refMethod = emr.getName().getIdentifier();
if ("sendEvent".equals(refMethod) || "sendEvents".equals(refMethod) ||
"sendEventCollect".equals(refMethod) || "sendEventMono".equals(refMethod)) {
processSendEventMethodReference(node, emr, cu, triggers);
}
}
}
}
processHints(node, cu, triggers); processHints(node, cu, triggers);
@@ -45,11 +56,97 @@ public class GenericEventDetector {
return triggers; return triggers;
} }
private void processSendEventMethodReference(MethodInvocation node, ExpressionMethodReference emr, CompilationUnit cu, List<TriggerPoint> triggers) {
Expression receiver = node.getExpression();
String eventValue = extractEventFromReceiver(receiver);
if (eventValue == null) {
eventValue = receiver != null ? receiver.toString() : null;
}
MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration type = findEnclosingType(node);
if (type == null) return;
String sourceState = extractSourceState(node);
String[] smTypes = resolveStateMachineTypeArgumentsForExpression(emr.getExpression(), node);
boolean external = false;
if (receiver != null) {
AssignmentDag dag = new AssignmentDag(method);
external = dag.isExternal(receiver, new java.util.HashSet<>());
}
if (eventValue != null) {
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
triggers.add(TriggerPoint.builder()
.event(eventValue)
.sourceState(sourceState)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
.lineNumber(cu.getLineNumber(node.getStartPosition()))
.stateTypeFqn(smTypes[0])
.eventTypeFqn(smTypes[1])
.external(external)
.constraint(constraint)
.build());
}
}
private String extractEventFromReceiver(Expression receiver) {
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());
}
}
if (receiver instanceof SimpleName sn) {
return sn.getIdentifier();
}
return null;
}
private String[] resolveStateMachineTypeArgumentsForExpression(Expression receiver, MethodInvocation node) {
if (receiver == null) return new String[]{null, null};
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
ITypeBinding smBinding = findStateMachineSupertype(binding, new java.util.HashSet<>());
if (smBinding != null) {
ITypeBinding[] typeArgs = smBinding.getTypeArguments();
if (typeArgs.length >= 2) {
return new String[]{typeArgs[0].getQualifiedName(), typeArgs[1].getQualifiedName()};
}
}
}
Type declType = findReceiverTypeManually(receiver, node);
if (declType instanceof ParameterizedType pt) {
List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
CompilationUnit compilationUnit = (CompilationUnit) node.getRoot();
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit);
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit);
return new String[]{stateType, eventType};
}
}
return new String[]{null, null};
}
private void processSendEvent(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) { private void processSendEvent(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
TriggerPoint trigger = buildTriggerPoint(node, cu, null); List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, null);
if (trigger != null) { if (builtTriggers != null) {
log.debug("Successfully built trigger point: {}", trigger.getEvent()); for (TriggerPoint trigger : builtTriggers) {
triggers.add(trigger); log.debug("Successfully built trigger point: {}", trigger.getEvent());
triggers.add(trigger);
}
} }
} }
@@ -61,10 +158,23 @@ public class GenericEventDetector {
for (LibraryHint hint : hints) { for (LibraryHint hint : hints) {
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) { if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) {
TriggerPoint trigger = buildTriggerPoint(node, cu, hint.getEvent()); String eventToUse = hint.getEvent();
if (trigger != null) { if (eventToUse == null && hint.getEventArgumentIndex() != null) {
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent()); if (node.arguments().size() > hint.getEventArgumentIndex()) {
triggers.add(trigger); Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex());
eventToUse = argExpr.toString();
if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) {
eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()";
}
}
}
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
triggers.add(trigger);
}
} }
} }
} }
@@ -101,12 +211,12 @@ public class GenericEventDetector {
return receiver.toString() + "." + methodName; return receiver.toString() + "." + methodName;
} }
private TriggerPoint buildTriggerPoint(MethodInvocation node, CompilationUnit cu, String forcedEvent) { private List<TriggerPoint> buildTriggerPoints(MethodInvocation node, CompilationUnit cu, String forcedEvent) {
String eventValue = null; String eventValue = null;
if (forcedEvent != null) { if (forcedEvent != null) {
eventValue = context.resolveString(forcedEvent); eventValue = context.resolveString(forcedEvent);
} else { } else {
if (node.arguments().isEmpty()) return null; if (node.arguments().isEmpty()) return Collections.emptyList();
Expression eventExpr = (Expression) node.arguments().get(0); Expression eventExpr = (Expression) node.arguments().get(0);
// Try MessageBuilder extraction first // Try MessageBuilder extraction first
@@ -122,34 +232,269 @@ public class GenericEventDetector {
MethodDeclaration method = findEnclosingMethod(node); MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration type = findEnclosingType(node); TypeDeclaration type = findEnclosingType(node);
if (type == null) return null; if (type == null) return Collections.emptyList();
return TriggerPoint.builder() String sourceState = extractSourceState(node);
.event(eventValue) String[] smTypes = resolveStateMachineTypeArguments(node);
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer") boolean external = false;
.sourceFile(context.getRelativePath(context.getFqn(type))) if (!node.arguments().isEmpty()) {
.lineNumber(cu.getLineNumber(node.getStartPosition())) Expression eventExpr = (Expression) node.arguments().get(0);
.build(); AssignmentDag dag = new AssignmentDag(method);
external = dag.isExternal(eventExpr, new java.util.HashSet<>());
}
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
List<TriggerPoint> results = new ArrayList<>();
if (eventValue != null && eventValue.startsWith("ENUM_SET:")) {
String[] parts = eventValue.substring(9).split(",");
for (String part : parts) {
if (!part.trim().isEmpty()) {
results.add(TriggerPoint.builder()
.event(part.trim())
.sourceState(sourceState)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
.lineNumber(cu.getLineNumber(node.getStartPosition()))
.stateTypeFqn(smTypes[0])
.eventTypeFqn(smTypes[1])
.external(external)
.constraint(constraint)
.build());
}
}
} else {
results.add(TriggerPoint.builder()
.event(eventValue)
.sourceState(sourceState)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
.lineNumber(cu.getLineNumber(node.getStartPosition()))
.stateTypeFqn(smTypes[0])
.eventTypeFqn(smTypes[1])
.external(external)
.constraint(constraint)
.build());
}
return results;
}
private String extractSourceState(ASTNode node) {
ASTNode current = node;
while (current != null && !(current instanceof MethodDeclaration)) {
ASTNode parent = current.getParent();
// 1. Direct parent is IfStatement wrapper (e.g., if (state == X) { sm.sendEvent() })
if (parent instanceof IfStatement ifStmt) {
// Only consider if our current node is in the 'then' or 'else' part, not the condition
if (ifStmt.getExpression() != current) {
Expression expr = ifStmt.getExpression();
String state = extractStateFromExpression(expr);
if (state != null) return state;
}
}
// 2. Parent is a Block or SwitchStatement: perform sibling traversal
if (parent instanceof Block block) {
String state = extractStateFromSiblings(current, block.statements());
if (state != null) return state;
} else if (parent instanceof SwitchStatement switchStmt) {
String state = extractStateFromSiblings(current, switchStmt.statements());
if (state != null) return state;
}
current = parent;
}
return null;
}
private String extractStateFromSiblings(ASTNode currentNode, List<?> statements) {
int index = statements.indexOf(currentNode);
if (index <= 0) return null; // No previous siblings
// Walk backwards through siblings
for (int i = index - 1; i >= 0; i--) {
Object stmtObj = statements.get(i);
if (stmtObj instanceof SwitchCase switchCase) {
if (!switchCase.expressions().isEmpty()) {
return getSimpleNameString((Expression) switchCase.expressions().get(0));
}
} else if (stmtObj instanceof IfStatement ifStmt) {
// Guard clause detection: check if the 'then' block has a return/throw
if (isGuardClause(ifStmt.getThenStatement())) {
Expression expr = ifStmt.getExpression();
// If it's a guard clause, it usually checks `state != EXPECTED`, so the true state *is* EXPECTED
String state = extractStateFromExpression(expr);
if (state != null) return state;
}
}
}
return null;
}
private boolean isGuardClause(Statement thenStatement) {
if (thenStatement instanceof ReturnStatement || thenStatement instanceof ThrowStatement) {
return true;
}
if (thenStatement instanceof Block block) {
for (Object obj : block.statements()) {
if (obj instanceof ReturnStatement || obj instanceof ThrowStatement) {
return true;
}
}
}
return false;
}
private String extractStateFromExpression(Expression expr) {
if (expr instanceof InfixExpression infix) {
if (infix.getOperator() == InfixExpression.Operator.EQUALS || infix.getOperator() == InfixExpression.Operator.NOT_EQUALS) {
Expression left = infix.getLeftOperand();
Expression right = infix.getRightOperand();
// Ignore null checks entirely
if (left instanceof NullLiteral || right instanceof NullLiteral) {
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);
}
if (right instanceof QualifiedName || right instanceof StringLiteral || right instanceof FieldAccess) {
return getSimpleNameString(right);
}
// Fallback
return getSimpleNameString(right);
}
} else if (expr instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier();
if (("equals".equals(methodName) || "equalsIgnoreCase".equals(methodName)) && !mi.arguments().isEmpty()) {
Expression receiver = mi.getExpression();
Expression arg = (Expression) mi.arguments().get(0);
// If receiver is null (e.g., implicit this), fall back to arg
if (receiver == null) {
return getSimpleNameString(arg);
}
// 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 null;
}
private String getSimpleNameString(Expression expr) {
if (expr instanceof QualifiedName qn) {
return qn.getName().getIdentifier();
} else if (expr instanceof FieldAccess fa) {
return fa.getName().getIdentifier();
} else if (expr instanceof StringLiteral sl) {
return sl.getLiteralValue();
}
return expr.toString();
} }
private String extractEventFromMessageBuilder(Expression expr) { private String extractEventFromMessageBuilder(Expression expr) {
if (expr instanceof CastExpression ce) {
return extractEventFromMessageBuilder(ce.getExpression());
}
if (expr instanceof SimpleName sn) {
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(expr);
if (enclosingMethod != null) {
// Find variable declaration
final Expression[] initializer = new Expression[1];
enclosingMethod.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);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
initializer[0] = node.getRightHandSide();
}
return super.visit(node);
}
});
if (initializer[0] != null) {
return extractEventFromMessageBuilder(initializer[0]); // recursive
}
// If it has NO initializer, it might be a method parameter!
for (Object paramObj : enclosingMethod.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(varName)) {
return varName;
}
}
}
}
}
if (expr instanceof ClassInstanceCreation cic) {
return cic.getType().toString();
}
if (!(expr instanceof MethodInvocation mi)) return null; if (!(expr instanceof MethodInvocation mi)) return null;
// Trace back chain: MessageBuilder.withPayload(event).setHeader(...).build() // Trace back chain: MessageBuilder.withPayload(event).setHeader(...).build()
MethodInvocation current = mi; MethodInvocation current = mi;
while (current != null) { while (current != null) {
String name = current.getName().getIdentifier(); String name = current.getName().getIdentifier();
if ("withPayload".equals(name) && !current.arguments().isEmpty()) { if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) {
Expression payloadExpr = (Expression) current.arguments().get(0); 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); String resolved = constantResolver.resolve(payloadExpr, context);
if (resolved != null) return resolved; if (resolved != null) return resolved;
// If not a constant, it might be a parameter like 'event' // If not a constant, it might be a parameter like 'event'
if (payloadExpr instanceof CastExpression ce) {
payloadExpr = ce.getExpression();
}
if (payloadExpr instanceof SimpleName sn) { if (payloadExpr instanceof SimpleName sn) {
return sn.getIdentifier(); // Return the parameter name so the call graph can fill it String extracted = extractEventFromMessageBuilder(payloadExpr);
if (extracted != null) {
return extracted;
}
return sn.getIdentifier(); // Fall back to returning the parameter name
} }
return payloadExpr.toString(); return payloadExpr.toString();
} else if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) {
// If the event is obtained by calling a method on a provider/supplier parameter,
// return the provider's name so CallGraphBuilder can trace the lambda argument
String traced = extractEventFromMessageBuilder(sn);
return traced != null ? traced : sn.getIdentifier();
} }
Expression receiver = current.getExpression(); Expression receiver = current.getExpression();
@@ -177,4 +522,287 @@ public class GenericEventDetector {
} }
return (TypeDeclaration) parent; return (TypeDeclaration) parent;
} }
private String[] resolveStateMachineTypeArguments(MethodInvocation node) {
Expression receiver = node.getExpression();
// Trace back the receiver if it's a builder chain
while (receiver instanceof MethodInvocation mi) {
if (mi.getName().getIdentifier().equals("sendEvent")) {
receiver = mi.getExpression();
break;
}
receiver = mi.getExpression();
}
// Also if the node itself is sendEvent, receiver is already correct
if (node.getName().getIdentifier().equals("sendEvent")) {
receiver = node.getExpression();
}
if (receiver == null) return new String[]{null, null};
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
ITypeBinding smBinding = findStateMachineSupertype(binding, new java.util.HashSet<>());
if (smBinding != null) {
ITypeBinding[] typeArgs = smBinding.getTypeArguments();
if (typeArgs.length >= 2) {
return new String[]{typeArgs[0].getQualifiedName(), typeArgs[1].getQualifiedName()};
}
}
}
// Fallback: search enclosing class/method for variable declaration
Type declType = findReceiverTypeManually(receiver, node);
if (declType instanceof ParameterizedType pt) {
List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
CompilationUnit cu = (CompilationUnit) node.getRoot();
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), cu);
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), cu);
if (stateType != null && stateType.length() == 1) {
stateType = resolveGenericTypeVariable(stateType, node, cu);
}
if (eventType != null && eventType.length() == 1) {
eventType = resolveGenericTypeVariable(eventType, node, cu);
}
return new String[]{stateType, eventType};
}
}
return new String[]{null, null};
}
private String resolveGenericTypeVariable(String typeVarName, ASTNode node, CompilationUnit cu) {
TypeDeclaration enclosingClass = findEnclosingType(node);
if (enclosingClass == null) return typeVarName;
String className = context.getFqn(enclosingClass);
if (className == null) return typeVarName;
List<String> impls = context.getImplementations(className);
if (impls == null || impls.isEmpty()) return typeVarName;
for (String implFqn : impls) {
org.eclipse.jdt.core.dom.AbstractTypeDeclaration implNode = context.getAbstractTypeDeclaration(implFqn);
if (implNode instanceof TypeDeclaration td) {
Type superclassType = td.getSuperclassType();
if (superclassType instanceof ParameterizedType pt) {
Type baseType = pt.getType();
String baseName = resolveTypeToFqn(baseType, (CompilationUnit) implNode.getRoot());
if (className.equals(baseName) || className.endsWith("." + baseName)) {
int typeIndex = -1;
List<?> typeParams = enclosingClass.typeParameters();
for (int i = 0; i < typeParams.size(); i++) {
TypeParameter tp = (TypeParameter) typeParams.get(i);
if (tp.getName().getIdentifier().equals(typeVarName)) {
typeIndex = i;
break;
}
}
if (typeIndex >= 0 && typeIndex < pt.typeArguments().size()) {
Type concreteType = (Type) pt.typeArguments().get(typeIndex);
return resolveTypeToFqn(concreteType, (CompilationUnit) implNode.getRoot());
}
}
}
}
}
return typeVarName;
}
private ITypeBinding findStateMachineSupertype(ITypeBinding binding, java.util.Set<String> visited) {
ITypeBinding current = binding;
while (current != null) {
String erasureName = current.getErasure().getQualifiedName();
if (erasureName != null && !visited.add(erasureName)) {
return null;
}
if ("org.springframework.statemachine.StateMachine".equals(erasureName)) {
return current;
}
for (ITypeBinding iface : current.getInterfaces()) {
ITypeBinding res = findStateMachineSupertype(iface, visited);
if (res != null) return res;
}
current = current.getSuperclass();
}
return null;
}
private Type findReceiverTypeManually(Expression receiver, MethodInvocation node) {
if (!(receiver instanceof SimpleName sn)) return null;
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(node);
if (enclosingMethod != null) {
// Check params
for (Object paramObj : enclosingMethod.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(varName)) {
return svd.getType();
}
}
}
// Check local variables
if (enclosingMethod.getBody() != null) {
Type[] found = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(varName)) {
found[0] = vds.getType();
}
}
}
return super.visit(vds);
}
});
if (found[0] != null) return found[0];
}
}
// Check fields
TypeDeclaration enclosingType = findEnclosingType(node);
if (enclosingType != null) {
for (FieldDeclaration fd : enclosingType.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(varName)) {
return fd.getType();
}
}
}
}
}
return null;
}
private String resolveTypeToFqn(Type type, CompilationUnit cu) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), cu);
} else {
simpleName = type.toString();
}
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
// Check package name
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) return context.getFqn(localTd);
}
return simpleName;
}
private static class AssignmentDag {
final java.util.Map<String, List<Expression>> assignments = new java.util.HashMap<>();
final java.util.Set<SingleVariableDeclaration> parameters = new java.util.HashSet<>();
AssignmentDag(MethodDeclaration method) {
if (method == null) return;
for (Object paramObj : method.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd) {
parameters.add(svd);
}
}
if (method.getBody() != null) {
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment fragment) {
String varName = fragment.getName().getIdentifier();
if (fragment.getInitializer() != null) {
assignments.computeIfAbsent(varName, k -> new ArrayList<>()).add(fragment.getInitializer());
}
return super.visit(fragment);
}
@Override
public boolean visit(Assignment assignment) {
if (assignment.getLeftHandSide() instanceof SimpleName sn) {
String varName = sn.getIdentifier();
assignments.computeIfAbsent(varName, k -> new ArrayList<>()).add(assignment.getRightHandSide());
}
return super.visit(assignment);
}
});
}
}
boolean isExternal(Expression expr, java.util.Set<String> visited) {
if (expr == null) return false;
Expression peeled = expr;
while (peeled instanceof ParenthesizedExpression pe) {
peeled = pe.getExpression();
}
while (peeled instanceof CastExpression ce) {
peeled = ce.getExpression();
}
if (peeled instanceof SimpleName sn) {
String varName = sn.getIdentifier();
for (SingleVariableDeclaration svd : parameters) {
if (svd.getName().getIdentifier().equals(varName)) {
return isAnnotatedAsExternal(svd);
}
}
if (assignments.containsKey(varName)) {
if (!visited.add(varName)) {
return false;
}
List<Expression> defs = assignments.get(varName);
for (Expression def : defs) {
if (isExternal(def, visited)) {
return true;
}
}
}
} else if (peeled instanceof MethodInvocation mi) {
if (mi.getExpression() != null && isExternal(mi.getExpression(), visited)) {
return true;
}
for (Object arg : mi.arguments()) {
if (isExternal((Expression) arg, visited)) {
return true;
}
}
}
return false;
}
private boolean isAnnotatedAsExternal(SingleVariableDeclaration svd) {
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")
|| typeName.endsWith("PathParam") || typeName.endsWith("QueryParam")) {
return true;
}
}
}
return false;
}
}
} }

View File

@@ -0,0 +1,634 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@Slf4j
public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
protected String currentMethodFqn;
protected Map<String, List<CallEdge>> graph;
public HeuristicCallGraphEngine(CodebaseContext context) {
super(context);
}
@SuppressWarnings("unchecked")
protected Map<String, List<CallEdge>> buildCallGraph() {
Map<String, List<CallEdge>> cached = (Map<String, List<CallEdge>>) context.getCache().get("heuristicCallGraph");
if (cached != null) {
this.graph = cached;
return cached;
}
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
for (String calledMethod : calledMethods) {
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(resolveConstraint(node, calledMethod, constraint));
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration td2 = findEnclosingType(node);
if (td2 != null) {
String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) {
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(refMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args, receiver);
implEdge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(implEdge);
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = 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;
}
List<String> args = resolveArguments(node.arguments());
String receiver = "super";
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
}
}
return super.visit(node);
}
});
}
context.getCache().put("heuristicCallGraph", graph);
return graph;
}
@Override
protected String postProcessResolvedArgument(org.eclipse.jdt.core.dom.Expression originalExpr, String resolvedValue) {
// Preserve getter calls on 'this' or 'super' for later resolution in trigger parameter tracing
// where we can substitute the actual receiver from the call edge
boolean isThisOrSuperGetter = (originalExpr instanceof org.eclipse.jdt.core.dom.MethodInvocation mi
&& (mi.getExpression() instanceof org.eclipse.jdt.core.dom.ThisExpression || mi.getExpression() instanceof org.eclipse.jdt.core.dom.SuperMethodInvocation)
&& mi.arguments().isEmpty()) || (originalExpr instanceof org.eclipse.jdt.core.dom.SuperMethodInvocation smi && smi.arguments().isEmpty());
if (isThisOrSuperGetter) {
return originalExpr.toString(); // Preserve "this.getTransitionType()" or "super.getEvent()" for later substitution
}
// Narrow resolution for "localVar.getX()" patterns where localVar is initialized
// from a ClassInstanceCreation (directly or via a factory method). This avoids
// the broad ENUM_SET fallback when the getter transforms one enum to another.
if (originalExpr instanceof org.eclipse.jdt.core.dom.MethodInvocation getterMi
&& getterMi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName instanceSn
&& getterMi.arguments().isEmpty()) {
String narrowed = tryNarrowGetterViaLocalVarChain(getterMi, instanceSn);
if (narrowed != null) {
return narrowed;
}
}
return resolvedValue;
}
private String findDefiningClass(String className, String methodName) {
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) return null;
String resolvedMethodFqn = resolveMethodInTypeHierarchy(td, methodName);
if (resolvedMethodFqn != null) {
return resolvedMethodFqn.substring(0, resolvedMethodFqn.lastIndexOf('.'));
}
return null;
}
protected String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
if (receiver instanceof MethodInvocation miReceiver) {
String returnType = resolveMethodInvocationReturnType(miReceiver);
if (returnType != null) {
return returnType + "." + methodName;
}
}
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInTypeHierarchy(td, methodName);
}
return null;
}
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
return binding.getQualifiedName() + "." + methodName;
}
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
}
if (receiver instanceof SimpleName sn) {
String fallbackTypeFqn = resolveReceiverTypeFallback(sn);
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
String receiverName = sn.getIdentifier();
return receiverName + "." + methodName;
}
if (receiver instanceof QualifiedName qn) {
String fqn = qn.getFullyQualifiedName();
TypeDeclaration td = context.getTypeDeclaration(fqn);
if (td != null) {
return fqn + "." + methodName;
}
return fqn + "." + methodName;
}
return null;
}
protected String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier();
CompilationUnit cu = receiverNameNode.getRoot() instanceof CompilationUnit ? (CompilationUnit) receiverNameNode.getRoot() : null;
TypeDeclaration staticTd = context.getTypeDeclaration(varName, cu);
if (staticTd != null) {
return context.getFqn(staticTd);
}
TypeDeclaration fallbackStaticTd = context.getTypeDeclaration(varName);
if (fallbackStaticTd != null) {
return context.getFqn(fallbackStaticTd);
}
// 1. Check local variables in enclosing method
MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode);
if (enclosingMethod != null) {
// Check parameters
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(svd.getType(), receiverNameNode);
}
}
// Check method body (local variables)
if (enclosingMethod.getBody() != null) {
Type[] foundType = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType();
}
}
return super.visit(node);
}
});
if (foundType[0] != null) {
return resolveTypeToFqn(foundType[0], receiverNameNode);
}
}
}
// 2. Check fields in enclosing class and its superclasses
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
TypeDeclaration currentType = enclosingType;
while (currentType != null) {
for (FieldDeclaration field : currentType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(field.getType(), receiverNameNode);
}
}
}
String superclass = context.getSuperclassFqn(currentType);
currentType = superclass != null ? context.getTypeDeclaration(superclass) : null;
}
return null;
}
protected String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode);
} else {
simpleName = type.toString();
}
CompilationUnit cu = (CompilationUnit) contextNode.getRoot();
TypeDeclaration td = context.getTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching if CodebaseContext doesn't know it (e.g., external library)
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
return simpleName;
}
/**
* Attempts to narrow the resolution of {@code instanceVar.getterMethod()} when the
* instance variable is initialised from a {@link org.eclipse.jdt.core.dom.ClassInstanceCreation}
* (either directly or via a factory/mapper method).
*
* <p>This handles both:
* <ul>
* <li>Simple pass-through: {@code Wrapper(MyEnum e){this.e=e;} getE(){return e;}}</li>
* <li>Transformation: {@code getSmEvent(){return switch(dtoEnum){case A->SmEvents.X;…};}}</li>
* </ul>
*
* @return the resolved constant string, or {@code null} when narrowing is not possible
*/
private String tryNarrowGetterViaLocalVarChain(
MethodInvocation getterCall, SimpleName instanceSn) {
MethodDeclaration enclosing = findEnclosingMethod(getterCall);
if (enclosing == null || enclosing.getBody() == null) return null;
String varName = instanceSn.getIdentifier();
String getterName = getterCall.getName().getIdentifier();
// Find the declared type and initialiser of the local variable
String[] varTypeName = {null};
Expression[] initExpr = {null};
enclosing.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
varTypeName[0] = vds.getType().toString();
initExpr[0] = frag.getInitializer();
}
}
return super.visit(vds);
}
});
if (varTypeName[0] == null) return null;
// Obtain the ClassInstanceCreation that created the object
org.eclipse.jdt.core.dom.ClassInstanceCreation cic = null;
String receiverType = null;
if (initExpr[0] instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation directCic) {
cic = directCic;
} else if (initExpr[0] instanceof MethodInvocation factoryMi
&& (factoryMi.getExpression() instanceof SimpleName || factoryMi.getExpression() instanceof QualifiedName)) {
// e.g. "mapper.toMsg(dto)" or "com.example.Factory.toMsg()"
if (factoryMi.getExpression() instanceof SimpleName receiverSn) {
receiverType = resolveReceiverTypeFallback(receiverSn);
} else if (factoryMi.getExpression() instanceof QualifiedName qn) {
receiverType = qn.getFullyQualifiedName();
}
if (receiverType != null) {
cic = findReturnedCIC(receiverType, factoryMi.getName().getIdentifier(), context);
}
}
if (cic == null) {
// Support builder pattern: e.g. EventWrapper.builder().event(TransitionEnum.STATE_X).build()
if (initExpr[0] instanceof MethodInvocation initMi) {
String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName;
Expression currentMi = initMi;
while (currentMi instanceof MethodInvocation chainMi) {
String mName = chainMi.getName().getIdentifier();
if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) {
if (!chainMi.arguments().isEmpty()) {
return chainMi.arguments().get(0).toString();
}
}
currentMi = chainMi.getExpression();
}
}
return null;
}
// Resolve the variable's declared type to a TypeDeclaration
CompilationUnit cu = (CompilationUnit) getterCall.getRoot();
TypeDeclaration varTypeTd = context.getTypeDeclaration(varTypeName[0], cu);
if (varTypeTd == null) varTypeTd = context.getTypeDeclaration(varTypeName[0]);
if (varTypeTd == null) return null;
// Evaluate the getter body with field values substituted from the CIC's constructor args
java.util.Map<String, String> initialLocals = new java.util.HashMap<>();
if (initExpr[0] instanceof MethodInvocation factoryMi) {
MethodDeclaration md = context.findMethodDeclaration(context.getTypeDeclaration(receiverType != null ? receiverType : varTypeName[0]), factoryMi.getName().getIdentifier(), true);
if (md != null) {
for (int i = 0; i < md.parameters().size() && i < factoryMi.arguments().size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i);
Expression arg = (Expression) factoryMi.arguments().get(i);
String resolvedVal = constantResolver.resolve(arg, context);
if (resolvedVal != null) {
initialLocals.put(param.getName().getIdentifier(), resolvedVal);
}
}
}
}
java.util.Map<String, String> fieldValues = constructorAnalyzer.buildFieldValuesFromCIC(varTypeTd, cic, context, this::evaluateMethodCallInConstructor, initialLocals);
if (fieldValues.isEmpty()) return null;
// Track setter calls on the variable between its initialization and the getter call
// to capture field updates like e.setType("NEW_VALUE")
trackSetterCallsBetween(enclosing.getBody(), varName, getterCall, fieldValues, context);
MethodDeclaration getter = context.findMethodDeclaration(varTypeTd, getterName, true);
if (getter == null || getter.getBody() == null) return null;
return constantResolver.evaluateMethodBodyWithLocals(
getter, fieldValues, context, new java.util.HashSet<>());
}
/**
* Scans the method body for setter calls or field assignments to the given variable
* that occur between the variable's initialization and the getter call.
* Updates fieldValues with any field values set via setters.
*/
private void trackSetterCallsBetween(org.eclipse.jdt.core.dom.Block methodBody, String varName,
org.eclipse.jdt.core.dom.MethodInvocation getterCall,
java.util.Map<String, String> fieldValues,
CodebaseContext context) {
java.util.List<org.eclipse.jdt.core.dom.ASTNode> statements = new java.util.ArrayList<>();
for (Object stmtObj : methodBody.statements()) {
statements.add((org.eclipse.jdt.core.dom.ASTNode) stmtObj);
}
int varDeclIndex = -1;
int getterCallIndex = -1;
for (int i = 0; i < statements.size(); i++) {
org.eclipse.jdt.core.dom.ASTNode stmt = statements.get(i);
// Find variable declaration
if (varDeclIndex == -1) {
if (stmt instanceof org.eclipse.jdt.core.dom.VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
if (fragObj instanceof org.eclipse.jdt.core.dom.VariableDeclarationFragment frag
&& frag.getName().getIdentifier().equals(varName)) {
varDeclIndex = i;
break;
}
}
}
}
// Find getter call
if (getterCallIndex == -1) {
if (stmt.toString().contains(getterCall.toString())) {
getterCallIndex = i;
break;
}
}
}
if (varDeclIndex >= 0 && getterCallIndex > varDeclIndex) {
// Scan statements between var declaration and getter call
for (int i = varDeclIndex + 1; i < getterCallIndex; i++) {
org.eclipse.jdt.core.dom.ASTNode stmt = statements.get(i);
if (stmt instanceof org.eclipse.jdt.core.dom.ExpressionStatement es) {
org.eclipse.jdt.core.dom.Expression expr = es.getExpression();
// Check for setter calls: var.setField(value)
if (expr instanceof org.eclipse.jdt.core.dom.MethodInvocation mi) {
if (mi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName sn
&& sn.getIdentifier().equals(varName)) {
String methodName = mi.getName().getIdentifier();
// Check if it's a setter (setXxx or xxx)
if (methodName.startsWith("set") && methodName.length() > 3) {
String fieldName = Character.toLowerCase(methodName.charAt(3))
+ methodName.substring(4);
if (!mi.arguments().isEmpty()) {
org.eclipse.jdt.core.dom.Expression arg = (org.eclipse.jdt.core.dom.Expression) mi.arguments().get(0);
String val = constantResolver.resolve(arg, context);
if (val != null) {
fieldValues.put(fieldName, val);
fieldValues.put("this." + fieldName, val);
}
}
}
}
}
// Check for direct field assignments: var.field = value
else if (expr instanceof org.eclipse.jdt.core.dom.Assignment assignment) {
if (assignment.getLeftHandSide() instanceof org.eclipse.jdt.core.dom.FieldAccess fa) {
if (fa.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName sn
&& sn.getIdentifier().equals(varName)) {
String fieldName = fa.getName().getIdentifier();
org.eclipse.jdt.core.dom.Expression rhs = assignment.getRightHandSide();
String val = constantResolver.resolve(rhs, context);
if (val != null) {
fieldValues.put(fieldName, val);
fieldValues.put("this." + fieldName, val);
}
}
}
}
}
}
}
}
/**
* Extracts a string representation of the receiver expression from a MethodInvocation.
* Handles ThisExpression, SimpleName, and other expression types.
*/
protected String getReceiverString(Expression receiver) {
if (receiver == null) {
return null;
}
if (receiver instanceof ThisExpression) {
return "this";
}
if (receiver instanceof SuperMethodInvocation) {
return "super";
}
if (receiver instanceof SimpleName sn) {
return sn.getIdentifier();
}
return receiver.toString();
}
/**
* Finds the receiver expression used in the caller method when calling the target method.
* Walks the caller's AST to find a MethodInvocation with the target method name
* and returns the receiver string.
*/
protected String findCallerReceiver(String callerFqn, String targetMethodFqn) {
int lastDot = callerFqn.lastIndexOf('.');
if (lastDot < 0) return null;
String callerClass = callerFqn.substring(0, lastDot);
String callerMethod = callerFqn.substring(lastDot + 1);
int targetLastDot = targetMethodFqn.lastIndexOf('.');
if (targetLastDot < 0) return null;
String targetMethodName = targetMethodFqn.substring(targetLastDot + 1);
TypeDeclaration callerTd = context.getTypeDeclaration(callerClass);
if (callerTd == null) return null;
MethodDeclaration callerMd = context.findMethodDeclaration(callerTd, callerMethod, false);
if (callerMd == null || callerMd.getBody() == null) return null;
final String[] foundReceiver = {null};
callerMd.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (foundReceiver[0] != null) return false;
String called = resolveCalledMethod(node);
if (called != null && (called.equals(targetMethodFqn) || called.endsWith("." + targetMethodName))) {
foundReceiver[0] = getReceiverString(node.getExpression());
return false;
}
return super.visit(node);
}
});
return foundReceiver[0];
}
protected String resolveMethodInvocationReturnType(MethodInvocation mi) {
Expression receiver = mi.getExpression();
String receiverType = null;
if (receiver == null) {
TypeDeclaration td = findEnclosingType(mi);
if (td != null) {
receiverType = context.getFqn(td);
}
} else if (receiver instanceof SimpleName sn) {
receiverType = resolveReceiverTypeFallback(sn);
} else if (receiver instanceof FieldAccess fa) {
receiverType = resolveReceiverTypeFallback(fa.getName());
} else if (receiver instanceof MethodInvocation innerMi) {
receiverType = resolveMethodInvocationReturnType(innerMi);
}
if (receiverType == null) {
ITypeBinding binding = receiver != null ? receiver.resolveTypeBinding() : null;
if (binding != null) {
receiverType = binding.getErasure().getQualifiedName();
}
}
if (receiverType != null) {
String rawType = receiverType;
if (rawType.contains("<")) {
rawType = rawType.substring(0, rawType.indexOf('<'));
}
if (rawType.equals("java.util.Map") || rawType.equals("Map")) {
String valType = resolveMapValueType(mi);
if (valType != null) return valType;
}
if (receiverType.contains("<")) {
receiverType = receiverType.substring(0, receiverType.indexOf('<'));
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration td = context.getTypeDeclaration(receiverType);
if (td == null && receiverType.contains(".")) {
String simpleClass = receiverType.substring(receiverType.lastIndexOf('.') + 1);
td = context.getTypeDeclaration(simpleClass);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getReturnType2() != null) {
return resolveTypeToFqn(md.getReturnType2(), mi);
}
}
}
IMethodBinding methodBinding = mi.resolveMethodBinding();
if (methodBinding != null && methodBinding.getReturnType() != null) {
return methodBinding.getReturnType().getErasure().getQualifiedName();
}
return null;
}
}

View File

@@ -0,0 +1,419 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
@Slf4j
public class JdtCallGraphEngine extends AbstractCallGraphEngine {
private final InjectionPointAnalyzer injectionAnalyzer;
private String currentMethodFqn;
private Map<String, List<CallEdge>> graph;
public JdtCallGraphEngine(CodebaseContext context, InjectionPointAnalyzer injectionAnalyzer) {
super(context);
this.injectionAnalyzer = injectionAnalyzer;
}
@Override
public Map<String, List<CallEdge>> getCallGraph() {
return buildCallGraph();
}
@SuppressWarnings("unchecked")
protected Map<String, List<CallEdge>> buildCallGraph() {
Map<String, List<CallEdge>> cached = (Map<String, List<CallEdge>>) context.getCache().get("jdtCallGraph");
if (cached != null) {
this.graph = cached;
return cached;
}
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
for (String calledMethod : calledMethods) {
CallEdge edge = new CallEdge(calledMethod, args);
edge.setConstraint(resolveConstraint(node, calledMethod, constraint));
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration td2 = findEnclosingType(node);
if (td2 != null) {
String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) {
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
CallEdge edge = new CallEdge(refMethod, implicitArgs);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
CallEdge edge = new CallEdge(calledMethod, implicitArgs);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args);
implEdge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(implEdge);
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = 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;
}
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
CallEdge edge = new CallEdge(calledMethod, args);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
}
}
return super.visit(node);
}
});
}
context.getCache().put("jdtCallGraph", graph);
return graph;
}
@Override
protected String resolveArgument(org.eclipse.jdt.core.dom.Expression expr) {
if (expr == null) {
return "null";
}
// Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...))
org.eclipse.jdt.core.dom.Expression unwrappedBuilder = unwrapMessageBuilder(expr);
if (unwrappedBuilder != expr) {
expr = unwrappedBuilder;
}
// Trace variable to resolve local variable references
org.eclipse.jdt.core.dom.Expression tracedExpr = traceVariable(expr);
if (tracedExpr instanceof org.eclipse.jdt.core.dom.QualifiedName
|| tracedExpr instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation
|| tracedExpr instanceof org.eclipse.jdt.core.dom.StringLiteral
|| tracedExpr instanceof org.eclipse.jdt.core.dom.NumberLiteral) {
expr = tracedExpr; // Only accept trace if it resolves to a pure constant/primitive/constructor
}
// Delegate to base class for lambda, method reference, and constructor unwrapping
String result = super.resolveArgument(expr);
if (result != null) {
result = result.replaceAll("->\\s*yield\\s+", "-> ");
}
return result;
}
protected String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
if (receiver instanceof MethodInvocation miReceiver) {
String returnType = resolveMethodInvocationReturnType(miReceiver);
if (returnType != null) {
return returnType + "." + methodName;
}
}
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInType(td, methodName);
}
return null;
}
if (injectionAnalyzer != null) {
IBinding nameBinding = null;
if (receiver instanceof SimpleName sn) {
nameBinding = sn.resolveBinding();
} else if (receiver instanceof FieldAccess fa) {
nameBinding = fa.resolveFieldBinding();
}
if (nameBinding instanceof IVariableBinding varBinding) {
log.debug("CALLGRAPH RESOLVING BEAN FOR BINDING: {} calling {}", varBinding.getName(), methodName);
String concreteFqn = injectionAnalyzer.resolveInjectedBeanFqn(varBinding);
if (concreteFqn != null) {
log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn);
return concreteFqn + "." + methodName;
} else {
log.debug(" -> RESOLVE FAILED, RETURNED NULL");
}
} else {
log.debug("CALLGRAPH NAME BINDING IS NOT VARIABLE: {} calling {}", nameBinding, methodName);
}
}
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
String typeName = null;
try {
if (binding.getErasure() != null) {
typeName = binding.getErasure().getQualifiedName();
if (typeName == null || typeName.isEmpty()) {
typeName = binding.getErasure().getName();
}
}
} catch (Exception e) {
// Ignore JDT internal exceptions
}
if (typeName == null || typeName.isEmpty()) {
typeName = binding.getQualifiedName();
if (typeName == null || typeName.isEmpty()) {
typeName = binding.getName();
}
if (typeName != null && typeName.contains("<")) {
typeName = typeName.replaceAll("<.*>", "");
}
}
if (typeName != null && !typeName.isEmpty()) {
org.eclipse.jdt.core.dom.TypeDeclaration resolvedTd = context.getTypeDeclaration(typeName);
if (resolvedTd != null && context.findMethodDeclaration(resolvedTd, methodName, true) != null) {
return typeName + "." + methodName;
}
}
}
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
}
if (receiver instanceof SimpleName sn) {
String fallbackTypeFqn = resolveReceiverTypeFallback(sn);
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
String receiverName = sn.getIdentifier();
return receiverName + "." + methodName;
}
return null;
}
private String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier();
// 1. Check local variables in enclosing method
MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode);
if (enclosingMethod != null) {
// Check parameters
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(svd.getType(), receiverNameNode);
}
}
// Check method body (local variables)
if (enclosingMethod.getBody() != null) {
Type[] foundType = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType();
}
}
return super.visit(node);
}
});
if (foundType[0] != null) {
return resolveTypeToFqn(foundType[0], receiverNameNode);
}
}
}
// 2. Check fields in enclosing class and its superclasses
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
TypeDeclaration currentType = enclosingType;
while (currentType != null) {
for (FieldDeclaration field : currentType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(field.getType(), receiverNameNode);
}
}
}
String superclass = context.getSuperclassFqn(currentType);
currentType = superclass != null ? context.getTypeDeclaration(superclass) : null;
}
return null;
}
private String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode);
} else {
simpleName = type.toString();
}
CompilationUnit cu = (CompilationUnit) contextNode.getRoot();
TypeDeclaration td = context.getTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching if CodebaseContext doesn't know it (e.g., external library)
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
return simpleName;
}
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;
}
protected String resolveMethodInvocationReturnType(MethodInvocation mi) {
Expression receiver = mi.getExpression();
String receiverType = null;
if (receiver == null) {
TypeDeclaration td = findEnclosingType(mi);
if (td != null) {
receiverType = context.getFqn(td);
}
} else if (receiver instanceof SimpleName sn) {
receiverType = resolveReceiverTypeFallback(sn);
} else if (receiver instanceof FieldAccess fa) {
receiverType = resolveReceiverTypeFallback(fa.getName());
} else if (receiver instanceof MethodInvocation innerMi) {
receiverType = resolveMethodInvocationReturnType(innerMi);
}
if (receiverType == null) {
ITypeBinding binding = receiver != null ? receiver.resolveTypeBinding() : null;
if (binding != null) {
receiverType = binding.getErasure().getQualifiedName();
}
}
if (receiverType != null) {
String rawType = receiverType;
if (rawType.contains("<")) {
rawType = rawType.substring(0, rawType.indexOf('<'));
}
if (rawType.equals("java.util.Map") || rawType.equals("Map")) {
String valType = resolveMapValueType(mi);
if (valType != null) return valType;
}
if (receiverType.contains("<")) {
receiverType = receiverType.substring(0, receiverType.indexOf('<'));
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration td = context.getTypeDeclaration(receiverType);
if (td == null && receiverType.contains(".")) {
String simpleClass = receiverType.substring(receiverType.lastIndexOf('.') + 1);
td = context.getTypeDeclaration(simpleClass);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getReturnType2() != null) {
return resolveTypeToFqn(md.getReturnType2(), mi);
}
}
}
return null;
}
}

View File

@@ -4,6 +4,10 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
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 click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.CompilationUnit; import org.eclipse.jdt.core.dom.CompilationUnit;
@@ -21,9 +25,12 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
private final GenericEventDetector eventDetector; private final GenericEventDetector eventDetector;
private final SpringMvcDetector mvcDetector; private final SpringMvcDetector mvcDetector;
private final MessagingDetector messagingDetector; private final MessagingDetector messagingDetector;
private final CallGraphBuilder callGraphBuilder; private final CallGraphEngine callGraphEngine;
private final LifecycleDetector lifecycleDetector; private final LifecycleDetector lifecycleDetector;
private final InterceptorDetector interceptorDetector; private final InterceptorDetector interceptorDetector;
private final SpringComponentDetector componentDetector;
private List<TriggerPoint> cachedTriggers;
private List<EntryPoint> cachedEntryPoints;
public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) { public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) {
this.context = context; this.context = context;
@@ -31,13 +38,27 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints()); this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints());
this.mvcDetector = new SpringMvcDetector(context, constantResolver); this.mvcDetector = new SpringMvcDetector(context, constantResolver);
this.messagingDetector = new MessagingDetector(context); this.messagingDetector = new MessagingDetector(context);
this.callGraphBuilder = new CallGraphBuilder(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);
this.callGraphEngine = new JdtCallGraphEngine(context, injectionAnalyzer);
this.lifecycleDetector = new LifecycleDetector(context); this.lifecycleDetector = new LifecycleDetector(context);
this.interceptorDetector = new InterceptorDetector(context); this.interceptorDetector = new InterceptorDetector(context);
this.componentDetector = new SpringComponentDetector(context);
} }
@Override @Override
public List<TriggerPoint> findTriggerPoints() { public List<TriggerPoint> findTriggerPoints() {
if (cachedTriggers != null) {
return cachedTriggers;
}
List<TriggerPoint> allTriggers = new ArrayList<>(); List<TriggerPoint> allTriggers = new ArrayList<>();
var cus = context.getCompilationUnits(); var cus = context.getCompilationUnits();
log.info("JdtIntelligenceProvider scanning {} compilation units for triggers", cus.size()); log.info("JdtIntelligenceProvider scanning {} compilation units for triggers", cus.size());
@@ -46,11 +67,15 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
allTriggers.addAll(lifecycleDetector.detect(cu)); allTriggers.addAll(lifecycleDetector.detect(cu));
} }
log.info("Found {} triggers (including lifecycle) in total", allTriggers.size()); log.info("Found {} triggers (including lifecycle) in total", allTriggers.size());
cachedTriggers = allTriggers;
return allTriggers; return allTriggers;
} }
@Override @Override
public List<EntryPoint> findEntryPoints() { public List<EntryPoint> findEntryPoints() {
if (cachedEntryPoints != null) {
return cachedEntryPoints;
}
List<EntryPoint> allEntryPoints = new ArrayList<>(); List<EntryPoint> allEntryPoints = new ArrayList<>();
var cus = context.getCompilationUnits(); var cus = context.getCompilationUnits();
log.info("JdtIntelligenceProvider scanning {} compilation units for entry points", cus.size()); log.info("JdtIntelligenceProvider scanning {} compilation units for entry points", cus.size());
@@ -58,15 +83,17 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
allEntryPoints.addAll(mvcDetector.detect(cu)); allEntryPoints.addAll(mvcDetector.detect(cu));
allEntryPoints.addAll(messagingDetector.detect(cu)); allEntryPoints.addAll(messagingDetector.detect(cu));
allEntryPoints.addAll(interceptorDetector.detect(cu)); allEntryPoints.addAll(interceptorDetector.detect(cu));
allEntryPoints.addAll(componentDetector.detect(cu));
} }
log.info("Found {} entry points (including interceptors and listeners) in total", allEntryPoints.size()); log.info("Found {} entry points (including interceptors and listeners) in total", allEntryPoints.size());
cachedEntryPoints = allEntryPoints;
return allEntryPoints; return allEntryPoints;
} }
@Override @Override
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) { public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers", entryPoints.size(), triggers.size()); log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers", entryPoints.size(), triggers.size());
return callGraphBuilder.findChains(entryPoints, triggers); return callGraphEngine.findChains(entryPoints, triggers);
} }
@Override @Override

View File

@@ -71,6 +71,7 @@ public class MessagingDetector {
.name(protocolName + ": " + destination) .name(protocolName + ": " + destination)
.className(context.getFqn((TypeDeclaration) method.getParent())) .className(context.getFqn((TypeDeclaration) method.getParent()))
.methodName(method.getName().getIdentifier()) .methodName(method.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn((TypeDeclaration) method.getParent())))
.metadata(metadata) .metadata(metadata)
.parameters(extractParameters(method)) .parameters(extractParameters(method))
.build()); .build());

View File

@@ -0,0 +1,117 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.RequiredArgsConstructor;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RequiredArgsConstructor
public class SpringComponentDetector {
private final CodebaseContext context;
public List<EntryPoint> detect(CompilationUnit cu) {
List<EntryPoint> entryPoints = new ArrayList<>();
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodDeclaration node) {
if (!(node.getParent() instanceof TypeDeclaration)) {
return super.visit(node);
}
TypeDeclaration parentTd = (TypeDeclaration) node.getParent();
for (Object modifier : node.modifiers()) {
if (modifier instanceof Annotation annotation) {
String typeName = annotation.getTypeName().getFullyQualifiedName();
if (typeName.endsWith("Scheduled")) {
Map<String, String> meta = new HashMap<>();
String cron = extractAnnotationValue(annotation, "cron");
if (!cron.isEmpty())
meta.put("cron", cron);
String fixedRate = extractAnnotationValue(annotation, "fixedRate");
if (!fixedRate.isEmpty())
meta.put("fixedRate", fixedRate);
String fixedDelay = extractAnnotationValue(annotation, "fixedDelay");
if (!fixedDelay.isEmpty())
meta.put("fixedDelay", fixedDelay);
entryPoints.add(EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.name("@Scheduled: " + node.getName().getIdentifier())
.className(context.getFqn(parentTd))
.methodName(node.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn(parentTd)))
.metadata(meta)
.build());
} else if (typeName.endsWith("EventListener")) {
Map<String, String> meta = new HashMap<>();
String classes = extractAnnotationValue(annotation, "classes");
if (!classes.isEmpty())
meta.put("classes", classes);
String condition = extractAnnotationValue(annotation, "condition");
if (!condition.isEmpty())
meta.put("condition", condition);
entryPoints.add(EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.name("@EventListener: " + node.getName().getIdentifier())
.className(context.getFqn(parentTd))
.methodName(node.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn(parentTd)))
.metadata(meta)
.build());
} else if (typeName.endsWith("Around") || typeName.endsWith("Before") || typeName.endsWith("After") || typeName.endsWith("AfterReturning") || typeName.endsWith("AfterThrowing")) {
Map<String, String> meta = new HashMap<>();
String value = extractAnnotationValue(annotation, "value");
if (!value.isEmpty())
meta.put("pointcut", value);
else {
String pointcut = extractAnnotationValue(annotation, "pointcut");
if (!pointcut.isEmpty())
meta.put("pointcut", pointcut);
}
entryPoints.add(EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.name("@" + annotation.getTypeName().getFullyQualifiedName() + ": " + node.getName().getIdentifier())
.className(context.getFqn(parentTd))
.methodName(node.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn(parentTd)))
.metadata(meta)
.build());
}
}
}
return super.visit(node);
}
});
return entryPoints;
}
private String extractAnnotationValue(Annotation annotation, String memberName) {
if (annotation instanceof SingleMemberAnnotation sma) {
if ("value".equals(memberName)) {
return sma.getValue().toString();
}
} else if (annotation instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if (pair.getName().getIdentifier().equals(memberName)) {
return pair.getValue().toString();
}
}
}
return "";
}
}

View File

@@ -0,0 +1,148 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class TypeResolver {
private final CodebaseContext context;
public TypeResolver(CodebaseContext context) {
this.context = context;
}
public int getParameterIndex(String methodFqn, String paramName) {
if (methodFqn == null || !methodFqn.contains(".")) return -1;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
for (int i = 0; i < md.parameters().size(); i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) md.parameters().get(i);
if (svd.getName().getIdentifier().equals(paramName)) {
return i;
}
}
}
}
return -1;
}
public String getParameterName(String methodFqn, int index) {
if (methodFqn == null || !methodFqn.contains(".") || index < 0) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && index < md.parameters().size()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) md.parameters().get(index);
return svd.getName().getIdentifier();
}
}
return null;
}
public String getParameterType(String methodFqn, int index) {
if (methodFqn == null || !methodFqn.contains(".") || index < 0) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && index < md.parameters().size()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) md.parameters().get(index);
return svd.getType().toString();
}
}
return null;
}
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;
if (actualType.equals("Object") || actualType.equals("java.lang.Object")) return true;
if (expectedType.length() == 1 && Character.isUpperCase(expectedType.charAt(0))) return true;
if (actualType.length() == 1 && Character.isUpperCase(actualType.charAt(0))) return true;
String originalExpectedType = expectedType;
if (actualType.contains("<")) {
String rawType = actualType.substring(0, actualType.indexOf('<'));
if (rawType.equals("List") || rawType.equals("Set") || rawType.equals("Collection") || rawType.equals("Iterable") || rawType.endsWith("List") || rawType.endsWith("Set") || rawType.endsWith("Collection")) {
int start = actualType.indexOf('<');
int end = actualType.lastIndexOf('>');
if (start >= 0 && end > start) {
actualType = actualType.substring(start + 1, end);
}
}
}
if (expectedType.contains("<")) {
String rawType = expectedType.substring(0, expectedType.indexOf('<'));
if (rawType.equals("List") || rawType.equals("Set") || rawType.equals("Collection") || rawType.equals("Iterable") || rawType.endsWith("List") || rawType.endsWith("Set") || rawType.endsWith("Collection")) {
int start = expectedType.indexOf('<');
int end = expectedType.lastIndexOf('>');
if (start >= 0 && end > start) {
expectedType = expectedType.substring(start + 1, end);
}
}
}
if (actualType.contains("<")) {
actualType = actualType.substring(0, actualType.indexOf('<'));
}
if (expectedType.contains("<")) {
expectedType = expectedType.substring(0, expectedType.indexOf('<'));
}
if (actualType.equals(expectedType)) return true;
if (actualType.endsWith("." + expectedType) || expectedType.endsWith("." + actualType)) return true;
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(actualType);
if (td != null) {
if (td instanceof TypeDeclaration typeDecl) {
String superFqn = context.getSuperclassFqn(typeDecl);
while (superFqn != null) {
if (superFqn.contains("<")) {
superFqn = superFqn.substring(0, superFqn.indexOf('<'));
}
if (superFqn.equals(expectedType) || superFqn.endsWith("." + expectedType) || expectedType.endsWith("." + superFqn)) return true;
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
superFqn = superTd != null ? context.getSuperclassFqn(superTd) : null;
}
}
List interfaces = java.util.Collections.emptyList();
if (td instanceof TypeDeclaration typeDecl) {
interfaces = typeDecl.superInterfaceTypes();
} else if (td instanceof EnumDeclaration enumDecl) {
interfaces = enumDecl.superInterfaceTypes();
} else if (td instanceof RecordDeclaration recordDecl) {
interfaces = recordDecl.superInterfaceTypes();
}
for (Object interfaceType : interfaces) {
String itStr = interfaceType.toString();
if (itStr.contains("<")) {
itStr = itStr.substring(0, itStr.indexOf('<'));
}
if (itStr.equals(expectedType) || itStr.endsWith("." + expectedType) || expectedType.endsWith("." + itStr)) return true;
}
}
if (originalExpectedType.contains("<")) {
int start = originalExpectedType.indexOf('<');
int end = originalExpectedType.lastIndexOf('>');
if (start >= 0 && end > start) {
String sub = originalExpectedType.substring(start + 1, end);
for (String part : sub.split(",")) {
String typeArg = part.trim();
if (isTypeCompatible(actualType, typeArg)) {
return true;
}
}
}
}
return false;
}
}

View File

@@ -0,0 +1,765 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class VariableTracer {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private ConstantExtractor constantExtractor;
private final click.kamil.springstatemachineexporter.ast.common.DataFlowModel dataFlowModel;
public VariableTracer(CodebaseContext context, ConstantResolver constantResolver) {
this.context = context;
this.constantResolver = constantResolver;
this.dataFlowModel = new click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel(context);
}
public void setConstantExtractor(ConstantExtractor constantExtractor) {
this.constantExtractor = constantExtractor;
}
public Expression traceVariable(Expression expr) {
List<Expression> all = traceVariableAll(expr);
return all.isEmpty() ? expr : all.get(all.size() - 1);
}
private Expression peelExpression(Expression expr) {
while (expr instanceof ParenthesizedExpression pe) {
expr = pe.getExpression();
}
if (expr instanceof CastExpression ce) {
return peelExpression(ce.getExpression());
}
return expr;
}
public List<Expression> traceVariableAll(Expression expr) {
return dataFlowModel.getReachingDefinitions(expr);
}
public List<Expression> traceVariableAll(Expression expr, Set<String> visitedVariables) {
return dataFlowModel.getReachingDefinitions(expr);
}
public Expression traceLocalSetter(String methodFqn, String varName, String getterName) {
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) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
final Expression[] setterArg = new Expression[1];
String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName;
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (node.getName().getIdentifier().equalsIgnoreCase("set" + propName) || node.getName().getIdentifier().equalsIgnoreCase(propName)) {
if (node.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) {
if (!node.arguments().isEmpty()) {
setterArg[0] = (Expression) node.arguments().get(0);
}
}
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof FieldAccess fa) {
if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) {
if (fa.getName().getIdentifier().equalsIgnoreCase(propName)) {
setterArg[0] = node.getRightHandSide();
}
}
} else if (node.getLeftHandSide() instanceof QualifiedName qqn) {
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
if (qqn.getName().getIdentifier().equalsIgnoreCase(propName)) {
setterArg[0] = node.getRightHandSide();
}
}
}
return super.visit(node);
}
});
return setterArg[0];
}
}
return null;
}
private String getFieldType(TypeDeclaration td, String fieldName, CodebaseContext context, java.util.Set<String> visited) {
if (td == null) return null;
String typeFqn = context.getFqn(td);
if (!visited.add(typeFqn)) return null;
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName)) {
return fd.getType().toString();
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
String result = getFieldType(superTd, fieldName, context, visited);
if (result != null) return result;
}
}
for (Object intfObj : td.superInterfaceTypes()) {
Type intfType = (Type) intfObj;
String intfName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(intfType);
String resolvedIntf = resolveTypeFqn(intfName, context, td);
if (resolvedIntf != null) {
TypeDeclaration intfTd = context.getTypeDeclaration(resolvedIntf);
if (intfTd != null) {
String result = getFieldType(intfTd, fieldName, context, visited);
if (result != null) return result;
}
}
}
return null;
}
private String resolveTypeFqn(String simpleName, CodebaseContext context, TypeDeclaration td) {
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
if (cu != null) {
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String name = imp.getName().getFullyQualifiedName();
if (name.endsWith("." + simpleName)) {
return name;
}
}
if (cu.getPackage() != null) {
return cu.getPackage().getName().getFullyQualifiedName() + "." + simpleName;
}
}
return simpleName;
}
public String getVariableDeclaredType(String methodFqn, String cleanFieldName) {
if (methodFqn == null) return null;
int lastDot = methodFqn.lastIndexOf('.');
if (lastDot > 0) {
String fqn = methodFqn.substring(0, lastDot);
String methodName = methodFqn.substring(lastDot + 1);
TypeDeclaration td = context.getTypeDeclaration(fqn);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, false);
if (md != null) {
for (Object pObj : md.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) pObj;
if (svd.getName().getIdentifier().equals(cleanFieldName)) {
return svd.getType().toString();
}
}
final String[] foundType = new String[1];
if (md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(cleanFieldName)) {
foundType[0] = node.getType().toString();
}
}
return super.visit(node);
}
@Override
public boolean visit(LambdaExpression node) {
for (Object paramObj : node.parameters()) {
VariableDeclaration param = (VariableDeclaration) paramObj;
if (param.getName().getIdentifier().equals(cleanFieldName)) {
if (param instanceof SingleVariableDeclaration svd && svd.getType() != null) {
foundType[0] = svd.getType().toString();
return false;
}
ASTNode parent = node.getParent();
if (parent instanceof MethodInvocation mi) {
Expression caller = mi.getExpression();
boolean hasMap = false;
while (caller instanceof MethodInvocation chainMi) {
String mName = chainMi.getName().getIdentifier();
if (mName.equals("map") || mName.equals("flatMap")) {
hasMap = true;
if (chainMi.arguments().size() == 1 && chainMi.arguments().get(0) instanceof LambdaExpression lambda) {
if (lambda.getBody() instanceof MethodInvocation mapMi) {
Expression mapCaller = mapMi.getExpression();
if (mapCaller instanceof SimpleName mapSn) {
String mapCallerType = getVariableDeclaredType(methodFqn, mapSn.getIdentifier());
if (mapCallerType != null) {
TypeDeclaration currentTd = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
TypeDeclaration td = context.getTypeDeclaration(mapCallerType, cu);
if (td != null) {
MethodDeclaration mapMd = context.findMethodDeclaration(td, mapMi.getName().getIdentifier(), true);
if (mapMd != null && mapMd.getReturnType2() != null) {
foundType[0] = mapMd.getReturnType2().toString();
return false;
}
}
}
}
}
}
}
if (mName.equals("stream") || mName.equals("filter") || mName.equals("map") ||
mName.equals("flatMap") || mName.equals("peek") || mName.equals("distinct") ||
mName.equals("sorted") || mName.equals("limit") || mName.equals("skip")) {
caller = chainMi.getExpression();
} else {
break;
}
}
if (!hasMap) {
if (caller instanceof SimpleName callerSn) {
String callerName = callerSn.getIdentifier();
if (!callerName.equals(cleanFieldName)) {
String callerType = getVariableDeclaredType(methodFqn, callerName);
if (callerType != null && callerType.contains("<")) {
int start = callerType.indexOf('<');
int end = callerType.lastIndexOf('>');
if (start >= 0 && end > start) {
foundType[0] = callerType.substring(start + 1, end);
return false;
}
}
}
} else if (caller instanceof FieldAccess fa) {
String callerName = fa.getName().getIdentifier();
if (!callerName.equals(cleanFieldName)) {
String callerType = getVariableDeclaredType(methodFqn, callerName);
if (callerType != null && callerType.contains("<")) {
int start = callerType.indexOf('<');
int end = callerType.lastIndexOf('>');
if (start >= 0 && end > start) {
foundType[0] = callerType.substring(start + 1, end);
return false;
}
}
}
}
}
}
}
}
return super.visit(node);
}
});
}
if (foundType[0] != null) return foundType[0];
}
// Fallback to fields including superclasses
String fieldType = getFieldType(td, cleanFieldName, context, new java.util.HashSet<>());
if (fieldType != null) return fieldType;
}
}
// Final fallback: ask constantExtractor to resolve it via method return constant
if (constantExtractor != null && methodFqn.contains(".")) {
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
CompilationUnit cu = td != null && td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
List<String> values = constantExtractor.resolveMethodReturnConstant(cleanFieldName, methodName, 0, new HashSet<>(), cu);
if (values != null && !values.isEmpty()) {
// If it resolves to some class constant, we could infer type or return a fallback
return null;
}
}
return null;
}
public String traceLocalVariable(String methodFqn, String varName) {
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) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
List<Expression> initializers = 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()));
}
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()));
}
return super.visit(node);
}
});
if (!initializers.isEmpty()) {
List<String> stringified = new ArrayList<>();
for (Expression expr : initializers) {
Expression traced = traceVariable(expr);
if (traced instanceof MethodInvocation mi) {
Expression innerMost = unwrapMethodInvocation(mi, 0, methodFqn);
stringified.add(innerMost.toString());
} else if (traced instanceof ArrayInitializer) {
stringified.add("new Object[]" + traced.toString());
} else {
stringified.add(traced.toString());
}
}
if (stringified.size() == 1) {
return stringified.get(0).replaceAll("->\\s*yield\\s+", "-> ");
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < stringified.size() - 1; i++) {
sb.append("true ? ").append(stringified.get(i)).append(" : ");
}
sb.append(stringified.get(stringified.size() - 1));
return sb.toString().replaceAll("->\\s*yield\\s+", "-> ");
}
}
// If local variable not found, check field assignments
String cleanFieldName = varName.startsWith("this.") ? varName.substring(5) : varName;
final Expression[] fieldInitializer = new Expression[1];
ASTVisitor fieldVisitor = new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(cleanFieldName) && node.getInitializer() != null) {
fieldInitializer[0] = node.getInitializer();
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
Expression left = node.getLeftHandSide();
if ((left instanceof SimpleName && ((SimpleName) left).getIdentifier().equals(cleanFieldName)) ||
(left instanceof FieldAccess && ((FieldAccess) left).getName().getIdentifier().equals(cleanFieldName))) {
fieldInitializer[0] = node.getRightHandSide();
}
return super.visit(node);
}
};
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(cleanFieldName) && frag.getInitializer() != null) {
fieldInitializer[0] = frag.getInitializer();
}
}
}
if (fieldInitializer[0] != null) return fieldInitializer[0].toString();
for (MethodDeclaration classMd : td.getMethods()) {
if (classMd.isConstructor() && classMd.getBody() != null) {
classMd.getBody().accept(fieldVisitor);
}
}
if (fieldInitializer[0] != null) return fieldInitializer[0].toString();
for (MethodDeclaration classMd : td.getMethods()) {
if (!classMd.isConstructor() && classMd.getBody() != null) {
classMd.getBody().accept(fieldVisitor);
}
}
if (fieldInitializer[0] != null) return fieldInitializer[0].toString();
}
return null;
}
public String traceLocalVariable(String methodFqn, String varName, Map<String, String> parameterValues) {
String result = traceLocalVariable(methodFqn, varName);
if (result != null && parameterValues != null && !parameterValues.isEmpty()) {
TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
if (td != null) {
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
final ASTNode[] switchNode = new ASTNode[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
Expression init = node.getInitializer();
if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION) {
switchNode[0] = init;
return false;
}
if (init.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = init;
return false;
}
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
Expression rhs = node.getRightHandSide();
if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION) {
switchNode[0] = rhs;
return false;
}
if (rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = rhs;
return false;
}
}
return super.visit(node);
}
});
if (switchNode[0] != null) {
if (switchNode[0] instanceof SwitchExpression se) {
String evaluated = constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
if (evaluated != null) return evaluated;
} else if (switchNode[0] instanceof SwitchStatement ss) {
String evaluated = constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
if (evaluated != null) return evaluated;
}
}
}
}
}
return result;
}
public Map<String, String> buildParameterValuesMap(String caller, String target, Map<String, List<CallEdge>> callGraph, List<String> path, int pathIndex) {
Map<String, String> paramValues = new HashMap<>();
List<CallEdge> edges = callGraph.get(caller);
boolean hasEdge = false;
if (edges != null) {
for (CallEdge edge : edges) {
if (edge.getTargetMethod().equals(target) || isHeuristicMatch(edge.getTargetMethod(), target)) {
hasEdge = true;
break;
}
}
}
// Bridge polymorphic interfaces to implementations without graph edges
if (!hasEdge) {
String callerSimple = caller.contains(".") ? caller.substring(caller.lastIndexOf('.') + 1) : caller;
String targetSimple = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target;
if (callerSimple.equals(targetSimple)) {
List<String> callerParams = getParameterNames(caller);
List<String> targetParams = getParameterNames(target);
if (callerParams != null && targetParams != null && callerParams.size() == targetParams.size()) {
for (int i = 0; i < callerParams.size(); i++) {
paramValues.put(targetParams.get(i), callerParams.get(i));
}
return paramValues;
}
}
}
if (edges == null) {
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;
int lastDot = target.lastIndexOf('.');
if (lastDot < 0) break;
String className = target.substring(0, lastDot);
String methodName = target.substring(lastDot + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) break;
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null) break;
List<String> paramNames = new ArrayList<>();
for (Object paramObj : md.parameters()) {
SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj;
paramNames.add(param.getName().getIdentifier());
}
int minSize = Math.min(paramNames.size(), args.size());
for (int j = 0; j < minSize; j++) {
String argValue = args.get(j);
String resolvedArgValue = resolveArgumentValue(argValue, caller, path, pathIndex, callGraph);
paramValues.put(paramNames.get(j), resolvedArgValue);
}
break;
}
}
return paramValues;
}
private List<String> getParameterNames(String methodFqn) {
int lastDot = methodFqn.lastIndexOf('.');
if (lastDot < 0) return null;
String className = methodFqn.substring(0, lastDot);
String methodName = methodFqn.substring(lastDot + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) return null;
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null) return null;
List<String> paramNames = new ArrayList<>();
for (Object paramObj : md.parameters()) {
SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj;
paramNames.add(param.getName().getIdentifier());
}
return paramNames;
}
public String resolveArgumentValue(String argValue, String callerMethod, List<String> path, int pathIndex, Map<String, List<CallEdge>> callGraph) {
if (argValue == null) return null;
if (argValue.contains(".") || argValue.startsWith("new ") || argValue.matches(".*[0-9].*") || (argValue.startsWith("\"") && argValue.endsWith("\""))) {
return argValue;
}
String tracedLocal = traceLocalVariable(callerMethod, argValue);
if (tracedLocal != null && !tracedLocal.equals(argValue)) {
if (tracedLocal.contains(".") || tracedLocal.startsWith("new ") || tracedLocal.matches(".*[0-9].*") || tracedLocal.contains("(") || (tracedLocal.startsWith("\"") && tracedLocal.endsWith("\""))) {
return tracedLocal;
}
argValue = tracedLocal;
}
int callerIndex = path.indexOf(callerMethod);
if (callerIndex <= 0) return argValue;
String prevCaller = path.get(callerIndex - 1);
List<CallEdge> prevEdges = callGraph.get(prevCaller);
if (prevEdges == null) return argValue;
for (CallEdge edge : prevEdges) {
if (edge.getTargetMethod().equals(callerMethod) || isHeuristicMatch(edge.getTargetMethod(), callerMethod)) {
List<String> prevArgs = edge.getArguments();
if (prevArgs == null || prevArgs.isEmpty()) break;
int lastDot = callerMethod.lastIndexOf('.');
if (lastDot < 0) break;
String className = callerMethod.substring(0, lastDot);
String methodName = callerMethod.substring(lastDot + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) break;
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null) break;
List<String> paramNames = new ArrayList<>();
for (Object paramObj : md.parameters()) {
SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj;
paramNames.add(param.getName().getIdentifier());
}
int paramIdx = paramNames.indexOf(argValue);
if (paramIdx >= 0 && paramIdx < prevArgs.size()) {
String prevArg = prevArgs.get(paramIdx);
return resolveArgumentValue(prevArg, prevCaller, path, pathIndex, callGraph);
}
break;
}
}
return argValue;
}
private boolean isHeuristicMatch(String neighbor, String target) {
if (neighbor == null || target == null) return false;
if (neighbor.equals(target)) return true;
if (neighbor.contains(".") && target.contains(".")) {
String methodNeighbor = neighbor.substring(neighbor.lastIndexOf('.') + 1);
String methodTarget = target.substring(target.lastIndexOf('.') + 1);
if (!methodNeighbor.equals(methodTarget)) return false;
String classNeighbor = neighbor.substring(0, neighbor.lastIndexOf('.'));
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;
List<String> impls = context.getImplementations(classTarget);
if (impls != null) {
for (String impl : impls) {
if (impl.equals(classNeighbor) || impl.endsWith("." + classNeighbor)) return true;
}
}
List<String> implsN = context.getImplementations(classNeighbor);
if (implsN != null) {
for (String impl : implsN) {
if (impl.equals(classTarget) || impl.endsWith("." + classTarget)) return true;
}
}
return false;
}
String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target;
String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor;
if (!simpleNeighbor.equals(simpleTarget)) return false;
String classNeighbor = neighbor.contains(".") ? neighbor.substring(0, neighbor.lastIndexOf('.')) : null;
String classTarget = target.contains(".") ? target.substring(0, target.lastIndexOf('.')) : null;
String simpleClassTarget = classTarget != null && classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
if (simpleClassNeighbor != null && simpleClassTarget != null) {
if (simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) return true;
if (simpleClassNeighbor.equals("this") || simpleClassNeighbor.equals("super")) return true;
return false;
}
return true;
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) {
if (depth > 5) return mi;
String targetMethodFqn = resolveTargetMethodFqn(mi, methodFqn);
if (targetMethodFqn != null && targetMethodFqn.contains(".")) {
String className = targetMethodFqn.substring(0, targetMethodFqn.lastIndexOf('.'));
String methodName = targetMethodFqn.substring(targetMethodFqn.lastIndexOf('.') + 1);
TypeDeclaration currentTd = methodFqn != null && methodFqn.contains(".") ? context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.'))) : null;
CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
TypeDeclaration td = context.getTypeDeclaration(className, cu);
if (td != null) {
MethodDeclaration localMd = context.findMethodDeclaration(td, methodName, true);
if (localMd != null) {
int paramIdx = getReturnedParameterIndex(localMd);
if (paramIdx >= 0 && paramIdx < mi.arguments().size()) {
Expression arg = peelExpression((Expression) mi.arguments().get(paramIdx));
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
}
return arg;
}
}
}
}
if (mi.getExpression() != null) {
String exprStr = mi.getExpression().toString();
if (!exprStr.equals("Optional") && !exprStr.equals("Stream") && !exprStr.equals("List") && !exprStr.equals("Set") && !exprStr.equals("Arrays") && !exprStr.equals("Objects") && !exprStr.equals("Mono") && !exprStr.equals("Flux")) {
return mi;
}
}
if (!mi.arguments().isEmpty()) {
String mName = mi.getName().getIdentifier();
String lowerName = mName.toLowerCase();
if (lowerName.startsWith("map") || lowerName.startsWith("parse") ||
lowerName.startsWith("convert") || lowerName.startsWith("to") ||
lowerName.startsWith("resolve") || lowerName.startsWith("build") ||
lowerName.startsWith("create") || lowerName.startsWith("from") ||
lowerName.startsWith("transform") || lowerName.startsWith("translate") ||
lowerName.startsWith("derive") || lowerName.startsWith("determine") ||
lowerName.startsWith("calculate") || lowerName.startsWith("decode") ||
lowerName.startsWith("extract") || lowerName.startsWith("get") ||
lowerName.startsWith("find") || lowerName.startsWith("validate")) {
return mi;
}
Expression arg = peelExpression((Expression) mi.arguments().get(0));
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
}
return arg;
}
return mi;
}
private String resolveTargetMethodFqn(MethodInvocation mi, String currentMethodFqn) {
if (currentMethodFqn == null || !currentMethodFqn.contains(".")) return null;
String currentClass = currentMethodFqn.substring(0, currentMethodFqn.lastIndexOf('.'));
if (mi.getExpression() == null || mi.getExpression().toString().equals("this")) {
return currentClass + "." + mi.getName().getIdentifier();
}
if (mi.getExpression() instanceof SimpleName sn) {
String declaredType = getVariableDeclaredType(currentMethodFqn, sn.getIdentifier());
if (declaredType != null) {
return declaredType + "." + mi.getName().getIdentifier();
}
} else if (mi.getExpression() instanceof FieldAccess fa) {
String declaredType = getVariableDeclaredType(currentMethodFqn, fa.getName().getIdentifier());
if (declaredType != null) {
return declaredType + "." + mi.getName().getIdentifier();
}
}
return null;
}
private int getReturnedParameterIndex(MethodDeclaration md) {
if (md.getBody() == null) return -1;
List<?> parameters = md.parameters();
if (parameters.isEmpty()) return -1;
final List<String> returnedExprs = new ArrayList<>();
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
returnedExprs.add(node.getExpression().toString());
}
return super.visit(node);
}
});
if (returnedExprs.size() == 1) {
String retStr = returnedExprs.get(0);
for (int i = 0; i < parameters.size(); i++) {
if (parameters.get(i) instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(retStr)) {
return i;
}
}
}
}
return -1;
}
}

View File

@@ -0,0 +1,93 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import org.eclipse.jdt.core.dom.IAnnotationBinding;
import org.eclipse.jdt.core.dom.IMemberValuePairBinding;
import org.eclipse.jdt.core.dom.IVariableBinding;
import java.util.List;
public class InjectionPointAnalyzer {
private final SpringDependencyResolver resolver;
public InjectionPointAnalyzer(SpringDependencyResolver resolver) {
this.resolver = resolver;
}
/**
* Resolves the Spring bean injected into the given variable.
*
* @param variableBinding The binding of the field or parameter being injected.
* @return The FQN of the resolved concrete bean class, or null if unresolved.
*/
public String resolveInjectedBeanFqn(IVariableBinding variableBinding) {
if (variableBinding == null || variableBinding.getType() == null) {
return null;
}
String requiredTypeFqn = variableBinding.getType().getQualifiedName();
String injectionName = variableBinding.getName();
String qualifier = extractQualifier(variableBinding);
List<SpringBean> resolvedBeans = resolver.resolve(requiredTypeFqn, qualifier, injectionName);
if (resolvedBeans != null && resolvedBeans.size() == 1) {
return resolvedBeans.get(0).getTypeFqn();
}
return null;
}
private String extractQualifier(IVariableBinding binding) {
String qualifier = getQualifierValue(binding.getAnnotations());
if (qualifier != null) {
return qualifier;
}
if (binding.isField()) {
org.eclipse.jdt.core.dom.ITypeBinding declaringClass = binding.getDeclaringClass();
if (declaringClass != null) {
for (org.eclipse.jdt.core.dom.IMethodBinding method : declaringClass.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) {
for (int i = 0; i < method.getParameterTypes().length; i++) {
org.eclipse.jdt.core.dom.ITypeBinding paramType = method.getParameterTypes()[i];
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.
return paramQual;
}
} catch (Exception e) {
// Ignore
}
}
}
}
}
}
return null;
}
private String getQualifierValue(IAnnotationBinding[] annotations) {
for (IAnnotationBinding ann : annotations) {
if (ann.getAnnotationType() != null && "org.springframework.beans.factory.annotation.Qualifier".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if ("value".equals(pair.getName()) && pair.getValue() instanceof String) {
return (String) pair.getValue();
}
}
}
}
return null;
}
}

View File

@@ -0,0 +1,27 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import java.util.HashSet;
import java.util.Set;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class SpringBean {
private String typeFqn;
@Builder.Default
private Set<String> assignableTypes = new HashSet<>();
@Builder.Default
private Set<String> beanNames = new HashSet<>();
@Builder.Default
private Set<String> qualifiers = new HashSet<>();
private boolean isPrimary;
private Integer order;
private String declaringClassFqn;
private String factoryMethodName;
}

View File

@@ -0,0 +1,17 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class SpringBeanRegistry {
private final List<SpringBean> beans = new ArrayList<>();
public void addBean(SpringBean bean) {
beans.add(bean);
}
public List<SpringBean> getBeans() {
return Collections.unmodifiableList(beans);
}
}

View File

@@ -0,0 +1,280 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import org.eclipse.jdt.core.dom.*;
import java.util.HashSet;
import java.util.Set;
public class SpringContextScanner extends ASTVisitor {
private final SpringBeanRegistry registry;
public SpringContextScanner(SpringBeanRegistry registry) {
this.registry = registry;
}
@Override
public boolean visit(TypeDeclaration node) {
ITypeBinding typeBinding = node.resolveBinding();
if (typeBinding == null) return true;
if (isSpringComponent(typeBinding, node)) {
SpringBean bean = SpringBean.builder()
.typeFqn(typeBinding.getQualifiedName())
.assignableTypes(getAssignableTypes(typeBinding))
.isPrimary(hasPrimary(typeBinding, node))
.qualifiers(extractQualifiers(typeBinding))
.order(extractOrder(typeBinding))
.declaringClassFqn(typeBinding.getQualifiedName())
.build();
// Default bean name is uncapitalized simple name
String defaultName = Character.toLowerCase(node.getName().getIdentifier().charAt(0)) + node.getName().getIdentifier().substring(1);
// Check if @Component or @Service specifies a name (e.g., @Service("myService"))
String explicitName = extractStereotypeValue(typeBinding);
if (explicitName != null && !explicitName.isEmpty()) {
bean.getBeanNames().add(explicitName);
} else {
bean.getBeanNames().add(defaultName);
}
registry.addBean(bean);
}
return true;
}
@Override
public boolean visit(MethodDeclaration node) {
IMethodBinding methodBinding = node.resolveBinding();
if (methodBinding == null) return true;
if (hasAnnotation(methodBinding, "org.springframework.context.annotation.Bean")) {
ITypeBinding returnType = methodBinding.getReturnType();
if (returnType != null) {
Set<String> assignables = getAssignableTypes(returnType);
String[] concreteType = new String[] { returnType.getQualifiedName() };
if (node.getBody() != null) {
node.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement ret) {
if (ret.getExpression() != null) {
ITypeBinding exprType = ret.getExpression().resolveTypeBinding();
if (exprType != null && exprType.isClass()) {
concreteType[0] = exprType.getQualifiedName();
assignables.addAll(getAssignableTypes(exprType));
}
}
return super.visit(ret);
}
});
}
SpringBean bean = SpringBean.builder()
.typeFqn(concreteType[0])
.assignableTypes(assignables)
.isPrimary(hasPrimaryMethod(methodBinding, node))
.qualifiers(extractQualifiers(methodBinding))
.order(extractOrder(methodBinding))
.declaringClassFqn(methodBinding.getDeclaringClass() != null ? methodBinding.getDeclaringClass().getQualifiedName() : null)
.factoryMethodName(node.getName().getIdentifier())
.build();
// Default bean name is method name
String defaultName = node.getName().getIdentifier();
// Check if @Bean specifies a name (e.g., @Bean("myBean"))
String explicitName = extractBeanName(methodBinding);
if (explicitName != null && !explicitName.isEmpty()) {
bean.getBeanNames().add(explicitName);
} else {
bean.getBeanNames().add(defaultName);
}
registry.addBean(bean);
}
}
return true;
}
private boolean isSpringComponent(ITypeBinding binding, TypeDeclaration node) {
if (hasMetaAnnotation(binding, "org.springframework.stereotype.Component") ||
hasMetaAnnotation(binding, "org.springframework.web.bind.annotation.RestController") ||
hasAnnotation(binding, "org.springframework.stereotype.Component") ||
hasAnnotation(binding, "org.springframework.stereotype.Service") ||
hasAnnotation(binding, "org.springframework.stereotype.Repository") ||
hasAnnotation(binding, "org.springframework.stereotype.Controller") ||
hasAnnotation(binding, "org.springframework.web.bind.annotation.RestController") ||
hasAnnotation(binding, "org.springframework.context.annotation.Configuration")) {
return true;
}
for (Object modObj : node.modifiers()) {
if (modObj instanceof Annotation ann) {
String name = ann.getTypeName().getFullyQualifiedName();
if (name.equals("Component") || name.equals("Service") || name.equals("Repository") ||
name.equals("Controller") || name.equals("RestController") || name.equals("Configuration")) {
return true;
}
}
}
return false;
}
private boolean hasPrimary(ITypeBinding binding, TypeDeclaration node) {
if (hasAnnotation(binding, "org.springframework.context.annotation.Primary")) return true;
for (Object modObj : node.modifiers()) {
if (modObj instanceof Annotation ann) {
String name = ann.getTypeName().getFullyQualifiedName();
if (name.equals("Primary")) return true;
}
}
return false;
}
private boolean hasPrimaryMethod(IMethodBinding binding, MethodDeclaration node) {
if (hasAnnotation(binding, "org.springframework.context.annotation.Primary")) return true;
for (Object modObj : node.modifiers()) {
if (modObj instanceof Annotation ann) {
String name = ann.getTypeName().getFullyQualifiedName();
if (name.equals("Primary")) return true;
}
}
return false;
}
private boolean hasMetaAnnotation(ITypeBinding binding, String targetFqn) {
return checkMetaAnnotation(binding.getAnnotations(), targetFqn, new HashSet<>());
}
private boolean checkMetaAnnotation(IAnnotationBinding[] annotations, String targetFqn, Set<String> visited) {
for (IAnnotationBinding ann : annotations) {
ITypeBinding annType = ann.getAnnotationType();
if (annType != null) {
String fqn = annType.getQualifiedName();
if (fqn.equals(targetFqn)) return true;
// Avoid circular meta-annotations (e.g. Documented -> Documented)
if (visited.add(fqn)) {
if (checkMetaAnnotation(annType.getAnnotations(), targetFqn, visited)) {
return true;
}
}
}
}
return false;
}
private boolean hasAnnotation(IBinding binding, String annotationFqn) {
IAnnotationBinding[] annotations = binding.getAnnotations();
for (IAnnotationBinding ann : annotations) {
if (ann.getAnnotationType() != null && annotationFqn.equals(ann.getAnnotationType().getQualifiedName())) {
return true;
}
}
return false;
}
private Set<String> getAssignableTypes(ITypeBinding binding) {
Set<String> types = new HashSet<>();
collectAssignableTypes(binding, types);
return types;
}
private void collectAssignableTypes(ITypeBinding binding, Set<String> types) {
if (binding == null) return;
String qName = binding.getQualifiedName();
if (qName != null && !qName.isEmpty()) {
types.add(qName);
}
ITypeBinding erasure = binding.getErasure();
if (erasure != null) {
String erasureName = erasure.getQualifiedName();
if (erasureName != null && !erasureName.isEmpty()) {
types.add(erasureName);
}
}
collectAssignableTypes(binding.getSuperclass(), types);
if (binding.getInterfaces() != null) {
for (ITypeBinding iface : binding.getInterfaces()) {
collectAssignableTypes(iface, types);
}
}
}
private Set<String> extractQualifiers(IBinding binding) {
Set<String> qualifiers = new HashSet<>();
for (IAnnotationBinding ann : binding.getAnnotations()) {
if (ann.getAnnotationType() != null && "org.springframework.beans.factory.annotation.Qualifier".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if ("value".equals(pair.getName()) && pair.getValue() instanceof String) {
qualifiers.add((String) pair.getValue());
}
}
}
}
return qualifiers;
}
private Integer extractOrder(IBinding binding) {
for (IAnnotationBinding ann : binding.getAnnotations()) {
if (ann.getAnnotationType() != null && "org.springframework.core.annotation.Order".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if ("value".equals(pair.getName())) {
Object val = pair.getValue();
if (val instanceof Integer) {
return (Integer) val;
} else if (val instanceof IVariableBinding) {
Object constantValue = ((IVariableBinding) val).getConstantValue();
if (constantValue instanceof Integer) {
return (Integer) constantValue;
}
}
}
}
return Integer.MAX_VALUE; // Spring's default for @Order()
}
}
return null;
}
private String extractStereotypeValue(ITypeBinding binding) {
for (IAnnotationBinding ann : binding.getAnnotations()) {
if (ann.getAnnotationType() == null) continue;
String fqn = ann.getAnnotationType().getQualifiedName();
if (checkMetaAnnotation(new IAnnotationBinding[]{ann}, "org.springframework.stereotype.Component", new HashSet<>()) ||
"org.springframework.stereotype.Component".equals(fqn) ||
"org.springframework.stereotype.Service".equals(fqn) ||
"org.springframework.stereotype.Repository".equals(fqn) ||
"org.springframework.stereotype.Controller".equals(fqn) ||
"org.springframework.web.bind.annotation.RestController".equals(fqn) ||
"org.springframework.context.annotation.Configuration".equals(fqn)) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if ("value".equals(pair.getName()) && pair.getValue() instanceof String) {
return (String) pair.getValue();
}
}
}
}
return null;
}
private String extractBeanName(IMethodBinding binding) {
for (IAnnotationBinding ann : binding.getAnnotations()) {
if ("org.springframework.context.annotation.Bean".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if (("value".equals(pair.getName()) || "name".equals(pair.getName())) && pair.getValue() instanceof String) {
return (String) pair.getValue();
} else if (("value".equals(pair.getName()) || "name".equals(pair.getName())) && pair.getValue() instanceof Object[]) {
Object[] vals = (Object[]) pair.getValue();
if (vals.length > 0 && vals[0] instanceof String) {
return (String) vals[0];
}
}
}
}
}
return null;
}
}

View File

@@ -0,0 +1,90 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class SpringDependencyResolver {
private final SpringBeanRegistry registry;
public SpringDependencyResolver(SpringBeanRegistry registry) {
this.registry = registry;
}
/**
* Resolves the exact SpringBean that would be injected for a given injection point.
*
* @param requiredTypeFqn The exact FQN of the type requested at the injection point.
* @param qualifier The value of the @Qualifier annotation on the injection point (if any).
* @param injectionName The name of the field or parameter being injected (used as fallback).
* @return A list of resolved beans. Usually 1. If 0, unresolved. If > 1, ambiguous.
*/
public List<SpringBean> resolve(String requiredTypeFqn, String qualifier, String injectionName) {
if (requiredTypeFqn == null) return new ArrayList<>();
log.debug("RESOLVING {} qual={} injectionName={}", requiredTypeFqn, qualifier, injectionName);
// 1. Filter by Type (Exact FQN or Assignable Type)
List<SpringBean> candidates = registry.getBeans().stream()
.filter(bean -> requiredTypeFqn.equals(bean.getTypeFqn()) || bean.getAssignableTypes().contains(requiredTypeFqn))
.collect(Collectors.toList());
log.debug("CANDIDATES AFTER TYPE: {}", candidates.stream().map(c -> c.getTypeFqn() + " (primary=" + c.isPrimary() + " names=" + c.getBeanNames() + " qual=" + c.getQualifiers() + ")").collect(Collectors.toList()));
if (candidates.isEmpty() || candidates.size() == 1) {
log.debug("RETURNING: {}", candidates.size());
return candidates;
}
// 2. Filter by Qualifier (if provided)
if (qualifier != null && !qualifier.isEmpty()) {
List<SpringBean> qualifiedCandidates = candidates.stream()
.filter(bean -> bean.getQualifiers().contains(qualifier) || bean.getBeanNames().contains(qualifier))
.collect(Collectors.toList());
if (qualifiedCandidates.size() == 1) {
return qualifiedCandidates;
} else if (!qualifiedCandidates.isEmpty()) {
candidates = qualifiedCandidates;
}
}
// 3. Filter by @Primary
List<SpringBean> primaryCandidates = candidates.stream()
.filter(SpringBean::isPrimary)
.collect(Collectors.toList());
if (primaryCandidates.size() == 1) {
return primaryCandidates;
}
// 4. Fallback to Injection Name (field name or parameter name)
if (injectionName != null && !injectionName.isEmpty()) {
List<SpringBean> namedCandidates = candidates.stream()
.filter(bean -> bean.getBeanNames().contains(injectionName))
.collect(Collectors.toList());
if (namedCandidates.size() == 1) {
return namedCandidates;
}
}
// 5. Fallback to @Order (lowest order wins)
Integer minOrder = candidates.stream()
.map(SpringBean::getOrder)
.filter(o -> o != null)
.min(Integer::compareTo)
.orElse(null);
if (minOrder != null) {
List<SpringBean> orderedCandidates = candidates.stream()
.filter(bean -> bean.getOrder() != null && bean.getOrder().equals(minOrder))
.collect(Collectors.toList());
if (!orderedCandidates.isEmpty()) {
candidates = orderedCandidates;
}
}
// Return whatever candidates remain (could be ambiguous)
log.debug("RETURNING: {}", candidates.size());
return candidates;
}
}

View File

@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver
import click.kamil.springstatemachineexporter.model.Action; import click.kamil.springstatemachineexporter.model.Action;
import click.kamil.springstatemachineexporter.model.Guard; import click.kamil.springstatemachineexporter.model.Guard;
import click.kamil.springstatemachineexporter.model.State; import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.Transition; import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.model.TransitionType; import click.kamil.springstatemachineexporter.model.TransitionType;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
@@ -209,23 +210,64 @@ public class AstTransitionParser {
Expression resolved = resolveArg((Expression) arg, argsMap); Expression resolved = resolveArg((Expression) arg, argsMap);
t.getTargetStates().add(context.resolveState(resolved, cu)); t.getTargetStates().add(context.resolveState(resolved, cu));
}); });
case "event" -> { case "event" -> {
Expression resolved = resolveArg((Expression) args.get(0), argsMap); Expression resolved = resolveArg((Expression) args.get(0), argsMap);
String eventValue = constantResolver.resolve(resolved, context); String eventValue = constantResolver.resolve(resolved, context);
if (eventValue != null) { if (eventValue != null) {
t.setEvent(eventValue); t.setEvent(Event.of(resolved.toString(), eventValue));
} else if (resolved instanceof QualifiedName qn) {
String qualifier = qn.getQualifier().toString();
String name = qn.getName().getIdentifier();
TypeDeclaration td = context.getTypeDeclaration(qualifier, cu);
if (td != null) {
t.setEvent(Event.of(resolved.toString(), context.getFqn(td) + "." + name));
} else {
t.setEvent(Event.of(resolved.toString(), qn.getFullyQualifiedName()));
}
} else if (resolved instanceof SimpleName sn) {
String name = sn.getIdentifier();
String full = name;
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + name)) {
full = impName;
break;
}
}
t.setEvent(Event.of(resolved.toString(), full));
} else { } else {
t.setEvent(QuotedExpression.of(resolved).toStringWithoutQuotes()); t.setEvent(Event.of(QuotedExpression.of(resolved).toStringWithoutQuotes()));
}
if (args.size() > 1) {
Expression actionArg = resolveArg((Expression) args.get(1), argsMap);
parseAction(actionArg, t, cu, context);
} }
} }
case "guard" -> { case "guard", "guardExpression" -> {
Expression resolved = resolveArg((Expression) args.get(0), argsMap); Expression resolved = resolveArg((Expression) args.get(0), argsMap);
parseGuard(resolved, t, cu, context); parseGuard(resolved, t, cu, context);
} }
case "guards" -> {
args.forEach(arg -> {
Expression resolved = resolveArg((Expression) arg, argsMap);
parseGuard(resolved, t, cu, context);
});
}
case "action" -> { case "action" -> {
Expression resolved = resolveArg((Expression) args.get(0), argsMap); Expression resolved = resolveArg((Expression) args.get(0), argsMap);
parseAction(resolved, t, cu, context); parseAction(resolved, t, cu, context);
} }
case "actions" -> {
args.forEach(arg -> {
Expression resolved = resolveArg((Expression) arg, argsMap);
parseAction(resolved, t, cu, context);
});
}
case "timer", "timerOnce" -> {
Expression resolved = resolveArg((Expression) args.get(0), argsMap);
t.setEvent(Event.of(methodName + "(" + resolved.toString() + ")", methodName + "(" + resolved.toString() + ")"));
}
} }
} }
return t; return t;
@@ -266,6 +308,15 @@ public class AstTransitionParser {
// Try same file // Try same file
ASTNode declNode = cu.findDeclaringNode(binding.getKey()); ASTNode declNode = cu.findDeclaringNode(binding.getKey());
if (declNode instanceof MethodDeclaration md) { if (declNode instanceof MethodDeclaration md) {
if (md.getBody() != null && md.getBody().statements().size() == 1) {
Object stmt = md.getBody().statements().get(0);
if (stmt instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) {
Expression retExpr = rs.getExpression();
if (isLambdaOrAnonymous(retExpr)) {
return retExpr.toString();
}
}
}
return md.toString(); return md.toString();
} }
@@ -326,6 +377,115 @@ public class AstTransitionParser {
return mr.toString(); return mr.toString();
} }
// 5. Bean Reference (SimpleName, FieldAccess, etc.)
String resolvedBeanBody = resolveBeanClassBody(expr, context);
if (resolvedBeanBody != null) {
return resolvedBeanBody;
}
return null;
}
private static String resolveBeanClassBody(Expression expr, CodebaseContext context) {
String typeFqn = null;
ITypeBinding binding = expr.resolveTypeBinding();
if (binding != null) {
typeFqn = binding.getQualifiedName();
} else if (expr instanceof SimpleName sn) {
// Check enclosing method first for local variables
MethodDeclaration enclosingMethod = findEnclosingMethod(expr);
if (enclosingMethod != null) {
final String[] foundType = {null};
final Expression[] foundInitializer = {null};
enclosingMethod.accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(sn.getIdentifier())) {
foundType[0] = vds.getType().toString();
foundInitializer[0] = fragment.getInitializer();
}
}
}
return super.visit(vds);
}
});
if (foundInitializer[0] != null) {
ASTNode root = expr.getRoot();
if (root instanceof CompilationUnit cu) {
String initLogic = extractInternalLogic(foundInitializer[0], cu, context);
if (initLogic != null) return initLogic;
}
}
if (foundType[0] != null) {
typeFqn = foundType[0];
}
}
if (typeFqn == null) {
TypeDeclaration enclosingClass = findEnclosingType(expr);
if (enclosingClass != null) {
for (FieldDeclaration fd : enclosingClass.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(sn.getIdentifier())) {
if (fragment.getInitializer() != null) {
ASTNode root = expr.getRoot();
if (root instanceof CompilationUnit cu) {
String initLogic = extractInternalLogic(fragment.getInitializer(), cu, context);
if (initLogic != null) return initLogic;
}
}
typeFqn = fd.getType().toString();
break;
}
}
}
}
}
}
}
if (typeFqn != null) {
if (typeFqn.contains("<")) {
typeFqn = typeFqn.substring(0, typeFqn.indexOf('<'));
}
TypeDeclaration beanTd = context.getTypeDeclaration(typeFqn);
if (beanTd == null) {
ASTNode root = expr.getRoot();
if (root instanceof CompilationUnit cu) {
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
if (!imp.isStatic() && !imp.isOnDemand()) {
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + typeFqn)) {
beanTd = context.getTypeDeclaration(impName);
break;
}
}
}
}
}
if (beanTd != null) {
for (MethodDeclaration md : beanTd.getMethods()) {
String name = md.getName().getIdentifier();
if ("evaluate".equals(name) || "execute".equals(name)) {
return md.toString();
}
}
return beanTd.toString();
}
}
return null;
}
private static MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof MethodDeclaration md) return md;
current = current.getParent();
}
return null; return null;
} }
@@ -377,7 +537,7 @@ public class AstTransitionParser {
TypeDeclaration td = findEnclosingType(quotedExpr.getExpression()); TypeDeclaration td = findEnclosingType(quotedExpr.getExpression());
String fqn = td != null ? context.getFqn(td) : null; String fqn = td != null ? context.getFqn(td) : null;
String sourceFile = fqn != null ? context.getRelativePath(fqn) : null; String sourceFile = fqn != null ? context.getRelativePath(fqn) : null;
t.setGuard(Guard.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression()), internalLogic, lineNumber, fqn, sourceFile)); t.getGuards().add(Guard.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression()), internalLogic, lineNumber, fqn, sourceFile));
} }
} }

View File

@@ -0,0 +1,620 @@
package click.kamil.springstatemachineexporter.ast.app;
import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.model.TransitionType;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class StateMachineAdapterParser {
private final CodebaseContext context;
private enum AnalysisGoal {TRANSITIONS, STATES}
public record AdapterStates(Set<String> initialStates, Set<String> endStates) {}
public record AdapterConfig(
List<Transition> transitions,
Set<String> initialStates,
Set<String> endStates
) {}
public StateMachineAdapterParser(CodebaseContext context) {
this.context = context;
}
public AdapterConfig parse(TypeDeclaration startClass) {
List<Transition> transitions = parseTransitions(startClass);
AdapterStates states = parseStates(startClass);
return new AdapterConfig(transitions, states.initialStates(), states.endStates());
}
public List<Transition> parseTransitions(TypeDeclaration startClass) {
List<Transition> allTransitions = new ArrayList<>();
Set<MethodVisit> visitedMethods = new HashSet<>();
aggregateTransitionsRecursive(startClass, startClass, allTransitions, new HashSet<>(), visitedMethods, new HashMap<>());
return allTransitions;
}
public AdapterStates parseStates(TypeDeclaration startClass) {
Set<String> initialStates = new HashSet<>();
Set<String> endStates = new HashSet<>();
Set<MethodVisit> visitedMethods = new HashSet<>();
aggregateStatesRecursive(startClass, startClass, initialStates, endStates, new HashSet<>(), visitedMethods, new HashMap<>());
return new AdapterStates(initialStates, endStates);
}
private void aggregateTransitionsRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, List<Transition> allTransitions, Set<String> visitedClasses, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
if (currentTd == null)
return;
String fqn = context.getFqn(currentTd);
if (visitedClasses.contains(fqn))
return;
visitedClasses.add(fqn);
for (MethodDeclaration method : currentTd.getMethods()) {
if (isConfigureTransitionsMethod(method)) {
allTransitions.addAll(parseTransitionsWithMethodCalls(method, searchStartClass, visitedMethods, argsMap));
}
}
CompilationUnit cu = (CompilationUnit) currentTd.getRoot();
// Hierarchy - Superclass
Type superclassType = currentTd.getSuperclassType();
if (superclassType != null) {
TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), cu);
if (superclassTd != null) {
aggregateTransitionsRecursive(superclassTd, searchStartClass, allTransitions, visitedClasses, visitedMethods, argsMap);
}
}
// Hierarchy - Interfaces
for (Object interfaceTypeObj : currentTd.superInterfaceTypes()) {
if (interfaceTypeObj instanceof Type interfaceType) {
TypeDeclaration interfaceTd = context.getTypeDeclaration(getSimpleName(interfaceType), cu);
if (interfaceTd != null) {
aggregateTransitionsRecursive(interfaceTd, searchStartClass, allTransitions, visitedClasses, visitedMethods, argsMap);
}
}
}
}
private record MethodVisit(MethodDeclaration method, Map<String, Object> args) {
}
private List<Transition> parseTransitionsWithMethodCalls(MethodDeclaration method, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
MethodVisit visit = new MethodVisit(method, new HashMap<>(argsMap));
if (visitedMethods.contains(visit)) {
return new ArrayList<>();
}
visitedMethods.add(visit);
Block body = method.getBody();
if (body == null) {
return new ArrayList<>();
}
return parseTransitionsInBlock(body, searchStartClass, visitedMethods, argsMap);
}
private List<Transition> parseTransitionsInExpression(Expression expr, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
List<Transition> transitions = new ArrayList<>();
if (expr instanceof MethodInvocation mi) {
if (isTransitionChainEntry(mi)) {
transitions.addAll(AstTransitionParser.parseTransitionsFromExpression(mi, convertToExpressionMap(argsMap), context));
} else if (isTransitionChainContinuation(mi, argsMap)) {
transitions.addAll(AstTransitionParser.parseTransitionsFromExpression(mi, convertToExpressionMap(argsMap), true, context));
} else if (isForEach(mi)) {
transitions.addAll(parseForEach(mi, searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
} else {
// Check if it's a call on a lambda parameter or a variable that resolved to a lambda
Expression lambdaReceiver = mi.getExpression();
if (lambdaReceiver instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof LambdaExpression lambda) {
transitions.addAll(parseLambdaInvocation(lambda, mi.arguments(), searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
}
}
MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass);
if (calledMethod == null) {
// Try to resolve method in another class if it's a static call or on a known type
Expression miReceiver = mi.getExpression();
if (miReceiver instanceof SimpleName sn) {
TypeDeclaration otherTd = context.getTypeDeclaration(sn.getIdentifier(), (CompilationUnit) searchStartClass.getRoot());
if (otherTd != null) {
calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), otherTd);
if (calledMethod != null) {
// For calls to other classes, we switch the "searchStartClass" to that class
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
transitions.addAll(parseTransitionsWithMethodCalls(calledMethod, otherTd, visitedMethods, nextArgsMap));
return transitions; // Handled
}
}
}
}
if (calledMethod != null) {
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
transitions.addAll(parseTransitionsWithMethodCalls(calledMethod, searchStartClass, visitedMethods, nextArgsMap));
}
}
}
return transitions;
}
private boolean isForEach(MethodInvocation mi) {
return mi.getName().getIdentifier().equals("forEach");
}
private List<Transition> parseLambdaInvocation(LambdaExpression lambda, List<?> arguments, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
Map<String, Object> lambdaArgsMap = new HashMap<>(argsMap);
List<?> params = lambda.parameters();
for (int i = 0; i < params.size() && i < arguments.size(); i++) {
Object p = params.get(i);
String paramName = "";
if (p instanceof VariableDeclaration vp)
paramName = vp.getName().getIdentifier();
if (!paramName.isEmpty()) {
lambdaArgsMap.put(paramName, resolveExpression((Expression) arguments.get(i), argsMap));
}
}
if (goal == AnalysisGoal.TRANSITIONS) {
if (lambda.getBody() instanceof Block block)
return parseTransitionsInBlock(block, searchStartClass, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr)
return parseTransitionsInExpression(lambdaExpr, searchStartClass, visitedMethods, lambdaArgsMap);
} else {
if (lambda.getBody() instanceof Block block)
parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr)
parseStatesInExpression(lambdaExpr, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
}
return Collections.emptyList();
}
private List<Transition> parseForEach(MethodInvocation forEachMi, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
List<Transition> transitions = new ArrayList<>();
Expression receiver = forEachMi.getExpression();
List<Expression> elements = unrollCollection(receiver, argsMap);
if (elements.isEmpty() || forEachMi.arguments().isEmpty())
return transitions;
Object lambdaObj = forEachMi.arguments().get(0);
if (lambdaObj instanceof LambdaExpression lambda) {
String paramName = getLambdaParamName(lambda);
for (Expression element : elements) {
Map<String, Object> lambdaArgsMap = new HashMap<>(argsMap);
if (!paramName.isEmpty())
lambdaArgsMap.put(paramName, element);
if (goal == AnalysisGoal.TRANSITIONS) {
if (lambda.getBody() instanceof Block block)
transitions.addAll(parseTransitionsInBlock(block, searchStartClass, visitedMethods, lambdaArgsMap));
else if (lambda.getBody() instanceof Expression lambdaExpr)
transitions.addAll(parseTransitionsInExpression(lambdaExpr, searchStartClass, visitedMethods, lambdaArgsMap));
} else {
if (lambda.getBody() instanceof Block block)
parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr)
parseStatesInExpression(lambdaExpr, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
}
}
}
return transitions;
}
@SuppressWarnings("unchecked")
private List<Transition> parseEnhancedForLoop(EnhancedForStatement efs, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
List<Transition> transitions = new ArrayList<>();
List<Expression> elements = unrollCollection(efs.getExpression(), argsMap);
String paramName = efs.getParameter().getName().getIdentifier();
for (Expression element : elements) {
Map<String, Object> loopArgsMap = new HashMap<>(argsMap);
loopArgsMap.put(paramName, element);
if (goal == AnalysisGoal.TRANSITIONS) {
if (efs.getBody() instanceof Block block)
transitions.addAll(parseTransitionsInBlock(block, searchStartClass, visitedMethods, loopArgsMap));
else if (efs.getBody() instanceof ExpressionStatement es)
transitions.addAll(parseTransitionsInExpression(es.getExpression(), searchStartClass, visitedMethods, loopArgsMap));
} else {
if (efs.getBody() instanceof Block block)
parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, loopArgsMap);
else if (efs.getBody() instanceof ExpressionStatement es)
parseStatesInExpression(es.getExpression(), searchStartClass, initialStates, endStates, visitedMethods, loopArgsMap);
}
}
return transitions;
}
@SuppressWarnings("unchecked")
private List<Expression> unrollCollection(Expression expr, Map<String, Object> argsMap) {
Set<Expression> visited = new HashSet<>();
Expression current = expr;
while (current != null) {
if (!visited.add(current))
break;
if (current instanceof MethodInvocation mi) {
String name = mi.getName().getIdentifier();
Expression receiver = mi.getExpression();
if ((name.equals("of") && receiver != null && (receiver.toString().equals("Stream") || receiver.toString().equals("List")))
|| (name.equals("asList") && receiver != null && receiver.toString().equals("Arrays"))) {
if (mi.arguments().size() == 1) {
Expression arg = (Expression) mi.arguments().get(0);
if (arg instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof List list)
return (List<Expression>) list;
}
}
return (List<Expression>) mi.arguments();
}
}
if (current instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof List list)
return (List<Expression>) list;
if (resolved instanceof Expression e) {
current = e;
continue;
}
}
if (current instanceof ArrayInitializer ai)
return (List<Expression>) ai.expressions();
break;
}
return Collections.emptyList();
}
private String getLambdaParamName(LambdaExpression lambda) {
if (lambda.parameters().size() == 1) {
Object p = lambda.parameters().get(0);
if (p instanceof VariableDeclaration vp)
return vp.getName().getIdentifier();
}
return "";
}
private List<Transition> parseTransitionsInBlock(Block block, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
List<Transition> transitions = new ArrayList<>();
for (Object stmt : block.statements()) {
if (stmt instanceof ExpressionStatement es)
transitions.addAll(parseTransitionsInExpression(es.getExpression(), searchStartClass, visitedMethods, argsMap));
else if (stmt instanceof VariableDeclarationStatement vds) {
for (Object fragmentObj : vds.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
Expression initializer = fragment.getInitializer();
if (initializer != null) {
Object resolved = resolve(initializer, argsMap);
if (resolved != null)
argsMap.put(fragment.getName().getIdentifier(), resolved);
transitions.addAll(parseTransitionsInExpression(initializer, searchStartClass, visitedMethods, argsMap));
}
}
}
} else if (stmt instanceof EnhancedForStatement efs)
transitions.addAll(parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
else if (stmt instanceof TryStatement ts)
transitions.addAll(parseTransitionsInBlock(ts.getBody(), searchStartClass, visitedMethods, argsMap));
}
return transitions;
}
private void parseStatesInBlock(Block block, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
for (Object stmt : block.statements()) {
if (stmt instanceof ExpressionStatement es)
parseStatesInExpression(es.getExpression(), searchStartClass, initialStates, endStates, visitedMethods, argsMap);
else if (stmt instanceof VariableDeclarationStatement vds) {
for (Object fragmentObj : vds.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
Expression initializer = fragment.getInitializer();
if (initializer != null) {
Object resolved = resolve(initializer, argsMap);
if (resolved != null)
argsMap.put(fragment.getName().getIdentifier(), resolved);
parseStatesInExpression(initializer, searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
}
} else if (stmt instanceof EnhancedForStatement efs)
parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
else if (stmt instanceof TryStatement ts)
parseStatesInBlock(ts.getBody(), searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
private void parseStatesInExpression(Expression expr, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
if (expr instanceof MethodInvocation mi) {
if (isWithStatesChain(mi, argsMap))
parseStateChain(mi, initialStates, endStates, argsMap);
else if (isForEach(mi))
parseForEach(mi, searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
else {
// Check if it's a call on a lambda parameter or a variable that resolved to a lambda
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof LambdaExpression lambda) {
parseLambdaInvocation(lambda, mi.arguments(), searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
}
}
MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass);
if (calledMethod != null) {
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
parseStatesWithMethodCalls(calledMethod, searchStartClass, initialStates, endStates, visitedMethods, nextArgsMap);
}
}
}
}
private Map<String, Object> buildArgsMap(MethodDeclaration method, MethodInvocation call, Map<String, Object> currentArgsMap) {
Map<String, Object> map = new HashMap<>(currentArgsMap);
List<?> params = method.parameters();
List<?> args = call.arguments();
for (int i = 0; i < params.size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) params.get(i);
String paramName = param.getName().getIdentifier();
if (param.isVarargs() && i < params.size()) {
List<Expression> varargList = new ArrayList<>();
for (int j = i; j < args.size(); j++) {
Object resolved = resolve((Expression) args.get(j), currentArgsMap);
if (resolved instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Expression e)
varargList.add(e);
}
} else if (resolved instanceof Expression e) {
varargList.add(e);
}
}
map.put(paramName, varargList);
} else if (i < args.size()) {
map.put(paramName, resolve((Expression) args.get(i), currentArgsMap));
}
}
return map;
}
private Object resolve(Expression expr, Map<String, Object> argsMap) {
if (expr instanceof NullLiteral)
return null;
Set<Expression> visited = new HashSet<>();
Object current = expr;
while (current instanceof SimpleName sn) {
if (!visited.add((SimpleName) current))
break;
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved == null || resolved == current)
break;
current = resolved;
}
return current;
}
private Expression resolveExpression(Expression expr, Map<String, Object> argsMap) {
Object resolved = resolve(expr, argsMap);
if (resolved instanceof Expression e)
return e;
return expr;
}
private Map<String, Expression> convertToExpressionMap(Map<String, Object> argsMap) {
Map<String, Expression> map = new HashMap<>();
for (Map.Entry<String, Object> entry : argsMap.entrySet()) {
if (entry.getValue() instanceof Expression e)
map.put(entry.getKey(), e);
}
return map;
}
private boolean isTransitionChainEntry(MethodInvocation mi) {
Expression current = mi;
while (current instanceof MethodInvocation call) {
String name = call.getName().getIdentifier();
if (TransitionType.fromMethodName(name).isPresent())
return true;
current = call.getExpression();
}
return false;
}
private boolean isTransitionChainContinuation(MethodInvocation mi, Map<String, Object> argsMap) {
Expression current = mi;
Set<Expression> visited = new HashSet<>();
while (current instanceof MethodInvocation call) {
if (!visited.add(current))
break;
String name = call.getName().getIdentifier();
if (Set.of("and", "source", "target", "event", "first", "then", "last").contains(name)) {
Expression receiver = call.getExpression();
if (receiver instanceof MethodInvocation next) {
current = next;
continue;
}
Object resolved = resolve(receiver, argsMap);
if (resolved instanceof MethodInvocation next) {
current = next;
continue;
}
return resolved == null; // null means it's a parameter we track
}
if (TransitionType.fromMethodName(name).isPresent())
return true;
break;
}
return false;
}
private MethodDeclaration findMethodInHierarchy(String methodName, TypeDeclaration td) {
return findMethodInHierarchy(methodName, td, new HashSet<>());
}
private MethodDeclaration findMethodInHierarchy(String methodName, TypeDeclaration td, Set<String> visited) {
if (td == null)
return null;
String fqn = context.getFqn(td);
if (visited.contains(fqn))
return null;
visited.add(fqn);
for (MethodDeclaration method : td.getMethods())
if (method.getName().getIdentifier().equals(methodName))
return method;
Type superclassType = td.getSuperclassType();
if (superclassType != null) {
TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), (CompilationUnit) td.getRoot());
return findMethodInHierarchy(methodName, superclassTd, visited);
}
return null;
}
private boolean isRelevantMethod(MethodDeclaration method) {
return true;
}
private MethodDeclaration findConfigureTransitionsMethod(TypeDeclaration td) {
for (MethodDeclaration method : td.getMethods())
if (isConfigureTransitionsMethod(method))
return method;
return null;
}
private boolean isConfigureTransitionsMethod(MethodDeclaration method) {
if (!method.getName().getIdentifier().equals("configure") || method.parameters().isEmpty())
return false;
return ((SingleVariableDeclaration) method.parameters().get(0)).getType().toString().contains("StateMachineTransitionConfigurer");
}
private void aggregateStatesRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<String> visitedClasses, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
if (currentTd == null)
return;
String fqn = context.getFqn(currentTd);
if (visitedClasses.contains(fqn))
return;
visitedClasses.add(fqn);
for (MethodDeclaration method : currentTd.getMethods()) {
if (isConfigureStatesMethod(method)) {
parseStatesWithMethodCalls(method, searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
CompilationUnit cu = (CompilationUnit) currentTd.getRoot();
// Hierarchy - Superclass
Type superclassType = currentTd.getSuperclassType();
if (superclassType != null) {
TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), cu);
if (superclassTd != null)
aggregateStatesRecursive(superclassTd, searchStartClass, initialStates, endStates, visitedClasses, visitedMethods, argsMap);
}
// Hierarchy - Interfaces
for (Object interfaceTypeObj : currentTd.superInterfaceTypes()) {
if (interfaceTypeObj instanceof Type interfaceType) {
TypeDeclaration interfaceTd = context.getTypeDeclaration(getSimpleName(interfaceType), cu);
if (interfaceTd != null)
aggregateStatesRecursive(interfaceTd, searchStartClass, initialStates, endStates, visitedClasses, visitedMethods, argsMap);
}
}
}
private void parseStatesWithMethodCalls(MethodDeclaration method, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
MethodVisit visit = new MethodVisit(method, new HashMap<>(argsMap));
if (visitedMethods.contains(visit))
return;
visitedMethods.add(visit);
Block body = method.getBody();
if (body == null)
return;
parseStatesInBlock(body, searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
private boolean isWithStatesChain(MethodInvocation mi, Map<String, Object> argsMap) {
Expression current = mi;
Set<Expression> visited = new HashSet<>();
while (current instanceof MethodInvocation call) {
if (!visited.add(current))
break;
if (call.getName().getIdentifier().equals("withStates"))
return true;
Expression receiver = call.getExpression();
if (receiver instanceof MethodInvocation next) {
current = next;
continue;
}
Object resolved = resolve(receiver, argsMap);
if (resolved instanceof MethodInvocation next) {
current = next;
continue;
}
break;
}
return false;
}
private void parseStateChain(MethodInvocation mi, Set<String> initialStates, Set<String> endStates, Map<String, Object> argsMap) {
List<MethodInvocation> chain = new ArrayList<>();
Expression current = mi;
while (current instanceof MethodInvocation m) {
chain.addFirst(m);
current = m.getExpression();
}
CompilationUnit cu = (CompilationUnit) mi.getRoot();
for (MethodInvocation call : chain) {
String methodName = call.getName().getIdentifier();
if (call.arguments().isEmpty())
continue;
Expression arg = resolveExpression((Expression) call.arguments().get(0), argsMap);
if ("initial".equals(methodName)) {
if (!isInsideRegionOrFork(call)) {
initialStates.add(context.resolveState(arg, cu).toString());
}
} else if ("end".equals(methodName)) {
endStates.add(context.resolveState(arg, cu).toString());
}
}
}
private boolean isInsideRegionOrFork(MethodInvocation mi) {
Expression expr = mi.getExpression();
while (expr instanceof MethodInvocation parent) {
if (List.of("fork", "region").contains(parent.getName().getIdentifier()))
return true;
expr = parent.getExpression();
}
return false;
}
private MethodDeclaration findConfigureStatesMethod(TypeDeclaration td) {
for (MethodDeclaration method : td.getMethods())
if (isConfigureStatesMethod(method))
return method;
return null;
}
private boolean isConfigureStatesMethod(MethodDeclaration method) {
if (!method.getName().getIdentifier().equals("configure") || method.parameters().isEmpty())
return false;
return ((SingleVariableDeclaration) method.parameters().get(0)).getType().toString().contains("StateMachineStateConfigurer");
}
private String getSimpleName(Type type) {
if (type.isSimpleType())
return ((SimpleType) type).getName().getFullyQualifiedName();
if (type.isParameterizedType())
return getSimpleName(((ParameterizedType) type).getType());
return type.toString();
}
}

View File

@@ -1,632 +1,37 @@
package click.kamil.springstatemachineexporter.ast.app; package click.kamil.springstatemachineexporter.ast.app;
import click.kamil.springstatemachineexporter.model.Transition; import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.model.TransitionType;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.Getter; import lombok.Getter;
import org.eclipse.jdt.core.dom.ArrayInitializer;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.EnhancedForStatement;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.ExpressionStatement;
import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.NullLiteral;
import org.eclipse.jdt.core.dom.ParameterizedType;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.TryStatement;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration; import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
public class StateMachineAggregator { public class StateMachineAggregator {
private final CodebaseContext context; private final CodebaseContext context;
private final StateMachineAdapterParser adapterParser;
private enum AnalysisGoal {TRANSITIONS, STATES}
public StateMachineAggregator(CodebaseContext context) {
this.context = context;
}
public List<Transition> aggregateTransitions(TypeDeclaration startClass) {
List<Transition> allTransitions = new ArrayList<>();
Set<MethodVisit> visitedMethods = new HashSet<>();
aggregateTransitionsRecursive(startClass, startClass, allTransitions, new HashSet<>(), visitedMethods, new HashMap<>());
return allTransitions;
}
private void aggregateTransitionsRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, List<Transition> allTransitions, Set<String> visitedClasses, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
if (currentTd == null)
return;
String fqn = context.getFqn(currentTd);
if (visitedClasses.contains(fqn))
return;
visitedClasses.add(fqn);
for (MethodDeclaration method : currentTd.getMethods()) {
if (isConfigureTransitionsMethod(method)) {
allTransitions.addAll(parseTransitionsWithMethodCalls(method, searchStartClass, visitedMethods, argsMap));
}
}
CompilationUnit cu = (CompilationUnit) currentTd.getRoot();
// Hierarchy - Superclass
Type superclassType = currentTd.getSuperclassType();
if (superclassType != null) {
TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), cu);
if (superclassTd != null) {
aggregateTransitionsRecursive(superclassTd, searchStartClass, allTransitions, visitedClasses, visitedMethods, argsMap);
}
}
// Hierarchy - Interfaces
for (Object interfaceTypeObj : currentTd.superInterfaceTypes()) {
if (interfaceTypeObj instanceof Type interfaceType) {
TypeDeclaration interfaceTd = context.getTypeDeclaration(getSimpleName(interfaceType), cu);
if (interfaceTd != null) {
aggregateTransitionsRecursive(interfaceTd, searchStartClass, allTransitions, visitedClasses, visitedMethods, argsMap);
}
}
}
}
private record MethodVisit(MethodDeclaration method, Map<String, Object> args) {
}
private List<Transition> parseTransitionsWithMethodCalls(MethodDeclaration method, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
MethodVisit visit = new MethodVisit(method, new HashMap<>(argsMap));
if (visitedMethods.contains(visit)) {
return new ArrayList<>();
}
visitedMethods.add(visit);
Block body = method.getBody();
if (body == null) {
return new ArrayList<>();
}
return parseTransitionsInBlock(body, searchStartClass, visitedMethods, argsMap);
}
private List<Transition> parseTransitionsInExpression(Expression expr, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
List<Transition> transitions = new ArrayList<>();
if (expr instanceof MethodInvocation mi) {
if (isTransitionChainEntry(mi)) {
transitions.addAll(AstTransitionParser.parseTransitionsFromExpression(mi, convertToExpressionMap(argsMap), context));
} else if (isTransitionChainContinuation(mi, argsMap)) {
transitions.addAll(AstTransitionParser.parseTransitionsFromExpression(mi, convertToExpressionMap(argsMap), true, context));
} else if (isForEach(mi)) {
transitions.addAll(parseForEach(mi, searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
} else {
// Check if it's a call on a lambda parameter or a variable that resolved to a lambda
Expression lambdaReceiver = mi.getExpression();
if (lambdaReceiver instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof LambdaExpression lambda) {
transitions.addAll(parseLambdaInvocation(lambda, mi.arguments(), searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
}
}
MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass);
if (calledMethod == null) {
// Try to resolve method in another class if it's a static call or on a known type
Expression miReceiver = mi.getExpression();
if (miReceiver instanceof SimpleName sn) {
TypeDeclaration otherTd = context.getTypeDeclaration(sn.getIdentifier(), (CompilationUnit) searchStartClass.getRoot());
if (otherTd != null) {
calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), otherTd);
if (calledMethod != null) {
// For calls to other classes, we switch the "searchStartClass" to that class
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
transitions.addAll(parseTransitionsWithMethodCalls(calledMethod, otherTd, visitedMethods, nextArgsMap));
return transitions; // Handled
}
}
}
}
if (calledMethod != null) {
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
transitions.addAll(parseTransitionsWithMethodCalls(calledMethod, searchStartClass, visitedMethods, nextArgsMap));
}
}
}
return transitions;
}
private boolean isForEach(MethodInvocation mi) {
return mi.getName().getIdentifier().equals("forEach");
}
private List<Transition> parseLambdaInvocation(LambdaExpression lambda, List<?> arguments, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
Map<String, Object> lambdaArgsMap = new HashMap<>(argsMap);
List<?> params = lambda.parameters();
for (int i = 0; i < params.size() && i < arguments.size(); i++) {
Object p = params.get(i);
String paramName = "";
if (p instanceof VariableDeclaration vp)
paramName = vp.getName().getIdentifier();
if (!paramName.isEmpty()) {
lambdaArgsMap.put(paramName, resolveExpression((Expression) arguments.get(i), argsMap));
}
}
if (goal == AnalysisGoal.TRANSITIONS) {
if (lambda.getBody() instanceof Block block)
return parseTransitionsInBlock(block, searchStartClass, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr)
return parseTransitionsInExpression(lambdaExpr, searchStartClass, visitedMethods, lambdaArgsMap);
} else {
if (lambda.getBody() instanceof Block block)
parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr)
parseStatesInExpression(lambdaExpr, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
}
return Collections.emptyList();
}
private List<Transition> parseForEach(MethodInvocation forEachMi, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
List<Transition> transitions = new ArrayList<>();
Expression receiver = forEachMi.getExpression();
List<Expression> elements = unrollCollection(receiver, argsMap);
if (elements.isEmpty() || forEachMi.arguments().isEmpty())
return transitions;
Object lambdaObj = forEachMi.arguments().get(0);
if (lambdaObj instanceof LambdaExpression lambda) {
String paramName = getLambdaParamName(lambda);
for (Expression element : elements) {
Map<String, Object> lambdaArgsMap = new HashMap<>(argsMap);
if (!paramName.isEmpty())
lambdaArgsMap.put(paramName, element);
if (goal == AnalysisGoal.TRANSITIONS) {
if (lambda.getBody() instanceof Block block)
transitions.addAll(parseTransitionsInBlock(block, searchStartClass, visitedMethods, lambdaArgsMap));
else if (lambda.getBody() instanceof Expression lambdaExpr)
transitions.addAll(parseTransitionsInExpression(lambdaExpr, searchStartClass, visitedMethods, lambdaArgsMap));
} else {
if (lambda.getBody() instanceof Block block)
parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr)
parseStatesInExpression(lambdaExpr, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
}
}
}
return transitions;
}
private List<Transition> parseEnhancedForLoop(EnhancedForStatement efs, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
List<Transition> transitions = new ArrayList<>();
List<Expression> elements = unrollCollection(efs.getExpression(), argsMap);
String paramName = efs.getParameter().getName().getIdentifier();
for (Expression element : elements) {
Map<String, Object> loopArgsMap = new HashMap<>(argsMap);
loopArgsMap.put(paramName, element);
if (goal == AnalysisGoal.TRANSITIONS) {
if (efs.getBody() instanceof Block block)
transitions.addAll(parseTransitionsInBlock(block, searchStartClass, visitedMethods, loopArgsMap));
else if (efs.getBody() instanceof ExpressionStatement es)
transitions.addAll(parseTransitionsInExpression(es.getExpression(), searchStartClass, visitedMethods, loopArgsMap));
} else {
if (efs.getBody() instanceof Block block)
parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, loopArgsMap);
else if (efs.getBody() instanceof ExpressionStatement es)
parseStatesInExpression(es.getExpression(), searchStartClass, initialStates, endStates, visitedMethods, loopArgsMap);
}
}
return transitions;
}
private List<Expression> unrollCollection(Expression expr, Map<String, Object> argsMap) {
Set<Expression> visited = new HashSet<>();
Expression current = expr;
while (current != null) {
if (!visited.add(current))
break;
if (current instanceof MethodInvocation mi) {
String name = mi.getName().getIdentifier();
Expression receiver = mi.getExpression();
if ((name.equals("of") && receiver != null && (receiver.toString().equals("Stream") || receiver.toString().equals("List")))
|| (name.equals("asList") && receiver != null && receiver.toString().equals("Arrays"))) {
if (mi.arguments().size() == 1) {
Expression arg = (Expression) mi.arguments().get(0);
if (arg instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof List list)
return (List<Expression>) list;
}
}
return (List<Expression>) mi.arguments();
}
}
if (current instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof List list)
return (List<Expression>) list;
if (resolved instanceof Expression e) {
current = e;
continue;
}
}
if (current instanceof ArrayInitializer ai)
return (List<Expression>) ai.expressions();
break;
}
return Collections.emptyList();
}
private String getLambdaParamName(LambdaExpression lambda) {
if (lambda.parameters().size() == 1) {
Object p = lambda.parameters().get(0);
if (p instanceof VariableDeclaration vp)
return vp.getName().getIdentifier();
}
return "";
}
private List<Transition> parseTransitionsInBlock(Block block, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
List<Transition> transitions = new ArrayList<>();
for (Object stmt : block.statements()) {
if (stmt instanceof ExpressionStatement es)
transitions.addAll(parseTransitionsInExpression(es.getExpression(), searchStartClass, visitedMethods, argsMap));
else if (stmt instanceof VariableDeclarationStatement vds) {
for (Object fragmentObj : vds.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
Expression initializer = fragment.getInitializer();
if (initializer != null) {
Object resolved = resolve(initializer, argsMap);
if (resolved != null)
argsMap.put(fragment.getName().getIdentifier(), resolved);
transitions.addAll(parseTransitionsInExpression(initializer, searchStartClass, visitedMethods, argsMap));
}
}
}
} else if (stmt instanceof EnhancedForStatement efs)
transitions.addAll(parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
else if (stmt instanceof TryStatement ts)
transitions.addAll(parseTransitionsInBlock(ts.getBody(), searchStartClass, visitedMethods, argsMap));
}
return transitions;
}
private void parseStatesInBlock(Block block, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
for (Object stmt : block.statements()) {
if (stmt instanceof ExpressionStatement es)
parseStatesInExpression(es.getExpression(), searchStartClass, initialStates, endStates, visitedMethods, argsMap);
else if (stmt instanceof VariableDeclarationStatement vds) {
for (Object fragmentObj : vds.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
Expression initializer = fragment.getInitializer();
if (initializer != null) {
Object resolved = resolve(initializer, argsMap);
if (resolved != null)
argsMap.put(fragment.getName().getIdentifier(), resolved);
parseStatesInExpression(initializer, searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
}
} else if (stmt instanceof EnhancedForStatement efs)
parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
else if (stmt instanceof TryStatement ts)
parseStatesInBlock(ts.getBody(), searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
private void parseStatesInExpression(Expression expr, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
if (expr instanceof MethodInvocation mi) {
if (isWithStatesChain(mi, argsMap))
parseStateChain(mi, initialStates, endStates, argsMap);
else if (isForEach(mi))
parseForEach(mi, searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
else {
// Check if it's a call on a lambda parameter or a variable that resolved to a lambda
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof LambdaExpression lambda) {
parseLambdaInvocation(lambda, mi.arguments(), searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
}
}
MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass);
if (calledMethod != null) {
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
parseStatesWithMethodCalls(calledMethod, searchStartClass, initialStates, endStates, visitedMethods, nextArgsMap);
}
}
}
}
private Map<String, Object> buildArgsMap(MethodDeclaration method, MethodInvocation call, Map<String, Object> currentArgsMap) {
Map<String, Object> map = new HashMap<>(currentArgsMap);
List<?> params = method.parameters();
List<?> args = call.arguments();
for (int i = 0; i < params.size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) params.get(i);
String paramName = param.getName().getIdentifier();
if (param.isVarargs() && i < params.size()) {
List<Expression> varargList = new ArrayList<>();
for (int j = i; j < args.size(); j++) {
Object resolved = resolve((Expression) args.get(j), currentArgsMap);
if (resolved instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Expression e)
varargList.add(e);
}
} else if (resolved instanceof Expression e) {
varargList.add(e);
}
}
map.put(paramName, varargList);
} else if (i < args.size()) {
map.put(paramName, resolve((Expression) args.get(i), currentArgsMap));
}
}
return map;
}
private Object resolve(Expression expr, Map<String, Object> argsMap) {
if (expr instanceof NullLiteral)
return null;
Set<Expression> visited = new HashSet<>();
Object current = expr;
while (current instanceof SimpleName sn) {
if (!visited.add((SimpleName) current))
break;
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved == null || resolved == current)
break;
current = resolved;
}
return current;
}
private Expression resolveExpression(Expression expr, Map<String, Object> argsMap) {
Object resolved = resolve(expr, argsMap);
if (resolved instanceof Expression e)
return e;
return expr;
}
private Map<String, Expression> convertToExpressionMap(Map<String, Object> argsMap) {
Map<String, Expression> map = new HashMap<>();
for (Map.Entry<String, Object> entry : argsMap.entrySet()) {
if (entry.getValue() instanceof Expression e)
map.put(entry.getKey(), e);
}
return map;
}
private boolean isTransitionChainEntry(MethodInvocation mi) {
Expression current = mi;
while (current instanceof MethodInvocation call) {
String name = call.getName().getIdentifier();
if (TransitionType.fromMethodName(name).isPresent())
return true;
current = call.getExpression();
}
return false;
}
private boolean isTransitionChainContinuation(MethodInvocation mi, Map<String, Object> argsMap) {
Expression current = mi;
Set<Expression> visited = new HashSet<>();
while (current instanceof MethodInvocation call) {
if (!visited.add(current))
break;
String name = call.getName().getIdentifier();
if (Set.of("and", "source", "target", "event", "first", "then", "last").contains(name)) {
Expression receiver = call.getExpression();
if (receiver instanceof MethodInvocation next) {
current = next;
continue;
}
Object resolved = resolve(receiver, argsMap);
if (resolved instanceof MethodInvocation next) {
current = next;
continue;
}
return resolved == null; // null means it's a parameter we track
}
if (TransitionType.fromMethodName(name).isPresent())
return true;
break;
}
return false;
}
private MethodDeclaration findMethodInHierarchy(String methodName, TypeDeclaration td) {
return findMethodInHierarchy(methodName, td, new HashSet<>());
}
private MethodDeclaration findMethodInHierarchy(String methodName, TypeDeclaration td, Set<String> visited) {
if (td == null)
return null;
String fqn = context.getFqn(td);
if (visited.contains(fqn))
return null;
visited.add(fqn);
for (MethodDeclaration method : td.getMethods())
if (method.getName().getIdentifier().equals(methodName))
return method;
Type superclassType = td.getSuperclassType();
if (superclassType != null) {
TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), (CompilationUnit) td.getRoot());
return findMethodInHierarchy(methodName, superclassTd, visited);
}
return null;
}
private boolean isRelevantMethod(MethodDeclaration method) {
return true;
}
private MethodDeclaration findConfigureTransitionsMethod(TypeDeclaration td) {
for (MethodDeclaration method : td.getMethods())
if (isConfigureTransitionsMethod(method))
return method;
return null;
}
private boolean isConfigureTransitionsMethod(MethodDeclaration method) {
if (!method.getName().getIdentifier().equals("configure") || method.parameters().isEmpty())
return false;
return ((SingleVariableDeclaration) method.parameters().get(0)).getType().toString().contains("StateMachineTransitionConfigurer");
}
@Getter @Getter
private final Set<String> initialStates = new HashSet<>(); private final Set<String> initialStates = new HashSet<>();
@Getter @Getter
private final Set<String> endStates = new HashSet<>(); private final Set<String> endStates = new HashSet<>();
public StateMachineAggregator(CodebaseContext context) {
this.context = context;
this.adapterParser = new StateMachineAdapterParser(context);
}
public List<Transition> aggregateTransitions(TypeDeclaration startClass) {
return adapterParser.parseTransitions(startClass);
}
public void aggregateStates(TypeDeclaration startClass) { public void aggregateStates(TypeDeclaration startClass) {
Set<MethodVisit> visitedMethods = new HashSet<>(); StateMachineAdapterParser.AdapterStates states = adapterParser.parseStates(startClass);
aggregateStatesRecursive(startClass, startClass, initialStates, endStates, new HashSet<>(), visitedMethods, new HashMap<>()); this.initialStates.clear();
} this.initialStates.addAll(states.initialStates());
this.endStates.clear();
private void aggregateStatesRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<String> visitedClasses, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) { this.endStates.addAll(states.endStates());
if (currentTd == null)
return;
String fqn = context.getFqn(currentTd);
if (visitedClasses.contains(fqn))
return;
visitedClasses.add(fqn);
for (MethodDeclaration method : currentTd.getMethods()) {
if (isConfigureStatesMethod(method)) {
parseStatesWithMethodCalls(method, searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
CompilationUnit cu = (CompilationUnit) currentTd.getRoot();
// Hierarchy - Superclass
Type superclassType = currentTd.getSuperclassType();
if (superclassType != null) {
TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), cu);
if (superclassTd != null)
aggregateStatesRecursive(superclassTd, searchStartClass, initialStates, endStates, visitedClasses, visitedMethods, argsMap);
}
// Hierarchy - Interfaces
for (Object interfaceTypeObj : currentTd.superInterfaceTypes()) {
if (interfaceTypeObj instanceof Type interfaceType) {
TypeDeclaration interfaceTd = context.getTypeDeclaration(getSimpleName(interfaceType), cu);
if (interfaceTd != null)
aggregateStatesRecursive(interfaceTd, searchStartClass, initialStates, endStates, visitedClasses, visitedMethods, argsMap);
}
}
}
private void parseStatesWithMethodCalls(MethodDeclaration method, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
MethodVisit visit = new MethodVisit(method, new HashMap<>(argsMap));
if (visitedMethods.contains(visit))
return;
visitedMethods.add(visit);
Block body = method.getBody();
if (body == null)
return;
parseStatesInBlock(body, searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
private boolean isWithStatesChain(MethodInvocation mi, Map<String, Object> argsMap) {
Expression current = mi;
Set<Expression> visited = new HashSet<>();
while (current instanceof MethodInvocation call) {
if (!visited.add(current))
break;
if (call.getName().getIdentifier().equals("withStates"))
return true;
Expression receiver = call.getExpression();
if (receiver instanceof MethodInvocation next) {
current = next;
continue;
}
Object resolved = resolve(receiver, argsMap);
if (resolved instanceof MethodInvocation next) {
current = next;
continue;
}
break;
}
return false;
}
private void parseStateChain(MethodInvocation mi, Set<String> initialStates, Set<String> endStates, Map<String, Object> argsMap) {
List<MethodInvocation> chain = new ArrayList<>();
Expression current = mi;
while (current instanceof MethodInvocation m) {
chain.addFirst(m);
current = m.getExpression();
}
CompilationUnit cu = (CompilationUnit) mi.getRoot();
for (MethodInvocation call : chain) {
String methodName = call.getName().getIdentifier();
if (call.arguments().isEmpty())
continue;
Expression arg = resolveExpression((Expression) call.arguments().get(0), argsMap);
if ("initial".equals(methodName)) {
if (!isInsideRegionOrFork(call)) {
initialStates.add(context.resolveState(arg, cu).toString());
}
} else if ("end".equals(methodName)) {
endStates.add(context.resolveState(arg, cu).toString());
}
}
}
private boolean isInsideRegionOrFork(MethodInvocation mi) {
Expression expr = mi.getExpression();
while (expr instanceof MethodInvocation parent) {
if (List.of("fork", "region").contains(parent.getName().getIdentifier()))
return true;
expr = parent.getExpression();
}
return false;
}
private MethodDeclaration findConfigureStatesMethod(TypeDeclaration td) {
for (MethodDeclaration method : td.getMethods())
if (isConfigureStatesMethod(method))
return method;
return null;
}
private boolean isConfigureStatesMethod(MethodDeclaration method) {
if (!method.getName().getIdentifier().equals("configure") || method.parameters().isEmpty())
return false;
return ((SingleVariableDeclaration) method.parameters().get(0)).getType().toString().contains("StateMachineStateConfigurer");
}
private String getSimpleName(Type type) {
if (type.isSimpleType())
return ((SimpleType) type).getName().getFullyQualifiedName();
if (type.isParameterizedType())
return getSimpleName(((ParameterizedType) type).getType());
return type.toString();
} }
} }

View File

@@ -36,4 +36,85 @@ public final class AstUtils {
return type.toString(); // fallback return type.toString(); // fallback
} }
} }
public static boolean isFunctionalCollectionMethod(org.eclipse.jdt.core.dom.MethodInvocation node) {
if (node.getExpression() == null) {
return false;
}
org.eclipse.jdt.core.dom.ITypeBinding typeBinding = node.getExpression().resolveTypeBinding();
if (typeBinding != null) {
if (isCollectionOrStreamType(typeBinding)) {
return true;
}
return false;
}
String methodName = node.getName().getIdentifier();
if (methodName.equals("forEach") || methodName.equals("map") ||
methodName.equals("filter") || methodName.equals("flatMap") ||
methodName.equals("anyMatch") || methodName.equals("allMatch") ||
methodName.equals("noneMatch") || methodName.equals("reduce") ||
methodName.equals("collect") || methodName.equals("removeIf") ||
methodName.equals("ifPresent") || methodName.equals("ifPresentOrElse") ||
methodName.equals("compute") || methodName.equals("computeIfAbsent") ||
methodName.equals("computeIfPresent") || methodName.equals("replaceAll") ||
methodName.equals("merge") || methodName.equals("peek") ||
methodName.equals("doOnNext") || methodName.equals("doOnSuccess") ||
methodName.equals("doOnError") || methodName.equals("concatMap") ||
methodName.equals("switchMap")) {
return true;
}
return false;
}
private static boolean isCollectionOrStreamType(org.eclipse.jdt.core.dom.ITypeBinding typeBinding) {
if (typeBinding == null) return false;
String fqn = typeBinding.getErasure().getQualifiedName();
if (fqn.startsWith("java.util.stream.") ||
fqn.equals("java.lang.Iterable") ||
fqn.equals("java.util.Collection") ||
fqn.equals("java.util.List") ||
fqn.equals("java.util.Set") ||
fqn.equals("java.util.Map") ||
fqn.equals("java.util.Iterator") ||
fqn.equals("java.util.Optional") ||
fqn.equals("reactor.core.publisher.Flux") ||
fqn.equals("reactor.core.publisher.Mono")) {
return true;
}
for (org.eclipse.jdt.core.dom.ITypeBinding intf : typeBinding.getInterfaces()) {
if (isCollectionOrStreamType(intf)) return true;
}
if (typeBinding.getSuperclass() != null) {
return isCollectionOrStreamType(typeBinding.getSuperclass());
}
return false;
}
public static String findConditionConstraint(org.eclipse.jdt.core.dom.ASTNode node) {
java.util.List<String> conditions = new java.util.ArrayList<>();
org.eclipse.jdt.core.dom.ASTNode current = node;
while (current != null && !(current instanceof org.eclipse.jdt.core.dom.MethodDeclaration)) {
org.eclipse.jdt.core.dom.ASTNode parent = current.getParent();
if (parent instanceof org.eclipse.jdt.core.dom.IfStatement ifStmt) {
if (ifStmt.getExpression() != current) {
org.eclipse.jdt.core.dom.Expression cond = ifStmt.getExpression();
if (current == ifStmt.getElseStatement()) {
conditions.add("!(" + cond.toString() + ")");
} else {
conditions.add(cond.toString());
}
}
}
current = parent;
}
if (conditions.isEmpty()) return null;
java.util.Collections.reverse(conditions);
return String.join(" && ", conditions);
}
} }

View File

@@ -0,0 +1,108 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.*;
import java.util.Set;
public final class BindingResolver {
private BindingResolver() {
// Prevent instantiation
}
/**
* Resolves the type binding for a given AST Type node.
*/
public static ITypeBinding resolveType(Type type) {
if (type == null) return null;
return type.resolveBinding();
}
/**
* Resolves the type binding for a given AST Expression node.
*/
public static ITypeBinding resolveType(Expression expression) {
if (expression == null) return null;
return expression.resolveTypeBinding();
}
/**
* Resolves the method binding for a MethodInvocation.
*/
public static IMethodBinding resolveMethod(MethodInvocation invocation) {
if (invocation == null) return null;
return invocation.resolveMethodBinding();
}
/**
* Resolves the constructor binding for a ClassInstanceCreation.
*/
public static IMethodBinding resolveConstructor(ClassInstanceCreation creation) {
if (creation == null) return null;
return creation.resolveConstructorBinding();
}
/**
* Resolves the constructor binding for a ConstructorInvocation (e.g. this(...)).
*/
public static IMethodBinding resolveConstructor(ConstructorInvocation invocation) {
if (invocation == null) return null;
return invocation.resolveConstructorBinding();
}
/**
* Resolves the constructor binding for a SuperConstructorInvocation (e.g. super(...)).
*/
public static IMethodBinding resolveConstructor(SuperConstructorInvocation invocation) {
if (invocation == null) return null;
return invocation.resolveConstructorBinding();
}
/**
* Resolves the type binding of a TypeDeclaration.
*/
public static ITypeBinding resolveType(TypeDeclaration td) {
if (td == null) return null;
return td.resolveBinding();
}
/**
* Gets the fully qualified name of a type binding, safely handling nulls and erasure.
*/
public static String getFullyQualifiedName(ITypeBinding binding) {
if (binding == null) return null;
return binding.getErasure().getQualifiedName();
}
public static boolean isSubtypeOf(ITypeBinding childBinding, String targetFqn) {
return isSubtypeOf(childBinding, targetFqn, new java.util.HashSet<>());
}
private static boolean isSubtypeOf(ITypeBinding childBinding, String targetFqn, Set<String> visited) {
if (childBinding == null || targetFqn == null) return false;
String key = childBinding.getKey();
if (key != null && !visited.add(key)) {
return false;
}
// Direct match
if (targetFqn.equals(childBinding.getErasure().getQualifiedName())) {
return true;
}
// Check interfaces
for (ITypeBinding intf : childBinding.getInterfaces()) {
if (isSubtypeOf(intf, targetFqn, visited)) {
return true;
}
}
// Check superclass
ITypeBinding superclass = childBinding.getSuperclass();
if (superclass != null) {
return isSubtypeOf(superclass, targetFqn, visited);
}
return false;
}
}

View File

@@ -0,0 +1,170 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class CfgBuilder {
public static ControlFlowGraph build(MethodDeclaration method) {
ControlFlowGraph cfg = new ControlFlowGraph(method);
if (method.getBody() == null) {
cfg.getEntryNode().addSuccessor(cfg.getExitNode());
return cfg;
}
// Build CFG recursively
List<ControlFlowGraph.CfgNode> exits = buildFlow(method.getBody(), List.of(cfg.getEntryNode()), cfg);
for (ControlFlowGraph.CfgNode exit : exits) {
exit.addSuccessor(cfg.getExitNode());
}
return cfg;
}
private static List<ControlFlowGraph.CfgNode> buildFlow(
ASTNode node,
List<ControlFlowGraph.CfgNode> sources,
ControlFlowGraph cfg) {
if (node == null || sources.isEmpty()) {
return sources;
}
if (node instanceof Block block) {
List<ControlFlowGraph.CfgNode> currentSources = sources;
for (Object stmtObj : block.statements()) {
currentSources = buildFlow((ASTNode) stmtObj, currentSources, cfg);
}
return currentSources;
}
if (node instanceof IfStatement ifStmt) {
// Condition node
ControlFlowGraph.CfgNode condNode = new ControlFlowGraph.CfgNode(ifStmt.getExpression(), ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(condNode);
for (ControlFlowGraph.CfgNode src : sources) {
src.addSuccessor(condNode);
}
List<ControlFlowGraph.CfgNode> thenExits = buildFlow(ifStmt.getThenStatement(), List.of(condNode), cfg);
List<ControlFlowGraph.CfgNode> elseExits;
if (ifStmt.getElseStatement() != null) {
elseExits = buildFlow(ifStmt.getElseStatement(), List.of(condNode), cfg);
} else {
elseExits = List.of(condNode);
}
List<ControlFlowGraph.CfgNode> combinedExits = new ArrayList<>();
combinedExits.addAll(thenExits);
combinedExits.addAll(elseExits);
return combinedExits;
}
if (node instanceof WhileStatement whileStmt) {
ControlFlowGraph.CfgNode condNode = new ControlFlowGraph.CfgNode(whileStmt.getExpression(), ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(condNode);
for (ControlFlowGraph.CfgNode src : sources) {
src.addSuccessor(condNode);
}
List<ControlFlowGraph.CfgNode> bodyExits = buildFlow(whileStmt.getBody(), List.of(condNode), cfg);
for (ControlFlowGraph.CfgNode exit : bodyExits) {
exit.addSuccessor(condNode); // Loop back
}
return List.of(condNode); // Exit loop when cond is false
}
if (node instanceof DoStatement doStmt) {
ControlFlowGraph.CfgNode condNode = new ControlFlowGraph.CfgNode(doStmt.getExpression(), ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(condNode);
List<ControlFlowGraph.CfgNode> bodySources = new ArrayList<>(sources);
bodySources.add(condNode);
List<ControlFlowGraph.CfgNode> bodyExits = buildFlow(doStmt.getBody(), bodySources, cfg);
for (ControlFlowGraph.CfgNode exit : bodyExits) {
exit.addSuccessor(condNode);
}
return List.of(condNode);
}
if (node instanceof ForStatement forStmt) {
// Initializers
List<ControlFlowGraph.CfgNode> currentSources = sources;
for (Object initObj : forStmt.initializers()) {
ControlFlowGraph.CfgNode initNode = new ControlFlowGraph.CfgNode((ASTNode) initObj, ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(initNode);
for (ControlFlowGraph.CfgNode src : currentSources) {
src.addSuccessor(initNode);
}
currentSources = List.of(initNode);
}
// Condition
ControlFlowGraph.CfgNode condNode;
if (forStmt.getExpression() != null) {
condNode = new ControlFlowGraph.CfgNode(forStmt.getExpression(), ControlFlowGraph.CfgNodeType.STATEMENT);
} else {
condNode = new ControlFlowGraph.CfgNode(forStmt, ControlFlowGraph.CfgNodeType.STATEMENT); // Dummy node
}
cfg.addNode(condNode);
for (ControlFlowGraph.CfgNode src : currentSources) {
src.addSuccessor(condNode);
}
List<ControlFlowGraph.CfgNode> bodyExits = buildFlow(forStmt.getBody(), List.of(condNode), cfg);
// Updaters
List<ControlFlowGraph.CfgNode> updaterSources = bodyExits;
for (Object updateObj : forStmt.updaters()) {
ControlFlowGraph.CfgNode updateNode = new ControlFlowGraph.CfgNode((ASTNode) updateObj, ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(updateNode);
for (ControlFlowGraph.CfgNode src : updaterSources) {
src.addSuccessor(updateNode);
}
updaterSources = List.of(updateNode);
}
for (ControlFlowGraph.CfgNode src : updaterSources) {
src.addSuccessor(condNode); // Loop back
}
return List.of(condNode);
}
if (node instanceof EnhancedForStatement effStmt) {
ControlFlowGraph.CfgNode loopNode = new ControlFlowGraph.CfgNode(effStmt.getParameter(), ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(loopNode);
for (ControlFlowGraph.CfgNode src : sources) {
src.addSuccessor(loopNode);
}
List<ControlFlowGraph.CfgNode> bodyExits = buildFlow(effStmt.getBody(), List.of(loopNode), cfg);
for (ControlFlowGraph.CfgNode exit : bodyExits) {
exit.addSuccessor(loopNode); // Loop back
}
return List.of(loopNode);
}
if (node instanceof ReturnStatement returnStmt) {
ControlFlowGraph.CfgNode retNode = new ControlFlowGraph.CfgNode(returnStmt, ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(retNode);
for (ControlFlowGraph.CfgNode src : sources) {
src.addSuccessor(retNode);
}
// Return flows to exit, not to subsequent sequential nodes
retNode.addSuccessor(cfg.getExitNode());
return Collections.emptyList();
}
// Default: general statement
ControlFlowGraph.CfgNode stmtNode = new ControlFlowGraph.CfgNode(node, ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(stmtNode);
for (ControlFlowGraph.CfgNode src : sources) {
src.addSuccessor(stmtNode);
}
return List.of(stmtNode);
}
}

View File

@@ -22,25 +22,41 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import click.kamil.springstatemachineexporter.spi.SourceContext;
@Slf4j @Slf4j
public class CodebaseContext { public class CodebaseContext implements SourceContext {
private final Map<String, CompilationUnit> classes = new HashMap<>(); private final Map<String, CompilationUnit> classes = new HashMap<>();
private final Map<String, Path> classPaths = new HashMap<>(); private final Map<String, Path> classPaths = new HashMap<>();
private final Map<String, String> simpleNameToFqn = new HashMap<>(); private final Map<String, String> simpleNameToFqn = new HashMap<>();
private final Set<String> ambiguousSimpleNames = new HashSet<>(); private final Set<String> ambiguousSimpleNames = new HashSet<>();
private final ConstantResolver constantResolver = new ConstantResolver(); private final ConstantResolver constantResolver = new ConstantResolver();
private final StateResolver stateResolver = new StateResolver(this);
private final PropertyResolver propertyResolver = new PropertyResolver(); private final PropertyResolver propertyResolver = new PropertyResolver();
public ConstantResolver getConstantResolver() {
return constantResolver;
}
private final List<String> activeProfiles = new ArrayList<>(); private final List<String> activeProfiles = new ArrayList<>();
private Map<String, Map<String, String>> allProperties = new HashMap<>(); private Map<String, Map<String, String>> allProperties = new HashMap<>();
private List<LibraryHint> libraryHints = new ArrayList<>(); private List<LibraryHint> libraryHints = new ArrayList<>();
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
private Path projectRoot; private Path projectRoot;
private final Map<String, Object> cache = new HashMap<>();
public Map<String, Object> getCache() {
return cache;
}
public void setProjectRoot(Path root) { public void setProjectRoot(Path root) {
this.projectRoot = root; this.projectRoot = root;
} }
@Override
public Path getProjectRoot() {
return projectRoot;
}
public String getRelativePath(Path fullPath) { public String getRelativePath(Path fullPath) {
if (projectRoot != null && fullPath.isAbsolute() && fullPath.startsWith(projectRoot)) { if (projectRoot != null && fullPath.isAbsolute() && fullPath.startsWith(projectRoot)) {
return projectRoot.relativize(fullPath).toString(); return projectRoot.relativize(fullPath).toString();
@@ -65,7 +81,7 @@ public class CodebaseContext {
return Collections.unmodifiableList(libraryHints); return Collections.unmodifiableList(libraryHints);
} }
private final Set<String> ignorePatterns = new HashSet<>(Set.of("**/test/**", "**/build/**", "**/node_modules/**", "**/out/**")); private final Set<String> ignorePatterns = new HashSet<>(Set.of("**/test/**", "**/build/classes/**", "**/build/libs/**", "**/build/tmp/**", "**/build/reports/**", "**/build/test-results/**", "**/node_modules/**", "**/out/**"));
private String[] classpath = new String[0]; private String[] classpath = new String[0];
private String[] sourcepath = new String[0]; private String[] sourcepath = new String[0];
private boolean resolveBindings = false; private boolean resolveBindings = false;
@@ -78,6 +94,10 @@ public class CodebaseContext {
this.classpath = classpath.toArray(new String[0]); this.classpath = classpath.toArray(new String[0]);
} }
public List<String> getClasspath() {
return java.util.Arrays.asList(classpath);
}
public void setSourcepath(List<String> sourcepath) { public void setSourcepath(List<String> sourcepath) {
this.sourcepath = sourcepath.toArray(new String[0]); this.sourcepath = sourcepath.toArray(new String[0]);
} }
@@ -135,7 +155,7 @@ public class CodebaseContext {
if (imp.isOnDemand()) { if (imp.isOnDemand()) {
// Star import: import static com.example.C.*; // Star import: import static com.example.C.*;
TypeDeclaration td = getTypeDeclaration(impName, contextCu); TypeDeclaration td = getTypeDeclaration(impName, contextCu);
if (td != null && hasField(td, memberName)) { if (td != null && (hasField(td, memberName) || hasMethod(td, memberName))) {
return td; return td;
} }
} else { } else {
@@ -150,6 +170,15 @@ public class CodebaseContext {
return null; return null;
} }
public boolean hasMethod(TypeDeclaration td, String name) {
for (MethodDeclaration md : td.getMethods()) {
if (md.getName().getIdentifier().equals(name)) {
return true;
}
}
return false;
}
public boolean hasField(TypeDeclaration td, String name) { public boolean hasField(TypeDeclaration td, String name) {
for (FieldDeclaration fd : td.getFields()) { for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) { for (Object fragObj : fd.fragments()) {
@@ -170,6 +199,7 @@ public class CodebaseContext {
private final List<CompilationUnit> allCus = new ArrayList<>(); private final List<CompilationUnit> allCus = new ArrayList<>();
private final Map<String, List<String>> interfaceToImpls = new HashMap<>(); private final Map<String, List<String>> interfaceToImpls = new HashMap<>();
private final Map<String, List<String>> enumValues = new HashMap<>();
public void scan(Set<Path> rootDirs, Set<String> customIgnorePatterns) throws IOException { public void scan(Set<Path> rootDirs, Set<String> customIgnorePatterns) throws IOException {
this.allProperties = propertyResolver.resolveAllProperties(rootDirs); this.allProperties = propertyResolver.resolveAllProperties(rootDirs);
@@ -188,6 +218,10 @@ public class CodebaseContext {
for (Object type : cu.types()) { for (Object type : cu.types()) {
if (type instanceof TypeDeclaration td) { if (type instanceof TypeDeclaration td) {
indexType(td, packageName, javaFile); indexType(td, packageName, javaFile);
} else if (type instanceof EnumDeclaration ed) {
indexEnum(ed, packageName, javaFile);
} else if (type instanceof org.eclipse.jdt.core.dom.RecordDeclaration rd) {
indexRecord(rd, packageName, javaFile);
} }
} }
} }
@@ -210,136 +244,161 @@ public class CodebaseContext {
} }
// Inheritance Mapping // Inheritance Mapping
for (Object itf : td.superInterfaceTypes()) { // Track implementations (for both interfaces and superclasses)
String itfName = itf.toString(); if (td.superInterfaceTypes() != null) {
interfaceToImpls.computeIfAbsent(itfName, k -> new ArrayList<>()).add(fqn); for (Object itfObj : td.superInterfaceTypes()) {
Type itf = (Type) itfObj;
String itfName = extractTypeName(itf);
interfaceToImpls.computeIfAbsent(itfName, k -> new ArrayList<>()).add(fqn);
}
}
Type superclass = td.getSuperclassType();
if (superclass != null) {
String superName = extractTypeName(superclass);
interfaceToImpls.computeIfAbsent(superName, k -> new ArrayList<>()).add(fqn);
} }
// Recursively index nested types // Recursively index nested types
for (Object type : td.getTypes()) { for (Object decl : td.bodyDeclarations()) {
if (type instanceof TypeDeclaration nestedTd) { if (decl instanceof TypeDeclaration nestedTd) {
indexType(nestedTd, fqn, javaFile); indexType(nestedTd, fqn, javaFile);
} else if (decl instanceof EnumDeclaration nestedEd) {
indexEnum(nestedEd, fqn, javaFile);
} else if (decl instanceof org.eclipse.jdt.core.dom.RecordDeclaration nestedRd) {
indexRecord(nestedRd, fqn, javaFile);
}
}
}
private void indexRecord(org.eclipse.jdt.core.dom.RecordDeclaration rd, String parentFqn, Path javaFile) {
String simpleName = rd.getName().getIdentifier();
String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName;
// Treat as a class since it is a type
classes.put(fqn, (CompilationUnit) rd.getRoot());
classPaths.put(fqn, javaFile);
if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName);
if (!existingFqn.equals(fqn)) {
ambiguousSimpleNames.add(simpleName);
}
} else {
simpleNameToFqn.put(simpleName, fqn);
}
// Recursively index nested types
for (Object decl : rd.bodyDeclarations()) {
if (decl instanceof TypeDeclaration nestedTd) {
indexType(nestedTd, fqn, javaFile);
} else if (decl instanceof EnumDeclaration nestedEd) {
indexEnum(nestedEd, fqn, javaFile);
} else if (decl instanceof org.eclipse.jdt.core.dom.RecordDeclaration nestedRd) {
indexRecord(nestedRd, fqn, javaFile);
} }
} }
} }
public List<String> getImplementations(String interfaceName) { public List<String> getImplementations(String interfaceName) {
String cleanName = interfaceName;
if (interfaceName != null && interfaceName.contains("<")) {
cleanName = interfaceName.substring(0, interfaceName.indexOf('<'));
}
Set<String> allImpls = new HashSet<>();
collectImplementations(cleanName, allImpls, new HashSet<>());
return new ArrayList<>(allImpls);
}
private void collectImplementations(String typeName, Set<String> results, Set<String> visited) {
String cleanName = typeName;
if (typeName != null && typeName.contains("<")) {
cleanName = typeName.substring(0, typeName.indexOf('<'));
}
if (!visited.add(cleanName)) return;
// Try direct match // Try direct match
List<String> impls = interfaceToImpls.get(interfaceName); List<String> directImpls = interfaceToImpls.get(cleanName);
if (impls != null) return impls;
// Try FQN match if input was simple name // Try FQN match if input was simple name
String fqn = simpleNameToFqn.get(interfaceName); if (directImpls == null) {
if (fqn != null) return interfaceToImpls.getOrDefault(fqn, Collections.emptyList()); String fqn = simpleNameToFqn.get(typeName);
if (fqn != null) {
directImpls = interfaceToImpls.get(fqn);
}
}
return Collections.emptyList(); // 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) {
for (String impl : directImpls) {
results.add(impl);
// Recursively find implementations of the implementation (e.g., subclasses of an abstract class)
collectImplementations(impl, results, visited);
}
}
}
private void indexEnum(EnumDeclaration ed, String parentFqn, Path javaFile) {
String simpleName = ed.getName().getIdentifier();
String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName;
classes.put(fqn, (CompilationUnit) ed.getRoot());
classPaths.put(fqn, javaFile);
List<String> values = new ArrayList<>();
for (Object o : ed.enumConstants()) {
if (o instanceof EnumConstantDeclaration ecd) {
values.add(fqn + "." + ecd.getName().getIdentifier());
}
}
enumValues.put(fqn, values);
if (!simpleNameToFqn.containsKey(simpleName)) {
simpleNameToFqn.put(simpleName, fqn);
}
// Recursively index nested types inside enum body
for (Object decl : ed.bodyDeclarations()) {
if (decl instanceof TypeDeclaration nestedTd) {
indexType(nestedTd, fqn, javaFile);
} else if (decl instanceof EnumDeclaration nestedEd) {
indexEnum(nestedEd, fqn, javaFile);
} else if (decl instanceof org.eclipse.jdt.core.dom.RecordDeclaration nestedRd) {
indexRecord(nestedRd, fqn, javaFile);
}
}
}
public Map<String, List<String>> getEnumValuesMap() {
return enumValues;
}
public List<String> getEnumValues(String fqnOrSimpleName) {
if (fqnOrSimpleName == null) return null;
String cleanName = fqnOrSimpleName;
if (cleanName.contains("<")) {
cleanName = cleanName.substring(0, cleanName.indexOf('<'));
}
if (cleanName.contains("[")) {
cleanName = cleanName.substring(0, cleanName.indexOf('['));
}
cleanName = cleanName.trim();
List<String> values = enumValues.get(cleanName);
if (values == null) {
String fqn = simpleNameToFqn.get(cleanName);
if (fqn != null) values = enumValues.get(fqn);
}
log.debug("getEnumValues called for: {} resolved to {}. Returns: {}", fqnOrSimpleName, cleanName, values);
return values;
} }
public State resolveState(Expression expr, CompilationUnit cu) { public State resolveState(Expression expr, CompilationUnit cu) {
if (expr == null) return stateResolver.resolveState(expr, cu);
return null;
// 1. Check for constants
String resolvedValue = constantResolver.resolve(expr, this);
if (resolvedValue != null) {
return State.of(expr.toString(), resolvedValue);
}
// 2. Check for @Value fields (SimpleName)
if (expr instanceof SimpleName sn) {
String fieldName = sn.getIdentifier();
TypeDeclaration td = findEnclosingType(sn);
if (td != null) {
String value = findValueFromField(td, fieldName);
if (value != null) {
return State.of(fieldName, value);
}
}
}
if (expr instanceof StringLiteral sl)
return State.of(sl.getLiteralValue());
String raw = expr.toString();
if (expr instanceof QualifiedName qn) {
String full = resolveQualifiedName(qn, cu);
return State.of(raw, full);
}
if (expr instanceof SimpleName sn) {
String full = resolveSimpleName(sn, cu);
return State.of(raw, full);
}
return State.of(raw);
}
private String findValueFromField(TypeDeclaration td, String fieldName) {
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(fieldName)) {
// Check for @Value annotation
for (Object mod : fd.modifiers()) {
if (mod instanceof Annotation ann) {
String name = ann.getTypeName().getFullyQualifiedName();
if (name.endsWith("Value")) {
return extractAnnotationValue(ann);
}
}
}
}
}
}
}
return null;
}
private String extractAnnotationValue(Annotation ann) {
if (ann instanceof SingleMemberAnnotation sma) {
return stripQuotes(sma.getValue().toString());
} else if (ann instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if ("value".equals(pair.getName().getIdentifier())) {
return stripQuotes(pair.getValue().toString());
}
}
}
return null;
}
private String stripQuotes(String s) {
if (s == null) return null;
return s.replaceAll("^\"|\"$", "");
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private String resolveQualifiedName(QualifiedName qn, CompilationUnit cu) {
String qualifier = qn.getQualifier().toString();
String name = qn.getName().getIdentifier();
TypeDeclaration td = getTypeDeclaration(qualifier, cu);
if (td != null)
return getFqn(td) + "." + name;
return qn.getFullyQualifiedName();
}
private String resolveSimpleName(SimpleName sn, CompilationUnit cu) {
String name = sn.getIdentifier();
if (cu == null)
return name;
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + name))
return impName;
}
return name;
} }
private CompilationUnit parse(String source, String unitName) { private CompilationUnit parse(String source, String unitName) {
@@ -347,6 +406,7 @@ public class CodebaseContext {
parser.setSource(source.toCharArray()); parser.setSource(source.toCharArray());
parser.setKind(ASTParser.K_COMPILATION_UNIT); parser.setKind(ASTParser.K_COMPILATION_UNIT);
parser.setResolveBindings(resolveBindings); parser.setResolveBindings(resolveBindings);
parser.setBindingsRecovery(true);
Map<String, String> options = JavaCore.getOptions(); Map<String, String> options = JavaCore.getOptions();
JavaCore.setComplianceOptions(JavaCore.VERSION_21, options); JavaCore.setComplianceOptions(JavaCore.VERSION_21, options);
@@ -392,6 +452,14 @@ public class CodebaseContext {
// 2. Check imports in contextCu // 2. Check imports in contextCu
if (contextCu != null) { if (contextCu != null) {
for (Object typeObj : contextCu.types()) {
if (typeObj instanceof TypeDeclaration tdDecl) {
if (tdDecl.getName().getIdentifier().equals(name)) {
return tdDecl;
}
}
}
for (Object impObj : contextCu.imports()) { for (Object impObj : contextCu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj; ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName(); String impName = imp.getName().getFullyQualifiedName();
@@ -428,11 +496,20 @@ public class CodebaseContext {
} }
public String getSuperclassFqn(TypeDeclaration td) { public String getSuperclassFqn(TypeDeclaration td) {
if (td == null) return null;
ITypeBinding binding = td.resolveBinding();
if (binding != null) {
ITypeBinding superBinding = binding.getSuperclass();
if (superBinding != null) {
return superBinding.getErasure().getQualifiedName();
}
}
Type superType = td.getSuperclassType(); Type superType = td.getSuperclassType();
if (superType == null) return null; if (superType == null) return null;
String superName = extractTypeName(superType); String superName = extractTypeName(superType);
CompilationUnit cu = (CompilationUnit) td.getRoot(); CompilationUnit cu = (td.getRoot() instanceof CompilationUnit) ? (CompilationUnit) td.getRoot() : null;
TypeDeclaration superTd = getTypeDeclaration(superName, cu); TypeDeclaration superTd = getTypeDeclaration(superName, cu);
return superTd != null ? getFqn(superTd) : superName; return superTd != null ? getFqn(superTd) : superName;
} }
@@ -449,6 +526,56 @@ public class CodebaseContext {
} }
public MethodDeclaration findMethodDeclaration(TypeDeclaration td, String methodName, boolean includeSuper) { public MethodDeclaration findMethodDeclaration(TypeDeclaration td, String methodName, boolean includeSuper) {
if (td == null) return null;
ITypeBinding binding = td.resolveBinding();
if (binding != null && includeSuper) {
IMethodBinding methodBinding = findMethodBinding(binding, methodName);
if (methodBinding != null && methodBinding.getDeclaringClass() != null) {
CompilationUnit declaringCu = classes.get(methodBinding.getDeclaringClass().getErasure().getQualifiedName());
if (declaringCu != null) {
ASTNode declNode = declaringCu.findDeclaringNode(methodBinding.getKey());
if (declNode instanceof MethodDeclaration) {
return (MethodDeclaration) declNode;
}
}
}
}
return findMethodDeclarationLegacy(td, methodName, includeSuper);
}
private IMethodBinding findMethodBinding(ITypeBinding typeBinding, String methodName) {
return findMethodBinding(typeBinding, methodName, new java.util.HashSet<>());
}
private IMethodBinding findMethodBinding(ITypeBinding typeBinding, String methodName, Set<String> visited) {
if (typeBinding == null) return null;
String key = typeBinding.getKey();
if (key != null && !visited.add(key)) {
return null;
}
for (IMethodBinding mb : typeBinding.getDeclaredMethods()) {
if (mb.getName().equals(methodName)) {
return mb;
}
}
ITypeBinding superclass = typeBinding.getSuperclass();
if (superclass != null) {
IMethodBinding mb = findMethodBinding(superclass, methodName, visited);
if (mb != null) return mb;
}
for (ITypeBinding intf : typeBinding.getInterfaces()) {
IMethodBinding mb = findMethodBinding(intf, methodName, visited);
if (mb != null) return mb;
}
return null;
}
private MethodDeclaration findMethodDeclarationLegacy(TypeDeclaration td, String methodName, boolean includeSuper) {
if (td == null) return null;
for (MethodDeclaration method : td.getMethods()) { for (MethodDeclaration method : td.getMethods()) {
if (method.getName().getIdentifier().equals(methodName)) { if (method.getName().getIdentifier().equals(methodName)) {
return method; return method;
@@ -460,7 +587,7 @@ public class CodebaseContext {
if (superFqn != null) { if (superFqn != null) {
TypeDeclaration superTd = getTypeDeclaration(superFqn); TypeDeclaration superTd = getTypeDeclaration(superFqn);
if (superTd != null) { if (superTd != null) {
return findMethodDeclaration(superTd, methodName, true); return findMethodDeclarationLegacy(superTd, methodName, true);
} }
} }
@@ -468,9 +595,9 @@ public class CodebaseContext {
for (Object interfaceTypeObj : td.superInterfaceTypes()) { for (Object interfaceTypeObj : td.superInterfaceTypes()) {
Type interfaceType = (Type) interfaceTypeObj; Type interfaceType = (Type) interfaceTypeObj;
String interfaceName = extractTypeName(interfaceType); String interfaceName = extractTypeName(interfaceType);
TypeDeclaration interfaceTd = getTypeDeclaration(interfaceName, (CompilationUnit) td.getRoot()); TypeDeclaration interfaceTd = getTypeDeclaration(interfaceName, (td.getRoot() instanceof CompilationUnit) ? (CompilationUnit) td.getRoot() : null);
if (interfaceTd != null) { if (interfaceTd != null) {
MethodDeclaration found = findMethodDeclaration(interfaceTd, methodName, true); MethodDeclaration found = findMethodDeclarationLegacy(interfaceTd, methodName, true);
if (found != null) return found; if (found != null) return found;
} }
} }
@@ -478,15 +605,107 @@ public class CodebaseContext {
return null; return null;
} }
public String getFqn(TypeDeclaration td) { public AbstractTypeDeclaration getAbstractTypeDeclaration(String name) {
StringBuilder sb = new StringBuilder(td.getName().getIdentifier()); // Try exact FQN match first
ASTNode current = td.getParent(); CompilationUnit cu = classes.get(name);
while (current instanceof TypeDeclaration parent) { if (cu != null)
return findAbstractTypeInCu(cu, name);
// If it's a simple name, check if it's ambiguous
if (ambiguousSimpleNames.contains(name)) {
return null;
}
// If not ambiguous, return the one we found during scan
String fqn = simpleNameToFqn.get(name);
if (fqn != null) {
cu = classes.get(fqn);
if (cu != null)
return findAbstractTypeInCu(cu, fqn);
}
return null;
}
public AbstractTypeDeclaration getAbstractTypeDeclaration(String name, CompilationUnit contextCu) {
if (name == null || name.isEmpty())
return null;
// 1. Check if it's already an FQN
if (classes.containsKey(name))
return getAbstractTypeDeclaration(name);
// 2. Check imports in contextCu
if (contextCu != null) {
for (Object impObj : contextCu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (!imp.isStatic() && !imp.isOnDemand()) {
if (impName.endsWith("." + name))
return getAbstractTypeDeclaration(impName);
}
}
// 3. Check same package
String packageName = contextCu.getPackage() != null ? contextCu.getPackage().getName().getFullyQualifiedName() : "";
String localFqn = packageName.isEmpty() ? name : packageName + "." + name;
if (classes.containsKey(localFqn))
return getAbstractTypeDeclaration(localFqn);
// 4. Check on-demand imports (star imports)
for (Object impObj : contextCu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
if (imp.isOnDemand()) {
String starFqn = imp.getName().getFullyQualifiedName() + "." + name;
if (classes.containsKey(starFqn))
return getAbstractTypeDeclaration(starFqn);
}
}
// 5. Fallback to java.lang (common)
String langFqn = "java.lang." + name;
if (classes.containsKey(langFqn))
return getAbstractTypeDeclaration(langFqn);
}
// 6. Last resort: global simple name match
return getAbstractTypeDeclaration(name);
}
private AbstractTypeDeclaration findAbstractTypeInCu(CompilationUnit cu, String fqn) {
for (Object type : cu.types()) {
if (type instanceof AbstractTypeDeclaration atd) {
AbstractTypeDeclaration found = findNestedAbstractType(atd, fqn);
if (found != null) return found;
}
}
return null;
}
private AbstractTypeDeclaration findNestedAbstractType(AbstractTypeDeclaration atd, String targetFqn) {
if (getFqn(atd).equals(targetFqn)) return atd;
if (atd instanceof TypeDeclaration td) {
for (TypeDeclaration nested : td.getTypes()) {
AbstractTypeDeclaration found = findNestedAbstractType(nested, targetFqn);
if (found != null) return found;
}
}
return null;
}
public String getFqn(AbstractTypeDeclaration atd) {
if (atd == null) return null;
StringBuilder sb = new StringBuilder(atd.getName().getIdentifier());
ASTNode current = atd.getParent();
while (current instanceof AbstractTypeDeclaration parent) {
sb.insert(0, parent.getName().getIdentifier() + "."); sb.insert(0, parent.getName().getIdentifier() + ".");
current = parent.getParent(); current = parent.getParent();
} }
CompilationUnit cu = (CompilationUnit) td.getRoot(); CompilationUnit cu = (atd.getRoot() instanceof CompilationUnit) ? (CompilationUnit) atd.getRoot() : null;
if (cu == null) {
return sb.toString();
}
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : ""; String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
String fqn = sb.toString(); String fqn = sb.toString();
return packageName.isEmpty() ? fqn : packageName + "." + fqn; return packageName.isEmpty() ? fqn : packageName + "." + fqn;
@@ -560,10 +779,19 @@ public class CodebaseContext {
} }
public boolean extendsStateMachineConfigurerAdapter(TypeDeclaration td) { public boolean extendsStateMachineConfigurerAdapter(TypeDeclaration td) {
return extendsStateMachineConfigurerAdapter(td, new HashSet<>()); ITypeBinding binding = td.resolveBinding();
if (binding != null) {
boolean isSubtype = BindingResolver.isSubtypeOf(binding, "org.springframework.statemachine.config.builders.StateMachineConfigurer")
|| BindingResolver.isSubtypeOf(binding, "org.springframework.statemachine.config.StateMachineConfigurerAdapter")
|| BindingResolver.isSubtypeOf(binding, "org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter");
if (isSubtype) {
return true;
}
}
return extendsStateMachineConfigurerAdapterLegacy(td, new HashSet<>());
} }
private boolean extendsStateMachineConfigurerAdapter(TypeDeclaration td, Set<String> visited) { private boolean extendsStateMachineConfigurerAdapterLegacy(TypeDeclaration td, Set<String> visited) {
String fqn = getFqn(td); String fqn = getFqn(td);
if (visited.contains(fqn)) { if (visited.contains(fqn)) {
return false; return false;
@@ -580,7 +808,7 @@ public class CodebaseContext {
if (knownAdapters.contains(superclassName)) if (knownAdapters.contains(superclassName))
return true; return true;
TypeDeclaration superTd = getTypeDeclaration(superclassName, cu); TypeDeclaration superTd = getTypeDeclaration(superclassName, cu);
if (superTd != null && extendsStateMachineConfigurerAdapter(superTd, visited)) if (superTd != null && extendsStateMachineConfigurerAdapterLegacy(superTd, visited))
return true; return true;
} }
@@ -591,7 +819,7 @@ public class CodebaseContext {
if (knownAdapters.contains(interfaceName)) if (knownAdapters.contains(interfaceName))
return true; return true;
TypeDeclaration interfaceTd = getTypeDeclaration(interfaceName, cu); TypeDeclaration interfaceTd = getTypeDeclaration(interfaceName, cu);
if (interfaceTd != null && extendsStateMachineConfigurerAdapter(interfaceTd, visited)) if (interfaceTd != null && extendsStateMachineConfigurerAdapterLegacy(interfaceTd, visited))
return true; return true;
} }
} }

View File

@@ -0,0 +1,78 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class ControlFlowGraph {
private final MethodDeclaration method;
private final CfgNode entryNode = new CfgNode(null, CfgNodeType.ENTRY);
private final CfgNode exitNode = new CfgNode(null, CfgNodeType.EXIT);
private final List<CfgNode> nodes = new ArrayList<>();
public enum CfgNodeType {
ENTRY,
EXIT,
STATEMENT
}
public static class CfgNode {
private final ASTNode astNode;
private final CfgNodeType type;
private final Set<CfgNode> predecessors = new HashSet<>();
private final Set<CfgNode> successors = new HashSet<>();
public CfgNode(ASTNode astNode, CfgNodeType type) {
this.astNode = astNode;
this.type = type;
}
public ASTNode getAstNode() {
return astNode;
}
public CfgNodeType getType() {
return type;
}
public Set<CfgNode> getPredecessors() {
return predecessors;
}
public Set<CfgNode> getSuccessors() {
return successors;
}
public void addSuccessor(CfgNode succ) {
if (succ != null) {
this.successors.add(succ);
succ.predecessors.add(this);
}
}
}
public ControlFlowGraph(MethodDeclaration method) {
this.method = method;
this.nodes.add(entryNode);
this.nodes.add(exitNode);
}
public MethodDeclaration getMethod() {
return method;
}
public CfgNode getEntryNode() {
return entryNode;
}
public CfgNode getExitNode() {
return exitNode;
}
public List<CfgNode> getNodes() {
return nodes;
}
public void addNode(CfgNode node) {
this.nodes.add(node);
}
}

View File

@@ -0,0 +1,18 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.Expression;
import java.util.List;
public interface DataFlowModel {
/**
* Resolves all possible Expressions that define the value of the given expression
* at its location in the control flow.
*/
List<Expression> getReachingDefinitions(Expression expr);
/**
* Resolves the single constant/literal value if all definitions converge,
* or if a single value is expected.
*/
String resolveValue(Expression expr, CodebaseContext context);
}

View File

@@ -32,6 +32,9 @@ public class FileUtils {
return paths return paths
.filter(p -> Files.isRegularFile(p) && p.toString().endsWith(extension)) .filter(p -> Files.isRegularFile(p) && p.toString().endsWith(extension))
.filter(p -> matchers.stream().noneMatch(m -> m.matches(p))) .filter(p -> matchers.stream().noneMatch(m -> m.matches(p)))
.map(Path::toAbsolutePath)
.map(Path::normalize)
.distinct()
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
} }

View File

@@ -0,0 +1,31 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.Expression;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import java.util.List;
public class LegacyDataFlowModel implements DataFlowModel {
private final VariableTracer variableTracer;
public LegacyDataFlowModel(VariableTracer variableTracer) {
this.variableTracer = variableTracer;
}
@Override
public List<Expression> getReachingDefinitions(Expression expr) {
if (variableTracer == null || expr == null) {
return expr != null ? List.of(expr) : List.of();
}
return variableTracer.traceVariableAll(expr);
}
@Override
public String resolveValue(Expression expr, CodebaseContext context) {
if (expr == null) return null;
if (variableTracer == null) {
return context.resolveExpression(expr);
}
Expression traced = variableTracer.traceVariable(expr);
return context.resolveExpression(traced);
}
}

View File

@@ -0,0 +1,155 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class ReachingDefinitions {
private final ControlFlowGraph cfg;
private final Map<ControlFlowGraph.CfgNode, Set<ASTNode>> inMap = new HashMap<>();
private final Map<ControlFlowGraph.CfgNode, Set<ASTNode>> outMap = new HashMap<>();
public ReachingDefinitions(ControlFlowGraph cfg) {
this.cfg = cfg;
analyze();
}
private void analyze() {
List<ControlFlowGraph.CfgNode> cfgNodes = cfg.getNodes();
Map<ControlFlowGraph.CfgNode, ASTNode> nodeDefs = new HashMap<>();
Map<String, Set<ASTNode>> varToDefs = new HashMap<>();
for (ControlFlowGraph.CfgNode node : cfgNodes) {
ASTNode ast = node.getAstNode();
if (ast == null) continue;
ASTNode def = findDefinition(ast);
if (def != null) {
nodeDefs.put(node, def);
String varName = getDefinedVariableName(def);
if (varName != null) {
varToDefs.computeIfAbsent(varName, k -> new HashSet<>()).add(def);
}
}
}
for (ControlFlowGraph.CfgNode node : cfgNodes) {
inMap.put(node, new HashSet<>());
outMap.put(node, new HashSet<>());
}
boolean changed = true;
int iterations = 0;
int maxIterations = 5000;
while (changed && iterations < maxIterations) {
iterations++;
changed = false;
for (ControlFlowGraph.CfgNode node : cfgNodes) {
Set<ASTNode> newIn = new HashSet<>();
for (ControlFlowGraph.CfgNode pred : node.getPredecessors()) {
newIn.addAll(outMap.get(pred));
}
inMap.put(node, newIn);
Set<ASTNode> newOut = new HashSet<>(newIn);
ASTNode def = nodeDefs.get(node);
if (def != null) {
String varName = getDefinedVariableName(def);
if (varName != null) {
Set<ASTNode> allDefs = varToDefs.get(varName);
if (allDefs != null) {
newOut.removeAll(allDefs);
}
}
newOut.add(def);
}
Set<ASTNode> oldOut = outMap.put(node, newOut);
if (!newOut.equals(oldOut)) {
changed = true;
}
}
}
if (iterations >= maxIterations) {
// Log warning or print to stderr
System.err.println("Warning: ReachingDefinitions analysis reached max iterations (" + maxIterations + ") and terminated early.");
}
}
public Set<ASTNode> getReachingDefinitions(ASTNode usageNode, String varName) {
ControlFlowGraph.CfgNode cfgNode = findCfgNodeForUsage(usageNode);
if (cfgNode == null) {
return Collections.emptySet();
}
Set<ASTNode> reaching = new HashSet<>();
Set<ASTNode> inDefs = inMap.get(cfgNode);
if (inDefs != null) {
for (ASTNode def : inDefs) {
if (varName.equals(getDefinedVariableName(def))) {
reaching.add(def);
}
}
}
return reaching;
}
private ControlFlowGraph.CfgNode findCfgNodeForUsage(ASTNode usageNode) {
ASTNode current = usageNode;
while (current != null) {
for (ControlFlowGraph.CfgNode node : cfg.getNodes()) {
if (node.getAstNode() == current) {
return node;
}
}
current = current.getParent();
}
return null;
}
private ASTNode findDefinition(ASTNode ast) {
if (ast instanceof VariableDeclarationStatement vds) {
if (!vds.fragments().isEmpty()) {
return (ASTNode) vds.fragments().get(0);
}
}
if (ast instanceof VariableDeclarationFragment) {
return ast;
}
if (ast instanceof SingleVariableDeclaration) {
return ast;
}
if (ast instanceof Assignment) {
return ast;
}
if (ast instanceof ExpressionStatement es) {
return findDefinition(es.getExpression());
}
return null;
}
public static String getDefinedVariableName(ASTNode def) {
if (def instanceof VariableDeclarationFragment frag) {
return frag.getName().getIdentifier();
}
if (def instanceof SingleVariableDeclaration svd) {
return svd.getName().getIdentifier();
}
if (def instanceof Assignment assign) {
if (assign.getLeftHandSide() instanceof SimpleName sn) {
return sn.getIdentifier();
}
}
return null;
}
public static Expression getDefinitionExpression(ASTNode def) {
if (def instanceof VariableDeclarationFragment frag) {
return frag.getInitializer();
}
if (def instanceof Assignment assign) {
return assign.getRightHandSide();
}
return null;
}
}

View File

@@ -0,0 +1,119 @@
package click.kamil.springstatemachineexporter.ast.common;
import click.kamil.springstatemachineexporter.model.State;
import org.eclipse.jdt.core.dom.*;
public class StateResolver {
private final CodebaseContext context;
public StateResolver(CodebaseContext context) {
this.context = context;
}
public State resolveState(Expression expr, CompilationUnit cu) {
if (expr == null)
return null;
// 1. Check for constants
String resolvedValue = context.getConstantResolver().resolve(expr, context);
if (resolvedValue != null) {
return State.of(expr.toString(), resolvedValue);
}
// 2. Check for @Value fields (SimpleName)
if (expr instanceof SimpleName sn) {
String fieldName = sn.getIdentifier();
TypeDeclaration td = findEnclosingType(sn);
if (td != null) {
String value = findValueFromField(td, fieldName);
if (value != null) {
return State.of(fieldName, value);
}
}
}
if (expr instanceof StringLiteral sl)
return State.of(sl.getLiteralValue());
String raw = expr.toString();
if (expr instanceof QualifiedName qn) {
String full = resolveQualifiedName(qn, cu);
return State.of(raw, full);
}
if (expr instanceof SimpleName sn) {
String full = resolveSimpleName(sn, cu);
return State.of(raw, full);
}
return State.of(raw);
}
private String findValueFromField(TypeDeclaration td, String fieldName) {
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(fieldName)) {
// Check for @Value annotation
for (Object mod : fd.modifiers()) {
if (mod instanceof Annotation ann) {
String name = ann.getTypeName().getFullyQualifiedName();
if (name.endsWith("Value")) {
return extractAnnotationValue(ann);
}
}
}
}
}
}
}
return null;
}
private String extractAnnotationValue(Annotation ann) {
if (ann instanceof SingleMemberAnnotation sma) {
return stripQuotes(sma.getValue().toString());
} else if (ann instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if ("value".equals(pair.getName().getIdentifier())) {
return stripQuotes(pair.getValue().toString());
}
}
}
return null;
}
private String stripQuotes(String s) {
if (s == null) return null;
return s.replaceAll("^\"|\"$", "");
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private String resolveQualifiedName(QualifiedName qn, CompilationUnit cu) {
String qualifier = qn.getQualifier().toString();
String name = qn.getName().getIdentifier();
TypeDeclaration td = context.getTypeDeclaration(qualifier, cu);
if (td != null)
return context.getFqn(td) + "." + name;
return qn.getFullyQualifiedName();
}
private String resolveSimpleName(SimpleName sn, CompilationUnit cu) {
String name = sn.getIdentifier();
if (cu == null)
return name;
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + name))
return impName;
}
return name;
}
}

View File

@@ -48,11 +48,28 @@ public class ExporterCommand implements Callable<Integer> {
@Option(names = {"-p", "--profiles"}, description = "Spring profiles to activate for property resolution.", split = ",") @Option(names = {"-p", "--profiles"}, description = "Spring profiles to activate for property resolution.", split = ",")
private List<String> activeProfiles; private List<String> activeProfiles;
@Option(names = {"--event"}, description = "Format for events when they are enums: fn (fullName, default), fqn (fully qualified name), sn (short name)", defaultValue = "fn")
private click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat;
@Option(names = {"--state"}, description = "Format for states when they are enums: fn (fullName, default), fqn (fully qualified name), sn (short name)", defaultValue = "fn")
private click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat;
@Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false")
private boolean debug;
@Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "false")
private boolean resolveClasspath;
@Override @Override
public Integer call() throws Exception { public Integer call() throws Exception {
var out = spec.commandLine().getOut(); var out = spec.commandLine().getOut();
var err = spec.commandLine().getErr(); var err = spec.commandLine().getErr();
if (debug) {
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug");
out.println(CommandLine.Help.Ansi.AUTO.string("@|bold,cyan Diagnostic mode enabled|@"));
}
if (inputDir == null && jsonFile == null) { if (inputDir == null && jsonFile == null) {
inputDir = Path.of("."); inputDir = Path.of(".");
} }
@@ -71,9 +88,9 @@ public class ExporterCommand implements Callable<Integer> {
try { try {
List<String> profiles = activeProfiles != null ? activeProfiles : java.util.Collections.emptyList(); List<String> profiles = activeProfiles != null ? activeProfiles : java.util.Collections.emptyList();
if (jsonFile != null) { if (jsonFile != null) {
exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles); exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat);
} else { } else {
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles); exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, resolveClasspath);
} }
out.println(CommandLine.Help.Ansi.AUTO.string("%n@|bold,green SUCCESS:|@ All state machines have been exported to " + outputDir.toAbsolutePath())); out.println(CommandLine.Help.Ansi.AUTO.string("%n@|bold,green SUCCESS:|@ All state machines have been exported to " + outputDir.toAbsolutePath()));
return 0; return 0;

View File

@@ -41,7 +41,7 @@ public class Dot implements StateMachineExporter {
for (Transition t : transitions) { for (Transition t : transitions) {
if (t.getType() == TransitionType.CHOICE && t.getSourceStates() != null) { if (t.getType() == TransitionType.CHOICE && t.getSourceStates() != null) {
for (State source : t.getSourceStates()) { for (State source : t.getSourceStates()) {
String simplified = simplify(source.toString()); String simplified = simplify(options.formatState(source));
statesWithChoice.add(simplified); statesWithChoice.add(simplified);
if (!choiceColorMap.containsKey(simplified)) { if (!choiceColorMap.containsKey(simplified)) {
choiceColorMap.put(simplified, CHOICE_COLORS[colorIndex % CHOICE_COLORS.length]); choiceColorMap.put(simplified, CHOICE_COLORS[colorIndex % CHOICE_COLORS.length]);
@@ -86,11 +86,11 @@ public class Dot implements StateMachineExporter {
} }
for (State rawSourceState : t.getSourceStates()) { for (State rawSourceState : t.getSourceStates()) {
String rawSource = rawSourceState.toString(); String rawSource = options.formatState(rawSourceState);
String source = simplify(rawSource); String source = simplify(rawSource);
for (State rawTargetState : targets) { for (State rawTargetState : targets) {
String rawTarget = rawTargetState.toString(); String rawTarget = options.formatState(rawTargetState);
String target = simplify(rawTarget); String target = simplify(rawTarget);
String edgeSource = source; String edgeSource = source;
@@ -100,17 +100,26 @@ public class Dot implements StateMachineExporter {
// Label: event [guard] / actions // Label: event [guard] / actions
StringBuilder label = new StringBuilder(); StringBuilder label = new StringBuilder();
if (t.getEvent() != null && !t.getEvent().isBlank()) { if (t.getEvent() != null) {
label.append(escapeDotString(t.getEvent())); String eventStr = options.formatEvent(t.getEvent());
if (eventStr != null && !eventStr.isBlank()) {
label.append(escapeDotString(eventStr));
}
} }
// Guard // Guards
if (t.getGuard() != null && !t.getGuard().expression().isBlank()) { if (t.getGuards() != null && !t.getGuards().isEmpty()) {
if (!label.isEmpty()) if (!label.isEmpty())
label.append(" "); label.append(" ");
String guardText = options.isUseLambdaGuards() && t.getGuard().isLambda() ? "λ" label.append("[");
: escapeDotString(t.getGuard().expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim()); for (int i = 0; i < t.getGuards().size(); i++) {
label.append("[").append(guardText).append("]"); if (i > 0) label.append(" && ");
var guard = t.getGuards().get(i);
String guardText = options.isUseLambdaGuards() && guard.isLambda() ? "λ"
: escapeDotString(guard.expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim());
label.append(guardText);
}
label.append("]");
} }
// Actions // Actions

View File

@@ -0,0 +1,5 @@
package click.kamil.springstatemachineexporter.exporter;
public enum EnumFormat {
fn, fqn, sn
}

View File

@@ -12,4 +12,37 @@ public class ExportOptions {
boolean renderChoicesAsDiamonds = true; boolean renderChoicesAsDiamonds = true;
@Builder.Default @Builder.Default
boolean embedIdentifiers = false; boolean embedIdentifiers = false;
@Builder.Default
boolean includeMetadataPane = true;
@Builder.Default
EnumFormat eventFormat = EnumFormat.fn;
@Builder.Default
EnumFormat stateFormat = EnumFormat.fn;
public String formatState(click.kamil.springstatemachineexporter.model.State state) {
if (state == null) return null;
return format(state.rawName(), state.fullIdentifier(), stateFormat);
}
public String formatEvent(click.kamil.springstatemachineexporter.model.Event event) {
if (event == null) return null;
return format(event.rawName(), event.fullIdentifier(), eventFormat);
}
private String format(String raw, String fqn, EnumFormat format) {
if (fqn == null) return raw;
switch (format) {
case fqn: return fqn;
case sn: return fqn.substring(fqn.lastIndexOf('.') + 1);
case fn:
int lastDot = fqn.lastIndexOf('.');
if (lastDot > 0) {
int prevDot = fqn.lastIndexOf('.', lastDot - 1);
return prevDot > 0 ? fqn.substring(prevDot + 1) : fqn;
}
return fqn;
default: return raw;
}
}
} }

View File

@@ -63,7 +63,7 @@ public class PlantUml implements StateMachineExporter {
for (Transition t : transitions) { for (Transition t : transitions) {
if (t.getType() == TransitionType.CHOICE && t.getSourceStates() != null) { if (t.getType() == TransitionType.CHOICE && t.getSourceStates() != null) {
for (State source : t.getSourceStates()) { for (State source : t.getSourceStates()) {
statesWithChoice.add(simplify(source.toString())); statesWithChoice.add(simplify(options.formatState(source)));
} }
} }
} }
@@ -77,7 +77,7 @@ public class PlantUml implements StateMachineExporter {
Set<String> junctionStates = transitions.stream() Set<String> junctionStates = transitions.stream()
.filter(t -> t.getType() == TransitionType.JUNCTION) .filter(t -> t.getType() == TransitionType.JUNCTION)
.flatMap(t -> t.getSourceStates().stream()) .flatMap(t -> t.getSourceStates().stream())
.map(s -> simplify(s.toString())) .map(s -> simplify(options.formatState(s)))
.collect(Collectors.toCollection(java.util.LinkedHashSet::new)); .collect(Collectors.toCollection(java.util.LinkedHashSet::new));
for (String junction : junctionStates) { for (String junction : junctionStates) {
if (options.isRenderChoicesAsDiamonds()) { if (options.isRenderChoicesAsDiamonds()) {
@@ -96,16 +96,16 @@ public class PlantUml implements StateMachineExporter {
List<String> targets; List<String> targets;
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) { if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
if (!allowNoTarget) { if (!allowNoTarget) {
targets = t.getSourceStates().stream().map(s -> s.toString()).toList(); targets = t.getSourceStates().stream().map(s -> options.formatState(s)).toList();
} else { } else {
continue; continue;
} }
} else { } else {
targets = t.getTargetStates().stream().map(s -> s.toString()).toList(); targets = t.getTargetStates().stream().map(s -> options.formatState(s)).toList();
} }
for (State rawSourceState : t.getSourceStates()) { for (State rawSourceState : t.getSourceStates()) {
String source = simplify(rawSourceState.toString()); String source = simplify(options.formatState(rawSourceState));
for (String rawTarget : targets) { for (String rawTarget : targets) {
String target = simplify(rawTarget); String target = simplify(rawTarget);
@@ -116,19 +116,23 @@ public class PlantUml implements StateMachineExporter {
sb.append(" <<").append(styleClass).append(">>"); sb.append(" <<").append(styleClass).append(">>");
if (t.getEvent() != null && !t.getEvent().isBlank()) {
sb.append(" <<e_").append(normalize(t.getEvent())).append(">>");
}
String label = buildLabel(t, options.isUseLambdaGuards()); String label = buildLabel(t, options);
if (options.isEmbedIdentifiers()) { if (options.isEmbedIdentifiers()) {
String linkId = t.getEvent() != null ? "link_" + normalize(t.getEvent()) : "link_anon_" + normalize(source) + "_" + normalize(target); String eventStr = t.getEvent() != null ? options.formatEvent(t.getEvent()) : null;
// Force a label even if empty to ensure the link group exists in SVG String linkId = eventStr != null && !eventStr.isBlank() ? "link_" + normalize(source) + "__" + normalize(eventStr) : "link_anon_" + normalize(source) + "_" + normalize(target);
String displayLabel = label.isEmpty() ? " " : label;
// Brackets [...] in the label break PlantUML link syntax [[url label]], sb.append(" : [[#").append(linkId).append(" ");
// so we strip them for the interactive identification label. if (!label.isEmpty()) {
String safeLabel = displayLabel.replaceAll("[\\[\\]]", ""); String sanitized = label.replace("[", "&#91;")
sb.append(" : ").append("[[#").append(linkId).append(" ").append(safeLabel).append("]]"); .replace("]", "&#93;")
.replace("<", "&lt;")
.replace(">", "&gt;");
sb.append(sanitized);
} else {
sb.append(".");
}
sb.append("]]");
} else if (!label.isEmpty()) { } else if (!label.isEmpty()) {
sb.append(" : ").append(label); sb.append(" : ").append(label);
} }
@@ -191,22 +195,32 @@ public class PlantUml implements StateMachineExporter {
return state.replaceAll("[^a-zA-Z0-9_.]", ""); return state.replaceAll("[^a-zA-Z0-9_.]", "");
} }
private String buildLabel(Transition t, boolean useLambdaGuards) { private String buildLabel(Transition t, ExportOptions options) {
List<String> parts = new ArrayList<>(); List<String> parts = new ArrayList<>();
if (t.getEvent() != null && !t.getEvent().isBlank()) { if (t.getEvent() != null) {
parts.add(t.getEvent()); String eventStr = options.formatEvent(t.getEvent());
} if (eventStr != null && !eventStr.isBlank()) {
if (t.getGuard() != null) { parts.add(eventStr);
String g = t.getGuard().expression();
if (useLambdaGuards && t.getGuard().isLambda()) {
parts.add("[" + LAMBDA + "]");
} else {
parts.add("[" + g.replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim() + "]");
} }
} }
if (t.getGuards() != null && !t.getGuards().isEmpty()) {
StringBuilder guardsBuilder = new StringBuilder();
guardsBuilder.append("[");
for (int i = 0; i < t.getGuards().size(); i++) {
if (i > 0) guardsBuilder.append(" && ");
var guard = t.getGuards().get(i);
if (options.isUseLambdaGuards() && guard.isLambda()) {
guardsBuilder.append(LAMBDA);
} else {
guardsBuilder.append(guard.expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim());
}
}
guardsBuilder.append("]");
parts.add(guardsBuilder.toString());
}
if (t.getActions() != null && !t.getActions().isEmpty()) { if (t.getActions() != null && !t.getActions().isEmpty()) {
parts.add("/ " + t.getActions().stream() parts.add("/ " + t.getActions().stream()
.map(a -> (useLambdaGuards && a.isLambda()) ? LAMBDA : a.expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim()) .map(a -> (options.isUseLambdaGuards() && a.isLambda()) ? LAMBDA : a.expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim())
.collect(Collectors.joining(", "))); .collect(Collectors.joining(", ")));
} }
Optional.ofNullable(t.getOrder()) Optional.ofNullable(t.getOrder())

View File

@@ -31,8 +31,8 @@ public class Scxml implements StateMachineExporter {
Set<String> allStates = new java.util.LinkedHashSet<>(); Set<String> allStates = new java.util.LinkedHashSet<>();
for (Transition t : transitions) { for (Transition t : transitions) {
t.getSourceStates().forEach(s -> allStates.add(s.toString())); t.getSourceStates().forEach(s -> allStates.add(options.formatState(s)));
t.getTargetStates().forEach(s -> allStates.add(s.toString())); t.getTargetStates().forEach(s -> allStates.add(options.formatState(s)));
} }
allStates.addAll(startStates); allStates.addAll(startStates);
allStates.addAll(endStates); allStates.addAll(endStates);
@@ -41,7 +41,7 @@ public class Scxml implements StateMachineExporter {
sb.append(" <state id=\"").append(simplify(state)).append("\">\n"); sb.append(" <state id=\"").append(simplify(state)).append("\">\n");
for (Transition t : transitions) { for (Transition t : transitions) {
if (t.getSourceStates() == null || t.getSourceStates().stream().noneMatch(s -> s.toString().equals(state))) if (t.getSourceStates() == null || t.getSourceStates().stream().noneMatch(s -> options.formatState(s).equals(state)))
continue; continue;
boolean allowNoTarget = t.getType() == TransitionType.JOIN || t.getType() == TransitionType.FORK; boolean allowNoTarget = t.getType() == TransitionType.JOIN || t.getType() == TransitionType.FORK;
@@ -58,7 +58,7 @@ public class Scxml implements StateMachineExporter {
} }
for (State target : targets) { for (State target : targets) {
sb.append(renderTransition(t, target, options.isUseLambdaGuards())); sb.append(renderTransition(t, target, options));
} }
} }
sb.append(" </state>\n"); sb.append(" </state>\n");
@@ -73,15 +73,18 @@ public class Scxml implements StateMachineExporter {
return ".scxml.xml"; return ".scxml.xml";
} }
private String renderTransition(Transition t, State target, boolean useLambdaGuards) { private String renderTransition(Transition t, State target, ExportOptions options) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append(" <transition target=\"").append(simplify(target.toString())).append("\""); sb.append(" <transition target=\"").append(simplify(options.formatState(target))).append("\"");
if (t.getEvent() != null && !t.getEvent().isBlank()) { if (t.getEvent() != null) {
sb.append(" event=\"").append(escapeXml(t.getEvent())).append("\""); String eventStr = options.formatEvent(t.getEvent());
if (eventStr != null && !eventStr.isBlank()) {
sb.append(" event=\"").append(escapeXml(eventStr)).append("\"");
}
} }
String guard = getGuardText(t, useLambdaGuards); String guard = getGuardText(t, options.isUseLambdaGuards());
if (!guard.isBlank()) { if (!guard.isBlank()) {
sb.append(" cond=\"").append(escapeXml(guard)).append("\""); sb.append(" cond=\"").append(escapeXml(guard)).append("\"");
} }
@@ -99,12 +102,20 @@ public class Scxml implements StateMachineExporter {
} }
private static String getGuardText(Transition t, boolean useLambdaGuards) { private static String getGuardText(Transition t, boolean useLambdaGuards) {
if (t.getGuard() == null || t.getGuard().expression().isBlank()) if (t.getGuards() == null || t.getGuards().isEmpty())
return ""; return "";
if (useLambdaGuards && t.getGuard().isLambda()) {
return LAMBDA; StringBuilder sb = new StringBuilder();
for (int i = 0; i < t.getGuards().size(); i++) {
if (i > 0) sb.append(" && ");
var guard = t.getGuards().get(i);
if (useLambdaGuards && guard.isLambda()) {
sb.append(LAMBDA);
} else {
sb.append(guard.expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim());
}
} }
return t.getGuard().expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim(); return sb.toString();
} }
private static String escapeXml(String s) { private static String escapeXml(String s) {

View File

@@ -0,0 +1,16 @@
package click.kamil.springstatemachineexporter.model;
public record Event(String rawName, String fullIdentifier) {
public static Event of(String name) {
return new Event(name, name);
}
public static Event of(String raw, String full) {
return new Event(raw, full);
}
@Override
public String toString() {
return fullIdentifier != null ? fullIdentifier : rawName;
}
}

View File

@@ -14,9 +14,9 @@ public class Transition {
private TransitionType type; private TransitionType type;
private List<State> sourceStates = new ArrayList<>(); private List<State> sourceStates = new ArrayList<>();
private List<State> targetStates = new ArrayList<>(); private List<State> targetStates = new ArrayList<>();
private String event; private Event event;
private Guard guard; private List<Guard> guards = new ArrayList<>();
private List<Action> actions = new ArrayList<>(); private List<Action> actions = new ArrayList<>();
private Integer order = null; // optional order for withChoice or withJunction private Integer order = null; // optional order for withChoice or withJunction

View File

@@ -0,0 +1,147 @@
package click.kamil.springstatemachineexporter.plugin.java;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.JdtCallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.app.AstTransitionParser;
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
import click.kamil.springstatemachineexporter.ast.app.TransitionStateUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.spi.LanguageAnalyzerPlugin;
import click.kamil.springstatemachineexporter.spi.SourceContext;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.nio.file.Path;
import java.util.*;
public class JavaLanguageAnalyzerPlugin implements LanguageAnalyzerPlugin {
@Override
public boolean supports(Path projectRoot) {
return true;
}
@Override
public SourceContext parseProject(Path projectRoot, List<String> classpath, Set<Path> sourcePaths) {
CodebaseContext context = new CodebaseContext();
context.setProjectRoot(projectRoot);
if (classpath != null && !classpath.isEmpty()) {
context.setClasspath(classpath);
}
Path hintsFile = projectRoot.resolve("hints.json");
if (!java.nio.file.Files.exists(hintsFile)) {
hintsFile = projectRoot.resolve("src/main/resources/hints.json");
}
if (java.nio.file.Files.exists(hintsFile)) {
try {
context.loadLibraryHints(hintsFile);
} catch (java.io.IOException e) {
throw new RuntimeException(e);
}
}
List<String> sp = sourcePaths.stream()
.map(p -> p.resolve("src/main/java"))
.filter(p -> java.nio.file.Files.exists(p))
.map(Path::toString)
.toList();
if (sp.isEmpty()) {
sp = sourcePaths.stream().map(Path::toString).toList();
}
context.setSourcepath(sp);
context.setResolveBindings(true);
try {
context.scan(sourcePaths, Collections.emptySet());
} catch (java.io.IOException e) {
throw new RuntimeException(e);
}
return context;
}
@Override
public CallGraphEngine createCallGraphEngine(SourceContext context) {
if (context instanceof CodebaseContext jdtContext) {
return new JdtCallGraphEngine(jdtContext, null);
}
throw new IllegalArgumentException("Expected CodebaseContext");
}
@Override
public CodebaseIntelligenceProvider createIntelligenceProvider(SourceContext context) {
if (context instanceof CodebaseContext jdtContext) {
return new JdtIntelligenceProvider(jdtContext, jdtContext.getProjectRoot());
}
throw new IllegalArgumentException("Expected CodebaseContext");
}
@Override
public List<AnalysisResult> extractStateMachines(SourceContext context, boolean renderChoicesAsDiamonds) {
if (context instanceof CodebaseContext jdtContext) {
List<AnalysisResult> results = new ArrayList<>();
// 1. Find entry point classes (annotated or extending adapter)
List<TypeDeclaration> entryPoints = jdtContext.findEntryPointClasses(List.of("EnableStateMachineFactory", "EnableStateMachine"));
for (TypeDeclaration td : entryPoints) {
String className = jdtContext.getFqn(td);
StateMachineAggregator aggregator = new StateMachineAggregator(jdtContext);
List<Transition> transitions = aggregator.aggregateTransitions(td);
aggregator.aggregateStates(td);
Set<String> initialStatesAst = aggregator.getInitialStates();
Set<String> endStatesAst = aggregator.getEndStates();
Set<String> startStates = TransitionStateUtils.findStartStates(transitions, initialStatesAst);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst);
Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst);
if (allStates.isEmpty() && transitions.isEmpty()) {
continue;
}
results.add(AnalysisResult.builder()
.name(className)
.states(allStates)
.transitions(transitions)
.startStates(startStates)
.endStates(endStates)
.renderChoicesAsDiamonds(renderChoicesAsDiamonds)
.build());
}
// 2. Find methods returning StateMachine beans
List<MethodDeclaration> beanMethods = jdtContext.findBeanMethodsReturning(Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory"));
for (MethodDeclaration m : beanMethods) {
TypeDeclaration parentClass = (TypeDeclaration) m.getParent();
String parentFqn = jdtContext.getFqn(parentClass);
String methodName = m.getName().getIdentifier();
String uniqueName = parentFqn + "#" + methodName;
List<Transition> transitions = AstTransitionParser.parseTransitions(m, jdtContext);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions, null);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, null);
Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, null, null);
if (allStates.isEmpty() && transitions.isEmpty()) {
continue;
}
results.add(AnalysisResult.builder()
.name(uniqueName)
.states(allStates)
.transitions(transitions)
.startStates(startStates)
.endStates(endStates)
.renderChoicesAsDiamonds(renderChoicesAsDiamonds)
.build());
}
return results;
}
return Collections.emptyList();
}
}

View File

@@ -20,6 +20,11 @@ import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.MethodDeclaration; import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration; import org.eclipse.jdt.core.dom.TypeDeclaration;
import click.kamil.springstatemachineexporter.spi.LanguageAnalyzerPlugin;
import click.kamil.springstatemachineexporter.spi.SourceContext;
import click.kamil.springstatemachineexporter.analysis.service.CompositeIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.JdtCallGraphEngine;
import java.io.IOException; import java.io.IOException;
import java.io.PrintWriter; import java.io.PrintWriter;
@@ -44,7 +49,9 @@ public class ExportService {
new TriggerEnricher(), new TriggerEnricher(),
new EntryPointEnricher(), new EntryPointEnricher(),
new PropertyEnricher(), new PropertyEnricher(),
new CallChainEnricher() new CallChainEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.path.ForwardPathEstimationEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher()
))); )));
} }
@@ -52,14 +59,22 @@ public class ExportService {
public static final Set<String> STATE_MACHINE_RETURN_TYPES = Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory"); public static final Set<String> STATE_MACHINE_RETURN_TYPES = Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory");
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException { public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList()); runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
} }
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles) throws IOException { public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null); runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
} }
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter) throws IOException { public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, eventFormat, stateFormat, false);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, boolean resolveClasspath) throws IOException {
CodebaseContext context = new CodebaseContext(); CodebaseContext context = new CodebaseContext();
context.setActiveProfiles(activeProfiles); context.setActiveProfiles(activeProfiles);
@@ -85,6 +100,18 @@ public class ExportService {
.collect(Collectors.toList()); .collect(Collectors.toList());
log.info("Setting JDT sourcepath: {}", sourcepaths); log.info("Setting JDT sourcepath: {}", sourcepaths);
context.setSourcepath(sourcepaths); context.setSourcepath(sourcepaths);
if (resolveClasspath) {
click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver classpathResolver = new click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver();
List<String> dynamicClasspath = classpathResolver.resolveClasspath(projectRoot);
if (!dynamicClasspath.isEmpty()) {
context.setClasspath(dynamicClasspath);
log.info("Injected {} external JARs into JDT ASTParser", dynamicClasspath.size());
}
} else {
log.info("Dynamic classpath resolution disabled. Use --resolve-classpath to enable.");
}
context.setResolveBindings(true); context.setResolveBindings(true);
Path hintsFile = inputDir.resolve("hints.json"); Path hintsFile = inputDir.resolve("hints.json");
@@ -115,22 +142,75 @@ public class ExportService {
context.scan(allPaths, Collections.emptySet()); context.scan(allPaths, Collections.emptySet());
CodebaseIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, inputDir); // Load SPI plugins dynamically
exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds, flows, machineFilter, activeProfiles != null ? activeProfiles : Collections.emptyList()); ServiceLoader<LanguageAnalyzerPlugin> loader = ServiceLoader.load(LanguageAnalyzerPlugin.class);
List<LanguageAnalyzerPlugin> plugins = new ArrayList<>();
for (LanguageAnalyzerPlugin plugin : loader) {
if (plugin.supports(inputDir)) {
plugins.add(plugin);
}
}
if (plugins.isEmpty()) {
plugins.add(new click.kamil.springstatemachineexporter.plugin.java.JavaLanguageAnalyzerPlugin());
}
List<SourceContext> sourceContexts = new ArrayList<>();
List<CodebaseIntelligenceProvider> providers = new ArrayList<>();
List<CallGraphEngine> engines = new ArrayList<>();
List<AnalysisResult> extractedResults = new ArrayList<>();
for (LanguageAnalyzerPlugin plugin : plugins) {
SourceContext sc = plugin.parseProject(projectRoot, context.getClasspath(), allPaths);
sourceContexts.add(sc);
providers.add(plugin.createIntelligenceProvider(sc));
engines.add(plugin.createCallGraphEngine(sc));
extractedResults.addAll(plugin.extractStateMachines(sc, renderChoicesAsDiamonds));
}
CodebaseContext codebaseContext = context;
CodebaseIntelligenceProvider unifiedIntelligence;
if (plugins.size() == 1) {
unifiedIntelligence = providers.get(0);
if (sourceContexts.get(0) instanceof CodebaseContext jdtCtx) {
codebaseContext = jdtCtx;
}
} else {
unifiedIntelligence = new CompositeIntelligenceProvider(providers, engines);
}
// Process all extracted state machines
for (AnalysisResult result : extractedResults) {
String name = result.getName();
if (machineFilter != null && !name.contains(machineFilter)) {
continue;
}
if (result.getFlows() == null || result.getFlows().isEmpty()) {
result.setFlows(flows);
}
enrichmentService.enrich(result, codebaseContext, unifiedIntelligence);
resolveProperties(result, activeProfiles);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
}
} }
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats) throws IOException { public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats) throws IOException {
runJsonExporter(jsonFile, outputDir, selectedFormats, Collections.emptyList()); runJsonExporter(jsonFile, outputDir, selectedFormats, Collections.emptyList(), click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
} }
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats, List<String> activeProfiles) throws IOException { public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats, List<String> activeProfiles) throws IOException {
runJsonExporter(jsonFile, outputDir, selectedFormats, activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
}
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
JsonImportService jsonImportService = new JsonImportService(); JsonImportService jsonImportService = new JsonImportService();
AnalysisResult result = jsonImportService.importAnalysisResult(jsonFile); AnalysisResult result = jsonImportService.importAnalysisResult(jsonFile);
// Apply property resolution pass if placeholders exist // Apply property resolution pass if placeholders exist
resolveProperties(result, activeProfiles); resolveProperties(result, activeProfiles);
generateOutputs(outputDir, result, selectedFormats); generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
} }
private void resolveProperties(AnalysisResult result, List<String> activeProfiles) { private void resolveProperties(AnalysisResult result, List<String> activeProfiles) {
@@ -154,7 +234,7 @@ public class ExportService {
result.applyResolution(merged); result.applyResolution(merged);
} }
private void exportAll(CodebaseContext context, CodebaseIntelligenceProvider intelligence, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows, String machineFilter, List<String> activeProfiles) throws IOException { 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 {
Set<String> processedLocations = new HashSet<>(); Set<String> processedLocations = new HashSet<>();
// 1. Find entry point classes (annotated or extending adapter) // 1. Find entry point classes (annotated or extending adapter)
@@ -163,7 +243,7 @@ public class ExportService {
String fqn = context.getFqn(td); String fqn = context.getFqn(td);
if (machineFilter != null && !fqn.contains(machineFilter)) continue; if (machineFilter != null && !fqn.contains(machineFilter)) continue;
if (processedLocations.add(fqn)) { if (processedLocations.add(fqn)) {
processEntryPoint(td, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds, flows, activeProfiles); processEntryPoint(td, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds, flows, activeProfiles, eventFormat, stateFormat);
} }
} }
@@ -177,12 +257,12 @@ public class ExportService {
if (machineFilter != null && !uniqueId.contains(machineFilter)) continue; if (machineFilter != null && !uniqueId.contains(machineFilter)) continue;
if (processedLocations.add(uniqueId)) { if (processedLocations.add(uniqueId)) {
processBeanMethod(m, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds, flows, activeProfiles); processBeanMethod(m, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds, flows, activeProfiles, eventFormat, stateFormat);
} }
} }
} }
private void processEntryPoint(TypeDeclaration td, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows, List<String> activeProfiles) throws IOException { private void processEntryPoint(TypeDeclaration td, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
String className = context.getFqn(td); String className = context.getFqn(td);
log.info("Processing state machine config: {}", className); log.info("Processing state machine config: {}", className);
@@ -200,6 +280,11 @@ public class ExportService {
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst); Set<String> endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst);
Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst); Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst);
if (allStates.isEmpty() && transitions.isEmpty()) {
log.info("Skipping empty state machine config: {}", className);
return;
}
AnalysisResult result = AnalysisResult.builder() AnalysisResult result = AnalysisResult.builder()
.name(className) .name(className)
.states(allStates) .states(allStates)
@@ -214,10 +299,10 @@ public class ExportService {
resolveProperties(result, activeProfiles); resolveProperties(result, activeProfiles);
generateOutputs(outputDir, result, selectedFormats); generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
} }
private void processBeanMethod(MethodDeclaration m, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows, List<String> activeProfiles) throws IOException { private void processBeanMethod(MethodDeclaration m, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
TypeDeclaration parentClass = (TypeDeclaration) m.getParent(); TypeDeclaration parentClass = (TypeDeclaration) m.getParent();
String parentFqn = context.getFqn(parentClass); String parentFqn = context.getFqn(parentClass);
String methodName = m.getName().getIdentifier(); String methodName = m.getName().getIdentifier();
@@ -231,6 +316,11 @@ public class ExportService {
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, null); Set<String> endStates = TransitionStateUtils.findEndStates(transitions, null);
Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, null, null); Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, null, null);
if (allStates.isEmpty() && transitions.isEmpty()) {
log.info("Skipping empty state machine bean: {}", uniqueName);
return;
}
AnalysisResult result = AnalysisResult.builder() AnalysisResult result = AnalysisResult.builder()
.name(uniqueName) .name(uniqueName)
.states(allStates) .states(allStates)
@@ -245,10 +335,10 @@ public class ExportService {
resolveProperties(result, activeProfiles); resolveProperties(result, activeProfiles);
generateOutputs(outputDir, result, selectedFormats); generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
} }
private void generateOutputs(Path outputDir, AnalysisResult result, List<String> selectedFormats) throws IOException { private void generateOutputs(Path outputDir, AnalysisResult result, List<String> selectedFormats, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
if (selectedFormats == null || selectedFormats.isEmpty()) { if (selectedFormats == null || selectedFormats.isEmpty()) {
return; return;
} }
@@ -259,6 +349,8 @@ public class ExportService {
ExportOptions options = ExportOptions.builder() ExportOptions options = ExportOptions.builder()
.renderChoicesAsDiamonds(result.isRenderChoicesAsDiamonds()) .renderChoicesAsDiamonds(result.isRenderChoicesAsDiamonds())
.eventFormat(eventFormat)
.stateFormat(stateFormat)
.build(); .build();
for (StateMachineExporter exporter : exporters) { for (StateMachineExporter exporter : exporters) {

View File

@@ -0,0 +1,43 @@
package click.kamil.springstatemachineexporter.spi;
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
/**
* Service Provider Interface (SPI) for introducing support for different languages (Java, Kotlin, etc.).
*/
public interface LanguageAnalyzerPlugin {
/**
* Determines if this plugin is capable of handling the provided project root.
* Often checks for file extensions (.kt, .java) within the root.
*/
boolean supports(Path projectRoot);
/**
* Parses the codebase and sets up a SourceContext loaded with the language's AST structures.
*/
SourceContext parseProject(Path projectRoot, List<String> classpath, Set<Path> sourcePaths);
/**
* Creates a CallGraphEngine specialized for this language's syntax and paradigms.
*/
CallGraphEngine createCallGraphEngine(SourceContext context);
/**
* Creates a CodebaseIntelligenceProvider specialized for this language to discover
* Spring State Machine configurations, entry points, and transitions.
*/
CodebaseIntelligenceProvider createIntelligenceProvider(SourceContext context);
/**
* Finds and extracts all Spring State Machine models (configurations and beans) from the project.
*/
default List<click.kamil.springstatemachineexporter.analysis.model.AnalysisResult> extractStateMachines(SourceContext context, boolean renderChoicesAsDiamonds) {
return java.util.Collections.emptyList();
}
}

View File

@@ -0,0 +1,43 @@
package click.kamil.springstatemachineexporter.spi;
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
/**
* An abstraction over a parsed source codebase (Java, Kotlin, etc.).
* Provides access to common metadata required by analysis engines.
*/
public interface SourceContext {
/**
* Gets the root directory of the scanned project.
*/
Path getProjectRoot();
/**
* Relativizes an absolute path against the project root.
*/
String getRelativePath(Path fullPath);
/**
* Returns a relative file path for a given fully qualified class/type name.
*/
String getRelativePath(String fqn);
/**
* Retrieves any loaded library hints (e.g. from hints.json).
*/
List<LibraryHint> getLibraryHints();
/**
* Retrieves application properties (e.g. from application.yml).
*/
Map<String, Map<String, String>> getProperties();
/**
* Gets an unstructured cache map that plugins can use to share data across processing phases.
*/
Map<String, Object> getCache();
}

View File

@@ -23,8 +23,7 @@ import java.util.List;
public class GoldenUpdater { public class GoldenUpdater {
private static final List<StateMachineExporter> exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter()); private static final List<StateMachineExporter> exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
private static final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList()); private static final ExportService exportService = new ExportService(exporters);
private static final ExportService exportService = new ExportService(exporters, enrichmentService);
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
List<TestScenario> scenarios = provideTestScenarios(); List<TestScenario> scenarios = provideTestScenarios();
@@ -34,7 +33,7 @@ public class GoldenUpdater {
System.out.println("Updating golden files for: " + scenario.name()); System.out.println("Updating golden files for: " + scenario.name());
Path tempDir = Files.createTempDirectory("golden-update-"); Path tempDir = Files.createTempDirectory("golden-update-");
try { try {
exportService.runExporter(scenario.inputPath(), tempDir, null, true); exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml", "dot", "scxml", "json"), true);
List<Path> generatedDirs; List<Path> generatedDirs;
try (var stream = Files.list(tempDir)) { try (var stream = Files.list(tempDir)) {
@@ -159,6 +158,24 @@ public class GoldenUpdater {
Path.of("state_machines/inheritance_extra_functions3_state_machine"), Path.of("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/G2StateMachineConfiguration"), Path.of("src/test/resources/golden/G2StateMachineConfiguration"),
"G2StateMachineConfiguration" "G2StateMachineConfiguration"
),
new TestScenario(
"Enterprise Order State Machine",
Path.of("state_machines/state_machine_enterprise"),
Path.of("src/test/resources/golden/OrderStateMachineConfiguration"),
"OrderStateMachineConfiguration"
),
new TestScenario(
"Enterprise Document State Machine",
Path.of("state_machines/state_machine_enterprise"),
Path.of("src/test/resources/golden/DocumentStateMachineConfiguration"),
"DocumentStateMachineConfiguration"
),
new TestScenario(
"Enterprise User State Machine",
Path.of("state_machines/state_machine_enterprise"),
Path.of("src/test/resources/golden/UserStateMachineConfiguration"),
"UserStateMachineConfiguration"
) )
); );
} }

View File

@@ -137,6 +137,18 @@ public class RegressionTest {
root.resolve("state_machines/maven_multi_module/core-module"), root.resolve("state_machines/maven_multi_module/core-module"),
Path.of("src/test/resources/golden/MavenOrderStateMachine"), Path.of("src/test/resources/golden/MavenOrderStateMachine"),
"MavenOrderStateMachine" "MavenOrderStateMachine"
),
new TestScenario(
"Complex Multi-Module Sample",
root.resolve("state_machines/complex_multi_module_sm"),
Path.of("src/test/resources/golden/StateMachineConfig"),
"StateMachineConfig"
),
new TestScenario(
"Polymorphic Events Sample",
root.resolve("state_machines/polymorphic_events_sample"),
Path.of("src/test/resources/golden/PolymorphicStateMachineConfiguration"),
"PolymorphicStateMachineConfiguration"
) )
); );
} }
@@ -149,7 +161,7 @@ public class RegressionTest {
if (scenario.inputPath() == null) System.out.println("inputPath is NULL"); if (scenario.inputPath() == null) System.out.println("inputPath is NULL");
if (tempDir == null) System.out.println("tempDir is NULL"); if (tempDir == null) System.out.println("tempDir is NULL");
exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml", "dot", "scxml", "json"), true, scenario.activeProfiles()); exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml", "dot", "scxml", "json"), true, scenario.activeProfiles(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
// Find the generated directory (it might be named with FQN) // Find the generated directory (it might be named with FQN)
List<Path> generatedDirs; List<Path> generatedDirs;

View File

@@ -9,6 +9,7 @@ import org.junit.jupiter.api.io.TempDir;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -88,4 +89,42 @@ public class ResolutionRobustnessTest {
assertThat(context.getTypeDeclaration("p1.X")).isNotNull(); assertThat(context.getTypeDeclaration("p1.X")).isNotNull();
assertThat(context.getTypeDeclaration("p2.X")).isNotNull(); assertThat(context.getTypeDeclaration("p2.X")).isNotNull();
} }
@Test
void testJdtBindingsResolution(@TempDir Path tempDir) throws IOException {
Path comFoo = tempDir.resolve("com/foo");
Files.createDirectories(comFoo);
Files.writeString(comFoo.resolve("Base.java"),
"package com.foo;\n" +
"public class Base {\n" +
" public void run() {}\n" +
"}"
);
Files.writeString(comFoo.resolve("Child.java"),
"package com.foo;\n" +
"public class Child extends Base {\n" +
" public void execute() {\n" +
" run();\n" +
" }\n" +
"}"
);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
context.scan(tempDir);
TypeDeclaration childTd = context.getTypeDeclaration("com.foo.Child");
assertThat(childTd).isNotNull();
// Resolve the binding for Child class
org.eclipse.jdt.core.dom.ITypeBinding binding = childTd.resolveBinding();
assertThat(binding).isNotNull();
assertThat(binding.getQualifiedName()).isEqualTo("com.foo.Child");
// Verify we can get the superclass binding
org.eclipse.jdt.core.dom.ITypeBinding superBinding = binding.getSuperclass();
assertThat(superBinding).isNotNull();
assertThat(superBinding.getQualifiedName()).isEqualTo("com.foo.Base");
}
} }

View File

@@ -0,0 +1,17 @@
package click.kamil.springstatemachineexporter;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import com.fasterxml.jackson.databind.ObjectMapper;
public class TestTriggerPoint {
public static void main(String[] args) throws Exception {
TriggerPoint tp = TriggerPoint.builder()
.event("PLACE_ORDER")
.eventTypeFqn("String")
.build();
System.out.println("Result event: " + tp.getEvent());
ObjectMapper mapper = new ObjectMapper();
System.out.println("JSON: " + mapper.writeValueAsString(tp));
}
}

View File

@@ -4,7 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.service.CallGraphBuilder; import click.kamil.springstatemachineexporter.analysis.service.HeuristicCallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.GenericEventDetector; import click.kamil.springstatemachineexporter.analysis.service.GenericEventDetector;
import click.kamil.springstatemachineexporter.analysis.service.SpringMvcDetector; import click.kamil.springstatemachineexporter.analysis.service.SpringMvcDetector;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
@@ -76,7 +76,7 @@ public class InheritedEventDetectionTest {
assertThat(tp.getEvent()).isEqualTo("event"); assertThat(tp.getEvent()).isEqualTo("event");
// Build Call Chains // Build Call Chains
CallGraphBuilder callGraphBuilder = new CallGraphBuilder(context); HeuristicCallGraphEngine callGraphBuilder = new HeuristicCallGraphEngine(context);
List<CallChain> chains = callGraphBuilder.findChains(entryPoints, triggers); List<CallChain> chains = callGraphBuilder.findChains(entryPoints, triggers);
assertThat(chains).describedAs("Should link ChildController.trigger to BaseController.send").hasSize(1); assertThat(chains).describedAs("Should link ChildController.trigger to BaseController.send").hasSize(1);

View File

@@ -0,0 +1,515 @@
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 TransitionLinkerEnricherTest {
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
@Test
void shouldLinkTransitionByEventOnly() {
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"));
Transition t2 = new Transition();
t2.setSourceStates(List.of(State.of("FAILED", "FAILED")));
t2.setTargetStates(List.of(State.of("PAID", "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.getMatchedTransitions()).hasSize(2);
assertThat(updatedChain.getMatchedTransitions())
.extracting("sourceState")
.containsExactlyInAnyOrder("NEW", "FAILED");
}
@Test
void shouldLinkTransitionByEventAndSourceState() {
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"));
Transition t2 = new Transition();
t2.setSourceStates(List.of(State.of("FAILED", "FAILED")));
t2.setTargetStates(List.of(State.of("PAID", "PAID")));
t2.setEvent(Event.of("PAY", "PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PAY").sourceState("NEW").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.getMatchedTransitions()).hasSize(1);
assertThat(updatedChain.getMatchedTransitions().get(0).getSourceState()).isEqualTo("NEW");
assertThat(updatedChain.getMatchedTransitions().get(0).getTargetState()).isEqualTo("PAID");
}
@Test
void shouldLinkTransitionByFullIdentifier() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("\"NEW\"", "NEW")));
t1.setTargetStates(List.of(State.of("\"PAID\"", "PAID")));
// This simulates how Spring Config usually has Event as OrderEvents.PAY but its resolved string is "PAY_ORDER"
t1.setEvent(Event.of("OrderEvents.PAY", "PAY_ORDER"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PAY_ORDER").sourceState("NEW").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.getMatchedTransitions().get(0).getEvent()).isEqualTo("PAY_ORDER");
}
@Test
void shouldHandleUnknownEvents() {
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event(null).build()) // Dynamic or unresolved event
.build();
AnalysisResult result = AnalysisResult.builder()
.transitions(List.of(new Transition()))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).isNull();
}
@Test
void shouldMatchWildcardVariable() {
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("event").build())
.contextMachineId("testMachine")
.build();
AnalysisResult result = AnalysisResult.builder()
.name("testMachine")
.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);
}
@Test
void shouldMatchBaseEventDespiteScrapedPolyEvents() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("PAID", "PAID")));
// State machine uses the base event generically
t1.setEvent(Event.of("BaseEvent", "BaseEvent"));
// Trigger point uses "BaseEvent", but polyEvents scraped some implementation/constants
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("BaseEvent")
.polymorphicEvents(List.of("EVENT_A", "EVENT_B"))
.build())
.contextMachineId("testMachine")
.build();
AnalysisResult result = AnalysisResult.builder()
.name("testMachine")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
// It should match because smEvent matches triggerEvent directly, overriding the failed poly search
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
}
@Test
void shouldNotMatchArbitraryGetXMethodWildcard() {
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("richEvent.getId()").build())
.contextMachineId("testMachine")
.build();
AnalysisResult result = AnalysisResult.builder()
.name("testMachine")
.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);
}
@Test
void shouldAllowWildcardWhenContextMachineIdIsMissing() {
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("event").build())
// NO contextMachineId or stateMachineId
.build();
AnalysisResult result = AnalysisResult.builder()
.name("testMachine")
.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);
}
@Test
void shouldNotFalsePositiveOnSubstringContains() {
// This test proves that the heuristic is entirely removed and false positives are eliminated
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"));
// Trigger point sends "PAYMENT" which contains "PAY". Under the old heuristics, this would falsely match!
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PAYMENT").build())
.build();
AnalysisResult result = AnalysisResult.builder()
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
// It should NOT match, because "PAYMENT" != "PAY" strictly.
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
}
@Test
void shouldExecuteDeterministicallyAndUtilizeInternalCaches() {
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 chain1 = CallChain.builder().triggerPoint(TriggerPoint.builder().event("PAY").build()).build();
CallChain chain2 = CallChain.builder().triggerPoint(TriggerPoint.builder().event("PAY").build()).build();
CallChain chain3 = CallChain.builder().triggerPoint(TriggerPoint.builder().event("PAY").build()).build();
AnalysisResult result = AnalysisResult.builder()
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain1, chain2, chain3)).build())
.build();
enricher.enrich(result, null, null);
// All 3 identical trigger point chains should be linked seamlessly using the memoization cache.
for (CallChain chain : result.getMetadata().getCallChains()) {
assertThat(chain.getMatchedTransitions()).hasSize(1);
assertThat(chain.getMatchedTransitions().get(0).getEvent()).isEqualTo("PAY");
}
}
@Test
void shouldFilterCrossStateMachineRoutingByVertical() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("ASSEMBLED", "ASSEMBLED")));
t1.setEvent(Event.of("ASSEMBLE", "ASSEMBLE"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of(
"com.example.electronics.ElectronicsOrderController.post()",
"com.example.electronics.ElectronicsOrderService.processOrderEvent()"
))
.build();
// Testing against Electronics SM - should match
AnalysisResult resultElectronics = AnalysisResult.builder()
.name("com.example.electronics.ElectronicsOwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(resultElectronics, null, null);
CallChain updatedChainElec = resultElectronics.getMetadata().getCallChains().get(0);
assertThat(updatedChainElec.getMatchedTransitions()).hasSize(1);
// Testing against ComputerStore SM - should NOT match because chain has Electronics service
AnalysisResult resultComputer = AnalysisResult.builder()
.name("com.example.computerstore.OwnDeliveryStateMachineConfiguration") // the non-electronics one
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(resultComputer, null, null);
CallChain updatedChainComp = resultComputer.getMetadata().getCallChains().get(0);
assertThat(updatedChainComp.getMatchedTransitions()).isNullOrEmpty();
}
@Test
void shouldAllowSharedServicesToRouteToMultipleStateMachines() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("PROCESSED", "PROCESSED")));
t1.setEvent(Event.of("PROCESS", "PROCESS"));
// Chain contains NO vertical prefix (e.g. CommonOrderService)
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PROCESS").build())
.methodChain(List.of(
"com.example.CommonOrderController.post()",
"com.example.CommonOrderService.processOrderEvent()"
))
.build();
AnalysisResult resultElectronics = AnalysisResult.builder()
.name("com.example.electronics.ElectronicsOwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(resultElectronics, null, null);
CallChain updatedChainElec = resultElectronics.getMetadata().getCallChains().get(0);
// Should match Electronics because it's a shared service (no computerstore service in path)
assertThat(updatedChainElec.getMatchedTransitions()).hasSize(1);
AnalysisResult resultComputer = AnalysisResult.builder()
.name("com.example.computerstore.OwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(resultComputer, null, null);
CallChain updatedChainComp = resultComputer.getMetadata().getCallChains().get(0);
// Should ALSO match ComputerStore because it's a shared service
assertThat(updatedChainComp.getMatchedTransitions()).hasSize(1);
}
@Test
void shouldFilterCrossStateMachineRoutingWithMultipleVerticals() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("ASSEMBLED", "ASSEMBLED")));
t1.setEvent(Event.of("ASSEMBLE", "ASSEMBLE"));
// Chain 1: Electronics
CallChain chainElec = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of("com.example.electronics.ElectronicsOrderController.post()"))
.build();
// Chain 2: Furniture
CallChain chainFurn = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of("com.example.furniture.FurnitureOrderController.post()"))
.build();
// Chain 3: Groceries
CallChain chainGroc = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of("com.example.groceries.GroceriesOrderController.post()"))
.build();
// SM 1: Electronics
AnalysisResult resElec = AnalysisResult.builder()
.name("com.example.electronics.ElectronicsOwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build())
.build();
// SM 2: Furniture
AnalysisResult resFurn = AnalysisResult.builder()
.name("com.example.furniture.FurnitureOwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build())
.build();
// SM 3: Groceries
AnalysisResult resGroc = AnalysisResult.builder()
.name("com.example.groceries.GroceriesStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build())
.build();
// SM 4: Computer Store (The catch-all base one in computerstore package)
AnalysisResult resComp = AnalysisResult.builder()
.name("com.example.computerstore.OwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build())
.build();
// 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
// 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
// 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
// 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
}
@Test
void shouldHandleMultipleNegations() {
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"));
// Case 1: Double Negation on ORDER -> !(!(ORDER)) -> Should act as POSITIVE (meaning order is required).
// Since we are running OrderStateMachineConfiguration, it should match!
CallChain chain1 = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("PAY")
.constraint("!(!(ORDER.equals(type)))")
.build())
.build();
AnalysisResult resultOrder = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain1)).build())
.build();
enricher.enrich(resultOrder, null, null);
assertThat(resultOrder.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
// Case 2: Triple Negation on ORDER -> !(!(!(ORDER))) -> Should act as NEGATIVE (meaning order is rejected/forbidden).
// Under OrderStateMachineConfiguration, this chain should be rejected!
CallChain chain2 = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("PAY")
.constraint("!(!(!(ORDER.equals(type))))")
.build())
.build();
AnalysisResult resultOrder2 = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain2)).build())
.build();
enricher.enrich(resultOrder2, null, null);
assertThat(resultOrder2.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
}
@Test
void shouldHandleComplexBooleanLogic() {
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"));
// Case 1: OR expression -> "ORDER".equals(type) || "DOCUMENT".equals(type)
// Under OrderStateMachineConfiguration, it should match!
CallChain chain1 = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("PAY")
.constraint("\"ORDER\".equalsIgnoreCase(type) || \"DOCUMENT\".equalsIgnoreCase(type)")
.build())
.build();
AnalysisResult resultOrder = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain1)).build())
.build();
enricher.enrich(resultOrder, null, null);
assertThat(resultOrder.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
// Case 2: Under UserStateMachineConfiguration, it should be rejected!
AnalysisResult resultUser = AnalysisResult.builder()
.name("com.example.UserStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain1)).build())
.build();
enricher.enrich(resultUser, null, null);
assertThat(resultUser.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
}
}

View File

@@ -0,0 +1,150 @@
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 java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class HeuristicEventMatchingEngineTest {
private final HeuristicEventMatchingEngine engine = new HeuristicEventMatchingEngine();
@Test
void shouldMatchExactFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("com.example.OrderEvents.PAY").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchEnumQualifierToFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("OrderEvents.PAY").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldPreventCrossEnumContamination() {
Event smEvent = Event.of("PAY", "com.example.InvoiceEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("OrderEvents.PAY").build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldPreventCrossEnumContaminationWithPolymorphicEvents() {
Event smEvent = Event.of("PAY", "com.example.InvoiceEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("getType()").polymorphicEvents(List.of("OrderEvents.PAY")).build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchPolymorphicEnumQualifierToFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("getType()").polymorphicEvents(List.of("OrderEvents.PAY")).build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchRawStringToFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("PAY").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchRawStringToRawString() {
Event smEvent = Event.of("PAY", "PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("PAY").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchPolymorphicRawStringToFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("getType()").polymorphicEvents(List.of("PAY")).build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchBidirectionalFqnAndShortName() {
// Case 1: smEvent is short, trigger event is FQN
Event smEvent1 = Event.of("PAY", "OrderEvents.PAY");
TriggerPoint triggerPoint1 = TriggerPoint.builder().event("com.example.OrderEvents.PAY").build();
assertThat(engine.matches(smEvent1, triggerPoint1)).isTrue();
// Case 2: smEvent is short, polymorphic event is FQN
Event smEvent2 = Event.of("PAY", "OrderEvents.PAY");
TriggerPoint triggerPoint2 = TriggerPoint.builder().event("getType()").polymorphicEvents(List.of("com.example.OrderEvents.PAY")).build();
assertThat(engine.matches(smEvent2, triggerPoint2)).isTrue();
}
@Test
void shouldMatchCaseInsensitive() {
Event smEvent = Event.of("PAY", "PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("pay").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchGuardedContains() {
Event smEvent = Event.of("PAY", "PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("pay_order").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
Event smEvent2 = Event.of("PAY_ORDER", "PAY_ORDER");
TriggerPoint triggerPoint2 = TriggerPoint.builder().event("pay").build();
assertThat(engine.matches(smEvent2, triggerPoint2)).isTrue();
}
@Test
void shouldMatchDottedEventPackageMismatch() {
Event smEvent = Event.of("CREATE", "com.example.other.OrderEvent.CREATE");
TriggerPoint triggerPoint = TriggerPoint.builder().event("com.example.some.OrderEvent.CREATE").build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchPolymorphicDottedEventPackageMismatch() {
Event smEvent = Event.of("CREATE", "com.example.other.OrderEvent.CREATE");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("getType()")
.polymorphicEvents(List.of("com.example.some.OrderEvent.CREATE"))
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchWildcardIfTypeMatches() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("event")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldNotMatchWildcardIfTypeMismatches() {
Event smEvent = Event.of("PAY", "com.example.InvoiceEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("event")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
}

View File

@@ -0,0 +1,209 @@
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 java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class StrictFqnMatchingEngineTest {
private final StrictFqnMatchingEngine engine = new StrictFqnMatchingEngine();
@Test
void shouldMatchExactFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("PAY")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchEnumQualifierToFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvents.PAY")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldPreventCrossEnumContamination() {
Event smEvent = Event.of("PAY", "com.example.InvoiceEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvents.PAY")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldPreventCrossEnumContaminationWithPolymorphicEvents() {
Event smEvent = Event.of("PAY", "com.example.InvoiceEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("getType()")
.eventTypeFqn("com.example.OrderEvents")
.polymorphicEvents(List.of("OrderEvents.PAY"))
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchPolymorphicEnumQualifierToFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("getType()")
.eventTypeFqn("com.example.OrderEvents")
.polymorphicEvents(List.of("OrderEvents.PAY"))
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchRawStringToFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("PAY")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchRawStringToRawString() {
Event smEvent = Event.of("PAY", "PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("PAY").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchPolymorphicRawStringToFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("getType()")
.eventTypeFqn("com.example.OrderEvents")
.polymorphicEvents(List.of("PAY"))
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchWildcards() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("event").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldNotMatchMismatchedFqn() {
Event smEvent = Event.of("CREATE", "com.example.other.OrderEvent.CREATE");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("CREATE")
.eventTypeFqn("com.example.some.OrderEvent")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldNotMatchStringTypeToEnumFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("PAY")
.eventTypeFqn("java.lang.String")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldNotMatchEnumTypeToStringFqn() {
Event smEvent = Event.of("PAY", "PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvents.PAY")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchValueOfWithCorrectEnum() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvents.valueOf(str)")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldNotMatchValueOfWithIncorrectEnum() {
Event smEvent = Event.of("PAY", "com.example.InvoiceEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvents.valueOf(str)")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchDynamicVariableWithMatchingTypeFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("myCustomEvt") // Not "event", "e", etc., but a custom variable name
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldNotMatchDynamicVariableWithMismatchedTypeFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("myCustomEvt")
.eventTypeFqn("com.example.InvoiceEvents") // Mismatched type
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchDynamicVariableWithUnknownTypeFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("myCustomEvt")
.eventTypeFqn(null) // Unknown type, fallback to true
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchDynamicMethodCallAsVariable() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("event.getPayload()") // Dynamic method call
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
}

View File

@@ -0,0 +1,130 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
@Tag("heuristic_bean_resolution")
public class HeuristicBeanResolutionEngineTest {
private final HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
@Test
public void testDeepPackageDivergenceMismatch() {
// This is the bug case: sharing a deep root, but diverging into different domains
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.corp.division.project.orders.OrderService.pay()"))
.build();
String machineName = "com.acme.corp.division.project.payments.PaymentStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName, null);
// They share com.acme.corp.division.project, but diverge into orders vs payments
// This should be a strong mismatch, so it should return false
assertFalse(result, "Deep package divergence into different domains should be rejected");
}
@Test
public void testSameDomainMatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.ecommerce.orders.OrderService.process()"))
.build();
String machineName = "com.acme.ecommerce.orders.OrderStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName, null);
// Same exact domain, should match
assertTrue(result, "Same domain should be accepted");
}
@Test
public void testSubPackageMatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.ecommerce.orders.impl.OrderServiceImpl.process()"))
.build();
String machineName = "com.acme.ecommerce.orders.OrderStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName, null);
// Subpackage should match
assertTrue(result, "Sub-packages of the same domain should be accepted");
}
@Test
public void testSharedDomainTermMatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.orders.web.OrderController.submit()"))
.build();
String machineName = "com.acme.orders.service.OrderStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName, null);
// Different subpackages, but they share the "order" domain term and common prefix
assertTrue(result, "Divergent packages that share a domain term should be accepted");
}
@Test
public void testExplicitTargetVariableMatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.common.GlobalTrigger.fire()"))
.contextMachineId("paymentStateMachine") // Explicitly targets payment
.build();
String machineName = "com.acme.corp.payments.PaymentStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName, null);
// Mismatched domains, but explicit variable targeting works
assertTrue(result, "Explicit variable target matching the machine name should be accepted");
}
@Test
public void testExplicitTargetVariableMismatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.common.GlobalTrigger.fire()"))
.contextMachineId("paymentStateMachine") // Explicitly targets payment
.build();
String machineName = "com.acme.corp.orders.OrderStateMachine"; // But this is Order
boolean result = engine.isRoutedToCorrectMachine(chain, machineName, null);
// Explicit variable targeting completely conflicts
assertFalse(result, "Explicit variable target mismatching the machine name should be rejected");
}
@Test
public void testGenericStateMachineConfigInGenericPackage() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.corp.orders.OrderController.submit()"))
.build();
String machineName = "com.acme.corp.config.StateMachineConfig";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName, null);
// StateMachineConfig has generic prefix and is in config package. OrderController is in orders.
// There should be no domain mismatch because config/statemachine are generic.
assertTrue(result, "Generic state machine config in generic config package should not trigger mismatch");
}
@Test
public void testNoMismatchOnStateMachineConfigSuffix() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.shipping.service.OrderServiceImpl.place()"))
.build();
String machineName = "com.acme.shipping.config.MyStateMachineConfig";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName, null);
assertTrue(result, "MyStateMachineConfig should not trigger mismatch because it contains StateMachineConfig");
}
}

View File

@@ -152,4 +152,376 @@ public class ConstantResolverTest {
String result = resolver.resolve(returnExpr, context); String result = resolver.resolve(returnExpr, context);
assertThat(result).isEqualTo("${app.path}"); assertThat(result).isEqualTo("${app.path}");
} }
@Test
void testConstructorAssignmentAndFieldAccess(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("EventDto.java"),
"package com.example;\n" +
"public class EventDto {\n" +
" private String eventType;\n" +
" public EventDto() {\n" +
" this.eventType = \"PAYMENT_SUCCESS\";\n" +
" }\n" +
" public String getEventType() {\n" +
" return this.eventType;\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.EventDto");
MethodDeclaration method = td.getMethods()[1]; // getEventType
ReturnStatement rs = (ReturnStatement) method.getBody().statements().get(0);
Expression returnExpr = rs.getExpression(); // this.eventType
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(returnExpr, context);
assertThat(result).isEqualTo("PAYMENT_SUCCESS");
}
@Test
void testMethodEvaluationWithNestedBlocksAndVars(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Logic.java"),
"package com.example;\n" +
"public class Logic {\n" +
" public String process(String event) {\n" +
" String myVar = event;\n" +
" if (myVar != null) {\n" +
" return myVar;\n" +
" }\n" +
" return \"DEFAULT\";\n" +
" }\n" +
"}");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" Logic logic = new Logic();\n" +
" String result = logic.process(\"MY_EVENT\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0]; // call()
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // logic.process("MY_EVENT")
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
assertThat(result).isEqualTo("MY_EVENT");
}
@Test
void testMethodShadowingReturnsParameter(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Logic.java"),
"package com.example;\n" +
"public class Logic {\n" +
" public String a = \"OLD_FIELD\";\n" +
" public String process(String a) {\n" + // shadowing
" this.a = \"NEW_FIELD\";\n" +
" return a;\n" + // should return the parameter
" }\n" +
"}");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" Logic logic = new Logic();\n" +
" String result = logic.process(\"PARAM_VALUE\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0]; // call()
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // logic.process(...)
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
assertThat(result).isEqualTo("PARAM_VALUE");
}
@Test
void testMethodShadowingReturnsField(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Logic.java"),
"package com.example;\n" +
"public class Logic {\n" +
" public String a = \"OLD_FIELD\";\n" +
" public String process(String a) {\n" + // shadowing
" this.a = \"NEW_FIELD_VALUE\";\n" +
" return this.a;\n" + // should return the field
" }\n" +
"}");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" Logic logic = new Logic();\n" +
" String result = logic.process(\"PARAM_VALUE\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0]; // call()
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // logic.process(...)
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
assertThat(result).isEqualTo("NEW_FIELD_VALUE");
}
@Test
void testMethodImplicitFieldAssignment(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Logic.java"),
"package com.example;\n" +
"public class Logic {\n" +
" public String a = \"OLD_FIELD\";\n" +
" public String process(String p) {\n" +
" a = p;\n" + // implicit field assignment
" return this.a;\n" +
" }\n" +
"}");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" Logic logic = new Logic();\n" +
" String result = logic.process(\"MY_EVENT\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0]; // call()
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // logic.process(...)
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
assertThat(result).isEqualTo("MY_EVENT");
}
@Test
void testZeroArgMethodEvaluation(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Logic.java"),
"package com.example;\n" +
"public class Logic {\n" +
" public String a = \"MY_CONSTANT\";\n" +
" public String getEvent() {\n" +
" return this.a;\n" +
" }\n" +
"}");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" Logic logic = new Logic();\n" +
" String result = logic.getEvent();\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0]; // call()
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // logic.getEvent()
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
assertThat(result).isEqualTo("MY_CONSTANT");
}
/**
* When a switch method is called with a <em>known</em> constant argument, {@code evaluateMethodOutput}
* should evaluate the switch and return only the matching branch.
*
* <p>Regression guard: before the {@code SwitchStatement.visit} fix, the ASTVisitor would also descend
* into child {@code ReturnStatement} nodes after the switch was already handled, potentially overwriting
* a correct result with a wrong one.
*/
@Test
void testOldStyleSwitchResolvesCorrectBranchForKnownConstant(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Classifier.java"),
"package com.example;\n" +
"public class Classifier {\n" +
" public OrderEvents classify(String q) {\n" +
" switch (q) {\n" +
" case \"a\": return OrderEvents.A8;\n" +
" case \"b\": return OrderEvents.B2;\n" +
" default: return OrderEvents.DEF;\n" +
" }\n" +
" }\n" +
"}\n" +
"enum OrderEvents { A8, B2, DEF }");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" Classifier c = new Classifier();\n" +
" OrderEvents r = c.classify(\"a\");\n" + // known constant → should match case "a"
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0];
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // c.classify("a")
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
// Only the matching branch should be returned, not all branches
assertThat(result).isEqualTo("OrderEvents.A8");
}
/**
* When a switch method is called with a <em>runtime</em> variable that cannot be statically resolved,
* {@code evaluateMethodOutput} must return {@code null} so the resolver falls back to the return-type
* enum set (covering all branches).
*
* <p>Regression: before the fix, {@code SwitchStatement.visit} returned {@code super.visit(ss)} (true),
* causing child {@code ReturnStatement} nodes inside the switch to fire. The first return — e.g.
* {@code return OrderEvents.A8} — was captured as if it were the only possible result, silently
* dropping the other branches. After the fix the visitor returns {@code false}, preventing child
* traversal, so the resolver correctly falls back to the full ENUM_SET.
*/
@Test
void testOldStyleSwitchWithUnknownVariableReturnsFallbackEnumSet(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Classifier.java"),
"package com.example;\n" +
"public class Classifier {\n" +
" public OrderEvents classify(String q) {\n" +
" switch (q) {\n" +
" case \"a\": return OrderEvents.A8;\n" +
" case \"b\": return OrderEvents.B2;\n" +
" default: return OrderEvents.DEF;\n" +
" }\n" +
" }\n" +
"}\n" +
"enum OrderEvents { A8, B2, DEF }");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call(String x) {\n" + // x is a runtime parameter — not a constant
" Classifier c = new Classifier();\n" +
" OrderEvents r = c.classify(x);\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0];
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // c.classify(x)
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
// Must cover ALL branches via ENUM_SET — not just the first one ("A8")
assertThat(result).startsWith("ENUM_SET:");
assertThat(result).contains("OrderEvents.A8");
assertThat(result).contains("OrderEvents.B2");
assertThat(result).contains("OrderEvents.DEF");
}
@Test
void shouldResolveEnumValueOfWithConstantString(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("OrderEvent.java"),
"package com.example;\n" +
"public enum OrderEvent { RECEIVED, SHIPPED }\n");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" OrderEvent e1 = OrderEvent.valueOf(\"RECEIVED\");\n" +
" OrderEvent e2 = OrderEvent.valueOf(getEventString());\n" +
" }\n" +
" private String getEventString() {\n" +
" return \"SHIPPED\";\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0];
VariableDeclarationStatement vds1 = (VariableDeclarationStatement) callMethod.getBody().statements().get(0);
VariableDeclarationFragment fragment1 = (VariableDeclarationFragment) vds1.fragments().get(0);
MethodInvocation mi1 = (MethodInvocation) fragment1.getInitializer();
VariableDeclarationStatement vds2 = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment2 = (VariableDeclarationFragment) vds2.fragments().get(0);
MethodInvocation mi2 = (MethodInvocation) fragment2.getInitializer();
ConstantResolver resolver = new ConstantResolver();
String result1 = resolver.resolve(mi1, context);
assertThat(result1).isEqualTo("com.example.OrderEvent.RECEIVED");
String result2 = resolver.resolve(mi2, context);
assertThat(result2).isEqualTo("com.example.OrderEvent.SHIPPED");
}
} }

View File

@@ -0,0 +1,60 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
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.Map;
import static org.assertj.core.api.Assertions.assertThat;
class PropertyResolverTest {
@Test
void testYamlPropertiesAreLoadedAndFlattened(@TempDir Path tempDir) throws IOException {
Path applicationYml = tempDir.resolve("application.yml");
Files.writeString(applicationYml, """
messaging:
sqs:
integration-system1:
callback:
queue: "integration-system1-queue"
""");
PropertyResolver resolver = new PropertyResolver();
Map<String, String> props = resolver.resolveProperties(tempDir);
assertThat(props).containsEntry("messaging.sqs.integration-system1.callback.queue", "integration-system1-queue");
}
@Test
void testPropertiesFilesAreLoaded(@TempDir Path tempDir) throws IOException {
Path applicationProps = tempDir.resolve("application.properties");
Files.writeString(applicationProps, "messaging.sqs.integration-system1.callback.queue=integration-system1-queue-props");
PropertyResolver resolver = new PropertyResolver();
Map<String, String> props = resolver.resolveProperties(tempDir);
assertThat(props).containsEntry("messaging.sqs.integration-system1.callback.queue", "integration-system1-queue-props");
}
@Test
void testPlaceholderResolution() {
PropertyResolver resolver = new PropertyResolver();
Map<String, String> properties = Map.of("messaging.sqs.integration-system1.callback.queue", "integration-system1-queue");
String resolved = resolver.resolveValue("SQS: ${messaging.sqs.integration-system1.callback.queue}", properties);
assertThat(resolved).isEqualTo("SQS: integration-system1-queue");
}
@Test
void testPlaceholderResolutionWithDefault() {
PropertyResolver resolver = new PropertyResolver();
Map<String, String> properties = Map.of();
String resolved = resolver.resolveValue("SQS: ${messaging.sqs.queue:default-queue}", properties);
assertThat(resolved).isEqualTo("SQS: default-queue");
}
}

View File

@@ -0,0 +1,40 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
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.Set;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class RelativePathTest {
@Test
public void testRelativePathDirectory(@TempDir Path tempDir) throws Exception {
Path rootDir = tempDir.resolve("root");
Path childDir = tempDir.resolve("child");
Files.createDirectories(rootDir);
Files.createDirectories(childDir);
Files.writeString(rootDir.resolve("pom.xml"), "<project><groupId>com.acme</groupId><artifactId>root</artifactId></project>");
Files.writeString(childDir.resolve("pom.xml"), "<project><parent><relativePath>../root</relativePath></parent></project>");
SiblingDependencyResolver resolver = new SiblingDependencyResolver();
Set<Path> siblings = resolver.resolveSiblings(childDir);
assertTrue(siblings.contains(rootDir), "Should resolve parent directory from <relativePath>");
}
@Test
public void testRelativePathFile(@TempDir Path tempDir) throws Exception {
Path rootDir = tempDir.resolve("root");
Path childDir = tempDir.resolve("child");
Files.createDirectories(rootDir);
Files.createDirectories(childDir);
Files.writeString(rootDir.resolve("pom.xml"), "<project><groupId>com.acme</groupId><artifactId>root</artifactId></project>");
Files.writeString(childDir.resolve("pom.xml"), "<project><parent><relativePath>../root/pom.xml</relativePath></parent></project>");
SiblingDependencyResolver resolver = new SiblingDependencyResolver();
Set<Path> siblings = resolver.resolveSiblings(childDir);
assertTrue(siblings.contains(rootDir), "Should resolve parent directory from <relativePath> file");
}
}

View File

@@ -0,0 +1,102 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
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.Set;
import static org.assertj.core.api.Assertions.assertThat;
class SiblingDependencyResolverTest {
@Test
void shouldResolveGradleSiblingsTransitively(@TempDir Path tempDir) throws IOException {
// Create root project
Path rootDir = tempDir.resolve("my-root-project");
Files.createDirectories(rootDir);
Files.writeString(rootDir.resolve("settings.gradle"), "include 'module-a', 'module-b', 'module-c'");
// Create module A (depends on B)
Path moduleA = rootDir.resolve("module-a");
Files.createDirectories(moduleA);
Files.writeString(moduleA.resolve("build.gradle"), "dependencies { implementation project(':module-b') }");
// Create module B (depends on C)
Path moduleB = rootDir.resolve("module-b");
Files.createDirectories(moduleB);
Files.writeString(moduleB.resolve("build.gradle"), "dependencies { implementation project(':module-c') }");
// Create module C (no deps)
Path moduleC = rootDir.resolve("module-c");
Files.createDirectories(moduleC);
Files.writeString(moduleC.resolve("build.gradle"), "");
SiblingDependencyResolver resolver = new SiblingDependencyResolver();
// When we resolve from A, it should transitively find B and C
Set<Path> siblings = resolver.resolveSiblings(moduleA);
assertThat(siblings).containsExactlyInAnyOrder(moduleB.normalize(), moduleC.normalize());
}
@Test
void shouldResolveMavenSiblingsTransitively(@TempDir Path tempDir) throws IOException {
// Create root project
Path rootDir = tempDir.resolve("my-maven-root");
Files.createDirectories(rootDir);
Files.writeString(rootDir.resolve("pom.xml"), "<project><groupId>com.acme</groupId><artifactId>root</artifactId></project>");
// Create a dummy .git so findMavenRoot knows where to stop
Files.createDirectories(rootDir.resolve(".git"));
// Create module A (depends on B)
Path moduleA = rootDir.resolve("module-a");
Files.createDirectories(moduleA);
Files.writeString(moduleA.resolve("pom.xml"), "<project><groupId>com.acme</groupId><artifactId>module-a</artifactId><dependencies><dependency><groupId>com.acme</groupId><artifactId>module-b</artifactId><version>1.0</version></dependency></dependencies></project>");
// Create module B (depends on C)
Path moduleB = rootDir.resolve("module-b");
Files.createDirectories(moduleB);
Files.writeString(moduleB.resolve("pom.xml"), "<project><groupId>com.acme</groupId><artifactId>module-b</artifactId><dependencies><dependency><groupId>com.acme</groupId><artifactId>module-c</artifactId><version>1.0</version></dependency></dependencies></project>");
// Create module C (no deps)
Path moduleC = rootDir.resolve("module-c");
Files.createDirectories(moduleC);
Files.writeString(moduleC.resolve("pom.xml"), "<project><groupId>com.acme</groupId><artifactId>module-c</artifactId></project>");
SiblingDependencyResolver resolver = new SiblingDependencyResolver();
// When we resolve from A, it should transitively find B and C and the parent rootDir
Set<Path> siblings = resolver.resolveSiblings(moduleA);
assertThat(siblings).containsExactlyInAnyOrder(moduleB.normalize(), moduleC.normalize(), rootDir.normalize());
}
@Test
void shouldResolveMavenModulesFromAggregator(@TempDir Path tempDir) throws IOException {
// Create root project
Path rootDir = tempDir.resolve("my-aggregator-root");
Files.createDirectories(rootDir);
Files.writeString(rootDir.resolve("pom.xml"), "<project><groupId>com.acme</groupId><artifactId>root</artifactId><modules><module>service-x</module><module>service-y</module></modules></project>");
Files.createDirectories(rootDir.resolve(".git"));
// Create service X
Path serviceX = rootDir.resolve("service-x");
Files.createDirectories(serviceX);
Files.writeString(serviceX.resolve("pom.xml"), "<project><artifactId>service-x</artifactId></project>");
// Create service Y
Path serviceY = rootDir.resolve("service-y");
Files.createDirectories(serviceY);
Files.writeString(serviceY.resolve("pom.xml"), "<project><artifactId>service-y</artifactId></project>");
SiblingDependencyResolver resolver = new SiblingDependencyResolver();
// Resolving from rootDir should pick up the <module> tags and resolve service-x and service-y
Set<Path> siblings = resolver.resolveSiblings(rootDir);
assertThat(siblings).containsExactlyInAnyOrder(serviceX.normalize(), serviceY.normalize());
}
}

View File

@@ -0,0 +1,78 @@
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;
public class AnonymousClassBugTest {
@Test
void shouldResolveEventThroughAnonymousClass() throws IOException {
String source = """
package com.example;
public class MyController {
private StateMachine machine;
public void process() {
// Supplying the event via an anonymous class
fireEvent(new EventProvider() {
@Override
public TransitionEnum getEvent() {
return TransitionEnum.STATE_A;
}
});
}
private void fireEvent(EventProvider provider) {
machine.fire(provider.getEvent());
}
}
interface EventProvider {
TransitionEnum getEvent();
}
class StateMachine {
public void fire(TransitionEnum event) {}
}
enum TransitionEnum { STATE_A, STATE_B }
""";
Path tempDir = Files.createTempDirectory("callgraph_test_anonymous");
Files.writeString(tempDir.resolve("Config.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.MyController")
.methodName("process")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("fire")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
System.out.println("Poly events from anonymous class: " + chains.get(0).getTriggerPoint().getPolymorphicEvents());
// It SHOULD resolve to STATE_A by tracking the anonymous class implementation
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactly("TransitionEnum.STATE_A");
}
}

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