Compare commits
268 Commits
d6b1571f18
...
ai-branch-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5081715e13 | |||
| e70e11ba96 | |||
| bf8bfd58ed | |||
| 9a3b0403a7 | |||
| 6909af8727 | |||
| 7398df19ca | |||
| a463291f9a | |||
| 18872e26a8 | |||
| 32847d112b | |||
| 2c20b2c3fe | |||
| ed74a44c29 | |||
| 57484b768b | |||
| 9b62be913c | |||
| 40bcde26a3 | |||
| 0c152b0318 | |||
| 4cb4ba12e0 | |||
| 828347f80d | |||
| 232328a7c8 | |||
| d4c0de1d0a | |||
| 5300fdb881 | |||
| 2fc5ad53d5 | |||
| 961873d29c | |||
| 08adbcb5ff | |||
| 139eee0a43 | |||
| 4b5de0e167 | |||
| 1d9c986793 | |||
| e067412db9 | |||
| 598b504873 | |||
| 9250e97055 | |||
| 4809423004 | |||
| b4a7575bb9 | |||
|
|
109957b5fa | ||
| 342efaef8e | |||
| 9a3bd3394f | |||
| d6754b7464 | |||
| 0558fd2bb7 | |||
| d5a6adafcc | |||
| 9a54616e5f | |||
| dbc22bf1af | |||
| 758d433726 | |||
| 1957266a98 | |||
| 33ccc4a6c5 | |||
| 6ca8e64750 | |||
| 5b0778301a | |||
| d19d4f20ef | |||
| 3e0f8f18b8 | |||
| 9455a2c8a9 | |||
| 13de69862b | |||
| bb28e72bbe | |||
| a675a773da | |||
| 01c8c58277 | |||
| dd25db0109 | |||
| 280c346761 | |||
| a9dbf17ced | |||
| 60cd6511d4 | |||
| 85af06c89d | |||
| 73f047e0aa | |||
| 24cd248a22 | |||
| b9db614cb1 | |||
| fba7a0c90c | |||
| 12db9dbef4 | |||
| 79bb4e2aba | |||
| 80b784da29 | |||
| 610c83a7ec | |||
| fd21cfa440 | |||
| 8d7e04cc33 | |||
| 2d478d5779 | |||
| 252255f8b0 | |||
| a70059331b | |||
| 8ef6e5ef8f | |||
| 5348dafb69 | |||
| a3789ceb3c | |||
| 2e7afd6d42 | |||
| e3f3350398 | |||
| 5d432e8893 | |||
| 24fe8dfe00 | |||
| 00d1f24709 | |||
| 32fe96041c | |||
| 7799f8ce5f | |||
| d11f8afd79 | |||
| dbeab64e1b | |||
| d589f65cc1 | |||
| 4a8b6ae9d9 | |||
| be1ac91beb | |||
| 8c4f15133e | |||
| 57686c575e | |||
| 5bc65be30d | |||
| 5c0d9c466d | |||
| bd9136a45c | |||
| cef9dd1f9a | |||
| 26560fee6d | |||
| 8869ebc706 | |||
| 6401a58c20 | |||
| 82f4e5c087 | |||
| 828f1260cd | |||
| fb3218e1a2 | |||
| 5972b9df50 | |||
| a94490712f | |||
| dcae599d21 | |||
| 4ca42ae3dc | |||
| f3c82d5d7f | |||
| bbfb54c51b | |||
| 44497afd32 | |||
| 69361e5a60 | |||
| c1085a2bd3 | |||
| 308dac557e | |||
| be5386b972 | |||
| 4dd64c6cdf | |||
| 73754cf6ea | |||
| 1146cdbae6 | |||
| 4f3d6c40e7 | |||
| 4a7b3ff124 | |||
| 60b5ceea8c | |||
| 423bb4e819 | |||
| c7e826c0fb | |||
| 024458d638 | |||
| c3c5d66031 | |||
| ad567ace0c | |||
| 169fae88ab | |||
| f8f64487d1 | |||
| 1bf75c8a34 | |||
| 2f853fb971 | |||
| e6effa3dcd | |||
| 83008a6898 | |||
| 77bf840465 | |||
| 873e2c91f3 | |||
| 58d0701df5 | |||
| 422ea94b28 | |||
| 7ffd157105 | |||
| 393c998b66 | |||
| dd0363cf64 | |||
| cc2cf1845b | |||
| 8d954804b2 | |||
| 66aaa9563f | |||
| 411b7a0e52 | |||
| 82a70b5374 | |||
| 5d3333d494 | |||
| 7a5fc60ace | |||
| 9ede60049b | |||
| cc35546d01 | |||
| c2eff6d7bb | |||
| 613dfffd53 | |||
| db158200dd | |||
| 23a14d8391 | |||
| b158179559 | |||
| 4f13c9449c | |||
| 0aca0aade9 | |||
| ea9b8d6cff | |||
| 8a97bceca4 | |||
| 8fa6a44e75 | |||
| a108d8cc6e | |||
| 7f12ed3f3a | |||
| 0628710106 | |||
| c447641800 | |||
| 12183693aa | |||
| 1be8d5e950 | |||
| 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 |
63
AGENTS.md
Normal file
63
AGENTS.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Spring State Machine Explorer is a static analysis tool that extracts and visualizes Spring State Machines from source code. It does **not** run Maven/Gradle on target projects.
|
||||||
|
|
||||||
|
## Build System
|
||||||
|
|
||||||
|
- **Java 21** required (toolchain configured in all build.gradle files)
|
||||||
|
- **Gradle** build system with shadow plugin for fat JARs
|
||||||
|
- Run from root: `./gradlew <task>`
|
||||||
|
|
||||||
|
## Key Commands
|
||||||
|
|
||||||
|
### Run the exporter
|
||||||
|
```bash
|
||||||
|
./gradlew :state_machine_exporter:run --args="-i ./my-project -o ./out -f json"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run the HTML generator
|
||||||
|
```bash
|
||||||
|
./gradlew :state_machine_exporter_html:run --args="-i ./my-project -o ./out_html"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update golden test files
|
||||||
|
```bash
|
||||||
|
./gradlew :state_machine_exporter:runGolden
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run tests
|
||||||
|
```bash
|
||||||
|
./gradlew :state_machine_exporter:test
|
||||||
|
./gradlew :state_machine_exporter_html:test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Structure
|
||||||
|
|
||||||
|
- **state_machine_exporter**: Core Java AST analyzer (Main class: `click.kamil.springstatemachineexporter.Main`)
|
||||||
|
- **state_machine_exporter_html**: HTML portal generator (Main class: `click.kamil.springstatemachineexporter.html.Main`)
|
||||||
|
- **state_machine_exporter_spring_based**: Spring-based exporter sample
|
||||||
|
- **file_combine**: Utility for concatenating Java files
|
||||||
|
- **state_machines/**: Sample state machine projects for testing
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- **Source-first analysis**: Reads source + build metadata (pom.xml, settings.gradle) statically
|
||||||
|
- **Accessor inlining**: Enabled by default (O(1) lookup for trivial getters/setters). Disable with `--no-inline-accessors`
|
||||||
|
- **Generated sources**: Scans `target/`/`build/` by default. Control with `--include-generated-sources`
|
||||||
|
- **Debug mode**: Use `--debug` flag for verbose logging and unresolved chain diagnostics
|
||||||
|
- **Spring profiles**: Pass with `-p, --profiles` for property placeholder resolution
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Golden tests**: JSON/PNG/PUML/DOT/SCXML outputs are compared against golden files in `state_machine_exporter/src/test/resources/golden/`
|
||||||
|
- **E2E tests**: Tagged with `@Tag("e2e")` in `PlantUmlE2ETest.java`
|
||||||
|
- **Test scenarios**: Defined in `TestScenario.java` record with name, inputPath, goldenPath, baseName, activeProfiles
|
||||||
|
|
||||||
|
## Important Constraints
|
||||||
|
|
||||||
|
- **No Maven/Gradle execution**: The tool does not run the target project's build
|
||||||
|
- **JDT bindings**: Limited to library types (Spring Boot 3.2.0, Spring State Machine 3.2.0) - external JARs not resolved
|
||||||
|
- **Monorepo structure**: All modules in root; state_machines/ contains sample projects
|
||||||
|
- **Java 21 only**: All modules require Java 21 toolchain
|
||||||
53
README.md
53
README.md
@@ -8,16 +8,46 @@ Static analysis tool to extract and visualize Spring State Machines with deep co
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
Analysis is **source-first**: the exporter reads project source and build metadata (`pom.xml`, `settings.gradle`) statically. It does **not** run Maven or Gradle against the project being analyzed.
|
||||||
|
|
||||||
### 1. Extract Metadata (JSON)
|
### 1. Extract Metadata (JSON)
|
||||||
```bash
|
```bash
|
||||||
./gradlew :state_machine_exporter:run --args="-i ./my-project -o ./out -f json"
|
./gradlew :state_machine_exporter:run --args="-i ./my-project -o ./out -f json"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Common options:
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `-i, --input` | Project directory to scan (defaults to `.`) |
|
||||||
|
| `-j, --json` | Re-render from an existing JSON export instead of scanning source |
|
||||||
|
| `-o, --output` | Output directory (default `./out`) |
|
||||||
|
| `-f, --format` | Comma-separated formats: `json`, `dot`, `puml`, `scxml` |
|
||||||
|
| `-p, --profiles` | Spring profiles for property placeholder resolution |
|
||||||
|
| `--event`, `--state` | Enum formatting: `fn` (default), `fqn`, `sn` |
|
||||||
|
| `--debug` | Verbose analysis logging and unresolved chain diagnostics |
|
||||||
|
| `--no-diamonds` | Render choice/junction states as regular nodes (default: diamonds) |
|
||||||
|
| `--include-generated-sources` | Also scan `target/` / `build/` generated `.java` trees (default: on) |
|
||||||
|
| `--no-inline-accessors` | Disable accessor indexing/inlining (enabled by default) |
|
||||||
|
|
||||||
### 2. Generate Interactive Portal (HTML)
|
### 2. Generate Interactive Portal (HTML)
|
||||||
```bash
|
```bash
|
||||||
./gradlew :state_machine_exporter_html:run --args="-i ./my-project -o ./out_html"
|
./gradlew :state_machine_exporter_html:run --args="-i ./my-project -o ./out_html"
|
||||||
```
|
```
|
||||||
*Supports `--profiles prod` to resolve Spring property placeholders.*
|
|
||||||
|
HTML-specific options:
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `-f, --flows` | Custom `flows.json` path |
|
||||||
|
| `-m, --machine` | Export only machines whose name contains this filter |
|
||||||
|
| `--open` | Open the generated portal in the default browser |
|
||||||
|
| `--no-metadata-pane` | Hide the left metadata pane (entry points, flows) |
|
||||||
|
| `--no-diamonds` | Render choice pseudostates as normal nodes |
|
||||||
|
| `--debug` | Same diagnostic mode as the core exporter |
|
||||||
|
| `--include-generated-sources` | Same generated-source scanning as the core exporter |
|
||||||
|
| `--no-inline-accessors` | Disable accessor indexing/inlining (enabled by default) |
|
||||||
|
| `-p, --profiles` | Spring profiles for property resolution |
|
||||||
|
|
||||||
## Business Flows
|
## Business Flows
|
||||||
Define sequence of events in `src/main/resources/flows.json`:
|
Define sequence of events in `src/main/resources/flows.json`:
|
||||||
@@ -26,13 +56,32 @@ Define sequence of events in `src/main/resources/flows.json`:
|
|||||||
{
|
{
|
||||||
"name": "Order Success",
|
"name": "Order Success",
|
||||||
"description": "Happy path for order placement",
|
"description": "Happy path for order placement",
|
||||||
"steps": ["PAY", "CHECK_AVAILABILITY->PENDING", "SHIP"]
|
"steps": [
|
||||||
|
"PAY",
|
||||||
|
"CHECK_AVAILABILITY->PENDING",
|
||||||
|
{ "source": "com.example.order.OrderState.PAID", "event": "com.example.order.OrderEvent.SHIP" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Event-only strings remain supported but do not highlight in the HTML explorer unless paired with a source state. Use `{source, event}` objects for precise transition highlighting.
|
||||||
|
|
||||||
## JSON Structure
|
## JSON Structure
|
||||||
- `metadata.entryPoints`: REST, WebFlux, and JMS entry points.
|
- `metadata.entryPoints`: REST, WebFlux, and JMS entry points.
|
||||||
- `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`).
|
- `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`).
|
||||||
- `transitions.actions[].internalLogic`: Extracted source code/lambdas.
|
- `transitions.actions[].internalLogic`: Extracted source code/lambdas.
|
||||||
- `metadata.properties`: Dictionary of all detected Spring profiles.
|
- `metadata.properties`: Dictionary of all detected Spring profiles.
|
||||||
|
|
||||||
|
## Static analysis: accessor inlining
|
||||||
|
|
||||||
|
During scan, the exporter builds an **accessor index** of trivial getters, setters, record components, and constant-returning methods. At analysis time, `AccessorResolver` uses this index first (O(1) lookup), then falls back to constructor/field tracing and method-body evaluation.
|
||||||
|
|
||||||
|
This improves resolution of DTO/event chains such as `request.getPayload().getType()` without running Maven or Gradle on the target project.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enabled by default; disable for debugging or A/B comparison
|
||||||
|
./gradlew :state_machine_exporter:run --args="-i ./my-project -o ./out --no-inline-accessors"
|
||||||
|
```
|
||||||
|
|
||||||
|
Pipeline components live under `analysis/pipeline/` (`AccessorResolver`, `ResolutionBudget`, `MethodInvocationUnwrapper`, `FieldInitializerFinder`, `LibraryUnwrapRegistry`).
|
||||||
|
|||||||
1
TODO.md
1
TODO.md
@@ -1,6 +1,5 @@
|
|||||||
1.extract from all possible formats (ask chatgpt)
|
1.extract from all possible formats (ask chatgpt)
|
||||||
|
|
||||||
[DONE] json
|
|
||||||
[TODO] xml, maybe yaml
|
[TODO] xml, maybe yaml
|
||||||
|
|
||||||
2. [PLAN] Support external dependencies (JARs/compiled classes):
|
2. [PLAN] Support external dependencies (JARs/compiled classes):
|
||||||
|
|||||||
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) {
|
task runGolden(type: JavaExec) {
|
||||||
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
|
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
|
||||||
classpath = sourceSets.test.runtimeClasspath
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
|
workingDir = rootProject.projectDir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ include ':state_machines:inheritance_extra_functions3_state_machine'
|
|||||||
include ':state_machines:simple_state_machine'
|
include ':state_machines:simple_state_machine'
|
||||||
include ':state_machines:extended_analysis_sample'
|
include ':state_machines:extended_analysis_sample'
|
||||||
include ':state_machines:inheritance_sample'
|
include ':state_machines:inheritance_sample'
|
||||||
|
include ':state_machines:layered_dispatcher_sample'
|
||||||
include ':state_machines:ultimate_ecosystem_sm'
|
include ':state_machines:ultimate_ecosystem_sm'
|
||||||
include ':state_machines:enterprise_order_system'
|
include ':state_machines:enterprise_order_system'
|
||||||
|
include ':state_machines:jms_abstract_template_sample'
|
||||||
include ':state_machines:multi_module_sample:api-module'
|
include ':state_machines:multi_module_sample:api-module'
|
||||||
include ':state_machines:multi_module_sample:core-module'
|
include ':state_machines:multi_module_sample:core-module'
|
||||||
|
include ':state_machines:state_machine_enterprise'
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ dependencies {
|
|||||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
|
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
|
||||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.17.1'
|
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.17.1'
|
||||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1'
|
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1'
|
||||||
|
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1'
|
||||||
|
|
||||||
compileOnly 'org.projectlombok:lombok:1.18.46'
|
compileOnly 'org.projectlombok:lombok:1.18.46'
|
||||||
annotationProcessor 'org.projectlombok:lombok:1.18.46'
|
annotationProcessor 'org.projectlombok:lombok:1.18.46'
|
||||||
@@ -47,20 +48,20 @@ dependencies {
|
|||||||
testImplementation 'org.assertj:assertj-core:3.27.7'
|
testImplementation 'org.assertj:assertj-core:3.27.7'
|
||||||
|
|
||||||
implementation 'net.sourceforge.plantuml:plantuml:1.2024.3'
|
implementation 'net.sourceforge.plantuml:plantuml:1.2024.3'
|
||||||
|
|
||||||
|
// Library types for JDT binding when analyzing cloned source (never runs the target project's build).
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter:3.2.0'
|
||||||
|
implementation 'org.springframework.statemachine:spring-statemachine-starter:3.2.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
|
maxParallelForks = Math.max(1, Runtime.runtime.availableProcessors().intdiv(2) ?: 1)
|
||||||
systemProperty "updateGolden", System.getProperty("updateGolden")
|
systemProperty "updateGolden", System.getProperty("updateGolden")
|
||||||
}
|
}
|
||||||
|
|
||||||
task runGolden(type: JavaExec) {
|
task runGolden(type: JavaExec) {
|
||||||
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
|
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
|
||||||
classpath = sourceSets.test.runtimeClasspath
|
classpath = sourceSets.test.runtimeClasspath
|
||||||
}
|
|
||||||
|
|
||||||
task runGoldenFixed(type: JavaExec) {
|
|
||||||
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
|
|
||||||
classpath = sourceSets.test.runtimeClasspath
|
|
||||||
workingDir = rootProject.projectDir
|
workingDir = rootProject.projectDir
|
||||||
}
|
}
|
||||||
|
|||||||
168
state_machine_exporter/scripts/audit_golden_json.py
Normal file
168
state_machine_exporter/scripts/audit_golden_json.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Deep audit of golden JSON analysis exports."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
GOLDEN = Path(__file__).resolve().parent.parent / "src/test/resources/golden"
|
||||||
|
PH = re.compile(r"\$\{")
|
||||||
|
|
||||||
|
|
||||||
|
def fn_form(identifier):
|
||||||
|
if not identifier:
|
||||||
|
return identifier
|
||||||
|
last = identifier.rfind(".")
|
||||||
|
if last <= 0:
|
||||||
|
return identifier
|
||||||
|
prev = identifier.rfind(".", 0, last)
|
||||||
|
return identifier[prev + 1 :] if prev > 0 else identifier
|
||||||
|
|
||||||
|
|
||||||
|
def sid(st):
|
||||||
|
if isinstance(st, dict):
|
||||||
|
return st.get("fullIdentifier") or st.get("rawName")
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def eid(ev):
|
||||||
|
if isinstance(ev, dict):
|
||||||
|
return ev.get("fullIdentifier") or ev.get("rawName")
|
||||||
|
return ev
|
||||||
|
|
||||||
|
|
||||||
|
def audit_file(path, data):
|
||||||
|
name = data.get("name", path.stem)
|
||||||
|
issues = []
|
||||||
|
st_fqn = data.get("stateTypeFqn")
|
||||||
|
ev_fqn = data.get("eventTypeFqn")
|
||||||
|
transitions = data.get("transitions") or []
|
||||||
|
starts = list(data.get("startStates") or [])
|
||||||
|
ends = list(data.get("endStates") or [])
|
||||||
|
states = data.get("states") or []
|
||||||
|
meta = data.get("metadata") or {}
|
||||||
|
|
||||||
|
is_pkg_enum = st_fqn and "." in st_fqn and st_fqn not in ("String", "java.lang.String")
|
||||||
|
is_string = st_fqn in (None, "String", "java.lang.String") or (
|
||||||
|
not st_fqn and not ev_fqn
|
||||||
|
)
|
||||||
|
|
||||||
|
if PH.search(json.dumps(data)):
|
||||||
|
issues.append("BUG: unresolved ${placeholder} in export")
|
||||||
|
|
||||||
|
# Duplicate fullIdentifiers in states[]
|
||||||
|
by_full = defaultdict(list)
|
||||||
|
for s in states:
|
||||||
|
by_full[sid(s)].append(s.get("rawName"))
|
||||||
|
for fid, raws in by_full.items():
|
||||||
|
if fid and len(raws) > 1 and len(set(raws)) > 1:
|
||||||
|
issues.append(f"NOISE: duplicate state entry {fid!r} with rawNames {raws}")
|
||||||
|
|
||||||
|
# Collect transition state/event ids
|
||||||
|
t_events, t_sources, t_targets = set(), set(), set()
|
||||||
|
for t in transitions:
|
||||||
|
if eid(t.get("event")):
|
||||||
|
t_events.add(eid(t.get("event")))
|
||||||
|
for s in t.get("sourceStates") or []:
|
||||||
|
t_sources.add(sid(s))
|
||||||
|
for s in t.get("targetStates") or []:
|
||||||
|
t_targets.add(sid(s))
|
||||||
|
|
||||||
|
graph = t_sources | t_targets
|
||||||
|
|
||||||
|
# startStates canonical consistency
|
||||||
|
for ss in starts:
|
||||||
|
if is_string and ss and not ss.startswith("String."):
|
||||||
|
if any(x.startswith("String.") for x in graph):
|
||||||
|
issues.append(f"BUG: startStates {ss!r} bare but transitions use String.* form")
|
||||||
|
if ss not in graph and fn_form(ss) not in {fn_form(x) for x in graph}:
|
||||||
|
issues.append(f"WARN: startStates {ss!r} not in transition graph")
|
||||||
|
|
||||||
|
# endStates
|
||||||
|
for es in ends:
|
||||||
|
if es not in graph and fn_form(es) not in {fn_form(x) for x in graph}:
|
||||||
|
issues.append(f"WARN: endStates {es!r} not in transition graph")
|
||||||
|
|
||||||
|
# Enum canonical fullIdentifiers
|
||||||
|
if is_pkg_enum:
|
||||||
|
for i, t in enumerate(transitions):
|
||||||
|
for side in ("sourceStates", "targetStates"):
|
||||||
|
for s in t.get(side) or []:
|
||||||
|
fid = sid(s)
|
||||||
|
if fid and not fid.startswith(st_fqn + ".") and not fid.startswith('"'):
|
||||||
|
if "." in fid and not fid.startswith("<"):
|
||||||
|
issues.append(f"BUG: transitions[{i}].{side} {fid!r} not under {st_fqn}")
|
||||||
|
ev = eid(t.get("event"))
|
||||||
|
if ev and ev_fqn and "." in ev_fqn and not ev.startswith("<") and not ev.startswith(ev_fqn + "."):
|
||||||
|
if ev not in ("event",) and "." in ev and not ev.startswith(ev_fqn):
|
||||||
|
issues.append(f"BUG: transitions[{i}].event {ev!r} not under {ev_fqn}")
|
||||||
|
for i, tr in enumerate(meta.get("triggers") or []):
|
||||||
|
for field in ("event", "sourceState"):
|
||||||
|
val = tr.get(field)
|
||||||
|
if not val or val in ("event", "payload", "customMessage", "eventProvider"):
|
||||||
|
continue
|
||||||
|
type_fqn = ev_fqn if field == "event" else st_fqn
|
||||||
|
if type_fqn and "." in val and not val.startswith("<") and not val.startswith(type_fqn + "."):
|
||||||
|
if field == "sourceState" and val not in graph and fn_form(val) not in {fn_form(x) for x in graph}:
|
||||||
|
issues.append(f"BUG: triggers[{i}].sourceState {val!r} not a machine state")
|
||||||
|
elif field == "event" and type_fqn and val.isupper() is False and "." not in val and not val.startswith("<"):
|
||||||
|
if val not in ("event", "payload", "customMessage", "eventProvider", "true", "false"):
|
||||||
|
issues.append(f"INFO: triggers[{i}].event dynamic {val!r}")
|
||||||
|
|
||||||
|
# matchedTransitions
|
||||||
|
for i, chain in enumerate(meta.get("callChains") or []):
|
||||||
|
for j, mt in enumerate(chain.get("matchedTransitions") or []):
|
||||||
|
me, ms, mtgt = mt.get("event"), mt.get("sourceState"), mt.get("targetState")
|
||||||
|
ok = False
|
||||||
|
for t in transitions:
|
||||||
|
if fn_form(eid(t.get("event"))) != fn_form(me):
|
||||||
|
continue
|
||||||
|
srcs = [sid(s) for s in t.get("sourceStates") or []]
|
||||||
|
tgts = [sid(s) for s in t.get("targetStates") or []]
|
||||||
|
if (not ms or any(fn_form(x) == fn_form(ms) for x in srcs)) and (
|
||||||
|
not mtgt or any(fn_form(x) == fn_form(mtgt) for x in tgts)
|
||||||
|
):
|
||||||
|
ok = True
|
||||||
|
break
|
||||||
|
if not ok:
|
||||||
|
issues.append(
|
||||||
|
f"BUG: callChains[{i}].matchedTransitions[{j}] ({ms!r},{me!r},{mtgt!r}) no transition"
|
||||||
|
)
|
||||||
|
|
||||||
|
# String machine: mixed NEW vs String.NEW
|
||||||
|
if is_string or st_fqn == "String":
|
||||||
|
all_ids = set(graph) | set(starts) | set(ends) | {sid(s) for s in states}
|
||||||
|
for x in list(all_ids):
|
||||||
|
if not x:
|
||||||
|
continue
|
||||||
|
bare = x.strip('"')
|
||||||
|
if bare and "." not in bare and f"String.{bare}" in all_ids:
|
||||||
|
issues.append(f"BUG: mixed bare {x!r} and String.{bare}")
|
||||||
|
|
||||||
|
# stateTypeFqn missing on enum machine
|
||||||
|
if is_pkg_enum and not st_fqn:
|
||||||
|
issues.append("BUG: enum machine missing stateTypeFqn")
|
||||||
|
|
||||||
|
return name, issues
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
total = 0
|
||||||
|
for path in sorted(GOLDEN.rglob("*.json")):
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
machine, issues = audit_file(path, data)
|
||||||
|
rel = path.relative_to(GOLDEN)
|
||||||
|
bugs = [i for i in issues if i.startswith("BUG")]
|
||||||
|
warns = [i for i in issues if i.startswith("WARN")]
|
||||||
|
noise = [i for i in issues if i.startswith("NOISE")]
|
||||||
|
info = [i for i in issues if i.startswith("INFO")]
|
||||||
|
if bugs or warns:
|
||||||
|
print(f"\n{'='*70}\n{rel}\n machine: {machine}")
|
||||||
|
for i in bugs + warns + noise + info:
|
||||||
|
print(f" [{i.split(':')[0]}] {i.split(': ', 1)[-1]}")
|
||||||
|
total += len(bugs) + len(warns)
|
||||||
|
print(f"\nTotal BUG+WARN: {total}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -14,6 +14,14 @@ import java.util.List;
|
|||||||
|
|
||||||
public class Main {
|
public class Main {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
// Enable diagnostic mode early before SLF4J initializes
|
||||||
|
for (String arg : args) {
|
||||||
|
if ("--debug".equals(arg)) {
|
||||||
|
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Manual DI / Wiring
|
// Manual DI / Wiring
|
||||||
var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
|
var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
|
||||||
var exportService = new ExportService(exporters);
|
var exportService = new ExportService(exporters);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.validation.AnalysisCanonicalFormValidator;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Final enrichment step: fail fast when enum identifiers in the analysis model are not
|
||||||
|
* package-canonical. Disable with {@code -Danalyzer.canonical-form-validation.enabled=false}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class AnalysisCanonicalFormEnricher implements AnalysisEnricher {
|
||||||
|
|
||||||
|
private final boolean enabled;
|
||||||
|
|
||||||
|
public AnalysisCanonicalFormEnricher() {
|
||||||
|
this.enabled = Boolean.parseBoolean(
|
||||||
|
System.getProperty("analyzer.canonical-form-validation.enabled", "true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||||
|
if (!enabled || result == null || context == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.debug("Validating canonical enum identifiers for {}", result.getName());
|
||||||
|
AnalysisCanonicalFormValidator.enforce(result, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,16 @@ package click.kamil.springstatemachineexporter.analysis.enricher;
|
|||||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class CallChainEnricher implements AnalysisEnricher {
|
public class CallChainEnricher implements AnalysisEnricher {
|
||||||
@@ -15,14 +20,89 @@ public class CallChainEnricher implements AnalysisEnricher {
|
|||||||
@Override
|
@Override
|
||||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||||
log.info("Enriching {} with call chains", result.getName());
|
log.info("Enriching {} with call chains", result.getName());
|
||||||
|
List<CallChain> chains = buildCallChainsForMachine(result, context, intelligence, true);
|
||||||
List<CallChain> chains = intelligence.findCallChains(
|
if (chains == null) {
|
||||||
result.getMetadata().getEntryPoints(),
|
return;
|
||||||
result.getMetadata().getTriggers()
|
}
|
||||||
);
|
|
||||||
|
|
||||||
result.addMetadata(CodebaseMetadata.builder()
|
result.addMetadata(CodebaseMetadata.builder()
|
||||||
.callChains(chains)
|
.callChains(chains)
|
||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolves call chains from the codebase call graph for the given machine.
|
||||||
|
* Used on JSON re-export when source is available to refresh {@code polymorphicEvents},
|
||||||
|
* {@code external}, and branch {@code constraint} on trigger points.
|
||||||
|
*/
|
||||||
|
public static List<CallChain> buildCallChainsForMachine(
|
||||||
|
AnalysisResult result,
|
||||||
|
CodebaseContext context,
|
||||||
|
CodebaseIntelligenceProvider intelligence) {
|
||||||
|
return buildCallChainsForMachine(result, context, intelligence, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<CallChain> buildCallChainsForMachine(
|
||||||
|
AnalysisResult result,
|
||||||
|
CodebaseContext context,
|
||||||
|
CodebaseIntelligenceProvider intelligence,
|
||||||
|
boolean preferMetadataTriggers) {
|
||||||
|
if (result.getMetadata() == null || intelligence == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<EntryPoint> entryPoints = result.getMetadata().getEntryPoints();
|
||||||
|
if (entryPoints == null || entryPoints.isEmpty()) {
|
||||||
|
entryPoints = synthesizeEntryPoints(result.getMetadata().getCallChains());
|
||||||
|
}
|
||||||
|
if (entryPoints == null || entryPoints.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TriggerPoint> scopedTriggers = null;
|
||||||
|
if (preferMetadataTriggers) {
|
||||||
|
scopedTriggers = result.getMetadata().getTriggers();
|
||||||
|
}
|
||||||
|
if (scopedTriggers == null || scopedTriggers.isEmpty()) {
|
||||||
|
scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
|
||||||
|
intelligence.findTriggerPoints(),
|
||||||
|
result.getName(),
|
||||||
|
context);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CallChain> chains = intelligence.findCallChains(entryPoints, scopedTriggers);
|
||||||
|
// Semantic deduplication runs in TransitionLinkerEnricher after transitions are linked.
|
||||||
|
return MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<EntryPoint> synthesizeEntryPoints(List<CallChain> callChains) {
|
||||||
|
if (callChains == null || callChains.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Map<String, EntryPoint> byMethod = new LinkedHashMap<>();
|
||||||
|
for (CallChain chain : callChains) {
|
||||||
|
EntryPoint entryPoint = chain.getEntryPoint();
|
||||||
|
if (hasClassAndMethod(entryPoint)) {
|
||||||
|
byMethod.putIfAbsent(entryPoint.getClassName() + "#" + entryPoint.getMethodName(), entryPoint);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
TriggerPoint triggerPoint = chain.getTriggerPoint();
|
||||||
|
if (triggerPoint != null
|
||||||
|
&& triggerPoint.getClassName() != null
|
||||||
|
&& triggerPoint.getMethodName() != null) {
|
||||||
|
EntryPoint fallback = EntryPoint.builder()
|
||||||
|
.type(EntryPoint.Type.CUSTOM)
|
||||||
|
.className(triggerPoint.getClassName())
|
||||||
|
.methodName(triggerPoint.getMethodName())
|
||||||
|
.sourceFile(triggerPoint.getSourceFile())
|
||||||
|
.build();
|
||||||
|
byMethod.putIfAbsent(fallback.getClassName() + "#" + fallback.getMethodName(), fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(byMethod.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasClassAndMethod(EntryPoint entryPoint) {
|
||||||
|
return entryPoint != null
|
||||||
|
&& entryPoint.getClassName() != null
|
||||||
|
&& entryPoint.getMethodName() != null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.DynamicTriggerExpressions;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.ExternalTriggerPolicy;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Event;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central rules for transition linking outcomes derived from existing trigger evidence only.
|
||||||
|
*/
|
||||||
|
public final class CallChainLinkPolicy {
|
||||||
|
|
||||||
|
private CallChainLinkPolicy() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the machine event enum FQN from config AST, embedded analysis fields, or transition events.
|
||||||
|
* Abstract/inherited configs often omit AST type args; transition {@code fullIdentifier} values still
|
||||||
|
* carry the correct enum type for endpoint narrowing and linking.
|
||||||
|
*/
|
||||||
|
public static String resolveMachineEventTypeFqn(
|
||||||
|
String machineConfigName,
|
||||||
|
String embeddedEventTypeFqn,
|
||||||
|
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||||
|
List<Transition> machineTransitions,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (machineTypes != null && machineTypes.eventTypeFqn() != null && !machineTypes.eventTypeFqn().isBlank()) {
|
||||||
|
return machineTypes.eventTypeFqn();
|
||||||
|
}
|
||||||
|
if (embeddedEventTypeFqn != null && !embeddedEventTypeFqn.isBlank()) {
|
||||||
|
return embeddedEventTypeFqn;
|
||||||
|
}
|
||||||
|
if (context != null && machineConfigName != null) {
|
||||||
|
String[] types = StateMachineTypeResolver.resolve(machineConfigName, context);
|
||||||
|
if (types != null && types.length > 1 && types[1] != null && !types[1].isBlank()) {
|
||||||
|
return types[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eventTypeFromTransitions(machineTransitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StateMachineTypeResolver.MachineTypes resolveEffectiveMachineTypes(
|
||||||
|
String machineConfigName,
|
||||||
|
String embeddedStateTypeFqn,
|
||||||
|
String embeddedEventTypeFqn,
|
||||||
|
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||||
|
List<Transition> machineTransitions,
|
||||||
|
CodebaseContext context) {
|
||||||
|
String eventTypeFqn = resolveMachineEventTypeFqn(
|
||||||
|
machineConfigName, embeddedEventTypeFqn, machineTypes, machineTransitions, context);
|
||||||
|
String stateTypeFqn = machineTypes != null && machineTypes.stateTypeFqn() != null
|
||||||
|
? machineTypes.stateTypeFqn()
|
||||||
|
: embeddedStateTypeFqn;
|
||||||
|
if ((stateTypeFqn == null || stateTypeFqn.isBlank()) && context != null && machineConfigName != null) {
|
||||||
|
String[] types = StateMachineTypeResolver.resolve(machineConfigName, context);
|
||||||
|
if (types != null && types.length > 0 && types[0] != null && !types[0].isBlank()) {
|
||||||
|
stateTypeFqn = types[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((stateTypeFqn == null || stateTypeFqn.isBlank()) && machineTransitions != null) {
|
||||||
|
stateTypeFqn = stateTypeFromTransitions(machineTransitions);
|
||||||
|
}
|
||||||
|
return new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String eventTypeFromTransitions(List<Transition> machineTransitions) {
|
||||||
|
if (machineTransitions == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Transition transition : machineTransitions) {
|
||||||
|
Event event = transition.getEvent();
|
||||||
|
if (event == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String fullIdentifier = event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
|
||||||
|
if (fullIdentifier != null && fullIdentifier.contains(".")) {
|
||||||
|
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stateTypeFromTransitions(List<Transition> machineTransitions) {
|
||||||
|
if (machineTransitions == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Transition transition : machineTransitions) {
|
||||||
|
if (transition.getSourceStates() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (var state : transition.getSourceStates()) {
|
||||||
|
String fullIdentifier = state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName();
|
||||||
|
if (fullIdentifier != null && fullIdentifier.contains(".")) {
|
||||||
|
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint trigger) {
|
||||||
|
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
String machineEventTypeFqn) {
|
||||||
|
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
String machineEventTypeFqn,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (trigger == null || trigger.isExternal()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String eventTypeFqn = trigger.getEventTypeFqn();
|
||||||
|
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||||
|
eventTypeFqn = machineEventTypeFqn;
|
||||||
|
}
|
||||||
|
List<String> concrete = concretePolymorphicCandidates(
|
||||||
|
trigger.getPolymorphicEvents(), eventTypeFqn, context);
|
||||||
|
// Multiple concrete events on a dynamic/ENUM_SET trigger must not link every transition
|
||||||
|
// (e.g. predicate-filtered PAY+SHIP, or an adjust endpoint sharing a wide dispatcher poly).
|
||||||
|
// Do not require trigger.ambiguous — that flag is often cleared incorrectly upstream.
|
||||||
|
if (concrete.size() <= 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String event = trigger.getEvent();
|
||||||
|
if (event != null && event.startsWith("ENUM_SET:")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return DynamicTriggerExpressions.isDynamic(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when call-graph evidence resolves to a single concrete machine enum event for this chain.
|
||||||
|
* Dispatcher/rich-event paths should narrow to one constant per endpoint before linking.
|
||||||
|
*/
|
||||||
|
public static boolean isEndpointNarrowPolyEvidence(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
String machineEventTypeFqn,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (trigger == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String eventTypeFqn = trigger.getEventTypeFqn();
|
||||||
|
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||||
|
eventTypeFqn = machineEventTypeFqn;
|
||||||
|
}
|
||||||
|
List<String> concrete = concretePolymorphicCandidates(
|
||||||
|
trigger.getPolymorphicEvents(), eventTypeFqn, context);
|
||||||
|
return concrete.size() == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isEndpointNarrowPolyEvidence(TriggerPoint trigger) {
|
||||||
|
return isEndpointNarrowPolyEvidence(trigger, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isEndpointNarrowPolyEvidence(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
String machineEventTypeFqn) {
|
||||||
|
return isEndpointNarrowPolyEvidence(trigger, machineEventTypeFqn, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when every concrete polymorphic candidate belongs to the trigger's declared event type.
|
||||||
|
* Used to allow machine-scoped symbolic expansion for generic dispatchers while rejecting
|
||||||
|
* cross-package or unqualified widens.
|
||||||
|
*/
|
||||||
|
static boolean isTrustedEnumPolymorphicWiden(TriggerPoint trigger) {
|
||||||
|
return isTrustedEnumPolymorphicWiden(trigger, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isTrustedEnumPolymorphicWiden(TriggerPoint trigger, String machineEventTypeFqn) {
|
||||||
|
return isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isTrustedEnumPolymorphicWiden(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
String machineEventTypeFqn,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (trigger == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String eventTypeFqn = trigger.getEventTypeFqn();
|
||||||
|
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||||
|
eventTypeFqn = machineEventTypeFqn;
|
||||||
|
}
|
||||||
|
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
List<String> poly = trigger.getPolymorphicEvents();
|
||||||
|
List<String> concrete = concretePolymorphicCandidates(poly, eventTypeFqn, context);
|
||||||
|
if (concrete.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String pe : concrete) {
|
||||||
|
if (!MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(pe, eventTypeFqn, context)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> concretePolymorphicCandidates(
|
||||||
|
List<String> poly,
|
||||||
|
String eventTypeFqn,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (poly == null || poly.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> concrete = new ArrayList<>();
|
||||||
|
for (String pe : poly) {
|
||||||
|
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String qualified = pe;
|
||||||
|
if (!pe.contains(".")) {
|
||||||
|
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
qualified = eventTypeFqn + "." + pe;
|
||||||
|
} else if (eventTypeFqn != null && !eventTypeFqn.isBlank()) {
|
||||||
|
qualified = MachineEnumCanonicalizer.canonicalizeLabel(pe, eventTypeFqn, context);
|
||||||
|
}
|
||||||
|
if (qualified != null && qualified.contains(".")) {
|
||||||
|
concrete.add(qualified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return concrete;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LinkResolution resolveLinkResolution(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
List<MatchedTransition> matched,
|
||||||
|
boolean ambiguousSource) {
|
||||||
|
return resolveLinkResolution(trigger, matched, ambiguousSource, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LinkResolution resolveLinkResolution(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
List<MatchedTransition> matched,
|
||||||
|
boolean ambiguousSource,
|
||||||
|
String machineEventTypeFqn) {
|
||||||
|
return resolveLinkResolution(trigger, matched, ambiguousSource, machineEventTypeFqn, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LinkResolution resolveLinkResolution(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
List<MatchedTransition> matched,
|
||||||
|
boolean ambiguousSource,
|
||||||
|
String machineEventTypeFqn,
|
||||||
|
CodebaseContext context) {
|
||||||
|
// Prefer concrete matches over an external flag — dedicated endpoints can be
|
||||||
|
// mis-tagged external when unrelated RequestParams appear in path bindings.
|
||||||
|
if (matched != null && !matched.isEmpty()) {
|
||||||
|
if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) {
|
||||||
|
return LinkResolution.AMBIGUOUS_WIDEN;
|
||||||
|
}
|
||||||
|
return LinkResolution.RESOLVED;
|
||||||
|
}
|
||||||
|
if (trigger != null && trigger.isExternal()) {
|
||||||
|
return LinkResolution.UNRESOLVED_EXTERNAL;
|
||||||
|
}
|
||||||
|
if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) {
|
||||||
|
return LinkResolution.AMBIGUOUS_WIDEN;
|
||||||
|
}
|
||||||
|
if (ambiguousSource) {
|
||||||
|
return LinkResolution.AMBIGUOUS_WIDEN;
|
||||||
|
}
|
||||||
|
return LinkResolution.NO_MATCH;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TriggerPoint applyRestExternalPolicy(TriggerPoint trigger, CallChain chain, CodebaseContext context) {
|
||||||
|
if (trigger == null || chain.getEntryPoint() == null) {
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
EntryPoint entryPoint = chain.getEntryPoint();
|
||||||
|
String entryMethod = entryPoint.getClassName() + "." + entryPoint.getMethodName();
|
||||||
|
// Pass null for event param name — trigger.getEvent() is an expression/FQN, not a REST param.
|
||||||
|
boolean external = ExternalTriggerPolicy.isExternalFromSource(
|
||||||
|
entryPoint, trigger, entryMethod, null, context);
|
||||||
|
if (external == trigger.isExternal()) {
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
return trigger.toBuilder().external(external).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.enricher.path.SymbolicPathValueEstimator;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstraintClauses;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.DynamicTriggerExpressions;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||||
|
import org.eclipse.jdt.core.dom.Expression;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||||
|
import org.eclipse.jdt.core.dom.QualifiedName;
|
||||||
|
import org.eclipse.jdt.core.dom.SimpleName;
|
||||||
|
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows wide call-graph polymorphic widens using dataflow evidence only.
|
||||||
|
*
|
||||||
|
* <p>Evidence sources: static trigger events, path-binding map values, branch constraints,
|
||||||
|
* sendEvent enum literals along the method chain, and AST path-value estimation.
|
||||||
|
* REST URL path segments and method-name tokens are intentionally not used — those are not
|
||||||
|
* dataflow and falsely link endpoints to transitions.
|
||||||
|
*/
|
||||||
|
public final class CallChainPolyNarrower {
|
||||||
|
|
||||||
|
private static final int STRONG = 3;
|
||||||
|
private static final int MEDIUM = 2;
|
||||||
|
private static final int WEAK = 1;
|
||||||
|
|
||||||
|
private static final Pattern ENUM_EQ = Pattern.compile(
|
||||||
|
"([A-Za-z_][\\w.]*)\\s*==\\s*([A-Za-z_][\\w.]*)");
|
||||||
|
private static final Pattern ROUTE_LITERAL = Pattern.compile(
|
||||||
|
"route\\(\\s*\"([^\"]+)\"\\s*,\\s*\"([^\"]+)\"\\s*\\)");
|
||||||
|
|
||||||
|
private static final Set<String> FIRE_METHOD_NAMES = Set.of(
|
||||||
|
"sendEvent", "fire", "fireEvent", "dispatchEvent", "send", "trigger");
|
||||||
|
|
||||||
|
private CallChainPolyNarrower() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TriggerPoint narrowForCallChain(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
CallChain chain,
|
||||||
|
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||||
|
List<Transition> machineTransitions,
|
||||||
|
CodebaseContext context) {
|
||||||
|
return narrowForCallChain(trigger, chain, machineTypes, null, machineTransitions, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TriggerPoint narrowForCallChain(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
CallChain chain,
|
||||||
|
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||||
|
String embeddedEventTypeFqn,
|
||||||
|
List<Transition> machineTransitions,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (trigger == null) {
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
List<String> poly = trigger.getPolymorphicEvents();
|
||||||
|
if (poly == null || poly.size() <= 1) {
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
String eventTypeFqn = CallChainLinkPolicy.resolveMachineEventTypeFqn(
|
||||||
|
null,
|
||||||
|
embeddedEventTypeFqn != null ? embeddedEventTypeFqn : trigger.getEventTypeFqn(),
|
||||||
|
machineTypes,
|
||||||
|
machineTransitions,
|
||||||
|
context);
|
||||||
|
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
Set<String> configuredConstants = configuredEventConstants(machineTransitions, eventTypeFqn, context);
|
||||||
|
if (configuredConstants.isEmpty()) {
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
HintVotes votes = new HintVotes();
|
||||||
|
collectHints(trigger, chain, configuredConstants, context, votes);
|
||||||
|
|
||||||
|
String chosen = votes.choose(configuredConstants);
|
||||||
|
if (chosen == null) {
|
||||||
|
return trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> narrowed = filterPolyToConstant(poly, chosen, eventTypeFqn, context);
|
||||||
|
if (narrowed.size() == 1) {
|
||||||
|
return trigger.toBuilder()
|
||||||
|
.polymorphicEvents(narrowed)
|
||||||
|
.ambiguous(false)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return trigger.toBuilder()
|
||||||
|
.polymorphicEvents(List.of(eventTypeFqn + "." + chosen))
|
||||||
|
.ambiguous(false)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collectHints(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
CallChain chain,
|
||||||
|
Set<String> configuredConstants,
|
||||||
|
CodebaseContext context,
|
||||||
|
HintVotes votes) {
|
||||||
|
collectFromStaticTriggerEvent(trigger, configuredConstants, votes);
|
||||||
|
collectFromConstraint(trigger.getConstraint(), configuredConstants, votes);
|
||||||
|
if (chain != null) {
|
||||||
|
collectFromPathBindings(chain.getPathBindings(), configuredConstants, votes);
|
||||||
|
collectFromSendEventLiterals(chain.getMethodChain(), configuredConstants, context, votes);
|
||||||
|
collectFromPathEstimator(chain, configuredConstants, context, votes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collectFromStaticTriggerEvent(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
Set<String> configuredConstants,
|
||||||
|
HintVotes votes) {
|
||||||
|
String event = trigger.getEvent();
|
||||||
|
if (event == null || event.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (DynamicTriggerExpressions.isDynamic(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addEnumReference(event, configuredConstants, votes, STRONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path bindings come from {@code PathBindingEvaluator} / call-graph dataflow, not from URL text.
|
||||||
|
*/
|
||||||
|
private static void collectFromPathBindings(
|
||||||
|
Map<String, String> pathBindings,
|
||||||
|
Set<String> configuredConstants,
|
||||||
|
HintVotes votes) {
|
||||||
|
if (pathBindings == null || pathBindings.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, String> entry : pathBindings.entrySet()) {
|
||||||
|
String key = entry.getKey();
|
||||||
|
String value = entry.getValue();
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean eventLikeKey = EventCarrierPolicy.isCarrierParamName(key);
|
||||||
|
int weight = eventLikeKey ? STRONG : MEDIUM;
|
||||||
|
addEnumReference(value, configuredConstants, votes, weight);
|
||||||
|
addPathToken(value, configuredConstants, votes, weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collectFromConstraint(
|
||||||
|
String constraint,
|
||||||
|
Set<String> configuredConstants,
|
||||||
|
HintVotes votes) {
|
||||||
|
if (constraint == null || constraint.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String literal : ConstraintClauses.carrierLiterals(constraint)) {
|
||||||
|
addPathToken(literal, configuredConstants, votes, MEDIUM);
|
||||||
|
if (literal.contains(".")) {
|
||||||
|
addPathToken(literal, configuredConstants, votes, MEDIUM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Matcher route = ROUTE_LITERAL.matcher(constraint);
|
||||||
|
while (route.find()) {
|
||||||
|
addPathToken(route.group(2), configuredConstants, votes, MEDIUM);
|
||||||
|
}
|
||||||
|
Matcher enumEq = ENUM_EQ.matcher(constraint);
|
||||||
|
while (enumEq.find()) {
|
||||||
|
addEnumReference(enumEq.group(1), configuredConstants, votes, MEDIUM);
|
||||||
|
addEnumReference(enumEq.group(2), configuredConstants, votes, MEDIUM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collectFromSendEventLiterals(
|
||||||
|
List<String> methodChain,
|
||||||
|
Set<String> configuredConstants,
|
||||||
|
CodebaseContext context,
|
||||||
|
HintVotes votes) {
|
||||||
|
if (context == null || methodChain == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ConstantResolver resolver = new ConstantResolver();
|
||||||
|
for (String methodFqn : methodChain) {
|
||||||
|
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||||
|
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||||
|
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
|
||||||
|
if (typeDeclaration == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
|
||||||
|
if (methodDeclaration == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (Object parameter : methodDeclaration.parameters()) {
|
||||||
|
if (parameter instanceof SingleVariableDeclaration variable) {
|
||||||
|
addUniqueIdentifierMatch(variable.getType().toString(), configuredConstants, votes, MEDIUM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (methodDeclaration.getBody() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
methodDeclaration.getBody().accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(MethodInvocation node) {
|
||||||
|
boolean fireSite = FIRE_METHOD_NAMES.contains(node.getName().getIdentifier());
|
||||||
|
for (Object argument : node.arguments()) {
|
||||||
|
if (!(argument instanceof Expression expression)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (expression instanceof QualifiedName qualifiedName) {
|
||||||
|
// Enum constant passed into the next hop (dataflow at the call site).
|
||||||
|
addEnumReference(
|
||||||
|
qualifiedName.getFullyQualifiedName(),
|
||||||
|
configuredConstants,
|
||||||
|
votes,
|
||||||
|
STRONG);
|
||||||
|
} else if (fireSite && expression instanceof SimpleName simpleName) {
|
||||||
|
// Only accept UPPERCASE identifiers that are configured constants —
|
||||||
|
// plain parameter names like "event" must not vote.
|
||||||
|
String identifier = simpleName.getIdentifier();
|
||||||
|
if (identifier != null
|
||||||
|
&& identifier.equals(identifier.toUpperCase(Locale.ROOT))
|
||||||
|
&& configuredConstants.contains(identifier)) {
|
||||||
|
addEnumReference(identifier, configuredConstants, votes, STRONG);
|
||||||
|
}
|
||||||
|
} else if (fireSite) {
|
||||||
|
String resolved = resolver.resolve(expression, context);
|
||||||
|
if (resolved != null) {
|
||||||
|
addEnumReference(resolved, configuredConstants, votes, STRONG);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collectFromPathEstimator(
|
||||||
|
CallChain chain,
|
||||||
|
Set<String> configuredConstants,
|
||||||
|
CodebaseContext context,
|
||||||
|
HintVotes votes) {
|
||||||
|
if (context == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> estimated = new SymbolicPathValueEstimator().estimatePossibleValues(chain, context);
|
||||||
|
if (estimated == null || estimated.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int weight = estimated.size() == 1 ? MEDIUM : WEAK;
|
||||||
|
for (String value : estimated) {
|
||||||
|
addEnumReference(value, configuredConstants, votes, weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addPathToken(
|
||||||
|
String token,
|
||||||
|
Set<String> configuredConstants,
|
||||||
|
HintVotes votes,
|
||||||
|
int weight) {
|
||||||
|
if (token == null || token.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addPathTokenVariants(token, configuredConstants, votes, weight);
|
||||||
|
if (token.contains(".")) {
|
||||||
|
addPathTokenVariants(token.substring(token.lastIndexOf('.') + 1), configuredConstants, votes, weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addPathTokenVariants(
|
||||||
|
String token,
|
||||||
|
Set<String> configuredConstants,
|
||||||
|
HintVotes votes,
|
||||||
|
int weight) {
|
||||||
|
maybeVoteConstant(votes, normalizeToken(token), configuredConstants, weight);
|
||||||
|
for (String part : token.split("[-_.]")) {
|
||||||
|
if (!part.isBlank()) {
|
||||||
|
maybeVoteConstant(votes, normalizeToken(part), configuredConstants, weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (token.toUpperCase(Locale.ROOT).contains("_")) {
|
||||||
|
String tail = token.substring(token.lastIndexOf('_') + 1);
|
||||||
|
maybeVoteConstant(votes, normalizeToken(tail), configuredConstants, weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addEnumReference(
|
||||||
|
String reference,
|
||||||
|
Set<String> configuredConstants,
|
||||||
|
HintVotes votes,
|
||||||
|
int weight) {
|
||||||
|
if (reference == null || reference.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
maybeVoteConstant(votes, constantName(reference), configuredConstants, weight);
|
||||||
|
if (reference.contains(".")) {
|
||||||
|
maybeVoteConstant(votes, constantName(reference), configuredConstants, weight);
|
||||||
|
}
|
||||||
|
String upper = reference.toUpperCase(Locale.ROOT);
|
||||||
|
if (upper.contains("_")) {
|
||||||
|
maybeVoteConstant(votes, upper.substring(upper.lastIndexOf('_') + 1), configuredConstants, weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addUniqueIdentifierMatch(
|
||||||
|
String identifier,
|
||||||
|
Set<String> configuredConstants,
|
||||||
|
HintVotes votes,
|
||||||
|
int weight) {
|
||||||
|
if (identifier == null || identifier.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String upper = identifier.toUpperCase(Locale.ROOT);
|
||||||
|
List<String> matched = new ArrayList<>();
|
||||||
|
for (String constant : configuredConstants) {
|
||||||
|
if (upper.contains(constant)) {
|
||||||
|
matched.add(constant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched.size() == 1) {
|
||||||
|
votes.add(matched.get(0), weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void maybeVoteConstant(
|
||||||
|
HintVotes votes,
|
||||||
|
String candidate,
|
||||||
|
Set<String> configuredConstants,
|
||||||
|
int weight) {
|
||||||
|
if (candidate == null || candidate.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String normalized = candidate.toUpperCase(Locale.ROOT);
|
||||||
|
if (configuredConstants.contains(normalized)) {
|
||||||
|
votes.add(normalized, weight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> filterPolyToConstant(
|
||||||
|
List<String> poly,
|
||||||
|
String constant,
|
||||||
|
String eventTypeFqn,
|
||||||
|
CodebaseContext context) {
|
||||||
|
List<String> narrowed = new ArrayList<>();
|
||||||
|
for (String candidate : poly) {
|
||||||
|
if (candidate == null || candidate.startsWith("<SYMBOLIC:") || candidate.startsWith("ENUM_SET:")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!constant.equalsIgnoreCase(constantName(candidate))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String canonical = candidate.contains(".")
|
||||||
|
? MachineEnumCanonicalizer.canonicalizeLabel(candidate, eventTypeFqn, context)
|
||||||
|
: eventTypeFqn + "." + constant;
|
||||||
|
if (!narrowed.contains(canonical)) {
|
||||||
|
narrowed.add(canonical);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return narrowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeToken(String token) {
|
||||||
|
return token.replace('-', '_').toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> splitCamelCase(String identifier) {
|
||||||
|
if (identifier == null || identifier.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> tokens = new ArrayList<>();
|
||||||
|
StringBuilder current = new StringBuilder();
|
||||||
|
for (int i = 0; i < identifier.length(); i++) {
|
||||||
|
char ch = identifier.charAt(i);
|
||||||
|
if (Character.isUpperCase(ch) && !current.isEmpty()) {
|
||||||
|
tokens.add(current.toString());
|
||||||
|
current.setLength(0);
|
||||||
|
}
|
||||||
|
current.append(ch);
|
||||||
|
}
|
||||||
|
if (!current.isEmpty()) {
|
||||||
|
tokens.add(current.toString());
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> configuredEventConstants(
|
||||||
|
List<Transition> transitions,
|
||||||
|
String eventTypeFqn,
|
||||||
|
CodebaseContext context) {
|
||||||
|
Set<String> constants = new LinkedHashSet<>();
|
||||||
|
for (String event : MachineEnumCanonicalizer.polymorphicEventsFromTransitions(
|
||||||
|
transitions, eventTypeFqn, context)) {
|
||||||
|
constants.add(constantName(event).toUpperCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
return constants;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String constantName(String ref) {
|
||||||
|
int dot = ref.lastIndexOf('.');
|
||||||
|
return (dot >= 0 ? ref.substring(dot + 1) : ref).toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class HintVotes {
|
||||||
|
private final Map<String, Integer> scores = new HashMap<>();
|
||||||
|
private final Map<String, Integer> maxWeight = new HashMap<>();
|
||||||
|
|
||||||
|
void add(String constant, int weight) {
|
||||||
|
if (constant == null || constant.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String key = constant.toUpperCase(Locale.ROOT);
|
||||||
|
scores.merge(key, weight, Integer::sum);
|
||||||
|
maxWeight.merge(key, weight, Math::max);
|
||||||
|
}
|
||||||
|
|
||||||
|
String choose(Set<String> configured) {
|
||||||
|
String strongUnique = uniqueAboveWeight(configured, STRONG);
|
||||||
|
if (strongUnique != null) {
|
||||||
|
return strongUnique;
|
||||||
|
}
|
||||||
|
String mediumUnique = uniqueAboveWeight(configured, MEDIUM);
|
||||||
|
if (mediumUnique != null) {
|
||||||
|
return mediumUnique;
|
||||||
|
}
|
||||||
|
String best = null;
|
||||||
|
int bestScore = 0;
|
||||||
|
for (String constant : configured) {
|
||||||
|
int score = scores.getOrDefault(constant, 0);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = constant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best == null || bestScore < MEDIUM) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final int winningScore = bestScore;
|
||||||
|
List<String> tied = new ArrayList<>();
|
||||||
|
for (String constant : configured) {
|
||||||
|
if (scores.getOrDefault(constant, 0) == winningScore) {
|
||||||
|
tied.add(constant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tied.size() == 1) {
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
String maxWeightWinner = null;
|
||||||
|
int bestMaxWeight = 0;
|
||||||
|
for (String constant : tied) {
|
||||||
|
int weight = maxWeight.getOrDefault(constant, 0);
|
||||||
|
if (weight > bestMaxWeight) {
|
||||||
|
bestMaxWeight = weight;
|
||||||
|
maxWeightWinner = constant;
|
||||||
|
} else if (weight == bestMaxWeight && weight > 0) {
|
||||||
|
maxWeightWinner = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxWeightWinner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String uniqueAboveWeight(Set<String> configured, int minWeight) {
|
||||||
|
String chosen = null;
|
||||||
|
for (String constant : configured) {
|
||||||
|
if (maxWeight.getOrDefault(constant, 0) >= minWeight) {
|
||||||
|
if (chosen != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
chosen = constant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chosen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ public class EntryPointEnricher implements AnalysisEnricher {
|
|||||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||||
log.info("Enriching {} with entry points", result.getName());
|
log.info("Enriching {} with entry points", result.getName());
|
||||||
|
|
||||||
|
// Keep all entry points; machine scoping is applied after call-chain resolution
|
||||||
|
// when source evidence (trigger type FQNs, literals, constraints) is available.
|
||||||
List<EntryPoint> entryPoints = intelligence.findEntryPoints();
|
List<EntryPoint> entryPoints = intelligence.findEntryPoints();
|
||||||
|
|
||||||
result.addMetadata(CodebaseMetadata.builder()
|
result.addMetadata(CodebaseMetadata.builder()
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.EntryPointKeys;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.GenericEventDetector;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.JdtCallGraphEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scopes REST entry points to a machine using call-graph reachability and trigger type evidence,
|
||||||
|
* not URL path segment guessing.
|
||||||
|
*/
|
||||||
|
public final class EntryPointScopeResolver {
|
||||||
|
|
||||||
|
private static final BeanResolutionEngine ROUTING = new HeuristicBeanResolutionEngine();
|
||||||
|
private static final String TRIGGERS_CACHE = "entryPointScope.triggers";
|
||||||
|
|
||||||
|
public enum Affinity {
|
||||||
|
FOR_MACHINE,
|
||||||
|
AGAINST_MACHINE,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
private EntryPointScopeResolver() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<EntryPoint> scopeFromCallChains(List<CallChain> chains, List<EntryPoint> allEntryPoints) {
|
||||||
|
Map<String, EntryPoint> scoped = new LinkedHashMap<>();
|
||||||
|
if (chains != null) {
|
||||||
|
for (CallChain chain : chains) {
|
||||||
|
EntryPoint entryPoint = chain.getEntryPoint();
|
||||||
|
if (entryPoint != null) {
|
||||||
|
scoped.putIfAbsent(EntryPointKeys.of(entryPoint), entryPoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allEntryPoints != null) {
|
||||||
|
for (EntryPoint entryPoint : allEntryPoints) {
|
||||||
|
if (hasPathVariablePlaceholder(entryPoint)) {
|
||||||
|
scoped.putIfAbsent(EntryPointKeys.of(entryPoint), entryPoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(scoped.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Affinity resolveAffinity(EntryPoint entryPoint, String machineName, CodebaseContext context) {
|
||||||
|
if (entryPoint == null || machineName == null) {
|
||||||
|
return Affinity.UNKNOWN;
|
||||||
|
}
|
||||||
|
if (entryPoint.getClassName() == null || entryPoint.getMethodName() == null) {
|
||||||
|
return Affinity.UNKNOWN;
|
||||||
|
}
|
||||||
|
if (context == null || context.getCompilationUnits().isEmpty()) {
|
||||||
|
return Affinity.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
CallGraphEngine engine = createCallGraphEngine(context);
|
||||||
|
List<CallChain> chains = engine.findChains(List.of(entryPoint), getOrCollectTriggers(context));
|
||||||
|
if (chains.isEmpty()) {
|
||||||
|
return Affinity.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean matched = false;
|
||||||
|
boolean mismatched = false;
|
||||||
|
for (CallChain chain : chains) {
|
||||||
|
TriggerPoint trigger = chain.getTriggerPoint();
|
||||||
|
if (trigger == null || !hasTypeEvidence(trigger)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ROUTING.isRoutedToCorrectMachine(chain, machineName, context)) {
|
||||||
|
matched = true;
|
||||||
|
} else {
|
||||||
|
mismatched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
return Affinity.FOR_MACHINE;
|
||||||
|
}
|
||||||
|
if (mismatched) {
|
||||||
|
return Affinity.AGAINST_MACHINE;
|
||||||
|
}
|
||||||
|
return Affinity.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean hasPathVariablePlaceholder(EntryPoint entryPoint) {
|
||||||
|
if (entryPoint == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String name = entryPoint.getName();
|
||||||
|
if (name != null && name.contains("{")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Map<String, String> metadata = entryPoint.getMetadata();
|
||||||
|
if (metadata != null) {
|
||||||
|
String path = metadata.get("path");
|
||||||
|
return path != null && path.contains("{");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasTypeEvidence(TriggerPoint trigger) {
|
||||||
|
if (trigger.getEventTypeFqn() != null || trigger.getStateTypeFqn() != null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
|
||||||
|
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static List<TriggerPoint> getOrCollectTriggers(CodebaseContext context) {
|
||||||
|
Object cached = context.getCache().get(TRIGGERS_CACHE);
|
||||||
|
if (cached instanceof List<?> list) {
|
||||||
|
return (List<TriggerPoint>) list;
|
||||||
|
}
|
||||||
|
List<TriggerPoint> triggers = new ArrayList<>();
|
||||||
|
GenericEventDetector detector =
|
||||||
|
new GenericEventDetector(context, context.getConstantResolver(), context.getLibraryHints());
|
||||||
|
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||||
|
triggers.addAll(detector.detect(cu));
|
||||||
|
}
|
||||||
|
context.getCache().put(TRIGGERS_CACHE, triggers);
|
||||||
|
return triggers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CallGraphEngine createCallGraphEngine(CodebaseContext context) {
|
||||||
|
SpringBeanRegistry registry = new SpringBeanRegistry();
|
||||||
|
SpringContextScanner scanner = new SpringContextScanner(registry);
|
||||||
|
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||||
|
cu.accept(scanner);
|
||||||
|
}
|
||||||
|
SpringDependencyResolver dependencyResolver = new SpringDependencyResolver(registry);
|
||||||
|
InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(dependencyResolver);
|
||||||
|
return new JdtCallGraphEngine(context, injectionAnalyzer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstraintClauses;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.DynamicTriggerExpressions;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.EntryPointBindingExpander;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When an expandable event-carrier entry (REST {@code {event}} / PathVariable carrier / messaging
|
||||||
|
* payload) still has an unbound {@code Enum.valueOf} multi-constant widen, synthesize one call
|
||||||
|
* chain per concrete polymorphic event so linking can proceed without softening fail-closed for
|
||||||
|
* non-expandable shared dispatchers.
|
||||||
|
*/
|
||||||
|
public final class ExpandableEntryPolySplitter {
|
||||||
|
|
||||||
|
private ExpandableEntryPolySplitter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the original chain alone, or N split chains (never empty)
|
||||||
|
*/
|
||||||
|
public static List<CallChain> maybeExpand(
|
||||||
|
CallChain chain,
|
||||||
|
TriggerPoint preparedTrigger,
|
||||||
|
String machineEventTypeFqn,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (chain == null || preparedTrigger == null) {
|
||||||
|
return chain == null ? List.of() : List.of(chain);
|
||||||
|
}
|
||||||
|
CallChain withPrepared = chain.toBuilder().triggerPoint(preparedTrigger).build();
|
||||||
|
if (!shouldSplit(withPrepared, preparedTrigger, machineEventTypeFqn, context)) {
|
||||||
|
return List.of(withPrepared);
|
||||||
|
}
|
||||||
|
List<String> concrete = CallChainLinkPolicy.concretePolymorphicCandidates(
|
||||||
|
preparedTrigger.getPolymorphicEvents(),
|
||||||
|
eventTypeFqn(preparedTrigger, machineEventTypeFqn),
|
||||||
|
context);
|
||||||
|
if (concrete.size() <= 1) {
|
||||||
|
return List.of(withPrepared);
|
||||||
|
}
|
||||||
|
String carrierParam = resolveCarrierParamName(chain.getEntryPoint());
|
||||||
|
List<CallChain> split = new ArrayList<>(concrete.size());
|
||||||
|
for (String constantFqn : concrete) {
|
||||||
|
String literal = constantLiteral(constantFqn);
|
||||||
|
Map<String, String> bindings = new LinkedHashMap<>();
|
||||||
|
if (chain.getPathBindings() != null) {
|
||||||
|
bindings.putAll(chain.getPathBindings());
|
||||||
|
}
|
||||||
|
if (carrierParam != null && literal != null) {
|
||||||
|
bindings.put(carrierParam, literal);
|
||||||
|
}
|
||||||
|
EntryPoint entryPoint = chain.getEntryPoint();
|
||||||
|
if (entryPoint != null && !bindings.isEmpty()) {
|
||||||
|
entryPoint = EntryPointBindingExpander.withResolvedPath(entryPoint, bindings);
|
||||||
|
}
|
||||||
|
TriggerPoint single = preparedTrigger.toBuilder()
|
||||||
|
.polymorphicEvents(List.of(constantFqn))
|
||||||
|
.ambiguous(false)
|
||||||
|
.external(false)
|
||||||
|
.build();
|
||||||
|
split.add(chain.toBuilder()
|
||||||
|
.entryPoint(entryPoint)
|
||||||
|
.triggerPoint(single)
|
||||||
|
.pathBindings(bindings.isEmpty() ? null : Map.copyOf(bindings))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return split;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean shouldSplit(
|
||||||
|
CallChain chain,
|
||||||
|
TriggerPoint trigger,
|
||||||
|
String machineEventTypeFqn,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (chain == null || trigger == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isExpandableEventCarrierEntry(chain.getEntryPoint())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasUnboundMachineDiscriminator(chain.getEntryPoint(), chain.getPathBindings())) {
|
||||||
|
// Dual templates like /{machineType}/…/{event} stay fail-closed until the machine
|
||||||
|
// route is bound; event-only fan-out would over-link across domains.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!ConstraintClauses.carrierLiteralsFromPathBindings(chain.getPathBindings()).isEmpty()) {
|
||||||
|
// Already narrowed by expander / dataflow — do not re-split.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!ConstraintClauses.boundCarrierLiterals(trigger.getConstraint(), chain.getPathBindings()).isEmpty()
|
||||||
|
&& ConstraintClauses.boundCarrierLiterals(trigger.getConstraint(), chain.getPathBindings()).size()
|
||||||
|
== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String event = trigger.getEvent();
|
||||||
|
if (event == null || !DynamicTriggerExpressions.isDynamic(event) || !event.contains(".valueOf(")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
List<String> concrete = CallChainLinkPolicy.concretePolymorphicCandidates(
|
||||||
|
trigger.getPolymorphicEvents(),
|
||||||
|
eventTypeFqn(trigger, machineEventTypeFqn),
|
||||||
|
context);
|
||||||
|
return concrete.size() > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isExpandableEventCarrierEntry(EntryPoint entryPoint) {
|
||||||
|
if (entryPoint == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasEventLikePlaceholder(entryPoint.getName())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entryPoint.getMetadata() != null) {
|
||||||
|
Object path = entryPoint.getMetadata().get("path");
|
||||||
|
if (path instanceof String pathValue && hasEventLikePlaceholder(pathValue)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entryPoint.getParameters() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||||
|
if (EventCarrierPolicy.isCarrierParameter(entryPoint, parameter)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isPathOrQueryVariable(parameter) && EventCarrierPolicy.isCarrierParamName(parameter.getName())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the entry advertises a machine-route discriminant that is still unbound.
|
||||||
|
*/
|
||||||
|
static boolean hasUnboundMachineDiscriminator(EntryPoint entryPoint, Map<String, String> pathBindings) {
|
||||||
|
if (entryPoint == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String name : machineDiscriminatorNames(entryPoint)) {
|
||||||
|
if (!isBound(pathBindings, name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> machineDiscriminatorNames(EntryPoint entryPoint) {
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
collectMachineDiscriminatorPlaceholders(entryPoint.getName(), names);
|
||||||
|
if (entryPoint.getMetadata() != null
|
||||||
|
&& entryPoint.getMetadata().get("path") instanceof String path) {
|
||||||
|
collectMachineDiscriminatorPlaceholders(path, names);
|
||||||
|
}
|
||||||
|
if (entryPoint.getParameters() != null) {
|
||||||
|
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||||
|
if (parameter == null || parameter.getName() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (EventCarrierPolicy.isMachineDiscriminatorName(parameter.getName())
|
||||||
|
&& (isPathOrQueryVariable(parameter)
|
||||||
|
|| entryPoint.getType() == EntryPoint.Type.REST)) {
|
||||||
|
if (!names.contains(parameter.getName())) {
|
||||||
|
names.add(parameter.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collectMachineDiscriminatorPlaceholders(String pathOrName, List<String> out) {
|
||||||
|
if (pathOrName == null || pathOrName.isBlank() || !pathOrName.contains("{")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int start = 0;
|
||||||
|
while (true) {
|
||||||
|
int open = pathOrName.indexOf('{', start);
|
||||||
|
if (open < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int close = pathOrName.indexOf('}', open + 1);
|
||||||
|
if (close < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String placeholder = pathOrName.substring(open + 1, close).trim();
|
||||||
|
int colon = placeholder.indexOf(':');
|
||||||
|
if (colon > 0) {
|
||||||
|
placeholder = placeholder.substring(0, colon);
|
||||||
|
}
|
||||||
|
if (EventCarrierPolicy.isMachineDiscriminatorName(placeholder) && !out.contains(placeholder)) {
|
||||||
|
out.add(placeholder);
|
||||||
|
}
|
||||||
|
start = close + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBound(Map<String, String> pathBindings, String name) {
|
||||||
|
if (pathBindings == null || name == null || name.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String value = pathBindings.get(name);
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Case-insensitive key fallback (expander sometimes normalizes).
|
||||||
|
for (Map.Entry<String, String> entry : pathBindings.entrySet()) {
|
||||||
|
if (entry.getKey() != null
|
||||||
|
&& entry.getKey().equalsIgnoreCase(name)
|
||||||
|
&& entry.getValue() != null
|
||||||
|
&& !entry.getValue().isBlank()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String resolveCarrierParamName(EntryPoint entryPoint) {
|
||||||
|
if (entryPoint == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entryPoint.getParameters() != null) {
|
||||||
|
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||||
|
if (EventCarrierPolicy.isCarrierParameter(entryPoint, parameter)
|
||||||
|
|| (isPathOrQueryVariable(parameter)
|
||||||
|
&& EventCarrierPolicy.isCarrierParamName(parameter.getName()))) {
|
||||||
|
return parameter.getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String fromName = firstEventLikePlaceholder(entryPoint.getName());
|
||||||
|
if (fromName != null) {
|
||||||
|
return fromName;
|
||||||
|
}
|
||||||
|
if (entryPoint.getMetadata() != null
|
||||||
|
&& entryPoint.getMetadata().get("path") instanceof String path) {
|
||||||
|
return firstEventLikePlaceholder(path);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPathOrQueryVariable(EntryPoint.Parameter parameter) {
|
||||||
|
if (parameter == null || parameter.getAnnotations() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return parameter.getAnnotations().stream().anyMatch(a ->
|
||||||
|
a != null && (a.equals("PathVariable")
|
||||||
|
|| a.endsWith(".PathVariable")
|
||||||
|
|| a.equals("RequestParam")
|
||||||
|
|| a.endsWith(".RequestParam")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasEventLikePlaceholder(String pathOrName) {
|
||||||
|
return firstEventLikePlaceholder(pathOrName) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstEventLikePlaceholder(String pathOrName) {
|
||||||
|
if (pathOrName == null || pathOrName.isBlank() || !pathOrName.contains("{")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int start = 0;
|
||||||
|
while (true) {
|
||||||
|
int open = pathOrName.indexOf('{', start);
|
||||||
|
if (open < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int close = pathOrName.indexOf('}', open + 1);
|
||||||
|
if (close < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String placeholder = pathOrName.substring(open + 1, close).trim();
|
||||||
|
int colon = placeholder.indexOf(':');
|
||||||
|
if (colon > 0) {
|
||||||
|
placeholder = placeholder.substring(0, colon);
|
||||||
|
}
|
||||||
|
if (EventCarrierPolicy.isCarrierParamName(placeholder)) {
|
||||||
|
return placeholder;
|
||||||
|
}
|
||||||
|
start = close + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String eventTypeFqn(TriggerPoint trigger, String machineEventTypeFqn) {
|
||||||
|
if (trigger.getEventTypeFqn() != null && !trigger.getEventTypeFqn().isBlank()) {
|
||||||
|
return trigger.getEventTypeFqn();
|
||||||
|
}
|
||||||
|
return machineEventTypeFqn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String constantLiteral(String constantFqn) {
|
||||||
|
if (constantFqn == null || constantFqn.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int lastDot = constantFqn.lastIndexOf('.');
|
||||||
|
return lastDot >= 0 ? constantFqn.substring(lastDot + 1) : constantFqn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts normalized domain keys from state machine configuration class names
|
||||||
|
* (e.g. {@code StandardOrderStateMachineConfiguration} → {@code ORDER}).
|
||||||
|
*/
|
||||||
|
public final class MachineDomainKeys {
|
||||||
|
|
||||||
|
private MachineDomainKeys() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String extractMachineDomainKey(String machineName) {
|
||||||
|
String simple = machineName.substring(machineName.lastIndexOf('.') + 1);
|
||||||
|
String upper = simple.toUpperCase(Locale.ROOT);
|
||||||
|
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) {
|
||||||
|
int idx = upper.indexOf(marker);
|
||||||
|
if (idx > 0) {
|
||||||
|
return normalizeDomainKey(upper.substring(0, idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int stateIdx = upper.indexOf("STATE");
|
||||||
|
if (stateIdx > 0) {
|
||||||
|
return normalizeDomainKey(upper.substring(0, stateIdx));
|
||||||
|
}
|
||||||
|
return normalizeDomainKey(upper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeDomainKey(String domain) {
|
||||||
|
if (domain == null || domain.isEmpty()) {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
String upper = domain.toUpperCase(Locale.ROOT);
|
||||||
|
if (upper.startsWith("STANDARD") && upper.length() > "STANDARD".length()) {
|
||||||
|
return upper.substring("STANDARD".length());
|
||||||
|
}
|
||||||
|
return upper;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Event;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters codebase-wide analysis artifacts to those relevant for a single state machine config.
|
||||||
|
*/
|
||||||
|
public final class MachineScopeFilter {
|
||||||
|
|
||||||
|
private static final BeanResolutionEngine ROUTING = new HeuristicBeanResolutionEngine();
|
||||||
|
|
||||||
|
private MachineScopeFilter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<TriggerPoint> filterTriggersForMachine(
|
||||||
|
List<TriggerPoint> triggers, String machineName, CodebaseContext context) {
|
||||||
|
return filterTriggersForMachine(triggers, machineName, context, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<TriggerPoint> filterTriggersForMachine(
|
||||||
|
List<TriggerPoint> triggers,
|
||||||
|
String machineName,
|
||||||
|
CodebaseContext context,
|
||||||
|
List<Transition> machineTransitions) {
|
||||||
|
if (triggers == null || machineName == null) {
|
||||||
|
return triggers == null ? List.of() : triggers;
|
||||||
|
}
|
||||||
|
List<TriggerPoint> filtered = new ArrayList<>();
|
||||||
|
for (TriggerPoint trigger : triggers) {
|
||||||
|
if (isTriggerForMachine(trigger, machineName, context, machineTransitions)) {
|
||||||
|
filtered.add(trigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<CallChain> filterCallChainsForMachine(
|
||||||
|
List<CallChain> chains, String machineName, CodebaseContext context) {
|
||||||
|
return filterCallChainsForMachine(chains, machineName, context, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<CallChain> filterCallChainsForMachine(
|
||||||
|
List<CallChain> chains,
|
||||||
|
String machineName,
|
||||||
|
CodebaseContext context,
|
||||||
|
List<Transition> machineTransitions) {
|
||||||
|
if (chains == null || machineName == null) {
|
||||||
|
return chains == null ? List.of() : chains;
|
||||||
|
}
|
||||||
|
List<CallChain> filtered = new ArrayList<>();
|
||||||
|
String machineEventTypeFqn = resolveMachineEventTypeFqn(machineName, context, machineTransitions);
|
||||||
|
for (CallChain chain : chains) {
|
||||||
|
if (ROUTING.hasProvenMachineAffinity(chain, machineName, context, machineTransitions)) {
|
||||||
|
filtered.add(chain);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
TriggerPoint trigger = chain.getTriggerPoint();
|
||||||
|
if (trigger != null
|
||||||
|
&& machineEventTypeFqn != null
|
||||||
|
&& CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context)) {
|
||||||
|
filtered.add(chain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String resolveMachineEventTypeFqn(
|
||||||
|
String machineName,
|
||||||
|
CodebaseContext context,
|
||||||
|
List<Transition> machineTransitions) {
|
||||||
|
if (context != null && machineName != null) {
|
||||||
|
String[] types = StateMachineTypeResolver.resolve(machineName, context);
|
||||||
|
if (types != null && types.length > 1 && types[1] != null && !types[1].isBlank()) {
|
||||||
|
return types[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (machineTransitions == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (Transition transition : machineTransitions) {
|
||||||
|
Event event = transition.getEvent();
|
||||||
|
if (event == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String fullIdentifier = event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
|
||||||
|
if (fullIdentifier != null && fullIdentifier.contains(".")) {
|
||||||
|
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<EntryPoint> filterEntryPointsForMachine(
|
||||||
|
List<EntryPoint> entryPoints, String machineName, CodebaseContext context) {
|
||||||
|
if (entryPoints == null || machineName == null) {
|
||||||
|
return entryPoints == null ? List.of() : entryPoints;
|
||||||
|
}
|
||||||
|
List<EntryPoint> filtered = new ArrayList<>();
|
||||||
|
for (EntryPoint entryPoint : entryPoints) {
|
||||||
|
if (isEntryPointForMachine(entryPoint, machineName, context)) {
|
||||||
|
filtered.add(entryPoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isEntryPointForMachine(
|
||||||
|
EntryPoint entryPoint, String machineName, CodebaseContext context) {
|
||||||
|
if (entryPoint == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (EntryPointScopeResolver.hasPathVariablePlaceholder(entryPoint)) {
|
||||||
|
EntryPointScopeResolver.Affinity affinity =
|
||||||
|
EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
|
||||||
|
return affinity != EntryPointScopeResolver.Affinity.AGAINST_MACHINE;
|
||||||
|
}
|
||||||
|
|
||||||
|
EntryPointScopeResolver.Affinity affinity =
|
||||||
|
EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
|
||||||
|
if (affinity == EntryPointScopeResolver.Affinity.FOR_MACHINE) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (affinity == EntryPointScopeResolver.Affinity.AGAINST_MACHINE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isTriggerForMachine(
|
||||||
|
TriggerPoint trigger, String machineName, CodebaseContext context, List<Transition> machineTransitions) {
|
||||||
|
if (trigger == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
CallChain probe = CallChain.builder().triggerPoint(trigger).methodChain(List.of()).build();
|
||||||
|
return ROUTING.hasProvenMachineAffinity(probe, machineName, context, machineTransitions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.EntryPointKeys;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses call chains that represent the same logical entrypoint-to-transition link
|
||||||
|
* but were discovered via different call-graph paths.
|
||||||
|
*/
|
||||||
|
public final class SemanticCallChainDedup {
|
||||||
|
|
||||||
|
public enum KeepStrategy {
|
||||||
|
SHORTEST,
|
||||||
|
FIRST
|
||||||
|
}
|
||||||
|
|
||||||
|
private SemanticCallChainDedup() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<CallChain> deduplicate(List<CallChain> chains) {
|
||||||
|
return deduplicate(chains, KeepStrategy.SHORTEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<CallChain> deduplicate(List<CallChain> chains, KeepStrategy strategy) {
|
||||||
|
if (chains == null || chains.isEmpty()) {
|
||||||
|
return chains == null ? List.of() : chains;
|
||||||
|
}
|
||||||
|
Map<String, CallChain> byKey = new LinkedHashMap<>();
|
||||||
|
for (CallChain chain : chains) {
|
||||||
|
if (chain == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = semanticKey(chain);
|
||||||
|
CallChain existing = byKey.get(key);
|
||||||
|
if (existing == null) {
|
||||||
|
byKey.put(key, chain);
|
||||||
|
} else {
|
||||||
|
byKey.put(key, pickPreferred(existing, chain, strategy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(byKey.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<CallChain> merge(
|
||||||
|
List<CallChain> existing,
|
||||||
|
List<CallChain> refreshed,
|
||||||
|
KeepStrategy strategy) {
|
||||||
|
List<CallChain> combined = new ArrayList<>();
|
||||||
|
if (existing != null) {
|
||||||
|
combined.addAll(existing);
|
||||||
|
}
|
||||||
|
if (refreshed != null) {
|
||||||
|
combined.addAll(refreshed);
|
||||||
|
}
|
||||||
|
return deduplicate(combined, strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON refresh path: when refresh produces any chains for an entry point, those replace all
|
||||||
|
* existing chains for that entry (so unresolved stubs like {@code event} do not linger).
|
||||||
|
* Multiple refreshed chains per entry (multi-event fan-out) are preserved, then semantic
|
||||||
|
* dedup collapses path variants.
|
||||||
|
*/
|
||||||
|
public static List<CallChain> mergeRefresh(
|
||||||
|
List<CallChain> existing,
|
||||||
|
List<CallChain> refreshed,
|
||||||
|
KeepStrategy strategy) {
|
||||||
|
Map<String, List<CallChain>> existingByEntry = groupByEntryPoint(existing);
|
||||||
|
Map<String, List<CallChain>> refreshedByEntry = groupByEntryPoint(refreshed);
|
||||||
|
LinkedHashSet<String> entries = new LinkedHashSet<>();
|
||||||
|
entries.addAll(existingByEntry.keySet());
|
||||||
|
entries.addAll(refreshedByEntry.keySet());
|
||||||
|
|
||||||
|
List<CallChain> combined = new ArrayList<>();
|
||||||
|
for (String entryKey : entries) {
|
||||||
|
if (refreshedByEntry.containsKey(entryKey)) {
|
||||||
|
combined.addAll(refreshedByEntry.get(entryKey));
|
||||||
|
} else {
|
||||||
|
combined.addAll(existingByEntry.getOrDefault(entryKey, List.of()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deduplicate(combined, strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, List<CallChain>> groupByEntryPoint(List<CallChain> chains) {
|
||||||
|
Map<String, List<CallChain>> byEntry = new LinkedHashMap<>();
|
||||||
|
if (chains == null) {
|
||||||
|
return byEntry;
|
||||||
|
}
|
||||||
|
for (CallChain chain : chains) {
|
||||||
|
if (chain == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byEntry.computeIfAbsent(EntryPointKeys.of(chain), key -> new ArrayList<>()).add(chain);
|
||||||
|
}
|
||||||
|
return byEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String semanticKey(CallChain chain) {
|
||||||
|
StringBuilder key = new StringBuilder();
|
||||||
|
key.append(EntryPointKeys.of(chain)).append('|');
|
||||||
|
key.append(triggerKey(chain.getTriggerPoint())).append('|');
|
||||||
|
key.append(linkResolutionKey(chain.getLinkResolution())).append('|');
|
||||||
|
key.append(matchedTransitionsKey(chain.getMatchedTransitions())).append('|');
|
||||||
|
key.append(nullToEmpty(chain.getContextMachineId()));
|
||||||
|
return key.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CallChain pickPreferred(CallChain existing, CallChain candidate, KeepStrategy strategy) {
|
||||||
|
if (strategy == KeepStrategy.FIRST) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
boolean existingAnchored = startsAtEntryPoint(existing);
|
||||||
|
boolean candidateAnchored = startsAtEntryPoint(candidate);
|
||||||
|
if (candidateAnchored != existingAnchored) {
|
||||||
|
return candidateAnchored ? candidate : existing;
|
||||||
|
}
|
||||||
|
int existingLen = chainLength(existing);
|
||||||
|
int candidateLen = chainLength(candidate);
|
||||||
|
if (candidateLen < existingLen) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
if (candidateLen > existingLen) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
int existingRichness = metadataRichness(existing);
|
||||||
|
int candidateRichness = metadataRichness(candidate);
|
||||||
|
if (candidateRichness != existingRichness) {
|
||||||
|
return candidateRichness > existingRichness ? candidate : existing;
|
||||||
|
}
|
||||||
|
List<String> existingChain = existing.getMethodChain();
|
||||||
|
List<String> candidateChain = candidate.getMethodChain();
|
||||||
|
if (existingChain == null) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
if (candidateChain == null) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return candidateChain.toString().compareTo(existingChain.toString()) < 0 ? candidate : existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the first hop equals the entry point method identity
|
||||||
|
* ({@code className.methodName} or {@code className#methodName} key form).
|
||||||
|
*/
|
||||||
|
static boolean startsAtEntryPoint(CallChain chain) {
|
||||||
|
if (chain == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
EntryPoint entryPoint = chain.getEntryPoint();
|
||||||
|
List<String> methodChain = chain.getMethodChain();
|
||||||
|
if (entryPoint == null
|
||||||
|
|| entryPoint.getClassName() == null
|
||||||
|
|| entryPoint.getMethodName() == null
|
||||||
|
|| methodChain == null
|
||||||
|
|| methodChain.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String firstHop = methodChain.get(0);
|
||||||
|
if (firstHop == null || firstHop.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String entryHop = entryPoint.getClassName() + "." + entryPoint.getMethodName();
|
||||||
|
if (entryHop.equals(firstHop)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return EntryPointKeys.normalizeMethodHop(firstHop)
|
||||||
|
.equals(entryPoint.getClassName() + "#" + entryPoint.getMethodName());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int metadataRichness(CallChain chain) {
|
||||||
|
int score = 0;
|
||||||
|
if (chain.getEntryPoint() != null) {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty()) {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) {
|
||||||
|
score += 4;
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int chainLength(CallChain chain) {
|
||||||
|
return chain.getMethodChain() == null ? Integer.MAX_VALUE : chain.getMethodChain().size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String triggerKey(TriggerPoint trigger) {
|
||||||
|
if (trigger == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
StringBuilder key = new StringBuilder();
|
||||||
|
key.append(nullToEmpty(trigger.getClassName())).append('#');
|
||||||
|
key.append(nullToEmpty(trigger.getMethodName())).append('|');
|
||||||
|
key.append(nullToEmpty(trigger.getEvent())).append('|');
|
||||||
|
key.append(nullToEmpty(trigger.getConstraint())).append('|');
|
||||||
|
key.append(trigger.isExternal()).append('|');
|
||||||
|
key.append(trigger.isAmbiguous()).append('|');
|
||||||
|
key.append(nullToEmpty(trigger.getSourceState()));
|
||||||
|
return key.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String linkResolutionKey(LinkResolution resolution) {
|
||||||
|
return resolution == null ? "" : resolution.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String matchedTransitionsKey(List<MatchedTransition> matchedTransitions) {
|
||||||
|
if (matchedTransitions == null || matchedTransitions.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return matchedTransitions.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparing((MatchedTransition mt) -> nullToEmpty(mt.getEvent()))
|
||||||
|
.thenComparing(mt -> nullToEmpty(mt.getSourceState()))
|
||||||
|
.thenComparing(mt -> nullToEmpty(mt.getTargetState())))
|
||||||
|
.map(mt -> nullToEmpty(mt.getEvent()) + ">"
|
||||||
|
+ nullToEmpty(mt.getSourceState()) + ">"
|
||||||
|
+ nullToEmpty(mt.getTargetState()))
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullToEmpty(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,58 +3,222 @@ package click.kamil.springstatemachineexporter.analysis.enricher;
|
|||||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.TriggerMachineTypeRebinder;
|
||||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
import click.kamil.springstatemachineexporter.model.State;
|
import click.kamil.springstatemachineexporter.model.State;
|
||||||
import click.kamil.springstatemachineexporter.model.Transition;
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.enricher.routing.SharedServiceRoutingPolicy;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.DynamicTriggerExpressions;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||||
|
|
||||||
public class TransitionLinkerEnricher implements AnalysisEnricher {
|
public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||||
|
|
||||||
|
private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||||
if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
|
if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine(context);
|
||||||
|
|
||||||
List<CallChain> updatedChains = new ArrayList<>();
|
List<CallChain> updatedChains = new ArrayList<>();
|
||||||
List<Transition> stateMachineTransitions = result.getTransitions();
|
List<Transition> stateMachineTransitions = result.getTransitions();
|
||||||
|
StateMachineTypeResolver.MachineTypes machineTypes =
|
||||||
|
JsonExportContextFactory.resolveMachineTypes(
|
||||||
|
result.getName(),
|
||||||
|
result.getStateTypeFqn(),
|
||||||
|
result.getEventTypeFqn(),
|
||||||
|
context);
|
||||||
|
StateMachineTypeResolver.MachineTypes effectiveMachineTypes =
|
||||||
|
CallChainLinkPolicy.resolveEffectiveMachineTypes(
|
||||||
|
result.getName(),
|
||||||
|
result.getStateTypeFqn(),
|
||||||
|
result.getEventTypeFqn(),
|
||||||
|
machineTypes,
|
||||||
|
stateMachineTransitions,
|
||||||
|
context);
|
||||||
|
String effectiveEventTypeFqn = effectiveMachineTypes.eventTypeFqn();
|
||||||
|
|
||||||
|
List<CallChain> workList = new ArrayList<>();
|
||||||
for (CallChain chain : result.getMetadata().getCallChains()) {
|
for (CallChain chain : result.getMetadata().getCallChains()) {
|
||||||
|
TriggerPoint tp = chain.getTriggerPoint();
|
||||||
|
if (tp == null || tp.getEvent() == null) {
|
||||||
|
workList.add(chain);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tp = MachineEnumCanonicalizer.prepareCallChainTrigger(
|
||||||
|
tp,
|
||||||
|
effectiveMachineTypes,
|
||||||
|
context,
|
||||||
|
stateMachineTransitions,
|
||||||
|
chain.getPathBindings());
|
||||||
|
tp = CallChainPolyNarrower.narrowForCallChain(
|
||||||
|
tp, chain, effectiveMachineTypes, result.getEventTypeFqn(), stateMachineTransitions, context);
|
||||||
|
tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, effectiveMachineTypes, context);
|
||||||
|
tp = TriggerMachineTypeRebinder.rebind(tp, chain.getMethodChain(), context);
|
||||||
|
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
|
||||||
|
tp = tp.toBuilder()
|
||||||
|
.eventTypeFqn(SharedServiceRoutingPolicy.concreteOrFallback(
|
||||||
|
tp.getEventTypeFqn(), effectiveEventTypeFqn))
|
||||||
|
.stateTypeFqn(SharedServiceRoutingPolicy.concreteOrFallback(
|
||||||
|
tp.getStateTypeFqn(), effectiveMachineTypes.stateTypeFqn()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
workList.addAll(ExpandableEntryPolySplitter.maybeExpand(
|
||||||
|
chain, tp, effectiveEventTypeFqn, context));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (CallChain chain : workList) {
|
||||||
TriggerPoint tp = chain.getTriggerPoint();
|
TriggerPoint tp = chain.getTriggerPoint();
|
||||||
if (tp == null || tp.getEvent() == null) {
|
if (tp == null || tp.getEvent() == null) {
|
||||||
updatedChains.add(chain);
|
updatedChains.add(chain);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
String triggerEvent = simplify(tp.getEvent());
|
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
|
||||||
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null;
|
updatedChains.add(chain);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbound event templates (REST /{event}) and unbound messaging payloads stay unresolved.
|
||||||
|
// Resource placeholders like /{id}/pay must still link when the event is known.
|
||||||
|
if (tp.isExternal()
|
||||||
|
&& isUnresolvedExternalEndpoint(chain, tp)
|
||||||
|
&& !hasConcreteEventEvidence(tp)) {
|
||||||
|
updatedChains.add(chain.toBuilder()
|
||||||
|
.triggerPoint(tp)
|
||||||
|
.matchedTransitions(null)
|
||||||
|
.linkResolution(LinkResolution.UNRESOLVED_EXTERNAL)
|
||||||
|
.build());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear spurious external flags (unrelated PathVariable/RequestParam) once we can link.
|
||||||
|
if (tp.isExternal()
|
||||||
|
&& (!isUnresolvedExternalEndpoint(chain, tp) || hasConcreteEventEvidence(tp))) {
|
||||||
|
tp = tp.toBuilder().external(false).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null;
|
||||||
|
if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
|
||||||
|
tp,
|
||||||
|
effectiveEventTypeFqn,
|
||||||
|
context)) {
|
||||||
|
updatedChains.add(chain.toBuilder()
|
||||||
|
.triggerPoint(tp)
|
||||||
|
.matchedTransitions(null)
|
||||||
|
.linkResolution(LinkResolution.AMBIGUOUS_WIDEN)
|
||||||
|
.build());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean ambiguousSource = false;
|
||||||
|
|
||||||
|
if (triggerSource == null) {
|
||||||
|
Map<String, Set<String>> sourcesByEvent = new LinkedHashMap<>();
|
||||||
|
Map<String, Set<String>> stateTypesByEvent = new LinkedHashMap<>();
|
||||||
|
Map<String, String> canonicalSourceBySimplified = new LinkedHashMap<>();
|
||||||
|
for (Transition t : stateMachineTransitions) {
|
||||||
|
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)
|
||||||
|
&& isRoutedToCorrectMachine(
|
||||||
|
chain, result.getName(), context, stateMachineTransitions, tp,
|
||||||
|
effectiveEventTypeFqn)
|
||||||
|
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||||
|
String smEventForLink = canonicalEvent(t.getEvent());
|
||||||
|
for (State smSourceState : t.getSourceStates()) {
|
||||||
|
String smSourceForLink = canonicalState(smSourceState);
|
||||||
|
String smSource = simplifySourceState(smSourceForLink);
|
||||||
|
sourcesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
|
||||||
|
.add(smSource);
|
||||||
|
stateTypesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
|
||||||
|
.add(extractStateEnumType(smSourceForLink));
|
||||||
|
canonicalSourceBySimplified.putIfAbsent(smSource, smSourceForLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boolean crossStateTypeConflict = stateTypesByEvent.values().stream()
|
||||||
|
.anyMatch(types -> types.size() > 1);
|
||||||
|
if (crossStateTypeConflict) {
|
||||||
|
ambiguousSource = true;
|
||||||
|
} else if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) {
|
||||||
|
// Same event from multiple sources within one state enum is normal — link all.
|
||||||
|
// Do not require endpoint-narrow poly evidence (that conflates "which event?"
|
||||||
|
// with "which place?").
|
||||||
|
ambiguousSource = false;
|
||||||
|
} else {
|
||||||
|
Set<String> allDistinctSources = new LinkedHashSet<>();
|
||||||
|
sourcesByEvent.values().forEach(allDistinctSources::addAll);
|
||||||
|
if (allDistinctSources.size() == 1) {
|
||||||
|
String inferredSimplifiedSource = allDistinctSources.iterator().next();
|
||||||
|
String inferredCanonicalSource = canonicalSourceBySimplified.get(inferredSimplifiedSource);
|
||||||
|
tp = tp.toBuilder().sourceState(inferredCanonicalSource).build();
|
||||||
|
triggerSource = inferredSimplifiedSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<MatchedTransition> matched = new ArrayList<>();
|
List<MatchedTransition> matched = new ArrayList<>();
|
||||||
|
|
||||||
|
if (!ambiguousSource) {
|
||||||
for (Transition t : stateMachineTransitions) {
|
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 smEventForLink = canonicalEvent(t.getEvent());
|
||||||
String smEvent = simplify(smEventRaw);
|
for (State smSourceState : t.getSourceStates()) {
|
||||||
if (smEvent.equals(triggerEvent)) {
|
String smSourceForLink = canonicalState(smSourceState);
|
||||||
// Event matches. Check source state if provided
|
String smSource = simplifySourceState(smSourceForLink);
|
||||||
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 (triggerSource == null || triggerSource.equals(smSource)) {
|
||||||
for (State smTargetState : t.getTargetStates()) {
|
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
|
||||||
String sourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
|
MatchedTransition mt = MatchedTransition.builder()
|
||||||
String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName();
|
.sourceState(smSourceForLink)
|
||||||
matched.add(MatchedTransition.builder()
|
.targetState(smSourceForLink)
|
||||||
.sourceState(sourceRaw)
|
.event(smEventForLink)
|
||||||
.targetState(targetRaw)
|
.build();
|
||||||
.event(smEventRaw)
|
if (isRoutedToCorrectMachine(
|
||||||
.build());
|
chain, result.getName(), context, stateMachineTransitions, tp,
|
||||||
|
effectiveEventTypeFqn)
|
||||||
|
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||||
|
matched.add(mt);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (State smTargetState : t.getTargetStates()) {
|
||||||
|
String targetForLink = canonicalState(smTargetState);
|
||||||
|
MatchedTransition mt = MatchedTransition.builder()
|
||||||
|
.sourceState(smSourceForLink)
|
||||||
|
.targetState(targetForLink)
|
||||||
|
.event(smEventForLink)
|
||||||
|
.build();
|
||||||
|
if (isRoutedToCorrectMachine(
|
||||||
|
chain, result.getName(), context, stateMachineTransitions, tp,
|
||||||
|
effectiveEventTypeFqn)
|
||||||
|
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||||
|
matched.add(mt);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,35 +226,223 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new CallChain with the matched transitions
|
if (ambiguousSource) {
|
||||||
CallChain updatedChain = CallChain.builder()
|
tp = tp.toBuilder().ambiguous(true).build();
|
||||||
.entryPoint(chain.getEntryPoint())
|
}
|
||||||
.methodChain(chain.getMethodChain())
|
|
||||||
.triggerPoint(chain.getTriggerPoint())
|
|
||||||
.contextMachineId(chain.getContextMachineId())
|
|
||||||
.matchedTransitions(matched)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
updatedChains.add(updatedChain);
|
LinkResolution linkResolution = CallChainLinkPolicy.resolveLinkResolution(
|
||||||
|
tp,
|
||||||
|
matched,
|
||||||
|
ambiguousSource,
|
||||||
|
effectiveEventTypeFqn,
|
||||||
|
context);
|
||||||
|
|
||||||
|
if (!matched.isEmpty()) {
|
||||||
|
CallChain newChain = chain.toBuilder()
|
||||||
|
.triggerPoint(tp)
|
||||||
|
.matchedTransitions(matched)
|
||||||
|
.linkResolution(linkResolution)
|
||||||
|
.build();
|
||||||
|
updatedChains.add(newChain);
|
||||||
|
} else {
|
||||||
|
updatedChains.add(chain.toBuilder()
|
||||||
|
.triggerPoint(tp)
|
||||||
|
.matchedTransitions(null)
|
||||||
|
.linkResolution(linkResolution)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the metadata with the new call chains
|
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
|
||||||
|
result.getMetadata().getTriggers(), result.getName(), context, stateMachineTransitions);
|
||||||
|
List<CallChain> scopedChains = MachineScopeFilter.filterCallChainsForMachine(
|
||||||
|
updatedChains, result.getName(), context, stateMachineTransitions);
|
||||||
|
scopedChains = SemanticCallChainDedup.deduplicate(scopedChains);
|
||||||
|
List<EntryPoint> scopedEntryPoints = EntryPointScopeResolver.scopeFromCallChains(
|
||||||
|
scopedChains, result.getMetadata().getEntryPoints());
|
||||||
|
|
||||||
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
|
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
|
||||||
.triggers(result.getMetadata().getTriggers())
|
.triggers(scopedTriggers)
|
||||||
.entryPoints(result.getMetadata().getEntryPoints())
|
.entryPoints(scopedEntryPoints)
|
||||||
.callChains(updatedChains)
|
.callChains(scopedChains)
|
||||||
.properties(result.getMetadata().getProperties())
|
.properties(result.getMetadata().getProperties())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
result.setMetadata(updatedMetadata);
|
result.setMetadata(updatedMetadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String simplify(String name) {
|
|
||||||
if (name == null) return "";
|
private boolean isRoutedToCorrectMachine(
|
||||||
int dot = name.lastIndexOf('.');
|
CallChain chain,
|
||||||
if (dot >= 0) {
|
String currentMachineName,
|
||||||
return name.substring(dot + 1);
|
CodebaseContext context,
|
||||||
|
List<Transition> machineTransitions,
|
||||||
|
TriggerPoint triggerPoint,
|
||||||
|
String machineEventTypeFqn) {
|
||||||
|
if (triggerPoint != null
|
||||||
|
&& machineEventTypeFqn != null
|
||||||
|
&& CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(triggerPoint, machineEventTypeFqn, context)) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return name;
|
return routingEngine.hasProvenMachineAffinity(chain, currentMachineName, context, machineTransitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRoutedToCorrectMachine(
|
||||||
|
CallChain chain, String currentMachineName, CodebaseContext context, List<Transition> machineTransitions) {
|
||||||
|
return isRoutedToCorrectMachine(
|
||||||
|
chain, currentMachineName, context, machineTransitions, chain.getTriggerPoint(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isConstraintCompatible(String constraint, String machineName) {
|
||||||
|
if (constraint == null || machineName == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||||
|
constraint, MachineDomainKeys.extractMachineDomainKey(machineName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractStateEnumType(String stateIdentifier) {
|
||||||
|
if (stateIdentifier == null || stateIdentifier.isBlank()) {
|
||||||
|
return stateIdentifier;
|
||||||
|
}
|
||||||
|
int lastDot = stateIdentifier.lastIndexOf('.');
|
||||||
|
if (lastDot <= 0) {
|
||||||
|
// Bare constant (NEW) — treat as same unknown type within one machine analysis.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return stateIdentifier.substring(0, lastDot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final java.util.Map<String, String> simplifySourceCache = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
private String simplifySourceState(String name) {
|
||||||
|
if (name == null) return null;
|
||||||
|
if (simplifySourceCache.containsKey(name)) return simplifySourceCache.get(name);
|
||||||
|
|
||||||
|
// For source states we must preserve enum type context to avoid collisions between
|
||||||
|
// different state enums that share a constant name (e.g. OrderState.NEW vs InvoiceState.NEW).
|
||||||
|
// Use EnumFormat.fn-like suffix (Type.CONST) when possible.
|
||||||
|
String simplified = name;
|
||||||
|
int lastDot = name.lastIndexOf('.');
|
||||||
|
if (lastDot > 0) {
|
||||||
|
int prevDot = name.lastIndexOf('.', lastDot - 1);
|
||||||
|
if (prevDot > 0) {
|
||||||
|
simplified = name.substring(prevDot + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
simplifySourceCache.put(name, simplified);
|
||||||
|
return simplified;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String canonicalState(State state) {
|
||||||
|
if (state == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String canonicalEvent(click.kamil.springstatemachineexporter.model.Event event) {
|
||||||
|
if (event == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the entry path still has an unbound event-carrying placeholder
|
||||||
|
* ({@code {event}}, {@code {commandKey}}, …). Resource ids like {@code {id}} do not count.
|
||||||
|
*/
|
||||||
|
private static boolean isUnresolvedExternalEndpoint(CallChain chain, TriggerPoint trigger) {
|
||||||
|
if (chain == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isUnresolvedGenericRestEndpoint(chain.getEntryPoint())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isUnresolvedMessagingPayloadEndpoint(chain, trigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isUnresolvedMessagingPayloadEndpoint(CallChain chain, TriggerPoint trigger) {
|
||||||
|
EntryPoint entryPoint = chain.getEntryPoint();
|
||||||
|
if (entryPoint == null || entryPoint.getType() == null || trigger == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean messaging = switch (entryPoint.getType()) {
|
||||||
|
case JMS, KAFKA, RABBIT, SQS, SNS -> true;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
if (!messaging) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!DynamicTriggerExpressions.isDynamic(trigger.getEvent())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Expander variants carry concrete payload bindings (same channel as REST pathBindings).
|
||||||
|
Map<String, String> bindings = chain.getPathBindings();
|
||||||
|
return bindings == null || bindings.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isUnresolvedGenericRestEndpoint(EntryPoint entryPoint) {
|
||||||
|
if (entryPoint == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hasUnboundEventPlaceholder(entryPoint.getName())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entryPoint.getMetadata() != null) {
|
||||||
|
Object path = entryPoint.getMetadata().get("path");
|
||||||
|
if (path instanceof String pathValue && hasUnboundEventPlaceholder(pathValue)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasUnboundEventPlaceholder(String pathOrName) {
|
||||||
|
if (pathOrName == null || pathOrName.isBlank() || !pathOrName.contains("{")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int start = 0;
|
||||||
|
while (true) {
|
||||||
|
int open = pathOrName.indexOf('{', start);
|
||||||
|
if (open < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int close = pathOrName.indexOf('}', open + 1);
|
||||||
|
if (close < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String placeholder = pathOrName.substring(open + 1, close).trim();
|
||||||
|
if (isEventLikePlaceholder(placeholder)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
start = close + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isEventLikePlaceholder(String placeholder) {
|
||||||
|
if (placeholder == null || placeholder.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String lower = placeholder.toLowerCase();
|
||||||
|
// Strip matrix/regex extras: event:.+ or event:.*
|
||||||
|
int colon = lower.indexOf(':');
|
||||||
|
if (colon > 0) {
|
||||||
|
lower = lower.substring(0, colon);
|
||||||
|
}
|
||||||
|
return EventCarrierPolicy.isCarrierParamName(lower)
|
||||||
|
|| EventCarrierPolicy.isMachineDiscriminatorName(lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasConcreteEventEvidence(TriggerPoint trigger) {
|
||||||
|
if (trigger == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Only a resolved enum constant on the trigger event itself bypasses unbound {event}
|
||||||
|
// templates. Polymorphic widens / single-transition inference must not count — otherwise
|
||||||
|
// every generic dispatcher template becomes RESOLVED against a one-transition machine.
|
||||||
|
return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
|
||||||
|
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalizes trigger-side enum identifiers ({@code event}, {@code polymorphicEvents},
|
||||||
|
* {@code sourceState}) using the owning machine config's {@code <State, Event>} types.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||||
|
if (result.getMetadata() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StateMachineTypeResolver.MachineTypes machineTypes = JsonExportContextFactory.resolveMachineTypes(
|
||||||
|
result.getName(), result.getStateTypeFqn(), result.getEventTypeFqn(), context);
|
||||||
|
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<TriggerPoint> triggers = result.getMetadata().getTriggers();
|
||||||
|
List<TriggerPoint> canonicalTriggers = triggers == null ? null : triggers.stream()
|
||||||
|
.map(trigger -> MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
|
||||||
|
trigger, machineTypes, context))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
List<CallChain> callChains = result.getMetadata().getCallChains();
|
||||||
|
List<CallChain> canonicalChains = callChains == null ? null : callChains.stream()
|
||||||
|
.map(chain -> {
|
||||||
|
if (chain.getTriggerPoint() == null) {
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
TriggerPoint expanded = MachineEnumCanonicalizer.prepareCallChainTrigger(
|
||||||
|
chain.getTriggerPoint(),
|
||||||
|
machineTypes,
|
||||||
|
context,
|
||||||
|
result.getTransitions(),
|
||||||
|
chain.getPathBindings());
|
||||||
|
TriggerPoint canonical = expanded.getEventTypeFqn() == null && expanded.getStateTypeFqn() == null
|
||||||
|
? MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(
|
||||||
|
expanded, machineTypes, context)
|
||||||
|
: MachineEnumCanonicalizer.canonicalizeTriggerPoint(
|
||||||
|
expanded, machineTypes, context);
|
||||||
|
return chain.toBuilder().triggerPoint(canonical).build();
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (canonicalTriggers == null && canonicalChains == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Canonicalized triggers for {}", result.getName());
|
||||||
|
|
||||||
|
result.setMetadata(CodebaseMetadata.builder()
|
||||||
|
.triggers(canonicalTriggers != null ? canonicalTriggers : triggers)
|
||||||
|
.entryPoints(result.getMetadata().getEntryPoints())
|
||||||
|
.callChains(canonicalChains != null ? canonicalChains : callChains)
|
||||||
|
.properties(result.getMetadata().getProperties())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@ package click.kamil.springstatemachineexporter.analysis.enricher;
|
|||||||
|
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class TriggerEnricher implements AnalysisEnricher {
|
public class TriggerEnricher implements AnalysisEnricher {
|
||||||
@@ -15,10 +18,14 @@ public class TriggerEnricher implements AnalysisEnricher {
|
|||||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||||
log.info("Enriching {} with triggers", result.getName());
|
log.info("Enriching {} with triggers", result.getName());
|
||||||
|
|
||||||
List<TriggerPoint> triggers = intelligence.findTriggerPoints();
|
StateMachineTypeResolver.MachineTypes machineTypes =
|
||||||
|
StateMachineTypeResolver.resolveTypes(result.getName(), context);
|
||||||
|
|
||||||
|
List<TriggerPoint> triggers = intelligence.findTriggerPoints().stream()
|
||||||
|
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
triggers = MachineScopeFilter.filterTriggersForMachine(triggers, result.getName(), context);
|
||||||
|
|
||||||
// Initially, we just add all triggers found in the codebase to the metadata.
|
|
||||||
// In later steps of Phase 2, we will filter them by State Machine ID.
|
|
||||||
result.addMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
|
result.addMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
|
||||||
.triggers(triggers)
|
.triggers(triggers)
|
||||||
.build());
|
.build());
|
||||||
|
|||||||
@@ -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,173 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.DynamicTriggerExpressions;
|
||||||
|
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 = DynamicTriggerExpressions.isDynamic(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 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,186 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Event;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||||
|
|
||||||
|
private final CodebaseContext context;
|
||||||
|
|
||||||
|
public StrictFqnMatchingEngine() {
|
||||||
|
this(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public StrictFqnMatchingEngine(CodebaseContext context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint) {
|
||||||
|
if (stateMachineEvent == null || triggerPoint == null || triggerPoint.getEvent() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers
|
||||||
|
.isLifecycle(triggerPoint.getEvent())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
|
||||||
|
String rawTriggerEvent = triggerPoint.getEvent();
|
||||||
|
|
||||||
|
if (triggerPoint.isExternal()) {
|
||||||
|
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null
|
||||||
|
? triggerPoint.getPolymorphicEvents()
|
||||||
|
: java.util.Collections.emptyList();
|
||||||
|
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawTriggerEvent != null && rawTriggerEvent.startsWith("<SYMBOLIC: ") && rawTriggerEvent.endsWith(".*>")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
|
||||||
|
|
||||||
|
for (String pe : polyEvents) {
|
||||||
|
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String eventTypeFqn = triggerPoint.getEventTypeFqn();
|
||||||
|
if (pe.contains(".") && MachineEnumCanonicalizer.isStringOrPrimitiveEventType(eventTypeFqn)) {
|
||||||
|
eventTypeFqn = null;
|
||||||
|
}
|
||||||
|
if (matchEventNames(pe, smEventRaw, eventTypeFqn)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!pe.contains(".") && smEventRaw.contains(".")) {
|
||||||
|
String smConst = constantName(smEventRaw);
|
||||||
|
if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null
|
||||||
|
&& !MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerPoint.getEventTypeFqn())) {
|
||||||
|
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
||||||
|
return typesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawTriggerEvent != null && rawTriggerEvent.contains(".valueOf(")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
|
||||||
|
if (triggerPoint.getEventTypeFqn() != null && !MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerPoint.getEventTypeFqn())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Getter/method-call expressions (e.g. richEvent.getId(), getType()) without resolved
|
||||||
|
// polymorphic events must not wildcard-match bare or enum transition events.
|
||||||
|
if (rawTriggerEvent.contains(".") || rawTriggerEvent.endsWith("()")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (triggerPoint.getEventTypeFqn() != null && MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerPoint.getEventTypeFqn())) {
|
||||||
|
return !smEventRaw.contains(".");
|
||||||
|
}
|
||||||
|
return !smEventRaw.contains(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = constantName(triggerEvent);
|
||||||
|
String smConst = constantName(smEvent);
|
||||||
|
|
||||||
|
if (!tConst.equals(smConst)) return false;
|
||||||
|
|
||||||
|
// String-typed triggers may match configured string transition events by constant name.
|
||||||
|
if (MachineEnumCanonicalizer.isStringOrPrimitiveEventType(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
|
||||||
|
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
|
||||||
|
return MachineEnumCanonicalizer.isStringOrPrimitiveEventType(smEnumType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger scraped as String.CONST while config still stores the bare literal constant.
|
||||||
|
if (MachineEnumCanonicalizer.isStringOrPrimitiveEventType(eventTypeFqn) && triggerEvent.contains(".") && !smEvent.contains(".")) {
|
||||||
|
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
||||||
|
return MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerEnumType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Qualified trigger against bare SM event without eventTypeFqn: only when the qualifier
|
||||||
|
// itself is a string/primitive type (not a real enum like OrderEvent.PAY ↔ PAY).
|
||||||
|
if (eventTypeFqn == null && !smEvent.contains(".") && triggerEvent.contains(".")) {
|
||||||
|
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
||||||
|
return MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerEnumType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (smEvent.contains(".")) {
|
||||||
|
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
|
||||||
|
|
||||||
|
if (eventTypeFqn != null) {
|
||||||
|
return typesMatch(eventTypeFqn, smEnumType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (triggerEvent.contains(".")) {
|
||||||
|
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
||||||
|
return typesMatch(triggerEnumType, smEnumType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare constant name (e.g. after Enum.name()) with no type context must not match every enum
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both sides are unqualified identifiers (string/primitive events)
|
||||||
|
return !triggerEvent.contains(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean typesMatch(String type1, String type2) {
|
||||||
|
if (MachineEnumCanonicalizer.enumTypesMatch(type1, type2, context)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (type1 != null && type2 != null) {
|
||||||
|
String machineFqn = type1.contains(".") ? type1 : type2;
|
||||||
|
String candidate = type1.contains(".") ? type2 : type1;
|
||||||
|
return MachineEnumCanonicalizer.enumTypesMatchForMachine(machineFqn, candidate, context);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String constantName(String event) {
|
||||||
|
return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 constantPart = eventStr.substring(eventStr.lastIndexOf('.') + 1);
|
||||||
|
if (!constantPart.isEmpty() && Character.isUpperCase(constantPart.charAt(0))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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,20 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface BeanResolutionEngine {
|
||||||
|
boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context);
|
||||||
|
|
||||||
|
default boolean hasProvenMachineAffinity(
|
||||||
|
CallChain chain,
|
||||||
|
String currentMachineName,
|
||||||
|
CodebaseContext context,
|
||||||
|
List<Transition> machineTransitions) {
|
||||||
|
return isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.Annotation;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps {@code @EnableStateMachine(name=…)} bean names to configuration class FQNs.
|
||||||
|
*/
|
||||||
|
final class EnableStateMachineBeanRouting {
|
||||||
|
|
||||||
|
private EnableStateMachineBeanRouting() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static Boolean matchesNamedBean(String beanName, String currentMachineConfigFqn, CodebaseContext context) {
|
||||||
|
String normalized = stripQuotes(beanName);
|
||||||
|
if (normalized == null || normalized.isEmpty() || context == null || currentMachineConfigFqn == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String matchingConfig = findConfigFqnByBeanName(normalized, context);
|
||||||
|
if (matchingConfig == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return currentMachineConfigFqn.equals(matchingConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String findConfigFqnByBeanName(String beanName, CodebaseContext context) {
|
||||||
|
for (TypeDeclaration configType : context.findEntryPointClasses(java.util.List.of("EnableStateMachine"))) {
|
||||||
|
String configFqn = context.getFqn(configType);
|
||||||
|
String configuredName = extractEnableStateMachineName(configType);
|
||||||
|
if (configuredName == null || configuredName.isEmpty()) {
|
||||||
|
configuredName = defaultBeanName(configType.getName().getIdentifier());
|
||||||
|
}
|
||||||
|
if (beanName.equals(configuredName)) {
|
||||||
|
return configFqn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String extractEnableStateMachineName(TypeDeclaration configType) {
|
||||||
|
for (Object modifierObj : configType.modifiers()) {
|
||||||
|
if (!(modifierObj instanceof Annotation annotation)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!annotation.getTypeName().toString().endsWith("EnableStateMachine")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String name = AstUtils.extractAnnotationMember(annotation, "name");
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
return stripQuotes(name);
|
||||||
|
}
|
||||||
|
String value = AstUtils.extractAnnotationMember(annotation, "value");
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return stripQuotes(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String defaultBeanNameFromFqn(String fqn) {
|
||||||
|
if (fqn == null || !fqn.contains(".")) {
|
||||||
|
return fqn;
|
||||||
|
}
|
||||||
|
String simple = fqn.substring(fqn.lastIndexOf('.') + 1);
|
||||||
|
return defaultBeanName(simple);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String defaultBeanName(String simpleClassName) {
|
||||||
|
if (simpleClassName == null || simpleClassName.isEmpty()) {
|
||||||
|
return simpleClassName;
|
||||||
|
}
|
||||||
|
return Character.toLowerCase(simpleClassName.charAt(0)) + simpleClassName.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripQuotes(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
if (trimmed.length() >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
|
||||||
|
return trimmed.substring(1, trimmed.length() - 1);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
|
||||||
|
|
||||||
|
private enum DistinctTypeAffinity {
|
||||||
|
FOR_MACHINE,
|
||||||
|
AGAINST_MACHINE,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasProvenMachineAffinity(
|
||||||
|
CallChain chain,
|
||||||
|
String currentMachineName,
|
||||||
|
CodebaseContext context,
|
||||||
|
List<Transition> machineTransitions) {
|
||||||
|
if (chain == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (currentMachineName == null) {
|
||||||
|
return isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
|
||||||
|
|
||||||
|
String explicitTarget = evidence.explicitBeanName();
|
||||||
|
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null) {
|
||||||
|
Boolean namedTarget = EnableStateMachineBeanRouting.matchesNamedBean(
|
||||||
|
explicitTarget, currentMachineName, context);
|
||||||
|
if (namedTarget != null) {
|
||||||
|
return namedTarget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chain.getTriggerPoint() != null && context != null) {
|
||||||
|
DistinctTypeAffinity typeAffinity =
|
||||||
|
resolveDistinctTypeAffinity(chain.getTriggerPoint(), currentMachineName, context);
|
||||||
|
if (typeAffinity == DistinctTypeAffinity.FOR_MACHINE) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeAffinity == DistinctTypeAffinity.AGAINST_MACHINE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context == null || isSingleStateMachine(context)) {
|
||||||
|
return isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
TriggerPoint trigger = chain.getTriggerPoint();
|
||||||
|
if (trigger != null
|
||||||
|
&& SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)
|
||||||
|
&& matchesConfiguredTransitionEvent(trigger, machineTransitions, context)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
|
||||||
|
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
|
||||||
|
|
||||||
|
String explicitTarget = evidence.explicitBeanName();
|
||||||
|
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null && currentMachineName != null) {
|
||||||
|
Boolean namedTarget = EnableStateMachineBeanRouting.matchesNamedBean(
|
||||||
|
explicitTarget, currentMachineName, context);
|
||||||
|
if (namedTarget != null) {
|
||||||
|
return namedTarget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean hasExplicitBeanTarget = explicitTarget != null && !explicitTarget.isEmpty();
|
||||||
|
|
||||||
|
// Precise FQN Type argument match from JDT bindings at sendEvent site
|
||||||
|
if (!hasExplicitBeanTarget && chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
|
||||||
|
String triggerEventFqn = concreteTriggerType(chain.getTriggerPoint().getEventTypeFqn());
|
||||||
|
String triggerStateFqn = concreteTriggerType(chain.getTriggerPoint().getStateTypeFqn());
|
||||||
|
if (triggerEventFqn != null || triggerStateFqn != null) {
|
||||||
|
String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context);
|
||||||
|
String machineStateFqn = machineTypes[0];
|
||||||
|
String machineEventFqn = machineTypes[1];
|
||||||
|
boolean matched = false;
|
||||||
|
boolean mismatched = false;
|
||||||
|
|
||||||
|
if (triggerEventFqn != null && machineEventFqn != null) {
|
||||||
|
if (typesEquivalent(triggerEventFqn, machineEventFqn)) {
|
||||||
|
matched = true;
|
||||||
|
} else if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
|
||||||
|
mismatched = true;
|
||||||
|
} else if (isSingleStateMachine(context)
|
||||||
|
&& (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) {
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (triggerStateFqn != null && machineStateFqn != null) {
|
||||||
|
if (typesEquivalent(triggerStateFqn, machineStateFqn)) {
|
||||||
|
matched = true;
|
||||||
|
} else if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
|
||||||
|
mismatched = true;
|
||||||
|
} else if (isSingleStateMachine(context)
|
||||||
|
&& (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) {
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched) {
|
||||||
|
if (!isAmbiguousSharedGenericMatch(
|
||||||
|
triggerEventFqn, triggerStateFqn, machineStateFqn, machineEventFqn)
|
||||||
|
|| isSingleStateMachine(context)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mismatched) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Fail closed only when both trigger and machine generic types were resolved but
|
||||||
|
// could not be reconciled. When machine config AST does not expose type args (stub
|
||||||
|
// config classes in tests, generated configs), fall through to single-SM / package routing.
|
||||||
|
boolean machineTypesKnown = machineEventFqn != null || machineStateFqn != null;
|
||||||
|
if (machineTypesKnown) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-null-but-ambiguous type vars ("E"/"S") are ignored above — fall through so
|
||||||
|
// polymorphicEvents / shared-infrastructure / package evidence can decide.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSingleStateMachine(context) && isAmbiguousSharedGenericTrigger(chain.getTriggerPoint())) {
|
||||||
|
Boolean injectionMatch = SpringInjectionRouting.matchesMachineConfig(
|
||||||
|
chain.getTriggerPoint(), currentMachineName, context);
|
||||||
|
if (injectionMatch != null) {
|
||||||
|
return injectionMatch;
|
||||||
|
}
|
||||||
|
// Ambiguous generics without injection evidence: do not hard-fail here when the trigger
|
||||||
|
// still has concrete polymorphicEvents — callers with transition lists use
|
||||||
|
// hasProvenMachineAffinity + SharedServiceRoutingPolicy for that case.
|
||||||
|
if (!hasConcretePolymorphicEvents(chain.getTriggerPoint())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Boolean packageMatch = PackageNameRoutingHeuristics.matches(chain, currentMachineName, explicitTarget);
|
||||||
|
if (packageMatch != null) {
|
||||||
|
return packageMatch;
|
||||||
|
}
|
||||||
|
return context == null || isSingleStateMachine(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DistinctTypeAffinity resolveDistinctTypeAffinity(
|
||||||
|
TriggerPoint trigger, String machineName, CodebaseContext context) {
|
||||||
|
String triggerEventFqn = concreteTriggerType(trigger.getEventTypeFqn());
|
||||||
|
String triggerStateFqn = concreteTriggerType(trigger.getStateTypeFqn());
|
||||||
|
if (triggerEventFqn == null && triggerStateFqn == null) {
|
||||||
|
return DistinctTypeAffinity.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] machineTypes = StateMachineTypeResolver.resolve(machineName, context);
|
||||||
|
String machineStateFqn = machineTypes[0];
|
||||||
|
String machineEventFqn = machineTypes[1];
|
||||||
|
|
||||||
|
boolean matched = false;
|
||||||
|
boolean mismatched = false;
|
||||||
|
|
||||||
|
if (triggerEventFqn != null && machineEventFqn != null) {
|
||||||
|
if (typesEquivalent(triggerEventFqn, machineEventFqn)
|
||||||
|
&& !isAmbiguousSharedGenericType(triggerEventFqn)) {
|
||||||
|
matched = true;
|
||||||
|
} else if (isDistinctTypeMismatched(triggerEventFqn, machineEventFqn)) {
|
||||||
|
mismatched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (triggerStateFqn != null && machineStateFqn != null) {
|
||||||
|
if (typesEquivalent(triggerStateFqn, machineStateFqn)
|
||||||
|
&& !isAmbiguousSharedGenericType(triggerStateFqn)) {
|
||||||
|
matched = true;
|
||||||
|
} else if (isDistinctTypeMismatched(triggerStateFqn, machineStateFqn)) {
|
||||||
|
mismatched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
return DistinctTypeAffinity.FOR_MACHINE;
|
||||||
|
}
|
||||||
|
if (mismatched) {
|
||||||
|
return DistinctTypeAffinity.AGAINST_MACHINE;
|
||||||
|
}
|
||||||
|
return DistinctTypeAffinity.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Concrete machine types only — unbound {@code E}/{@code S} and erasure placeholders → null. */
|
||||||
|
private static String concreteTriggerType(String typeFqn) {
|
||||||
|
return SharedServiceRoutingPolicy.isConcreteMachineType(typeFqn) ? typeFqn : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasConcretePolymorphicEvents(TriggerPoint trigger) {
|
||||||
|
if (trigger == null || trigger.getPolymorphicEvents() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String event : trigger.getPolymorphicEvents()) {
|
||||||
|
if (event == null || event.isBlank() || event.startsWith("<SYMBOLIC:")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.startsWith("ENUM_SET:")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesConfiguredTransitionEvent(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
List<Transition> machineTransitions,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (trigger == null || machineTransitions == null || machineTransitions.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine(context);
|
||||||
|
for (Transition transition : machineTransitions) {
|
||||||
|
if (transition.getEvent() != null && matchingEngine.matches(transition.getEvent(), trigger)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAmbiguousSharedGenericMatch(
|
||||||
|
String triggerEventFqn,
|
||||||
|
String triggerStateFqn,
|
||||||
|
String machineStateFqn,
|
||||||
|
String machineEventFqn) {
|
||||||
|
boolean eventShared = triggerEventFqn != null
|
||||||
|
&& machineEventFqn != null
|
||||||
|
&& typesEquivalent(triggerEventFqn, machineEventFqn)
|
||||||
|
&& isAmbiguousSharedGenericType(triggerEventFqn);
|
||||||
|
boolean stateShared = triggerStateFqn != null
|
||||||
|
&& machineStateFqn != null
|
||||||
|
&& typesEquivalent(triggerStateFqn, machineStateFqn)
|
||||||
|
&& isAmbiguousSharedGenericType(triggerStateFqn);
|
||||||
|
return eventShared || stateShared;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAmbiguousSharedGenericType(String typeFqn) {
|
||||||
|
if (typeFqn == null || typeFqn.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !SharedServiceRoutingPolicy.isConcreteMachineType(typeFqn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isErasedOrOpaqueType(String typeFqn) {
|
||||||
|
if (typeFqn == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String erased = eraseGenerics(typeFqn);
|
||||||
|
return "java.lang.Object".equals(erased) || "Object".equals(erased)
|
||||||
|
|| "java.io.Serializable".equals(erased) || "Serializable".equals(erased)
|
||||||
|
|| (erased.length() == 1 && Character.isUpperCase(erased.charAt(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDistinctTypeMismatched(String type1, String type2) {
|
||||||
|
if (isAmbiguousSharedGenericType(type1) || isAmbiguousSharedGenericType(type2)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isTypeMismatched(type1, type2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isStringType(String typeFqn) {
|
||||||
|
return MachineEnumCanonicalizer.isJavaStringType(typeFqn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Boolean resolveNamedBeanTarget(String beanName, String currentMachineName, CodebaseContext context) {
|
||||||
|
return EnableStateMachineBeanRouting.matchesNamedBean(beanName, currentMachineName, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAmbiguousSharedGenericTrigger(TriggerPoint trigger) {
|
||||||
|
if (trigger == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isAmbiguousSharedGenericType(trigger.getEventTypeFqn())
|
||||||
|
|| isAmbiguousSharedGenericType(trigger.getStateTypeFqn());
|
||||||
|
}
|
||||||
|
|
||||||
|
private int countStateMachines(CodebaseContext context) {
|
||||||
|
if (context == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
Set<String> machines = new HashSet<>();
|
||||||
|
for (TypeDeclaration td : context.findEntryPointClasses(
|
||||||
|
java.util.List.of("EnableStateMachineFactory", "EnableStateMachine"))) {
|
||||||
|
String fqn = context.getFqn(td);
|
||||||
|
if (fqn != null) {
|
||||||
|
machines.add(fqn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (TypeDeclaration td : context.getTypeDeclarations()) {
|
||||||
|
if (td == null || td.isInterface()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (context.extendsStateMachineConfigurerAdapter(td)) {
|
||||||
|
String fqn = context.getFqn(td);
|
||||||
|
if (fqn != null) {
|
||||||
|
machines.add(fqn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!machines.isEmpty()) {
|
||||||
|
return machines.size();
|
||||||
|
}
|
||||||
|
return context.findBeanMethodsReturning(
|
||||||
|
java.util.Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory")).size();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 boolean typesEquivalent(String type1, String type2) {
|
||||||
|
if (type1 == null || type2 == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String erased1 = normalizePrimitiveFqn(eraseGenerics(type1));
|
||||||
|
String erased2 = normalizePrimitiveFqn(eraseGenerics(type2));
|
||||||
|
return erased1.equals(erased2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizePrimitiveFqn(String type) {
|
||||||
|
if (type == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return switch (type) {
|
||||||
|
case "java.lang.String" -> "String";
|
||||||
|
case "java.lang.Object" -> "Object";
|
||||||
|
case "java.io.Serializable" -> "Serializable";
|
||||||
|
default -> type;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isTypeMismatched(String type1, String type2) {
|
||||||
|
if (type1 == null || type2 == null) return false;
|
||||||
|
type1 = eraseGenerics(type1);
|
||||||
|
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) {
|
||||||
|
return countStateMachines(context) <= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source-derived evidence used to decide whether a call chain belongs to a state-machine export.
|
||||||
|
*/
|
||||||
|
public record MachineRoutingEvidence(
|
||||||
|
String explicitBeanName,
|
||||||
|
String stateMachineId,
|
||||||
|
String eventTypeFqn,
|
||||||
|
String stateTypeFqn,
|
||||||
|
String triggerClassFqn,
|
||||||
|
String triggerMethod,
|
||||||
|
Integer triggerLine) {
|
||||||
|
|
||||||
|
public static MachineRoutingEvidence from(CallChain chain) {
|
||||||
|
if (chain == null) {
|
||||||
|
return new MachineRoutingEvidence(null, null, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
TriggerPoint trigger = chain.getTriggerPoint();
|
||||||
|
String explicit = chain.getContextMachineId();
|
||||||
|
String stateMachineId = null;
|
||||||
|
Integer line = null;
|
||||||
|
if (trigger != null) {
|
||||||
|
if (explicit == null) {
|
||||||
|
explicit = trigger.getStateMachineId();
|
||||||
|
}
|
||||||
|
stateMachineId = trigger.getStateMachineId();
|
||||||
|
if (trigger.getLineNumber() > 0) {
|
||||||
|
line = trigger.getLineNumber();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new MachineRoutingEvidence(
|
||||||
|
explicit,
|
||||||
|
stateMachineId,
|
||||||
|
trigger != null ? trigger.getEventTypeFqn() : null,
|
||||||
|
trigger != null ? trigger.getStateTypeFqn() : null,
|
||||||
|
trigger != null ? trigger.getClassName() : null,
|
||||||
|
trigger != null ? trigger.getMethodName() : null,
|
||||||
|
line);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy package-prefix and domain-name matching for single-machine / null-context fallback routing.
|
||||||
|
*/
|
||||||
|
final class PackageNameRoutingHeuristics {
|
||||||
|
|
||||||
|
private static final Set<String> GENERIC_CLASS_PREFIXES = Set.of(
|
||||||
|
"state", "statemachine", "config", "configuration", "adapter",
|
||||||
|
"enterprise", "app", "global", "core", "project", "main",
|
||||||
|
"base", "common", "shared");
|
||||||
|
|
||||||
|
private static final Set<String> GENERIC_PACKAGE_SEGMENTS = Set.of(
|
||||||
|
"config", "configuration", "service", "services", "impl", "api", "web",
|
||||||
|
"controller", "controllers", "messaging", "listener", "listeners",
|
||||||
|
"repository", "repositories", "db", "model", "models", "domain",
|
||||||
|
"constants", "utils", "helper", "helpers", "event", "events",
|
||||||
|
"state", "states", "transition", "transitions", "shared", "common",
|
||||||
|
"global", "core", "base", "main", "app", "enterprise");
|
||||||
|
|
||||||
|
private PackageNameRoutingHeuristics() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true when any class in the call chain carries a vertical domain prefix or package segment
|
||||||
|
*/
|
||||||
|
static boolean hasVerticalDomainOwnership(CallChain chain) {
|
||||||
|
if (chain == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
|
||||||
|
if (classHasVerticalDomainOwnership(chain.getTriggerPoint().getClassName())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chain.getMethodChain() != null) {
|
||||||
|
for (String method : chain.getMethodChain()) {
|
||||||
|
if (classHasVerticalDomainOwnership(getClassNameOnly(method))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true/false if package heuristics decide, null if inconclusive
|
||||||
|
*/
|
||||||
|
static Boolean matches(CallChain chain, String currentMachineName, String explicitBeanTarget) {
|
||||||
|
if (chain == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean hasExplicitBeanTarget = explicitBeanTarget != null && !explicitBeanTarget.isEmpty();
|
||||||
|
String targetVar = hasExplicitBeanTarget ? explicitBeanTarget : chain.getContextMachineId();
|
||||||
|
if (targetVar == null && chain.getTriggerPoint() != null) {
|
||||||
|
targetVar = chain.getTriggerPoint().getStateMachineId();
|
||||||
|
}
|
||||||
|
|
||||||
|
String simplifiedMachineName = currentMachineName != null
|
||||||
|
? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (targetVar != null && !targetVar.isEmpty()) {
|
||||||
|
targetVar = targetVar.toLowerCase();
|
||||||
|
if (targetVar.endsWith("statemachine")) {
|
||||||
|
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
|
||||||
|
if (!prefix.isEmpty()) {
|
||||||
|
if (simplifiedMachineName.contains(prefix)) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (simplifiedMachineName.contains(targetVar)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) {
|
||||||
|
String smPackage = getPackageName(currentMachineName);
|
||||||
|
String smSimple = getSimpleClassName(currentMachineName);
|
||||||
|
String smPrefix = getFirstCamelCaseWord(smSimple);
|
||||||
|
|
||||||
|
boolean hasPositiveMatch = false;
|
||||||
|
boolean hasStrongMismatch = false;
|
||||||
|
|
||||||
|
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
|
||||||
|
String chainClass = chain.getTriggerPoint().getClassName();
|
||||||
|
String chainPackage = getPackageName(chainClass);
|
||||||
|
String chainSimple = getSimpleClassName(chainClass);
|
||||||
|
String chainPrefix = getFirstCamelCaseWord(chainSimple);
|
||||||
|
|
||||||
|
if (hasMatchingDomainPrefix(smPrefix, chainPrefix)) {
|
||||||
|
hasPositiveMatch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
|
||||||
|
hasPositiveMatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String method : chain.getMethodChain()) {
|
||||||
|
String chainClass = getClassNameOnly(method);
|
||||||
|
String chainPackage = getPackageName(chainClass);
|
||||||
|
String chainSimple = getSimpleClassName(chainClass);
|
||||||
|
String chainPrefix = getFirstCamelCaseWord(chainSimple);
|
||||||
|
|
||||||
|
if (hasMatchingDomainPrefix(smPrefix, chainPrefix)) {
|
||||||
|
hasPositiveMatch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
|
||||||
|
hasPositiveMatch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix, smSimple, chainSimple)) {
|
||||||
|
hasStrongMismatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPositiveMatch) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (hasStrongMismatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
|
||||||
|
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (smPackage.equals(chainPackage)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chainPackage.startsWith(smPackage + ".") && !smPackage.startsWith(chainPackage + ".")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] p1 = smPackage.split("\\.");
|
||||||
|
String[] p2 = chainPackage.split("\\.");
|
||||||
|
int matchIdx = -1;
|
||||||
|
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
|
||||||
|
if (p1[i].equals(p2[i])) {
|
||||||
|
matchIdx = i;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (smPrefix != null && matchIdx >= 0) {
|
||||||
|
String lowerPrefix = smPrefix.toLowerCase();
|
||||||
|
for (int i = 0; i <= matchIdx; i++) {
|
||||||
|
if (p1[i].toLowerCase().contains(lowerPrefix) || lowerPrefix.contains(p1[i].toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isDomainMismatch(
|
||||||
|
String smPackage,
|
||||||
|
String chainPackage,
|
||||||
|
String smPrefix,
|
||||||
|
String chainPrefix,
|
||||||
|
String smSimple,
|
||||||
|
String chainSimple) {
|
||||||
|
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (smPrefix == null || chainPrefix == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String smLower = smPrefix.toLowerCase();
|
||||||
|
String chainLower = chainPrefix.toLowerCase();
|
||||||
|
|
||||||
|
if (isGenericClassPrefix(smLower) || isGenericClassPrefix(chainLower)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!smLower.equals(chainLower)) {
|
||||||
|
if (chainPackage.toLowerCase().contains(smLower) || smPackage.toLowerCase().contains(chainLower)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] p1 = smPackage.split("\\.");
|
||||||
|
String[] p2 = chainPackage.split("\\.");
|
||||||
|
int matchIdx = -1;
|
||||||
|
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
|
||||||
|
if (p1[i].equals(p2[i])) {
|
||||||
|
matchIdx = i;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchIdx >= 1) {
|
||||||
|
boolean onlyGenericDivergence = true;
|
||||||
|
for (int i = matchIdx + 1; i < p1.length; i++) {
|
||||||
|
if (!isGenericPackageSegment(p1[i])) {
|
||||||
|
onlyGenericDivergence = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onlyGenericDivergence) {
|
||||||
|
for (int i = matchIdx + 1; i < p2.length; i++) {
|
||||||
|
if (!isGenericPackageSegment(p2[i])) {
|
||||||
|
onlyGenericDivergence = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onlyGenericDivergence) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getPackageName(String fqn) {
|
||||||
|
if (fqn == null || !fqn.contains(".")) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return fqn.substring(0, fqn.lastIndexOf('.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getSimpleClassName(String fqn) {
|
||||||
|
if (fqn == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!fqn.contains(".")) {
|
||||||
|
return fqn;
|
||||||
|
}
|
||||||
|
return fqn.substring(fqn.lastIndexOf('.') + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getClassNameOnly(String methodFqn) {
|
||||||
|
if (methodFqn == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String clean = methodFqn;
|
||||||
|
if (clean.contains("(")) {
|
||||||
|
clean = clean.substring(0, clean.indexOf('('));
|
||||||
|
}
|
||||||
|
if (clean.contains(".")) {
|
||||||
|
clean = clean.substring(0, clean.lastIndexOf('.'));
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getFirstCamelCaseWord(String simpleName) {
|
||||||
|
if (simpleName == null || simpleName.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String[] words = simpleName.split("(?<!^)(?=[A-Z])");
|
||||||
|
if (words.length > 0) {
|
||||||
|
return words[0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean classHasVerticalDomainOwnership(String classFqn) {
|
||||||
|
if (classFqn == null || classFqn.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String prefix = getFirstCamelCaseWord(getSimpleClassName(classFqn));
|
||||||
|
if (prefix != null && !isGenericClassPrefix(prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return hasVerticalPackageSegment(getPackageName(classFqn));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasVerticalPackageSegment(String packageName) {
|
||||||
|
if (packageName == null || packageName.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String[] segments = packageName.split("\\.");
|
||||||
|
for (int i = 2; i < segments.length; i++) {
|
||||||
|
if (!isGenericPackageSegment(segments[i])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasMatchingDomainPrefix(String smPrefix, String chainPrefix) {
|
||||||
|
return smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isGenericClassPrefix(String prefix) {
|
||||||
|
return prefix != null && GENERIC_CLASS_PREFIXES.contains(prefix.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isGenericPackageSegment(String segment) {
|
||||||
|
return segment != null && GENERIC_PACKAGE_SEGMENTS.contains(segment.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether a call chain is provably shared infrastructure that may intentionally
|
||||||
|
* attach to multiple state machines when they share the same transition event.
|
||||||
|
* <p>
|
||||||
|
* Shared infrastructure requires source-derived evidence only: no <em>concrete</em> machine enum
|
||||||
|
* types at the sendEvent site and no vertical domain ownership in the call chain classes/packages.
|
||||||
|
* Unbound generic parameters ({@code StateMachine<S,E>} on a shared base class) and erasure
|
||||||
|
* ({@code Object}) are treated as absent — not as proven types. Event-name matching alone is never
|
||||||
|
* sufficient outside this policy.
|
||||||
|
*/
|
||||||
|
public final class SharedServiceRoutingPolicy {
|
||||||
|
|
||||||
|
private SharedServiceRoutingPolicy() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true when the chain may multi-attach to every machine that shares its transition event
|
||||||
|
*/
|
||||||
|
public static boolean isProvablySharedInfrastructure(CallChain chain) {
|
||||||
|
if (chain == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
|
||||||
|
if (isConcreteMachineType(evidence.eventTypeFqn()) || isConcreteMachineType(evidence.stateTypeFqn())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !PackageNameRoutingHeuristics.hasVerticalDomainOwnership(chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a trigger type FQN is a real state/event enum (or similar), not an unbound
|
||||||
|
* type variable / erasure placeholder from a shared generic base class.
|
||||||
|
*/
|
||||||
|
public static boolean isConcreteMachineType(String typeFqn) {
|
||||||
|
if (typeFqn == null || typeFqn.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String erased = typeFqn;
|
||||||
|
int generic = erased.indexOf('<');
|
||||||
|
if (generic >= 0) {
|
||||||
|
erased = erased.substring(0, generic);
|
||||||
|
}
|
||||||
|
if (erased.length() == 1 && Character.isUpperCase(erased.charAt(0))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !"java.io.Serializable".equals(erased) && !"Serializable".equals(erased)
|
||||||
|
&& !isNonConcreteMachineErasure(erased);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer a concrete trigger type; otherwise adopt the machine type only when it is also
|
||||||
|
* concrete. Non-concrete machine types ({@code String}/{@code Object}/type-vars) must not be
|
||||||
|
* stamped onto triggers — that makes multi-machine affinity treat them as ambiguous shared
|
||||||
|
* generics and fail-closed, wiping package-proven String-machine chains.
|
||||||
|
*/
|
||||||
|
public static String concreteOrFallback(String triggerTypeFqn, String machineTypeFqn) {
|
||||||
|
if (isConcreteMachineType(triggerTypeFqn)) {
|
||||||
|
return triggerTypeFqn;
|
||||||
|
}
|
||||||
|
if (isConcreteMachineType(machineTypeFqn)) {
|
||||||
|
return machineTypeFqn;
|
||||||
|
}
|
||||||
|
return triggerTypeFqn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isNonConcreteMachineErasure(String erased) {
|
||||||
|
return "java.lang.Object".equals(erased) || "Object".equals(erased)
|
||||||
|
|| "java.lang.String".equals(erased) || "String".equals(erased);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.*;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves which {@code @EnableStateMachine} configuration owns a trigger's {@code StateMachine} receiver
|
||||||
|
* using Spring injection analysis (JDT bindings), not package-name guessing.
|
||||||
|
*/
|
||||||
|
final class SpringInjectionRouting {
|
||||||
|
|
||||||
|
private static final Set<String> TRIGGER_METHODS = Set.of(
|
||||||
|
"sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger");
|
||||||
|
|
||||||
|
private SpringInjectionRouting() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static Boolean matchesMachineConfig(TriggerPoint trigger, String machineConfigFqn, CodebaseContext context) {
|
||||||
|
if (trigger == null || machineConfigFqn == null || context == null || !context.isResolveBindings()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String configFqn = resolveConfigurationFqn(trigger, context);
|
||||||
|
if (configFqn == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return machineConfigFqn.equals(configFqn);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String resolveConfigurationFqn(TriggerPoint trigger, CodebaseContext context) {
|
||||||
|
if (trigger == null || trigger.getClassName() == null || trigger.getMethodName() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!context.isResolveBindings()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
InjectionPointAnalyzer analyzer = createAnalyzer(context);
|
||||||
|
TypeDeclaration type = context.getTypeDeclaration(trigger.getClassName());
|
||||||
|
if (type == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MethodDeclaration method = context.findMethodDeclaration(type, trigger.getMethodName(), true);
|
||||||
|
if (method == null || method.getBody() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
CompilationUnit cu = (CompilationUnit) type.getRoot();
|
||||||
|
final IVariableBinding[] receiverBinding = new IVariableBinding[1];
|
||||||
|
method.getBody().accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(MethodInvocation node) {
|
||||||
|
if (trigger.getLineNumber() > 0 && cu != null
|
||||||
|
&& cu.getLineNumber(node.getStartPosition()) != trigger.getLineNumber()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!TRIGGER_METHODS.contains(node.getName().getIdentifier())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Expression receiver = node.getExpression();
|
||||||
|
if (receiver instanceof SimpleName sn) {
|
||||||
|
IBinding binding = sn.resolveBinding();
|
||||||
|
if (binding instanceof IVariableBinding vb) {
|
||||||
|
receiverBinding[0] = vb;
|
||||||
|
}
|
||||||
|
} else if (receiver instanceof FieldAccess fa) {
|
||||||
|
receiverBinding[0] = fa.resolveFieldBinding();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (receiverBinding[0] == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String injectedFqn = analyzer.resolveInjectedBeanFqn(receiverBinding[0]);
|
||||||
|
if (injectedFqn == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration injectedType = context.getTypeDeclaration(injectedFqn);
|
||||||
|
if (injectedType != null && context.extendsStateMachineConfigurerAdapter(injectedType)) {
|
||||||
|
return injectedFqn;
|
||||||
|
}
|
||||||
|
return EnableStateMachineBeanRouting.findConfigFqnByBeanName(
|
||||||
|
EnableStateMachineBeanRouting.defaultBeanNameFromFqn(injectedFqn), context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InjectionPointAnalyzer createAnalyzer(CodebaseContext context) {
|
||||||
|
SpringBeanRegistry registry = new SpringBeanRegistry();
|
||||||
|
SpringContextScanner scanner = new SpringContextScanner(registry);
|
||||||
|
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||||
|
cu.accept(scanner);
|
||||||
|
}
|
||||||
|
return new InjectionPointAnalyzer(new SpringDependencyResolver(registry));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.flow;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||||
|
import org.eclipse.jdt.core.dom.Expression;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared binder: {@link CallEdge} argument text → unique AST argument expression in the caller body.
|
||||||
|
* Fail-closed when zero or multiple sites match (no pick-any).
|
||||||
|
* Used by allocation facts and event-identity string forwarding.
|
||||||
|
*/
|
||||||
|
public final class CallSiteArgBinder {
|
||||||
|
|
||||||
|
private CallSiteArgBinder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the unique caller AST argument for {@code edge}'s {@code paramIndex}, or empty
|
||||||
|
*/
|
||||||
|
public static Optional<Expression> findUniqueArgument(
|
||||||
|
MethodDeclaration callerBody,
|
||||||
|
CallEdge edge,
|
||||||
|
int paramIndex) {
|
||||||
|
if (callerBody == null || callerBody.getBody() == null || edge == null || edge.getTargetMethod() == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (paramIndex < 0) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String targetSimple = methodNameOf(edge.getTargetMethod());
|
||||||
|
if (targetSimple == null || targetSimple.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String edgeArgText = null;
|
||||||
|
if (edge.getArguments() != null && paramIndex < edge.getArguments().size()) {
|
||||||
|
edgeArgText = normalizeExprText(edge.getArguments().get(paramIndex));
|
||||||
|
}
|
||||||
|
List<Expression> matches = new ArrayList<>();
|
||||||
|
callerBody.accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(MethodInvocation node) {
|
||||||
|
if (!targetSimple.equals(node.getName().getIdentifier())) {
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
if (paramIndex >= node.arguments().size()) {
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
matches.add((Expression) node.arguments().get(paramIndex));
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (matches.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (matches.size() == 1) {
|
||||||
|
return Optional.of(matches.get(0));
|
||||||
|
}
|
||||||
|
if (edgeArgText == null || edgeArgText.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
List<Expression> textHits = new ArrayList<>();
|
||||||
|
for (Expression candidate : matches) {
|
||||||
|
if (edgeArgText.equals(normalizeExprText(candidate.toString()))) {
|
||||||
|
textHits.add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (textHits.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
// Identical arg text at several sites is equivalent — pick the first.
|
||||||
|
// Differing texts that somehow normalize equal still share one edge string.
|
||||||
|
return Optional.of(textHits.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether two method FQNs refer to the same simple method (path hop matching).
|
||||||
|
*/
|
||||||
|
public static boolean methodsMatch(String left, String right) {
|
||||||
|
if (left == null || right == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (left.equals(right)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String leftSimple = methodNameOf(left);
|
||||||
|
String rightSimple = methodNameOf(right);
|
||||||
|
return leftSimple != null && leftSimple.equals(rightSimple)
|
||||||
|
&& (left.endsWith("." + rightSimple) || right.endsWith("." + leftSimple));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String methodNameOf(String methodFqn) {
|
||||||
|
if (methodFqn == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int lastDot = methodFqn.lastIndexOf('.');
|
||||||
|
return lastDot >= 0 ? methodFqn.substring(lastDot + 1) : methodFqn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String normalizeExprText(String text) {
|
||||||
|
if (text == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return text.replaceAll("\\s+", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String normalizeExprText(Expression expression) {
|
||||||
|
if (expression == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalizeExprText(expression.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,931 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.flow.alloc;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.flow.CallSiteArgBinder;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTNode;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||||
|
import org.eclipse.jdt.core.dom.Assignment;
|
||||||
|
import org.eclipse.jdt.core.dom.CastExpression;
|
||||||
|
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||||
|
import org.eclipse.jdt.core.dom.Expression;
|
||||||
|
import org.eclipse.jdt.core.dom.FieldAccess;
|
||||||
|
import org.eclipse.jdt.core.dom.FieldDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.IBinding;
|
||||||
|
import org.eclipse.jdt.core.dom.IMethodBinding;
|
||||||
|
import org.eclipse.jdt.core.dom.ITypeBinding;
|
||||||
|
import org.eclipse.jdt.core.dom.IVariableBinding;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||||
|
import org.eclipse.jdt.core.dom.Modifier;
|
||||||
|
import org.eclipse.jdt.core.dom.Name;
|
||||||
|
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
|
||||||
|
import org.eclipse.jdt.core.dom.SimpleName;
|
||||||
|
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.ThisExpression;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path-scoped allocation fact propagator: CIC → locals → callee parameters via call-edge args,
|
||||||
|
* factory {@code return new T()}, and trivial getter→field→ctor-arg peels.
|
||||||
|
* Optionally treats a unique Spring {@code @Bean}/component as an alloc substitute when
|
||||||
|
* {@link InjectionPointAnalyzer} is wired (multi-bean / qualifier conflict → empty).
|
||||||
|
* Fail-closed when facts are empty or ambiguous (multi-site).
|
||||||
|
*/
|
||||||
|
public final class AllocationFactPropagator {
|
||||||
|
|
||||||
|
private static final int MAX_DEPTH = 8;
|
||||||
|
|
||||||
|
private final CodebaseContext context;
|
||||||
|
private final VariableTracer variableTracer;
|
||||||
|
private final ConstructorAnalyzer constructorAnalyzer;
|
||||||
|
private InjectionPointAnalyzer injectionAnalyzer;
|
||||||
|
|
||||||
|
public AllocationFactPropagator(CodebaseContext context, VariableTracer variableTracer) {
|
||||||
|
this(context, variableTracer, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AllocationFactPropagator(
|
||||||
|
CodebaseContext context,
|
||||||
|
VariableTracer variableTracer,
|
||||||
|
ConstructorAnalyzer constructorAnalyzer) {
|
||||||
|
this.context = context;
|
||||||
|
this.variableTracer = variableTracer;
|
||||||
|
this.constructorAnalyzer = constructorAnalyzer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setInjectionAnalyzer(InjectionPointAnalyzer injectionAnalyzer) {
|
||||||
|
this.injectionAnalyzer = injectionAnalyzer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocation facts for {@code expression} within {@code enclosingMethod}, optionally
|
||||||
|
* refined by walking {@code methodPath} call edges backward for parameter receivers.
|
||||||
|
* Dominating {@code instanceof} / pattern narrowing is merged via {@link InstanceofNarrower}.
|
||||||
|
*/
|
||||||
|
public ReceiverFacts factsAt(
|
||||||
|
Expression expression,
|
||||||
|
MethodDeclaration enclosingMethod,
|
||||||
|
List<String> methodPath,
|
||||||
|
Map<String, List<CallEdge>> callGraph) {
|
||||||
|
ReceiverFacts base = factsAtRaw(expression, enclosingMethod, methodPath, callGraph, 0, new LinkedHashSet<>());
|
||||||
|
return InstanceofNarrower.refine(base, expression, expression, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceiverFacts factsAtRaw(
|
||||||
|
Expression expression,
|
||||||
|
MethodDeclaration enclosingMethod,
|
||||||
|
List<String> methodPath,
|
||||||
|
Map<String, List<CallEdge>> callGraph,
|
||||||
|
int depth,
|
||||||
|
Set<String> visited) {
|
||||||
|
if (expression == null || depth > MAX_DEPTH) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
Expression peeled = peel(expression);
|
||||||
|
LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
if (peeled instanceof ClassInstanceCreation cic) {
|
||||||
|
AllocationSite site = siteFromCic(cic);
|
||||||
|
if (site != null) {
|
||||||
|
sites.add(site);
|
||||||
|
}
|
||||||
|
return ReceiverFacts.ofAll(sites);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peeled instanceof MethodInvocation mi) {
|
||||||
|
ReceiverFacts fromGetter = factsFromGetterPeel(
|
||||||
|
mi, enclosingMethod, methodPath, callGraph, depth, visited);
|
||||||
|
if (!fromGetter.isEmpty()) {
|
||||||
|
return fromGetter;
|
||||||
|
}
|
||||||
|
ReceiverFacts fromFactory = factsFromFactoryReturn(mi, depth, visited);
|
||||||
|
if (!fromFactory.isEmpty()) {
|
||||||
|
return fromFactory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variableTracer != null) {
|
||||||
|
for (Expression def : variableTracer.traceVariableAll(peeled)) {
|
||||||
|
Expression defPeeled = peel(def);
|
||||||
|
if (defPeeled == null || defPeeled == peeled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (defPeeled instanceof ClassInstanceCreation cic) {
|
||||||
|
AllocationSite site = siteFromCic(cic);
|
||||||
|
if (site != null) {
|
||||||
|
sites.add(site);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Follow aliases / casts / factories: RichEvent carrier = e; e = (RichEvent) o;
|
||||||
|
String defKey = "alias:" + System.identityHashCode(defPeeled);
|
||||||
|
if (visited.add(defKey)) {
|
||||||
|
ReceiverFacts nested = factsAtRaw(
|
||||||
|
defPeeled, enclosingMethod, methodPath, callGraph, depth + 1, visited);
|
||||||
|
sites.addAll(nested.sites());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sites.isEmpty()) {
|
||||||
|
return ReceiverFacts.ofAll(sites);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameter: walk call path to find caller argument CIC.
|
||||||
|
if (peeled instanceof SimpleName simpleName
|
||||||
|
&& enclosingMethod != null
|
||||||
|
&& methodPath != null
|
||||||
|
&& callGraph != null) {
|
||||||
|
int paramIndex = parameterIndex(enclosingMethod, simpleName.getIdentifier());
|
||||||
|
if (paramIndex >= 0) {
|
||||||
|
String calleeFqn = methodFqn(enclosingMethod);
|
||||||
|
if (calleeFqn != null && visited.add(calleeFqn + "#" + paramIndex)) {
|
||||||
|
ReceiverFacts fromCaller = factsFromCallerArgument(
|
||||||
|
calleeFqn, paramIndex, methodPath, callGraph, depth + 1, visited);
|
||||||
|
if (!fromCaller.isEmpty()) {
|
||||||
|
return fromCaller;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance field: unique path-scoped write this.f = provenParam/CIC (or setF(...)).
|
||||||
|
ReceiverFacts fromField = factsFromInstanceFieldStore(
|
||||||
|
peeled, enclosingMethod, methodPath, callGraph, depth, visited);
|
||||||
|
if (!fromField.isEmpty()) {
|
||||||
|
return fromField;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique Spring bean injected into an interface/abstract field (when analyzer is wired).
|
||||||
|
ReceiverFacts fromBean = factsFromUniqueInjectedBean(peeled);
|
||||||
|
if (!fromBean.isEmpty()) {
|
||||||
|
return fromBean;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceiverFacts factsFromUniqueInjectedBean(Expression expression) {
|
||||||
|
if (injectionAnalyzer == null || expression == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
IVariableBinding varBinding = null;
|
||||||
|
Expression peeled = peel(expression);
|
||||||
|
if (peeled instanceof SimpleName sn) {
|
||||||
|
IBinding binding = sn.resolveBinding();
|
||||||
|
if (binding instanceof IVariableBinding vb) {
|
||||||
|
varBinding = vb;
|
||||||
|
}
|
||||||
|
} else if (peeled instanceof FieldAccess fa) {
|
||||||
|
varBinding = fa.resolveFieldBinding();
|
||||||
|
}
|
||||||
|
if (varBinding == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String concreteFqn = injectionAnalyzer.resolveInjectedBeanFqn(varBinding);
|
||||||
|
if (concreteFqn == null || concreteFqn.isBlank()) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(concreteFqn);
|
||||||
|
if (td == null) {
|
||||||
|
String simple = concreteFqn.contains(".")
|
||||||
|
? concreteFqn.substring(concreteFqn.lastIndexOf('.') + 1)
|
||||||
|
: concreteFqn;
|
||||||
|
td = context.getTypeDeclaration(simple);
|
||||||
|
if (td != null) {
|
||||||
|
concreteFqn = context.getFqn(td);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (td == null || td.isInterface() || Modifier.isAbstract(td.getModifiers())) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
return ReceiverFacts.of(new AllocationSite(concreteFqn));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facts for the receiver of a virtual accessor call ({@code e.getType()}).
|
||||||
|
* Uses the accessor invocation as instanceof use-site so then-branch narrowing applies.
|
||||||
|
*/
|
||||||
|
public ReceiverFacts factsForAccessorReceiver(
|
||||||
|
MethodInvocation accessorCall,
|
||||||
|
List<String> methodPath,
|
||||||
|
Map<String, List<CallEdge>> callGraph) {
|
||||||
|
if (accessorCall == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
Expression receiver = accessorCall.getExpression();
|
||||||
|
if (receiver == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
MethodDeclaration enclosing = findEnclosingMethod(accessorCall);
|
||||||
|
ReceiverFacts base = factsAtRaw(
|
||||||
|
receiver, enclosing, methodPath, callGraph, 0, new LinkedHashSet<>());
|
||||||
|
return InstanceofNarrower.refine(base, receiver, accessorCall, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceiverFacts factsFromFactoryReturn(MethodInvocation mi, int depth, Set<String> visited) {
|
||||||
|
if (constructorAnalyzer == null || mi == null || depth > MAX_DEPTH) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String owner = resolveInvocationOwner(mi);
|
||||||
|
if (owner == null || owner.isBlank()) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String key = "factory:" + owner + "#" + mi.getName().getIdentifier();
|
||||||
|
if (!visited.add(key)) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ClassInstanceCreation cic = constructorAnalyzer.findReturnedCIC(
|
||||||
|
owner, mi.getName().getIdentifier(), context);
|
||||||
|
if (cic == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
AllocationSite site = siteFromCic(cic);
|
||||||
|
return site == null ? ReceiverFacts.empty() : ReceiverFacts.of(site);
|
||||||
|
} finally {
|
||||||
|
visited.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceiverFacts factsFromGetterPeel(
|
||||||
|
MethodInvocation mi,
|
||||||
|
MethodDeclaration enclosingMethod,
|
||||||
|
List<String> methodPath,
|
||||||
|
Map<String, List<CallEdge>> callGraph,
|
||||||
|
int depth,
|
||||||
|
Set<String> visited) {
|
||||||
|
if (mi.getExpression() == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String peelKey = "getter:" + mi.getName().getIdentifier() + "@" + System.identityHashCode(mi);
|
||||||
|
if (!visited.add(peelKey)) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ReceiverFacts receiverFacts = factsAtRaw(
|
||||||
|
mi.getExpression(), enclosingMethod, methodPath, callGraph, depth + 1, visited);
|
||||||
|
AllocationSite unique = receiverFacts.uniqueOrNull();
|
||||||
|
if (unique != null && unique.cic() != null) {
|
||||||
|
Expression ctorArg = resolveGetterToCtorArg(unique, mi.getName().getIdentifier());
|
||||||
|
if (ctorArg != null) {
|
||||||
|
return factsAtRaw(ctorArg, enclosingMethod, methodPath, callGraph, depth + 1, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trivial setter peel: w.setEvent(new T()); … w.getEvent()
|
||||||
|
String fieldName = javaBeansFieldName(mi.getName().getIdentifier());
|
||||||
|
ReceiverFacts fromSetter = factsFromUniqueSetterWrite(
|
||||||
|
mi.getExpression(),
|
||||||
|
fieldName,
|
||||||
|
mi.getName().getIdentifier(),
|
||||||
|
enclosingMethod,
|
||||||
|
methodPath,
|
||||||
|
callGraph,
|
||||||
|
depth,
|
||||||
|
visited);
|
||||||
|
if (!fromSetter.isEmpty()) {
|
||||||
|
return fromSetter;
|
||||||
|
}
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
} finally {
|
||||||
|
visited.remove(peelKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path-scoped instance field store: {@code this.carrier = e} or {@code setCarrier(e)} with a
|
||||||
|
* unique proven RHS on methods appearing on {@code methodPath}.
|
||||||
|
*/
|
||||||
|
private ReceiverFacts factsFromInstanceFieldStore(
|
||||||
|
Expression peeled,
|
||||||
|
MethodDeclaration enclosingMethod,
|
||||||
|
List<String> methodPath,
|
||||||
|
Map<String, List<CallEdge>> callGraph,
|
||||||
|
int depth,
|
||||||
|
Set<String> visited) {
|
||||||
|
if (enclosingMethod == null || methodPath == null || depth > MAX_DEPTH) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String fieldName = instanceFieldName(peeled);
|
||||||
|
if (fieldName == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
TypeDeclaration owner = findEnclosingType(enclosingMethod);
|
||||||
|
if (owner == null || !ownerDeclaresField(owner, fieldName)) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String visitKey = "fieldStore:" + context.getFqn(owner) + "#" + fieldName;
|
||||||
|
if (!visited.add(visitKey)) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
|
||||||
|
int writeCount = 0;
|
||||||
|
for (String hop : methodPath) {
|
||||||
|
if (hop == null || !hopContainsType(hop, owner)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
MethodDeclaration md = findMethodDeclaration(hop);
|
||||||
|
if (md == null || md.getBody() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<Expression> rhsList = collectFieldWrites(md, fieldName);
|
||||||
|
for (Expression rhs : rhsList) {
|
||||||
|
writeCount++;
|
||||||
|
ReceiverFacts nested = factsAtRaw(rhs, md, methodPath, callGraph, depth + 1, visited);
|
||||||
|
sites.addAll(nested.sites());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (writeCount == 0 || sites.isEmpty()) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
// Ambiguous: multiple writes that disagree → fail closed via ofAll size; also reject
|
||||||
|
// multiple writes even if they agree on type (safer path-scoped uniqueness).
|
||||||
|
if (writeCount != 1) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
return ReceiverFacts.ofAll(sites);
|
||||||
|
} finally {
|
||||||
|
visited.remove(visitKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique {@code receiver.setX(arg)} / {@code setX(arg)} on path whose arg has allocation facts.
|
||||||
|
*/
|
||||||
|
private ReceiverFacts factsFromUniqueSetterWrite(
|
||||||
|
Expression receiver,
|
||||||
|
String fieldName,
|
||||||
|
String getterName,
|
||||||
|
MethodDeclaration enclosingMethod,
|
||||||
|
List<String> methodPath,
|
||||||
|
Map<String, List<CallEdge>> callGraph,
|
||||||
|
int depth,
|
||||||
|
Set<String> visited) {
|
||||||
|
if (receiver == null || methodPath == null || fieldName == null || fieldName.isBlank()) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String setterName = "set" + Character.toUpperCase(fieldName.charAt(0))
|
||||||
|
+ (fieldName.length() > 1 ? fieldName.substring(1) : "");
|
||||||
|
String receiverText = CallSiteArgBinder.normalizeExprText(receiver);
|
||||||
|
LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
|
||||||
|
int matchCount = 0;
|
||||||
|
Set<Integer> seenBodies = new LinkedHashSet<>();
|
||||||
|
for (String hop : methodPath) {
|
||||||
|
MethodDeclaration md = findMethodDeclaration(hop);
|
||||||
|
if (md == null || md.getBody() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int bodyId = System.identityHashCode(md);
|
||||||
|
if (!seenBodies.add(bodyId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<Expression> setterArgs = collectSetterArgs(md, setterName, receiverText);
|
||||||
|
for (Expression arg : setterArgs) {
|
||||||
|
matchCount++;
|
||||||
|
ReceiverFacts nested = factsAtRaw(arg, md, methodPath, callGraph, depth + 1, visited);
|
||||||
|
sites.addAll(nested.sites());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Same-method setter before getter (path may not re-list enclosing).
|
||||||
|
if (enclosingMethod != null && enclosingMethod.getBody() != null
|
||||||
|
&& seenBodies.add(System.identityHashCode(enclosingMethod))) {
|
||||||
|
List<Expression> local = collectSetterArgs(enclosingMethod, setterName, receiverText);
|
||||||
|
for (Expression arg : local) {
|
||||||
|
matchCount++;
|
||||||
|
ReceiverFacts nested = factsAtRaw(
|
||||||
|
arg, enclosingMethod, methodPath, callGraph, depth + 1, visited);
|
||||||
|
sites.addAll(nested.sites());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matchCount != 1 || sites.isEmpty()) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
return ReceiverFacts.ofAll(sites);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String instanceFieldName(Expression peeled) {
|
||||||
|
if (peeled instanceof FieldAccess fa
|
||||||
|
&& (fa.getExpression() instanceof ThisExpression || fa.getExpression() == null)) {
|
||||||
|
return fa.getName().getIdentifier();
|
||||||
|
}
|
||||||
|
if (peeled instanceof SimpleName sn) {
|
||||||
|
return sn.getIdentifier();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean ownerDeclaresField(TypeDeclaration owner, String fieldName) {
|
||||||
|
if (owner == null || fieldName == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (FieldDeclaration fd : owner.getFields()) {
|
||||||
|
for (Object fragObj : fd.fragments()) {
|
||||||
|
if (fragObj instanceof VariableDeclarationFragment frag
|
||||||
|
&& fieldName.equals(frag.getName().getIdentifier())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hopContainsType(String methodFqn, TypeDeclaration owner) {
|
||||||
|
if (methodFqn == null || owner == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String typeFqn = context.getFqn(owner);
|
||||||
|
if (typeFqn != null && methodFqn.startsWith(typeFqn + ".")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String simple = owner.getName().getIdentifier();
|
||||||
|
return methodFqn.contains("." + simple + ".") || methodFqn.startsWith(simple + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Expression> collectFieldWrites(MethodDeclaration method, String fieldName) {
|
||||||
|
List<Expression> writes = new java.util.ArrayList<>();
|
||||||
|
method.getBody().accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(Assignment node) {
|
||||||
|
Expression lhs = node.getLeftHandSide();
|
||||||
|
if (lhs instanceof SimpleName sn && fieldName.equals(sn.getIdentifier())) {
|
||||||
|
writes.add(node.getRightHandSide());
|
||||||
|
} else if (lhs instanceof FieldAccess fa
|
||||||
|
&& fieldName.equals(fa.getName().getIdentifier())
|
||||||
|
&& (fa.getExpression() instanceof ThisExpression || fa.getExpression() == null)) {
|
||||||
|
writes.add(node.getRightHandSide());
|
||||||
|
}
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean visit(MethodInvocation node) {
|
||||||
|
String name = node.getName().getIdentifier();
|
||||||
|
if (name != null && name.startsWith("set") && name.length() > 3
|
||||||
|
&& javaBeansFieldName(name).equals(fieldName)
|
||||||
|
&& node.arguments().size() == 1
|
||||||
|
&& (node.getExpression() == null || node.getExpression() instanceof ThisExpression)) {
|
||||||
|
writes.add((Expression) node.arguments().get(0));
|
||||||
|
}
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return writes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Expression> collectSetterArgs(
|
||||||
|
MethodDeclaration method, String setterName, String receiverText) {
|
||||||
|
List<Expression> args = new java.util.ArrayList<>();
|
||||||
|
method.getBody().accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(MethodInvocation node) {
|
||||||
|
if (!setterName.equals(node.getName().getIdentifier()) || node.arguments().size() != 1) {
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
Expression recv = node.getExpression();
|
||||||
|
if (recv == null) {
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
if (receiverText != null && receiverText.equals(CallSiteArgBinder.normalizeExprText(recv))) {
|
||||||
|
args.add((Expression) node.arguments().get(0));
|
||||||
|
}
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression resolveGetterToCtorArg(AllocationSite site, String getterName) {
|
||||||
|
ClassInstanceCreation cic = site.cic();
|
||||||
|
if (cic == null || getterName == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = resolveTypeDeclaration(site.typeFqn());
|
||||||
|
if (td == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String fieldName = fieldNameForGetter(site.typeFqn(), getterName);
|
||||||
|
if (fieldName == null || fieldName.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int idx = findCtorArgIndexForField(td, cic, fieldName);
|
||||||
|
if (idx < 0 || idx >= cic.arguments().size()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (Expression) cic.arguments().get(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fieldNameForGetter(String ownerFqn, String getterName) {
|
||||||
|
if (context.getAccessorIndex() != null) {
|
||||||
|
Optional<AccessorSummary> indexed = context.getAccessorIndex().lookup(ownerFqn, getterName);
|
||||||
|
if (indexed.isPresent() && indexed.get().fieldName() != null) {
|
||||||
|
return indexed.get().fieldName();
|
||||||
|
}
|
||||||
|
String simple = simplifyTypeName(ownerFqn);
|
||||||
|
if (!simple.equals(ownerFqn)) {
|
||||||
|
indexed = context.getAccessorIndex().lookup(simple, getterName);
|
||||||
|
if (indexed.isPresent() && indexed.get().fieldName() != null) {
|
||||||
|
return indexed.get().fieldName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return javaBeansFieldName(getterName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String javaBeansFieldName(String getterName) {
|
||||||
|
if (getterName == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (getterName.startsWith("get") && getterName.length() > 3) {
|
||||||
|
return Character.toLowerCase(getterName.charAt(3)) + getterName.substring(4);
|
||||||
|
}
|
||||||
|
if (getterName.startsWith("is") && getterName.length() > 2) {
|
||||||
|
return Character.toLowerCase(getterName.charAt(2)) + getterName.substring(3);
|
||||||
|
}
|
||||||
|
return getterName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int findCtorArgIndexForField(TypeDeclaration td, ClassInstanceCreation cic, String fieldName) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
int fromBody = ctorParamIndexAssignedToField(ctor, fieldName);
|
||||||
|
if (fromBody >= 0) {
|
||||||
|
return fromBody;
|
||||||
|
}
|
||||||
|
// Same-name param fallback: EventWrapper(RichEvent event) { this.event = event; }
|
||||||
|
for (int i = 0; i < ctor.parameters().size(); i++) {
|
||||||
|
SingleVariableDeclaration svd = (SingleVariableDeclaration) ctor.parameters().get(i);
|
||||||
|
if (fieldName.equals(svd.getName().getIdentifier())) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (constructorAnalyzer != null) {
|
||||||
|
int lombokIdx = constructorAnalyzer.findConstructorArgumentIndexForLombokField(td, fieldName);
|
||||||
|
if (lombokIdx >= 0) {
|
||||||
|
return lombokIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Single-arg ctor → assume it initializes the sole relevant field.
|
||||||
|
if (cic.arguments().size() == 1 && td.getMethods() != null) {
|
||||||
|
for (MethodDeclaration ctor : td.getMethods()) {
|
||||||
|
if (ctor.isConstructor() && ctor.parameters().size() == 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ctorParamIndexAssignedToField(MethodDeclaration ctor, String fieldName) {
|
||||||
|
int[] found = {-1};
|
||||||
|
List<?> params = ctor.parameters();
|
||||||
|
ctor.getBody().accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(Assignment node) {
|
||||||
|
if (!(node.getRightHandSide() instanceof SimpleName rhs)) {
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
String paramName = rhs.getIdentifier();
|
||||||
|
int paramIdx = -1;
|
||||||
|
for (int i = 0; i < params.size(); i++) {
|
||||||
|
if (params.get(i) instanceof SingleVariableDeclaration svd
|
||||||
|
&& paramName.equals(svd.getName().getIdentifier())) {
|
||||||
|
paramIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (paramIdx < 0) {
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
Expression lhs = node.getLeftHandSide();
|
||||||
|
if (lhs instanceof SimpleName sn && fieldName.equals(sn.getIdentifier())) {
|
||||||
|
found[0] = paramIdx;
|
||||||
|
} else if (lhs instanceof FieldAccess fa
|
||||||
|
&& fa.getExpression() instanceof ThisExpression
|
||||||
|
&& fieldName.equals(fa.getName().getIdentifier())) {
|
||||||
|
found[0] = paramIdx;
|
||||||
|
}
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return found[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveInvocationOwner(MethodInvocation mi) {
|
||||||
|
Expression expr = mi.getExpression();
|
||||||
|
if (expr == null) {
|
||||||
|
MethodDeclaration enclosing = findEnclosingMethod(mi);
|
||||||
|
TypeDeclaration td = enclosing != null ? findEnclosingType(enclosing) : null;
|
||||||
|
return td != null ? context.getFqn(td) : null;
|
||||||
|
}
|
||||||
|
ITypeBinding binding = expr.resolveTypeBinding();
|
||||||
|
if (binding != null) {
|
||||||
|
ITypeBinding erasure = binding.getErasure();
|
||||||
|
if (erasure != null && erasure.getQualifiedName() != null && !erasure.getQualifiedName().isBlank()) {
|
||||||
|
// Static type name as qualifier often binds to the type itself.
|
||||||
|
return erasure.getQualifiedName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expr instanceof SimpleName sn) {
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(sn.getIdentifier());
|
||||||
|
if (td != null) {
|
||||||
|
String fqn = context.getFqn(td);
|
||||||
|
if (fqn != null) {
|
||||||
|
return fqn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CompilationUnitAwareFqn local = resolveSimpleInEnclosingCu(mi, sn.getIdentifier());
|
||||||
|
if (local != null) {
|
||||||
|
return local.fqn();
|
||||||
|
}
|
||||||
|
return sn.getIdentifier();
|
||||||
|
}
|
||||||
|
if (expr instanceof Name name) {
|
||||||
|
return name.getFullyQualifiedName();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TypeDeclaration resolveTypeDeclaration(String typeFqn) {
|
||||||
|
if (typeFqn == null || typeFqn.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(typeFqn);
|
||||||
|
if (td != null) {
|
||||||
|
return td;
|
||||||
|
}
|
||||||
|
return context.getTypeDeclaration(simplifyTypeName(typeFqn));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String simplifyTypeName(String typeFqn) {
|
||||||
|
if (typeFqn == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int lastDot = typeFqn.lastIndexOf('.');
|
||||||
|
return lastDot >= 0 ? typeFqn.substring(lastDot + 1) : typeFqn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceiverFacts factsFromCallerArgument(
|
||||||
|
String calleeMethodFqn,
|
||||||
|
int paramIndex,
|
||||||
|
List<String> methodPath,
|
||||||
|
Map<String, List<CallEdge>> callGraph,
|
||||||
|
int depth,
|
||||||
|
Set<String> visited) {
|
||||||
|
if (calleeMethodFqn == null || methodPath == null || callGraph == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
int calleeIdx = methodPath.indexOf(calleeMethodFqn);
|
||||||
|
if (calleeIdx <= 0) {
|
||||||
|
calleeIdx = indexOfMethodOnPath(methodPath, calleeMethodFqn);
|
||||||
|
}
|
||||||
|
if (calleeIdx <= 0) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String callerMethodFqn = methodPath.get(calleeIdx - 1);
|
||||||
|
List<CallEdge> edges = callGraph.get(callerMethodFqn);
|
||||||
|
if (edges == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
|
||||||
|
for (CallEdge edge : edges) {
|
||||||
|
if (edge == null || edge.getTargetMethod() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!CallSiteArgBinder.methodsMatch(edge.getTargetMethod(), calleeMethodFqn)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<String> args = edge.getArguments();
|
||||||
|
if (args == null || paramIndex >= args.size()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Prefer AST so nested CIC args stay attached for getter peels.
|
||||||
|
MethodDeclaration callerMd = findMethodDeclaration(callerMethodFqn);
|
||||||
|
ReceiverFacts nested = ReceiverFacts.empty();
|
||||||
|
if (callerMd != null) {
|
||||||
|
var bound = CallSiteArgBinder.findUniqueArgument(callerMd, edge, paramIndex);
|
||||||
|
if (bound.isPresent()) {
|
||||||
|
nested = factsAtRaw(bound.get(), callerMd, methodPath, callGraph, depth + 1, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!nested.isEmpty()) {
|
||||||
|
sites.addAll(nested.sites());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
AllocationSite fromArg = siteFromArgumentText(args.get(paramIndex));
|
||||||
|
if (fromArg != null) {
|
||||||
|
sites.add(fromArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ReceiverFacts.ofAll(sites);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AllocationSite siteFromCic(ClassInstanceCreation cic) {
|
||||||
|
if (cic == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ITypeBinding binding = cic.resolveTypeBinding();
|
||||||
|
if (binding != null) {
|
||||||
|
ITypeBinding erasure = binding.getErasure();
|
||||||
|
if (erasure != null && erasure.getQualifiedName() != null && !erasure.getQualifiedName().isBlank()) {
|
||||||
|
return new AllocationSite(erasure.getQualifiedName(), cic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cic.resolveConstructorBinding() != null
|
||||||
|
&& cic.resolveConstructorBinding().getDeclaringClass() != null) {
|
||||||
|
String qn = cic.resolveConstructorBinding().getDeclaringClass().getErasure().getQualifiedName();
|
||||||
|
if (qn != null && !qn.isBlank()) {
|
||||||
|
return new AllocationSite(qn, cic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String simple = AstUtils.extractSimpleTypeName(cic.getType());
|
||||||
|
if (simple == null || simple.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(simple);
|
||||||
|
if (td != null) {
|
||||||
|
String fqn = context.getFqn(td);
|
||||||
|
if (fqn != null && !fqn.isBlank()) {
|
||||||
|
return new AllocationSite(fqn, cic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CompilationUnitAwareFqn resolved = resolveSimpleInEnclosingCu(cic, simple);
|
||||||
|
if (resolved != null) {
|
||||||
|
return new AllocationSite(resolved.fqn(), cic);
|
||||||
|
}
|
||||||
|
return new AllocationSite(simple, cic);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CompilationUnitAwareFqn(String fqn) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompilationUnitAwareFqn resolveSimpleInEnclosingCu(ASTNode node, String simple) {
|
||||||
|
if (node == null || simple == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ASTNode root = node.getRoot();
|
||||||
|
if (!(root instanceof org.eclipse.jdt.core.dom.CompilationUnit cu)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(simple, cu);
|
||||||
|
if (td == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String fqn = context.getFqn(td);
|
||||||
|
return fqn == null || fqn.isBlank() ? null : new CompilationUnitAwareFqn(fqn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AllocationSite siteFromArgumentText(String arg) {
|
||||||
|
if (arg == null || arg.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = arg.trim();
|
||||||
|
if (!trimmed.startsWith("new ")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String rest = trimmed.substring(4).trim();
|
||||||
|
int paren = rest.indexOf('(');
|
||||||
|
int brace = rest.indexOf('{');
|
||||||
|
int end = rest.length();
|
||||||
|
if (paren >= 0) {
|
||||||
|
end = Math.min(end, paren);
|
||||||
|
}
|
||||||
|
if (brace >= 0) {
|
||||||
|
end = Math.min(end, brace);
|
||||||
|
}
|
||||||
|
String type = rest.substring(0, end).trim();
|
||||||
|
if (type.isBlank() || type.contains(" ")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int generic = type.indexOf('<');
|
||||||
|
if (generic >= 0) {
|
||||||
|
type = type.substring(0, generic);
|
||||||
|
}
|
||||||
|
return new AllocationSite(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression peel(Expression expression) {
|
||||||
|
Expression current = expression;
|
||||||
|
while (current instanceof ParenthesizedExpression pe) {
|
||||||
|
current = pe.getExpression();
|
||||||
|
}
|
||||||
|
if (current instanceof CastExpression ce) {
|
||||||
|
return peel(ce.getExpression());
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int parameterIndex(MethodDeclaration method, String name) {
|
||||||
|
if (method == null || name == null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
List<?> params = method.parameters();
|
||||||
|
for (int i = 0; i < params.size(); i++) {
|
||||||
|
if (params.get(i) instanceof SingleVariableDeclaration svd
|
||||||
|
&& name.equals(svd.getName().getIdentifier())) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String methodFqn(MethodDeclaration method) {
|
||||||
|
if (method == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = findEnclosingType(method);
|
||||||
|
if (td == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String typeFqn = context.getFqn(td);
|
||||||
|
if (typeFqn == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return typeFqn + "." + method.getName().getIdentifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MethodDeclaration findMethodDeclaration(String methodFqn) {
|
||||||
|
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||||
|
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||||
|
if (td == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return context.findMethodDeclaration(td, methodName, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MethodDeclaration findEnclosingMethod(ASTNode node) {
|
||||||
|
ASTNode current = node;
|
||||||
|
while (current != null) {
|
||||||
|
if (current instanceof MethodDeclaration md) {
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
current = current.getParent();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TypeDeclaration findEnclosingType(ASTNode node) {
|
||||||
|
ASTNode current = node;
|
||||||
|
while (current != null) {
|
||||||
|
if (current instanceof TypeDeclaration td) {
|
||||||
|
return td;
|
||||||
|
}
|
||||||
|
current = current.getParent();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String methodNameOf(String methodFqn) {
|
||||||
|
return CallSiteArgBinder.methodNameOf(methodFqn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean methodsMatch(String left, String right) {
|
||||||
|
return CallSiteArgBinder.methodsMatch(left, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int indexOfMethodOnPath(List<String> methodPath, String methodFqn) {
|
||||||
|
if (methodPath == null || methodFqn == null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
String simple = CallSiteArgBinder.methodNameOf(methodFqn);
|
||||||
|
for (int i = methodPath.size() - 1; i >= 0; i--) {
|
||||||
|
String hop = methodPath.get(i);
|
||||||
|
if (hop != null && (hop.equals(methodFqn) || hop.endsWith("." + simple))) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.flow.alloc;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A concrete {@code new T(...)} allocation site relevant to receiver-type narrowing.
|
||||||
|
* Optional CIC AST is retained for getter peels (ctor args) but is not part of equality —
|
||||||
|
* uniqueness remains by type FQN.
|
||||||
|
*/
|
||||||
|
public final class AllocationSite {
|
||||||
|
|
||||||
|
private final String typeFqn;
|
||||||
|
private final ClassInstanceCreation cic;
|
||||||
|
|
||||||
|
public AllocationSite(String typeFqn) {
|
||||||
|
this(typeFqn, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AllocationSite(String typeFqn, ClassInstanceCreation cic) {
|
||||||
|
this.typeFqn = typeFqn;
|
||||||
|
this.cic = cic;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String typeFqn() {
|
||||||
|
return typeFqn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source CIC when known (for nested ctor-arg peels). May be null for text-only sites.
|
||||||
|
*/
|
||||||
|
public ClassInstanceCreation cic() {
|
||||||
|
return cic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer the site that retains CIC AST when merging equal type FQNs.
|
||||||
|
*/
|
||||||
|
public AllocationSite preferRich(AllocationSite other) {
|
||||||
|
if (other == null) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
if (!Objects.equals(typeFqn, other.typeFqn)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
if (this.cic == null && other.cic != null) {
|
||||||
|
return other;
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(o instanceof AllocationSite that)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Objects.equals(typeFqn, that.typeFqn);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(typeFqn);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "AllocationSite{" + typeFqn + (cic != null ? "+cic" : "") + '}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.flow.alloc;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTNode;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||||
|
import org.eclipse.jdt.core.dom.CastExpression;
|
||||||
|
import org.eclipse.jdt.core.dom.Expression;
|
||||||
|
import org.eclipse.jdt.core.dom.IfStatement;
|
||||||
|
import org.eclipse.jdt.core.dom.InstanceofExpression;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.Modifier;
|
||||||
|
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
|
||||||
|
import org.eclipse.jdt.core.dom.PatternInstanceofExpression;
|
||||||
|
import org.eclipse.jdt.core.dom.SimpleName;
|
||||||
|
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.Statement;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.TypePattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dominating {@code instanceof} / pattern narrowing → unique concrete {@link AllocationSite}.
|
||||||
|
* Shared by {@link AllocationFactPropagator}; fail-closed for else-branch, abstract targets,
|
||||||
|
* and sends outside the narrow.
|
||||||
|
*/
|
||||||
|
public final class InstanceofNarrower {
|
||||||
|
|
||||||
|
private InstanceofNarrower() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facts when {@code useSite} is dominated by {@code recv instanceof T} (then-branch)
|
||||||
|
* or {@code recv} is pattern variable {@code T p}.
|
||||||
|
*/
|
||||||
|
public static ReceiverFacts factsAt(
|
||||||
|
Expression receiver,
|
||||||
|
ASTNode useSite,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (context == null || receiver == null || useSite == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
Expression peeledRecv = peel(receiver);
|
||||||
|
if (!(peeledRecv instanceof SimpleName recvSn)) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String recvName = recvSn.getIdentifier();
|
||||||
|
MethodDeclaration enclosing = findEnclosingMethod(useSite);
|
||||||
|
String patternType = resolvePatternVariableType(recvName, enclosing);
|
||||||
|
if (patternType != null) {
|
||||||
|
return factsFromConcreteTypeName(patternType, context);
|
||||||
|
}
|
||||||
|
ASTNode current = useSite;
|
||||||
|
while (current != null && !(current instanceof MethodDeclaration)) {
|
||||||
|
ASTNode parent = current.getParent();
|
||||||
|
if (parent instanceof IfStatement ifStmt) {
|
||||||
|
if (isNodeUnder(ifStmt.getThenStatement(), current)) {
|
||||||
|
ReceiverFacts fromCond = extractInstanceofFacts(
|
||||||
|
ifStmt.getExpression(), recvName, context);
|
||||||
|
if (!fromCond.isEmpty()) {
|
||||||
|
return fromCond;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge base AFP facts with dominating instanceof.
|
||||||
|
* <ul>
|
||||||
|
* <li>instanceof empty → base unchanged</li>
|
||||||
|
* <li>base empty → instanceof</li>
|
||||||
|
* <li>base unique is Object / missing TD / interface / abstract → refine to instanceof</li>
|
||||||
|
* <li>base unique concrete equals instanceof → that site</li>
|
||||||
|
* <li>base unique concrete conflicts with instanceof → empty (fail-closed)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public static ReceiverFacts refine(
|
||||||
|
ReceiverFacts base,
|
||||||
|
Expression receiver,
|
||||||
|
ASTNode useSite,
|
||||||
|
CodebaseContext context) {
|
||||||
|
ReceiverFacts narrowed = factsAt(receiver, useSite, context);
|
||||||
|
if (narrowed.isEmpty()) {
|
||||||
|
return base == null ? ReceiverFacts.empty() : base;
|
||||||
|
}
|
||||||
|
if (base == null || base.isEmpty()) {
|
||||||
|
return narrowed;
|
||||||
|
}
|
||||||
|
AllocationSite baseUnique = base.uniqueOrNull();
|
||||||
|
AllocationSite narrowUnique = narrowed.uniqueOrNull();
|
||||||
|
if (baseUnique == null || narrowUnique == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
if (isWeakAllocSite(baseUnique, context)) {
|
||||||
|
return narrowed;
|
||||||
|
}
|
||||||
|
if (sameType(baseUnique.typeFqn(), narrowUnique.typeFqn())) {
|
||||||
|
return ReceiverFacts.of(baseUnique.preferRich(narrowUnique));
|
||||||
|
}
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String resolvePatternVariableType(String varName, MethodDeclaration enclosing) {
|
||||||
|
if (varName == null || enclosing == null || enclosing.getBody() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String[] found = {null};
|
||||||
|
enclosing.getBody().accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(PatternInstanceofExpression node) {
|
||||||
|
SingleVariableDeclaration svd = patternVariableOf(node);
|
||||||
|
if (svd != null
|
||||||
|
&& varName.equals(svd.getName().getIdentifier())
|
||||||
|
&& svd.getType() != null) {
|
||||||
|
found[0] = svd.getType().toString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (found[0] == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String type = found[0];
|
||||||
|
int generic = type.indexOf('<');
|
||||||
|
return generic >= 0 ? type.substring(0, generic) : type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isWeakAllocSite(AllocationSite site, CodebaseContext context) {
|
||||||
|
if (site == null || site.typeFqn() == null || site.typeFqn().isBlank()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String fqn = site.typeFqn();
|
||||||
|
if ("java.lang.Object".equals(fqn) || "Object".equals(fqn)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = resolveTypeDeclaration(fqn, context);
|
||||||
|
if (td == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return td.isInterface() || Modifier.isAbstract(td.getModifiers());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean sameType(String a, String b) {
|
||||||
|
if (a == null || b == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (a.equals(b)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String simpleA = a.contains(".") ? a.substring(a.lastIndexOf('.') + 1) : a;
|
||||||
|
String simpleB = b.contains(".") ? b.substring(b.lastIndexOf('.') + 1) : b;
|
||||||
|
return simpleA.equals(simpleB);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReceiverFacts extractInstanceofFacts(
|
||||||
|
Expression condition,
|
||||||
|
String recvName,
|
||||||
|
CodebaseContext context) {
|
||||||
|
Expression cond = peel(condition);
|
||||||
|
if (cond instanceof InstanceofExpression io) {
|
||||||
|
Expression left = peel(io.getLeftOperand());
|
||||||
|
if (left instanceof SimpleName sn && recvName.equals(sn.getIdentifier())
|
||||||
|
&& io.getRightOperand() != null) {
|
||||||
|
return factsFromConcreteTypeName(io.getRightOperand().toString(), context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cond instanceof PatternInstanceofExpression pio) {
|
||||||
|
Expression left = peel(pio.getLeftOperand());
|
||||||
|
if (left instanceof SimpleName sn && recvName.equals(sn.getIdentifier())) {
|
||||||
|
SingleVariableDeclaration svd = patternVariableOf(pio);
|
||||||
|
if (svd != null && svd.getType() != null) {
|
||||||
|
return factsFromConcreteTypeName(svd.getType().toString(), context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReceiverFacts factsFromConcreteTypeName(String typeFqn, CodebaseContext context) {
|
||||||
|
if (typeFqn == null || typeFqn.isBlank()) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
int generic = typeFqn.indexOf('<');
|
||||||
|
if (generic >= 0) {
|
||||||
|
typeFqn = typeFqn.substring(0, generic);
|
||||||
|
}
|
||||||
|
TypeDeclaration td = resolveTypeDeclaration(typeFqn, context);
|
||||||
|
if (td == null || td.isInterface() || Modifier.isAbstract(td.getModifiers())) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String fqn = context.getFqn(td);
|
||||||
|
return ReceiverFacts.of(new AllocationSite(fqn != null ? fqn : typeFqn));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TypeDeclaration resolveTypeDeclaration(String typeFqn, CodebaseContext context) {
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(typeFqn);
|
||||||
|
if (td == null) {
|
||||||
|
String simple = typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn;
|
||||||
|
td = context.getTypeDeclaration(simple);
|
||||||
|
}
|
||||||
|
return td;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SingleVariableDeclaration patternVariableOf(PatternInstanceofExpression node) {
|
||||||
|
if (node == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (node.getPattern() instanceof TypePattern tp) {
|
||||||
|
SingleVariableDeclaration pv = tp.getPatternVariable();
|
||||||
|
if (pv != null) {
|
||||||
|
return pv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// API level may expose rightOperand instead of pattern
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return node.getRightOperand();
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isNodeUnder(Statement root, ASTNode node) {
|
||||||
|
if (root == null || node == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ASTNode current = node;
|
||||||
|
while (current != null) {
|
||||||
|
if (current == root) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (current instanceof MethodDeclaration) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
current = current.getParent();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MethodDeclaration findEnclosingMethod(ASTNode node) {
|
||||||
|
ASTNode current = node;
|
||||||
|
while (current != null) {
|
||||||
|
if (current instanceof MethodDeclaration md) {
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
current = current.getParent();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression peel(Expression expression) {
|
||||||
|
Expression current = expression;
|
||||||
|
while (current instanceof ParenthesizedExpression pe) {
|
||||||
|
current = pe.getExpression();
|
||||||
|
}
|
||||||
|
if (current instanceof CastExpression ce) {
|
||||||
|
return peel(ce.getExpression());
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.flow.alloc;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May-set of concrete allocation sites for a receiver expression / parameter.
|
||||||
|
* Empty or multi-site facts fail closed (do not narrow virtual accessors).
|
||||||
|
*/
|
||||||
|
public final class ReceiverFacts {
|
||||||
|
|
||||||
|
private final Set<AllocationSite> sites;
|
||||||
|
|
||||||
|
private ReceiverFacts(Set<AllocationSite> sites) {
|
||||||
|
this.sites = sites == null ? Set.of() : Set.copyOf(sites);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReceiverFacts empty() {
|
||||||
|
return new ReceiverFacts(Set.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReceiverFacts of(AllocationSite site) {
|
||||||
|
if (site == null || site.typeFqn() == null || site.typeFqn().isBlank()) {
|
||||||
|
return empty();
|
||||||
|
}
|
||||||
|
return new ReceiverFacts(Set.of(site));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReceiverFacts ofAll(Iterable<AllocationSite> sites) {
|
||||||
|
// Merge by type FQN, preferring sites that retain CIC AST.
|
||||||
|
Map<String, AllocationSite> byType = new LinkedHashMap<>();
|
||||||
|
if (sites != null) {
|
||||||
|
for (AllocationSite site : sites) {
|
||||||
|
if (site == null || site.typeFqn() == null || site.typeFqn().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
AllocationSite existing = byType.get(site.typeFqn());
|
||||||
|
byType.put(site.typeFqn(), existing == null ? site : existing.preferRich(site));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ReceiverFacts(Set.copyOf(byType.values()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return sites.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUnique() {
|
||||||
|
return sites.size() == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AllocationSite uniqueOrNull() {
|
||||||
|
return isUnique() ? sites.iterator().next() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AllocationSite> sites() {
|
||||||
|
return List.copyOf(sites);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int size() {
|
||||||
|
return sites.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.flow.alloc;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||||
|
import org.eclipse.jdt.core.dom.Expression;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given concrete allocation facts and an accessor name ({@code getType}/{@code getEvent}/…),
|
||||||
|
* resolve overrides and extract compile-time return constants. Succeeds only when exactly one
|
||||||
|
* constant is proven across all facts.
|
||||||
|
*/
|
||||||
|
public final class VirtualAccessorConstResolver {
|
||||||
|
|
||||||
|
private final CodebaseContext context;
|
||||||
|
private final AccessorResolver accessorResolver;
|
||||||
|
private final ConstantResolver constantResolver;
|
||||||
|
|
||||||
|
public VirtualAccessorConstResolver(
|
||||||
|
CodebaseContext context,
|
||||||
|
AccessorResolver accessorResolver,
|
||||||
|
ConstantResolver constantResolver) {
|
||||||
|
this.context = context;
|
||||||
|
this.accessorResolver = accessorResolver;
|
||||||
|
this.constantResolver = constantResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return singleton list with the proven constant, or empty when unknown / ambiguous / non-constant
|
||||||
|
*/
|
||||||
|
public List<String> resolveUniqueConstant(ReceiverFacts facts, String accessorName) {
|
||||||
|
if (facts == null || facts.isEmpty() || accessorName == null || accessorName.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
LinkedHashSet<String> constants = new LinkedHashSet<>();
|
||||||
|
for (AllocationSite site : facts.sites()) {
|
||||||
|
List<String> fromSite = constantsForSite(site, accessorName);
|
||||||
|
if (fromSite.isEmpty()) {
|
||||||
|
// Any site without a compile-time constant → fail closed.
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
constants.addAll(fromSite);
|
||||||
|
if (constants.size() > 1) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (constants.size() != 1) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return List.copyOf(constants);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> constantsForSite(AllocationSite site, String accessorName) {
|
||||||
|
if (site == null || site.typeFqn() == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
String typeFqn = resolveTypeFqn(site.typeFqn());
|
||||||
|
if (typeFqn == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(typeFqn);
|
||||||
|
CompilationUnit cu = td != null && td.getRoot() instanceof CompilationUnit c ? c : null;
|
||||||
|
|
||||||
|
List<String> viaAccessor = List.of();
|
||||||
|
if (accessorResolver != null) {
|
||||||
|
viaAccessor = accessorResolver.resolve(
|
||||||
|
typeFqn,
|
||||||
|
accessorName,
|
||||||
|
new AccessorResolver.ReceiverContext(td, null, cu, false, null),
|
||||||
|
ResolutionBudget.defaults());
|
||||||
|
}
|
||||||
|
LinkedHashSet<String> cleaned = new LinkedHashSet<>();
|
||||||
|
for (String value : viaAccessor) {
|
||||||
|
String normalized = normalizeConstant(value);
|
||||||
|
if (normalized != null) {
|
||||||
|
cleaned.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleaned.size() == 1) {
|
||||||
|
return List.copyOf(cleaned);
|
||||||
|
}
|
||||||
|
if (cleaned.size() > 1) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: evaluate return expressions with ConstantResolver (handles Enum.CONST.name()).
|
||||||
|
if (td == null || constantResolver == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
MethodDeclaration method = context.findMethodDeclaration(td, accessorName, true);
|
||||||
|
if (method == null || method.getBody() == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> fromReturns = new ArrayList<>();
|
||||||
|
method.getBody().accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(ReturnStatement node) {
|
||||||
|
Expression expr = node.getExpression();
|
||||||
|
if (expr == null) {
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
String resolved = constantResolver.resolve(expr, context);
|
||||||
|
String normalized = normalizeConstant(resolved);
|
||||||
|
if (normalized != null) {
|
||||||
|
fromReturns.add(normalized);
|
||||||
|
}
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
LinkedHashSet<String> unique = new LinkedHashSet<>(fromReturns);
|
||||||
|
if (unique.size() == 1) {
|
||||||
|
return List.copyOf(unique);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveTypeFqn(String typeName) {
|
||||||
|
if (typeName == null || typeName.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeName.contains(".")) {
|
||||||
|
if (context.getTypeDeclaration(typeName) != null) {
|
||||||
|
return typeName;
|
||||||
|
}
|
||||||
|
// May be simple-with-package missing; still try as-is for resolve.
|
||||||
|
return typeName;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(typeName);
|
||||||
|
if (td != null) {
|
||||||
|
String fqn = context.getFqn(td);
|
||||||
|
return fqn != null ? fqn : typeName;
|
||||||
|
}
|
||||||
|
return typeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop symbolic / enum-set / blank; keep FQN or bare constant identifiers.
|
||||||
|
*/
|
||||||
|
public static String normalizeConstant(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.startsWith("<SYMBOLIC:") || value.startsWith("ENUM_SET:") || value.contains(".*>")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
// Enum.CONST.name() peel may already be bare; QualName may be FQN.
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.flow.eventid;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.flow.alloc.ReceiverFacts;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proven virtual accessor on a receiver with allocation (or concrete-type) facts.
|
||||||
|
*/
|
||||||
|
public final class AccessProof {
|
||||||
|
|
||||||
|
private final ReceiverFacts receiverFacts;
|
||||||
|
private final String accessorName;
|
||||||
|
private final MethodInvocation accessorCall;
|
||||||
|
private final MethodDeclaration enclosingMethod;
|
||||||
|
|
||||||
|
public AccessProof(
|
||||||
|
ReceiverFacts receiverFacts,
|
||||||
|
String accessorName,
|
||||||
|
MethodInvocation accessorCall,
|
||||||
|
MethodDeclaration enclosingMethod) {
|
||||||
|
this.receiverFacts = receiverFacts == null ? ReceiverFacts.empty() : receiverFacts;
|
||||||
|
this.accessorName = accessorName;
|
||||||
|
this.accessorCall = accessorCall;
|
||||||
|
this.enclosingMethod = enclosingMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReceiverFacts receiverFacts() {
|
||||||
|
return receiverFacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String accessorName() {
|
||||||
|
return accessorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MethodInvocation accessorCall() {
|
||||||
|
return accessorCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MethodDeclaration enclosingMethod() {
|
||||||
|
return enclosingMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValid() {
|
||||||
|
return accessorName != null
|
||||||
|
&& !accessorName.isBlank()
|
||||||
|
&& !receiverFacts.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||||
|
import org.eclipse.jdt.core.dom.FieldDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves constant values for fields referenced by indexed trivial accessors.
|
||||||
|
*/
|
||||||
|
public final class AccessorFieldResolver {
|
||||||
|
|
||||||
|
private AccessorFieldResolver() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> resolveFieldConstants(
|
||||||
|
AccessorSummary accessor,
|
||||||
|
String requestingTypeFqn,
|
||||||
|
CodebaseContext context,
|
||||||
|
ConstructorAnalyzer constructorAnalyzer,
|
||||||
|
CompilationUnit contextCu,
|
||||||
|
Set<String> visited,
|
||||||
|
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor) {
|
||||||
|
if (accessor == null || !accessor.isGetter() || context == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (accessor.kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER
|
||||||
|
|| accessor.fieldName() == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
String traceTypeFqn = requestingTypeFqn != null && !requestingTypeFqn.isBlank()
|
||||||
|
? requestingTypeFqn
|
||||||
|
: accessor.declaringFqn();
|
||||||
|
TypeDeclaration typeDeclaration = contextCu != null
|
||||||
|
? context.getTypeDeclaration(traceTypeFqn, contextCu)
|
||||||
|
: null;
|
||||||
|
if (typeDeclaration == null) {
|
||||||
|
typeDeclaration = context.getTypeDeclaration(traceTypeFqn);
|
||||||
|
}
|
||||||
|
if (typeDeclaration == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> constants = new ArrayList<>();
|
||||||
|
if (constructorAnalyzer != null) {
|
||||||
|
constants.addAll(constructorAnalyzer.traceFieldInConstructors(
|
||||||
|
typeDeclaration, accessor.fieldName(), context, visited));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (constants.isEmpty()) {
|
||||||
|
collectFieldInitializerConstants(
|
||||||
|
typeDeclaration, accessor.fieldName(), constantExtractor, constants, context);
|
||||||
|
}
|
||||||
|
return constants;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collectFieldInitializerConstants(
|
||||||
|
TypeDeclaration typeDeclaration,
|
||||||
|
String fieldName,
|
||||||
|
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor,
|
||||||
|
List<String> constants,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (constantExtractor == null || typeDeclaration == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
|
||||||
|
for (Object fragmentObj : fieldDeclaration.fragments()) {
|
||||||
|
if (fragmentObj instanceof VariableDeclarationFragment fragment
|
||||||
|
&& fragment.getName().getIdentifier().equals(fieldName)
|
||||||
|
&& fragment.getInitializer() != null) {
|
||||||
|
constantExtractor.accept(fragment.getInitializer(), constants);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (constants.isEmpty() && context != null) {
|
||||||
|
String superFqn = context.getSuperclassFqn(typeDeclaration);
|
||||||
|
if (superFqn != null && !superFqn.equals("java.lang.Object")) {
|
||||||
|
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
|
||||||
|
if (superType != null) {
|
||||||
|
collectFieldInitializerConstants(superType, fieldName, constantExtractor, constants, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public class AccessorIndex {
|
||||||
|
|
||||||
|
private static final AccessorIndex EMPTY = new AccessorIndex(Map.of(), 0, 0, 0);
|
||||||
|
|
||||||
|
private final Map<String, Map<String, AccessorSummary>> byOwnerAndMethod;
|
||||||
|
private final int typeCount;
|
||||||
|
private final int trivialCount;
|
||||||
|
private final int blockedCount;
|
||||||
|
|
||||||
|
public AccessorIndex(
|
||||||
|
Map<String, Map<String, AccessorSummary>> byOwnerAndMethod,
|
||||||
|
int typeCount,
|
||||||
|
int trivialCount,
|
||||||
|
int blockedCount) {
|
||||||
|
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
|
||||||
|
this.typeCount = typeCount;
|
||||||
|
this.trivialCount = trivialCount;
|
||||||
|
this.blockedCount = blockedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AccessorIndex empty() {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AccessorSummary> lookup(String ownerFqn, String methodName) {
|
||||||
|
if (ownerFqn == null || methodName == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Map<String, AccessorSummary> methods = byOwnerAndMethod.get(ownerFqn);
|
||||||
|
if (methods == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.ofNullable(methods.get(methodName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AccessorSummary> accessorsForType(String ownerFqn) {
|
||||||
|
Map<String, AccessorSummary> methods = byOwnerAndMethod.get(ownerFqn);
|
||||||
|
if (methods == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return List.copyOf(methods.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Map<String, AccessorSummary>> asMap() {
|
||||||
|
return byOwnerAndMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int typeCount() {
|
||||||
|
return typeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int trivialCount() {
|
||||||
|
return trivialCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int blockedCount() {
|
||||||
|
return blockedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Map<String, AccessorSummary>> deepCopy(Map<String, Map<String, AccessorSummary>> source) {
|
||||||
|
Map<String, Map<String, AccessorSummary>> copy = new HashMap<>();
|
||||||
|
for (Map.Entry<String, Map<String, AccessorSummary>> entry : source.entrySet()) {
|
||||||
|
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.eclipse.jdt.core.dom.*;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public class AccessorIndexBuilder {
|
||||||
|
|
||||||
|
private final TrivialAccessorDetector detector = new TrivialAccessorDetector();
|
||||||
|
private CodebaseContext context;
|
||||||
|
|
||||||
|
public AccessorIndex build(CodebaseContext context) {
|
||||||
|
this.context = context;
|
||||||
|
Map<String, DirectTypeAccessors> direct = new HashMap<>();
|
||||||
|
|
||||||
|
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||||
|
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
|
||||||
|
for (Object typeObj : cu.types()) {
|
||||||
|
if (typeObj instanceof TypeDeclaration td) {
|
||||||
|
indexTypeDeclaration(td, direct);
|
||||||
|
} else if (typeObj instanceof RecordDeclaration rd) {
|
||||||
|
indexRecord(rd, packageName, direct);
|
||||||
|
} else if (typeObj instanceof EnumDeclaration ed) {
|
||||||
|
indexEnum(ed, packageName, direct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int blocked = direct.values().stream().mapToInt(state -> state.blockedMethods.size()).sum();
|
||||||
|
|
||||||
|
Map<String, Map<String, AccessorSummary>> effective = new HashMap<>();
|
||||||
|
int trivial = 0;
|
||||||
|
for (String ownerFqn : direct.keySet()) {
|
||||||
|
Map<String, AccessorSummary> merged = mergeEffective(ownerFqn, direct, effective, new HashSet<>());
|
||||||
|
if (!merged.isEmpty()) {
|
||||||
|
effective.put(ownerFqn, merged);
|
||||||
|
trivial += merged.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Built accessor index: {} trivial accessors across {} types ({} blocked overrides)",
|
||||||
|
trivial,
|
||||||
|
effective.size(),
|
||||||
|
blocked);
|
||||||
|
|
||||||
|
this.context = null;
|
||||||
|
return new AccessorIndex(effective, effective.size(), trivial, blocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void indexTypeDeclaration(TypeDeclaration td, Map<String, DirectTypeAccessors> direct) {
|
||||||
|
String ownerFqn = context.getFqn(td);
|
||||||
|
DirectTypeAccessors state = direct.computeIfAbsent(ownerFqn, k -> new DirectTypeAccessors());
|
||||||
|
for (MethodDeclaration md : td.getMethods()) {
|
||||||
|
classifyMethod(ownerFqn, md, fieldName -> findFieldDeclaringFqn(td, fieldName), state);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (TypeDeclaration nested : td.getTypes()) {
|
||||||
|
indexTypeDeclaration(nested, direct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void indexEnum(EnumDeclaration ed, String parentFqn, Map<String, DirectTypeAccessors> direct) {
|
||||||
|
String ownerFqn = parentFqn.isEmpty() ? ed.getName().getIdentifier() : parentFqn + "." + ed.getName().getIdentifier();
|
||||||
|
DirectTypeAccessors state = direct.computeIfAbsent(ownerFqn, k -> new DirectTypeAccessors());
|
||||||
|
|
||||||
|
for (Object decl : ed.bodyDeclarations()) {
|
||||||
|
if (decl instanceof MethodDeclaration md) {
|
||||||
|
classifyMethod(ownerFqn, md, fieldName -> findEnumFieldDeclaringFqn(ed, ownerFqn, fieldName), state);
|
||||||
|
} else if (decl instanceof TypeDeclaration nestedTd) {
|
||||||
|
indexTypeDeclaration(nestedTd, direct);
|
||||||
|
} else if (decl instanceof RecordDeclaration nestedRd) {
|
||||||
|
indexRecord(nestedRd, ownerFqn, direct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void indexRecord(RecordDeclaration rd, String parentFqn, Map<String, DirectTypeAccessors> direct) {
|
||||||
|
String ownerFqn = parentFqn.isEmpty() ? rd.getName().getIdentifier() : parentFqn + "." + rd.getName().getIdentifier();
|
||||||
|
DirectTypeAccessors state = direct.computeIfAbsent(ownerFqn, k -> new DirectTypeAccessors());
|
||||||
|
|
||||||
|
for (Object compObj : rd.recordComponents()) {
|
||||||
|
String componentName = recordComponentName(compObj);
|
||||||
|
if (componentName == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
detector.detectRecordComponent(ownerFqn, componentName).ifPresent(summary ->
|
||||||
|
state.trivial.put(summary.methodName(), summary));
|
||||||
|
}
|
||||||
|
|
||||||
|
TrivialAccessorDetector.FieldLookup recordFieldLookup =
|
||||||
|
fieldName -> findRecordFieldDeclaringFqn(rd, ownerFqn, fieldName);
|
||||||
|
|
||||||
|
for (Object declObj : rd.bodyDeclarations()) {
|
||||||
|
if (declObj instanceof MethodDeclaration md) {
|
||||||
|
String methodName = md.getName().getIdentifier();
|
||||||
|
AccessorSummary existing = state.trivial.get(methodName);
|
||||||
|
if (existing != null && existing.kind() == AccessorKind.RECORD_COMPONENT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
classifyMethod(ownerFqn, md, recordFieldLookup, state);
|
||||||
|
} else if (declObj instanceof TypeDeclaration nestedTd) {
|
||||||
|
indexTypeDeclaration(nestedTd, direct);
|
||||||
|
} else if (declObj instanceof RecordDeclaration nestedRd) {
|
||||||
|
indexRecord(nestedRd, ownerFqn, direct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void classifyMethod(
|
||||||
|
String ownerFqn,
|
||||||
|
MethodDeclaration md,
|
||||||
|
TrivialAccessorDetector.FieldLookup fieldLookup,
|
||||||
|
DirectTypeAccessors state) {
|
||||||
|
if (md.getBody() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String methodName = md.getName().getIdentifier();
|
||||||
|
Optional<AccessorSummary> summary = detector.detect(ownerFqn, md, fieldLookup);
|
||||||
|
applyClassification(state, methodName, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyClassification(DirectTypeAccessors state, String methodName, Optional<AccessorSummary> summary) {
|
||||||
|
if (summary.isPresent()) {
|
||||||
|
state.trivial.put(methodName, summary.get());
|
||||||
|
state.blockedMethods.remove(methodName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (looksLikeAccessorMethod(methodName)) {
|
||||||
|
state.blockedMethods.add(methodName);
|
||||||
|
state.trivial.remove(methodName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean looksLikeAccessorMethod(String methodName) {
|
||||||
|
return methodName.startsWith("get")
|
||||||
|
|| methodName.startsWith("set")
|
||||||
|
|| methodName.startsWith("is");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, AccessorSummary> mergeEffective(
|
||||||
|
String ownerFqn,
|
||||||
|
Map<String, DirectTypeAccessors> direct,
|
||||||
|
Map<String, Map<String, AccessorSummary>> cache,
|
||||||
|
Set<String> visiting) {
|
||||||
|
ownerFqn = resolveTypeFqn(ownerFqn);
|
||||||
|
if (ownerFqn == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
if (cache.containsKey(ownerFqn)) {
|
||||||
|
return cache.get(ownerFqn);
|
||||||
|
}
|
||||||
|
if (!visiting.add(ownerFqn)) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, AccessorSummary> merged = new HashMap<>();
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(ownerFqn);
|
||||||
|
if (td != null) {
|
||||||
|
String superFqn = resolveTypeFqn(context.getSuperclassFqn(td));
|
||||||
|
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
|
||||||
|
for (Map.Entry<String, AccessorSummary> inherited : mergeEffective(superFqn, direct, cache, visiting).entrySet()) {
|
||||||
|
merged.put(inherited.getKey(), rebindOwner(inherited.getValue(), ownerFqn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DirectTypeAccessors own = direct.get(ownerFqn);
|
||||||
|
if (own != null) {
|
||||||
|
own.blockedMethods.forEach(merged::remove);
|
||||||
|
merged.putAll(own.trivial);
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.remove(ownerFqn);
|
||||||
|
cache.put(ownerFqn, Map.copyOf(merged));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AccessorSummary rebindOwner(AccessorSummary summary, String ownerFqn) {
|
||||||
|
if (summary.ownerFqn().equals(ownerFqn)) {
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
return new AccessorSummary(
|
||||||
|
ownerFqn,
|
||||||
|
summary.methodName(),
|
||||||
|
summary.kind(),
|
||||||
|
summary.fieldName(),
|
||||||
|
summary.paramName(),
|
||||||
|
summary.declaringFqn(),
|
||||||
|
summary.returnsThis()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveTypeFqn(String typeName) {
|
||||||
|
if (typeName == null || typeName.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeName.contains(".")) {
|
||||||
|
return typeName;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(typeName);
|
||||||
|
return td != null ? context.getFqn(td) : typeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class DirectTypeAccessors {
|
||||||
|
private final Map<String, AccessorSummary> trivial = new HashMap<>();
|
||||||
|
private final Set<String> blockedMethods = new HashSet<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> findFieldDeclaringFqn(TypeDeclaration td, String fieldName) {
|
||||||
|
return findFieldDeclaringFqn(td, fieldName, new HashSet<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> findFieldDeclaringFqn(TypeDeclaration td, String fieldName, Set<String> visited) {
|
||||||
|
if (td == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String ownerFqn = context.getFqn(td);
|
||||||
|
if (!visited.add(ownerFqn)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (FieldDeclaration fd : td.getFields()) {
|
||||||
|
for (Object fragObj : fd.fragments()) {
|
||||||
|
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
|
||||||
|
if (frag.getName().getIdentifier().equals(fieldName)) {
|
||||||
|
return Optional.of(ownerFqn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String superFqn = context.getSuperclassFqn(td);
|
||||||
|
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
|
||||||
|
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
||||||
|
if (superTd != null) {
|
||||||
|
return findFieldDeclaringFqn(superTd, fieldName, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> findEnumFieldDeclaringFqn(EnumDeclaration ed, String ownerFqn, String fieldName) {
|
||||||
|
for (Object decl : ed.bodyDeclarations()) {
|
||||||
|
if (decl instanceof FieldDeclaration fd) {
|
||||||
|
for (Object fragObj : fd.fragments()) {
|
||||||
|
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
|
||||||
|
if (frag.getName().getIdentifier().equals(fieldName)) {
|
||||||
|
return Optional.of(ownerFqn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> findRecordFieldDeclaringFqn(RecordDeclaration rd, String ownerFqn, String fieldName) {
|
||||||
|
for (Object compObj : rd.recordComponents()) {
|
||||||
|
String componentName = recordComponentName(compObj);
|
||||||
|
if (fieldName.equals(componentName)) {
|
||||||
|
return Optional.of(ownerFqn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String recordComponentName(Object compObj) {
|
||||||
|
if (compObj instanceof SingleVariableDeclaration svd) {
|
||||||
|
return svd.getName().getIdentifier();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
java.lang.reflect.Method getNameMethod = compObj.getClass().getMethod("getName");
|
||||||
|
Object nameObj = getNameMethod.invoke(compObj);
|
||||||
|
if (nameObj instanceof SimpleName sn) {
|
||||||
|
return sn.getIdentifier();
|
||||||
|
}
|
||||||
|
} catch (ReflectiveOperationException ignored) {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||||
|
import org.eclipse.jdt.core.dom.Expression;
|
||||||
|
import org.eclipse.jdt.core.dom.IMethodBinding;
|
||||||
|
import org.eclipse.jdt.core.dom.ITypeBinding;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public final class AccessorInlining {
|
||||||
|
|
||||||
|
private AccessorInlining() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Optional<AccessorSummary> lookupAccessor(
|
||||||
|
CodebaseContext context,
|
||||||
|
MethodInvocation mi,
|
||||||
|
List<Expression> receiverDefinitions) {
|
||||||
|
if (context == null || mi == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String methodName = mi.getName().getIdentifier();
|
||||||
|
if (isLikelyNonBeanGet(methodName, mi)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receiverDefinitions != null) {
|
||||||
|
for (Expression receiverDef : receiverDefinitions) {
|
||||||
|
if (receiverDef instanceof ClassInstanceCreation cic) {
|
||||||
|
Optional<AccessorSummary> concrete = lookupOnType(context, resolveTypeFqn(cic), methodName);
|
||||||
|
if (concrete.isPresent()) {
|
||||||
|
return concrete;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IMethodBinding methodBinding = mi.resolveMethodBinding();
|
||||||
|
if (methodBinding != null && methodBinding.getDeclaringClass() != null) {
|
||||||
|
ITypeBinding declaringClass = methodBinding.getDeclaringClass().getErasure();
|
||||||
|
Optional<AccessorSummary> declared = lookupOnType(context, declaringClass.getQualifiedName(), methodName);
|
||||||
|
if (declared.isPresent()) {
|
||||||
|
return declared;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Optional<AccessorSummary> lookupAccessor(CodebaseContext context, MethodInvocation mi) {
|
||||||
|
return lookupAccessor(context, mi, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<AccessorSummary> lookupOnType(CodebaseContext context, String ownerFqn, String methodName) {
|
||||||
|
if (ownerFqn == null || ownerFqn.isBlank() || ownerFqn.startsWith("java.")) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return context.getAccessorIndex().lookup(ownerFqn, methodName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String resolveTypeFqn(ClassInstanceCreation cic) {
|
||||||
|
if (cic == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (cic.resolveConstructorBinding() != null && cic.resolveConstructorBinding().getDeclaringClass() != null) {
|
||||||
|
return cic.resolveConstructorBinding().getDeclaringClass().getErasure().getQualifiedName();
|
||||||
|
}
|
||||||
|
if (cic.resolveTypeBinding() != null) {
|
||||||
|
return cic.resolveTypeBinding().getErasure().getQualifiedName();
|
||||||
|
}
|
||||||
|
return AstUtils.extractSimpleTypeName(cic.getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isLikelyNonBeanGet(String methodName, MethodInvocation mi) {
|
||||||
|
if (!"get".equals(methodName) && !"getOrDefault".equals(methodName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (mi.arguments().size() != 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
IMethodBinding methodBinding = mi.resolveMethodBinding();
|
||||||
|
if (methodBinding == null || methodBinding.getDeclaringClass() == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String ownerFqn = methodBinding.getDeclaringClass().getErasure().getQualifiedName();
|
||||||
|
return ownerFqn != null && ownerFqn.startsWith("java.util");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
public enum AccessorKind {
|
||||||
|
GETTER,
|
||||||
|
BOOLEAN_GETTER,
|
||||||
|
CONSTANT_GETTER,
|
||||||
|
RECORD_COMPONENT,
|
||||||
|
SETTER,
|
||||||
|
FLUENT_SETTER
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared JavaBean accessor naming helpers used by analysis and dataflow code.
|
||||||
|
*/
|
||||||
|
public final class AccessorNaming {
|
||||||
|
|
||||||
|
private AccessorNaming() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isBeanStyleAccessorName(String accessorName) {
|
||||||
|
return (accessorName.startsWith("get") && accessorName.length() > 3)
|
||||||
|
|| (accessorName.startsWith("is") && accessorName.length() > 2)
|
||||||
|
|| (accessorName.startsWith("set") && accessorName.length() > 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String propertyNameFromAccessor(String accessorName) {
|
||||||
|
if (accessorName.startsWith("get") && accessorName.length() > 3) {
|
||||||
|
return TrivialAccessorDetector.decapitalize(accessorName.substring(3));
|
||||||
|
}
|
||||||
|
if (accessorName.startsWith("is") && accessorName.length() > 2) {
|
||||||
|
return TrivialAccessorDetector.decapitalize(accessorName.substring(2));
|
||||||
|
}
|
||||||
|
if (accessorName.startsWith("set") && accessorName.length() > 3) {
|
||||||
|
return TrivialAccessorDetector.decapitalize(accessorName.substring(3));
|
||||||
|
}
|
||||||
|
return accessorName.startsWith("get") ? accessorName.substring(3) : accessorName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String setterMethodNameForGetter(String getterName) {
|
||||||
|
return "set" + capitalizeProperty(propertyNameFromAccessor(getterName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String capitalizeProperty(String propertyName) {
|
||||||
|
if (propertyName == null || propertyName.isEmpty()) {
|
||||||
|
return propertyName;
|
||||||
|
}
|
||||||
|
if (propertyName.length() > 1 && Character.isUpperCase(propertyName.charAt(1))) {
|
||||||
|
return propertyName;
|
||||||
|
}
|
||||||
|
return Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean looksLikeIndexedAccessorName(String methodName) {
|
||||||
|
return methodName.startsWith("get") || methodName.startsWith("is") || methodName.startsWith("set");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
public record AccessorSummary(
|
||||||
|
String ownerFqn,
|
||||||
|
String methodName,
|
||||||
|
AccessorKind kind,
|
||||||
|
String fieldName,
|
||||||
|
String paramName,
|
||||||
|
String declaringFqn,
|
||||||
|
boolean returnsThis
|
||||||
|
) {
|
||||||
|
public boolean isGetter() {
|
||||||
|
return kind == AccessorKind.GETTER
|
||||||
|
|| kind == AccessorKind.BOOLEAN_GETTER
|
||||||
|
|| kind == AccessorKind.CONSTANT_GETTER
|
||||||
|
|| kind == AccessorKind.RECORD_COMPONENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSetter() {
|
||||||
|
return kind == AccessorKind.SETTER || kind == AccessorKind.FLUENT_SETTER;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public final class FieldTypeIndex {
|
||||||
|
|
||||||
|
private static final FieldTypeIndex EMPTY = new FieldTypeIndex(Map.of(), 0);
|
||||||
|
|
||||||
|
private final Map<String, Map<String, String>> byOwnerAndField;
|
||||||
|
private final int fieldCount;
|
||||||
|
|
||||||
|
public FieldTypeIndex(Map<String, Map<String, String>> byOwnerAndField, int fieldCount) {
|
||||||
|
this.byOwnerAndField = Collections.unmodifiableMap(deepCopy(byOwnerAndField));
|
||||||
|
this.fieldCount = fieldCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FieldTypeIndex empty() {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<String> lookup(String ownerFqn, String fieldName) {
|
||||||
|
if (ownerFqn == null || fieldName == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Map<String, String> fields = byOwnerAndField.get(ownerFqn);
|
||||||
|
if (fields == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.ofNullable(fields.get(fieldName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int fieldCount() {
|
||||||
|
return fieldCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Map<String, String>> deepCopy(Map<String, Map<String, String>> source) {
|
||||||
|
Map<String, Map<String, String>> copy = new HashMap<>();
|
||||||
|
for (Map.Entry<String, Map<String, String>> entry : source.entrySet()) {
|
||||||
|
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.eclipse.jdt.core.dom.FieldDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.Modifier;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public final class FieldTypeIndexBuilder {
|
||||||
|
|
||||||
|
private CodebaseContext context;
|
||||||
|
|
||||||
|
public FieldTypeIndex build(CodebaseContext context) {
|
||||||
|
this.context = context;
|
||||||
|
if (context == null) {
|
||||||
|
return FieldTypeIndex.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Map<String, String>> direct = new HashMap<>();
|
||||||
|
for (String ownerFqn : context.getIndexedTypeFqns()) {
|
||||||
|
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
|
||||||
|
if (typeDeclaration != null) {
|
||||||
|
indexDeclaredFields(ownerFqn, typeDeclaration, direct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Map<String, String>> effective = new HashMap<>();
|
||||||
|
int fieldCount = 0;
|
||||||
|
Map<String, Map<String, String>> cache = new HashMap<>();
|
||||||
|
for (String ownerFqn : direct.keySet()) {
|
||||||
|
Map<String, String> merged = mergeEffective(ownerFqn, direct, cache, new HashSet<>());
|
||||||
|
if (!merged.isEmpty()) {
|
||||||
|
effective.put(ownerFqn, merged);
|
||||||
|
fieldCount += merged.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Built field-type index: {} fields across {} types", fieldCount, effective.size());
|
||||||
|
this.context = null;
|
||||||
|
return new FieldTypeIndex(effective, fieldCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void indexDeclaredFields(
|
||||||
|
String ownerFqn,
|
||||||
|
TypeDeclaration typeDeclaration,
|
||||||
|
Map<String, Map<String, String>> direct) {
|
||||||
|
Map<String, String> fields = direct.computeIfAbsent(ownerFqn, ignored -> new HashMap<>());
|
||||||
|
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
|
||||||
|
String fieldType = fieldDeclaration.getType().toString();
|
||||||
|
for (Object fragmentObj : fieldDeclaration.fragments()) {
|
||||||
|
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
|
||||||
|
fields.put(fragment.getName().getIdentifier(), fieldType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (TypeDeclaration nested : typeDeclaration.getTypes()) {
|
||||||
|
indexDeclaredFields(context.getFqn(nested), nested, direct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> mergeEffective(
|
||||||
|
String ownerFqn,
|
||||||
|
Map<String, Map<String, String>> direct,
|
||||||
|
Map<String, Map<String, String>> cache,
|
||||||
|
Set<String> visiting) {
|
||||||
|
if (cache.containsKey(ownerFqn)) {
|
||||||
|
return cache.get(ownerFqn);
|
||||||
|
}
|
||||||
|
if (!visiting.add(ownerFqn)) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> merged = new HashMap<>();
|
||||||
|
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
|
||||||
|
if (ownerType != null) {
|
||||||
|
String superFqn = context.getSuperclassFqn(ownerType);
|
||||||
|
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
|
||||||
|
merged.putAll(mergeEffective(superFqn, direct, cache, visiting));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> own = direct.get(ownerFqn);
|
||||||
|
if (own != null) {
|
||||||
|
merged.putAll(own);
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.remove(ownerFqn);
|
||||||
|
cache.put(ownerFqn, Map.copyOf(merged));
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan-time result for {@code owner.getX()} getter-chain hops: the next receiver type
|
||||||
|
* and optional {@code new ...()} field initializer discovered on the backing field.
|
||||||
|
*/
|
||||||
|
public record GetterChainEdge(
|
||||||
|
String ownerFqn,
|
||||||
|
String getterName,
|
||||||
|
String nextTypeFqn,
|
||||||
|
ClassInstanceCreation fieldInitializerCic) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public final class GetterChainIndex {
|
||||||
|
|
||||||
|
private static final GetterChainIndex EMPTY = new GetterChainIndex(Map.of(), 0);
|
||||||
|
|
||||||
|
private final Map<String, Map<String, GetterChainEdge>> byOwnerAndGetter;
|
||||||
|
private final int edgeCount;
|
||||||
|
|
||||||
|
public GetterChainIndex(Map<String, Map<String, GetterChainEdge>> byOwnerAndGetter, int edgeCount) {
|
||||||
|
this.byOwnerAndGetter = Collections.unmodifiableMap(deepCopy(byOwnerAndGetter));
|
||||||
|
this.edgeCount = edgeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GetterChainIndex empty() {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<GetterChainEdge> lookup(String ownerFqn, String getterName) {
|
||||||
|
if (ownerFqn == null || getterName == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Map<String, GetterChainEdge> getters = byOwnerAndGetter.get(ownerFqn);
|
||||||
|
if (getters == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.ofNullable(getters.get(getterName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int edgeCount() {
|
||||||
|
return edgeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Map<String, GetterChainEdge>> deepCopy(
|
||||||
|
Map<String, Map<String, GetterChainEdge>> source) {
|
||||||
|
Map<String, Map<String, GetterChainEdge>> copy = new HashMap<>();
|
||||||
|
for (Map.Entry<String, Map<String, GetterChainEdge>> entry : source.entrySet()) {
|
||||||
|
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public final class GetterChainIndexBuilder {
|
||||||
|
|
||||||
|
public GetterChainIndex build(CodebaseContext context, AccessorIndex accessorIndex) {
|
||||||
|
if (context == null || accessorIndex == null) {
|
||||||
|
return GetterChainIndex.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
FieldInitializerFinder fieldInitializerFinder = new FieldInitializerFinder(context);
|
||||||
|
Map<String, Map<String, GetterChainEdge>> edges = new HashMap<>();
|
||||||
|
int edgeCount = 0;
|
||||||
|
|
||||||
|
for (Map.Entry<String, Map<String, AccessorSummary>> ownerEntry : accessorIndex.asMap().entrySet()) {
|
||||||
|
String ownerFqn = ownerEntry.getKey();
|
||||||
|
for (AccessorSummary accessor : ownerEntry.getValue().values()) {
|
||||||
|
if (!accessor.isGetter()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
GetterChainEdge edge = buildEdge(context, fieldInitializerFinder, ownerFqn, accessor);
|
||||||
|
if (edge == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
edges.computeIfAbsent(ownerFqn, ignored -> new HashMap<>())
|
||||||
|
.put(accessor.methodName(), edge);
|
||||||
|
edgeCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Built getter-chain index: {} edges across {} types", edgeCount, edges.size());
|
||||||
|
return new GetterChainIndex(edges, edgeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GetterChainEdge buildEdge(
|
||||||
|
CodebaseContext context,
|
||||||
|
FieldInitializerFinder fieldInitializerFinder,
|
||||||
|
String ownerFqn,
|
||||||
|
AccessorSummary accessor) {
|
||||||
|
String nextType = null;
|
||||||
|
ClassInstanceCreation fieldInitializerCic = null;
|
||||||
|
|
||||||
|
if (accessor.fieldName() != null) {
|
||||||
|
fieldInitializerCic = fieldInitializerFinder.findFieldInitializerCic(ownerFqn, accessor.fieldName());
|
||||||
|
nextType = fieldInitializerFinder.resolveFieldTypeName(ownerFqn, accessor.fieldName());
|
||||||
|
if (fieldInitializerCic != null) {
|
||||||
|
nextType = AstUtils.extractSimpleTypeName(fieldInitializerCic.getType());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextType == null) {
|
||||||
|
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
|
||||||
|
MethodDeclaration getter = ownerType != null
|
||||||
|
? context.findMethodDeclaration(ownerType, accessor.methodName(), true)
|
||||||
|
: null;
|
||||||
|
if (getter != null && getter.getReturnType2() != null) {
|
||||||
|
nextType = getter.getReturnType2().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextType == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new GetterChainEdge(ownerFqn, accessor.methodName(), nextType, fieldInitializerCic);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan-time constants for indexed getters backed by fields with literal or enum initializers.
|
||||||
|
*/
|
||||||
|
public final class IndexedFieldConstantsIndex {
|
||||||
|
|
||||||
|
private static final IndexedFieldConstantsIndex EMPTY = new IndexedFieldConstantsIndex(Map.of(), 0);
|
||||||
|
|
||||||
|
private final Map<String, Map<String, List<String>>> byOwnerAndMethod;
|
||||||
|
private final int entryCount;
|
||||||
|
|
||||||
|
public IndexedFieldConstantsIndex(Map<String, Map<String, List<String>>> byOwnerAndMethod, int entryCount) {
|
||||||
|
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
|
||||||
|
this.entryCount = entryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IndexedFieldConstantsIndex empty() {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<List<String>> lookup(String ownerFqn, String methodName) {
|
||||||
|
if (ownerFqn == null || methodName == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Map<String, List<String>> methods = byOwnerAndMethod.get(ownerFqn);
|
||||||
|
if (methods == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
List<String> constants = methods.get(methodName);
|
||||||
|
if (constants == null || constants.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(constants);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int entryCount() {
|
||||||
|
return entryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Map<String, List<String>>> deepCopy(Map<String, Map<String, List<String>>> source) {
|
||||||
|
Map<String, Map<String, List<String>>> copy = new HashMap<>();
|
||||||
|
for (Map.Entry<String, Map<String, List<String>>> entry : source.entrySet()) {
|
||||||
|
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public final class IndexedFieldConstantsIndexBuilder {
|
||||||
|
|
||||||
|
public IndexedFieldConstantsIndex build(CodebaseContext context, AccessorIndex accessorIndex) {
|
||||||
|
if (context == null || accessorIndex == null) {
|
||||||
|
return IndexedFieldConstantsIndex.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Map<String, List<String>>> entries = new HashMap<>();
|
||||||
|
int entryCount = 0;
|
||||||
|
|
||||||
|
for (Map.Entry<String, Map<String, AccessorSummary>> ownerEntry : accessorIndex.asMap().entrySet()) {
|
||||||
|
String ownerFqn = ownerEntry.getKey();
|
||||||
|
for (AccessorSummary accessor : ownerEntry.getValue().values()) {
|
||||||
|
if (!accessor.isGetter() || accessor.fieldName() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<String> constants = ScanTimeAccessorConstants.resolve(
|
||||||
|
context, accessorIndex, ownerFqn, accessor.methodName());
|
||||||
|
if (constants.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.computeIfAbsent(ownerFqn, ignored -> new HashMap<>())
|
||||||
|
.put(accessor.methodName(), List.copyOf(constants));
|
||||||
|
entryCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Built indexed field-constants index: {} entries across {} types", entryCount, entries.size());
|
||||||
|
return new IndexedFieldConstantsIndex(entries, entryCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan-time summary of how a getter on an interface or abstract type resolves across
|
||||||
|
* its concrete implementations. Values are derived from static analysis only.
|
||||||
|
*/
|
||||||
|
public record PolymorphicAccessorEntry(
|
||||||
|
String ownerFqn,
|
||||||
|
String methodName,
|
||||||
|
Optional<AccessorSummary> representativeImplAccessor,
|
||||||
|
List<String> widenedReturnConstants,
|
||||||
|
boolean staticallyComplete) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public final class PolymorphicAccessorIndex {
|
||||||
|
|
||||||
|
private static final PolymorphicAccessorIndex EMPTY = new PolymorphicAccessorIndex(Map.of(), 0);
|
||||||
|
|
||||||
|
private final Map<String, Map<String, PolymorphicAccessorEntry>> byOwnerAndMethod;
|
||||||
|
private final int entryCount;
|
||||||
|
|
||||||
|
public PolymorphicAccessorIndex(
|
||||||
|
Map<String, Map<String, PolymorphicAccessorEntry>> byOwnerAndMethod,
|
||||||
|
int entryCount) {
|
||||||
|
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
|
||||||
|
this.entryCount = entryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PolymorphicAccessorIndex empty() {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<PolymorphicAccessorEntry> lookup(String ownerFqn, String methodName) {
|
||||||
|
if (ownerFqn == null || methodName == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Map<String, PolymorphicAccessorEntry> methods = byOwnerAndMethod.get(ownerFqn);
|
||||||
|
if (methods == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.ofNullable(methods.get(methodName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AccessorSummary> representativeImplAccessor(String ownerFqn, String methodName) {
|
||||||
|
return lookup(ownerFqn, methodName)
|
||||||
|
.flatMap(PolymorphicAccessorEntry::representativeImplAccessor);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<List<String>> widenedReturnConstants(String ownerFqn, String methodName) {
|
||||||
|
return lookup(ownerFqn, methodName)
|
||||||
|
.filter(PolymorphicAccessorEntry::staticallyComplete)
|
||||||
|
.map(PolymorphicAccessorEntry::widenedReturnConstants)
|
||||||
|
.filter(values -> !values.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
public int entryCount() {
|
||||||
|
return entryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Map<String, PolymorphicAccessorEntry>> deepCopy(
|
||||||
|
Map<String, Map<String, PolymorphicAccessorEntry>> source) {
|
||||||
|
Map<String, Map<String, PolymorphicAccessorEntry>> copy = new HashMap<>();
|
||||||
|
for (Map.Entry<String, Map<String, PolymorphicAccessorEntry>> entry : source.entrySet()) {
|
||||||
|
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.Modifier;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public final class PolymorphicAccessorIndexBuilder {
|
||||||
|
|
||||||
|
public PolymorphicAccessorIndex build(CodebaseContext context, AccessorIndex accessorIndex) {
|
||||||
|
if (context == null || accessorIndex == null) {
|
||||||
|
return PolymorphicAccessorIndex.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Map<String, PolymorphicAccessorEntry>> entries = new HashMap<>();
|
||||||
|
int entryCount = 0;
|
||||||
|
|
||||||
|
for (String typeFqn : context.getIndexedTypeFqns()) {
|
||||||
|
TypeDeclaration typeDeclaration = context.getTypeDeclaration(typeFqn);
|
||||||
|
if (!isPolymorphicType(typeDeclaration)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<String> implementations = context.getImplementations(typeFqn);
|
||||||
|
if (implementations.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> methodNames = collectGetterMethodNames(context, accessorIndex, typeFqn, implementations);
|
||||||
|
for (String methodName : methodNames) {
|
||||||
|
PolymorphicAccessorEntry entry = buildEntry(
|
||||||
|
context, accessorIndex, typeFqn, methodName, implementations);
|
||||||
|
if (entry == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.computeIfAbsent(typeFqn, ignored -> new HashMap<>()).put(methodName, entry);
|
||||||
|
entryCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Built polymorphic accessor index: {} entries across {} types", entryCount, entries.size());
|
||||||
|
return new PolymorphicAccessorIndex(entries, entryCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PolymorphicAccessorEntry buildEntry(
|
||||||
|
CodebaseContext context,
|
||||||
|
AccessorIndex accessorIndex,
|
||||||
|
String ownerFqn,
|
||||||
|
String methodName,
|
||||||
|
List<String> implementations) {
|
||||||
|
LinkedHashSet<String> widenedConstants = new LinkedHashSet<>();
|
||||||
|
widenedConstants.addAll(ScanTimeAccessorConstants.resolve(context, accessorIndex, ownerFqn, methodName));
|
||||||
|
|
||||||
|
Optional<AccessorSummary> representative = findRepresentativeImplAccessor(accessorIndex, implementations, methodName);
|
||||||
|
for (String implementationFqn : implementations) {
|
||||||
|
widenedConstants.addAll(
|
||||||
|
ScanTimeAccessorConstants.resolve(context, accessorIndex, implementationFqn, methodName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (representative.isEmpty() && widenedConstants.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
boolean staticallyComplete = isStaticallyComplete(accessorIndex, methodName, implementations);
|
||||||
|
return new PolymorphicAccessorEntry(
|
||||||
|
ownerFqn,
|
||||||
|
methodName,
|
||||||
|
representative,
|
||||||
|
List.copyOf(widenedConstants),
|
||||||
|
staticallyComplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isStaticallyComplete(
|
||||||
|
AccessorIndex accessorIndex,
|
||||||
|
String methodName,
|
||||||
|
List<String> implementations) {
|
||||||
|
for (String implementationFqn : implementations) {
|
||||||
|
if (!isConstantLikeAccessor(accessorIndex, implementationFqn, methodName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isConstantLikeAccessor(
|
||||||
|
AccessorIndex accessorIndex,
|
||||||
|
String typeFqn,
|
||||||
|
String methodName) {
|
||||||
|
Optional<AccessorSummary> accessor = accessorIndex.lookup(typeFqn, methodName);
|
||||||
|
if (accessor.isEmpty() || !accessor.get().isGetter()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return accessor.get().kind() == AccessorKind.CONSTANT_GETTER;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<AccessorSummary> findRepresentativeImplAccessor(
|
||||||
|
AccessorIndex accessorIndex,
|
||||||
|
List<String> implementations,
|
||||||
|
String methodName) {
|
||||||
|
for (String implementationFqn : implementations) {
|
||||||
|
Optional<AccessorSummary> implAccessor = accessorIndex.lookup(implementationFqn, methodName);
|
||||||
|
if (implAccessor.isPresent() && implAccessor.get().isGetter()) {
|
||||||
|
return implAccessor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> collectGetterMethodNames(
|
||||||
|
CodebaseContext context,
|
||||||
|
AccessorIndex accessorIndex,
|
||||||
|
String ownerFqn,
|
||||||
|
List<String> implementations) {
|
||||||
|
Set<String> methodNames = new LinkedHashSet<>();
|
||||||
|
for (AccessorSummary accessor : accessorIndex.accessorsForType(ownerFqn)) {
|
||||||
|
if (accessor.isGetter()) {
|
||||||
|
methodNames.add(accessor.methodName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
|
||||||
|
if (ownerType != null) {
|
||||||
|
for (MethodDeclaration method : ownerType.getMethods()) {
|
||||||
|
String name = method.getName().getIdentifier();
|
||||||
|
if (looksLikeGetterName(name)) {
|
||||||
|
methodNames.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String implementationFqn : implementations) {
|
||||||
|
for (AccessorSummary accessor : accessorIndex.accessorsForType(implementationFqn)) {
|
||||||
|
if (accessor.isGetter()) {
|
||||||
|
methodNames.add(accessor.methodName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return methodNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPolymorphicType(TypeDeclaration typeDeclaration) {
|
||||||
|
return typeDeclaration != null
|
||||||
|
&& (typeDeclaration.isInterface()
|
||||||
|
|| Modifier.isAbstract(typeDeclaration.getModifiers()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean looksLikeGetterName(String methodName) {
|
||||||
|
return methodName.startsWith("get") || methodName.startsWith("is");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan-time expansion of interface/abstract method calls to concrete implementation method FQNs.
|
||||||
|
*/
|
||||||
|
public final class PolymorphicMethodExpansionIndex {
|
||||||
|
|
||||||
|
private static final PolymorphicMethodExpansionIndex EMPTY =
|
||||||
|
new PolymorphicMethodExpansionIndex(Map.of(), 0);
|
||||||
|
|
||||||
|
private final Map<String, Map<String, List<String>>> byOwnerAndMethod;
|
||||||
|
private final int entryCount;
|
||||||
|
|
||||||
|
public PolymorphicMethodExpansionIndex(
|
||||||
|
Map<String, Map<String, List<String>>> byOwnerAndMethod,
|
||||||
|
int entryCount) {
|
||||||
|
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
|
||||||
|
this.entryCount = entryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PolymorphicMethodExpansionIndex empty() {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<List<String>> lookup(String ownerFqn, String methodName) {
|
||||||
|
if (ownerFqn == null || methodName == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Map<String, List<String>> methods = byOwnerAndMethod.get(ownerFqn);
|
||||||
|
if (methods == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
List<String> expanded = methods.get(methodName);
|
||||||
|
if (expanded == null || expanded.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int entryCount() {
|
||||||
|
return entryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Map<String, List<String>>> deepCopy(
|
||||||
|
Map<String, Map<String, List<String>>> source) {
|
||||||
|
Map<String, Map<String, List<String>>> copy = new HashMap<>();
|
||||||
|
for (Map.Entry<String, Map<String, List<String>>> entry : source.entrySet()) {
|
||||||
|
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.eclipse.jdt.core.dom.Modifier;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public final class PolymorphicMethodExpansionIndexBuilder {
|
||||||
|
|
||||||
|
public PolymorphicMethodExpansionIndex build(CodebaseContext context) {
|
||||||
|
if (context == null) {
|
||||||
|
return PolymorphicMethodExpansionIndex.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Map<String, List<String>>> entries = new HashMap<>();
|
||||||
|
int entryCount = 0;
|
||||||
|
|
||||||
|
for (String typeFqn : context.getIndexedTypeFqns()) {
|
||||||
|
TypeDeclaration typeDeclaration = context.getTypeDeclaration(typeFqn);
|
||||||
|
if (!isPolymorphicType(typeDeclaration)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<String> implementations = context.getImplementations(typeFqn);
|
||||||
|
if (implementations.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> methodNames = collectMethodNames(context, typeDeclaration, implementations);
|
||||||
|
for (String methodName : methodNames) {
|
||||||
|
List<String> expanded = new ArrayList<>();
|
||||||
|
for (String implementationFqn : implementations) {
|
||||||
|
expanded.add(implementationFqn + "." + methodName);
|
||||||
|
}
|
||||||
|
entries.computeIfAbsent(typeFqn, ignored -> new HashMap<>())
|
||||||
|
.put(methodName, List.copyOf(expanded));
|
||||||
|
entryCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Built polymorphic method expansion index: {} entries across {} types",
|
||||||
|
entryCount, entries.size());
|
||||||
|
return new PolymorphicMethodExpansionIndex(entries, entryCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> collectMethodNames(
|
||||||
|
CodebaseContext context,
|
||||||
|
TypeDeclaration ownerType,
|
||||||
|
List<String> implementations) {
|
||||||
|
Set<String> methodNames = new LinkedHashSet<>();
|
||||||
|
if (ownerType != null) {
|
||||||
|
for (var method : ownerType.getMethods()) {
|
||||||
|
methodNames.add(method.getName().getIdentifier());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String implementationFqn : implementations) {
|
||||||
|
TypeDeclaration implementationType = context.getTypeDeclaration(implementationFqn);
|
||||||
|
if (implementationType == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (var method : implementationType.getMethods()) {
|
||||||
|
methodNames.add(method.getName().getIdentifier());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return methodNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPolymorphicType(TypeDeclaration typeDeclaration) {
|
||||||
|
return typeDeclaration != null
|
||||||
|
&& (typeDeclaration.isInterface()
|
||||||
|
|| Modifier.isAbstract(typeDeclaration.getModifiers()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||||
|
import org.eclipse.jdt.core.dom.Expression;
|
||||||
|
import org.eclipse.jdt.core.dom.FieldDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves accessor return constants using only scan-time information: indexed accessors,
|
||||||
|
* literal method bodies, and field initializers. No dataflow or constructor tracing.
|
||||||
|
*/
|
||||||
|
public final class ScanTimeAccessorConstants {
|
||||||
|
|
||||||
|
private ScanTimeAccessorConstants() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> resolve(
|
||||||
|
CodebaseContext context,
|
||||||
|
AccessorIndex accessorIndex,
|
||||||
|
String ownerFqn,
|
||||||
|
String methodName) {
|
||||||
|
if (context == null || accessorIndex == null || ownerFqn == null || methodName == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Set<String> deduped = new LinkedHashSet<>();
|
||||||
|
Optional<AccessorSummary> indexed = accessorIndex.lookup(ownerFqn, methodName);
|
||||||
|
if (indexed.isPresent() && indexed.get().isGetter()) {
|
||||||
|
if (indexed.get().kind() == AccessorKind.CONSTANT_GETTER) {
|
||||||
|
deduped.addAll(extractReturnConstants(context, ownerFqn, methodName));
|
||||||
|
} else if (indexed.get().fieldName() != null) {
|
||||||
|
deduped.addAll(extractFieldInitializerConstants(
|
||||||
|
context, ownerFqn, indexed.get().fieldName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deduped.isEmpty()) {
|
||||||
|
deduped.addAll(extractReturnConstants(context, ownerFqn, methodName));
|
||||||
|
}
|
||||||
|
return List.copyOf(deduped);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> extractReturnConstants(
|
||||||
|
CodebaseContext context,
|
||||||
|
String ownerFqn,
|
||||||
|
String methodName) {
|
||||||
|
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
|
||||||
|
if (typeDeclaration == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
|
||||||
|
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> constants = new ArrayList<>();
|
||||||
|
ConstantResolver constantResolver = context.getConstantResolver();
|
||||||
|
methodDeclaration.getBody().accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(ReturnStatement node) {
|
||||||
|
Expression expression = node.getExpression();
|
||||||
|
if (expression != null) {
|
||||||
|
addResolvedExpression(constantResolver, context, expression, constants);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return constants;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> extractFieldInitializerConstants(
|
||||||
|
CodebaseContext context,
|
||||||
|
String ownerFqn,
|
||||||
|
String fieldName) {
|
||||||
|
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
|
||||||
|
if (typeDeclaration == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> constants = new ArrayList<>();
|
||||||
|
collectFieldInitializerConstants(context, typeDeclaration, fieldName, constants);
|
||||||
|
return constants;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collectFieldInitializerConstants(
|
||||||
|
CodebaseContext context,
|
||||||
|
TypeDeclaration typeDeclaration,
|
||||||
|
String fieldName,
|
||||||
|
List<String> constants) {
|
||||||
|
ConstantResolver constantResolver = context.getConstantResolver();
|
||||||
|
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
|
||||||
|
for (Object fragmentObj : fieldDeclaration.fragments()) {
|
||||||
|
if (fragmentObj instanceof VariableDeclarationFragment fragment
|
||||||
|
&& fragment.getName().getIdentifier().equals(fieldName)
|
||||||
|
&& fragment.getInitializer() != null) {
|
||||||
|
addResolvedExpression(
|
||||||
|
constantResolver, context, fragment.getInitializer(), constants);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (constants.isEmpty()) {
|
||||||
|
String superFqn = context.getSuperclassFqn(typeDeclaration);
|
||||||
|
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
|
||||||
|
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
|
||||||
|
if (superType != null) {
|
||||||
|
collectFieldInitializerConstants(context, superType, fieldName, constants);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addResolvedExpression(
|
||||||
|
ConstantResolver constantResolver,
|
||||||
|
CodebaseContext context,
|
||||||
|
Expression expression,
|
||||||
|
List<String> constants) {
|
||||||
|
if (constantResolver == null || expression == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String resolved = constantResolver.resolve(expression, context);
|
||||||
|
if (resolved == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resolved.startsWith("ENUM_SET:")) {
|
||||||
|
for (String value : resolved.substring(9).split(",")) {
|
||||||
|
addUnique(constants, parseEnumSetElement(value));
|
||||||
|
}
|
||||||
|
} else if (resolved.startsWith("MAP:")) {
|
||||||
|
List<String> mapValues = ConstantResolver.decodeMapLiteralValues(resolved);
|
||||||
|
if (mapValues != null) {
|
||||||
|
for (String mapValue : mapValues) {
|
||||||
|
addUnique(constants, mapValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (resolved.startsWith("ARRAY:")) {
|
||||||
|
for (String arrayValue : resolved.substring(6).split("\\|", -1)) {
|
||||||
|
addUnique(constants, arrayValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addUnique(constants, resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String parseEnumSetElement(String value) {
|
||||||
|
int lastDot = value.lastIndexOf('.');
|
||||||
|
int secondLastDot = lastDot > 0 ? value.lastIndexOf('.', lastDot - 1) : -1;
|
||||||
|
return secondLastDot >= 0 ? value.substring(secondLastDot + 1) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addUnique(List<String> constants, String value) {
|
||||||
|
if (value != null && !constants.contains(value)) {
|
||||||
|
constants.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.core.dom.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects trivial JavaBean-style getters and setters whose bodies only read or write a single field.
|
||||||
|
*/
|
||||||
|
public class TrivialAccessorDetector {
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface FieldLookup extends Function<String, Optional<String>> {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AccessorSummary> detect(String ownerFqn, MethodDeclaration method, FieldLookup fieldLookup) {
|
||||||
|
if (ownerFqn == null || method == null || method.getBody() == null || fieldLookup == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String methodName = method.getName().getIdentifier();
|
||||||
|
if (method.isConstructor()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<String> getterField = propertyNameForGetter(methodName, method);
|
||||||
|
if (getterField.isPresent()) {
|
||||||
|
Optional<AccessorSummary> fieldGetter = detectGetter(ownerFqn, method, methodName, getterField.get(), fieldLookup);
|
||||||
|
if (fieldGetter.isPresent()) {
|
||||||
|
return fieldGetter;
|
||||||
|
}
|
||||||
|
return detectConstantGetter(ownerFqn, method, methodName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<String> setterField = propertyNameForSetter(methodName);
|
||||||
|
if (setterField.isPresent() && !method.parameters().isEmpty()) {
|
||||||
|
return detectSetter(ownerFqn, method, methodName, setterField.get(), fieldLookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AccessorSummary> detectRecordComponent(String ownerFqn, String componentName) {
|
||||||
|
if (ownerFqn == null || componentName == null || componentName.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(new AccessorSummary(
|
||||||
|
ownerFqn,
|
||||||
|
componentName,
|
||||||
|
AccessorKind.RECORD_COMPONENT,
|
||||||
|
componentName,
|
||||||
|
null,
|
||||||
|
ownerFqn,
|
||||||
|
false
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<AccessorSummary> detectConstantGetter(
|
||||||
|
String ownerFqn,
|
||||||
|
MethodDeclaration method,
|
||||||
|
String methodName) {
|
||||||
|
List<?> statements = method.getBody().statements();
|
||||||
|
if (statements.size() != 1 || !(statements.get(0) instanceof ReturnStatement rs)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Expression expr = rs.getExpression();
|
||||||
|
if (expr == null || !isConstantReturnExpression(expr)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(new AccessorSummary(
|
||||||
|
ownerFqn,
|
||||||
|
methodName,
|
||||||
|
AccessorKind.CONSTANT_GETTER,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
ownerFqn,
|
||||||
|
false
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isConstantReturnExpression(Expression expr) {
|
||||||
|
Expression current = unwrap(expr);
|
||||||
|
if (current instanceof StringLiteral
|
||||||
|
|| current instanceof NumberLiteral
|
||||||
|
|| current instanceof BooleanLiteral
|
||||||
|
|| current instanceof CharacterLiteral) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (current instanceof QualifiedName) {
|
||||||
|
return !containsMethodCall(current);
|
||||||
|
}
|
||||||
|
if (current instanceof SimpleName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (current instanceof FieldAccess fa) {
|
||||||
|
Expression receiver = fa.getExpression();
|
||||||
|
return (receiver instanceof SimpleName || receiver instanceof QualifiedName)
|
||||||
|
&& !containsMethodCall(current);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<AccessorSummary> detectGetter(
|
||||||
|
String ownerFqn,
|
||||||
|
MethodDeclaration method,
|
||||||
|
String methodName,
|
||||||
|
String fieldName,
|
||||||
|
FieldLookup fieldLookup) {
|
||||||
|
if (!isTrivialGetterBody(method.getBody(), fieldName)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Optional<String> declaringFqn = fieldLookup.apply(fieldName);
|
||||||
|
if (declaringFqn.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessorKind kind = methodName.startsWith("is") ? AccessorKind.BOOLEAN_GETTER : AccessorKind.GETTER;
|
||||||
|
return Optional.of(new AccessorSummary(
|
||||||
|
ownerFqn,
|
||||||
|
methodName,
|
||||||
|
kind,
|
||||||
|
fieldName,
|
||||||
|
null,
|
||||||
|
declaringFqn.get(),
|
||||||
|
false
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<AccessorSummary> detectSetter(
|
||||||
|
String ownerFqn,
|
||||||
|
MethodDeclaration method,
|
||||||
|
String methodName,
|
||||||
|
String fieldName,
|
||||||
|
FieldLookup fieldLookup) {
|
||||||
|
SingleVariableDeclaration param = (SingleVariableDeclaration) method.parameters().get(0);
|
||||||
|
String paramName = param.getName().getIdentifier();
|
||||||
|
|
||||||
|
boolean fluent = isTrivialFluentSetterBody(method.getBody(), fieldName, paramName);
|
||||||
|
boolean voidSetter = isVoidReturn(method) && isTrivialVoidSetterBody(method.getBody(), fieldName, paramName);
|
||||||
|
if (!fluent && !voidSetter) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<String> declaringFqn = fieldLookup.apply(fieldName);
|
||||||
|
if (declaringFqn.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessorKind kind = fluent ? AccessorKind.FLUENT_SETTER : AccessorKind.SETTER;
|
||||||
|
return Optional.of(new AccessorSummary(
|
||||||
|
ownerFqn,
|
||||||
|
methodName,
|
||||||
|
kind,
|
||||||
|
fieldName,
|
||||||
|
paramName,
|
||||||
|
declaringFqn.get(),
|
||||||
|
fluent
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> propertyNameForGetter(String methodName, MethodDeclaration method) {
|
||||||
|
if (methodName.startsWith("get") && methodName.length() > 3) {
|
||||||
|
return Optional.of(decapitalize(methodName.substring(3)));
|
||||||
|
}
|
||||||
|
if (methodName.startsWith("is") && methodName.length() > 2 && isBooleanReturn(method)) {
|
||||||
|
return Optional.of(decapitalize(methodName.substring(2)));
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> propertyNameForSetter(String methodName) {
|
||||||
|
if (methodName.startsWith("set") && methodName.length() > 3) {
|
||||||
|
return Optional.of(decapitalize(methodName.substring(3)));
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBooleanReturn(MethodDeclaration method) {
|
||||||
|
return "boolean".equals(method.getReturnType2().toString())
|
||||||
|
|| "Boolean".equals(method.getReturnType2().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isVoidReturn(MethodDeclaration method) {
|
||||||
|
return method.getReturnType2() instanceof PrimitiveType pt && pt.getPrimitiveTypeCode() == PrimitiveType.VOID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String decapitalize(String name) {
|
||||||
|
if (name == null || name.isEmpty()) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
if (name.length() > 1 && Character.isUpperCase(name.charAt(1))) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isTrivialGetterBody(Block body, String fieldName) {
|
||||||
|
List<?> statements = body.statements();
|
||||||
|
if (statements.size() != 1 || !(statements.get(0) instanceof ReturnStatement rs)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Expression expr = rs.getExpression();
|
||||||
|
if (expr == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isDirectFieldRead(unwrap(expr), fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isTrivialVoidSetterBody(Block body, String fieldName, String paramName) {
|
||||||
|
List<?> statements = body.statements();
|
||||||
|
if (statements.size() != 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isAssignmentStatement(statements.get(0), fieldName, paramName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isTrivialFluentSetterBody(Block body, String fieldName, String paramName) {
|
||||||
|
List<?> statements = body.statements();
|
||||||
|
if (statements.size() != 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isAssignmentStatement(statements.get(0), fieldName, paramName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!(statements.get(1) instanceof ReturnStatement rs)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Expression ret = unwrap(rs.getExpression());
|
||||||
|
return ret instanceof ThisExpression;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAssignmentStatement(Object statement, String fieldName, String paramName) {
|
||||||
|
if (!(statement instanceof ExpressionStatement es)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!(es.getExpression() instanceof Assignment assignment)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (assignment.getOperator() != Assignment.Operator.ASSIGN) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isDirectFieldWrite(assignment.getLeftHandSide(), fieldName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Expression rhs = unwrap(assignment.getRightHandSide());
|
||||||
|
return rhs instanceof SimpleName sn && sn.getIdentifier().equals(paramName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDirectFieldRead(Expression expr, String fieldName) {
|
||||||
|
if (containsMethodCall(expr)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (expr instanceof SimpleName sn) {
|
||||||
|
return sn.getIdentifier().equals(fieldName);
|
||||||
|
}
|
||||||
|
if (expr instanceof FieldAccess fa) {
|
||||||
|
Expression receiver = fa.getExpression();
|
||||||
|
if (receiver == null || receiver instanceof ThisExpression) {
|
||||||
|
return fa.getName().getIdentifier().equals(fieldName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDirectFieldWrite(Expression expr, String fieldName) {
|
||||||
|
if (expr instanceof SimpleName sn) {
|
||||||
|
return sn.getIdentifier().equals(fieldName);
|
||||||
|
}
|
||||||
|
if (expr instanceof FieldAccess fa) {
|
||||||
|
Expression receiver = fa.getExpression();
|
||||||
|
if (receiver == null || receiver instanceof ThisExpression) {
|
||||||
|
return fa.getName().getIdentifier().equals(fieldName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean containsMethodCall(Expression expr) {
|
||||||
|
if (expr == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final boolean[] found = {false};
|
||||||
|
expr.accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(MethodInvocation node) {
|
||||||
|
found[0] = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return found[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression unwrap(Expression expr) {
|
||||||
|
Expression current = expr;
|
||||||
|
while (current instanceof ParenthesizedExpression pe) {
|
||||||
|
current = pe.getExpression();
|
||||||
|
}
|
||||||
|
while (current instanceof CastExpression ce) {
|
||||||
|
current = ce.getExpression();
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,13 @@ package click.kamil.springstatemachineexporter.analysis.model;
|
|||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
|
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
import click.kamil.springstatemachineexporter.model.Transition;
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@@ -31,10 +38,19 @@ public class AnalysisResult {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<BusinessFlow> flows = java.util.Collections.emptyList();
|
private List<BusinessFlow> flows = java.util.Collections.emptyList();
|
||||||
|
|
||||||
|
/** Resolved {@code StateMachineConfigurerAdapter<S,E>} state type; persisted for JSON round-trip. */
|
||||||
|
private String stateTypeFqn;
|
||||||
|
/** Resolved {@code StateMachineConfigurerAdapter<S,E>} event type; persisted for JSON round-trip. */
|
||||||
|
private String eventTypeFqn;
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private CodebaseMetadata metadata = CodebaseMetadata.empty();
|
private CodebaseMetadata metadata = CodebaseMetadata.empty();
|
||||||
|
|
||||||
public void applyResolution(Map<String, String> properties) {
|
public void applyResolution(Map<String, String> properties) {
|
||||||
|
applyResolution(properties, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void applyResolution(Map<String, String> properties, CodebaseContext context) {
|
||||||
var resolver = new click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver();
|
var resolver = new click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver();
|
||||||
|
|
||||||
// 1. Resolve start/end states strings
|
// 1. Resolve start/end states strings
|
||||||
@@ -52,7 +68,9 @@ public class AnalysisResult {
|
|||||||
// 2. Resolve states (State record is immutable, so recreate)
|
// 2. Resolve states (State record is immutable, so recreate)
|
||||||
if (states != null) {
|
if (states != null) {
|
||||||
this.states = states.stream()
|
this.states = states.stream()
|
||||||
.map(s -> click.kamil.springstatemachineexporter.model.State.of(s.rawName(), resolver.resolveValue(s.fullIdentifier(), properties)))
|
.map(s -> click.kamil.springstatemachineexporter.model.State.of(
|
||||||
|
resolver.resolveValue(s.rawName(), properties),
|
||||||
|
resolver.resolveValue(s.fullIdentifier(), properties)))
|
||||||
.collect(java.util.stream.Collectors.toSet());
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,15 +90,18 @@ public class AnalysisResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Resolve metadata (triggers, entry points)
|
// 4. Resolve metadata (triggers, entry points, call chains)
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
if (metadata.getTriggers() != null) {
|
List<TriggerPoint> resolvedTriggers = metadata.getTriggers() == null ? null
|
||||||
for (var trigger : metadata.getTriggers()) {
|
: metadata.getTriggers().stream()
|
||||||
if (trigger.getEvent() != null) {
|
.map(trigger -> resolveTrigger(trigger, resolver, properties, context))
|
||||||
trigger.setEvent(resolver.resolveValue(trigger.getEvent(), properties));
|
.collect(java.util.stream.Collectors.toList());
|
||||||
}
|
|
||||||
}
|
List<CallChain> resolvedCallChains = metadata.getCallChains() == null ? null
|
||||||
}
|
: metadata.getCallChains().stream()
|
||||||
|
.map(chain -> resolveCallChain(chain, resolver, properties, context))
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
|
||||||
if (metadata.getEntryPoints() != null) {
|
if (metadata.getEntryPoints() != null) {
|
||||||
for (var ep : metadata.getEntryPoints()) {
|
for (var ep : metadata.getEntryPoints()) {
|
||||||
if (ep.getName() != null) {
|
if (ep.getName() != null) {
|
||||||
@@ -95,7 +116,76 @@ public class AnalysisResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.metadata = CodebaseMetadata.builder()
|
||||||
|
.triggers(resolvedTriggers != null ? resolvedTriggers : metadata.getTriggers())
|
||||||
|
.entryPoints(metadata.getEntryPoints())
|
||||||
|
.callChains(resolvedCallChains != null ? resolvedCallChains : metadata.getCallChains())
|
||||||
|
.properties(metadata.getProperties())
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canonicalizeResolvedIdentifiers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void canonicalizeResolvedIdentifiers() {
|
||||||
|
if (stateTypeFqn == null && eventTypeFqn == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
click.kamil.springstatemachineexporter.analysis.service.AnalysisResultFinalizer.applyCanonicalization(
|
||||||
|
this,
|
||||||
|
null,
|
||||||
|
new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TriggerPoint resolveTrigger(
|
||||||
|
TriggerPoint trigger,
|
||||||
|
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
|
||||||
|
Map<String, String> properties,
|
||||||
|
CodebaseContext context) {
|
||||||
|
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
|
||||||
|
: trigger.getPolymorphicEvents().stream()
|
||||||
|
.map(value -> resolver.resolveValue(value, properties))
|
||||||
|
.map(value -> MachineEnumCanonicalizer.qualifyEventIdentifier(
|
||||||
|
value, trigger.getEventTypeFqn(), context))
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
String resolvedEvent = trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null;
|
||||||
|
String resolvedSource = trigger.getSourceState() != null
|
||||||
|
? resolver.resolveValue(trigger.getSourceState(), properties)
|
||||||
|
: null;
|
||||||
|
return trigger.toBuilder()
|
||||||
|
.event(resolvedEvent != null
|
||||||
|
? MachineEnumCanonicalizer.qualifyEventIdentifier(
|
||||||
|
resolvedEvent, trigger.getEventTypeFqn(), context)
|
||||||
|
: null)
|
||||||
|
.sourceState(resolvedSource != null
|
||||||
|
? MachineEnumCanonicalizer.canonicalizeLabel(
|
||||||
|
resolvedSource, trigger.getStateTypeFqn(), context)
|
||||||
|
: null)
|
||||||
|
.polymorphicEvents(polymorphicEvents)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CallChain resolveCallChain(
|
||||||
|
CallChain chain,
|
||||||
|
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
|
||||||
|
Map<String, String> properties,
|
||||||
|
CodebaseContext context) {
|
||||||
|
TriggerPoint trigger = chain.getTriggerPoint() == null
|
||||||
|
? null
|
||||||
|
: resolveTrigger(chain.getTriggerPoint(), resolver, properties, context);
|
||||||
|
List<MatchedTransition> matchedTransitions = chain.getMatchedTransitions() == null ? null
|
||||||
|
: chain.getMatchedTransitions().stream()
|
||||||
|
.map(matched -> MatchedTransition.builder()
|
||||||
|
.event(resolver.resolveValue(matched.getEvent(), properties))
|
||||||
|
.sourceState(resolver.resolveValue(matched.getSourceState(), properties))
|
||||||
|
.targetState(resolver.resolveValue(matched.getTargetState(), properties))
|
||||||
|
.build())
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
return chain.toBuilder()
|
||||||
|
.triggerPoint(trigger)
|
||||||
|
.matchedTransitions(matchedTransitions)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addMetadata(CodebaseMetadata newMetadata) {
|
public void addMetadata(CodebaseMetadata newMetadata) {
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import lombok.extern.jackson.Jacksonized;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder(toBuilder = true)
|
||||||
@Jacksonized
|
@Jacksonized
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public class BusinessFlow {
|
public class BusinessFlow {
|
||||||
private final String name;
|
private final String name;
|
||||||
private final String description;
|
private final String description;
|
||||||
private final List<String> steps; // List of Event names in order
|
private final List<FlowStep> steps;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package click.kamil.springstatemachineexporter.analysis.model;
|
package click.kamil.springstatemachineexporter.analysis.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.extern.jackson.Jacksonized;
|
import lombok.extern.jackson.Jacksonized;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder(toBuilder = true)
|
||||||
@Jacksonized
|
@Jacksonized
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public class CallChain {
|
public class CallChain {
|
||||||
@@ -17,4 +19,8 @@ public class CallChain {
|
|||||||
private final TriggerPoint triggerPoint;
|
private final TriggerPoint triggerPoint;
|
||||||
private final String contextMachineId;
|
private final String contextMachineId;
|
||||||
private final List<MatchedTransition> matchedTransitions;
|
private final List<MatchedTransition> matchedTransitions;
|
||||||
|
/** How this chain's trigger was linked; derived from trigger flags and matchedTransitions. */
|
||||||
|
private final LinkResolution linkResolution;
|
||||||
|
@JsonIgnore
|
||||||
|
private final Map<String, String> pathBindings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class CallEdge {
|
||||||
|
private String targetMethod;
|
||||||
|
private List<String> arguments;
|
||||||
|
private String receiver;
|
||||||
|
private String constraint;
|
||||||
|
/**
|
||||||
|
* Proven or CHA-refined receiver type FQN when known (field/param type, unique injection,
|
||||||
|
* or the concrete impl class of a polymorphic expansion target). Used by pathfinding to
|
||||||
|
* prune collaborator template-method fan-out; null means fail open.
|
||||||
|
*/
|
||||||
|
private String receiverTypeFqn;
|
||||||
|
|
||||||
|
public CallEdge(String targetMethod, List<String> arguments) {
|
||||||
|
this(targetMethod, arguments, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CallEdge(String targetMethod, List<String> arguments, String receiver) {
|
||||||
|
this(targetMethod, arguments, receiver, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CallEdge(String targetMethod, List<String> arguments, String receiver, String constraint) {
|
||||||
|
this(targetMethod, arguments, receiver, constraint, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CallEdge(
|
||||||
|
String targetMethod,
|
||||||
|
List<String> arguments,
|
||||||
|
String receiver,
|
||||||
|
String constraint,
|
||||||
|
String receiverTypeFqn) {
|
||||||
|
this.targetMethod = targetMethod;
|
||||||
|
this.arguments = arguments;
|
||||||
|
this.receiver = receiver;
|
||||||
|
this.constraint = constraint;
|
||||||
|
this.receiverTypeFqn = receiverTypeFqn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable identity keys for {@link EntryPoint} used by scoping, merge, and semantic dedup.
|
||||||
|
*/
|
||||||
|
public final class EntryPointKeys {
|
||||||
|
|
||||||
|
private EntryPointKeys() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer human-readable entry name (REST path / topic); otherwise {@code class#method}.
|
||||||
|
*/
|
||||||
|
public static String of(EntryPoint entryPoint) {
|
||||||
|
if (entryPoint == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
|
||||||
|
return entryPoint.getName();
|
||||||
|
}
|
||||||
|
if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) {
|
||||||
|
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry identity for a call chain: entry point when present, otherwise first hop / trigger site.
|
||||||
|
*/
|
||||||
|
public static String of(CallChain chain) {
|
||||||
|
if (chain == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String fromEntry = of(chain.getEntryPoint());
|
||||||
|
if (!fromEntry.isEmpty()) {
|
||||||
|
return fromEntry;
|
||||||
|
}
|
||||||
|
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty()) {
|
||||||
|
return normalizeMethodHop(chain.getMethodChain().get(0));
|
||||||
|
}
|
||||||
|
TriggerPoint trigger = chain.getTriggerPoint();
|
||||||
|
if (trigger != null && trigger.getClassName() != null && trigger.getMethodName() != null) {
|
||||||
|
return trigger.getClassName() + "#" + trigger.getMethodName();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts {@code com.example.Foo.bar} hop form to {@code com.example.Foo#bar}. */
|
||||||
|
public static String normalizeMethodHop(String hop) {
|
||||||
|
if (hop == null || hop.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int lastDot = hop.lastIndexOf('.');
|
||||||
|
if (lastDot <= 0 || lastDot >= hop.length() - 1) {
|
||||||
|
return hop;
|
||||||
|
}
|
||||||
|
return hop.substring(0, lastDot) + "#" + hop.substring(lastDot + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder(toBuilder = true)
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
@JsonDeserialize(using = FlowStepDeserializer.class)
|
||||||
|
@JsonSerialize(using = FlowStepSerializer.class)
|
||||||
|
public class FlowStep {
|
||||||
|
/** Source state identifier (package-canonical FQN or short form). */
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
private final String source;
|
||||||
|
/** Event identifier or {@code Source->Target} for anonymous transitions. */
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
private final String event;
|
||||||
|
/** Precomputed {@code #link_*} suffix for HTML/SVG highlight. */
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
private final String linkKey;
|
||||||
|
|
||||||
|
public static FlowStep ofEvent(String event) {
|
||||||
|
return FlowStep.builder().event(event).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonParser;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||||
|
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
class FlowStepDeserializer extends JsonDeserializer<FlowStep> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FlowStep deserialize(JsonParser parser, DeserializationContext context) throws IOException {
|
||||||
|
JsonNode node = parser.getCodec().readTree(parser);
|
||||||
|
if (node.isTextual()) {
|
||||||
|
return FlowStep.builder().event(node.asText()).build();
|
||||||
|
}
|
||||||
|
return FlowStep.builder()
|
||||||
|
.source(textOrNull(node, "source"))
|
||||||
|
.event(textOrNull(node, "event"))
|
||||||
|
.linkKey(textOrNull(node, "linkKey"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String textOrNull(JsonNode node, String field) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
if (value == null || value.isNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String text = value.asText();
|
||||||
|
return text.isBlank() ? null : text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonGenerator;
|
||||||
|
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||||
|
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
class FlowStepSerializer extends JsonSerializer<FlowStep> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void serialize(FlowStep step, JsonGenerator generator, SerializerProvider serializers) throws IOException {
|
||||||
|
if (step == null) {
|
||||||
|
generator.writeNull();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean structured = step.getSource() != null && !step.getSource().isBlank()
|
||||||
|
|| step.getLinkKey() != null && !step.getLinkKey().isBlank();
|
||||||
|
if (!structured && step.getEvent() != null && !step.getEvent().isBlank()) {
|
||||||
|
generator.writeString(step.getEvent());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
generator.writeStartObject();
|
||||||
|
if (step.getSource() != null && !step.getSource().isBlank()) {
|
||||||
|
generator.writeStringField("source", step.getSource());
|
||||||
|
}
|
||||||
|
if (step.getEvent() != null && !step.getEvent().isBlank()) {
|
||||||
|
generator.writeStringField("event", step.getEvent());
|
||||||
|
}
|
||||||
|
if (step.getLinkKey() != null && !step.getLinkKey().isBlank()) {
|
||||||
|
generator.writeStringField("linkKey", step.getLinkKey());
|
||||||
|
}
|
||||||
|
generator.writeEndObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,5 +13,7 @@ import java.util.List;
|
|||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public class LibraryHint {
|
public class LibraryHint {
|
||||||
private final String methodFqn; // e.g., "com.thirdparty.Workflow.send"
|
private final String methodFqn; // e.g., "com.thirdparty.Workflow.send"
|
||||||
private final String event; // The event it triggers
|
private final String event; // The event it triggers (static)
|
||||||
|
private final Integer eventArgumentIndex; // e.g., 0 to extract from the 0th argument
|
||||||
|
private final String eventArgumentMethod; // e.g., "getType" to extract from argument method call
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthetic trigger markers for persistence/lifecycle operations that do not fire
|
||||||
|
* {@code sendEvent} transitions (e.g. {@code StateMachinePersister.restore(...)}).
|
||||||
|
*/
|
||||||
|
public final class LifecycleTriggerMarkers {
|
||||||
|
|
||||||
|
public static final String PREFIX = "[LIFECYCLE:";
|
||||||
|
|
||||||
|
private LifecycleTriggerMarkers() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isLifecycle(String event) {
|
||||||
|
return event != null && event.startsWith(PREFIX) && event.endsWith("]");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String lifecycleType(String event) {
|
||||||
|
if (!isLifecycle(event)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return event.substring(PREFIX.length(), event.length() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String displayLabel(String event) {
|
||||||
|
String type = lifecycleType(event);
|
||||||
|
if (type == null) {
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
return switch (type.toUpperCase()) {
|
||||||
|
case "RESTORE" -> "Restore persisted state";
|
||||||
|
case "RESET" -> "Reset state machine";
|
||||||
|
default -> "Lifecycle: " + type.toLowerCase();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export metadata describing how a call-chain trigger was linked to state-machine transitions.
|
||||||
|
* Derived from existing trigger flags only — no additional heuristics.
|
||||||
|
*/
|
||||||
|
public enum LinkResolution {
|
||||||
|
/** Concrete trigger linked to one or more matched transitions. */
|
||||||
|
RESOLVED,
|
||||||
|
/** REST or other external trigger that cannot be statically linked. */
|
||||||
|
UNRESOLVED_EXTERNAL,
|
||||||
|
/** Call-graph widening or source-state ambiguity prevented linking. */
|
||||||
|
AMBIGUOUS_WIDEN,
|
||||||
|
/** Static trigger with no matching transition link. */
|
||||||
|
NO_MATCH
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
package click.kamil.springstatemachineexporter.analysis.model;
|
package click.kamil.springstatemachineexporter.analysis.model;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.extern.jackson.Jacksonized;
|
import lombok.extern.jackson.Jacksonized;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder(toBuilder = true)
|
||||||
@Jacksonized
|
@Jacksonized
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public class MatchedTransition {
|
public class MatchedTransition {
|
||||||
private final String sourceState;
|
private final String sourceState;
|
||||||
private final String targetState;
|
private final String targetState;
|
||||||
private final String event;
|
private final String event;
|
||||||
|
/** Precomputed {@code #link_*} suffix for HTML/SVG highlight; optional in JSON exports. */
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
private final String linkKey;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package click.kamil.springstatemachineexporter.analysis.model;
|
package click.kamil.springstatemachineexporter.analysis.model;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -8,7 +9,7 @@ import lombok.extern.jackson.Jacksonized;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder(toBuilder = true)
|
||||||
@Jacksonized
|
@Jacksonized
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public class TriggerPoint {
|
public class TriggerPoint {
|
||||||
@@ -20,4 +21,43 @@ public class TriggerPoint {
|
|||||||
private final String stateMachineId; // Optional: to link to a specific SM instance
|
private final String stateMachineId; // Optional: to link to a specific SM instance
|
||||||
private final String sourceState; // Optional: if we can determine the expected current state
|
private final String sourceState; // Optional: if we can determine the expected current state
|
||||||
private final int lineNumber;
|
private final int lineNumber;
|
||||||
|
@com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
private final String stateTypeFqn; // Type of State (e.g. OrderStates FQN)
|
||||||
|
@com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
private final String eventTypeFqn; // Type of Event (e.g. OrderEvents FQN)
|
||||||
|
private final java.util.List<String> polymorphicEvents; // NEW: stores concrete events resolved via deep polymorphism
|
||||||
|
private final boolean external;
|
||||||
|
private final String constraint;
|
||||||
|
private final boolean ambiguous;
|
||||||
|
|
||||||
|
public TriggerPoint(
|
||||||
|
String event,
|
||||||
|
String className,
|
||||||
|
String methodName,
|
||||||
|
String sourceFile,
|
||||||
|
String sourceModule,
|
||||||
|
String stateMachineId,
|
||||||
|
String sourceState,
|
||||||
|
int lineNumber,
|
||||||
|
String stateTypeFqn,
|
||||||
|
String eventTypeFqn,
|
||||||
|
java.util.List<String> polymorphicEvents,
|
||||||
|
boolean external,
|
||||||
|
String constraint,
|
||||||
|
boolean ambiguous) {
|
||||||
|
this.className = className;
|
||||||
|
this.methodName = methodName;
|
||||||
|
this.sourceFile = sourceFile;
|
||||||
|
this.sourceModule = sourceModule;
|
||||||
|
this.stateMachineId = stateMachineId;
|
||||||
|
this.sourceState = sourceState;
|
||||||
|
this.lineNumber = lineNumber;
|
||||||
|
this.stateTypeFqn = stateTypeFqn;
|
||||||
|
this.eventTypeFqn = eventTypeFqn;
|
||||||
|
this.event = event;
|
||||||
|
this.polymorphicEvents = polymorphicEvents;
|
||||||
|
this.external = external;
|
||||||
|
this.constraint = constraint;
|
||||||
|
this.ambiguous = ambiguous;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.index.AccessorKind;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.index.GetterChainEdge;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.pipeline.GetterBodyConstantScanner;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||||
|
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||||
|
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
|
||||||
|
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.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified accessor resolution pipeline: index lookup first, then field/CIC/method-body fallbacks.
|
||||||
|
*/
|
||||||
|
public final class AccessorResolver {
|
||||||
|
|
||||||
|
public record GetterChainStep(String typeFqn, ClassInstanceCreation cic) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReceiverContext(
|
||||||
|
TypeDeclaration ownerType,
|
||||||
|
ClassInstanceCreation cic,
|
||||||
|
CompilationUnit contextCu,
|
||||||
|
boolean anonymousGetter,
|
||||||
|
Map<String, String> fieldValues) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final CodebaseContext context;
|
||||||
|
private final ConstantExtractor constantExtractor;
|
||||||
|
private final ConstructorAnalyzer constructorAnalyzer;
|
||||||
|
private final ConstantResolver constantResolver;
|
||||||
|
private final FieldInitializerFinder fieldInitializerFinder;
|
||||||
|
private final Map<String, List<String>> sessionCache = new HashMap<>();
|
||||||
|
private final Map<String, Optional<AccessorSummary>> indexedGetterCache = new HashMap<>();
|
||||||
|
|
||||||
|
public AccessorResolver(
|
||||||
|
CodebaseContext context,
|
||||||
|
ConstantExtractor constantExtractor,
|
||||||
|
ConstructorAnalyzer constructorAnalyzer,
|
||||||
|
ConstantResolver constantResolver) {
|
||||||
|
this.context = context;
|
||||||
|
this.constantExtractor = constantExtractor;
|
||||||
|
this.constructorAnalyzer = constructorAnalyzer;
|
||||||
|
this.constantResolver = constantResolver;
|
||||||
|
this.fieldInitializerFinder = new FieldInitializerFinder(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AccessorSummary> lookupIndexedGetter(String className, String methodName) {
|
||||||
|
if (className == null || className.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String cacheKey = className + "#" + methodName;
|
||||||
|
if (indexedGetterCache.containsKey(cacheKey)) {
|
||||||
|
return indexedGetterCache.get(cacheKey);
|
||||||
|
}
|
||||||
|
Optional<AccessorSummary> resolved = lookupIndexedGetterInternal(className, methodName);
|
||||||
|
indexedGetterCache.put(cacheKey, resolved);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<AccessorSummary> lookupIndexedGetterInternal(String className, String methodName) {
|
||||||
|
Optional<AccessorSummary> direct = context.getAccessorIndex().lookup(className, methodName);
|
||||||
|
if (direct.isPresent()) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
|
||||||
|
if (typeDeclaration != null) {
|
||||||
|
direct = context.getAccessorIndex().lookup(context.getFqn(typeDeclaration), methodName);
|
||||||
|
if (direct.isPresent()) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
if (!typeDeclaration.isInterface() && !org.eclipse.jdt.core.dom.Modifier.isAbstract(typeDeclaration.getModifiers())) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> implementations = context.getImplementations(className);
|
||||||
|
if (implementations == null || implementations.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Optional<AccessorSummary> precomputed = context.getPolymorphicAccessorIndex()
|
||||||
|
.representativeImplAccessor(className, methodName);
|
||||||
|
if (precomputed.isPresent()) {
|
||||||
|
return precomputed;
|
||||||
|
}
|
||||||
|
for (String implementation : implementations) {
|
||||||
|
Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName);
|
||||||
|
if (implAccessor.isPresent()) {
|
||||||
|
return implAccessor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> resolve(String ownerFqn, String methodName, ReceiverContext receiverContext, ResolutionBudget budget) {
|
||||||
|
return resolve(ownerFqn, methodName, receiverContext, budget, new HashSet<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> resolve(
|
||||||
|
String ownerFqn,
|
||||||
|
String methodName,
|
||||||
|
ReceiverContext receiverContext,
|
||||||
|
ResolutionBudget budget,
|
||||||
|
Set<String> visited) {
|
||||||
|
if (ownerFqn == null || methodName == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (budget == null) {
|
||||||
|
budget = ResolutionBudget.defaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
String cacheKey = cacheKey(ownerFqn, methodName, receiverContext);
|
||||||
|
List<String> cached = sessionCache.get(cacheKey);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> resolved = resolveInternal(ownerFqn, methodName, receiverContext, budget, visited);
|
||||||
|
if (!resolved.isEmpty() || isStaticCacheable(receiverContext)) {
|
||||||
|
List<String> cachedCopy = List.copyOf(resolved);
|
||||||
|
sessionCache.put(cacheKey, cachedCopy);
|
||||||
|
return cachedCopy;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GetterChainStep resolveGetterChainStep(String ownerFqn, String getterName, ResolutionBudget budget) {
|
||||||
|
if (ownerFqn == null || getterName == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<GetterChainEdge> precomputed = context.getGetterChainIndex().lookup(ownerFqn, getterName);
|
||||||
|
if (precomputed.isPresent()) {
|
||||||
|
GetterChainEdge edge = precomputed.get();
|
||||||
|
return new GetterChainStep(edge.nextTypeFqn(), edge.fieldInitializerCic());
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(ownerFqn, getterName);
|
||||||
|
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
|
||||||
|
AccessorSummary accessor = indexedAccessor.get();
|
||||||
|
ClassInstanceCreation nextCic = fieldInitializerFinder.findFieldInitializerCic(ownerFqn, accessor.fieldName());
|
||||||
|
String nextType = fieldInitializerFinder.resolveFieldTypeName(ownerFqn, accessor.fieldName());
|
||||||
|
if (nextCic != null) {
|
||||||
|
nextType = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(nextCic.getType());
|
||||||
|
}
|
||||||
|
if (nextType != null) {
|
||||||
|
return new GetterChainStep(nextType, nextCic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String simpleType = simplifyTypeName(ownerFqn);
|
||||||
|
ClassInstanceCreation nextCic = constructorAnalyzer.findReturnedCIC(simpleType, getterName, context);
|
||||||
|
if (nextCic == null) {
|
||||||
|
String fieldName = indexedFieldName(ownerFqn, getterName);
|
||||||
|
nextCic = fieldInitializerFinder.findFieldInitializerCic(ownerFqn, fieldName);
|
||||||
|
}
|
||||||
|
if (nextCic != null) {
|
||||||
|
return new GetterChainStep(
|
||||||
|
click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(nextCic.getType()),
|
||||||
|
nextCic);
|
||||||
|
}
|
||||||
|
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(simpleType);
|
||||||
|
if (td == null) {
|
||||||
|
td = context.getTypeDeclaration(ownerFqn);
|
||||||
|
}
|
||||||
|
if (td != null) {
|
||||||
|
MethodDeclaration getter = context.findMethodDeclaration(td, getterName, true);
|
||||||
|
if (getter != null && getter.getReturnType2() != null) {
|
||||||
|
return new GetterChainStep(getter.getReturnType2().toString(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isStaticCacheable(ReceiverContext receiverContext) {
|
||||||
|
if (receiverContext == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (receiverContext.cic() != null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Map<String, String> fieldValues = receiverContext.fieldValues();
|
||||||
|
return fieldValues == null || fieldValues.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearSessionCache() {
|
||||||
|
sessionCache.clear();
|
||||||
|
indexedGetterCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> resolveInternal(
|
||||||
|
String ownerFqn,
|
||||||
|
String methodName,
|
||||||
|
ReceiverContext receiverContext,
|
||||||
|
ResolutionBudget budget,
|
||||||
|
Set<String> visited) {
|
||||||
|
TypeDeclaration ownerType = receiverContext != null ? receiverContext.ownerType() : null;
|
||||||
|
if (ownerType == null) {
|
||||||
|
ownerType = context.getTypeDeclaration(ownerFqn);
|
||||||
|
}
|
||||||
|
boolean widenToImplementations = ownerType == null
|
||||||
|
|| ownerType.isInterface()
|
||||||
|
|| org.eclipse.jdt.core.dom.Modifier.isAbstract(ownerType.getModifiers());
|
||||||
|
|
||||||
|
if (widenToImplementations) {
|
||||||
|
List<String> results = new ArrayList<>();
|
||||||
|
|
||||||
|
if (receiverContext != null && receiverContext.cic() != null) {
|
||||||
|
String cicOwnerFqn = resolveCicOwnerFqn(receiverContext);
|
||||||
|
if (cicOwnerFqn != null) {
|
||||||
|
TypeDeclaration cicOwnerType = context.getTypeDeclaration(
|
||||||
|
cicOwnerFqn, receiverContext.contextCu());
|
||||||
|
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(cicOwnerFqn, methodName);
|
||||||
|
ReceiverContext cicReceiverContext = new ReceiverContext(
|
||||||
|
cicOwnerType,
|
||||||
|
receiverContext.cic(),
|
||||||
|
receiverContext.contextCu(),
|
||||||
|
receiverContext.anonymousGetter(),
|
||||||
|
receiverContext.fieldValues());
|
||||||
|
List<String> cicValues = resolveWithCic(
|
||||||
|
cicOwnerFqn,
|
||||||
|
methodName,
|
||||||
|
cicReceiverContext,
|
||||||
|
indexedAccessor.orElse(null),
|
||||||
|
visited);
|
||||||
|
if (!cicValues.isEmpty()) {
|
||||||
|
return deduplicatePreservingOrder(cicValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (constantExtractor != null && !budget.isMethodReturnExhausted(0)) {
|
||||||
|
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : null;
|
||||||
|
results.addAll(constantExtractor.resolveMethodReturnConstant(
|
||||||
|
ownerFqn, methodName, 0, visited, contextCu, budget));
|
||||||
|
}
|
||||||
|
if (!results.isEmpty()) {
|
||||||
|
return deduplicatePreservingOrder(results);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(ownerFqn, methodName);
|
||||||
|
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
|
||||||
|
List<String> indexed = resolveIndexedAccessor(indexedAccessor.get(), receiverContext, visited);
|
||||||
|
if (!indexed.isEmpty()) {
|
||||||
|
return indexed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receiverContext != null && receiverContext.ownerType() != null && receiverContext.cic() != null) {
|
||||||
|
List<String> cicValues = resolveWithCic(
|
||||||
|
ownerFqn, methodName, receiverContext, indexedAccessor.orElse(null), visited);
|
||||||
|
if (!cicValues.isEmpty()) {
|
||||||
|
return cicValues;
|
||||||
|
}
|
||||||
|
} else if (receiverContext != null && receiverContext.cic() != null) {
|
||||||
|
ReceiverContext enriched = new ReceiverContext(
|
||||||
|
context.getTypeDeclaration(ownerFqn),
|
||||||
|
receiverContext.cic(),
|
||||||
|
receiverContext.contextCu(),
|
||||||
|
receiverContext.anonymousGetter(),
|
||||||
|
receiverContext.fieldValues());
|
||||||
|
List<String> cicValues = resolveWithCic(
|
||||||
|
ownerFqn, methodName, enriched, indexedAccessor.orElse(null), visited);
|
||||||
|
if (!cicValues.isEmpty()) {
|
||||||
|
return cicValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (constantExtractor != null && !budget.isMethodReturnExhausted(0)) {
|
||||||
|
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : null;
|
||||||
|
List<String> deep = constantExtractor.resolveMethodReturnConstant(
|
||||||
|
ownerFqn, methodName, 0, visited, contextCu, budget);
|
||||||
|
if (!deep.isEmpty()) {
|
||||||
|
return deep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> resolveIndexedAccessor(
|
||||||
|
AccessorSummary accessor,
|
||||||
|
ReceiverContext receiverContext,
|
||||||
|
Set<String> visited) {
|
||||||
|
if (accessor.kind() == AccessorKind.CONSTANT_GETTER) {
|
||||||
|
List<String> constants = extractConstantGetterReturn(accessor.ownerFqn(), accessor.methodName());
|
||||||
|
if (!constants.isEmpty()) {
|
||||||
|
return constants;
|
||||||
|
}
|
||||||
|
if (constantExtractor != null) {
|
||||||
|
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : null;
|
||||||
|
return constantExtractor.resolveMethodReturnConstant(
|
||||||
|
accessor.ownerFqn(), accessor.methodName(), 0, new HashSet<>(visited), contextCu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (constantExtractor != null) {
|
||||||
|
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : null;
|
||||||
|
String requestingTypeFqn = receiverContext != null && receiverContext.ownerType() != null
|
||||||
|
? context.getFqn(receiverContext.ownerType())
|
||||||
|
: accessor.ownerFqn();
|
||||||
|
List<String> fieldConstants = constantExtractor.resolveIndexedGetterFieldConstants(
|
||||||
|
accessor, requestingTypeFqn, contextCu, visited);
|
||||||
|
if (!fieldConstants.isEmpty()) {
|
||||||
|
return fieldConstants;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receiverContext != null
|
||||||
|
&& receiverContext.fieldValues() != null
|
||||||
|
&& accessor.fieldName() != null) {
|
||||||
|
String fieldValue = receiverContext.fieldValues().get(accessor.fieldName());
|
||||||
|
if (fieldValue != null) {
|
||||||
|
return List.of(fieldValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> resolveWithCic(
|
||||||
|
String ownerFqn,
|
||||||
|
String methodName,
|
||||||
|
ReceiverContext receiverContext,
|
||||||
|
AccessorSummary indexedAccessor,
|
||||||
|
Set<String> visited) {
|
||||||
|
TypeDeclaration td = receiverContext.ownerType();
|
||||||
|
if (td == null) {
|
||||||
|
td = context.getTypeDeclaration(ownerFqn);
|
||||||
|
}
|
||||||
|
ClassInstanceCreation cic = receiverContext.cic();
|
||||||
|
Map<String, String> fieldValues = receiverContext.fieldValues() != null
|
||||||
|
? receiverContext.fieldValues()
|
||||||
|
: new HashMap<>();
|
||||||
|
|
||||||
|
MethodDeclaration getter = null;
|
||||||
|
if (cic.getAnonymousClassDeclaration() != null) {
|
||||||
|
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
|
||||||
|
if (declObj instanceof MethodDeclaration md && md.getName().getIdentifier().equals(methodName)) {
|
||||||
|
getter = md;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (getter == null) {
|
||||||
|
getter = context.findMethodDeclaration(td, methodName, true);
|
||||||
|
}
|
||||||
|
if (getter != null && getter.getBody() != null) {
|
||||||
|
if (fieldValues.isEmpty() && constructorAnalyzer != null) {
|
||||||
|
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, null));
|
||||||
|
}
|
||||||
|
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, visited);
|
||||||
|
if (result != null) {
|
||||||
|
return List.of(qualifyEnumLikeResult(getter, result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indexedAccessor != null && indexedAccessor.isGetter() && !receiverContext.anonymousGetter()) {
|
||||||
|
if (indexedAccessor.kind() == AccessorKind.CONSTANT_GETTER) {
|
||||||
|
List<String> constantValues = extractConstantGetterReturn(ownerFqn, methodName);
|
||||||
|
if (!constantValues.isEmpty()) {
|
||||||
|
return constantValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fieldValues.isEmpty() && constructorAnalyzer != null) {
|
||||||
|
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, null));
|
||||||
|
}
|
||||||
|
if (indexedAccessor.fieldName() != null) {
|
||||||
|
String indexedFieldValue = fieldValues.get(indexedAccessor.fieldName());
|
||||||
|
if (indexedFieldValue != null) {
|
||||||
|
return List.of(indexedFieldValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (constantExtractor != null) {
|
||||||
|
List<String> indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants(
|
||||||
|
indexedAccessor, ownerFqn, receiverContext.contextCu(), visited);
|
||||||
|
if (!indexedConstants.isEmpty()) {
|
||||||
|
return indexedConstants;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> extractConstantGetterReturn(String ownerFqn, String methodName) {
|
||||||
|
return GetterBodyConstantScanner.extractConstantGetterReturn(
|
||||||
|
context, constantExtractor, constantResolver, ownerFqn, methodName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String qualifyEnumLikeResult(MethodDeclaration getter, String result) {
|
||||||
|
if (result.contains(".")) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null;
|
||||||
|
if (returnType == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
int lastDotRet = returnType.lastIndexOf('.');
|
||||||
|
String simpleReturnType = lastDotRet > 0 ? returnType.substring(lastDotRet + 1) : returnType;
|
||||||
|
if ("String".equals(simpleReturnType) || "Integer".equals(simpleReturnType)
|
||||||
|
|| "Long".equals(simpleReturnType) || "Boolean".equals(simpleReturnType)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return simpleReturnType + "." + result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String indexedFieldName(String ownerFqn, String getterName) {
|
||||||
|
Optional<AccessorSummary> accessorOpt = lookupIndexedGetter(ownerFqn, getterName);
|
||||||
|
if (accessorOpt.isEmpty() || accessorOpt.get().fieldName() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return accessorOpt.get().fieldName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveCicOwnerFqn(ReceiverContext receiverContext) {
|
||||||
|
if (receiverContext == null || receiverContext.cic() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ClassInstanceCreation cic = receiverContext.cic();
|
||||||
|
CompilationUnit contextCu = receiverContext.contextCu();
|
||||||
|
String simpleName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
|
||||||
|
TypeDeclaration cicType = contextCu != null
|
||||||
|
? context.getTypeDeclaration(simpleName, contextCu)
|
||||||
|
: null;
|
||||||
|
if (cicType == null) {
|
||||||
|
cicType = context.getTypeDeclaration(simpleName);
|
||||||
|
}
|
||||||
|
if (cicType != null) {
|
||||||
|
return context.getFqn(cicType);
|
||||||
|
}
|
||||||
|
if (cic.resolveTypeBinding() != null) {
|
||||||
|
return cic.resolveTypeBinding().getErasure().getQualifiedName();
|
||||||
|
}
|
||||||
|
return simpleName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> deduplicatePreservingOrder(List<String> values) {
|
||||||
|
List<String> deduped = new ArrayList<>();
|
||||||
|
for (String value : values) {
|
||||||
|
if (!deduped.contains(value)) {
|
||||||
|
deduped.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String cacheKey(String ownerFqn, String methodName, ReceiverContext receiverContext) {
|
||||||
|
String cicKey = receiverContext != null && receiverContext.cic() != null
|
||||||
|
? receiverContext.cic().toString()
|
||||||
|
: "";
|
||||||
|
String fieldValuesKey = receiverContext != null && receiverContext.fieldValues() != null
|
||||||
|
? receiverContext.fieldValues().toString()
|
||||||
|
: "";
|
||||||
|
return ownerFqn + "#" + methodName + "#" + cicKey + "#" + fieldValuesKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String simplifyTypeName(String typeFqn) {
|
||||||
|
if (typeFqn == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String simple = typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn;
|
||||||
|
if (simple.contains("<")) {
|
||||||
|
simple = simple.substring(0, simple.indexOf('<'));
|
||||||
|
}
|
||||||
|
return simple;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||||
|
|
||||||
|
import java.util.AbstractMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoizes polymorphic class compatibility checks used during call-graph path matching.
|
||||||
|
*/
|
||||||
|
public final class ClassCompatibilityCache {
|
||||||
|
|
||||||
|
private final Map<Map.Entry<String, String>, Boolean> cache = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Object> memoCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public boolean areClassesCompatible(String classNeighbor, String classTarget, ClassCompatibilityChecker checker) {
|
||||||
|
if (classNeighbor == null || classTarget == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (classNeighbor.equals(classTarget)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Map.Entry<String, String> key = cacheKey(classNeighbor, classTarget);
|
||||||
|
return cache.computeIfAbsent(key, ignored -> checker.check(classNeighbor, classTarget));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <T> T memoize(String key, java.util.function.Function<String, T> computer) {
|
||||||
|
return (T) memoCache.computeIfAbsent(key, computer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearMemoCache() {
|
||||||
|
memoCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clear() {
|
||||||
|
cache.clear();
|
||||||
|
memoCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map.Entry<String, String> cacheKey(String classNeighbor, String classTarget) {
|
||||||
|
if (classNeighbor.compareTo(classTarget) <= 0) {
|
||||||
|
return new AbstractMap.SimpleImmutableEntry<>(classNeighbor, classTarget);
|
||||||
|
}
|
||||||
|
return new AbstractMap.SimpleImmutableEntry<>(classTarget, classNeighbor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ClassCompatibilityChecker {
|
||||||
|
boolean check(String classNeighbor, String classTarget);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||||
|
import org.eclipse.jdt.core.dom.FieldDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds {@code new ...()} field initializers, walking the inheritance chain once.
|
||||||
|
*/
|
||||||
|
public final class FieldInitializerFinder {
|
||||||
|
|
||||||
|
private final CodebaseContext context;
|
||||||
|
|
||||||
|
public FieldInitializerFinder(CodebaseContext context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClassInstanceCreation findFieldInitializerCic(String ownerFqn, String fieldName) {
|
||||||
|
if (fieldName == null || ownerFqn == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(ownerFqn);
|
||||||
|
return findFieldInitializerCic(td, fieldName, new HashSet<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClassInstanceCreation findFieldInitializerCic(TypeDeclaration td, String fieldName, Set<String> visited) {
|
||||||
|
if (td == null || fieldName == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String typeFqn = context.getFqn(td);
|
||||||
|
if (typeFqn == null || !visited.add(typeFqn)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (FieldDeclaration fd : td.getFields()) {
|
||||||
|
for (Object fragObj : fd.fragments()) {
|
||||||
|
if (fragObj instanceof VariableDeclarationFragment frag
|
||||||
|
&& frag.getName().getIdentifier().equals(fieldName)
|
||||||
|
&& frag.getInitializer() instanceof ClassInstanceCreation cic) {
|
||||||
|
return cic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String superFqn = context.getSuperclassFqn(td);
|
||||||
|
if (superFqn != null) {
|
||||||
|
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
||||||
|
ClassInstanceCreation inherited = findFieldInitializerCic(superTd, fieldName, visited);
|
||||||
|
if (inherited != null) {
|
||||||
|
return inherited;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolveFieldTypeName(String ownerFqn, String fieldName) {
|
||||||
|
if (ownerFqn == null || fieldName == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(ownerFqn);
|
||||||
|
return resolveFieldTypeName(td, fieldName, new HashSet<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public String resolveFieldTypeName(TypeDeclaration td, String fieldName, Set<String> visited) {
|
||||||
|
if (td == null || fieldName == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String typeFqn = context.getFqn(td);
|
||||||
|
if (typeFqn == null || !visited.add(typeFqn)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (FieldDeclaration fd : td.getFields()) {
|
||||||
|
for (Object fragObj : fd.fragments()) {
|
||||||
|
if (fragObj instanceof VariableDeclarationFragment frag
|
||||||
|
&& frag.getName().getIdentifier().equals(fieldName)) {
|
||||||
|
return fd.getType().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String superFqn = context.getSuperclassFqn(td);
|
||||||
|
if (superFqn != null) {
|
||||||
|
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
||||||
|
return resolveFieldTypeName(superTd, fieldName, visited);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||||
|
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans trivial getter bodies for constant return values.
|
||||||
|
*/
|
||||||
|
public final class GetterBodyConstantScanner {
|
||||||
|
|
||||||
|
private GetterBodyConstantScanner() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> extractConstantGetterReturn(
|
||||||
|
CodebaseContext context,
|
||||||
|
ConstantExtractor constantExtractor,
|
||||||
|
ConstantResolver constantResolver,
|
||||||
|
String ownerFqn,
|
||||||
|
String methodName) {
|
||||||
|
return extractConstantGetterReturn(context, constantExtractor, constantResolver, ownerFqn, methodName, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> extractConstantGetterReturn(
|
||||||
|
CodebaseContext context,
|
||||||
|
ConstantExtractor constantExtractor,
|
||||||
|
ConstantResolver constantResolver,
|
||||||
|
String ownerFqn,
|
||||||
|
String methodName,
|
||||||
|
CompilationUnit contextCu,
|
||||||
|
Set<String> ignoredVisited) {
|
||||||
|
TypeDeclaration typeDeclaration = contextCu != null
|
||||||
|
? context.getTypeDeclaration(ownerFqn, contextCu)
|
||||||
|
: null;
|
||||||
|
if (typeDeclaration == null) {
|
||||||
|
typeDeclaration = context.getTypeDeclaration(ownerFqn);
|
||||||
|
}
|
||||||
|
if (typeDeclaration == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
|
||||||
|
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> constants = new ArrayList<>();
|
||||||
|
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> extractor =
|
||||||
|
constantExtractor != null
|
||||||
|
? constantExtractor::extractConstantsFromExpression
|
||||||
|
: (expr, out) -> {
|
||||||
|
String resolved = constantResolver.resolve(expr, context);
|
||||||
|
if (resolved != null) {
|
||||||
|
out.add(resolved);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
methodDeclaration.getBody().accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(ReturnStatement node) {
|
||||||
|
if (node.getExpression() != null) {
|
||||||
|
extractor.accept(node.getExpression(), constants);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return constants;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central registry for reactive/factory unwrap and passthrough method names used during constant extraction.
|
||||||
|
*/
|
||||||
|
public final class LibraryUnwrapRegistry {
|
||||||
|
|
||||||
|
private static final Set<String> UNWRAP_RECEIVER_TYPES = Set.of(
|
||||||
|
"Optional", "Stream", "List", "Set", "Arrays", "Objects", "Mono", "Flux", "MessageBuilder");
|
||||||
|
|
||||||
|
private static final Set<String> UNWRAP_METHOD_NAMES = Set.of(
|
||||||
|
"of", "ofEntries", "asList", "entry", "just", "withPayload", "success");
|
||||||
|
|
||||||
|
private static final Set<String> REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success");
|
||||||
|
|
||||||
|
private static final Set<String> REACTIVE_TRANSFORM_METHODS = Set.of("map", "flatMap", "switchMap", "concatMap");
|
||||||
|
|
||||||
|
/** Fixed header keys that may carry a machine event (free-form / dynamic keys stay fail-closed). */
|
||||||
|
private static final Set<String> EVENT_HEADER_KEYS = Set.of("event", "smEvent");
|
||||||
|
|
||||||
|
private static final String[] STOP_UNWRAP_PREFIXES = {
|
||||||
|
"map", "parse", "convert", "to", "resolve", "build", "create", "from",
|
||||||
|
"transform", "translate", "derive", "determine", "calculate", "decode",
|
||||||
|
"extract", "get", "find", "validate"
|
||||||
|
};
|
||||||
|
|
||||||
|
private LibraryUnwrapRegistry() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isUnwrapReceiverType(String receiverExpressionText) {
|
||||||
|
return receiverExpressionText != null && UNWRAP_RECEIVER_TYPES.contains(receiverExpressionText);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isUnwrapMethodName(String methodName) {
|
||||||
|
return methodName != null && UNWRAP_METHOD_NAMES.contains(methodName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isReactiveFactoryMethod(String methodName) {
|
||||||
|
return methodName != null && REACTIVE_FACTORY_METHODS.contains(methodName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isReactiveTransformMethod(String methodName) {
|
||||||
|
return methodName != null && REACTIVE_TRANSFORM_METHODS.contains(methodName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean shouldStopUnwrap(String methodName) {
|
||||||
|
if (methodName == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String lowerName = methodName.toLowerCase(Locale.ROOT);
|
||||||
|
for (String prefix : STOP_UNWRAP_PREFIXES) {
|
||||||
|
if (lowerName.startsWith(prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isEventSetterMethodName(String methodName) {
|
||||||
|
if (methodName == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return "event".equalsIgnoreCase(methodName)
|
||||||
|
|| "type".equalsIgnoreCase(methodName)
|
||||||
|
|| "eventType".equalsIgnoreCase(methodName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isHeaderSetterMethodName(String methodName) {
|
||||||
|
return "setHeader".equals(methodName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isEventHeaderKey(String headerKey) {
|
||||||
|
return headerKey != null && EVENT_HEADER_KEYS.contains(headerKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isGenericMessageType(String typeName) {
|
||||||
|
if (typeName == null || typeName.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String simple = typeName.contains(".") ? typeName.substring(typeName.lastIndexOf('.') + 1) : typeName;
|
||||||
|
int generic = simple.indexOf('<');
|
||||||
|
if (generic >= 0) {
|
||||||
|
simple = simple.substring(0, generic);
|
||||||
|
}
|
||||||
|
return "GenericMessage".equals(simple);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.core.dom.CastExpression;
|
||||||
|
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||||
|
import org.eclipse.jdt.core.dom.Expression;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||||
|
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
|
||||||
|
import org.eclipse.jdt.core.dom.StringLiteral;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared MessageBuilder / GenericMessage / allowlisted-header peels for event identity.
|
||||||
|
* Used by {@code EventIdentityResolver} and call-graph unwrap paths.
|
||||||
|
*/
|
||||||
|
public final class MessageCarrierSupport {
|
||||||
|
|
||||||
|
private MessageCarrierSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer allowlisted {@code setHeader("event"|"smEvent", …)} on a MessageBuilder chain;
|
||||||
|
* else peel {@code withPayload}/{@code just}/{@code success}; else GenericMessage ctor arg.
|
||||||
|
*
|
||||||
|
* @return the event-bearing expression, or null when nothing is proven
|
||||||
|
*/
|
||||||
|
public static Expression peelEventFromMessageExpr(Expression expression) {
|
||||||
|
if (expression == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Expression peeled = peel(expression);
|
||||||
|
Expression headerEvent = findSetHeaderValueOnChain(peeled, null);
|
||||||
|
if (headerEvent != null) {
|
||||||
|
return peel(headerEvent);
|
||||||
|
}
|
||||||
|
Expression payload = ReactiveExpressionSupport.peelFactoryPayloadExpression(peeled);
|
||||||
|
if (payload != null) {
|
||||||
|
return peel(payload);
|
||||||
|
}
|
||||||
|
return peelGenericMessageCtor(peeled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Expression peelGenericMessageCtor(Expression expression) {
|
||||||
|
Expression peeled = peel(expression);
|
||||||
|
if (peeled instanceof ClassInstanceCreation cic
|
||||||
|
&& LibraryUnwrapRegistry.isGenericMessageType(cic.getType().toString())
|
||||||
|
&& !cic.arguments().isEmpty()
|
||||||
|
&& cic.arguments().get(0) instanceof Expression arg) {
|
||||||
|
return peel(arg);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a MessageBuilder chain for {@code setHeader(key, value)}. When {@code requiredKey}
|
||||||
|
* is null, returns the first allowlisted event-header value on the chain.
|
||||||
|
*/
|
||||||
|
public static Expression findSetHeaderValueOnChain(Expression expression, String requiredKey) {
|
||||||
|
Expression current = peel(expression);
|
||||||
|
while (current instanceof MethodInvocation mi) {
|
||||||
|
if (LibraryUnwrapRegistry.isHeaderSetterMethodName(mi.getName().getIdentifier())
|
||||||
|
&& mi.arguments().size() >= 2) {
|
||||||
|
String key = stringLiteralValue(mi.arguments().get(0));
|
||||||
|
if (requiredKey != null) {
|
||||||
|
if (requiredKey.equals(key) && mi.arguments().get(1) instanceof Expression value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
} else if (LibraryUnwrapRegistry.isEventHeaderKey(key)
|
||||||
|
&& mi.arguments().get(1) instanceof Expression value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("build".equals(mi.getName().getIdentifier()) && mi.arguments().isEmpty()
|
||||||
|
&& mi.getExpression() != null) {
|
||||||
|
current = peel(mi.getExpression());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (mi.getExpression() instanceof MethodInvocation next) {
|
||||||
|
current = next;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stringLiteralValue(Object arg) {
|
||||||
|
if (arg instanceof StringLiteral sl) {
|
||||||
|
return sl.getLiteralValue();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression peel(Expression expression) {
|
||||||
|
Expression current = expression;
|
||||||
|
while (current instanceof ParenthesizedExpression pe) {
|
||||||
|
current = pe.getExpression();
|
||||||
|
}
|
||||||
|
if (current instanceof CastExpression ce) {
|
||||||
|
return peel(ce.getExpression());
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||||
|
import org.eclipse.jdt.core.dom.Expression;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||||
|
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
|
||||||
|
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||||
|
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared {@link MethodInvocation} peeling for factory/reactive wrappers and local passthrough methods.
|
||||||
|
*/
|
||||||
|
public final class MethodInvocationUnwrapper {
|
||||||
|
|
||||||
|
private MethodInvocationUnwrapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Expression unwrap(
|
||||||
|
MethodInvocation mi,
|
||||||
|
int depth,
|
||||||
|
String methodFqn,
|
||||||
|
CodebaseContext context,
|
||||||
|
Function<MethodInvocation, String> targetMethodFqnResolver) {
|
||||||
|
if (ResolutionBudget.defaults().isUnwrapExhausted(depth)) {
|
||||||
|
return mi;
|
||||||
|
}
|
||||||
|
|
||||||
|
String targetMethodFqn = targetMethodFqnResolver != null
|
||||||
|
? targetMethodFqnResolver.apply(mi)
|
||||||
|
: null;
|
||||||
|
if (targetMethodFqn == null && methodFqn != null && methodFqn.contains(".") && mi.getExpression() == null) {
|
||||||
|
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||||
|
targetMethodFqn = className + "." + mi.getName().getIdentifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
org.eclipse.jdt.core.dom.CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof org.eclipse.jdt.core.dom.CompilationUnit compilationUnit
|
||||||
|
? compilationUnit
|
||||||
|
: null;
|
||||||
|
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(className, cu);
|
||||||
|
if (td == null) {
|
||||||
|
td = context.getTypeDeclaration(className);
|
||||||
|
}
|
||||||
|
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 unwrap(innerMi, depth + 1, methodFqn, context, targetMethodFqnResolver);
|
||||||
|
}
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mi.getExpression() != null) {
|
||||||
|
String exprStr = mi.getExpression().toString();
|
||||||
|
if (!LibraryUnwrapRegistry.isUnwrapReceiverType(exprStr)) {
|
||||||
|
return mi;
|
||||||
|
}
|
||||||
|
} else if (targetMethodFqn == null) {
|
||||||
|
return mi;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mi.arguments().isEmpty()) {
|
||||||
|
String methodName = mi.getName().getIdentifier();
|
||||||
|
if (LibraryUnwrapRegistry.shouldStopUnwrap(methodName)) {
|
||||||
|
return mi;
|
||||||
|
}
|
||||||
|
|
||||||
|
Expression arg = peelExpression((Expression) mi.arguments().get(0));
|
||||||
|
if (arg instanceof MethodInvocation innerMi) {
|
||||||
|
return unwrap(innerMi, depth + 1, methodFqn, context, targetMethodFqnResolver);
|
||||||
|
}
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
return mi;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Expression peelExpression(Expression expression) {
|
||||||
|
Expression current = expression;
|
||||||
|
while (current instanceof ParenthesizedExpression pe) {
|
||||||
|
current = pe.getExpression();
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static 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
|
||||||
|
&& svd.getName().getIdentifier().equals(retStr)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.Block;
|
||||||
|
import org.eclipse.jdt.core.dom.Expression;
|
||||||
|
import org.eclipse.jdt.core.dom.LambdaExpression;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||||
|
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||||
|
import org.eclipse.jdt.core.dom.SimpleName;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts terminal payload literals from reactive factory chains ({@code Mono.just}, nested transforms).
|
||||||
|
*/
|
||||||
|
public final class ReactiveExpressionSupport {
|
||||||
|
|
||||||
|
private ReactiveExpressionSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String extractPayload(Expression expression, ConstantResolver constantResolver, CodebaseContext context) {
|
||||||
|
return extractPayload(expression, null, constantResolver, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peels {@code just}/{@code withPayload}/{@code success} wrappers on an invocation chain and returns
|
||||||
|
* the payload {@link Expression} for dataflow analysis.
|
||||||
|
*/
|
||||||
|
public static Expression peelFactoryPayloadExpression(Expression expression) {
|
||||||
|
if (!(expression instanceof MethodInvocation mi)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MethodInvocation current = mi;
|
||||||
|
while (current != null) {
|
||||||
|
String methodName = current.getName().getIdentifier();
|
||||||
|
if (LibraryUnwrapRegistry.isReactiveFactoryMethod(methodName) && !current.arguments().isEmpty()) {
|
||||||
|
return MethodInvocationUnwrapper.peelExpression((Expression) current.arguments().get(0));
|
||||||
|
}
|
||||||
|
Expression receiver = current.getExpression();
|
||||||
|
if (receiver instanceof MethodInvocation nextMi) {
|
||||||
|
current = nextMi;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Expression lambdaBodyExpression(LambdaExpression lambda) {
|
||||||
|
if (lambda.getBody() instanceof Expression bodyExpression) {
|
||||||
|
return bodyExpression;
|
||||||
|
}
|
||||||
|
if (lambda.getBody() instanceof Block block) {
|
||||||
|
for (Object statement : block.statements()) {
|
||||||
|
if (statement instanceof ReturnStatement returnStatement
|
||||||
|
&& returnStatement.getExpression() != null) {
|
||||||
|
return returnStatement.getExpression();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites {@code p.getEvent()} inside a reactive transform lambda to {@code payload.getEvent()}
|
||||||
|
* when {@code p} is fed by {@code Mono.just(payload)} (or a chained reactive source).
|
||||||
|
*/
|
||||||
|
public static String remapLambdaParameterGetter(
|
||||||
|
Expression eventExpr,
|
||||||
|
ConstantResolver constantResolver,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (!(eventExpr instanceof MethodInvocation getterCall)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!(getterCall.getExpression() instanceof SimpleName paramName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
LambdaExpression lambda = findEnclosingLambda(getterCall);
|
||||||
|
if (lambda == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MethodInvocation transformCall = findEnclosingReactiveTransform(lambda);
|
||||||
|
if (transformCall == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String mappedReceiver = mapLambdaParameterToSource(
|
||||||
|
lambda,
|
||||||
|
paramName.getIdentifier(),
|
||||||
|
transformCall.getExpression(),
|
||||||
|
constantResolver,
|
||||||
|
context);
|
||||||
|
if (mappedReceiver == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mappedReceiver + "." + getterCall.getName().getIdentifier() + "()";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #remapLambdaParameterGetter(Expression, ConstantResolver, CodebaseContext)} but locates
|
||||||
|
* the getter in a real method body when {@code expressionText} came from a detached parse tree.
|
||||||
|
*/
|
||||||
|
public static String remapLambdaParameterGetterInMethod(
|
||||||
|
String expressionText,
|
||||||
|
String methodFqn,
|
||||||
|
ConstantResolver constantResolver,
|
||||||
|
CodebaseContext context) {
|
||||||
|
MethodInvocation getter = AstUtils.findMethodInvocationInMethod(methodFqn, expressionText, context);
|
||||||
|
if (getter == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String remapped = remapLambdaParameterGetter(getter, constantResolver, context);
|
||||||
|
if (remapped != null) {
|
||||||
|
return remapped;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LambdaExpression findEnclosingLambda(ASTNode node) {
|
||||||
|
ASTNode current = node.getParent();
|
||||||
|
while (current != null) {
|
||||||
|
if (current instanceof LambdaExpression lambda) {
|
||||||
|
return lambda;
|
||||||
|
}
|
||||||
|
current = current.getParent();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MethodInvocation findEnclosingReactiveTransform(ASTNode node) {
|
||||||
|
ASTNode current = node.getParent();
|
||||||
|
while (current != null) {
|
||||||
|
if (current instanceof MethodInvocation mi
|
||||||
|
&& LibraryUnwrapRegistry.isReactiveTransformMethod(mi.getName().getIdentifier())) {
|
||||||
|
return mi;
|
||||||
|
}
|
||||||
|
current = current.getParent();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extractPayload(
|
||||||
|
Expression expression,
|
||||||
|
Expression flatMapReceiver,
|
||||||
|
ConstantResolver constantResolver,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (expression == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (expression instanceof MethodInvocation mi) {
|
||||||
|
String methodName = mi.getName().getIdentifier();
|
||||||
|
if (LibraryUnwrapRegistry.isReactiveTransformMethod(methodName) && !mi.arguments().isEmpty()) {
|
||||||
|
String fromArgument = extractTransformArgumentPayload(
|
||||||
|
(Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context);
|
||||||
|
if (fromArgument != null) {
|
||||||
|
return fromArgument;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (LibraryUnwrapRegistry.isReactiveFactoryMethod(methodName) && !mi.arguments().isEmpty()) {
|
||||||
|
Expression arg = (Expression) mi.arguments().get(0);
|
||||||
|
if (constantResolver != null && context != null) {
|
||||||
|
String resolved = constantResolver.resolve(arg, context);
|
||||||
|
if (resolved != null) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return arg.toString();
|
||||||
|
}
|
||||||
|
if (mi.getExpression() != null) {
|
||||||
|
return extractPayload(mi.getExpression(), flatMapReceiver, constantResolver, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extractTransformArgumentPayload(
|
||||||
|
Expression argument,
|
||||||
|
Expression transformReceiver,
|
||||||
|
ConstantResolver constantResolver,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (argument instanceof LambdaExpression lambda) {
|
||||||
|
Expression bodyExpression = lambdaBodyExpression(lambda);
|
||||||
|
if (bodyExpression != null) {
|
||||||
|
String lambdaPayload = extractLambdaBodyPayload(
|
||||||
|
bodyExpression, lambda, transformReceiver, constantResolver, context);
|
||||||
|
if (lambdaPayload != null) {
|
||||||
|
return lambdaPayload;
|
||||||
|
}
|
||||||
|
return extractPayload(bodyExpression, transformReceiver, constantResolver, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extractPayload(argument, transformReceiver, constantResolver, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extractLambdaBodyPayload(
|
||||||
|
Expression bodyExpression,
|
||||||
|
LambdaExpression lambda,
|
||||||
|
Expression transformReceiver,
|
||||||
|
ConstantResolver constantResolver,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (!(bodyExpression instanceof MethodInvocation factoryCall)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!LibraryUnwrapRegistry.isReactiveFactoryMethod(factoryCall.getName().getIdentifier())
|
||||||
|
|| factoryCall.arguments().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Expression factoryArg = (Expression) factoryCall.arguments().get(0);
|
||||||
|
if (!(factoryArg instanceof MethodInvocation getterCall)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String getterSuffix = "." + getterCall.getName().getIdentifier() + "()";
|
||||||
|
Expression getterReceiver = getterCall.getExpression();
|
||||||
|
if (getterReceiver instanceof SimpleName paramName) {
|
||||||
|
String mappedReceiver = mapLambdaParameterToSource(
|
||||||
|
lambda, paramName.getIdentifier(), transformReceiver, constantResolver, context);
|
||||||
|
if (mappedReceiver != null) {
|
||||||
|
return mappedReceiver + getterSuffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (getterReceiver != null) {
|
||||||
|
return getterReceiver.toString() + getterSuffix;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String mapLambdaParameterToSource(
|
||||||
|
LambdaExpression lambda,
|
||||||
|
String paramName,
|
||||||
|
Expression transformReceiver,
|
||||||
|
ConstantResolver constantResolver,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (lambda.parameters().size() != 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object parameter = lambda.parameters().get(0);
|
||||||
|
if (!(parameter instanceof org.eclipse.jdt.core.dom.VariableDeclaration variableDeclaration)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!paramName.equals(variableDeclaration.getName().getIdentifier())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (transformReceiver instanceof MethodInvocation receiverTransform
|
||||||
|
&& LibraryUnwrapRegistry.isReactiveTransformMethod(receiverTransform.getName().getIdentifier())
|
||||||
|
&& !receiverTransform.arguments().isEmpty()
|
||||||
|
&& receiverTransform.arguments().get(0) instanceof LambdaExpression feederLambda
|
||||||
|
&& feederLambda != lambda) {
|
||||||
|
Expression feederBody = lambdaBodyExpression(feederLambda);
|
||||||
|
if (feederBody != null) {
|
||||||
|
String feederPayload = extractLambdaBodyPayload(
|
||||||
|
feederBody, feederLambda, receiverTransform.getExpression(), constantResolver, context);
|
||||||
|
if (feederPayload != null) {
|
||||||
|
return feederPayload;
|
||||||
|
}
|
||||||
|
String extracted = extractPayload(
|
||||||
|
feederBody, receiverTransform.getExpression(), constantResolver, context);
|
||||||
|
if (extracted != null) {
|
||||||
|
return extracted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expression source = peelJustArgument(transformReceiver);
|
||||||
|
if (source instanceof MethodInvocation getterMi
|
||||||
|
&& getterMi.getExpression() instanceof SimpleName innerParamName) {
|
||||||
|
LambdaExpression outerLambda = findEnclosingLambda(lambda);
|
||||||
|
if (outerLambda != null && outerLambda != lambda) {
|
||||||
|
MethodInvocation outerTransform = findEnclosingReactiveTransform(outerLambda);
|
||||||
|
if (outerTransform != null) {
|
||||||
|
String mappedBase = mapLambdaParameterToSource(
|
||||||
|
outerLambda,
|
||||||
|
innerParamName.getIdentifier(),
|
||||||
|
outerTransform.getExpression(),
|
||||||
|
constantResolver,
|
||||||
|
context);
|
||||||
|
if (mappedBase != null) {
|
||||||
|
return mappedBase + "." + getterMi.getName().getIdentifier() + "()";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (source != null) {
|
||||||
|
if (constantResolver != null && context != null) {
|
||||||
|
String resolved = constantResolver.resolve(source, context);
|
||||||
|
if (resolved != null) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return source.toString();
|
||||||
|
}
|
||||||
|
return extractPayload(transformReceiver, null, constantResolver, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression peelJustArgument(Expression expression) {
|
||||||
|
Expression peeled = peelFactoryPayloadExpression(expression);
|
||||||
|
if (peeled != null) {
|
||||||
|
return peeled;
|
||||||
|
}
|
||||||
|
if (expression instanceof MethodInvocation mi && mi.getExpression() != null) {
|
||||||
|
return peelJustArgument(mi.getExpression());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared depth and fallback budget for analysis traversals. Replaces scattered magic-number limits.
|
||||||
|
*/
|
||||||
|
public final class ResolutionBudget {
|
||||||
|
|
||||||
|
public static final int DEFAULT_METHOD_RETURN = 20;
|
||||||
|
public static final int DEFAULT_ACCESSOR_INLINE = 25;
|
||||||
|
public static final int DEFAULT_DATAFLOW = 50;
|
||||||
|
public static final int DEFAULT_UNWRAP = 5;
|
||||||
|
public static final int DEFAULT_CONSTRUCTOR = 10;
|
||||||
|
public static final int DEFAULT_RETURNED_CIC = 50;
|
||||||
|
public static final int DEFAULT_GLOBAL_FIELD_SCAN = 10;
|
||||||
|
|
||||||
|
private final int methodReturnDepth;
|
||||||
|
private final int accessorInlineDepth;
|
||||||
|
private final int dataflowDepth;
|
||||||
|
private final int unwrapDepth;
|
||||||
|
private final int constructorDepth;
|
||||||
|
private final int returnedCicDepth;
|
||||||
|
private final int globalFieldScanLimit;
|
||||||
|
private final boolean allowGlobalFieldScan;
|
||||||
|
|
||||||
|
private ResolutionBudget(
|
||||||
|
int methodReturnDepth,
|
||||||
|
int accessorInlineDepth,
|
||||||
|
int dataflowDepth,
|
||||||
|
int unwrapDepth,
|
||||||
|
int constructorDepth,
|
||||||
|
int returnedCicDepth,
|
||||||
|
int globalFieldScanLimit,
|
||||||
|
boolean allowGlobalFieldScan) {
|
||||||
|
this.methodReturnDepth = methodReturnDepth;
|
||||||
|
this.accessorInlineDepth = accessorInlineDepth;
|
||||||
|
this.dataflowDepth = dataflowDepth;
|
||||||
|
this.unwrapDepth = unwrapDepth;
|
||||||
|
this.constructorDepth = constructorDepth;
|
||||||
|
this.returnedCicDepth = returnedCicDepth;
|
||||||
|
this.globalFieldScanLimit = globalFieldScanLimit;
|
||||||
|
this.allowGlobalFieldScan = allowGlobalFieldScan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResolutionBudget defaults() {
|
||||||
|
return new ResolutionBudget(
|
||||||
|
DEFAULT_METHOD_RETURN,
|
||||||
|
DEFAULT_ACCESSOR_INLINE,
|
||||||
|
DEFAULT_DATAFLOW,
|
||||||
|
DEFAULT_UNWRAP,
|
||||||
|
DEFAULT_CONSTRUCTOR,
|
||||||
|
DEFAULT_RETURNED_CIC,
|
||||||
|
DEFAULT_GLOBAL_FIELD_SCAN,
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResolutionBudget withGlobalFieldScan() {
|
||||||
|
return defaults().enableGlobalFieldScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResolutionBudget enableGlobalFieldScan() {
|
||||||
|
return new ResolutionBudget(
|
||||||
|
methodReturnDepth,
|
||||||
|
accessorInlineDepth,
|
||||||
|
dataflowDepth,
|
||||||
|
unwrapDepth,
|
||||||
|
constructorDepth,
|
||||||
|
returnedCicDepth,
|
||||||
|
globalFieldScanLimit,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResolutionBudget child() {
|
||||||
|
return new ResolutionBudget(
|
||||||
|
methodReturnDepth - 1,
|
||||||
|
accessorInlineDepth - 1,
|
||||||
|
dataflowDepth - 1,
|
||||||
|
unwrapDepth,
|
||||||
|
constructorDepth - 1,
|
||||||
|
returnedCicDepth - 1,
|
||||||
|
globalFieldScanLimit,
|
||||||
|
allowGlobalFieldScan);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMethodReturnExhausted(int depth) {
|
||||||
|
return depth > methodReturnDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAccessorInlineExhausted(int depth) {
|
||||||
|
return depth > accessorInlineDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDataflowExhausted(int depth) {
|
||||||
|
return depth > dataflowDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUnwrapExhausted(int depth) {
|
||||||
|
return depth > unwrapDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConstructorExhausted(int depth) {
|
||||||
|
return depth > constructorDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isReturnedCicExhausted(int depth) {
|
||||||
|
return depth > returnedCicDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int globalFieldScanLimit() {
|
||||||
|
return globalFieldScanLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean allowGlobalFieldScan() {
|
||||||
|
return allowGlobalFieldScan;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.CarrierConstraintSupport;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared boolean simplification for transition guards and dispatcher domain checks.
|
||||||
|
*/
|
||||||
|
public final class BooleanConstraintEvaluator {
|
||||||
|
|
||||||
|
private static final Pattern STRING_LITERAL_PATTERN = Pattern.compile("[\"']([a-zA-Z0-9_-]+)[\"']");
|
||||||
|
|
||||||
|
private BooleanConstraintEvaluator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplifies constraints such as {@code "ORDER".equals(domain)} against a machine domain key.
|
||||||
|
*/
|
||||||
|
public static boolean isCompatibleWithMachineDomain(String constraint, String machineDomainKey) {
|
||||||
|
if (constraint == null || machineDomainKey == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop event/payload carrier clauses so they never enter the domain rewrite path.
|
||||||
|
constraint = CarrierConstraintSupport.stripNonMachineClauses(constraint);
|
||||||
|
if (constraint == null || constraint.isBlank()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String cleanMachine = machineDomainKey.toUpperCase(Locale.ROOT);
|
||||||
|
List<String> rewritten = new ArrayList<>();
|
||||||
|
for (String clause : ConstraintClauses.splitAndClauses(constraint)) {
|
||||||
|
rewritten.add(rewriteDomainEqualityClause(clause, cleanMachine));
|
||||||
|
}
|
||||||
|
if (rewritten.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return evaluateBooleanExpression(String.join(" && ", rewritten));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple equality clauses collapse via {@link ConstraintClauses}; complex clauses ({@code !},
|
||||||
|
* {@code ||}, nested parens) keep a residual literal rewrite so negation/OR stay correct.
|
||||||
|
*/
|
||||||
|
private static String rewriteDomainEqualityClause(String clause, String cleanMachine) {
|
||||||
|
if (clause == null || clause.isBlank()) {
|
||||||
|
return clause;
|
||||||
|
}
|
||||||
|
String trimmed = clause.trim();
|
||||||
|
if ("true".equalsIgnoreCase(trimmed) || "false".equalsIgnoreCase(trimmed)) {
|
||||||
|
return trimmed.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
boolean complex = trimmed.indexOf('!') >= 0
|
||||||
|
|| trimmed.contains("||")
|
||||||
|
|| (trimmed.indexOf('(') >= 0 && !isObjectsEqualsClause(trimmed));
|
||||||
|
if (!complex) {
|
||||||
|
String literal = ConstraintClauses.quotedLiteral(trimmed);
|
||||||
|
if (literal != null && ConstraintClauses.primaryIdent(trimmed) != null) {
|
||||||
|
boolean matchesMachine = domainLiteralMatches(literal, cleanMachine);
|
||||||
|
boolean inequality = trimmed.contains("!=");
|
||||||
|
if (inequality) {
|
||||||
|
return matchesMachine ? "false" : "true";
|
||||||
|
}
|
||||||
|
return matchesMachine ? "true" : "false";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rewriteDomainLiteralsWithRegex(trimmed, cleanMachine);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isObjectsEqualsClause(String clause) {
|
||||||
|
return clause.matches("(?is)\\s*(?:java\\.util\\.)?Objects\\.equals(?:IgnoreCase)?\\s*\\(.*\\)\\s*");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean domainLiteralMatches(String literal, String cleanMachine) {
|
||||||
|
return literal.equalsIgnoreCase(cleanMachine)
|
||||||
|
|| cleanMachine.equalsIgnoreCase(literal)
|
||||||
|
|| (cleanMachine.length() > literal.length()
|
||||||
|
&& cleanMachine.startsWith(literal.toUpperCase(Locale.ROOT)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String rewriteDomainLiteralsWithRegex(String expr, String cleanMachine) {
|
||||||
|
Set<String> literals = extractStringLiterals(expr);
|
||||||
|
String result = expr;
|
||||||
|
for (String literal : literals) {
|
||||||
|
boolean matchesMachine = domainLiteralMatches(literal, cleanMachine);
|
||||||
|
String replacement = matchesMachine ? "true" : "false";
|
||||||
|
String quoted = Pattern.quote(literal);
|
||||||
|
result = result.replaceAll(
|
||||||
|
"(?i)[\"']?" + quoted + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\([^)]*\\)",
|
||||||
|
replacement);
|
||||||
|
result = result.replaceAll(
|
||||||
|
"(?i)[a-zA-Z0-9._]+\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||||
|
+ quoted + "[\"']?\\s*\\)",
|
||||||
|
replacement);
|
||||||
|
result = result.replaceAll(
|
||||||
|
"(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + quoted + "[\"']?",
|
||||||
|
replacement);
|
||||||
|
result = result.replaceAll(
|
||||||
|
"(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + quoted + "[\"']?",
|
||||||
|
matchesMachine ? "false" : "true");
|
||||||
|
result = result.replaceAll(
|
||||||
|
"(?i)(?:java\\.util\\.)?Objects\\.equals(?:IgnoreCase)?\\s*\\(\\s*[\"']?"
|
||||||
|
+ quoted + "[\"']?\\s*,\\s*[^)]+\\)",
|
||||||
|
replacement);
|
||||||
|
result = result.replaceAll(
|
||||||
|
"(?i)(?:java\\.util\\.)?Objects\\.equals(?:IgnoreCase)?\\s*\\(\\s*[^,]+\\s*,\\s*[\"']?"
|
||||||
|
+ quoted + "[\"']?\\s*\\)",
|
||||||
|
replacement);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean evaluateBooleanExpression(String expression) {
|
||||||
|
if (expression == null || expression.isBlank()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parseExpression(expression.replaceAll("\\s+", ""));
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Be conservative in pruning: if we can't parse the expression, do not discard paths.
|
||||||
|
return !expression.contains("false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates a constraint against known variable bindings gathered while walking a call path forward.
|
||||||
|
* Supports {@code var == VALUE} where VALUE may be a quoted string or enum constant; suffix matching
|
||||||
|
* is used so {@code command == ORDER_PAY} matches {@code DomainCommand.ORDER_PAY}.
|
||||||
|
*/
|
||||||
|
public static boolean isCompatibleWithBindings(String constraint, Map<String, String> bindings) {
|
||||||
|
return isCompatibleWithBindings(constraint, bindings, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link #isCompatibleWithBindings(String, Map)} but unbound equality variables are ignored
|
||||||
|
* instead of failing closed. Used when filtering call chains against entry path/payload bindings
|
||||||
|
* while trigger constraints may also mention unrelated locals.
|
||||||
|
*/
|
||||||
|
public static boolean isCompatibleWithKnownBindings(String constraint, Map<String, String> bindings) {
|
||||||
|
return isCompatibleWithKnownBindings(constraint, bindings, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link #isCompatibleWithBindings(String, Map)} but reconstructs truncated enum refs
|
||||||
|
* using {@code preferredEnumTypeFqn} before treating them as concrete.
|
||||||
|
*/
|
||||||
|
public static boolean isCompatibleWithBindings(
|
||||||
|
String constraint,
|
||||||
|
Map<String, String> bindings,
|
||||||
|
String preferredEnumTypeFqn,
|
||||||
|
CodebaseContext context) {
|
||||||
|
return isCompatibleWithBindings(constraint, bindings, preferredEnumTypeFqn, context, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isCompatibleWithKnownBindings(
|
||||||
|
String constraint,
|
||||||
|
Map<String, String> bindings,
|
||||||
|
String preferredEnumTypeFqn,
|
||||||
|
CodebaseContext context) {
|
||||||
|
return isCompatibleWithBindings(constraint, bindings, preferredEnumTypeFqn, context, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isCompatibleWithBindings(
|
||||||
|
String constraint,
|
||||||
|
Map<String, String> bindings,
|
||||||
|
String preferredEnumTypeFqn,
|
||||||
|
CodebaseContext context,
|
||||||
|
boolean ignoreUnboundEqualityVars) {
|
||||||
|
if (constraint == null || constraint.isBlank()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (bindings == null || bindings.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Set<String> equalityVars = ConstraintClauses.equalityVariables(constraint);
|
||||||
|
for (String varName : equalityVars) {
|
||||||
|
if (!bindings.containsKey(varName)) {
|
||||||
|
if (ignoreUnboundEqualityVars) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String expr = constraint;
|
||||||
|
for (Map.Entry<String, String> entry : bindings.entrySet()) {
|
||||||
|
String concreteValue = concreteBindingValue(
|
||||||
|
entry.getKey(), entry.getValue(), preferredEnumTypeFqn, context);
|
||||||
|
if (concreteValue == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expr = substituteVariableBindings(expr, entry.getKey(), concreteValue);
|
||||||
|
expr = substituteEqualsLiteralBindings(expr, entry.getKey(), concreteValue);
|
||||||
|
}
|
||||||
|
return evaluateBooleanExpression(expr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isConcreteBindingValue(String paramName, String boundValue) {
|
||||||
|
return concreteBindingValue(paramName, boundValue, null, null) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return concrete value to substitute (possibly reconstructed), or {@code null} if not concrete
|
||||||
|
*/
|
||||||
|
private static String concreteBindingValue(
|
||||||
|
String paramName,
|
||||||
|
String boundValue,
|
||||||
|
String preferredEnumTypeFqn,
|
||||||
|
CodebaseContext context) {
|
||||||
|
if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (isIncompleteDottedName(boundValue)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String candidate = boundValue;
|
||||||
|
if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(boundValue)) {
|
||||||
|
if (context == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
candidate = TruncatedEnumRefReconstructor.reconstruct(
|
||||||
|
boundValue, preferredEnumTypeFqn, context);
|
||||||
|
if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(candidate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidate.contains("(") && !candidate.contains("valueOf")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (candidate.startsWith("\"") && candidate.endsWith("\"")) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
if ("true".equalsIgnoreCase(candidate) || "false".equalsIgnoreCase(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
int lastDot = candidate.lastIndexOf('.');
|
||||||
|
if (lastDot >= 0
|
||||||
|
&& lastDot + 1 < candidate.length()
|
||||||
|
&& Character.isUpperCase(candidate.charAt(lastDot + 1))) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
// Treat simple routing keys (e.g. order.pay) as concrete string bindings, but avoid
|
||||||
|
// mistaking variable names (e.g. machineType) for concrete values.
|
||||||
|
if ((candidate.contains(".") || candidate.contains("/") || candidate.contains("-"))
|
||||||
|
&& candidate.matches("^[a-zA-Z0-9._/-]+$")) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
if (candidate.equals(candidate.toUpperCase(Locale.ROOT)) && candidate.chars().allMatch(ch ->
|
||||||
|
Character.isUpperCase(ch) || ch == '_')) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for truncated/malformed dotted names such as {@code com.example.DomainCommand.} or {@code .PAY}.
|
||||||
|
*/
|
||||||
|
public static boolean isIncompleteDottedName(String value) {
|
||||||
|
return value != null
|
||||||
|
&& !value.isBlank()
|
||||||
|
&& (value.startsWith(".") || value.endsWith(".") || value.contains(".."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String substituteEqualsLiteralBindings(String expr, String varName, String boundValue) {
|
||||||
|
if (boundValue == null || boundValue.isBlank()) {
|
||||||
|
return expr;
|
||||||
|
}
|
||||||
|
String cleanValue = boundValue;
|
||||||
|
if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) {
|
||||||
|
cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
String literalPattern = Pattern.quote(cleanValue);
|
||||||
|
String varToken = Pattern.quote(varName);
|
||||||
|
String argumentEquals = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||||
|
+ literalPattern + "[\"']?\\s*\\)";
|
||||||
|
expr = replaceReceiverEqualsForVariable(expr, varName, cleanValue, "true");
|
||||||
|
expr = replaceObjectsEqualsForVariable(expr, varName, cleanValue, "true");
|
||||||
|
expr = expr.replaceAll(argumentEquals, "true");
|
||||||
|
|
||||||
|
Set<String> allLiterals = extractStringLiterals(expr);
|
||||||
|
for (String literal : allLiterals) {
|
||||||
|
if (literal.equalsIgnoreCase(cleanValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String otherLiteral = Pattern.quote(literal);
|
||||||
|
String otherArgument = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||||
|
+ otherLiteral + "[\"']?\\s*\\)";
|
||||||
|
expr = replaceReceiverEqualsForVariable(expr, varName, literal, "false");
|
||||||
|
expr = replaceObjectsEqualsForVariable(expr, varName, literal, "false");
|
||||||
|
expr = expr.replaceAll(otherArgument, "false");
|
||||||
|
}
|
||||||
|
return expr;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String replaceObjectsEqualsForVariable(
|
||||||
|
String expr, String varName, String literalValue, String replacement) {
|
||||||
|
Pattern head = Pattern.compile("(?is)(?:java\\.util\\.)?Objects\\.equals(?:IgnoreCase)?\\s*\\(");
|
||||||
|
Matcher matcher = head.matcher(expr);
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
int cursor = 0;
|
||||||
|
while (matcher.find()) {
|
||||||
|
int matchStart = matcher.start();
|
||||||
|
int openParen = matcher.end() - 1;
|
||||||
|
int closeParen = findMatchingCloseParen(expr, openParen);
|
||||||
|
if (closeParen < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
String[] args = splitTopLevelComma(expr.substring(openParen + 1, closeParen));
|
||||||
|
result.append(expr, cursor, matchStart);
|
||||||
|
if (args != null
|
||||||
|
&& args.length == 2
|
||||||
|
&& objectsEqualsArgumentMatches(args[0], args[1], varName, literalValue)) {
|
||||||
|
result.append(replacement);
|
||||||
|
} else {
|
||||||
|
result.append(expr, matchStart, closeParen + 1);
|
||||||
|
}
|
||||||
|
cursor = closeParen + 1;
|
||||||
|
}
|
||||||
|
result.append(expr.substring(cursor));
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean objectsEqualsArgumentMatches(
|
||||||
|
String left, String right, String varName, String literalValue) {
|
||||||
|
String leftLiteral = stripConstraintLiteral(left);
|
||||||
|
String rightLiteral = stripConstraintLiteral(right);
|
||||||
|
if (leftLiteral != null
|
||||||
|
&& leftLiteral.equalsIgnoreCase(literalValue)
|
||||||
|
&& constraintArgumentReferencesVariable(right, varName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return rightLiteral != null
|
||||||
|
&& rightLiteral.equalsIgnoreCase(literalValue)
|
||||||
|
&& constraintArgumentReferencesVariable(left, varName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripConstraintLiteral(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
if (trimmed.length() >= 2
|
||||||
|
&& ((trimmed.startsWith("\"") && trimmed.endsWith("\""))
|
||||||
|
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
|
||||||
|
return trimmed.substring(1, trimmed.length() - 1);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean constraintArgumentReferencesVariable(String argument, String varName) {
|
||||||
|
return argument != null && argument.matches("(?s).*\\b" + Pattern.quote(varName) + "\\b.*");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String[] splitTopLevelComma(String args) {
|
||||||
|
int depth = 0;
|
||||||
|
for (int i = 0; i < args.length(); i++) {
|
||||||
|
char c = args.charAt(i);
|
||||||
|
if (c == '(') {
|
||||||
|
depth++;
|
||||||
|
} else if (c == ')') {
|
||||||
|
depth--;
|
||||||
|
} else if (c == ',' && depth == 0) {
|
||||||
|
return new String[] {args.substring(0, i), args.substring(i + 1)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String replaceReceiverEqualsForVariable(
|
||||||
|
String expr, String varName, String literalValue, String replacement) {
|
||||||
|
Pattern head = Pattern.compile(
|
||||||
|
"(?is)[\"']?" + Pattern.quote(literalValue) + "[\"']?\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(");
|
||||||
|
Matcher matcher = head.matcher(expr);
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
int cursor = 0;
|
||||||
|
while (matcher.find()) {
|
||||||
|
int matchStart = matcher.start();
|
||||||
|
int openParen = matcher.end() - 1;
|
||||||
|
int closeParen = findMatchingCloseParen(expr, openParen);
|
||||||
|
if (closeParen < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
String argument = expr.substring(openParen + 1, closeParen).trim();
|
||||||
|
result.append(expr, cursor, matchStart);
|
||||||
|
if (argument.equals(varName)
|
||||||
|
|| argument.matches("(?s).*\\b" + Pattern.quote(varName) + "\\b.*")) {
|
||||||
|
result.append(replacement);
|
||||||
|
} else {
|
||||||
|
result.append(expr, matchStart, closeParen + 1);
|
||||||
|
}
|
||||||
|
cursor = closeParen + 1;
|
||||||
|
}
|
||||||
|
result.append(expr.substring(cursor));
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int findMatchingCloseParen(String expr, int openIdx) {
|
||||||
|
int depth = 0;
|
||||||
|
for (int i = openIdx; i < expr.length(); i++) {
|
||||||
|
char c = expr.charAt(i);
|
||||||
|
if (c == '(') {
|
||||||
|
depth++;
|
||||||
|
} else if (c == ')') {
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String substituteVariableBindings(String expr, String varName, String boundValue) {
|
||||||
|
if (boundValue == null || boundValue.isBlank()) {
|
||||||
|
return expr;
|
||||||
|
}
|
||||||
|
String cleanValue = boundValue;
|
||||||
|
if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) {
|
||||||
|
cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Pattern eqPattern = Pattern.compile(
|
||||||
|
"(?i)" + Pattern.quote(varName) + "\\s*==\\s*([\\w.\"']+)");
|
||||||
|
Matcher matcher = eqPattern.matcher(expr);
|
||||||
|
StringBuffer sb = new StringBuffer();
|
||||||
|
while (matcher.find()) {
|
||||||
|
String rhs = matcher.group(1).replace("\"", "").replace("'", "");
|
||||||
|
boolean matches = constraintValuesMatch(cleanValue, rhs);
|
||||||
|
matcher.appendReplacement(sb, matches ? "true" : "false");
|
||||||
|
}
|
||||||
|
matcher.appendTail(sb);
|
||||||
|
if ("true".equalsIgnoreCase(cleanValue) || "false".equalsIgnoreCase(cleanValue)) {
|
||||||
|
Pattern bareVar = Pattern.compile("(?<![\\w.])" + Pattern.quote(varName) + "(?![\\w])");
|
||||||
|
return bareVar.matcher(expr).replaceAll(cleanValue.toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean constraintValuesMatch(String boundValue, String rhs) {
|
||||||
|
if (boundValue == null || rhs == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String bound = stripOuterQuotes(boundValue);
|
||||||
|
String rhsClean = stripOuterQuotes(rhs);
|
||||||
|
if (bound.equals(rhsClean) || bound.equalsIgnoreCase(rhsClean)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (bound.endsWith("." + rhsClean) || rhsClean.endsWith("." + bound)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String boundConstant = enumConstantName(bound);
|
||||||
|
String rhsConstant = enumConstantName(rhsClean);
|
||||||
|
if (!boundConstant.equalsIgnoreCase(rhsConstant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String boundType = enumTypePart(bound);
|
||||||
|
String rhsType = enumTypePart(rhsClean);
|
||||||
|
if (boundType == null || rhsType == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (boundType.equals(rhsType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String boundSimple = simpleTypeName(boundType);
|
||||||
|
String rhsSimple = simpleTypeName(rhsType);
|
||||||
|
if (!boundSimple.equals(rhsSimple)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean boundImportStyle = !boundType.contains(".");
|
||||||
|
boolean rhsImportStyle = !rhsType.contains(".");
|
||||||
|
return boundImportStyle || rhsImportStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripOuterQuotes(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
|
||||||
|
return value.substring(1, value.length() - 1);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String enumConstantName(String ref) {
|
||||||
|
int dot = ref.lastIndexOf('.');
|
||||||
|
return dot >= 0 ? ref.substring(dot + 1) : ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String enumTypePart(String ref) {
|
||||||
|
int dot = ref.lastIndexOf('.');
|
||||||
|
if (dot <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ref.substring(0, dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String simpleTypeName(String typePart) {
|
||||||
|
int dot = typePart.lastIndexOf('.');
|
||||||
|
return dot >= 0 ? typePart.substring(dot + 1) : typePart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true}/{@code false} when both sides are compile-time string literals; otherwise {@code null}.
|
||||||
|
*/
|
||||||
|
public static Boolean evaluateStringEquals(String leftLiteral, String rightLiteral, boolean ignoreCase) {
|
||||||
|
if (leftLiteral == null || rightLiteral == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ignoreCase
|
||||||
|
? leftLiteral.equalsIgnoreCase(rightLiteral)
|
||||||
|
: leftLiteral.equals(rightLiteral);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> extractStringLiterals(String constraint) {
|
||||||
|
Set<String> literals = new HashSet<>();
|
||||||
|
Matcher matcher = STRING_LITERAL_PATTERN.matcher(constraint);
|
||||||
|
while (matcher.find()) {
|
||||||
|
literals.add(matcher.group(1));
|
||||||
|
}
|
||||||
|
return literals;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean parseExpression(String expression) {
|
||||||
|
if (expression.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int parenDepth = 0;
|
||||||
|
for (int i = expression.length() - 1; i >= 0; i--) {
|
||||||
|
char c = expression.charAt(i);
|
||||||
|
if (c == ')') {
|
||||||
|
parenDepth++;
|
||||||
|
} else if (c == '(') {
|
||||||
|
parenDepth--;
|
||||||
|
} else if (parenDepth == 0 && i > 0 && expression.charAt(i) == '|' && expression.charAt(i - 1) == '|') {
|
||||||
|
return parseExpression(expression.substring(0, i - 1)) || parseExpression(expression.substring(i + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parenDepth = 0;
|
||||||
|
for (int i = expression.length() - 1; i >= 0; i--) {
|
||||||
|
char c = expression.charAt(i);
|
||||||
|
if (c == ')') {
|
||||||
|
parenDepth++;
|
||||||
|
} else if (c == '(') {
|
||||||
|
parenDepth--;
|
||||||
|
} else if (parenDepth == 0 && i > 0 && expression.charAt(i) == '&' && expression.charAt(i - 1) == '&') {
|
||||||
|
return parseExpression(expression.substring(0, i - 1)) && parseExpression(expression.substring(i + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expression.startsWith("!")) {
|
||||||
|
return !parseExpression(expression.substring(1));
|
||||||
|
}
|
||||||
|
if (expression.startsWith("(") && expression.endsWith(")")) {
|
||||||
|
return parseExpression(expression.substring(1, expression.length() - 1));
|
||||||
|
}
|
||||||
|
if ("true".equalsIgnoreCase(expression)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ("false".equalsIgnoreCase(expression)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user