Compare commits
123 Commits
8d4ba0697e
...
ai-branch-
| Author | SHA1 | Date | |
|---|---|---|---|
| bb97285906 | |||
| 9a5f122bd2 | |||
| a7bf07e8b2 | |||
| 4f8c35adef | |||
| 31adae0a4b | |||
| 2caaec5419 | |||
| 56588a835d | |||
| 0a8f9720c9 | |||
| 6472f01026 | |||
| e041cb9a80 | |||
| 9bea5c4687 | |||
| 475084aed4 | |||
| 28df9fc99f | |||
| e3dd26c0d4 | |||
| 89736a4aa1 | |||
| 092fbe49fa | |||
| 61f5549589 | |||
| f5067490a3 | |||
| 3a442c8890 | |||
| 7807df57ca | |||
| 82719489aa | |||
| c390f02389 | |||
| 180a283383 | |||
| 1c852005c8 | |||
| 34c53b1097 | |||
| 23af434504 | |||
| cc352432a4 | |||
| 78c0f3e705 | |||
| 27005da678 | |||
| 0cb78e5dfd | |||
| bcdbbffa13 | |||
| 3988864494 | |||
| b07b7855a1 | |||
| baa33a887c | |||
| 0165da5f51 | |||
| 4d7cda56f5 | |||
| 2717a01f13 | |||
| f84043f26e | |||
| 4d9fee716b | |||
| f3d030b19a | |||
| 16dccbf81b | |||
| 20b4ed780b | |||
| f4188f4384 | |||
| 96fc975170 | |||
| 8d3fb0d90c | |||
| fc9d6f1f1c | |||
| 858e4524c8 | |||
| 93688ef59b | |||
| ff2c8f8cfb | |||
| 901b43195a | |||
| ed411f22d8 | |||
| c9d41de13f | |||
| df2a596e26 | |||
| a75b7acfe4 | |||
| 74a4e66a33 | |||
| 4511e0e02f | |||
| a7c3b08164 | |||
| 8d66af6d24 | |||
| 642f6f8926 | |||
| 76bb88e065 | |||
| 5c7cf3639b | |||
| 016f0e9b21 | |||
| 22a0d3f5aa | |||
| 7008f162b5 | |||
| 60c666f7a1 | |||
| 8a56e92a65 | |||
| e8543ace38 | |||
| 101f2b5dc9 | |||
| f525f3d3ce | |||
| 5ae233eb75 | |||
| 0bda5a0aeb | |||
| c35c75efa3 | |||
| bd55a66985 | |||
| 0ac70bd3c6 | |||
| b45513c5fb | |||
| 75fd45d983 | |||
| b35effd05c | |||
| f30538e8c9 | |||
| 131ccbeda0 | |||
| 14b311be2b | |||
| 1b690582d6 | |||
| 3ca834fa71 | |||
| c6b4a4be1e | |||
| 24a9be3a4e | |||
| 07d241a060 | |||
| 32de0a51c6 | |||
| cbb00e8a0f | |||
| 6596303b7d | |||
| c903b079d3 | |||
| 23d79d1930 | |||
| 2720296d14 | |||
| b8b180ab3d | |||
| fc267c43c6 | |||
| 968601eefc | |||
| e00f4dca81 | |||
| bf82cc3562 | |||
| 24b67be64b | |||
| 9f651e7dc9 | |||
| e06429cb02 | |||
| 9cf2d063b3 | |||
| 073df21395 | |||
| f9e6247eb3 | |||
| 76ac143370 | |||
| 9ca8b2fe37 | |||
| 56cdf96f2d | |||
| c37aa92c78 | |||
| 3ac057618f | |||
| d59f4ac166 | |||
| 19547d3c5f | |||
| ab37eb5d40 | |||
| bf9208d529 | |||
| 7077214c81 | |||
| 344e295106 | |||
| d6b1571f18 | |||
| e617fb1754 | |||
| 06dc0ba641 | |||
| ba412b905e | |||
| 198c5d830e | |||
| a383de5a5f | |||
| ef9ebe3512 | |||
| b480dc83ef | |||
| 0a23daae05 | |||
| e894566112 |
1
TODO.md
1
TODO.md
@@ -1,6 +1,5 @@
|
||||
1.extract from all possible formats (ask chatgpt)
|
||||
|
||||
[DONE] json
|
||||
[TODO] xml, maybe yaml
|
||||
|
||||
2. [PLAN] Support external dependencies (JARs/compiled classes):
|
||||
|
||||
BIN
TestHeuristic.class
Normal file
BIN
TestHeuristic.class
Normal file
Binary file not shown.
17
TestHeuristic.java
Normal file
17
TestHeuristic.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,4 +1,5 @@
|
||||
task runGolden(type: JavaExec) {
|
||||
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
workingDir = rootProject.projectDir
|
||||
}
|
||||
|
||||
@@ -19,3 +19,6 @@ include ':state_machines:ultimate_ecosystem_sm'
|
||||
include ':state_machines:enterprise_order_system'
|
||||
include ':state_machines:multi_module_sample:api-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'
|
||||
|
||||
@@ -32,10 +32,14 @@ dependencies {
|
||||
// deps for parsing java AST
|
||||
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
|
||||
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-jsr310:2.17.1'
|
||||
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1'
|
||||
|
||||
compileOnly 'org.projectlombok:lombok:1.18.46'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.46'
|
||||
@@ -57,10 +61,5 @@ tasks.named('test') {
|
||||
task runGolden(type: JavaExec) {
|
||||
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
}
|
||||
|
||||
task runGoldenFixed(type: JavaExec) {
|
||||
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
workingDir = rootProject.projectDir
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ import java.util.List;
|
||||
|
||||
public class Main {
|
||||
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
|
||||
var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
|
||||
var exportService = new ExportService(exporters);
|
||||
|
||||
@@ -14,8 +14,16 @@ 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) {
|
||||
@@ -38,40 +46,48 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
List<MatchedTransition> matched = new ArrayList<>();
|
||||
|
||||
for (Transition t : stateMachineTransitions) {
|
||||
if (t.getEvent() != null) {
|
||||
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
|
||||
String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName();
|
||||
String smEvent = simplify(smEventRaw);
|
||||
if (smEvent.equals(triggerEvent)) {
|
||||
// Event matches. Check source state if provided
|
||||
// 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 sourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
|
||||
String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName();
|
||||
matched.add(MatchedTransition.builder()
|
||||
.sourceState(sourceRaw)
|
||||
MatchedTransition mt = MatchedTransition.builder()
|
||||
.sourceState(smSourceRaw)
|
||||
.targetState(targetRaw)
|
||||
.event(smEventRaw)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new CallChain with the matched transitions
|
||||
CallChain updatedChain = CallChain.builder()
|
||||
.entryPoint(chain.getEntryPoint())
|
||||
.methodChain(chain.getMethodChain())
|
||||
.triggerPoint(chain.getTriggerPoint())
|
||||
.contextMachineId(chain.getContextMachineId())
|
||||
.matchedTransitions(matched)
|
||||
.build();
|
||||
if (isRoutedToCorrectMachine(chain, result.getName(), context)
|
||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||
matched.add(mt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedChains.add(updatedChain);
|
||||
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
|
||||
@@ -85,12 +101,156 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
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 "";
|
||||
int dot = name.lastIndexOf('.');
|
||||
if (dot >= 0) {
|
||||
return name.substring(dot + 1);
|
||||
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;
|
||||
}
|
||||
return 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import lombok.extern.jackson.Jacksonized;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@Builder(toBuilder = true)
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class CallChain {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,7 @@ import java.util.List;
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class LibraryHint {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import lombok.extern.jackson.Jacksonized;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@Builder(toBuilder = true)
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class TriggerPoint {
|
||||
@@ -20,4 +20,111 @@ public class TriggerPoint {
|
||||
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;
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class ConstantResolver {
|
||||
@@ -14,9 +17,30 @@ public class ConstantResolver {
|
||||
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) {
|
||||
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?
|
||||
if (expr instanceof StringLiteral sl) {
|
||||
return sl.getLiteralValue();
|
||||
@@ -48,14 +72,604 @@ public class ConstantResolver {
|
||||
return resolveInfix(infix, context, visited);
|
||||
}
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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);
|
||||
if (td != null) {
|
||||
String fqn = context.getFqn(td);
|
||||
@@ -136,12 +776,73 @@ 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 {
|
||||
visited.remove(fieldId);
|
||||
}
|
||||
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) {
|
||||
for (Object mod : fd.modifiers()) {
|
||||
if (mod instanceof Annotation ann) {
|
||||
@@ -175,10 +876,150 @@ public class ConstantResolver {
|
||||
}
|
||||
|
||||
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();
|
||||
while (parent != null && !(parent instanceof TypeDeclaration)) {
|
||||
while (parent != null) {
|
||||
if (parent instanceof MethodDeclaration md) {
|
||||
return md;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -105,25 +105,32 @@ public class PropertyResolver {
|
||||
}
|
||||
|
||||
private Map<String, String> loadYaml(Path path) {
|
||||
// Placeholder for future YAML support
|
||||
log.warn("YAML parsing not fully implemented yet for {}", path);
|
||||
return new HashMap<>();
|
||||
Map<String, String> props = new HashMap<>();
|
||||
try {
|
||||
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) {
|
||||
if (placeholder == null || !placeholder.contains("${")) return placeholder;
|
||||
|
||||
// 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);
|
||||
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(placeholder, properties);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
public Set<Path> resolveSiblings(Path projectDir) {
|
||||
Set<Path> siblings = new HashSet<>();
|
||||
Set<Path> allSiblings = new HashSet<>();
|
||||
Queue<Path> queue = new LinkedList<>();
|
||||
queue.add(projectDir);
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
Path current = queue.poll();
|
||||
Set<Path> currentSiblings = new HashSet<>();
|
||||
try {
|
||||
siblings.addAll(resolveGradleSiblings(projectDir));
|
||||
siblings.addAll(resolveMavenSiblings(projectDir));
|
||||
currentSiblings.addAll(resolveGradleSiblings(current));
|
||||
currentSiblings.addAll(resolveMavenSiblings(current));
|
||||
} catch (Exception e) {
|
||||
log.warn("Error resolving sibling dependencies for {}", projectDir, e);
|
||||
log.warn("Error resolving sibling dependencies for {}", current, e);
|
||||
}
|
||||
return siblings;
|
||||
|
||||
for (Path sibling : currentSiblings) {
|
||||
if (!allSiblings.contains(sibling) && !sibling.equals(projectDir)) {
|
||||
allSiblings.add(sibling);
|
||||
queue.add(sibling);
|
||||
}
|
||||
}
|
||||
}
|
||||
return allSiblings;
|
||||
}
|
||||
|
||||
private Set<Path> resolveGradleSiblings(Path projectDir) throws IOException {
|
||||
@@ -52,6 +66,23 @@ public class SiblingDependencyResolver {
|
||||
for (String gradlePath : gradlePaths) {
|
||||
String relativePath = gradlePath.replace(':', '/');
|
||||
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)) {
|
||||
log.info("Discovered local Gradle sibling dependency: {}", resolvedSibling);
|
||||
siblings.add(resolvedSibling);
|
||||
@@ -117,7 +148,7 @@ public class SiblingDependencyResolver {
|
||||
if (artifactId != null && targetArtifacts.contains(artifactId)) {
|
||||
Path siblingDir = p.getParent();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,278 +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);
|
||||
String contextMachineId = extractContextMachineId(path, callGraph);
|
||||
chains.add(CallChain.builder()
|
||||
.entryPoint(ep)
|
||||
.triggerPoint(resolvedTp)
|
||||
.methodChain(path)
|
||||
.contextMachineId(contextMachineId)
|
||||
.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 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"))) {
|
||||
// Persister signatures usually like: restore(stateMachine, contextObj)
|
||||
if (edge.getArguments().size() >= 2) {
|
||||
return edge.getArguments().get(1); // The contextObj / machineId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
String calledMethod = null;
|
||||
if (superTd != null) {
|
||||
calledMethod = resolveMethodInType(superTd, methodName);
|
||||
}
|
||||
if (calledMethod == null) {
|
||||
calledMethod = superFqn + "." + methodName;
|
||||
}
|
||||
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) {
|
||||
val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc)
|
||||
}
|
||||
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; // 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
visited.remove(start);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,16 @@ public class GenericEventDetector {
|
||||
if ("sendEvent".equals(methodName) || "sendEvents".equals(methodName) ||
|
||||
"sendEventCollect".equals(methodName) || "sendEventMono".equals(methodName)) {
|
||||
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);
|
||||
@@ -46,13 +56,99 @@ public class GenericEventDetector {
|
||||
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) {
|
||||
TriggerPoint trigger = buildTriggerPoint(node, cu, null);
|
||||
if (trigger != null) {
|
||||
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, null);
|
||||
if (builtTriggers != null) {
|
||||
for (TriggerPoint trigger : builtTriggers) {
|
||||
log.debug("Successfully built trigger point: {}", trigger.getEvent());
|
||||
triggers.add(trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processHints(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
|
||||
if (hints == null || hints.isEmpty()) return;
|
||||
@@ -62,14 +158,27 @@ public class GenericEventDetector {
|
||||
|
||||
for (LibraryHint hint : hints) {
|
||||
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) {
|
||||
TriggerPoint trigger = buildTriggerPoint(node, cu, hint.getEvent());
|
||||
if (trigger != null) {
|
||||
String eventToUse = hint.getEvent();
|
||||
if (eventToUse == null && hint.getEventArgumentIndex() != null) {
|
||||
if (node.arguments().size() > hint.getEventArgumentIndex()) {
|
||||
Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex());
|
||||
eventToUse = argExpr.toString();
|
||||
if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) {
|
||||
eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isHintMatch(String called, String hintFqn) {
|
||||
return hintFqn.endsWith("." + called);
|
||||
@@ -102,12 +211,12 @@ public class GenericEventDetector {
|
||||
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;
|
||||
if (forcedEvent != null) {
|
||||
eventValue = context.resolveString(forcedEvent);
|
||||
} else {
|
||||
if (node.arguments().isEmpty()) return null;
|
||||
if (node.arguments().isEmpty()) return Collections.emptyList();
|
||||
Expression eventExpr = (Expression) node.arguments().get(0);
|
||||
|
||||
// Try MessageBuilder extraction first
|
||||
@@ -123,59 +232,171 @@ public class GenericEventDetector {
|
||||
MethodDeclaration method = findEnclosingMethod(node);
|
||||
TypeDeclaration type = findEnclosingType(node);
|
||||
|
||||
if (type == null) return null;
|
||||
if (type == null) return Collections.emptyList();
|
||||
|
||||
String sourceState = extractSourceState(node);
|
||||
String[] smTypes = resolveStateMachineTypeArguments(node);
|
||||
|
||||
return TriggerPoint.builder()
|
||||
boolean external = false;
|
||||
if (!node.arguments().isEmpty()) {
|
||||
Expression eventExpr = (Expression) node.arguments().get(0);
|
||||
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()))
|
||||
.build();
|
||||
.stateTypeFqn(smTypes[0])
|
||||
.eventTypeFqn(smTypes[1])
|
||||
.external(external)
|
||||
.constraint(constraint)
|
||||
.build());
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private String extractSourceState(ASTNode node) {
|
||||
ASTNode current = node.getParent();
|
||||
ASTNode current = node;
|
||||
while (current != null && !(current instanceof MethodDeclaration)) {
|
||||
if (current instanceof IfStatement ifStmt) {
|
||||
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;
|
||||
} else if (current instanceof SwitchCase switchCase) {
|
||||
if (!switchCase.expressions().isEmpty()) {
|
||||
return getSimpleNameString((Expression) switchCase.expressions().get(0));
|
||||
}
|
||||
} else if (current instanceof SwitchStatement switchStmt) {
|
||||
// If it's a switch block but we haven't hit a SwitchCase directly (AST hierarchy usually has SwitchCase as siblings of block statements)
|
||||
// Actually, walking up from the node, we will hit the SwitchStatement, but we need the SwitchCase right before us in the block.
|
||||
ASTNode parentBlock = current;
|
||||
// It's complex to walk siblings. A simpler heuristic is to just use IfStatement and SwitchCase (if wrapped correctly).
|
||||
}
|
||||
current = current.getParent();
|
||||
|
||||
// 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) {
|
||||
if (infix.getOperator() == InfixExpression.Operator.EQUALS || infix.getOperator() == InfixExpression.Operator.NOT_EQUALS) {
|
||||
Expression left = infix.getLeftOperand();
|
||||
Expression right = infix.getRightOperand();
|
||||
// Usually one is a method call like getState(), the other is an enum
|
||||
if (left instanceof QualifiedName || left instanceof SimpleName && !(right instanceof SimpleName)) {
|
||||
|
||||
// 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 SimpleName && !(left instanceof SimpleName)) {
|
||||
if (right instanceof QualifiedName || right instanceof StringLiteral || right instanceof FieldAccess) {
|
||||
return getSimpleNameString(right);
|
||||
}
|
||||
return getSimpleNameString(right); // Fallback
|
||||
|
||||
// Fallback
|
||||
return getSimpleNameString(right);
|
||||
}
|
||||
} else if (expr instanceof MethodInvocation mi) {
|
||||
if ("equals".equals(mi.getName().getIdentifier()) && !mi.arguments().isEmpty()) {
|
||||
return getSimpleNameString((Expression) mi.arguments().get(0));
|
||||
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;
|
||||
@@ -193,6 +414,10 @@ public class GenericEventDetector {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -218,8 +443,21 @@ public class GenericEventDetector {
|
||||
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;
|
||||
|
||||
@@ -240,10 +478,23 @@ public class GenericEventDetector {
|
||||
if (resolved != null) return resolved;
|
||||
|
||||
// If not a constant, it might be a parameter like 'event'
|
||||
if (payloadExpr instanceof CastExpression ce) {
|
||||
payloadExpr = ce.getExpression();
|
||||
}
|
||||
|
||||
if (payloadExpr instanceof SimpleName sn) {
|
||||
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();
|
||||
} 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();
|
||||
@@ -271,4 +522,287 @@ public class GenericEventDetector {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.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 lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||
@@ -21,9 +25,12 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
|
||||
private final GenericEventDetector eventDetector;
|
||||
private final SpringMvcDetector mvcDetector;
|
||||
private final MessagingDetector messagingDetector;
|
||||
private final CallGraphBuilder callGraphBuilder;
|
||||
private final CallGraphEngine callGraphEngine;
|
||||
private final LifecycleDetector lifecycleDetector;
|
||||
private final InterceptorDetector interceptorDetector;
|
||||
private final SpringComponentDetector componentDetector;
|
||||
private List<TriggerPoint> cachedTriggers;
|
||||
private List<EntryPoint> cachedEntryPoints;
|
||||
|
||||
public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) {
|
||||
this.context = context;
|
||||
@@ -31,13 +38,27 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
|
||||
this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints());
|
||||
this.mvcDetector = new SpringMvcDetector(context, constantResolver);
|
||||
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.interceptorDetector = new InterceptorDetector(context);
|
||||
this.componentDetector = new SpringComponentDetector(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TriggerPoint> findTriggerPoints() {
|
||||
if (cachedTriggers != null) {
|
||||
return cachedTriggers;
|
||||
}
|
||||
List<TriggerPoint> allTriggers = new ArrayList<>();
|
||||
var cus = context.getCompilationUnits();
|
||||
log.info("JdtIntelligenceProvider scanning {} compilation units for triggers", cus.size());
|
||||
@@ -46,11 +67,15 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
|
||||
allTriggers.addAll(lifecycleDetector.detect(cu));
|
||||
}
|
||||
log.info("Found {} triggers (including lifecycle) in total", allTriggers.size());
|
||||
cachedTriggers = allTriggers;
|
||||
return allTriggers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<EntryPoint> findEntryPoints() {
|
||||
if (cachedEntryPoints != null) {
|
||||
return cachedEntryPoints;
|
||||
}
|
||||
List<EntryPoint> allEntryPoints = new ArrayList<>();
|
||||
var cus = context.getCompilationUnits();
|
||||
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(messagingDetector.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());
|
||||
cachedEntryPoints = allEntryPoints;
|
||||
return allEntryPoints;
|
||||
}
|
||||
|
||||
@Override
|
||||
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());
|
||||
return callGraphBuilder.findChains(entryPoints, triggers);
|
||||
return callGraphEngine.findChains(entryPoints, triggers);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -71,6 +71,7 @@ public class MessagingDetector {
|
||||
.name(protocolName + ": " + destination)
|
||||
.className(context.getFqn((TypeDeclaration) method.getParent()))
|
||||
.methodName(method.getName().getIdentifier())
|
||||
.sourceFile(context.getRelativePath(context.getFqn((TypeDeclaration) method.getParent())))
|
||||
.metadata(metadata)
|
||||
.parameters(extractParameters(method))
|
||||
.build());
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -239,15 +239,35 @@ public class AstTransitionParser {
|
||||
} else {
|
||||
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);
|
||||
parseGuard(resolved, t, cu, context);
|
||||
}
|
||||
case "guards" -> {
|
||||
args.forEach(arg -> {
|
||||
Expression resolved = resolveArg((Expression) arg, argsMap);
|
||||
parseGuard(resolved, t, cu, context);
|
||||
});
|
||||
}
|
||||
case "action" -> {
|
||||
Expression resolved = resolveArg((Expression) args.get(0), argsMap);
|
||||
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;
|
||||
@@ -288,6 +308,15 @@ public class AstTransitionParser {
|
||||
// Try same file
|
||||
ASTNode declNode = cu.findDeclaringNode(binding.getKey());
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -348,6 +377,115 @@ public class AstTransitionParser {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -399,7 +537,7 @@ public class AstTransitionParser {
|
||||
TypeDeclaration td = findEnclosingType(quotedExpr.getExpression());
|
||||
String fqn = td != null ? context.getFqn(td) : 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,632 +1,37 @@
|
||||
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 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.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.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class StateMachineAggregator {
|
||||
private final CodebaseContext context;
|
||||
|
||||
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");
|
||||
}
|
||||
private final StateMachineAdapterParser adapterParser;
|
||||
|
||||
@Getter
|
||||
private final Set<String> initialStates = new HashSet<>();
|
||||
@Getter
|
||||
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) {
|
||||
Set<MethodVisit> visitedMethods = new HashSet<>();
|
||||
aggregateStatesRecursive(startClass, startClass, initialStates, endStates, new HashSet<>(), visitedMethods, new HashMap<>());
|
||||
}
|
||||
|
||||
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();
|
||||
StateMachineAdapterParser.AdapterStates states = adapterParser.parseStates(startClass);
|
||||
this.initialStates.clear();
|
||||
this.initialStates.addAll(states.initialStates());
|
||||
this.endStates.clear();
|
||||
this.endStates.addAll(states.endStates());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +36,85 @@ public final class AstUtils {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -22,25 +22,41 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import click.kamil.springstatemachineexporter.spi.SourceContext;
|
||||
|
||||
@Slf4j
|
||||
public class CodebaseContext {
|
||||
public class CodebaseContext implements SourceContext {
|
||||
private final Map<String, CompilationUnit> classes = new HashMap<>();
|
||||
private final Map<String, Path> classPaths = new HashMap<>();
|
||||
private final Map<String, String> simpleNameToFqn = new HashMap<>();
|
||||
private final Set<String> ambiguousSimpleNames = new HashSet<>();
|
||||
private final ConstantResolver constantResolver = new ConstantResolver();
|
||||
private final StateResolver stateResolver = new StateResolver(this);
|
||||
private final PropertyResolver propertyResolver = new PropertyResolver();
|
||||
|
||||
public ConstantResolver getConstantResolver() {
|
||||
return constantResolver;
|
||||
}
|
||||
private final List<String> activeProfiles = new ArrayList<>();
|
||||
private Map<String, Map<String, String>> allProperties = new HashMap<>();
|
||||
private List<LibraryHint> libraryHints = new ArrayList<>();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private Path projectRoot;
|
||||
private final Map<String, Object> cache = new HashMap<>();
|
||||
|
||||
public Map<String, Object> getCache() {
|
||||
return cache;
|
||||
}
|
||||
|
||||
public void setProjectRoot(Path root) {
|
||||
this.projectRoot = root;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getProjectRoot() {
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
public String getRelativePath(Path fullPath) {
|
||||
if (projectRoot != null && fullPath.isAbsolute() && fullPath.startsWith(projectRoot)) {
|
||||
return projectRoot.relativize(fullPath).toString();
|
||||
@@ -65,7 +81,7 @@ public class CodebaseContext {
|
||||
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[] sourcepath = new String[0];
|
||||
private boolean resolveBindings = false;
|
||||
@@ -78,6 +94,10 @@ public class CodebaseContext {
|
||||
this.classpath = classpath.toArray(new String[0]);
|
||||
}
|
||||
|
||||
public List<String> getClasspath() {
|
||||
return java.util.Arrays.asList(classpath);
|
||||
}
|
||||
|
||||
public void setSourcepath(List<String> sourcepath) {
|
||||
this.sourcepath = sourcepath.toArray(new String[0]);
|
||||
}
|
||||
@@ -135,7 +155,7 @@ public class CodebaseContext {
|
||||
if (imp.isOnDemand()) {
|
||||
// Star import: import static com.example.C.*;
|
||||
TypeDeclaration td = getTypeDeclaration(impName, contextCu);
|
||||
if (td != null && hasField(td, memberName)) {
|
||||
if (td != null && (hasField(td, memberName) || hasMethod(td, memberName))) {
|
||||
return td;
|
||||
}
|
||||
} else {
|
||||
@@ -150,6 +170,15 @@ public class CodebaseContext {
|
||||
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) {
|
||||
for (FieldDeclaration fd : td.getFields()) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
@@ -170,6 +199,7 @@ public class CodebaseContext {
|
||||
private final List<CompilationUnit> allCus = new ArrayList<>();
|
||||
|
||||
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 {
|
||||
this.allProperties = propertyResolver.resolveAllProperties(rootDirs);
|
||||
@@ -188,6 +218,10 @@ public class CodebaseContext {
|
||||
for (Object type : cu.types()) {
|
||||
if (type instanceof TypeDeclaration td) {
|
||||
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
|
||||
for (Object itf : td.superInterfaceTypes()) {
|
||||
String itfName = itf.toString();
|
||||
// Track implementations (for both interfaces and superclasses)
|
||||
if (td.superInterfaceTypes() != null) {
|
||||
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
|
||||
for (Object type : td.getTypes()) {
|
||||
if (type instanceof TypeDeclaration nestedTd) {
|
||||
for (Object decl : td.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
List<String> impls = interfaceToImpls.get(interfaceName);
|
||||
if (impls != null) return impls;
|
||||
List<String> directImpls = interfaceToImpls.get(cleanName);
|
||||
|
||||
// Try FQN match if input was simple name
|
||||
String fqn = simpleNameToFqn.get(interfaceName);
|
||||
if (fqn != null) return interfaceToImpls.getOrDefault(fqn, Collections.emptyList());
|
||||
if (directImpls == null) {
|
||||
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) {
|
||||
if (expr == null)
|
||||
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;
|
||||
return stateResolver.resolveState(expr, cu);
|
||||
}
|
||||
|
||||
private CompilationUnit parse(String source, String unitName) {
|
||||
@@ -347,6 +406,7 @@ public class CodebaseContext {
|
||||
parser.setSource(source.toCharArray());
|
||||
parser.setKind(ASTParser.K_COMPILATION_UNIT);
|
||||
parser.setResolveBindings(resolveBindings);
|
||||
parser.setBindingsRecovery(true);
|
||||
|
||||
Map<String, String> options = JavaCore.getOptions();
|
||||
JavaCore.setComplianceOptions(JavaCore.VERSION_21, options);
|
||||
@@ -392,6 +452,14 @@ public class CodebaseContext {
|
||||
|
||||
// 2. Check imports in contextCu
|
||||
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()) {
|
||||
ImportDeclaration imp = (ImportDeclaration) impObj;
|
||||
String impName = imp.getName().getFullyQualifiedName();
|
||||
@@ -428,11 +496,20 @@ public class CodebaseContext {
|
||||
}
|
||||
|
||||
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();
|
||||
if (superType == null) return null;
|
||||
|
||||
String superName = extractTypeName(superType);
|
||||
CompilationUnit cu = (CompilationUnit) td.getRoot();
|
||||
CompilationUnit cu = (td.getRoot() instanceof CompilationUnit) ? (CompilationUnit) td.getRoot() : null;
|
||||
TypeDeclaration superTd = getTypeDeclaration(superName, cu);
|
||||
return superTd != null ? getFqn(superTd) : superName;
|
||||
}
|
||||
@@ -449,6 +526,56 @@ public class CodebaseContext {
|
||||
}
|
||||
|
||||
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()) {
|
||||
if (method.getName().getIdentifier().equals(methodName)) {
|
||||
return method;
|
||||
@@ -460,7 +587,7 @@ public class CodebaseContext {
|
||||
if (superFqn != null) {
|
||||
TypeDeclaration superTd = getTypeDeclaration(superFqn);
|
||||
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()) {
|
||||
Type interfaceType = (Type) interfaceTypeObj;
|
||||
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) {
|
||||
MethodDeclaration found = findMethodDeclaration(interfaceTd, methodName, true);
|
||||
MethodDeclaration found = findMethodDeclarationLegacy(interfaceTd, methodName, true);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
@@ -478,15 +605,107 @@ public class CodebaseContext {
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getFqn(TypeDeclaration td) {
|
||||
StringBuilder sb = new StringBuilder(td.getName().getIdentifier());
|
||||
ASTNode current = td.getParent();
|
||||
while (current instanceof TypeDeclaration parent) {
|
||||
public AbstractTypeDeclaration getAbstractTypeDeclaration(String name) {
|
||||
// Try exact FQN match first
|
||||
CompilationUnit cu = classes.get(name);
|
||||
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() + ".");
|
||||
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 fqn = sb.toString();
|
||||
return packageName.isEmpty() ? fqn : packageName + "." + fqn;
|
||||
@@ -560,10 +779,19 @@ public class CodebaseContext {
|
||||
}
|
||||
|
||||
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);
|
||||
if (visited.contains(fqn)) {
|
||||
return false;
|
||||
@@ -580,7 +808,7 @@ public class CodebaseContext {
|
||||
if (knownAdapters.contains(superclassName))
|
||||
return true;
|
||||
TypeDeclaration superTd = getTypeDeclaration(superclassName, cu);
|
||||
if (superTd != null && extendsStateMachineConfigurerAdapter(superTd, visited))
|
||||
if (superTd != null && extendsStateMachineConfigurerAdapterLegacy(superTd, visited))
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -591,7 +819,7 @@ public class CodebaseContext {
|
||||
if (knownAdapters.contains(interfaceName))
|
||||
return true;
|
||||
TypeDeclaration interfaceTd = getTypeDeclaration(interfaceName, cu);
|
||||
if (interfaceTd != null && extendsStateMachineConfigurerAdapter(interfaceTd, visited))
|
||||
if (interfaceTd != null && extendsStateMachineConfigurerAdapterLegacy(interfaceTd, visited))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -32,6 +32,9 @@ public class FileUtils {
|
||||
return paths
|
||||
.filter(p -> Files.isRegularFile(p) && p.toString().endsWith(extension))
|
||||
.filter(p -> matchers.stream().noneMatch(m -> m.matches(p)))
|
||||
.map(Path::toAbsolutePath)
|
||||
.map(Path::normalize)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -54,11 +54,22 @@ public class ExporterCommand implements Callable<Integer> {
|
||||
@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
|
||||
public Integer call() throws Exception {
|
||||
var out = spec.commandLine().getOut();
|
||||
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) {
|
||||
inputDir = Path.of(".");
|
||||
}
|
||||
@@ -79,7 +90,7 @@ public class ExporterCommand implements Callable<Integer> {
|
||||
if (jsonFile != null) {
|
||||
exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat);
|
||||
} else {
|
||||
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, eventFormat, stateFormat);
|
||||
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()));
|
||||
return 0;
|
||||
|
||||
@@ -107,13 +107,19 @@ public class Dot implements StateMachineExporter {
|
||||
}
|
||||
}
|
||||
|
||||
// Guard
|
||||
if (t.getGuard() != null && !t.getGuard().expression().isBlank()) {
|
||||
// Guards
|
||||
if (t.getGuards() != null && !t.getGuards().isEmpty()) {
|
||||
if (!label.isEmpty())
|
||||
label.append(" ");
|
||||
String guardText = options.isUseLambdaGuards() && t.getGuard().isLambda() ? "λ"
|
||||
: escapeDotString(t.getGuard().expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim());
|
||||
label.append("[").append(guardText).append("]");
|
||||
label.append("[");
|
||||
for (int i = 0; i < t.getGuards().size(); i++) {
|
||||
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
|
||||
|
||||
@@ -116,23 +116,23 @@ public class PlantUml implements StateMachineExporter {
|
||||
|
||||
sb.append(" <<").append(styleClass).append(">>");
|
||||
|
||||
if (t.getEvent() != null) {
|
||||
String eventStr = options.formatEvent(t.getEvent());
|
||||
if (eventStr != null && !eventStr.isBlank()) {
|
||||
sb.append(" <<e_").append(normalize(eventStr)).append(">>");
|
||||
}
|
||||
}
|
||||
|
||||
String label = buildLabel(t, options);
|
||||
if (options.isEmbedIdentifiers()) {
|
||||
String eventStr = t.getEvent() != null ? options.formatEvent(t.getEvent()) : null;
|
||||
String linkId = eventStr != null && !eventStr.isBlank() ? "link_" + normalize(source) + "__" + normalize(eventStr) : "link_anon_" + normalize(source) + "_" + normalize(target);
|
||||
// Force a label even if empty to ensure the link group exists in SVG
|
||||
String displayLabel = label.isEmpty() ? " " : label;
|
||||
// Brackets [...] in the label break PlantUML link syntax [[url label]],
|
||||
// so we strip them for the interactive identification label.
|
||||
String safeLabel = displayLabel.replaceAll("[\\[\\]]", "");
|
||||
sb.append(" : ").append("[[#").append(linkId).append(" ").append(safeLabel).append("]]");
|
||||
|
||||
sb.append(" : [[#").append(linkId).append(" ");
|
||||
if (!label.isEmpty()) {
|
||||
String sanitized = label.replace("[", "[")
|
||||
.replace("]", "]")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">");
|
||||
sb.append(sanitized);
|
||||
} else {
|
||||
sb.append(".");
|
||||
}
|
||||
sb.append("]]");
|
||||
} else if (!label.isEmpty()) {
|
||||
sb.append(" : ").append(label);
|
||||
}
|
||||
@@ -203,14 +203,21 @@ public class PlantUml implements StateMachineExporter {
|
||||
parts.add(eventStr);
|
||||
}
|
||||
}
|
||||
if (t.getGuard() != null) {
|
||||
String g = t.getGuard().expression();
|
||||
if (options.isUseLambdaGuards() && t.getGuard().isLambda()) {
|
||||
parts.add("[" + LAMBDA + "]");
|
||||
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 {
|
||||
parts.add("[" + g.replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim() + "]");
|
||||
guardsBuilder.append(guard.expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim());
|
||||
}
|
||||
}
|
||||
guardsBuilder.append("]");
|
||||
parts.add(guardsBuilder.toString());
|
||||
}
|
||||
if (t.getActions() != null && !t.getActions().isEmpty()) {
|
||||
parts.add("/ " + t.getActions().stream()
|
||||
.map(a -> (options.isUseLambdaGuards() && a.isLambda()) ? LAMBDA : a.expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim())
|
||||
|
||||
@@ -102,12 +102,20 @@ public class Scxml implements StateMachineExporter {
|
||||
}
|
||||
|
||||
private static String getGuardText(Transition t, boolean useLambdaGuards) {
|
||||
if (t.getGuard() == null || t.getGuard().expression().isBlank())
|
||||
if (t.getGuards() == null || t.getGuards().isEmpty())
|
||||
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) {
|
||||
|
||||
@@ -16,7 +16,7 @@ public class Transition {
|
||||
private List<State> targetStates = new ArrayList<>();
|
||||
private Event event;
|
||||
|
||||
private Guard guard;
|
||||
private List<Guard> guards = new ArrayList<>();
|
||||
private List<Action> actions = new ArrayList<>();
|
||||
|
||||
private Integer order = null; // optional order for withChoice or withJunction
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
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.PrintWriter;
|
||||
@@ -45,6 +50,7 @@ public class ExportService {
|
||||
new EntryPointEnricher(),
|
||||
new PropertyEnricher(),
|
||||
new CallChainEnricher(),
|
||||
new click.kamil.springstatemachineexporter.analysis.enricher.path.ForwardPathEstimationEnricher(),
|
||||
new click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher()
|
||||
)));
|
||||
}
|
||||
@@ -53,22 +59,22 @@ public class ExportService {
|
||||
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 {
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList(), click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
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 {
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
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 {
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
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);
|
||||
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) throws IOException {
|
||||
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();
|
||||
context.setActiveProfiles(activeProfiles);
|
||||
|
||||
@@ -94,6 +100,18 @@ public class ExportService {
|
||||
.collect(Collectors.toList());
|
||||
log.info("Setting JDT sourcepath: {}", 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);
|
||||
|
||||
Path hintsFile = inputDir.resolve("hints.json");
|
||||
@@ -124,8 +142,57 @@ public class ExportService {
|
||||
|
||||
context.scan(allPaths, Collections.emptySet());
|
||||
|
||||
CodebaseIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, inputDir);
|
||||
exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds, flows, machineFilter, activeProfiles != null ? activeProfiles : Collections.emptyList(), eventFormat, stateFormat);
|
||||
// Load SPI plugins dynamically
|
||||
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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -23,8 +23,7 @@ import java.util.List;
|
||||
public class GoldenUpdater {
|
||||
|
||||
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, enrichmentService);
|
||||
private static final ExportService exportService = new ExportService(exporters);
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
List<TestScenario> scenarios = provideTestScenarios();
|
||||
@@ -159,6 +158,24 @@ public class GoldenUpdater {
|
||||
Path.of("state_machines/inheritance_extra_functions3_state_machine"),
|
||||
Path.of("src/test/resources/golden/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"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,6 +137,18 @@ public class RegressionTest {
|
||||
root.resolve("state_machines/maven_multi_module/core-module"),
|
||||
Path.of("src/test/resources/golden/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 (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)
|
||||
List<Path> generatedDirs;
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -88,4 +89,42 @@ public class ResolutionRobustnessTest {
|
||||
assertThat(context.getTypeDeclaration("p1.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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CallGraphBuilder;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.HeuristicCallGraphEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.GenericEventDetector;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.SpringMvcDetector;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
@@ -76,7 +76,7 @@ public class InheritedEventDetectionTest {
|
||||
assertThat(tp.getEvent()).isEqualTo("event");
|
||||
|
||||
// Build Call Chains
|
||||
CallGraphBuilder callGraphBuilder = new CallGraphBuilder(context);
|
||||
HeuristicCallGraphEngine callGraphBuilder = new HeuristicCallGraphEngine(context);
|
||||
List<CallChain> chains = callGraphBuilder.findChains(entryPoints, triggers);
|
||||
|
||||
assertThat(chains).describedAs("Should link ChildController.trigger to BaseController.send").hasSize(1);
|
||||
|
||||
@@ -118,4 +118,398 @@ class TransitionLinkerEnricherTest {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -152,4 +152,376 @@ public class ConstantResolverTest {
|
||||
String result = resolver.resolve(returnExpr, context);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
public class AnonymousClassTests {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void shouldResolveEventFromAnonymousClass() throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class AnonymousService {
|
||||
private EventService service;
|
||||
|
||||
public void process() {
|
||||
MyEvent event = new MyEvent() {
|
||||
@Override
|
||||
public String getEvent() {
|
||||
return "ANON_EVENT";
|
||||
}
|
||||
};
|
||||
service.trigger(event.getEvent());
|
||||
}
|
||||
}
|
||||
class EventService {
|
||||
public void trigger(String ev) {}
|
||||
}
|
||||
class MyEvent {
|
||||
public String getEvent() { return "BASE_EVENT"; }
|
||||
}
|
||||
""";
|
||||
|
||||
Files.writeString(tempDir.resolve("AnonymousService.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.EventService")
|
||||
.methodName("trigger")
|
||||
.sourceFile("AnonymousService.java")
|
||||
.sourceModule("com.example")
|
||||
.event("event.getEvent()")
|
||||
.lineNumber(12)
|
||||
.build();
|
||||
|
||||
TriggerPoint result = engine.resolveTriggerPointParameters(trigger, List.of("com.example.AnonymousService.process", "com.example.EventService.trigger"), java.util.Collections.emptyMap());
|
||||
|
||||
assertThat(result.getPolymorphicEvents()).contains("ANON_EVENT");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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 BuilderBugTest {
|
||||
|
||||
@Test
|
||||
void shouldResolveEventFromBuilder() throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
|
||||
public class Controller {
|
||||
private StateMachine machine;
|
||||
|
||||
public void process() {
|
||||
EventWrapper wrapper = EventWrapper.builder()
|
||||
.event(TransitionEnum.STATE_X)
|
||||
.build();
|
||||
machine.fire(wrapper.getEvent());
|
||||
}
|
||||
}
|
||||
|
||||
class StateMachine {
|
||||
public void fire(TransitionEnum event) {}
|
||||
}
|
||||
|
||||
class EventWrapper {
|
||||
private TransitionEnum event;
|
||||
public TransitionEnum getEvent() { return event; }
|
||||
public static Builder builder() { return new Builder(); }
|
||||
|
||||
public static class Builder {
|
||||
private TransitionEnum event;
|
||||
public Builder event(TransitionEnum e) { this.event = e; return this; }
|
||||
public EventWrapper build() {
|
||||
EventWrapper w = new EventWrapper();
|
||||
w.event = this.event;
|
||||
return w;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TransitionEnum { STATE_X, STATE_Y }
|
||||
""";
|
||||
|
||||
Path tempDir = Files.createTempDirectory("callgraph_test_builder");
|
||||
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.Controller")
|
||||
.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: " + chains.get(0).getTriggerPoint().getPolymorphicEvents());
|
||||
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).contains("TransitionEnum.STATE_X");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class BuilderLocalVariableTest {
|
||||
|
||||
@Test
|
||||
void shouldTraceLocalVariableInBuilderPattern(@TempDir Path tempDir) throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
private OrderService service;
|
||||
public void processOrderEvent() {
|
||||
OrderEvents e = OrderEvents.PAY;
|
||||
RichOrderEvent event = new RichOrderEvent();
|
||||
event.setType(e);
|
||||
service.updateOrderState(event.getType());
|
||||
}
|
||||
}
|
||||
|
||||
class OrderService {
|
||||
public void updateOrderState(OrderEvents event) {}
|
||||
}
|
||||
|
||||
class RichOrderEvent {
|
||||
private OrderEvents type;
|
||||
public void setType(OrderEvents type) { this.type = type; }
|
||||
public OrderEvents getType() { return type; }
|
||||
}
|
||||
|
||||
enum OrderEvents { PAY }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.OrderController")
|
||||
.methodName("processOrderEvent")
|
||||
.build();
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.OrderService")
|
||||
.methodName("updateOrderState")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
|
||||
|
||||
assertThat(chains).hasSize(1);
|
||||
CallChain chain = chains.get(0);
|
||||
System.out.println("POLY EVENTS FOUND: " + chain.getTriggerPoint().getPolymorphicEvents());
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder("OrderEvents.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTraceFluentBuilder(@TempDir Path tempDir) throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class MyController {
|
||||
private MyService service;
|
||||
public void processEvent() {
|
||||
MyEvent event = MyEvent.builder().type(MyEnum.STATE_2).build();
|
||||
service.process(event.getType());
|
||||
}
|
||||
}
|
||||
|
||||
class MyService {
|
||||
public void process(MyEnum event) {}
|
||||
}
|
||||
|
||||
class MyEvent {
|
||||
private MyEnum type;
|
||||
public static Builder builder() { return new Builder(); }
|
||||
public MyEnum getType() { return type; }
|
||||
public static class Builder {
|
||||
private MyEnum type;
|
||||
public Builder type(MyEnum type) { this.type = type; return this; }
|
||||
public MyEvent build() { return new MyEvent(); }
|
||||
}
|
||||
}
|
||||
|
||||
enum MyEnum { STATE_1, STATE_2 }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("MyConfig.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.MyController")
|
||||
.methodName("processEvent")
|
||||
.build();
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.MyService")
|
||||
.methodName("process")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
|
||||
|
||||
assertThat(chains).hasSize(1);
|
||||
CallChain chain = chains.get(0);
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder("MyEnum.STATE_2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CallGraphPathFinderTest {
|
||||
|
||||
private CallGraphPathFinder pathFinder;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
pathFinder = new CallGraphPathFinder(new CodebaseContext());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSkipFrameworkAndJdkPaths() {
|
||||
Map<String, List<CallEdge>> graph = new HashMap<>();
|
||||
|
||||
// Setup an anonymized call graph where business logic delegates to a platform boundary
|
||||
// e.g. BusinessService.process -> java.util.stream.Stream.map -> AnotherBusinessService.doWork
|
||||
// The pathfinder should skip the java.* path, avoiding spurious hops.
|
||||
|
||||
graph.put("com.acme.BusinessService.process", List.of(
|
||||
new CallEdge("java.util.stream.Stream.map", List.of()),
|
||||
new CallEdge("com.acme.DirectService.execute", List.of())
|
||||
));
|
||||
graph.put("java.util.stream.Stream.map", List.of(
|
||||
new CallEdge("com.acme.AnotherBusinessService.doWork", List.of())
|
||||
));
|
||||
graph.put("com.acme.DirectService.execute", List.of(
|
||||
new CallEdge("com.acme.AnotherBusinessService.doWork", List.of())
|
||||
));
|
||||
|
||||
List<List<String>> paths = pathFinder.findAllPaths(
|
||||
"com.acme.BusinessService.process",
|
||||
"com.acme.AnotherBusinessService.doWork",
|
||||
graph,
|
||||
new HashSet<>()
|
||||
);
|
||||
|
||||
// It should ONLY find the direct business path, skipping the java.util path.
|
||||
assertThat(paths).hasSize(1);
|
||||
assertThat(paths.get(0)).containsExactly(
|
||||
"com.acme.BusinessService.process",
|
||||
"com.acme.DirectService.execute",
|
||||
"com.acme.AnotherBusinessService.doWork"
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFollowValidBusinessPaths() {
|
||||
Map<String, List<CallEdge>> graph = new HashMap<>();
|
||||
|
||||
graph.put("com.acme.Start.run", List.of(
|
||||
new CallEdge("com.acme.Middle.process", List.of())
|
||||
));
|
||||
graph.put("com.acme.Middle.process", List.of(
|
||||
new CallEdge("com.acme.End.finish", List.of())
|
||||
));
|
||||
|
||||
List<List<String>> paths = pathFinder.findAllPaths(
|
||||
"com.acme.Start.run",
|
||||
"com.acme.End.finish",
|
||||
graph,
|
||||
new HashSet<>()
|
||||
);
|
||||
|
||||
assertThat(paths).hasSize(1);
|
||||
assertThat(paths.get(0)).containsExactly(
|
||||
"com.acme.Start.run",
|
||||
"com.acme.Middle.process",
|
||||
"com.acme.End.finish"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
public class CollectionIndexingTests {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void shouldResolveEventFromArrayAccess() throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ArrayService {
|
||||
private EventService service;
|
||||
|
||||
public void process() {
|
||||
String[] events = new String[] {"EVENT_1", "EVENT_2"};
|
||||
service.trigger(events[0]);
|
||||
}
|
||||
}
|
||||
class EventService {
|
||||
public void trigger(String ev) {}
|
||||
}
|
||||
""";
|
||||
|
||||
Files.writeString(tempDir.resolve("ArrayService.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.EventService")
|
||||
.methodName("trigger")
|
||||
.sourceFile("ArrayService.java")
|
||||
.sourceModule("com.example")
|
||||
.event("events[0]")
|
||||
.lineNumber(7)
|
||||
.build();
|
||||
|
||||
TriggerPoint result = engine.resolveTriggerPointParameters(trigger, List.of("com.example.ArrayService.process", "com.example.EventService.trigger"), java.util.Collections.emptyMap());
|
||||
|
||||
// When using array indexing, we should ideally extract the indexed element or at least all elements in the array
|
||||
assertThat(result.getPolymorphicEvents()).contains("EVENT_1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ConstructorInvocationTest {
|
||||
|
||||
@Test
|
||||
void shouldNotBlindlyAddAllConstructorArguments(@TempDir Path tempDir) throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
private OrderService service;
|
||||
public void processOrderEvent(RichOrderEvent domainEvent) {
|
||||
service.updateOrderState(domainEvent.getType());
|
||||
}
|
||||
}
|
||||
|
||||
class OrderService {
|
||||
public void updateOrderState(OrderEvents event) {}
|
||||
}
|
||||
|
||||
class RichOrderEvent {
|
||||
private OrderEvents type;
|
||||
private String irrelevantInfo;
|
||||
|
||||
public RichOrderEvent() {
|
||||
this(OrderEvents.PAY, "DEFAULT_INFO");
|
||||
}
|
||||
|
||||
public RichOrderEvent(OrderEvents type, String info) {
|
||||
this.type = type;
|
||||
this.irrelevantInfo = info;
|
||||
}
|
||||
|
||||
public OrderEvents getType() { return type; }
|
||||
}
|
||||
|
||||
enum OrderEvents { PAY }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.OrderController")
|
||||
.methodName("processOrderEvent")
|
||||
.build();
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.OrderService")
|
||||
.methodName("updateOrderState")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
|
||||
|
||||
assertThat(chains).hasSize(1);
|
||||
CallChain chain = chains.get(0);
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.contains("OrderEvents.PAY")
|
||||
.doesNotContain("DEFAULT_INFO");
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void shouldHandleOverloadedConstructorsGracefully(@TempDir Path tempDir) throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
private OrderService service;
|
||||
public void processOrderEvent(RichOrderEvent domainEvent) {
|
||||
service.updateOrderState(domainEvent.getType());
|
||||
}
|
||||
}
|
||||
|
||||
class OrderService {
|
||||
public void updateOrderState(OrderEvents event) {}
|
||||
}
|
||||
|
||||
class RichOrderEvent {
|
||||
private OrderEvents type;
|
||||
private String irrelevantInfo;
|
||||
private int count;
|
||||
|
||||
public RichOrderEvent() {
|
||||
this(OrderEvents.PAY, "DEFAULT_INFO");
|
||||
}
|
||||
|
||||
// Overloaded constructor 1
|
||||
public RichOrderEvent(OrderEvents type, String info) {
|
||||
this.type = type;
|
||||
this.irrelevantInfo = info;
|
||||
}
|
||||
|
||||
// Overloaded constructor 2 (same number of arguments!)
|
||||
public RichOrderEvent(String info, int count) {
|
||||
this.irrelevantInfo = info;
|
||||
this.count = count;
|
||||
// type is not set here
|
||||
}
|
||||
|
||||
public OrderEvents getType() { return type; }
|
||||
}
|
||||
|
||||
enum OrderEvents { PAY }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.OrderController")
|
||||
.methodName("processOrderEvent")
|
||||
.build();
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.OrderService")
|
||||
.methodName("updateOrderState")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
|
||||
|
||||
assertThat(chains).hasSize(1);
|
||||
CallChain chain = chains.get(0);
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.contains("OrderEvents.PAY")
|
||||
.doesNotContain("DEFAULT_INFO")
|
||||
.doesNotContain("count");
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void shouldHandleChainedConstructorDelegationGracefully(@TempDir Path tempDir) throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
private OrderService service;
|
||||
public void processOrderEvent(RichOrderEvent domainEvent) {
|
||||
service.updateOrderState(domainEvent.getType());
|
||||
}
|
||||
}
|
||||
|
||||
class OrderService {
|
||||
public void updateOrderState(OrderEvents event) {}
|
||||
}
|
||||
|
||||
class BaseEvent {
|
||||
protected OrderEvents type;
|
||||
public BaseEvent(OrderEvents type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
class RichOrderEvent extends BaseEvent {
|
||||
public RichOrderEvent() {
|
||||
this(OrderEvents.PAY);
|
||||
}
|
||||
|
||||
public RichOrderEvent(OrderEvents type) {
|
||||
this(type, "DEFAULT_INFO");
|
||||
}
|
||||
|
||||
public RichOrderEvent(OrderEvents type, String info) {
|
||||
super(type);
|
||||
}
|
||||
|
||||
public OrderEvents getType() { return type; }
|
||||
}
|
||||
|
||||
enum OrderEvents { PAY }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.OrderController")
|
||||
.methodName("processOrderEvent")
|
||||
.build();
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.OrderService")
|
||||
.methodName("updateOrderState")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
|
||||
|
||||
assertThat(chains).hasSize(1);
|
||||
CallChain chain = chains.get(0);
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.contains("OrderEvents.PAY")
|
||||
.doesNotContain("DEFAULT_INFO");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleFieldAccessGracefully(@TempDir Path tempDir) throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
private OrderService service;
|
||||
public void processOrderEvent(RichOrderEvent domainEvent) {
|
||||
service.updateOrderState(domainEvent.getType());
|
||||
}
|
||||
}
|
||||
|
||||
class OrderService {
|
||||
public void updateOrderState(OrderEvents event) {}
|
||||
}
|
||||
|
||||
class RichOrderEvent {
|
||||
private OrderEvents type;
|
||||
|
||||
public RichOrderEvent() {
|
||||
this(OrderEvents.PAY);
|
||||
}
|
||||
|
||||
public RichOrderEvent(OrderEvents type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public OrderEvents getType() {
|
||||
return this.type; // Specifically testing FieldAccess
|
||||
}
|
||||
}
|
||||
|
||||
enum OrderEvents { PAY }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.OrderController")
|
||||
.methodName("processOrderEvent")
|
||||
.build();
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.OrderService")
|
||||
.methodName("updateOrderState")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
|
||||
|
||||
assertThat(chains).hasSize(1);
|
||||
CallChain chain = chains.get(0);
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.contains("OrderEvents.PAY");
|
||||
}
|
||||
@Test
|
||||
void shouldTraceThroughWrappedConstructorArguments(@TempDir Path tempDir) throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
import java.util.Objects;
|
||||
|
||||
public class OrderController {
|
||||
private OrderService service;
|
||||
public void processOrderEvent(RichOrderEvent domainEvent) {
|
||||
service.updateOrderState(domainEvent.getType());
|
||||
}
|
||||
}
|
||||
|
||||
class OrderService {
|
||||
public void updateOrderState(OrderEvents event) {}
|
||||
}
|
||||
|
||||
class RichOrderEvent {
|
||||
private OrderEvents type;
|
||||
|
||||
public RichOrderEvent() {
|
||||
this(Objects.requireNonNull(OrderEvents.PAY));
|
||||
}
|
||||
|
||||
public RichOrderEvent(OrderEvents type) {
|
||||
this((OrderEvents) type, "DEFAULT_INFO");
|
||||
}
|
||||
|
||||
public RichOrderEvent(OrderEvents type, String info) {
|
||||
this.type = Objects.requireNonNull(type);
|
||||
}
|
||||
|
||||
public OrderEvents getType() { return type; }
|
||||
}
|
||||
|
||||
enum OrderEvents { PAY }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.OrderController")
|
||||
.methodName("processOrderEvent")
|
||||
.build();
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.OrderService")
|
||||
.methodName("updateOrderState")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
|
||||
|
||||
assertThat(chains).hasSize(1);
|
||||
CallChain chain = chains.get(0);
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.contains("OrderEvents.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTraceThroughComplexOverloadedConstructors(@TempDir Path tempDir) throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
|
||||
public class OrderController {
|
||||
private OrderService service;
|
||||
public void processOrderEvent(EnterpriseEvent domainEvent) {
|
||||
service.updateOrderState(domainEvent.getType());
|
||||
}
|
||||
}
|
||||
|
||||
class OrderService {
|
||||
public void updateOrderState(OrderEvents event) {}
|
||||
}
|
||||
|
||||
class EnterpriseEvent {
|
||||
private OrderEvents type;
|
||||
private String info;
|
||||
|
||||
public EnterpriseEvent() {
|
||||
this(OrderEvents.PAY);
|
||||
}
|
||||
|
||||
// Matches by parameter count but wrong type!
|
||||
public EnterpriseEvent(String info) {
|
||||
this.info = info;
|
||||
}
|
||||
|
||||
public EnterpriseEvent(OrderEvents type) {
|
||||
this("info", type);
|
||||
}
|
||||
|
||||
// Target of previous constructor
|
||||
public EnterpriseEvent(String info, OrderEvents type) {
|
||||
this(type, info, null);
|
||||
}
|
||||
|
||||
// Final destination
|
||||
public EnterpriseEvent(OrderEvents type, String info, Object extra) {
|
||||
this.type = type;
|
||||
this.info = info;
|
||||
}
|
||||
|
||||
public OrderEvents getType() { return type; }
|
||||
}
|
||||
|
||||
enum OrderEvents { PAY }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.OrderController")
|
||||
.methodName("processOrderEvent")
|
||||
.build();
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.OrderService")
|
||||
.methodName("updateOrderState")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
|
||||
|
||||
assertThat(chains).hasSize(1);
|
||||
CallChain chain = chains.get(0);
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.contains("OrderEvents.PAY");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user