176 Commits

Author SHA1 Message Date
e6effa3dcd Harden call-graph enum resolution and JSON re-export polymorphic repair.
Replace eager enum widen with symbolic placeholders and deferred narrowing, add switch-based predicate evaluation and entry-point synthesis for JSON round-trips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 16:54:14 +02:00
83008a6898 Harden polymorphic event narrowing to prevent over-linking transitions.
Fail closed when enum predicates or polymorphicEvents cannot be resolved, infer candidates from configured transitions, and validate exports that link more transitions than the machine defines.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 16:22:58 +02:00
77bf840465 Filter polymorphic events by enum predicates and machine transitions.
Add generic constraint-aware enum member evaluation, prefer transitions[] over full enum inference, and regression tests for boolean enum dispatchers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 16:14:44 +02:00
873e2c91f3 Fix JSON re-export StackOverflow and dedupe enricher canonicalization.
Scope project-root discovery to the nearest module, skip call-chain refresh when source lacks machine classes, harden ConstantResolver cycle detection, and remove redundant trigger canonicalization passes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 15:39:48 +02:00
58d0701df5 Add MachineDomainKeys for shared machine domain extraction.
Centralizes domain key normalization from config class names so Standard* machines map to ORDER/DOCUMENT/USER consistently.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 15:34:41 +02:00
422ea94b28 Fix empty matchedTransitions for dynamic dispatcher call chains.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 13:04:07 +02:00
7ffd157105 Harden enum identifier validation for rawName sync, polymorphic events, and matched transitions.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:22:39 +02:00
393c998b66 Fix HTML export states, endpoint linking, and dynamic trigger event validation.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:18:03 +02:00
dd0363cf64 Harden export pipeline identifier consistency and HTML link parity tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 11:47:20 +02:00
cc2cf1845b Fix identifier consistency on JSON re-export and after property resolution.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 11:38:55 +02:00
8d954804b2 Fix HTML export linking by restoring trigger canonicalization and state deduplication.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:46:38 +02:00
66aaa9563f Refactor analysis pipeline to remove duplicated call-graph and resolver logic.
Retire legacy CallGraphBuilder, unify graph building and accessor resolution through shared helpers, and centralize AST/type utilities to cut drift across engines and enrichers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:30:25 +02:00
411b7a0e52 Perf: use CFG dataflow for traceLocalSetter and guard hot-path logging.
Replace per-call method-body AST scans with reaching-definitions lookup, guard remaining debug/trace calls in call-graph hot paths, and add branch edge-case tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:14:02 +02:00
82a70b5374 Improve export perf: guard debug logs, scope triggers, memoize path bindings.
Reduce multi-config call-chain cost by filtering triggers before pathfinding and cache path-binding walks per session, while avoiding debug string work on hot constant-resolution paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 09:59:57 +02:00
5d3333d494 Fix export metadata scoping and call-chain resolution gaps.
Correct machine-scope filtering for domain paths and Spring type erasure, skip phantom REST interfaces, resolve custom message wrapper events, scope JDT call-chain lookup per machine, and wire scan-time analysis indexes with JDT/heuristic parity tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 09:43:50 +02:00
7a5fc60ace Fix map-routed dispatcher events to respect path-bound keys.
Resolve static map lookups along the call path, guard enum widening when
keys are bound, and add isolation tests for warmed-cache map routing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 08:31:08 +02:00
9ede60049b Add CallGraphBuilder with tests and maven/multi-module golden fixtures.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 08:30:48 +02:00
cc35546d01 Fix CIC accessor resolution, concrete overrides, and reactive flatMap tracing.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 07:06:55 +02:00
c2eff6d7bb Fix abstract getter widening and nested reactive flatMap resolution.
Ensure abstract-type getter resolution merges subclass field values and trace indexed accessors on the requesting type, and extract terminal payloads from chained flatMap sendEvent calls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 06:02:48 +02:00
613dfffd53 Align enum rawName with fn-form labels during canonicalization.
Derive transition and state rawName from package-canonical fullIdentifier so JSON exports stay consistent when bare constants or mismatched short forms appear in source.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 05:54:45 +02:00
db158200dd Prune call-graph paths using switch constraints during search.
Propagate path bindings while exploring CallEdge constraints in CallGraphPathFinder so impossible dispatch arms are skipped before chain assembly, with inheritance fallback preserved for polymorphic resolution.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 05:53:17 +02:00
23a14d8391 Expand entry-point and trigger detector coverage.
Detect fire/trigger wrappers as state-machine triggers, resolve Kafka topicPattern and additional Rabbit listener attributes, and add regression tests for WebFlux RouterFunction routes and messaging listeners.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 05:50:34 +02:00
b158179559 Fix golden audit issues and tighten analysis/export pipeline.
Resolve property placeholders before call-chain linking, skip phantom routing states and lifecycle triggers from transition matching, dedupe exported states, and add golden JSON audit regression plus HTML lifecycle endpoint display.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 05:49:25 +02:00
4f13c9449c Make JSON a self-contained analysis interchange for downstream exporters.
Persist machine types, finalize on JSON import, resolve @Value placeholders, and canonicalize String states so HTML and other consumers can import JSON without source re-analysis.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 05:33:27 +02:00
0aca0aade9 Enforce package-canonical enum identifiers across analysis export.
Resolve generic configurer type arguments, canonicalize transitions and triggers consistently, validate after property resolution, and update regression goldens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 00:48:43 +02:00
ea9b8d6cff Improve multi-machine scoping, path binding expansion, and analysis precision.
Expand generic REST path variables into concrete command keys, filter entry points and call chains per machine, and tighten ambiguous routing and switch constraints so layered dispatchers link fewer false positives.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 21:26:13 +02:00
8a97bceca4 Prune switch-dispatch call paths and scope analysis per state machine.
Switch-case constraints on call-graph edges and forward path binding stop string-key endpoints from linking every dispatch arm; machine-scoped triggers/call chains reduce cross-config noise, with pipeline bug fixes and regression coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 20:49:16 +02:00
8fa6a44e75 Add --inline-accessors CLI flag, layered dispatcher regression goldens, and accessor index docs.
Expose accessor index toggling through ExportService and picocli, lock in layered_dispatcher_sample exports as golden fixtures, and document the static analysis pipeline in the README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 20:29:07 +02:00
a108d8cc6e Unify accessor resolution through a shared pipeline and widen interface getters to all implementations.
Introduce AccessorResolver with session caching and ordered index-first fallbacks, replace fragmented unwrap/depth logic with shared utilities, and fix polymorphic event resolution that stopped after the first indexed implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 20:12:22 +02:00
7f12ed3f3a Index trivial accessors and inline them in dataflow, constant extraction, and call graph resolution.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 18:17:17 +02:00
0628710106 Analyze cloned repos from source without running Maven or Gradle on the target project.
Parse build files statically for module wiring, use the exporter's bundled Spring classpath for type bindings, optionally include pre-built generated main sources, and exclude test output and test-scoped dependencies from analysis.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 10:35:52 +02:00
c447641800 Replace .get() heuristics with type-based access classification and extend literal dataflow resolution.
Introduce ExpressionAccessClassifier to distinguish map lookups from Supplier/bean accessors, fold Map.of and string/array literals at compile time, and add regression tests for dispatcher and dataflow paths.

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

22
InspectGolden.java Normal file
View File

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

View File

@@ -1,34 +1,81 @@
# Spring State Machine Explorer
```shell
brew install graphviz
Static analysis tool to extract and visualize Spring State Machines with deep code traceability.
## Core Modules
- **`state_machine_exporter`**: Java AST analyzer. Scans source code, resolves local Maven/Gradle dependencies, and extracts logic (Lambdas, Actions, Guards) into JSON/PUML.
- **`state_machine_exporter_html`**: Generates an interactive HTML portal with SVG diagrams and rich tooltips.
## 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)
```bash
./gradlew :state_machine_exporter:run --args="-i ./my-project -o ./out -f json"
```
```shell
dot -Tpng statemachine.dot -o statemachine.png
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)
```bash
./gradlew :state_machine_exporter_html:run --args="-i ./my-project -o ./out_html"
```
HTML-specific options:
```shell
brew install plantuml
```
```shell
plantuml statemachine.puml
plantuml -tsvg statemachine.puml
```
Possible formats
```shell
-teps To generate images using EPS format
-thtml To generate HTML file for class diagram
-tlatex:nopreamble To generate images using LaTeX/Tikz format without preamble
-tlatex To generate images using LaTeX/Tikz format
-tpdf To generate images using PDF format
-tpng To generate images using PNG format (default)
-tscxml To generate SCXML file for state diagram
-tsvg To generate images using SVG format
-ttxt To generate images with ASCII art
-tutxt To generate images with ASCII art using Unicode characters
-tvdx To generate images using VDX format
-txmi To generate XMI file for class diagram
| 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
Define sequence of events in `src/main/resources/flows.json`:
```json
[
{
"name": "Order Success",
"description": "Happy path for order placement",
"steps": ["PAY", "CHECK_AVAILABILITY->PENDING", "SHIP"]
}
]
```
plantuml statemachine.scxml
## JSON Structure
- `metadata.entryPoints`: REST, WebFlux, and JMS entry points.
- `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`).
- `transitions.actions[].internalLogic`: Extracted source code/lambdas.
- `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`).

View File

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

BIN
TestHeuristic.class Normal file

Binary file not shown.

17
TestHeuristic.java Normal file
View File

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

3
lombok.config Normal file
View File

@@ -0,0 +1,3 @@
lombok.anyConstructor.addConstructorProperties = true
lombok.jacksonized.flagUsage = ALLOW
config.stopBubbling = true

5
run_golden.gradle Normal file
View File

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

View File

@@ -2,6 +2,7 @@ rootProject.name = 'state machine exporter'
include ':file_combine'
include ':state_machine_exporter'
include ':state_machine_exporter_html'
include ':state_machine_exporter_spring_based'
include ':state_machines:complex_state_machine'
include ':state_machines:dynamic_state_machine'
@@ -12,3 +13,11 @@ include ':state_machines:inheritance_extra_functions_state_machine'
include ':state_machines:inheritance_extra_functions2_state_machine'
include ':state_machines:inheritance_extra_functions3_state_machine'
include ':state_machines:simple_state_machine'
include ':state_machines:extended_analysis_sample'
include ':state_machines:inheritance_sample'
include ':state_machines:layered_dispatcher_sample'
include ':state_machines:ultimate_ecosystem_sm'
include ':state_machines:enterprise_order_system'
include ':state_machines:multi_module_sample:api-module'
include ':state_machines:multi_module_sample:core-module'
include ':state_machines:state_machine_enterprise'

View File

@@ -36,6 +36,7 @@ dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.17.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1'
compileOnly 'org.projectlombok:lombok:1.18.46'
annotationProcessor 'org.projectlombok:lombok:1.18.46'
@@ -45,8 +46,22 @@ dependencies {
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:6.1.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher:6.1.0'
testImplementation 'org.assertj:assertj-core:3.27.7'
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') {
useJUnitPlatform()
maxParallelForks = Math.max(1, Runtime.runtime.availableProcessors().intdiv(2) ?: 1)
systemProperty "updateGolden", System.getProperty("updateGolden")
}
task runGolden(type: JavaExec) {
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
classpath = sourceSets.test.runtimeClasspath
workingDir = rootProject.projectDir
}

View File

@@ -1,101 +0,0 @@
# Interactive HTML Generator: Architecture & Flow Definition Format
Following the success of the interactive SVG PoC, this document outlines the proposed architecture for adding an **HTML Generator** to the State Machine Exporter, allowing users to define and visualize specific business flows dynamically.
## 1. The Flow Definition Format (JSON & JSON Schema)
To make the interactive HTML generator useful and intuitive, users need a way to define their business flows externally. Rather than reinventing the wheel, we looked at industry standards for defining graph paths, workflows, and execution traces:
* **Workflow Definitions:** Tools like *Amazon States Language (ASL)*, *SCXML*, and *XState* are highly structured but define the *entire* machine's logic. They are too heavy for simply defining a "path" through an existing machine.
* **Trace Formats:** *OpenTelemetry (OTel)* uses "Spans" to represent individual steps forming a "Trace". This is closer to what we want (a history of states).
* **Graph Formats:** The *JSON Graph Format (JGF)* standardizes graph nodes and edges representation but is geared towards raw data modeling rather than human-readable business flows.
**Conclusion on Format:** We will use a lightweight custom JSON format inspired by execution traces. It is designed to be highly readable for humans defining business flows, relying simply on ordered arrays of State names.
### JSON Schema for IDE Autocomplete
To make the format truly intuitive and prevent typos, we will publish a **JSON Schema** (`flow-schema.json`).
By referencing this schema, developers writing `flows.json` in IDEs like VS Code or IntelliJ will get **instant autocomplete, hover tooltips, and real-time validation** for fields like `id`, `title`, and `color`.
* *Future Enhancement:* The Java exporter could dynamically generate a project-specific JSON Schema on the fly that hardcodes the allowed `enum` values for the `path` array (e.g., restricted exactly to `"SUBMITTED"`, `"PAID"`, etc.), providing perfect autocomplete for state names!
### Proposed Schema (`flows.json`)
```json
{
"$schema": "./flow-schema.json",
"stateMachine": "click.kamil.examples.statemachine.simple.SimpleEnumStateMachineConfiguration",
"flows": [
{
"id": "flow-happy-path",
"title": "Happy Path (Success)",
"description": "The standard flow for a successful order: Submitted -> Paid -> Fulfilled.",
"color": "#32CD32",
"path": [
".start.",
"SUBMITTED",
"PAID",
"FULFILLED",
".end."
]
},
{
"id": "flow-cancel-post-pay",
"title": "Cancel Post-Payment",
"description": "Order is canceled after payment.",
"color": "#FF6347",
"path": [
".start.",
"SUBMITTED",
"PAID",
"CANCELED",
".end."
]
}
]
}
```
### Format Details:
* **`$schema`**: Points to the JSON schema for IDE tooling.
* **`stateMachine`**: Links these flows to a specific state machine configuration in the codebase.
* **`id`**: A unique identifier for the flow (used for DOM elements).
* **`title` & `description`**: Rendered in the HTML sidebar for the user.
* **`color` (Optional)**: Allows overriding the default highlight color (e.g., green for success, red for errors).
* **`path`**: An ordered array of **State Names** (acting like an OpenTelemetry trace).
* *Note on Edges:* The user only needs to define the *states* they pass through. The generator will automatically calculate the edges (transitions) between these states to highlight the lines. Special tokens like `.start.` and `.end.` map to the initial and terminal pseudo-states.
## 2. Robust Error Handling & Input Validation
Before generating the HTML, the exporter **must validate** that the provided `flows.json` is logically sound against the actual codebase AST. If a user defines a flow that isn't physically possible in the state machine, the tool must fail fast with highly descriptive errors.
### Validation Rules & Error Handling Strategy:
1. **Schema Validation (Syntax):**
* *Check:* Is the JSON structurally correct (required fields present, correct types)?
* *Error:* "Invalid format in flows.json: 'title' is required for flow 'flow-cancel-post-pay'."
2. **State Existence (Dangling Pointers):**
* *Check:* Every state listed in the `path` array must exist in the parsed `StateMachineModel`.
* *Error:* "Validation Error in 'Happy Path': State 'FULLFILLED' does not exist in StateMachine 'SimpleEnum'. Did you mean 'FULFILLED'?" (implementing a Levenshtein distance check for typos would be excellent here).
3. **Transition Validity (Impossible Paths):**
* *Check:* For every adjacent pair in the `path` array (e.g., `["SUBMITTED", "PAID"]`), the validator must check the `StateMachineModel.getTransitions()` to ensure a valid transition exists where `source == "SUBMITTED"` and `target == "PAID"`.
* *Error:* "Validation Error in 'Happy Path': No transition exists from 'PAID' to 'SUBMITTED'. Please check your state machine configuration."
4. **Ambiguous Transitions (Multiple Edges):**
* *Check:* If multiple transitions exist between the same two states (e.g., two different events trigger a move from A -> B).
* *Handling:* By default, highlight *all* edges between A and B to represent the general path.
* *Future Enhancement:* Expand the JSON format to allow specifying the exact `event` name in the path array (e.g., `["SUBMITTED", {"state": "PAID", "event": "PAY_CREDIT_CARD"}]`) to disambiguate.
## 3. HTML Generation Strategy
The HTML Exporter will be a new class implementing the `StateMachineExporter` interface (or a specialized decorator around the `PlantUml` exporter).
### Steps for Generation:
1. **Generate PlantUML SVG:** First, use the existing PlantUML engine to generate the raw SVG string in memory (using the modern CSS `<style>` approach we finalized).
2. **Parse `flows.json`:** Load the user-provided flow definitions.
3. **Validate Flows:** Run the "Flow Validator" against the AST `StateMachineModel`.
4. **Calculate Edges:** For each valid flow, convert the `path` array (States) into an `edges` array (Transitions) so the Javascript knows exactly which SVG arrows to highlight.
5. **Inject Template:** Load a base HTML template (similar to `interactive_poc2.html`).
6. **Data Binding:**
* Inject the raw `<svg>` string directly into the template (avoiding CORS issues).
* Inject the validated flow data into a `<script>` tag as a JSON object, or directly render the sidebar HTML elements with `data-*` attributes.
7. **Output:** Write the final standalone `.html` file to the output directory.
## 4. Advantages of this Architecture
* **Standalone Output:** The resulting HTML file is 100% self-contained. No server, no external CSS/JS, and no CORS issues. It can be directly emailed, attached to Jira tickets, or hosted on GitHub Pages.
* **CI/CD Integration:** Because validation is strictly tied to the AST, if a developer refactors the Java State Machine and breaks a documented "Flow", the CI pipeline will fail, ensuring interactive documentation never goes out of date.

View File

@@ -1,66 +0,0 @@
# Thought Experiment: Interactive SVG Flow Highlighting
## Overview
The goal of this thought experiment is to explore the feasibility and usefulness of animating or interactively highlighting specific "flows" (paths composed of states and transitions) within a generated state machine diagram. For example, hovering over a label like "Successful Payment Flow" would highlight the `SUBMITTED -> PAID -> FULFILLED` path, dimming the rest of the diagram.
## Usefulness
This feature would be **highly useful** and would significantly elevate the value of the State Machine Exporter:
1. **Comprehension of Complex Machines:** In large diagrams (like `ComplexStateMachineConfig`), a static image becomes a "spaghetti" of crossing lines. Interactive highlighting allows developers to visually isolate a single business transaction (e.g., "Cancellation Flow", "Refund Flow") without losing the context of the whole machine.
2. **Documentation & Onboarding:** It turns a static architectural diagram into an interactive documentation tool, making it much easier for new team members to trace how an entity moves through its lifecycle.
3. **Debugging:** By defining flows based on logged events, a developer could "replay" a sequence of events and see exactly what path the state machine took.
## Feasibility
This is **entirely feasible** using the tools currently available in the project.
### The Technical Reality of PlantUML SVGs
When PlantUML generates an SVG (`plantuml -tsvg`), it embeds highly predictable metadata into the DOM structure:
- **States (Nodes):** PlantUML creates `<g>` elements for states. They typically have an `id` matching the state name (e.g., `id="FULFILLED"`) or prefixed with entity (e.g., `id="entity_SUBMITTED"`).
- **Transitions (Links):** PlantUML creates `<g>` elements for arrows with predictable IDs and data attributes. For example: `<g class="link" data-entity-1="SUBMITTED" data-entity-2="PAID" id="link_SUBMITTED_PAID">`.
Because these identifiers match the semantic names of the states in the Java/JSON model, mapping a logical "Flow" to visual SVG elements is straightforward.
## Proposed Technical Approach
To realize this, we would build a lightweight **Interactive HTML Exporter** (or augment the JSON exporter to generate a companion HTML file).
### 1. Data Structure (The "Flow" Definition)
We need a way to define flows. This could be passed via configuration or inferred from the model. A flow is essentially an array of IDs:
```json
{
"name": "Successful Payment",
"elements": ["SUBMITTED", "link_SUBMITTED_PAID", "PAID", "link_PAID_FULFILLED", "FULFILLED"]
}
```
### 2. HTML/JS Wrapper
Instead of just outputting an `.svg` or `.png`, the exporter would output an `.html` file that contains:
1. **The inline SVG code** (generated via PlantUML).
2. **A Sidebar UI** containing a list of defined flows.
3. **Vanilla JavaScript** to handle the interactivity.
### 3. The JavaScript Logic (Proof of Concept)
The JS logic is remarkably simple. It listens for `mouseenter` events on the flow list:
1. **Dim Everything:** Add a class to the root SVG wrapper (e.g., `.flow-active`) that sets `opacity: 0.2` on all `<g>` tags.
2. **Highlight Flow:** Iterate through the `elements` array of the active flow. Find the SVG elements by their `id` (e.g., `document.getElementById('link_SUBMITTED_PAID')`) and add a `.highlighted` class.
3. **CSS Magic:**
```css
.diagram-container.flow-active svg g.highlighted {
opacity: 1 !important;
}
.diagram-container.flow-active svg g.highlighted path {
stroke: red !important;
stroke-width: 3px !important;
}
```
## Limitations & Challenges
While feasible, there are a few edge cases to handle:
1. **Multiple Transitions Between Same States:** If there are two distinct transitions from `A` to `B` (e.g., triggered by different events), PlantUML might generate IDs like `link_A_B` and `link_A_B_1`. The JS logic would need to rely on the event stereotypes we added recently (e.g., `<<e_PAY>>`) to distinguish them. We could parse the SVG text nodes to find the event label, or rely on the custom CSS classes if PlantUML supports passing them through to SVG (currently, PlantUML inlines styles rather than keeping CSS classes for transitions).
2. **Layout Shifts:** We must rely purely on CSS styling (colors, opacities, stroke widths). We cannot animate the *position* or layout of the arrows because PlantUML hardcodes the exact SVG path coordinates.
3. **Standalone vs. Hosted:** Generating a single `.html` file is easy and portable. However, if this is meant to be embedded in a documentation site (like Confluence or Backstage), injecting custom JS/CSS along with raw SVG can sometimes run into sanitization or iframe limitations.
4. **Maintenance:** Any changes to how PlantUML generates its internal SVG IDs in future versions would break the JS highlighting logic.
## Conclusion
The idea is brilliant. It transforms the output from a static image into an interactive, exploratory tool. Since the `StateMachineModel` is already cleanly exported as JSON, a standalone frontend script could easily marry that JSON data with the PlantUML SVG output to create dynamic, highlightable documentation flows without requiring heavy backend changes.

View File

@@ -1,90 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: sans-serif; display: flex; }
#sidebar { width: 300px; padding: 20px; background: #f4f4f4; }
#diagram { flex-grow: 1; padding: 20px; }
.flow-btn { padding: 10px; margin-bottom: 5px; cursor: pointer; background: #ddd; border: 1px solid #ccc; border-radius: 4px;}
.flow-btn:hover { background: #bdf; }
/* Dim everything by default when a flow is active */
.diagram-container.flow-active svg g {
opacity: 0.2;
transition: opacity 0.3s;
}
/* Highlight active elements */
.diagram-container.flow-active svg g.highlighted {
opacity: 1 !important;
}
/* Make highlighted paths thicker/red */
.diagram-container.flow-active svg g.highlighted path,
.diagram-container.flow-active svg g.highlighted polygon {
stroke: red !important;
stroke-width: 3px !important;
}
.diagram-container.flow-active svg g.highlighted rect,
.diagram-container.flow-active svg g.highlighted ellipse {
stroke: red !important;
stroke-width: 3px !important;
}
</style>
</head>
<body>
<div id="sidebar">
<h3>Flows</h3>
<div class="flow-btn" data-flow='["SUBMITTED", "link_SUBMITTED_PAID", "PAID", "link_PAID_FULFILLED", "FULFILLED"]'>Flow: Successful Payment</div>
<div class="flow-btn" data-flow='["SUBMITTED", "link_SUBMITTED_CANCELED", "CANCELED"]'>Flow: Cancel Before Pay</div>
</div>
<div id="diagram" class="diagram-container">
<!-- SVG will be injected here -->
</div>
<script>
// Load SVG
fetch('SimpleEnumStateMachineConfiguration.svg')
.then(r => r.text())
.then(svg => {
document.getElementById('diagram').innerHTML = svg;
setupInteractivity();
});
function setupInteractivity() {
const container = document.getElementById('diagram');
document.querySelectorAll('.flow-btn').forEach(btn => {
btn.addEventListener('mouseenter', (e) => {
container.classList.add('flow-active');
const flowIds = JSON.parse(e.target.getAttribute('data-flow'));
flowIds.forEach(id => {
// PlantUML sometimes prepends entity_
let el = document.getElementById(id) || document.getElementById('entity_' + id);
if(el) {
el.classList.add('highlighted');
} else {
// For links, fallback to data-attributes if id parsing fails
if (id.startsWith('link_')) {
const parts = id.split('_');
let link = document.querySelector(`g[data-entity-1="${parts[1]}"][data-entity-2="${parts[2]}"]`);
if (link) link.classList.add('highlighted');
}
}
});
});
btn.addEventListener('mouseleave', () => {
container.classList.remove('flow-active');
document.querySelectorAll('.highlighted').forEach(el => el.classList.remove('highlighted'));
});
});
}
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -1,29 +0,0 @@
digraph statemachine {
rankdir=LR;
node [shape=rounded, style=filled, fillcolor=white, fontname="Arial"];
edge [fontname="Arial", fontsize=10];
SUBMITTED_choice [shape=diamond, label="", fillcolor="blue", width=0.2, height=0.2];
SUBMITTED -> SUBMITTED_choice [style=dotted, color="blue"];
PAID_choice [shape=diamond, label="", fillcolor="green", width=0.2, height=0.2];
PAID -> PAID_choice [style=dotted, color="green"];
_start [shape=circle, label="", fillcolor=black, width=0.1];
_start -> SUBMITTED;
CANCELED [fillcolor=lightgray];
FULFILLED [fillcolor=lightgray];
SUBMITTED -> PAID [label="OrderEvents.PAY", style="solid", color="black"];
PAID -> FULFILLED [label="OrderEvents.FULFILL", style="solid", color="black"];
SUBMITTED -> CANCELED [label="OrderEvents.CANCEL [λ]", style="solid", color="black"];
PAID -> CANCELED [label="OrderEvents.CANCEL", style="solid", color="black"];
SUBMITTED -> SUBMITTED [label="OrderEvents.ABCD", style="solid", color="black"];
SUBMITTED_choice -> PAID2 [label="[λ] (order=0)", style="solid", color="blue"];
SUBMITTED_choice -> PAID3 [label="(order=1)", style="solid", color="blue"];
PAID_choice -> PAID1 [label="[λ] (order=0)", style="solid", color="green"];
PAID_choice -> PAID2 [label="[guard1] (order=1)", style="solid", color="green"];
PAID_choice -> HAPPEN [label="[λ] (order=2)", style="solid", color="green"];
PAID_choice -> PAID3 [label="(order=3)", style="solid", color="green"];
PAID2 -> CANCELED [style="solid", color="black"];
PAID3 -> CANCELED [style="solid", color="black"];
PAID1 -> PAID1 [label="OrderEvents.ABCD / λ, action2", style="solid", color="black"];
}

View File

@@ -1,46 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="SUBMITTED">
<state id="PAID1">
<transition target="PAID1" event="OrderEvents.ABCD"/>
</state>
<state id="SUBMITTED">
<transition target="PAID" event="OrderEvents.PAY"/>
<transition target="CANCELED" event="OrderEvents.CANCEL" cond="λ"/>
<transition target="SUBMITTED" event="OrderEvents.ABCD"/>
<transition target="PAID2" cond="λ">
<!-- order=0 -->
</transition>
<transition target="PAID3">
<!-- order=1 -->
</transition>
</state>
<state id="PAID">
<transition target="FULFILLED" event="OrderEvents.FULFILL"/>
<transition target="CANCELED" event="OrderEvents.CANCEL"/>
<transition target="PAID1" cond="λ">
<!-- order=0 -->
</transition>
<transition target="PAID2" cond="guard1">
<!-- order=1 -->
</transition>
<transition target="HAPPEN" cond="λ">
<!-- order=2 -->
</transition>
<transition target="PAID3">
<!-- order=3 -->
</transition>
</state>
<state id="CANCELED">
</state>
<state id="FULFILLED">
</state>
<state id="PAID3">
<transition target="CANCELED"/>
</state>
<state id="HAPPEN">
</state>
<state id="PAID2">
<transition target="CANCELED"/>
</state>
</scxml>

View 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()

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.exporter.Dot;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.exporter.PlantUml;
@@ -8,10 +9,19 @@ import click.kamil.springstatemachineexporter.command.ExporterCommand;
import click.kamil.springstatemachineexporter.service.ExportService;
import picocli.CommandLine;
import java.util.Collections;
import java.util.List;
public class Main {
public static void main(String[] args) {
// Enable diagnostic mode early before SLF4J initializes
for (String arg : args) {
if ("--debug".equals(arg)) {
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug");
break;
}
}
// Manual DI / Wiring
var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
var exportService = new ExportService(exporters);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,9 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
public interface AnalysisEnricher {
void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence);
}

View File

@@ -0,0 +1,107 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.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.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Slf4j
public class CallChainEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with call chains", result.getName());
List<CallChain> chains = buildCallChainsForMachine(result, context, intelligence, true);
if (chains == null) {
return;
}
result.addMetadata(CodebaseMetadata.builder()
.callChains(chains)
.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);
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;
}
}

View File

@@ -0,0 +1,26 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
public class EntryPointEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with entry points", result.getName());
List<EntryPoint> entryPoints = intelligence.findEntryPoints();
entryPoints = MachineScopeFilter.filterEntryPointsForMachine(entryPoints, result.getName(), context);
result.addMetadata(CodebaseMetadata.builder()
.entryPoints(entryPoints)
.build());
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,125 @@
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.ast.common.CodebaseContext;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Filters codebase-wide analysis artifacts to those relevant for a single state machine config.
*/
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) {
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)) {
filtered.add(trigger);
}
}
return filtered;
}
public static List<CallChain> filterCallChainsForMachine(
List<CallChain> chains, String machineName, CodebaseContext context) {
if (chains == null || machineName == null) {
return chains == null ? List.of() : chains;
}
List<CallChain> filtered = new ArrayList<>();
for (CallChain chain : chains) {
if (ROUTING.isRoutedToCorrectMachine(chain, machineName, context)) {
filtered.add(chain);
}
}
return filtered;
}
public static List<EntryPoint> filterEntryPointsForMachine(
List<EntryPoint> entryPoints, String machineName, CodebaseContext context) {
if (entryPoints == null || machineName == null) {
return entryPoints == null ? List.of() : entryPoints;
}
String machineDomain = MachineDomainKeys.extractMachineDomainKey(machineName);
List<EntryPoint> filtered = new ArrayList<>();
for (EntryPoint entryPoint : entryPoints) {
if (isEntryPointForMachine(entryPoint, machineDomain, machineName, context)) {
filtered.add(entryPoint);
}
}
return filtered;
}
private static boolean isEntryPointForMachine(
EntryPoint entryPoint, String machineDomain, String machineName, CodebaseContext context) {
if (entryPoint == null) {
return false;
}
String path = entryPoint.getMetadata() != null ? entryPoint.getMetadata().get("path") : null;
if (path != null) {
String pathDomain = inferDomainFromPath(path);
if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain)
&& domainsMatch(pathDomain, machineDomain)) {
return true;
}
if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain)
&& !domainsMatch(pathDomain, machineDomain)) {
return false;
}
if (pathDomain == null && path.contains("{")) {
return true;
}
}
CallChain probe = CallChain.builder()
.entryPoint(entryPoint)
.methodChain(List.of(entryPoint.getClassName() + "." + entryPoint.getMethodName()))
.build();
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context);
}
private static boolean isKnownDomainScopedMachine(String machineDomain) {
return "ORDER".equals(machineDomain) || "DOCUMENT".equals(machineDomain) || "USER".equals(machineDomain);
}
private static String inferDomainFromPath(String path) {
String lower = path.toLowerCase(Locale.ROOT);
if (lower.contains("/orders") || lower.contains("order.")) {
return "ORDER";
}
if (lower.contains("/documents") || lower.contains("document.")) {
return "DOCUMENT";
}
return null;
}
private static boolean domainsMatch(String pathDomain, String machineDomain) {
if (pathDomain == null || machineDomain == null) {
return false;
}
return pathDomain.equalsIgnoreCase(machineDomain)
|| machineDomain.startsWith(pathDomain)
|| pathDomain.startsWith(machineDomain);
}
private static boolean isTriggerForMachine(TriggerPoint trigger, String machineName, CodebaseContext context) {
if (trigger == null) {
return false;
}
CallChain probe = CallChain.builder().triggerPoint(trigger).methodChain(List.of()).build();
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context);
}
}

View File

@@ -0,0 +1,24 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
@Slf4j
public class PropertyEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with properties", result.getName());
Map<String, Map<String, String>> properties = intelligence.resolveProperties();
result.addMetadata(CodebaseMetadata.builder()
.properties(properties)
.build());
}
}

View File

@@ -0,0 +1,198 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.List;
import 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.resolver.BooleanConstraintEvaluator;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
public class TransitionLinkerEnricher implements AnalysisEnricher {
private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
private final EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine();
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
return;
}
List<CallChain> updatedChains = new ArrayList<>();
List<Transition> stateMachineTransitions = result.getTransitions();
StateMachineTypeResolver.MachineTypes machineTypes =
JsonExportContextFactory.resolveMachineTypes(
result.getName(),
result.getStateTypeFqn(),
result.getEventTypeFqn(),
context);
for (CallChain chain : result.getMetadata().getCallChains()) {
TriggerPoint tp = chain.getTriggerPoint();
if (tp == null || tp.getEvent() == null) {
updatedChains.add(chain);
continue;
}
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
tp, machineTypes, context, stateMachineTransitions, true);
tp = markRestEndpointExternal(tp, chain);
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
updatedChains.add(chain);
continue;
}
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null;
List<MatchedTransition> matched = new ArrayList<>();
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) {
String smSourceForLink = canonicalState(smSourceState);
String smSource = simplify(smSourceForLink);
if (triggerSource == null || triggerSource.equals(smSource)) {
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceForLink)
.targetState(smSourceForLink)
.event(smEventForLink)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
&& 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)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt);
}
}
}
}
}
}
}
if (!matched.isEmpty()) {
CallChain newChain = chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(matched)
.build();
updatedChains.add(newChain);
} else {
updatedChains.add(chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(null)
.build());
}
}
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
result.getMetadata().getTriggers(), result.getName(), context);
List<EntryPoint> scopedEntryPoints = MachineScopeFilter.filterEntryPointsForMachine(
result.getMetadata().getEntryPoints(), result.getName(), context);
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
.triggers(scopedTriggers)
.entryPoints(scopedEntryPoints)
.callChains(updatedChains)
.properties(result.getMetadata().getProperties())
.build();
result.setMetadata(updatedMetadata);
}
private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
return routingEngine.isRoutedToCorrectMachine(chain, currentMachineName, context);
}
private boolean isConstraintCompatible(String constraint, String machineName) {
if (constraint == null || machineName == null) {
return true;
}
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
constraint, MachineDomainKeys.extractMachineDomainKey(machineName));
}
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>();
private static final java.util.regex.Pattern SUFFIX_PATTERN = java.util.regex.Pattern.compile("(?i)(.+)(Event|Action|Transition|Command)(s)?$");
private static final java.util.regex.Pattern FQN_PATTERN = java.util.regex.Pattern.compile("^.*\\.([A-Z0-9_]+)$");
private String simplify(String name) {
if (name == null) return null;
if (simplifyCache.containsKey(name)) return simplifyCache.get(name);
// Strip common suffixes
String simplified = SUFFIX_PATTERN.matcher(name).replaceAll("$1");
if (simplified.isEmpty()) {
simplified = name;
}
// Simplify full identifiers to just the last part (enum name)
simplified = FQN_PATTERN.matcher(simplified).replaceAll("$1");
simplifyCache.put(name, simplified);
return simplified;
}
private 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();
}
private TriggerPoint markRestEndpointExternal(TriggerPoint trigger, CallChain chain) {
if (trigger == null || trigger.isExternal() || chain.getEntryPoint() == null) {
return trigger;
}
EntryPoint entryPoint = chain.getEntryPoint();
if (entryPoint.getType() != EntryPoint.Type.REST) {
return trigger;
}
String endpointName = entryPoint.getName();
if (endpointName == null || !endpointName.contains("{")) {
return trigger;
}
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
!= MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
return trigger;
}
return trigger.toBuilder().external(true).build();
}
}

View File

@@ -0,0 +1,69 @@
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 canonical = MachineEnumCanonicalizer.canonicalizeTriggerPoint(
chain.getTriggerPoint(), machineTypes);
TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
canonical, machineTypes, context, result.getTransitions(), true);
return chain.toBuilder().triggerPoint(enriched).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());
}
}

View File

@@ -0,0 +1,33 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
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.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
public class TriggerEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with triggers", result.getName());
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);
result.addMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.triggers(triggers)
.build());
}
}

View File

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

View File

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

View File

@@ -0,0 +1,155 @@
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.model.Event;
import java.util.List;
public class StrictFqnMatchingEngine implements EventMatchingEngine {
@Override
public boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint) {
if (stateMachineEvent == null || triggerPoint == null || triggerPoint.getEvent() == null) {
return false;
}
if (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(".") && isStringTypeOrPrimitive(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
&& !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
return MachineEnumCanonicalizer.enumTypesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
}
}
}
if (rawTriggerEvent != null && rawTriggerEvent.contains(".valueOf(")) {
return false;
}
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
if (triggerPoint.getEventTypeFqn() != null && !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
return false;
}
if (triggerPoint.getEventTypeFqn() != null && isStringTypeOrPrimitive(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;
// Prevent matching string/primitive triggers to enum transitions
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
return false;
}
// Prevent matching enum triggers to string transitions
if (!smEvent.contains(".") && triggerEvent.contains(".")) {
return false;
}
if (smEvent.contains(".")) {
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
if (eventTypeFqn != null) {
return MachineEnumCanonicalizer.enumTypesMatch(eventTypeFqn, smEnumType);
}
if (triggerEvent.contains(".")) {
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
return MachineEnumCanonicalizer.enumTypesMatch(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 String constantName(String event) {
return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event;
}
private boolean isStringTypeOrPrimitive(String typeFqn) {
if (typeFqn == null) return false;
return typeFqn.equals("String") || typeFqn.equals("java.lang.String") ||
typeFqn.equals("int") || typeFqn.equals("long") || typeFqn.equals("char");
}
private boolean isDynamicVariable(String eventStr) {
if (eventStr == null || eventStr.isEmpty()) return false;
if (eventStr.contains(" ? ") && eventStr.contains(" : ")) {
return true;
}
if (eventStr.endsWith("()")) {
return true;
}
if (eventStr.contains(".")) {
String 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,341 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.HashSet;
import java.util.Set;
public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
@Override
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
// Precise FQN Type argument match
if (chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn();
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn();
if (triggerEventFqn != null || triggerStateFqn != null) {
String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context);
String machineStateFqn = machineTypes[0];
String machineEventFqn = machineTypes[1];
if (triggerEventFqn != null && machineEventFqn != null) {
if (eraseGenerics(triggerEventFqn).equals(eraseGenerics(machineEventFqn))) {
return true;
}
if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
return false;
}
if (context != null && isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) {
return true;
}
}
if (triggerStateFqn != null && machineStateFqn != null) {
if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) {
return true; // Match!
}
if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
return false; // Mismatch!
}
if (context != null && isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) {
return true;
}
}
}
}
String targetVar = chain.getContextMachineId();
if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId();
}
String simplifiedMachineName = currentMachineName != null ? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase() : "";
if (targetVar != null && !targetVar.isEmpty()) {
targetVar = targetVar.toLowerCase();
if (targetVar.endsWith("statemachine")) {
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
if (!prefix.isEmpty()) {
if (simplifiedMachineName.contains(prefix)) {
return true; // Explicit positive match
} else {
return false; // Explicit negative match
}
}
} else if (simplifiedMachineName.contains(targetVar)) {
return true;
}
}
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) {
String smPackage = getPackageName(currentMachineName);
String smSimple = getSimpleClassName(currentMachineName);
String smPrefix = getFirstCamelCaseWord(smSimple);
boolean hasPositiveMatch = false;
boolean hasStrongMismatch = false;
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
String chainClass = chain.getTriggerPoint().getClassName();
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
}
for (String method : chain.getMethodChain()) {
String chainClass = getClassNameOnly(method);
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix, smSimple, chainSimple)) {
hasStrongMismatch = true;
}
}
if (hasPositiveMatch) {
return true;
}
if (hasStrongMismatch) {
return false;
}
}
return context == null || isSingleStateMachine(context);
}
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 boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
return false;
}
if (smPackage.equals(chainPackage)) {
return true;
}
if (!chainPackage.startsWith(smPackage + ".") && !smPackage.startsWith(chainPackage + ".")) {
return false;
}
// Find deepest common package root
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
int matchIdx = -1;
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
if (p1[i].equals(p2[i])) {
matchIdx = i;
} else {
break;
}
}
// If the state machine's prefix is part of the shared common package,
// then the state machine represents the parent domain of the caller.
if (smPrefix != null && matchIdx >= 0) {
String lowerPrefix = smPrefix.toLowerCase();
for (int i = 0; i <= matchIdx; i++) {
if (p1[i].toLowerCase().contains(lowerPrefix) || lowerPrefix.contains(p1[i].toLowerCase())) {
return true;
}
}
}
return false;
}
private boolean isDomainMismatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix, String smSimple, String chainSimple) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
if (smPrefix == null || chainPrefix == null) return false;
String smLower = smPrefix.toLowerCase();
String chainLower = chainPrefix.toLowerCase();
// Ignore generic/technical class prefixes
if (smLower.equals("state") || smLower.equals("statemachine") || smLower.equals("config") || smLower.equals("configuration") || smLower.equals("adapter") ||
smLower.equals("enterprise") || smLower.equals("app") || smLower.equals("global") || smLower.equals("core") || smLower.equals("project") || smLower.equals("main") || smLower.equals("base") || smLower.equals("common") || smLower.equals("shared") ||
chainLower.equals("state") || chainLower.equals("statemachine") || chainLower.equals("config") || chainLower.equals("configuration") || chainLower.equals("adapter") ||
chainLower.equals("enterprise") || chainLower.equals("app") || chainLower.equals("global") || chainLower.equals("core") || chainLower.equals("project") || chainLower.equals("main") || chainLower.equals("base") || chainLower.equals("common") || chainLower.equals("shared")) {
return false;
}
// If class prefixes diverge, check if they are from different domains
if (!smLower.equals(chainLower)) {
// If one prefix is present as a domain/feature segment in the other package, they are related
if (chainPackage.toLowerCase().contains(smLower) || smPackage.toLowerCase().contains(chainLower)) {
return false;
}
// Otherwise, if they diverge under a shared project package root, it is a mismatch
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
int matchIdx = -1;
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
if (p1[i].equals(p2[i])) {
matchIdx = i;
} else {
break;
}
}
if (matchIdx >= 1) {
// If they diverge under a shared root, check if the divergence is only on generic package layers
boolean onlyGenericDivergence = true;
Set<String> genericLayers = Set.of(
"config", "configuration", "service", "services", "impl", "api", "web",
"controller", "controllers", "messaging", "listener", "listeners",
"repository", "repositories", "db", "model", "models", "domain",
"constants", "utils", "helper", "helpers", "event", "events",
"state", "states", "transition", "transitions"
);
for (int i = matchIdx + 1; i < p1.length; i++) {
if (!genericLayers.contains(p1[i].toLowerCase())) {
onlyGenericDivergence = false;
break;
}
}
if (onlyGenericDivergence) {
for (int i = matchIdx + 1; i < p2.length; i++) {
if (!genericLayers.contains(p2[i].toLowerCase())) {
onlyGenericDivergence = false;
break;
}
}
}
if (!onlyGenericDivergence) {
return true; // Mismatch under a shared root
}
}
}
return false;
}
private Set<String> extractDomainTerms(String pkg, String prefix) {
Set<String> terms = new HashSet<>();
if (pkg != null && !pkg.isEmpty()) {
String[] segments = pkg.split("\\.");
// Take segments from index 2 onwards if length > 2
int start = segments.length > 2 ? 2 : 0;
for (int i = start; i < segments.length; i++) {
terms.add(segments[i].toLowerCase());
}
}
if (prefix != null && !prefix.isEmpty()) {
terms.add(prefix.toLowerCase());
}
return terms;
}
private String getPackageName(String fqn) {
if (fqn == null || !fqn.contains(".")) return "";
return fqn.substring(0, fqn.lastIndexOf('.'));
}
private String getSimpleClassName(String fqn) {
if (fqn == null) return "";
if (!fqn.contains(".")) return fqn;
return fqn.substring(fqn.lastIndexOf('.') + 1);
}
private String getClassNameOnly(String methodFqn) {
if (methodFqn == null) return "";
String clean = methodFqn;
if (clean.contains("(")) {
clean = clean.substring(0, clean.indexOf('('));
}
if (clean.contains(".")) {
clean = clean.substring(0, clean.lastIndexOf('.'));
}
return clean;
}
private String getFirstCamelCaseWord(String simpleName) {
if (simpleName == null || simpleName.isEmpty()) return null;
String[] words = simpleName.split("(?<!^)(?=[A-Z])");
if (words.length > 0) {
return words[0];
}
return null;
}
private String eraseGenerics(String type) {
if (type == null) return null;
int idx = type.indexOf('<');
if (idx != -1) {
return type.substring(0, idx);
}
return 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 isErasedOrOpaqueType(String typeFqn) {
if (typeFqn == null) {
return false;
}
String erased = eraseGenerics(typeFqn);
return "java.lang.Object".equals(erased) || "Object".equals(erased)
|| "java.io.Serializable".equals(erased) || "Serializable".equals(erased);
}
private boolean isSingleStateMachine(CodebaseContext context) {
return countStateMachines(context) <= 1;
}
}

View File

@@ -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);
}
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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");
}
}

View File

@@ -0,0 +1,10 @@
package click.kamil.springstatemachineexporter.analysis.index;
public enum AccessorKind {
GETTER,
BOOLEAN_GETTER,
CONSTANT_GETTER,
RECORD_COMPONENT,
SETTER,
FLUENT_SETTER
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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) {
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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) {
}

View File

@@ -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;
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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()));
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,195 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
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.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.model.Transition;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.jackson.Jacksonized;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Getter
@Setter
@Builder(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class AnalysisResult {
private String name;
@Builder.Default
private Set<click.kamil.springstatemachineexporter.model.State> states = java.util.Collections.emptySet();
private List<Transition> transitions;
private Set<String> startStates;
private Set<String> endStates;
private boolean renderChoicesAsDiamonds;
@Builder.Default
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
private CodebaseMetadata metadata = CodebaseMetadata.empty();
public void applyResolution(Map<String, String> properties) {
var resolver = new click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver();
// 1. Resolve start/end states strings
if (startStates != null) {
this.startStates = startStates.stream()
.map(s -> resolver.resolveValue(s, properties))
.collect(java.util.stream.Collectors.toSet());
}
if (endStates != null) {
this.endStates = endStates.stream()
.map(s -> resolver.resolveValue(s, properties))
.collect(java.util.stream.Collectors.toSet());
}
// 2. Resolve states (State record is immutable, so recreate)
if (states != null) {
this.states = states.stream()
.map(s -> click.kamil.springstatemachineexporter.model.State.of(
resolver.resolveValue(s.rawName(), properties),
resolver.resolveValue(s.fullIdentifier(), properties)))
.collect(java.util.stream.Collectors.toSet());
}
// 3. Resolve transitions
if (transitions != null) {
for (var t : transitions) {
if (t.getEvent() != null) {
t.setEvent(click.kamil.springstatemachineexporter.model.Event.of(t.getEvent().rawName(), resolver.resolveValue(t.getEvent().fullIdentifier(), properties)));
}
// Resolve states within transitions
t.setSourceStates(t.getSourceStates().stream()
.map(s -> click.kamil.springstatemachineexporter.model.State.of(s.rawName(), resolver.resolveValue(s.fullIdentifier(), properties)))
.collect(java.util.stream.Collectors.toList()));
t.setTargetStates(t.getTargetStates().stream()
.map(s -> click.kamil.springstatemachineexporter.model.State.of(s.rawName(), resolver.resolveValue(s.fullIdentifier(), properties)))
.collect(java.util.stream.Collectors.toList()));
}
}
// 4. Resolve metadata (triggers, entry points, call chains)
if (metadata != null) {
List<TriggerPoint> resolvedTriggers = metadata.getTriggers() == null ? null
: metadata.getTriggers().stream()
.map(trigger -> resolveTrigger(trigger, resolver, properties))
.collect(java.util.stream.Collectors.toList());
List<CallChain> resolvedCallChains = metadata.getCallChains() == null ? null
: metadata.getCallChains().stream()
.map(chain -> resolveCallChain(chain, resolver, properties))
.collect(java.util.stream.Collectors.toList());
if (metadata.getEntryPoints() != null) {
for (var ep : metadata.getEntryPoints()) {
if (ep.getName() != null) {
ep.setName(resolver.resolveValue(ep.getName(), properties));
}
if (ep.getMetadata() != null) {
Map<String, String> resolvedMeta = new java.util.HashMap<>();
for (var entry : ep.getMetadata().entrySet()) {
resolvedMeta.put(entry.getKey(), resolver.resolveValue(entry.getValue(), properties));
}
ep.setMetadata(resolvedMeta);
}
}
}
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) {
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream()
.map(value -> resolver.resolveValue(value, properties))
.collect(java.util.stream.Collectors.toList());
return trigger.toBuilder()
.event(trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null)
.sourceState(trigger.getSourceState() != null
? resolver.resolveValue(trigger.getSourceState(), properties)
: null)
.polymorphicEvents(polymorphicEvents)
.build();
}
private static CallChain resolveCallChain(
CallChain chain,
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
Map<String, String> properties) {
TriggerPoint trigger = chain.getTriggerPoint() == null
? null
: resolveTrigger(chain.getTriggerPoint(), resolver, properties);
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) {
if (newMetadata == null) return;
List<TriggerPoint> combinedTriggers = new java.util.ArrayList<>(this.metadata.getTriggers());
if (newMetadata.getTriggers() != null) combinedTriggers.addAll(newMetadata.getTriggers());
List<EntryPoint> combinedEntryPoints = new java.util.ArrayList<>(this.metadata.getEntryPoints());
if (newMetadata.getEntryPoints() != null) combinedEntryPoints.addAll(newMetadata.getEntryPoints());
List<CallChain> combinedCallChains = new java.util.ArrayList<>(this.metadata.getCallChains());
if (newMetadata.getCallChains() != null) combinedCallChains.addAll(newMetadata.getCallChains());
java.util.Map<String, java.util.Map<String, String>> combinedProperties = new java.util.HashMap<>(this.metadata.getProperties());
if (newMetadata.getProperties() != null) combinedProperties.putAll(newMetadata.getProperties());
this.metadata = CodebaseMetadata.builder()
.triggers(Collections.unmodifiableList(combinedTriggers))
.entryPoints(Collections.unmodifiableList(combinedEntryPoints))
.callChains(Collections.unmodifiableList(combinedCallChains))
.properties(Collections.unmodifiableMap(combinedProperties))
.build();
}
}

View File

@@ -0,0 +1,18 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
import java.util.List;
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class BusinessFlow {
private final String name;
private final String description;
private final List<String> steps; // List of Event names in order
}

View File

@@ -0,0 +1,20 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
import java.util.List;
@Data
@Builder(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class CallChain {
private final EntryPoint entryPoint;
private final List<String> methodChain; // e.g., ["Controller.submit", "Service.process", "Service.send"]
private final TriggerPoint triggerPoint;
private final String contextMachineId;
private final List<MatchedTransition> matchedTransitions;
}

View File

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

View File

@@ -0,0 +1,30 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.jackson.Jacksonized;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Getter
@Builder(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class CodebaseMetadata {
@Builder.Default
private final List<TriggerPoint> triggers = Collections.emptyList();
@Builder.Default
private final List<EntryPoint> entryPoints = Collections.emptyList();
@Builder.Default
private final List<CallChain> callChains = Collections.emptyList();
@Builder.Default
private final Map<String, Map<String, String>> properties = Collections.emptyMap();
public static CodebaseMetadata empty() {
return CodebaseMetadata.builder().build();
}
}

View File

@@ -0,0 +1,36 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
import java.util.List;
import java.util.Map;
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class EntryPoint {
public enum Type { REST, KAFKA, RABBIT, JMS, SQS, SNS, CUSTOM }
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Parameter {
private final String name;
private final String type;
private final List<String> annotations;
}
private final Type type;
private String name; // e.g., "POST /orders" or "Kafka Topic: orders"
private final String className;
private final String methodName;
private final String sourceFile;
private Map<String, String> metadata; // e.g., path, topic, exchange
@Builder.Default
private final List<Parameter> parameters = java.util.Collections.emptyList();
}

View File

@@ -0,0 +1,19 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
import java.util.List;
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class LibraryHint {
private final String methodFqn; // e.g., "com.thirdparty.Workflow.send"
private final String event; // The event it triggers (static)
private final Integer eventArgumentIndex; // e.g., 0 to extract from the 0th argument
private final String eventArgumentMethod; // e.g., "getType" to extract from argument method call
}

View File

@@ -0,0 +1,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();
};
}
}

View File

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

View File

@@ -0,0 +1,69 @@
package click.kamil.springstatemachineexporter.analysis.model;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
import java.util.Map;
@Data
@Builder(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class TriggerPoint {
private String event;
private final String className;
private final String methodName;
private final String sourceFile;
private final String sourceModule;
private final String stateMachineId; // Optional: to link to a specific SM instance
private final String sourceState; // Optional: if we can determine the expected current state
private final int lineNumber;
@com.fasterxml.jackson.annotation.JsonIgnore
private final String stateTypeFqn; // Type of State (e.g. OrderStates FQN)
@com.fasterxml.jackson.annotation.JsonIgnore
private final String eventTypeFqn; // Type of Event (e.g. OrderEvents FQN)
private final java.util.List<String> polymorphicEvents; // NEW: stores concrete events resolved via deep polymorphism
private final boolean external;
private final String constraint;
private final boolean ambiguous;
public TriggerPoint(
String event,
String className,
String methodName,
String sourceFile,
String sourceModule,
String stateMachineId,
String sourceState,
int lineNumber,
String stateTypeFqn,
String eventTypeFqn,
java.util.List<String> polymorphicEvents,
boolean external,
String constraint,
boolean ambiguous) {
this.className = className;
this.methodName = methodName;
this.sourceFile = sourceFile;
this.sourceModule = sourceModule;
this.stateMachineId = stateMachineId;
this.sourceState = MachineEnumCanonicalizer.canonicalizeLabel(sourceState, stateTypeFqn);
this.lineNumber = lineNumber;
this.stateTypeFqn = stateTypeFqn;
this.eventTypeFqn = eventTypeFqn;
this.event = MachineEnumCanonicalizer.qualifyEventIdentifier(event, eventTypeFqn);
if (polymorphicEvents != null) {
this.polymorphicEvents = polymorphicEvents.stream()
.map(pe -> MachineEnumCanonicalizer.qualifyEventIdentifier(pe, eventTypeFqn))
.collect(java.util.stream.Collectors.toList());
} else {
this.polymorphicEvents = null;
}
this.external = external;
this.constraint = constraint;
this.ambiguous = ambiguous;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,59 @@
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");
private static final Set<String> UNWRAP_METHOD_NAMES = Set.of(
"of", "ofEntries", "asList", "entry", "just", "withPayload", "success");
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 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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,313 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
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;
import java.util.Set;
/**
* Extracts terminal payload literals from reactive factory chains ({@code Mono.just}, nested {@code flatMap}).
*/
public final class ReactiveExpressionSupport {
private static final Set<String> REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success");
private ReactiveExpressionSupport() {
}
public static String extractPayload(Expression expression, ConstantResolver constantResolver, CodebaseContext context) {
return extractPayload(expression, null, constantResolver, context);
}
/**
* Rewrites {@code p.getEvent()} inside a {@code flatMap(p -> ...)} 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 flatMapCall = findEnclosingFlatMap(lambda);
if (flatMapCall == null) {
return null;
}
String mappedReceiver = mapLambdaParameterToSource(
lambda,
paramName.getIdentifier(),
flatMapCall.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 = findMethodInvocationInMethod(methodFqn, expressionText, context);
if (getter == null) {
return null;
}
String remapped = remapLambdaParameterGetter(getter, constantResolver, context);
if (remapped != null) {
return remapped;
}
return null;
}
private static MethodInvocation findMethodInvocationInMethod(
String methodFqn,
String expressionText,
CodebaseContext context) {
if (methodFqn == null || expressionText == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
org.eclipse.jdt.core.dom.MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
final MethodInvocation[] match = new MethodInvocation[1];
md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (expressionText.equals(node.toString())) {
match[0] = node;
return false;
}
return super.visit(node);
}
});
return match[0];
}
private static LambdaExpression findEnclosingLambda(ASTNode node) {
ASTNode current = node.getParent();
while (current != null) {
if (current instanceof LambdaExpression lambda) {
return lambda;
}
current = current.getParent();
}
return null;
}
private static MethodInvocation findEnclosingFlatMap(ASTNode node) {
ASTNode current = node.getParent();
while (current != null) {
if (current instanceof MethodInvocation mi && "flatMap".equals(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 ("flatMap".equals(methodName) && !mi.arguments().isEmpty()) {
String fromArgument = extractFlatMapArgumentPayload(
(Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context);
if (fromArgument != null) {
return fromArgument;
}
}
if (REACTIVE_FACTORY_METHODS.contains(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 extractFlatMapArgumentPayload(
Expression argument,
Expression flatMapReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (argument instanceof LambdaExpression lambda) {
Expression bodyExpression = lambdaBodyExpression(lambda);
if (bodyExpression != null) {
String lambdaPayload = extractLambdaBodyPayload(
bodyExpression, lambda, flatMapReceiver, constantResolver, context);
if (lambdaPayload != null) {
return lambdaPayload;
}
return extractPayload(bodyExpression, flatMapReceiver, constantResolver, context);
}
}
return extractPayload(argument, flatMapReceiver, constantResolver, context);
}
private static String extractLambdaBodyPayload(
Expression bodyExpression,
LambdaExpression lambda,
Expression flatMapReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (!(bodyExpression instanceof MethodInvocation factoryCall)) {
return null;
}
if (!REACTIVE_FACTORY_METHODS.contains(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(), flatMapReceiver, 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 flatMapReceiver,
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 (flatMapReceiver instanceof MethodInvocation receiverFlatMap
&& "flatMap".equals(receiverFlatMap.getName().getIdentifier())
&& !receiverFlatMap.arguments().isEmpty()
&& receiverFlatMap.arguments().get(0) instanceof LambdaExpression feederLambda
&& feederLambda != lambda) {
Expression feederBody = lambdaBodyExpression(feederLambda);
if (feederBody != null) {
String feederPayload = extractLambdaBodyPayload(
feederBody, feederLambda, receiverFlatMap.getExpression(), constantResolver, context);
if (feederPayload != null) {
return feederPayload;
}
String extracted = extractPayload(
feederBody, receiverFlatMap.getExpression(), constantResolver, context);
if (extracted != null) {
return extracted;
}
}
}
Expression source = peelJustArgument(flatMapReceiver);
if (source instanceof MethodInvocation getterMi
&& getterMi.getExpression() instanceof SimpleName innerParamName) {
LambdaExpression outerLambda = findEnclosingLambda(lambda);
if (outerLambda != null && outerLambda != lambda) {
MethodInvocation outerFlatMap = findEnclosingFlatMap(outerLambda);
if (outerFlatMap != null) {
String mappedBase = mapLambdaParameterToSource(
outerLambda,
innerParamName.getIdentifier(),
outerFlatMap.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(flatMapReceiver, null, constantResolver, context);
}
private static Expression peelJustArgument(Expression expression) {
if (expression instanceof MethodInvocation mi
&& REACTIVE_FACTORY_METHODS.contains(mi.getName().getIdentifier())
&& !mi.arguments().isEmpty()) {
return (Expression) mi.arguments().get(0);
}
if (expression instanceof MethodInvocation mi && mi.getExpression() != null) {
return peelJustArgument(mi.getExpression());
}
return null;
}
private static Expression lambdaBodyExpression(LambdaExpression lambda) {
if (lambda.getBody() instanceof Expression bodyExpression) {
return bodyExpression;
}
if (lambda.getBody() instanceof Block block) {
for (Object statement : block.statements()) {
if (statement instanceof ReturnStatement returnStatement
&& returnStatement.getExpression() != null) {
return returnStatement.getExpression();
}
}
}
return null;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,190 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
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;
}
String cleanMachine = machineDomainKey.toUpperCase();
String expr = constraint;
Set<String> literals = extractStringLiterals(constraint);
for (String literal : literals) {
boolean matchesMachine = literal.equalsIgnoreCase(cleanMachine)
|| cleanMachine.equalsIgnoreCase(literal)
|| (cleanMachine.length() > literal.length() && cleanMachine.startsWith(literal.toUpperCase()));
String replacement = matchesMachine ? "true" : "false";
String regex1 = "(?i)[\"']?" + Pattern.quote(literal) + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\([^)]*\\)";
String regex2 = "(?i)[a-zA-Z0-9._]+\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
+ Pattern.quote(literal) + "[\"']?\\s*\\)";
String regex3 = "(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + Pattern.quote(literal) + "[\"']?";
String regex4 = "(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + Pattern.quote(literal) + "[\"']?";
expr = expr.replaceAll(regex1, replacement);
expr = expr.replaceAll(regex2, replacement);
expr = expr.replaceAll(regex3, replacement);
expr = expr.replaceAll(regex4, matchesMachine ? "false" : "true");
}
return evaluateBooleanExpression(expr);
}
public static boolean evaluateBooleanExpression(String expression) {
if (expression == null || expression.isBlank()) {
return true;
}
try {
return parseExpression(expression.replaceAll("\\s+", ""));
} catch (Exception e) {
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) {
if (constraint == null || constraint.isBlank()) {
return true;
}
if (bindings == null || bindings.isEmpty()) {
return true;
}
Set<String> equalityVars = extractEqualityVariables(constraint);
for (String varName : equalityVars) {
if (!bindings.containsKey(varName)) {
return false;
}
}
String expr = constraint;
for (Map.Entry<String, String> entry : bindings.entrySet()) {
expr = substituteVariableBindings(expr, entry.getKey(), entry.getValue());
}
return evaluateBooleanExpression(expr);
}
private static Set<String> extractEqualityVariables(String constraint) {
Set<String> vars = new HashSet<>();
Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*==");
Matcher matcher = pattern.matcher(constraint);
while (matcher.find()) {
vars.add(matcher.group(1));
}
return vars;
}
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);
}
String suffix = cleanValue.contains(".") ? cleanValue.substring(cleanValue.lastIndexOf('.') + 1) : cleanValue;
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 = cleanValue.equals(rhs)
|| cleanValue.endsWith("." + rhs)
|| suffix.equalsIgnoreCase(rhs)
|| cleanValue.equalsIgnoreCase(rhs);
matcher.appendReplacement(sb, matches ? "true" : "false");
}
matcher.appendTail(sb);
return sb.toString();
}
/**
* 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;
}
}

View File

@@ -0,0 +1,529 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.BooleanLiteral;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.ConditionalExpression;
import org.eclipse.jdt.core.dom.EnumConstantDeclaration;
import org.eclipse.jdt.core.dom.EnumDeclaration;
import org.eclipse.jdt.core.dom.AbstractTypeDeclaration;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.ExpressionStatement;
import org.eclipse.jdt.core.dom.FieldAccess;
import org.eclipse.jdt.core.dom.FieldDeclaration;
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.QualifiedName;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.SwitchCase;
import org.eclipse.jdt.core.dom.SwitchExpression;
import org.eclipse.jdt.core.dom.SwitchStatement;
import org.eclipse.jdt.core.dom.ThisExpression;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.YieldStatement;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Evaluates no-arg boolean member calls in dispatcher constraints (e.g. {@code eventType.isEvent()})
* against concrete enum constants by reading enum source — no hard-coded predicate names.
*/
public final class EnumMemberPredicateEvaluator {
private static final Pattern PREDICATE_PART =
Pattern.compile("!?\\s*([a-zA-Z][\\w]*)\\s*\\.\\s*([a-zA-Z][\\w]*)\\s*\\(\\s*\\)");
private EnumMemberPredicateEvaluator() {
}
public record PredicateCall(String receiverName, String methodName, boolean negated) {
}
public static boolean hasEnumMemberPredicates(String constraint) {
return !extractPredicateCalls(constraint).isEmpty();
}
public static List<PredicateCall> extractPredicateCalls(String constraint) {
if (constraint == null || constraint.isBlank()) {
return List.of();
}
List<PredicateCall> calls = new ArrayList<>();
for (String part : splitTopLevelAndParts(constraint)) {
Matcher matcher = PREDICATE_PART.matcher(part.trim());
if (matcher.matches()) {
boolean negated = part.trim().startsWith("!");
calls.add(new PredicateCall(matcher.group(1), matcher.group(2), negated));
}
}
return calls;
}
public static List<String> filterEnumConstants(
List<String> candidates,
String constraint,
String enumTypeFqn,
CodebaseContext context) {
if (candidates == null || candidates.isEmpty()) {
return candidates == null ? List.of() : candidates;
}
List<PredicateCall> predicates = extractPredicateCalls(constraint);
if (predicates.isEmpty()) {
return candidates;
}
EnumDeclaration enumDecl = findEnumDeclaration(enumTypeFqn, context);
if (enumDecl == null) {
return List.of();
}
List<String> filtered = new ArrayList<>();
for (String candidate : candidates) {
if (satisfiesPredicates(candidate, predicates, enumDecl, context)) {
filtered.add(candidate);
}
}
return filtered;
}
private static boolean satisfiesPredicates(
String canonicalConstantFqn,
List<PredicateCall> predicates,
EnumDeclaration enumDecl,
CodebaseContext context) {
String constantName = constantSimpleName(canonicalConstantFqn);
if (constantName == null) {
return false;
}
Map<String, Boolean> boolFields = resolveConstantBooleanFields(enumDecl, constantName);
for (PredicateCall predicate : predicates) {
Boolean methodResult = evaluateBooleanMethod(
enumDecl, predicate.methodName(), constantName, boolFields, context);
if (methodResult == null) {
return false;
}
boolean expected = predicate.negated ? !methodResult : methodResult;
if (!expected) {
return false;
}
}
return true;
}
static Boolean evaluateBooleanMethod(
EnumDeclaration enumDecl,
String methodName,
String currentConstantName,
Map<String, Boolean> boolFields,
CodebaseContext context) {
MethodDeclaration method = findParameterlessMethod(enumDecl, methodName);
if (method == null && context != null) {
method = findInterfaceParameterlessMethod(enumDecl, methodName, context);
}
if (method == null || method.getBody() == null) {
return null;
}
Boolean switchResult = evaluateSwitchDrivenMethod(
method, currentConstantName, boolFields, enumDecl, context);
if (switchResult != null) {
return switchResult;
}
Boolean[] result = new Boolean[1];
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (result[0] == null) {
result[0] = evaluateBooleanExpression(
node.getExpression(), currentConstantName, boolFields, enumDecl, context);
}
return super.visit(node);
}
});
return result[0];
}
private static Boolean evaluateSwitchDrivenMethod(
MethodDeclaration method,
String currentConstantName,
Map<String, Boolean> boolFields,
EnumDeclaration enumDecl,
CodebaseContext context) {
Boolean[] result = new Boolean[1];
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(SwitchStatement node) {
if (result[0] == null && isEnumSelfSelector(node.getExpression())) {
result[0] = evaluateSwitchLike(
node.statements(), currentConstantName, boolFields, enumDecl, context);
}
return result[0] == null;
}
@Override
public boolean visit(SwitchExpression node) {
if (result[0] == null && isEnumSelfSelector(node.getExpression())) {
result[0] = evaluateSwitchLike(
node.statements(), currentConstantName, boolFields, enumDecl, context);
}
return result[0] == null;
}
});
return result[0];
}
static Map<String, Boolean> resolveConstantBooleanFields(EnumDeclaration enumDecl, String constantName) {
Map<String, Boolean> fields = new HashMap<>();
EnumConstantDeclaration constant = findEnumConstant(enumDecl, constantName);
if (constant == null) {
return fields;
}
MethodDeclaration constructor = findEnumConstructor(enumDecl);
if (constructor != null && constant.arguments().size() == constructor.parameters().size()) {
for (int i = 0; i < constructor.parameters().size(); i++) {
SingleVariableDeclaration param =
(SingleVariableDeclaration) constructor.parameters().get(i);
Expression arg = (Expression) constant.arguments().get(i);
Boolean boolVal = literalBoolean(arg);
if (boolVal != null) {
fields.put(param.getName().getIdentifier(), boolVal);
mapFieldName(enumDecl, param.getName().getIdentifier(), boolVal, fields);
}
}
}
for (Object bodyObj : enumDecl.bodyDeclarations()) {
if (bodyObj instanceof FieldDeclaration fd) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
Expression init = fragment.getInitializer();
Boolean boolVal = literalBoolean(init);
if (boolVal != null) {
fields.put(fragment.getName().getIdentifier(), boolVal);
}
}
}
}
}
return fields;
}
private static void mapFieldName(
EnumDeclaration enumDecl,
String paramName,
Boolean value,
Map<String, Boolean> fields) {
for (Object bodyObj : enumDecl.bodyDeclarations()) {
if (!(bodyObj instanceof FieldDeclaration fd)) {
continue;
}
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(paramName)) {
fields.put(paramName, value);
}
}
}
}
private static Boolean evaluateBooleanExpression(
Expression expr,
String currentConstantName,
Map<String, Boolean> boolFields,
EnumDeclaration enumDecl,
CodebaseContext context) {
if (expr instanceof BooleanLiteral bl) {
return bl.booleanValue();
}
if (expr instanceof SimpleName sn) {
return boolFields.get(sn.getIdentifier());
}
if (expr instanceof FieldAccess fa) {
if (fa.getExpression() == null || fa.getExpression() instanceof ThisExpression) {
return boolFields.get(fa.getName().getIdentifier());
}
}
if (expr instanceof ParenthesizedExpression pe) {
return evaluateBooleanExpression(
pe.getExpression(), currentConstantName, boolFields, enumDecl, context);
}
if (expr instanceof ConditionalExpression ce) {
Boolean thenVal = evaluateBooleanExpression(
ce.getThenExpression(), currentConstantName, boolFields, enumDecl, context);
Boolean elseVal = evaluateBooleanExpression(
ce.getElseExpression(), currentConstantName, boolFields, enumDecl, context);
if (thenVal != null && elseVal != null && thenVal.equals(elseVal)) {
return thenVal;
}
return null;
}
if (expr instanceof SwitchExpression se && isEnumSelfSelector(se.getExpression())) {
return evaluateSwitchLike(se.statements(), currentConstantName, boolFields, enumDecl, context);
}
if (expr instanceof MethodInvocation mi
&& mi.arguments().isEmpty()
&& enumDecl != null
&& context != null) {
return evaluateBooleanMethod(
enumDecl, mi.getName().getIdentifier(), currentConstantName, boolFields, context);
}
return null;
}
private static Boolean evaluateSwitchLike(
List<?> statements,
String currentConstantName,
Map<String, Boolean> boolFields,
EnumDeclaration enumDecl,
CodebaseContext context) {
List<String> caseConstants = new ArrayList<>();
boolean defaultCase = false;
boolean sawArmBody = false;
boolean pendingCase = false;
for (Object statementObj : statements) {
if (statementObj instanceof SwitchCase switchCase) {
if (sawArmBody) {
caseConstants.clear();
defaultCase = false;
sawArmBody = false;
}
pendingCase = true;
if (switchCase.isDefault()) {
defaultCase = true;
} else {
for (Object exprObj : switchCase.expressions()) {
if (exprObj instanceof Expression caseExpr) {
String caseConstant = extractCaseConstantName(caseExpr);
if (caseConstant != null) {
caseConstants.add(caseConstant);
}
}
}
}
continue;
}
if (!pendingCase) {
continue;
}
sawArmBody = true;
if (!defaultCase && !caseConstants.contains(currentConstantName)) {
continue;
}
Boolean armValue = evaluateSwitchArmNode(
statementObj, currentConstantName, boolFields, enumDecl, context);
if (armValue != null) {
return armValue;
}
}
return null;
}
private static Boolean evaluateSwitchArmNode(
Object node,
String currentConstantName,
Map<String, Boolean> boolFields,
EnumDeclaration enumDecl,
CodebaseContext context) {
if (node instanceof ReturnStatement rs) {
return evaluateBooleanExpression(
rs.getExpression(), currentConstantName, boolFields, enumDecl, context);
}
if (node instanceof YieldStatement ys) {
return evaluateBooleanExpression(
ys.getExpression(), currentConstantName, boolFields, enumDecl, context);
}
if (node instanceof ExpressionStatement es) {
return evaluateBooleanExpression(
es.getExpression(), currentConstantName, boolFields, enumDecl, context);
}
if (node instanceof Block block) {
return evaluateLinearBooleanStatements(
block.statements(), currentConstantName, boolFields, enumDecl, context);
}
if (node instanceof Expression expression) {
return evaluateBooleanExpression(
expression, currentConstantName, boolFields, enumDecl, context);
}
return null;
}
private static Boolean evaluateLinearBooleanStatements(
List<?> statements,
String currentConstantName,
Map<String, Boolean> boolFields,
EnumDeclaration enumDecl,
CodebaseContext context) {
for (Object statementObj : statements) {
Boolean value = evaluateSwitchArmNode(
statementObj, currentConstantName, boolFields, enumDecl, context);
if (value != null) {
return value;
}
}
return null;
}
private static boolean isEnumSelfSelector(Expression expr) {
if (expr instanceof ThisExpression) {
return true;
}
if (expr instanceof ParenthesizedExpression pe) {
return isEnumSelfSelector(pe.getExpression());
}
return false;
}
private static String extractCaseConstantName(Expression expr) {
if (expr instanceof SimpleName simpleName) {
return simpleName.getIdentifier();
}
if (expr instanceof QualifiedName qualifiedName) {
return qualifiedName.getName().getIdentifier();
}
if (expr instanceof FieldAccess fieldAccess) {
return fieldAccess.getName().getIdentifier();
}
if (expr instanceof ParenthesizedExpression pe) {
return extractCaseConstantName(pe.getExpression());
}
return null;
}
private static Boolean literalBoolean(Expression expr) {
return expr instanceof BooleanLiteral bl ? bl.booleanValue() : null;
}
private static MethodDeclaration findParameterlessMethod(AbstractTypeDeclaration typeDecl, String methodName) {
if (typeDecl == null) {
return null;
}
for (Object bodyObj : typeDecl.bodyDeclarations()) {
if (bodyObj instanceof MethodDeclaration md
&& !md.isConstructor()
&& methodName.equals(md.getName().getIdentifier())
&& md.parameters().isEmpty()) {
return md;
}
}
return null;
}
private static MethodDeclaration findInterfaceParameterlessMethod(
EnumDeclaration enumDecl,
String methodName,
CodebaseContext context) {
CompilationUnit cu = enumDecl.getRoot() instanceof CompilationUnit root ? root : null;
for (Object typeObj : enumDecl.superInterfaceTypes()) {
if (!(typeObj instanceof SimpleType simpleType)) {
continue;
}
String interfaceName = simpleType.getName().getFullyQualifiedName();
TypeDeclaration interfaceDecl = context.getTypeDeclaration(interfaceName, cu);
MethodDeclaration method = findParameterlessMethod(interfaceDecl, methodName);
if (method != null) {
return method;
}
}
return null;
}
private static MethodDeclaration findEnumConstructor(EnumDeclaration enumDecl) {
MethodDeclaration implicit = null;
for (Object bodyObj : enumDecl.bodyDeclarations()) {
if (bodyObj instanceof MethodDeclaration md && md.isConstructor()) {
if (md.parameters().isEmpty()) {
implicit = md;
} else {
return md;
}
}
}
return implicit;
}
private static EnumConstantDeclaration findEnumConstant(EnumDeclaration enumDecl, String constantName) {
for (Object constantObj : enumDecl.enumConstants()) {
if (constantObj instanceof EnumConstantDeclaration ecd
&& constantName.equals(ecd.getName().getIdentifier())) {
return ecd;
}
}
return null;
}
static EnumDeclaration findEnumDeclaration(String enumTypeFqn, CodebaseContext context) {
if (enumTypeFqn == null || context == null) {
return null;
}
String clean = enumTypeFqn.contains("<") ? enumTypeFqn.substring(0, enumTypeFqn.indexOf('<')) : enumTypeFqn;
for (CompilationUnit cu : context.getCompilationUnits()) {
String pkg = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
for (Object typeObj : cu.types()) {
EnumDeclaration found = findEnumNode(typeObj, clean, pkg, context);
if (found != null) {
return found;
}
}
}
return null;
}
private static EnumDeclaration findEnumNode(Object typeObj, String targetFqn, String pkg, CodebaseContext context) {
if (typeObj instanceof EnumDeclaration ed) {
String fqn = pkg.isEmpty() ? ed.getName().getIdentifier() : pkg + "." + ed.getName().getIdentifier();
if (targetFqn.equals(fqn)) {
return ed;
}
} else if (typeObj instanceof TypeDeclaration td) {
String parentFqn = pkg.isEmpty() ? td.getName().getIdentifier() : pkg + "." + td.getName().getIdentifier();
for (Object decl : td.bodyDeclarations()) {
if (decl instanceof EnumDeclaration nested) {
String nestedFqn = parentFqn + "." + nested.getName().getIdentifier();
if (targetFqn.equals(nestedFqn)) {
return nested;
}
}
}
}
return null;
}
private static String constantSimpleName(String canonicalConstantFqn) {
if (canonicalConstantFqn == null || !canonicalConstantFqn.contains(".")) {
return canonicalConstantFqn;
}
return canonicalConstantFqn.substring(canonicalConstantFqn.lastIndexOf('.') + 1);
}
private static List<String> splitTopLevelAndParts(String constraint) {
List<String> parts = new ArrayList<>();
StringBuilder current = new StringBuilder();
int depth = 0;
for (int i = 0; i < constraint.length(); i++) {
char c = constraint.charAt(i);
if (c == '(') {
depth++;
} else if (c == ')') {
depth--;
} else if (depth == 0 && i + 1 < constraint.length()
&& c == '&' && constraint.charAt(i + 1) == '&') {
parts.add(current.toString());
current.setLength(0);
i++;
continue;
}
current.append(c);
}
if (!current.isEmpty()) {
parts.add(current.toString());
}
return parts;
}
}

View File

@@ -0,0 +1,140 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Stream;
/**
* Discovers generated {@code .java} sources under Maven {@code target/} and Gradle {@code build/} when
* they already exist in the workspace. Does not run Maven or Gradle.
*/
@Slf4j
public final class GeneratedSourceDiscovery {
private static final List<String> BUILD_ROOTS = List.of("target", "build");
private static final List<String> EXCLUDED_RELATIVE_SEGMENTS = List.of(
"classes",
"test-classes",
"generated-test-sources",
"generated-test",
"test",
"testfixtures",
"test-fixtures",
"androidtest",
"inttest",
"functionaltest",
"e2etest",
"libs",
"lib",
"tmp",
"reports",
"test-results",
"surefire",
"failsafe",
"surefire-reports",
"failsafe-reports",
"cache",
"caches",
"outputs",
"intermediates",
"kotlin",
"native",
"resources",
"distributions",
"docs",
"javadoc",
"testOutput");
public Set<Path> discoverSourceRoots(Collection<Path> moduleDirectories) {
Set<Path> roots = new LinkedHashSet<>();
if (moduleDirectories == null) {
return roots;
}
for (Path moduleDir : moduleDirectories) {
if (moduleDir == null || !Files.isDirectory(moduleDir)) {
continue;
}
roots.addAll(discoverSourceRoots(moduleDir));
}
return roots;
}
public Set<Path> discoverSourceRoots(Path moduleDirectory) {
Set<Path> roots = new LinkedHashSet<>();
Path normalizedModule = moduleDirectory.toAbsolutePath().normalize();
for (String buildRootName : BUILD_ROOTS) {
Path buildRoot = normalizedModule.resolve(buildRootName);
if (containsGeneratedJava(normalizedModule, buildRoot)) {
roots.add(buildRoot);
}
}
if (!roots.isEmpty()) {
log.info("Discovered generated source roots under {}: {}", normalizedModule, roots);
}
return roots;
}
public boolean hasGeneratedSources(Path moduleDirectory) {
return !discoverSourceRoots(moduleDirectory).isEmpty();
}
private boolean containsGeneratedJava(Path moduleDirectory, Path buildRoot) {
if (!Files.isDirectory(buildRoot)) {
return false;
}
try (Stream<Path> files = Files.walk(buildRoot)) {
return files.filter(Files::isRegularFile)
.anyMatch(path -> path.getFileName().toString().endsWith(".java")
&& !isExcludedBuildPath(moduleDirectory, path));
} catch (IOException e) {
log.debug("Failed to scan generated sources under {}", buildRoot, e);
return false;
}
}
static boolean isExcludedBuildPath(Path moduleDirectory, Path candidate) {
Path normalizedModule = moduleDirectory.toAbsolutePath().normalize();
Path normalizedCandidate = candidate.toAbsolutePath().normalize();
if (!normalizedCandidate.startsWith(normalizedModule)) {
return true;
}
Path relative = normalizedModule.relativize(normalizedCandidate);
if (relative.getNameCount() < 2) {
return false;
}
String buildRoot = relative.getName(0).toString().toLowerCase(Locale.ROOT);
if (!buildRoot.equals("target") && !buildRoot.equals("build")) {
return true;
}
for (int i = 1; i < relative.getNameCount(); i++) {
String segment = relative.getName(i).toString().toLowerCase(Locale.ROOT);
if (isExcludedBuildSegment(segment)) {
return true;
}
}
return false;
}
static boolean isExcludedBuildSegment(String segment) {
if (segment == null || segment.isBlank()) {
return false;
}
String lower = segment.toLowerCase(Locale.ROOT);
if (EXCLUDED_RELATIVE_SEGMENTS.contains(lower)) {
return true;
}
return lower.contains("test-sources")
|| lower.startsWith("test-")
|| lower.endsWith("-test")
|| lower.contains("-test-");
}
}

View File

@@ -0,0 +1,304 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.BiFunction;
/**
* Compile-time literal folding for map lookups, array indexing, and narrow string transforms.
*/
final class LiteralExpressionSupport {
private LiteralExpressionSupport() {
}
static String resolveArrayAccess(
ArrayAccess access,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
Integer index = resolveLiteralInt(access.getIndex(), context, visited, resolver);
if (index == null || index < 0) {
return null;
}
List<String> elements = extractArrayElements(access.getArray(), context, visited, resolver);
if (elements == null || index >= elements.size()) {
return null;
}
return elements.get(index);
}
static String resolveMapGet(
MethodInvocation getCall,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (getCall.arguments().isEmpty() || getCall.getExpression() == null) {
return null;
}
String key = resolver.apply((Expression) getCall.arguments().get(0), visited);
if (key == null) {
return null;
}
Map<String, String> entries = extractMapEntries(getCall.getExpression(), context, visited, resolver);
if (entries == null) {
return null;
}
return entries.get(key);
}
static String resolveLiteralStringTransform(
MethodInvocation transformCall,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (!transformCall.arguments().isEmpty() || transformCall.getExpression() == null) {
return null;
}
String receiver = resolver.apply(transformCall.getExpression(), visited);
if (receiver == null) {
return null;
}
return switch (transformCall.getName().getIdentifier()) {
case "toUpperCase" -> receiver.toUpperCase(Locale.ROOT);
case "toLowerCase" -> receiver.toLowerCase(Locale.ROOT);
case "trim" -> receiver.trim();
default -> null;
};
}
static Integer resolveLiteralInt(
Expression expr,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (expr instanceof NumberLiteral nl) {
try {
return Integer.parseInt(nl.getToken().replace("_", ""));
} catch (NumberFormatException e) {
return null;
}
}
if (expr instanceof ParenthesizedExpression pe) {
return resolveLiteralInt(pe.getExpression(), context, visited, resolver);
}
if (expr instanceof InfixExpression infix) {
Integer left = resolveLiteralInt(infix.getLeftOperand(), context, visited, resolver);
Integer right = resolveLiteralInt(infix.getRightOperand(), context, visited, resolver);
if (left == null || right == null) {
return null;
}
InfixExpression.Operator op = infix.getOperator();
if (op == InfixExpression.Operator.PLUS) {
return left + right;
}
if (op == InfixExpression.Operator.MINUS) {
return left - right;
}
}
String resolved = resolver.apply(expr, visited);
if (resolved != null) {
try {
return Integer.parseInt(resolved.replace("_", ""));
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
static Map<String, String> extractMapEntries(
Expression expr,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (expr instanceof MethodInvocation mi) {
Map<String, String> fromFactory = extractMapFactoryEntries(mi, context, visited, resolver);
if (fromFactory != null) {
return fromFactory;
}
return null;
}
String resolvedField = resolver.apply(expr, visited);
if (resolvedField != null && resolvedField.startsWith("MAP:")) {
return parseMapLiteral(resolvedField);
}
return null;
}
private static Map<String, String> extractMapFactoryEntries(
MethodInvocation mi,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (!isMapFactory(mi.getExpression())) {
return null;
}
String method = mi.getName().getIdentifier();
if ("of".equals(method)) {
return extractAlternatingPairs(mi.arguments(), context, visited, resolver);
}
if ("ofEntries".equals(method)) {
Map<String, String> entries = new LinkedHashMap<>();
for (Object argObj : mi.arguments()) {
if (argObj instanceof Expression entryExpr) {
Map<String, String> single = extractMapEntryCall(entryExpr, context, visited, resolver);
if (single != null) {
entries.putAll(single);
}
}
}
return entries.isEmpty() ? null : entries;
}
return null;
}
private static Map<String, String> extractMapEntryCall(
Expression expr,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (!(expr instanceof MethodInvocation entryCall) || !"entry".equals(entryCall.getName().getIdentifier())) {
return null;
}
if (!isMapFactory(entryCall.getExpression()) || entryCall.arguments().size() != 2) {
return null;
}
String key = resolver.apply((Expression) entryCall.arguments().get(0), visited);
String value = resolver.apply((Expression) entryCall.arguments().get(1), visited);
if (key == null || value == null) {
return null;
}
return Map.of(key, value);
}
private static Map<String, String> extractAlternatingPairs(
List<?> arguments,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (arguments.size() < 2) {
return null;
}
Map<String, String> entries = new LinkedHashMap<>();
for (int i = 0; i + 1 < arguments.size(); i += 2) {
String key = resolver.apply((Expression) arguments.get(i), visited);
String value = resolver.apply((Expression) arguments.get(i + 1), visited);
if (key == null || value == null) {
return null;
}
entries.put(key, value);
}
return entries;
}
static String encodeMapLiteral(Map<String, String> entries) {
StringBuilder sb = new StringBuilder("MAP:");
boolean first = true;
for (Map.Entry<String, String> entry : entries.entrySet()) {
if (!first) {
sb.append('|');
}
first = false;
sb.append(escape(entry.getKey())).append('=').append(escape(entry.getValue()));
}
return sb.toString();
}
static Map<String, String> parseMapLiteral(String encoded) {
if (encoded == null || !encoded.startsWith("MAP:")) {
return null;
}
Map<String, String> entries = new LinkedHashMap<>();
for (String pair : encoded.substring(4).split("\\|")) {
int eq = pair.indexOf('=');
if (eq <= 0) {
continue;
}
entries.put(unescape(pair.substring(0, eq)), unescape(pair.substring(eq + 1)));
}
return entries.isEmpty() ? null : entries;
}
static List<String> mapLiteralValues(String encoded) {
Map<String, String> entries = parseMapLiteral(encoded);
if (entries == null) {
return null;
}
return List.copyOf(entries.values());
}
private static String escape(String value) {
return value.replace("\\", "\\\\").replace("|", "\\|").replace("=", "\\=");
}
private static String unescape(String value) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '\\' && i + 1 < value.length()) {
sb.append(value.charAt(++i));
} else {
sb.append(c);
}
}
return sb.toString();
}
private static List<String> extractArrayElements(
Expression arrayExpr,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (arrayExpr instanceof ArrayCreation creation && creation.getInitializer() != null) {
return extractExpressionList(creation.getInitializer().expressions(), context, visited, resolver);
}
if (arrayExpr instanceof ArrayInitializer initializer) {
return extractExpressionList(initializer.expressions(), context, visited, resolver);
}
String resolved = resolver.apply(arrayExpr, visited);
if (resolved != null && resolved.startsWith("ARRAY:")) {
return List.of(resolved.substring(6).split("\\|", -1));
}
return null;
}
static String encodeArrayLiteral(List<String> values) {
return "ARRAY:" + String.join("|", values.stream().map(LiteralExpressionSupport::escape).toList());
}
private static List<String> extractExpressionList(
List<?> expressions,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
List<String> values = new ArrayList<>();
for (Object expressionObj : expressions) {
String value = resolver.apply((Expression) expressionObj, visited);
if (value == null) {
return null;
}
values.add(value);
}
return values;
}
private static boolean isMapFactory(Expression expression) {
if (expression == null) {
return false;
}
if (expression instanceof SimpleName sn) {
return "Map".equals(sn.getIdentifier());
}
if (expression instanceof QualifiedName qn) {
return "Map".equals(qn.getName().getIdentifier());
}
return false;
}
}

View File

@@ -0,0 +1,849 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Canonicalizes enum-backed state/event identifiers for analysis using the owning machine config's
* {@code StateMachineConfigurerAdapter<State, Event>} type parameters.
*
* <p>Display formatting ({@code --event}/{@code --state}) is separate; this fixes internal
* {@code fullIdentifier} values used for matching and traceability.
*/
public final class MachineEnumCanonicalizer {
private MachineEnumCanonicalizer() {
}
public static void canonicalizeTransitions(
List<Transition> transitions,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (transitions == null || machineTypes == null) {
return;
}
for (Transition transition : transitions) {
if (transition.getEvent() != null) {
transition.setEvent(canonicalizeEvent(transition.getEvent(), machineTypes.eventTypeFqn()));
}
if (transition.getSourceStates() != null) {
transition.setSourceStates(canonicalizeStates(transition.getSourceStates(), machineTypes.stateTypeFqn()));
}
if (transition.getTargetStates() != null) {
transition.setTargetStates(canonicalizeStates(transition.getTargetStates(), machineTypes.stateTypeFqn()));
}
}
}
public static Set<String> canonicalizeStateLabels(Set<String> labels, String stateTypeFqn) {
if (labels == null || labels.isEmpty()) {
return labels;
}
return labels.stream()
.map(label -> canonicalizeLabel(label, stateTypeFqn))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
public static Set<State> canonicalizeStates(Set<State> states, String stateTypeFqn) {
if (states == null || states.isEmpty()) {
return states;
}
LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>();
for (State state : states) {
State canonical = canonicalizeState(state, stateTypeFqn);
byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical);
}
return new LinkedHashSet<>(byFullIdentifier.values());
}
public static TriggerPoint canonicalizeTriggerPoint(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (trigger == null) {
return null;
}
String eventTypeFqn = preferFullTypeFqn(
trigger.getEventTypeFqn(),
machineTypes != null ? machineTypes.eventTypeFqn() : null);
String stateTypeFqn = preferFullTypeFqn(
trigger.getStateTypeFqn(),
machineTypes != null ? machineTypes.stateTypeFqn() : null);
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream()
.map(event -> canonicalizeLabel(event, eventTypeFqn))
.collect(Collectors.toList());
return trigger.toBuilder()
.eventTypeFqn(eventTypeFqn)
.stateTypeFqn(stateTypeFqn)
.event(canonicalizeLabel(trigger.getEvent(), eventTypeFqn))
.sourceState(canonicalizeLabel(trigger.getSourceState(), stateTypeFqn))
.polymorphicEvents(polymorphicEvents)
.build();
}
/**
* Expands {@code <SYMBOLIC: EnumType.*>} placeholders into concrete package-canonical enum
* constants for the owning machine's event type. Non-matching symbolic entries are dropped.
*/
public static List<String> expandSymbolicPolymorphicEvents(
List<String> polymorphicEvents,
String machineEventTypeFqn,
CodebaseContext context) {
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return polymorphicEvents;
}
if (machineEventTypeFqn == null || machineEventTypeFqn.isBlank()) {
return polymorphicEvents;
}
List<String> expanded = new ArrayList<>();
for (String pe : polymorphicEvents) {
if (pe == null) {
continue;
}
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
String symbolicType = pe.substring("<SYMBOLIC: ".length(), pe.length() - 3).trim();
if (!enumTypesMatch(machineEventTypeFqn, symbolicType)) {
continue;
}
if (context != null) {
List<String> enumValues = context.getEnumValues(stripGenerics(machineEventTypeFqn));
if (enumValues != null) {
for (String value : enumValues) {
String parsed = value;
if (parsed.contains(".")) {
parsed = parsed.substring(parsed.lastIndexOf('.') + 1);
}
expanded.add(stripGenerics(machineEventTypeFqn) + "." + parsed);
}
}
}
continue;
}
expanded.add(pe);
}
return expanded;
}
public static TriggerPoint canonicalizeAndExpandTriggerPoint(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
TriggerPoint canonical = canonicalizeTriggerPoint(trigger, machineTypes);
if (canonical == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
return canonical;
}
List<String> expanded = expandSymbolicPolymorphicEvents(
canonical.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context);
List<String> narrowed = narrowExpandedPolymorphicEvents(
expanded,
canonical.getConstraint(),
machineTypes.eventTypeFqn(),
null,
context);
if (Objects.equals(narrowed, canonical.getPolymorphicEvents())) {
return canonical;
}
return canonical.toBuilder().polymorphicEvents(narrowed).build();
}
/**
* Expands symbolic placeholders and applies predicate/transition narrowing when possible.
*/
public static List<String> narrowExpandedPolymorphicEvents(
List<String> polymorphicEvents,
String constraint,
String eventTypeFqn,
List<Transition> machineTransitions,
CodebaseContext context) {
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return polymorphicEvents;
}
return narrowPolymorphicCandidates(
polymorphicEvents, constraint, eventTypeFqn, machineTransitions, context);
}
/**
* When a call-chain trigger uses a dynamic fire-site expression ({@code valueOf}, variable, getter)
* but has no concrete {@code polymorphicEvents}, infer all machine enum events so transition linking
* can proceed. Used only for {@code callChains[].triggerPoint}, not {@code metadata.triggers}.
*/
public static TriggerPoint ensureCallChainPolymorphicEvents(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
return ensureCallChainPolymorphicEvents(trigger, machineTypes, context, null, false);
}
public static TriggerPoint ensureCallChainPolymorphicEvents(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context,
List<Transition> machineTransitions) {
return ensureCallChainPolymorphicEvents(trigger, machineTypes, context, machineTransitions, false);
}
public static TriggerPoint ensureCallChainPolymorphicEvents(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context,
List<Transition> machineTransitions,
boolean skipCanonicalization) {
TriggerPoint expanded = skipCanonicalization
? expandSymbolicOnly(trigger, machineTypes, context)
: canonicalizeAndExpandTriggerPoint(trigger, machineTypes, context);
if (expanded == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
return expanded;
}
List<String> postExpand = expanded.getPolymorphicEvents();
if (postExpand != null && postExpand.stream().anyMatch(pe -> pe != null && pe.startsWith("<SYMBOLIC:"))) {
postExpand = expandSymbolicPolymorphicEvents(
postExpand, machineTypes.eventTypeFqn(), context);
postExpand = narrowExpandedPolymorphicEvents(
postExpand,
expanded.getConstraint(),
machineTypes.eventTypeFqn(),
machineTransitions,
context);
if (!Objects.equals(postExpand, expanded.getPolymorphicEvents())) {
expanded = expanded.toBuilder().polymorphicEvents(postExpand).build();
}
}
if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())) {
List<String> narrowed = narrowPolymorphicCandidates(
expanded.getPolymorphicEvents(),
expanded.getConstraint(),
machineTypes.eventTypeFqn(),
machineTransitions,
context);
if (!narrowed.equals(expanded.getPolymorphicEvents())) {
return expanded.toBuilder()
.polymorphicEvents(narrowed)
.ambiguous(narrowed.size() > 1)
.build();
}
return expanded;
}
if (shouldInferPolymorphicEvents(expanded, machineTransitions)) {
List<String> machineEvents = inferPolymorphicCandidates(
expanded.getConstraint(),
machineTypes.eventTypeFqn(),
machineTransitions,
context);
if (!machineEvents.isEmpty()) {
return expanded.toBuilder()
.polymorphicEvents(machineEvents)
.ambiguous(machineEvents.size() > 1)
.build();
}
}
return expanded;
}
private static TriggerPoint expandSymbolicOnly(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
if (trigger == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
return trigger;
}
List<String> expanded = expandSymbolicPolymorphicEvents(
trigger.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context);
if (java.util.Objects.equals(expanded, trigger.getPolymorphicEvents())) {
return trigger;
}
return trigger.toBuilder().polymorphicEvents(expanded).build();
}
private static boolean shouldInferPolymorphicEvents(
TriggerPoint trigger,
List<Transition> machineTransitions) {
if (trigger == null || machineTransitions == null || machineTransitions.isEmpty()) {
return false;
}
if (hasOnlySymbolicPolymorphicEvents(trigger.getPolymorphicEvents())) {
return true;
}
List<String> polyEvents = trigger.getPolymorphicEvents();
if (polyEvents != null && !polyEvents.isEmpty()) {
return false;
}
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(trigger.getConstraint())) {
return true;
}
if (trigger.isExternal() || trigger.isAmbiguous()) {
return true;
}
return classifyTriggerEvent(trigger.getEvent()) == TriggerEventKind.DYNAMIC_EXPRESSION;
}
private static boolean hasOnlySymbolicPolymorphicEvents(List<String> polymorphicEvents) {
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return false;
}
boolean hasSymbolic = false;
for (String pe : polymorphicEvents) {
if (pe == null) {
continue;
}
if (pe.startsWith("<SYMBOLIC:")) {
hasSymbolic = true;
continue;
}
if (classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM) {
return false;
}
}
return hasSymbolic;
}
private static List<String> inferPolymorphicCandidates(
String constraint,
String eventTypeFqn,
List<Transition> machineTransitions,
CodebaseContext context) {
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn);
if (!transitionEvents.isEmpty()) {
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, context);
return filtered.isEmpty() ? transitionEvents : filtered;
}
List<String> enumConstants = allPackageCanonicalEnumConstants(eventTypeFqn, context);
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
enumConstants, constraint, eventTypeFqn, context);
if (!filtered.isEmpty()) {
return filtered;
}
if (!transitionEvents.isEmpty()) {
return transitionEvents;
}
return List.of();
}
private static List<String> narrowPolymorphicCandidates(
List<String> current,
String constraint,
String eventTypeFqn,
List<Transition> machineTransitions,
CodebaseContext context) {
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn);
List<String> result = EnumMemberPredicateEvaluator.filterEnumConstants(
current, constraint, eventTypeFqn, context);
if (result.isEmpty() && !transitionEvents.isEmpty()) {
result = new ArrayList<>(transitionEvents);
}
if (!looksOverBroad(result, transitionEvents, constraint)) {
return result;
}
if (!transitionEvents.isEmpty()) {
List<String> intersected = new ArrayList<>();
for (String event : result) {
if (transitionEvents.contains(event)) {
intersected.add(event);
}
}
if (!intersected.isEmpty()) {
return intersected;
}
List<String> transitionFiltered = EnumMemberPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, context);
return transitionFiltered.isEmpty() ? transitionEvents : transitionFiltered;
}
return result;
}
private static boolean looksOverBroad(
List<String> current,
List<String> transitionEvents,
String constraint) {
if (current == null || current.isEmpty()) {
return false;
}
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) {
return true;
}
if (current.size() <= 1) {
return false;
}
if (transitionEvents.isEmpty()) {
return current.size() > 1;
}
if (current.size() > transitionEvents.size()) {
return true;
}
for (String event : current) {
if (!transitionEvents.contains(event)) {
return true;
}
}
return false;
}
public static List<String> polymorphicEventsFromTransitions(
List<Transition> machineTransitions,
String eventTypeFqn) {
if (machineTransitions == null || machineTransitions.isEmpty() || eventTypeFqn == null) {
return List.of();
}
List<String> events = new ArrayList<>();
for (Transition transition : machineTransitions) {
if (transition.getEvent() == null) {
continue;
}
String identifier = transition.getEvent().fullIdentifier() != null
? transition.getEvent().fullIdentifier()
: transition.getEvent().rawName();
if (identifier == null || identifier.isBlank()) {
continue;
}
String canonical = canonicalizeLabel(identifier, eventTypeFqn);
if (classifyTriggerEvent(canonical) == TriggerEventKind.CANONICAL_ENUM
&& !events.contains(canonical)) {
events.add(canonical);
}
}
return events;
}
private static boolean hasConcretePolymorphicEvents(List<String> polymorphicEvents) {
return polymorphicEvents != null
&& polymorphicEvents.stream()
.anyMatch(pe -> classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM);
}
private static List<String> allPackageCanonicalEnumConstants(String eventTypeFqn, CodebaseContext context) {
if (eventTypeFqn == null || eventTypeFqn.isBlank() || context == null) {
return List.of();
}
String enumType = stripGenerics(eventTypeFqn);
List<String> enumValues = context.getEnumValues(enumType);
if (enumValues == null || enumValues.isEmpty()) {
return List.of();
}
List<String> canonical = new ArrayList<>();
for (String value : enumValues) {
String constant = value.contains(".") ? value.substring(value.lastIndexOf('.') + 1) : value;
canonical.add(enumType + "." + constant);
}
return canonical;
}
public static String qualifyEventIdentifier(String event, String eventTypeFqn) {
if (event == null || eventTypeFqn == null || event.isEmpty()) {
return event;
}
if (isDynamicTriggerExpression(event)) {
return denormalizeCorruptedDynamicEvent(event);
}
if (event.startsWith("<SYMBOLIC: ")) {
return event;
}
if (isWildcardVariable(event)) {
return event;
}
if (Character.isLowerCase(event.charAt(0))) {
return event;
}
if (!isEnumConstantCandidate(event)) {
return event;
}
if (isStringOrPrimitiveType(eventTypeFqn)) {
return event;
}
if (isMachineEnumReference(event, eventTypeFqn)) {
return canonicalizeLabel(event, stripGenerics(eventTypeFqn));
}
return event;
}
private static String preferFullTypeFqn(String primary, String fallback) {
if (primary != null && primary.contains(".")) {
return stripGenerics(primary);
}
if (fallback != null && fallback.contains(".")) {
return stripGenerics(fallback);
}
return primary != null ? primary : fallback;
}
private static String stripGenerics(String typeFqn) {
int idx = typeFqn.indexOf('<');
return idx >= 0 ? typeFqn.substring(0, idx) : typeFqn;
}
public static boolean isStringOrPrimitiveType(String typeFqn) {
if (typeFqn == null) {
return false;
}
return typeFqn.equals("String") || typeFqn.equals("java.lang.String")
|| typeFqn.equals("int") || typeFqn.equals("long") || typeFqn.equals("char")
|| typeFqn.equals("java.lang.Object") || typeFqn.equals("Object");
}
private static boolean isEnumConstantCandidate(String eventStr) {
if (eventStr == null) {
return false;
}
if (eventStr.startsWith("<SYMBOLIC: ")) {
return false;
}
return !eventStr.contains(" ") && !eventStr.contains("(") && !eventStr.contains(")")
&& !eventStr.contains("?");
}
private static boolean isWildcardVariable(String eventStr) {
return isDynamicTriggerExpression(eventStr);
}
/**
* AST-derived trigger events that must never receive package-canonical enum rewriting
* ({@code getType()}, {@code valueOf(...)}, ternary dispatchers, parameter names, etc.).
*/
public static boolean isDynamicTriggerExpression(String eventStr) {
if (eventStr == null || eventStr.isBlank()) {
return false;
}
if (eventStr.startsWith("<SYMBOLIC: ")) {
return false;
}
if (eventStr.contains("${")) {
return true;
}
if (eventStr.equals("event") || eventStr.equals("e")
|| eventStr.equals("msg") || eventStr.equals("message")
|| eventStr.equals("payload")) {
return true;
}
if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) {
return true;
}
if (eventStr.contains(" ? ") && eventStr.contains(" : ")) {
return true;
}
if (eventStr.contains("(") || eventStr.contains(")")) {
return true;
}
if (Character.isLowerCase(eventStr.charAt(0)) && !eventStr.contains(".")) {
return true;
}
return false;
}
/**
* Reverts mistaken package-prefixing on dynamic {@code Type.valueOf(...)} trigger expressions.
*/
public static String denormalizeCorruptedDynamicEvent(String value) {
if (value == null || !value.contains(".valueOf(")) {
return value;
}
return value.replaceAll("(?:[a-z][a-z0-9_]*\\.)+([A-Z][A-Za-z0-9_]*)\\.valueOf\\(", "$1.valueOf(");
}
public enum TriggerEventKind {
CANONICAL_ENUM,
DYNAMIC_EXPRESSION,
SYMBOLIC,
LIFECYCLE,
OTHER
}
public static TriggerEventKind classifyTriggerEvent(String event) {
if (event == null || event.isBlank()) {
return TriggerEventKind.OTHER;
}
if (event.startsWith("<SYMBOLIC: ")) {
return TriggerEventKind.SYMBOLIC;
}
if (click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers.isLifecycle(event)) {
return TriggerEventKind.LIFECYCLE;
}
if (isDynamicTriggerExpression(event)) {
return TriggerEventKind.DYNAMIC_EXPRESSION;
}
if (event.contains(".")) {
String constant = constantName(event);
if (constant.matches("[A-Z_][A-Z0-9_]*")) {
return TriggerEventKind.CANONICAL_ENUM;
}
}
return TriggerEventKind.OTHER;
}
public static boolean hasCorruptedPackageDynamicEvent(String event) {
if (event == null || !event.contains(".valueOf(")) {
return false;
}
String denormalized = denormalizeCorruptedDynamicEvent(event);
return !event.equals(denormalized);
}
private static List<State> canonicalizeStates(List<State> states, String stateTypeFqn) {
if (states == null) {
return null;
}
LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>();
for (State state : states) {
State canonical = canonicalizeState(state, stateTypeFqn);
byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical);
}
return new ArrayList<>(byFullIdentifier.values());
}
static Event canonicalizeEvent(Event event, String enumTypeFqn) {
if (event == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return event;
}
String canonical = canonicalizeLabel(
event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(),
enumTypeFqn);
String fnRaw = toFnForm(canonical, enumTypeFqn);
if (fnRaw == null) {
fnRaw = event.rawName();
}
if (canonical.equals(event.fullIdentifier()) && fnRaw.equals(event.rawName())) {
return event;
}
return Event.of(fnRaw, canonical);
}
static State canonicalizeState(State state, String enumTypeFqn) {
if (state == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return state;
}
String full = resolvePlaceholder(
state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName());
String canonical = canonicalizeLabel(full, enumTypeFqn);
String raw;
if (isStringOrPrimitiveType(enumTypeFqn) && canonical.startsWith(enumTypeFqn + ".")) {
String constant = canonical.substring(enumTypeFqn.length() + 1);
raw = "\"" + constant + "\"";
} else {
String fnRaw = toFnForm(canonical, enumTypeFqn);
raw = fnRaw != null ? fnRaw : resolvePlaceholder(state.rawName());
}
if (canonical.equals(state.fullIdentifier()) && raw.equals(state.rawName())) {
return state;
}
return State.of(raw, canonical);
}
private static String resolvePlaceholder(String value) {
if (value == null || !value.contains("${")) {
return value;
}
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
value, java.util.Collections.emptyMap());
}
public static boolean isMachineEnumReference(String value, String enumTypeFqn) {
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return false;
}
if (isStringOrPrimitiveType(enumTypeFqn)) {
return false;
}
if (value.startsWith("<SYMBOLIC: ")) {
return false;
}
if (value.length() >= 2 && value.startsWith("\"")) {
return false;
}
if (!isEnumConstantCandidate(value)) {
return false;
}
if (Character.isLowerCase(value.charAt(0))) {
return false;
}
String typePart = enumTypeFromRef(value);
if (typePart == null) {
return true;
}
return enumTypesMatch(enumTypeFqn, typePart);
}
public static String canonicalizeLabel(String value, String enumTypeFqn) {
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return value;
}
if (isDynamicTriggerExpression(value)) {
return denormalizeCorruptedDynamicEvent(value);
}
value = resolvePlaceholder(value);
if (value == null || value.isBlank()) {
return value;
}
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
return stripQuotes(value);
}
String stripped = value;
if (stripped == null || stripped.isBlank()) {
return value;
}
if (stripped.startsWith(enumTypeFqn + ".")) {
return stripped;
}
String constant = constantName(stripped);
String typePart = enumTypeFromRef(stripped);
if (typePart != null && enumTypesMatch(enumTypeFqn, typePart)) {
return enumTypeFqn + "." + constant;
}
if (typePart != null && !typePart.contains(".") && !enumTypesMatch(enumTypeFqn, typePart)
&& importStyleEnumTypeMatches(typePart, enumTypeFqn)
&& constant.matches("[A-Z_][A-Z0-9_]*")) {
return enumTypeFqn + "." + constant;
}
if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) {
return enumTypeFqn + "." + stripped;
}
return stripped;
}
private static String stripQuotes(String value) {
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
return value.substring(1, value.length() - 1);
}
return value;
}
private static String constantName(String ref) {
int dot = ref.lastIndexOf('.');
return dot >= 0 ? ref.substring(dot + 1) : ref;
}
private static String enumTypeFromRef(String ref) {
int dot = ref.lastIndexOf('.');
if (dot <= 0) {
return null;
}
return ref.substring(0, dot);
}
private static boolean importStyleEnumTypeMatches(String typePart, String enumTypeFqn) {
String simple = simpleName(enumTypeFqn);
return simple.equals(typePart) || (simple + "s").equals(typePart);
}
public static boolean enumTypesMatch(String type1, String type2) {
if (type1 == null || type2 == null) {
return false;
}
if (type1.equals(type2)) {
return true;
}
if (type1.endsWith("." + type2) || type2.endsWith("." + type1)) {
return true;
}
String simple1 = simpleName(type1);
String simple2 = simpleName(type2);
if (!simple1.equals(simple2)) {
return false;
}
return !type1.contains(".") || !type2.contains(".");
}
private static String simpleName(String fqn) {
return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
}
/**
* Derives {@code EnumFormat.fn} display form ({@code OrderEvent.PAY}) from a package-canonical identifier.
*/
public static String expectedFnRawName(String canonicalFqn, String enumTypeFqn) {
return toFnForm(canonicalFqn, enumTypeFqn);
}
/**
* Returns whether {@code rawName} is the fn-form counterpart of a package-canonical {@code fullIdentifier}.
*/
public static boolean isRawNameConsistentWithFullIdentifier(
String rawName,
String fullIdentifier,
String enumTypeFqn) {
if (fullIdentifier == null || fullIdentifier.isBlank() || isStringOrPrimitiveType(enumTypeFqn)) {
return true;
}
if (fullIdentifier.startsWith("<") || fullIdentifier.startsWith("\"")) {
return true;
}
if (isDynamicTriggerExpression(fullIdentifier)) {
return true;
}
String canonical = canonicalizeLabel(fullIdentifier, enumTypeFqn);
if (!canonical.equals(fullIdentifier)) {
return true;
}
if (!constantName(fullIdentifier).matches("[A-Z_][A-Z0-9_]*")) {
return true;
}
String expectedRaw = toFnForm(fullIdentifier, enumTypeFqn);
if (expectedRaw == null) {
return true;
}
return expectedRaw.equals(rawName);
}
/**
* Polymorphic event lists must contain only package-canonical enum constants or symbolic wildcards.
*/
public static boolean isValidPolymorphicEventForm(String event) {
if (event == null || event.isBlank()) {
return false;
}
TriggerEventKind kind = classifyTriggerEvent(event);
if (kind != TriggerEventKind.CANONICAL_ENUM && kind != TriggerEventKind.SYMBOLIC) {
return false;
}
return !hasCorruptedPackageDynamicEvent(event);
}
/**
* True when every entry is a concrete package-canonical enum constant (not symbolic/dynamic).
*/
public static boolean hasOnlyConcretePolymorphicEvents(List<String> polymorphicEvents) {
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return false;
}
return polymorphicEvents.stream()
.allMatch(pe -> classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM);
}
static String toFnForm(String canonicalFqn, String enumTypeFqn) {
if (canonicalFqn == null || canonicalFqn.isBlank() || isStringOrPrimitiveType(enumTypeFqn)) {
return null;
}
if (canonicalFqn.startsWith("<") || canonicalFqn.startsWith("\"")) {
return null;
}
if (!canonicalFqn.contains(".")) {
return canonicalFqn;
}
int lastDot = canonicalFqn.lastIndexOf('.');
int prevDot = canonicalFqn.lastIndexOf('.', lastDot - 1);
if (prevDot > 0) {
return canonicalFqn.substring(prevDot + 1);
}
return simpleName(enumTypeFqn) + "." + canonicalFqn.substring(lastDot + 1);
}
}

View File

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

View File

@@ -0,0 +1,259 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Queue;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Static wiring model built from parsed {@code settings.gradle}, {@code build.gradle}, and {@code pom.xml}
* files — no Maven/Gradle execution.
*/
public final class ProjectModuleGraph {
public enum BuildSystem {
GRADLE,
MAVEN,
MIXED
}
public enum DependencyKind {
LOCAL_PROJECT,
EXTERNAL_LIBRARY
}
public record MavenCoordinates(String groupId, String artifactId) {
public MavenCoordinates {
Objects.requireNonNull(groupId, "groupId");
Objects.requireNonNull(artifactId, "artifactId");
}
public String key() {
return groupId + ":" + artifactId;
}
}
public record ProjectModule(
String id,
Path directory,
BuildSystem buildSystem,
MavenCoordinates mavenCoordinates) {
public Optional<MavenCoordinates> coordinates() {
return Optional.ofNullable(mavenCoordinates);
}
}
public record ModuleDependency(
String fromModuleId,
String toModuleId,
MavenCoordinates externalCoordinates,
DependencyKind kind) {
}
private final Path workspaceRoot;
private final Map<String, ProjectModule> modulesById;
private final Map<MavenCoordinates, ProjectModule> modulesByCoordinates;
private final List<ModuleDependency> dependencies;
public ProjectModuleGraph(
Path workspaceRoot,
Map<String, ProjectModule> modulesById,
Map<MavenCoordinates, ProjectModule> modulesByCoordinates,
List<ModuleDependency> dependencies) {
this.workspaceRoot = workspaceRoot != null ? workspaceRoot.normalize() : null;
this.modulesById = Map.copyOf(modulesById);
this.modulesByCoordinates = Map.copyOf(modulesByCoordinates);
this.dependencies = List.copyOf(dependencies);
}
public Path workspaceRoot() {
return workspaceRoot;
}
public Map<String, ProjectModule> modulesById() {
return modulesById;
}
public Map<MavenCoordinates, ProjectModule> modulesByCoordinates() {
return modulesByCoordinates;
}
public List<ModuleDependency> dependencies() {
return dependencies;
}
public Optional<ProjectModule> findByDirectory(Path directory) {
if (directory == null) {
return Optional.empty();
}
Path normalized = directory.toAbsolutePath().normalize();
return modulesById.values().stream()
.filter(module -> module.directory().toAbsolutePath().normalize().equals(normalized))
.findFirst();
}
public Optional<ProjectModule> findContainingModule(Path path) {
if (path == null) {
return Optional.empty();
}
Path normalized = path.toAbsolutePath().normalize();
ProjectModule best = null;
int bestLength = -1;
for (ProjectModule module : modulesById.values()) {
Path moduleDir = module.directory().toAbsolutePath().normalize();
if (normalized.startsWith(moduleDir) && moduleDir.getNameCount() > bestLength) {
best = module;
bestLength = moduleDir.getNameCount();
}
}
return Optional.ofNullable(best);
}
public Set<Path> allModuleDirectories() {
Set<Path> paths = new LinkedHashSet<>();
for (ProjectModule module : modulesById.values()) {
if (Files.isDirectory(module.directory())) {
paths.add(module.directory().toAbsolutePath().normalize());
}
}
return paths;
}
public boolean isGradleAggregatorRoot(Path directory) {
if (directory == null || workspaceRoot == null) {
return false;
}
return directory.toAbsolutePath().normalize().equals(workspaceRoot)
&& modulesById.values().stream().anyMatch(module -> module.buildSystem() == BuildSystem.GRADLE);
}
public boolean isMavenAggregatorRoot(Path directory) {
if (directory == null) {
return false;
}
Path normalized = directory.toAbsolutePath().normalize();
long childModules = modulesById.values().stream()
.filter(module -> module.directory().toAbsolutePath().normalize().startsWith(normalized))
.filter(module -> !module.directory().toAbsolutePath().normalize().equals(normalized))
.count();
return childModules > 0 && findByDirectory(normalized)
.map(module -> module.buildSystem() == BuildSystem.MAVEN || module.buildSystem() == BuildSystem.MIXED)
.orElse(false);
}
/**
* Resolves scan roots for analysis: the input directory, all Gradle includes when scanning from the
* workspace root, and transitively reachable local modules from build-file wiring.
*/
public Set<Path> resolveScanPaths(Path inputDir) {
return resolveScanPaths(inputDir, true);
}
public Set<Path> resolveScanPaths(Path inputDir, boolean includeGeneratedSources) {
if (inputDir == null) {
return Set.of();
}
Set<Path> scanPaths = new LinkedHashSet<>();
Path normalizedInput = inputDir.toAbsolutePath().normalize();
scanPaths.add(normalizedInput);
if (workspaceRoot != null && normalizedInput.equals(workspaceRoot)) {
scanPaths.addAll(allModuleDirectories());
}
Optional<ProjectModule> inputModule = findContainingModule(normalizedInput);
if (inputModule.isPresent()) {
scanPaths.addAll(transitiveLocalModuleDirectories(inputModule.get().id()));
resolveMavenParentDirectory(inputModule.get().directory()).ifPresent(scanPaths::add);
Path parentDir = inputModule.get().directory().getParent();
if (parentDir != null && Files.exists(parentDir.resolve("pom.xml"))) {
scanPaths.add(parentDir.toAbsolutePath().normalize());
}
}
if (includeGeneratedSources) {
scanPaths.addAll(new GeneratedSourceDiscovery().discoverSourceRoots(scanPaths));
}
return scanPaths;
}
static Optional<Path> resolveMavenParentDirectory(Path moduleDirectory) {
if (moduleDirectory == null) {
return Optional.empty();
}
Path pom = moduleDirectory.resolve("pom.xml");
if (!Files.exists(pom)) {
return Optional.empty();
}
try {
String content = Files.readString(pom);
Matcher parentMatcher = Pattern.compile(
"<parent>.*?<relativePath>(.*?)</relativePath>.*?</parent>", Pattern.DOTALL).matcher(content);
if (!parentMatcher.find()) {
return Optional.empty();
}
String relPathStr = parentMatcher.group(1).trim();
Path relPath = moduleDirectory.resolve(relPathStr).normalize();
if (Files.isRegularFile(relPath) && "pom.xml".equals(relPath.getFileName().toString())) {
return Optional.of(relPath.getParent().toAbsolutePath().normalize());
}
if (Files.isDirectory(relPath)) {
return Optional.of(relPath.toAbsolutePath().normalize());
}
} catch (IOException ignored) {
return Optional.empty();
}
return Optional.empty();
}
public Set<Path> transitiveLocalModuleDirectories(String rootModuleId) {
Set<Path> reachable = new LinkedHashSet<>();
Queue<String> queue = new ArrayDeque<>();
Set<String> visited = new HashSet<>();
if (rootModuleId != null) {
queue.add(rootModuleId);
}
while (!queue.isEmpty()) {
String current = queue.poll();
if (!visited.add(current)) {
continue;
}
ProjectModule module = modulesById.get(current);
if (module != null && Files.isDirectory(module.directory())) {
reachable.add(module.directory().toAbsolutePath().normalize());
}
for (ModuleDependency dependency : dependencies) {
if (dependency.kind() == DependencyKind.LOCAL_PROJECT
&& current.equals(dependency.fromModuleId())
&& dependency.toModuleId() != null) {
queue.add(dependency.toModuleId());
}
}
}
return reachable;
}
public List<ModuleDependency> localDependenciesOf(String moduleId) {
List<ModuleDependency> result = new ArrayList<>();
for (ModuleDependency dependency : dependencies) {
if (moduleId.equals(dependency.fromModuleId())) {
result.add(dependency);
}
}
return result;
}
}

View File

@@ -0,0 +1,136 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.stream.Stream;
@Slf4j
public class PropertyResolver {
private final List<String> activeProfiles = new ArrayList<>();
public void setActiveProfiles(List<String> profiles) {
this.activeProfiles.clear();
if (profiles != null) {
this.activeProfiles.addAll(profiles);
}
}
public Map<String, String> resolveProperties(Path rootDir) {
Map<String, String> allProperties = new HashMap<>();
// 1. Load base properties
loadMatching(rootDir, "application.properties", allProperties);
loadMatching(rootDir, "application.yml", allProperties);
loadMatching(rootDir, "application.yaml", allProperties);
// 2. Load profile-specific properties (overriding base)
for (String profile : activeProfiles) {
loadMatching(rootDir, "application-" + profile + ".properties", allProperties);
loadMatching(rootDir, "application-" + profile + ".yml", allProperties);
loadMatching(rootDir, "application-" + profile + ".yaml", allProperties);
}
return allProperties;
}
public Map<String, Map<String, String>> resolveAllProperties(Set<Path> rootDirs) {
Map<String, Map<String, String>> profiles = new HashMap<>();
for (Path rootDir : rootDirs) {
// 1. Load default profile
Map<String, String> defaultProps = profiles.computeIfAbsent("default", k -> new HashMap<>());
loadMatching(rootDir, "application.properties", defaultProps);
loadMatching(rootDir, "application.yml", defaultProps);
loadMatching(rootDir, "application.yaml", defaultProps);
// 2. Discover and load all other profiles
try (Stream<Path> paths = Files.walk(rootDir)) {
paths.filter(Files::isRegularFile)
.filter(p -> {
String name = p.getFileName().toString();
return name.startsWith("application-") &&
(name.endsWith(".properties") || name.endsWith(".yml") || name.endsWith(".yaml"));
})
.forEach(p -> {
String name = p.getFileName().toString();
String profile = name.substring("application-".length(), name.lastIndexOf('.'));
Map<String, String> loaded = p.toString().endsWith(".properties") ? loadProperties(p) : loadYaml(p);
profiles.computeIfAbsent(profile, k -> new HashMap<>()).putAll(loaded);
});
} catch (IOException e) {
log.error("Failed to scan for profiles in {}", rootDir, e);
}
}
return profiles;
}
private void loadMatching(Path rootDir, String fileName, Map<String, String> target) {
try (Stream<Path> paths = Files.walk(rootDir)) {
paths.filter(p -> Files.isRegularFile(p))
.filter(p -> p.getFileName().toString().equals(fileName))
.forEach(p -> {
if (p.toString().endsWith(".properties")) {
target.putAll(loadProperties(p));
} else {
target.putAll(loadYaml(p));
}
});
} catch (IOException e) {
log.error("Failed to scan for {} in {}", fileName, rootDir, e);
}
}
private Map<String, String> loadProperties(Path path) {
Map<String, String> props = new HashMap<>();
Properties p = new Properties();
try (InputStream is = Files.newInputStream(path)) {
p.load(is);
p.forEach((k, v) -> props.put(k.toString(), v.toString()));
} catch (IOException e) {
log.warn("Failed to load properties from {}", path);
}
return props;
}
private Map<String, String> loadYaml(Path path) {
Map<String, String> props = new HashMap<>();
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());
Map<String, Object> map = mapper.readValue(path.toFile(), new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
flattenYaml("", map, props);
} catch (IOException e) {
log.warn("Failed to load YAML from {}", path);
}
return props;
}
@SuppressWarnings("unchecked")
private void flattenYaml(String prefix, Map<String, Object> map, Map<String, String> result) {
if (map == null) return;
for (Map.Entry<String, Object> entry : map.entrySet()) {
String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey();
Object value = entry.getValue();
if (value instanceof Map) {
flattenYaml(key, (Map<String, Object>) value, result);
} else if (value != null) {
result.put(key, value.toString());
}
}
}
public String resolveValue(String placeholder, Map<String, String> properties) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(placeholder, properties);
}
}

View File

@@ -0,0 +1,43 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Discovers related local modules by statically parsing Maven/Gradle build files (never executes build tools).
*/
@Slf4j
public class SiblingDependencyResolver {
private final StaticBuildFileAnalyzer analyzer = new StaticBuildFileAnalyzer();
public ProjectModuleGraph analyzeProject(Path inputDir) throws IOException {
return analyzer.analyze(inputDir);
}
public Set<Path> resolveSiblings(Path projectDir) {
try {
ProjectModuleGraph graph = analyzer.analyze(projectDir);
return resolveSiblings(graph, projectDir);
} catch (IOException e) {
log.warn("Error resolving sibling dependencies for {}", projectDir, e);
return Set.of();
}
}
public Set<Path> resolveSiblings(ProjectModuleGraph graph, Path inputDir) {
Set<Path> scanPaths = graph.resolveScanPaths(inputDir);
Set<Path> siblings = new LinkedHashSet<>();
Path normalizedInput = inputDir.toAbsolutePath().normalize();
for (Path path : scanPaths) {
if (!path.equals(normalizedInput)) {
siblings.add(path);
}
}
return siblings;
}
}

View File

@@ -0,0 +1,210 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.service.TypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Resolves {@code StateMachineConfigurerAdapter<S, E>} type arguments for a config class.
* Used by analysis (canonical enum ids) and routing (machine scoping).
*/
public final class StateMachineTypeResolver {
public record MachineTypes(String stateTypeFqn, String eventTypeFqn) {
public static MachineTypes empty() {
return new MachineTypes(null, null);
}
}
private StateMachineTypeResolver() {
}
public static String[] resolve(String machineConfigFqn, CodebaseContext context) {
MachineTypes types = resolveTypes(machineConfigFqn, context);
return new String[]{types.stateTypeFqn(), types.eventTypeFqn()};
}
public static MachineTypes resolveTypes(String machineConfigFqn, CodebaseContext context) {
if (machineConfigFqn == null || context == null) {
return MachineTypes.empty();
}
String configFqn = machineConfigFqn;
if (configFqn.contains("#")) {
configFqn = configFqn.substring(0, configFqn.indexOf('#'));
}
TypeDeclaration td = context.getTypeDeclaration(configFqn);
if (td == null) {
return MachineTypes.empty();
}
Set<String> visited = new HashSet<>();
visited.add(configFqn);
String[] args = findTypeArgumentsRecursively(
td.getSuperclassType(),
context,
(CompilationUnit) td.getRoot(),
visited,
Map.of());
return new MachineTypes(args[0], args[1]);
}
public static boolean extendsEnumStateMachineConfigurer(String machineConfigFqn, CodebaseContext context) {
if (machineConfigFqn == null || context == null) {
return false;
}
String configFqn = machineConfigFqn;
if (configFqn.contains("#")) {
configFqn = configFqn.substring(0, configFqn.indexOf('#'));
}
TypeDeclaration td = context.getTypeDeclaration(configFqn);
if (td == null) {
return false;
}
Set<String> visited = new HashSet<>();
visited.add(configFqn);
return hierarchyHasEnumConfigurer(
td.getSuperclassType(),
context,
(CompilationUnit) td.getRoot(),
visited);
}
private static boolean hierarchyHasEnumConfigurer(
Type type,
CodebaseContext context,
CompilationUnit cu,
Set<String> visited) {
if (type == null) {
return false;
}
if (type instanceof ParameterizedType pt) {
String rawTypeName = pt.getType().toString();
if (rawTypeName.equals("EnumStateMachineConfigurerAdapter")
|| rawTypeName.endsWith(".EnumStateMachineConfigurerAdapter")) {
return true;
}
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(rawTypeName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
Type superclass = td instanceof TypeDeclaration typeDecl ? typeDecl.getSuperclassType() : null;
if (hierarchyHasEnumConfigurer(superclass, context, cu, visited)) {
return true;
}
}
}
} else {
String simpleName = type.toString();
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
Type superclass = td instanceof TypeDeclaration typeDecl ? typeDecl.getSuperclassType() : null;
if (hierarchyHasEnumConfigurer(superclass, context, cu, visited)) {
return true;
}
}
}
}
return false;
}
private static String[] findTypeArgumentsRecursively(
Type type,
CodebaseContext context,
CompilationUnit cu,
Set<String> visited,
Map<String, String> typeVarBindings) {
if (type == null) {
return new String[]{null, null};
}
if (type instanceof ParameterizedType pt) {
String rawTypeName = pt.getType().toString();
List<?> typeArgs = pt.typeArguments();
if (isStateMachineConfigurerAdapter(rawTypeName)) {
if (typeArgs.size() >= 2) {
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), cu, context, typeVarBindings);
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), cu, context, typeVarBindings);
return new String[]{stateType, eventType};
}
}
Map<String, String> nextBindings = extendBindings(rawTypeName, typeArgs, cu, context, typeVarBindings);
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(rawTypeName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
Type superclass = td instanceof TypeDeclaration typeDecl ? typeDecl.getSuperclassType() : null;
String[] res = findTypeArgumentsRecursively(superclass, context, cu, visited, nextBindings);
if (res[0] != null || res[1] != null) {
return res;
}
}
}
} else {
String simpleName = type.toString();
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
Type superclass = td instanceof TypeDeclaration typeDecl ? typeDecl.getSuperclassType() : null;
String[] res = findTypeArgumentsRecursively(superclass, context, cu, visited, typeVarBindings);
if (res[0] != null || res[1] != null) {
return res;
}
}
}
}
return new String[]{null, null};
}
private static Map<String, String> extendBindings(
String rawTypeName,
List<?> typeArgs,
CompilationUnit cu,
CodebaseContext context,
Map<String, String> parentBindings) {
Map<String, String> bindings = new HashMap<>(parentBindings);
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(rawTypeName, cu);
if (!(td instanceof TypeDeclaration typeDecl) || typeArgs.isEmpty()) {
return bindings;
}
List<TypeParameter> typeParameters = typeDecl.typeParameters();
for (int i = 0; i < Math.min(typeParameters.size(), typeArgs.size()); i++) {
String paramName = typeParameters.get(i).getName().getIdentifier();
String resolvedArg = resolveTypeToFqn((Type) typeArgs.get(i), cu, context, parentBindings);
if (resolvedArg != null) {
bindings.put(paramName, resolvedArg);
}
}
return bindings;
}
private static boolean isStateMachineConfigurerAdapter(String rawTypeName) {
return rawTypeName.equals("StateMachineConfigurerAdapter")
|| rawTypeName.equals("EnumStateMachineConfigurerAdapter")
|| rawTypeName.endsWith(".StateMachineConfigurerAdapter")
|| rawTypeName.endsWith(".EnumStateMachineConfigurerAdapter");
}
static String resolveTypeToFqn(Type type, CompilationUnit cu, CodebaseContext context) {
return resolveTypeToFqn(type, cu, context, Map.of());
}
static String resolveTypeToFqn(
Type type,
CompilationUnit cu,
CodebaseContext context,
Map<String, String> typeVarBindings) {
return new TypeResolver(context).resolveTypeToFqn(type, cu, typeVarBindings);
}
}

View File

@@ -0,0 +1,450 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Parses Maven/Gradle build files statically to discover modules and local dependency wiring.
*/
@Slf4j
public class StaticBuildFileAnalyzer {
private static final Pattern GRADLE_PROJECT_DEP = Pattern.compile("project\\(['\"]:(.*?)['\"]\\)");
private static final Pattern QUOTED_TOKEN = Pattern.compile("['\"]([^'\"]+)['\"]");
private static final Pattern GRADLE_PROJECT_DIR = Pattern.compile(
"project\\(['\"]:(.*?)['\"]\\)\\.projectDir\\s*=\\s*(?:file\\(['\"](.*?)['\"]\\)|new File\\(.*?,\\s*['\"](.*?)['\"]\\))");
private static final Pattern MAVEN_MODULE = Pattern.compile("<module>(.*?)</module>");
private static final Pattern MAVEN_DEPENDENCY_BLOCK = Pattern.compile("<dependency>(.*?)</dependency>", Pattern.DOTALL);
private static final Pattern MAVEN_TAG = Pattern.compile("<(groupId|artifactId)>(.*?)</\\1>");
public ProjectModuleGraph analyze(Path inputDir) throws IOException {
Path workspaceRoot = findWorkspaceRoot(inputDir);
Path indexRoot = findIndexingRoot(inputDir, workspaceRoot);
if (workspaceRoot == null) {
workspaceRoot = inputDir.toAbsolutePath().normalize();
}
if (indexRoot == null) {
indexRoot = workspaceRoot;
}
Map<String, ProjectModuleGraph.ProjectModule> modulesById = new LinkedHashMap<>();
Map<ProjectModuleGraph.MavenCoordinates, ProjectModuleGraph.ProjectModule> modulesByCoordinates = new LinkedHashMap<>();
List<ProjectModuleGraph.ModuleDependency> dependencies = new ArrayList<>();
Path gradleSettingsRoot = Files.exists(workspaceRoot.resolve("settings.gradle"))
|| Files.exists(workspaceRoot.resolve("settings.gradle.kts"))
? workspaceRoot
: indexRoot;
Map<String, Path> gradleModulePaths = parseGradleIncludes(gradleSettingsRoot);
for (Map.Entry<String, Path> entry : gradleModulePaths.entrySet()) {
registerGradleModule(entry.getKey(), entry.getValue(), modulesById);
}
List<Path> pomFiles = findPomFiles(indexRoot);
for (Path pom : pomFiles) {
registerMavenSubmodules(pom, modulesById, modulesByCoordinates);
}
for (Path pom : pomFiles) {
registerMavenModule(pom, modulesById, modulesByCoordinates);
}
ensureInputModule(inputDir, modulesById);
for (ProjectModuleGraph.ProjectModule module : List.copyOf(modulesById.values())) {
if (module.buildSystem() == ProjectModuleGraph.BuildSystem.GRADLE
|| module.buildSystem() == ProjectModuleGraph.BuildSystem.MIXED) {
dependencies.addAll(parseGradleDependencies(module, modulesById, gradleModulePaths));
}
}
for (Path pom : pomFiles) {
dependencies.addAll(parseMavenDependencies(pom, modulesById, modulesByCoordinates));
}
log.info("Static project graph: {} modules, {} dependencies under {}", modulesById.size(), dependencies.size(), workspaceRoot);
return new ProjectModuleGraph(workspaceRoot, modulesById, modulesByCoordinates, dependencies);
}
Map<String, Path> parseGradleIncludes(Path gradleRoot) throws IOException {
Map<String, Path> includes = new LinkedHashMap<>();
if (gradleRoot == null) {
return includes;
}
Path settingsFile = gradleRoot.resolve("settings.gradle");
if (!Files.exists(settingsFile)) {
settingsFile = gradleRoot.resolve("settings.gradle.kts");
}
if (!Files.exists(settingsFile)) {
return includes;
}
String content = Files.readString(settingsFile);
for (String includePath : extractGradleIncludePaths(content)) {
String normalized = normalizeGradlePath(includePath);
Path moduleDir = resolveGradleModuleDirectory(gradleRoot, normalized, content);
if (Files.isDirectory(moduleDir)) {
includes.put(normalized, moduleDir.normalize());
}
}
return includes;
}
static Set<String> extractGradleIncludePaths(String settingsContent) {
Set<String> paths = new LinkedHashSet<>();
for (String line : settingsContent.split("\\R")) {
if (!line.contains("include")) {
continue;
}
int includeIndex = line.indexOf("include");
Matcher tokenMatcher = QUOTED_TOKEN.matcher(line);
while (tokenMatcher.find()) {
if (tokenMatcher.start() > includeIndex) {
paths.add(normalizeGradlePath(tokenMatcher.group(1)));
}
}
}
return paths;
}
private static String normalizeGradlePath(String includePath) {
String path = includePath.trim();
if (path.startsWith(":")) {
path = path.substring(1);
}
return path;
}
private Path resolveGradleModuleDirectory(Path gradleRoot, String gradlePath, String settingsContent) {
String lookup = gradlePath.contains(":") ? gradlePath : gradlePath.replace('/', ':');
Matcher matcher = GRADLE_PROJECT_DIR.matcher(settingsContent);
while (matcher.find()) {
String configuredPath = matcher.group(1);
if (configuredPath != null && configuredPath.equals(lookup)) {
String custom = matcher.group(2) != null ? matcher.group(2) : matcher.group(3);
if (custom != null) {
return gradleRoot.resolve(custom).normalize();
}
}
}
return gradleRoot.resolve(gradlePath.replace(':', '/')).normalize();
}
private void registerGradleModule(String gradlePath, Path directory, Map<String, ProjectModuleGraph.ProjectModule> modulesById) {
String id = "gradle:" + gradlePath;
ProjectModuleGraph.ProjectModule existing = modulesById.get(id);
if (existing != null) {
modulesById.put(id, new ProjectModuleGraph.ProjectModule(
id,
directory,
ProjectModuleGraph.BuildSystem.MIXED,
existing.mavenCoordinates()));
return;
}
modulesById.put(id, new ProjectModuleGraph.ProjectModule(
id,
directory,
ProjectModuleGraph.BuildSystem.GRADLE,
null));
}
private void registerMavenSubmodules(
Path pomFile,
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
Map<ProjectModuleGraph.MavenCoordinates, ProjectModuleGraph.ProjectModule> modulesByCoordinates) throws IOException {
String content = Files.readString(pomFile);
Path parentDir = pomFile.getParent();
Matcher moduleMatcher = MAVEN_MODULE.matcher(content);
while (moduleMatcher.find()) {
Path moduleDir = parentDir.resolve(moduleMatcher.group(1).trim()).normalize();
Path childPom = moduleDir.resolve("pom.xml");
if (Files.exists(childPom)) {
registerMavenModule(childPom, modulesById, modulesByCoordinates);
}
}
}
private void registerMavenModule(
Path pomFile,
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
Map<ProjectModuleGraph.MavenCoordinates, ProjectModuleGraph.ProjectModule> modulesByCoordinates) throws IOException {
String content = Files.readString(pomFile);
Optional<ProjectModuleGraph.MavenCoordinates> coordinates = extractOwnMavenCoordinates(content);
if (coordinates.isEmpty()) {
return;
}
Path directory = pomFile.getParent().normalize();
for (Map.Entry<String, ProjectModuleGraph.ProjectModule> entry : modulesById.entrySet()) {
if (entry.getValue().directory().toAbsolutePath().normalize().equals(directory.toAbsolutePath().normalize())) {
ProjectModuleGraph.ProjectModule merged = new ProjectModuleGraph.ProjectModule(
entry.getKey(),
directory,
ProjectModuleGraph.BuildSystem.MIXED,
coordinates.get());
modulesById.put(entry.getKey(), merged);
modulesByCoordinates.put(coordinates.get(), merged);
return;
}
}
String id = "maven:" + coordinates.get().key();
ProjectModuleGraph.ProjectModule module = new ProjectModuleGraph.ProjectModule(
id,
directory,
ProjectModuleGraph.BuildSystem.MAVEN,
coordinates.get());
modulesById.put(id, module);
modulesByCoordinates.put(coordinates.get(), module);
}
static Optional<ProjectModuleGraph.MavenCoordinates> extractOwnMavenCoordinates(String pomContent) {
String content = pomContent;
int parentEnd = pomContent.indexOf("</parent>");
if (parentEnd != -1) {
content = pomContent.substring(parentEnd);
}
String groupId = extractFirstTag(content, "groupId").orElse(null);
String artifactId = extractFirstTag(content, "artifactId").orElse(null);
if (artifactId == null) {
return Optional.empty();
}
if (groupId == null) {
groupId = extractParentTag(pomContent, "groupId").orElse("unknown");
}
return Optional.of(new ProjectModuleGraph.MavenCoordinates(groupId, artifactId));
}
private static Optional<String> extractParentTag(String pomContent, String tag) {
Matcher parentMatcher = Pattern.compile("<parent>.*?</parent>", Pattern.DOTALL).matcher(pomContent);
if (!parentMatcher.find()) {
return Optional.empty();
}
return extractFirstTag(parentMatcher.group(), tag);
}
private static Optional<String> extractFirstTag(String content, String tag) {
Matcher matcher = Pattern.compile("<" + tag + ">(.*?)</" + tag + ">").matcher(content);
if (matcher.find()) {
return Optional.of(matcher.group(1).trim());
}
return Optional.empty();
}
private List<ProjectModuleGraph.ModuleDependency> parseGradleDependencies(
ProjectModuleGraph.ProjectModule module,
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
Map<String, Path> gradleModulePaths) throws IOException {
List<ProjectModuleGraph.ModuleDependency> result = new ArrayList<>();
Path buildFile = module.directory().resolve("build.gradle");
if (!Files.exists(buildFile)) {
buildFile = module.directory().resolve("build.gradle.kts");
}
if (!Files.exists(buildFile)) {
return result;
}
String content = Files.readString(buildFile);
for (String line : content.split("\\R")) {
if (isTestGradleConfigurationLine(line)) {
continue;
}
Matcher matcher = GRADLE_PROJECT_DEP.matcher(line);
while (matcher.find()) {
String gradlePath = normalizeGradlePath(matcher.group(1));
String targetId = findGradleModuleId(gradlePath, modulesById, gradleModulePaths);
if (targetId != null) {
result.add(new ProjectModuleGraph.ModuleDependency(
module.id(), targetId, null, ProjectModuleGraph.DependencyKind.LOCAL_PROJECT));
}
}
}
return result;
}
static boolean isTestScopedMavenDependency(String dependencyBlock) {
return extractFirstTag(dependencyBlock, "scope")
.map(scope -> scope.equalsIgnoreCase("test") || scope.equalsIgnoreCase("testCompile"))
.orElse(false);
}
static boolean isTestGradleConfigurationLine(String line) {
if (line == null || line.isBlank()) {
return false;
}
String trimmed = line.trim();
return trimmed.matches("(?i).*(testImplementation|testCompileOnly|testRuntimeOnly|testFixturesImplementation|androidTestImplementation|androidTestCompileOnly|androidTestRuntimeOnly|testAnnotationProcessor)\\b.*");
}
private static String findGradleModuleId(
String gradlePath,
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
Map<String, Path> gradleModulePaths) {
String exact = "gradle:" + gradlePath;
if (modulesById.containsKey(exact)) {
return exact;
}
Path expectedDir = gradleModulePaths.get(gradlePath);
if (expectedDir != null) {
for (ProjectModuleGraph.ProjectModule module : modulesById.values()) {
if (module.directory().toAbsolutePath().normalize().equals(expectedDir.toAbsolutePath().normalize())) {
return module.id();
}
}
}
return null;
}
private List<ProjectModuleGraph.ModuleDependency> parseMavenDependencies(
Path pomFile,
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
Map<ProjectModuleGraph.MavenCoordinates, ProjectModuleGraph.ProjectModule> modulesByCoordinates) throws IOException {
List<ProjectModuleGraph.ModuleDependency> result = new ArrayList<>();
String content = Files.readString(pomFile);
Optional<ProjectModuleGraph.MavenCoordinates> fromCoordinates = extractOwnMavenCoordinates(content);
if (fromCoordinates.isEmpty()) {
return result;
}
ProjectModuleGraph.ProjectModule fromModule = modulesByCoordinates.get(fromCoordinates.get());
if (fromModule == null) {
return result;
}
int depsStart = content.indexOf("<dependencies>");
int depsEnd = content.indexOf("</dependencies>");
if (depsStart == -1 || depsEnd <= depsStart) {
return result;
}
String depsSection = content.substring(depsStart, depsEnd);
Matcher blockMatcher = MAVEN_DEPENDENCY_BLOCK.matcher(depsSection);
while (blockMatcher.find()) {
String block = blockMatcher.group(1);
if (isTestScopedMavenDependency(block)) {
continue;
}
String groupId = extractFirstTag(block, "groupId").orElse(null);
String artifactId = extractFirstTag(block, "artifactId").orElse(null);
if (artifactId == null) {
continue;
}
if (groupId == null) {
groupId = extractParentTag(content, "groupId").orElse("unknown");
}
ProjectModuleGraph.MavenCoordinates targetCoordinates = new ProjectModuleGraph.MavenCoordinates(groupId, artifactId);
ProjectModuleGraph.ProjectModule targetModule = modulesByCoordinates.get(targetCoordinates);
if (targetModule != null && !targetModule.id().equals(fromModule.id())) {
result.add(new ProjectModuleGraph.ModuleDependency(
fromModule.id(),
targetModule.id(),
null,
ProjectModuleGraph.DependencyKind.LOCAL_PROJECT));
} else {
result.add(new ProjectModuleGraph.ModuleDependency(
fromModule.id(),
null,
targetCoordinates,
ProjectModuleGraph.DependencyKind.EXTERNAL_LIBRARY));
}
}
return result;
}
private void ensureInputModule(Path inputDir, Map<String, ProjectModuleGraph.ProjectModule> modulesById) {
Path normalized = inputDir.toAbsolutePath().normalize();
boolean known = modulesById.values().stream()
.anyMatch(module -> module.directory().toAbsolutePath().normalize().equals(normalized));
if (!known) {
modulesById.put("input:" + normalized.getFileName(), new ProjectModuleGraph.ProjectModule(
"input:" + normalized.getFileName(),
normalized,
ProjectModuleGraph.BuildSystem.MIXED,
null));
}
}
private List<Path> findPomFiles(Path workspaceRoot) throws IOException {
if (workspaceRoot == null || !Files.isDirectory(workspaceRoot)) {
return List.of();
}
try (Stream<Path> stream = Files.walk(workspaceRoot)) {
return stream.filter(path -> path.getFileName().toString().equals("pom.xml"))
.filter(path -> !path.toString().contains("/target/"))
.filter(path -> !path.toString().contains("\\target\\"))
.collect(Collectors.toList());
}
}
Path findWorkspaceRoot(Path start) {
if (start == null) {
return null;
}
Path normalized = start.toAbsolutePath().normalize();
Path current = normalized;
Path deepestMavenAggregator = null;
Path deepestGradleSettings = null;
Path deepestPom = null;
while (current != null) {
if (Files.exists(current.resolve("settings.gradle")) || Files.exists(current.resolve("settings.gradle.kts"))) {
deepestGradleSettings = current;
}
Path pom = current.resolve("pom.xml");
if (Files.exists(pom)) {
deepestPom = current;
try {
if (Files.readString(pom).contains("<modules>")) {
deepestMavenAggregator = current;
}
} catch (IOException ignored) {
// continue walking
}
}
current = current.getParent();
}
if (deepestMavenAggregator != null) {
return deepestMavenAggregator;
}
if (deepestGradleSettings != null) {
return deepestGradleSettings;
}
if (deepestPom != null) {
return deepestPom;
}
return normalized;
}
Path findIndexingRoot(Path start, Path workspaceRoot) {
if (workspaceRoot == null) {
return findIndexingRoot(start);
}
return workspaceRoot;
}
Path findIndexingRoot(Path start) {
if (start == null) {
return null;
}
Path current = start.toAbsolutePath().normalize();
Path gitRoot = null;
while (current != null) {
if (Files.exists(current.resolve(".git"))) {
gitRoot = current;
}
current = current.getParent();
}
return gitRoot != null ? gitRoot : findWorkspaceRoot(start);
}
}

View File

@@ -0,0 +1,166 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.validation.AnalysisCanonicalFormValidator;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.List;
import java.util.stream.Collectors;
/**
* Re-applies machine-scoped enum canonicalization and validates the analysis model.
* Must run after property resolution so placeholders cannot bypass validation.
*/
public final class AnalysisResultFinalizer {
private AnalysisResultFinalizer() {
}
public static void finalizeResult(AnalysisResult result, CodebaseContext context) {
if (result == null) {
return;
}
StateMachineTypeResolver.MachineTypes machineTypes = JsonExportContextFactory.resolveMachineTypes(
result.getName(),
result.getStateTypeFqn(),
result.getEventTypeFqn(),
context);
finalizeResult(result, context, machineTypes);
}
public static void finalizeResult(
AnalysisResult result,
CodebaseContext context,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (result == null || machineTypes == null) {
return;
}
persistMachineTypes(result, machineTypes);
applyCanonicalizationFields(result, context, machineTypes);
if (context != null) {
AnalysisCanonicalFormValidator.enforce(result, context);
} else {
AnalysisCanonicalFormValidator.enforceWithMachineTypes(result, machineTypes);
}
}
private static void persistMachineTypes(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes) {
result.setStateTypeFqn(machineTypes.stateTypeFqn());
result.setEventTypeFqn(machineTypes.eventTypeFqn());
}
/**
* Canonicalizes transitions, states, triggers, and matched transitions without validation.
* Safe to call before transition relinking when machine types are known.
*/
public static void applyCanonicalization(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes) {
applyCanonicalization(result, null, machineTypes);
}
public static void applyCanonicalization(
AnalysisResult result,
CodebaseContext context,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (result == null || machineTypes == null) {
return;
}
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) {
return;
}
applyCanonicalizationFields(result, context, machineTypes);
}
private static void applyCanonicalizationFields(
AnalysisResult result,
CodebaseContext context,
StateMachineTypeResolver.MachineTypes machineTypes) {
MachineEnumCanonicalizer.canonicalizeTransitions(result.getTransitions(), machineTypes);
if (result.getStates() != null) {
result.setStates(MachineEnumCanonicalizer.canonicalizeStates(
result.getStates(), machineTypes.stateTypeFqn()));
}
result.setStartStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
result.getStartStates(), machineTypes.stateTypeFqn()));
result.setEndStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
result.getEndStates(), machineTypes.stateTypeFqn()));
if (result.getMetadata() != null) {
CodebaseMetadata metadata = result.getMetadata();
List<TriggerPoint> triggers = canonicalizeTriggers(metadata.getTriggers(), context, machineTypes);
List<CallChain> callChains = canonicalizeCallChains(metadata.getCallChains(), context, machineTypes);
result.setMetadata(CodebaseMetadata.builder()
.triggers(triggers)
.entryPoints(metadata.getEntryPoints())
.callChains(callChains)
.properties(metadata.getProperties())
.build());
}
}
private static List<TriggerPoint> canonicalizeTriggers(
List<TriggerPoint> triggers,
CodebaseContext context,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (triggers == null) {
return null;
}
return triggers.stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
trigger, machineTypes, context))
.collect(Collectors.toList());
}
private static List<CallChain> canonicalizeCallChains(
List<CallChain> callChains,
CodebaseContext context,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (callChains == null) {
return null;
}
return callChains.stream()
.map(chain -> {
TriggerPoint trigger = chain.getTriggerPoint() == null
? null
: MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
chain.getTriggerPoint(), machineTypes, context);
List<MatchedTransition> matched = canonicalizeMatchedTransitions(
chain.getMatchedTransitions(), machineTypes);
return chain.toBuilder()
.triggerPoint(trigger)
.matchedTransitions(matched)
.build();
})
.collect(Collectors.toList());
}
private static List<MatchedTransition> canonicalizeMatchedTransitions(
List<MatchedTransition> matchedTransitions,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (matchedTransitions == null) {
return null;
}
return matchedTransitions.stream()
.map(matched -> MatchedTransition.builder()
.event(MachineEnumCanonicalizer.canonicalizeLabel(
matched.getEvent(), machineTypes.eventTypeFqn()))
.sourceState(MachineEnumCanonicalizer.canonicalizeLabel(
matched.getSourceState(), machineTypes.stateTypeFqn()))
.targetState(MachineEnumCanonicalizer.canonicalizeLabel(
matched.getTargetState(), machineTypes.stateTypeFqn()))
.build())
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,11 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import java.util.List;
public interface CallGraphEngine {
List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers);
}

View File

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

View File

@@ -0,0 +1,30 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import java.util.List;
import java.util.Map;
public interface CodebaseIntelligenceProvider {
/**
* Discovers all locations where events are triggered.
*/
List<TriggerPoint> findTriggerPoints();
/**
* Discovers all entry points (REST, Kafka, etc.).
*/
List<EntryPoint> findEntryPoints();
/**
* Links Entry Points to Trigger Points.
*/
List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers);
/**
* Resolves configuration properties per profile.
*/
Map<String, Map<String, String>> resolveProperties();
}

View File

@@ -0,0 +1,692 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
import click.kamil.springstatemachineexporter.analysis.pipeline.GetterBodyConstantScanner;
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import java.util.function.Function;
@Slf4j
public class ConstantExtractor {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private final Function<MethodInvocation, String> calledMethodResolver;
private VariableTracer variableTracer;
private ConstructorAnalyzer constructorAnalyzer;
private ExpressionAccessClassifier expressionAccessClassifier;
private AccessorResolver accessorResolver;
public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function<MethodInvocation, String> calledMethodResolver) {
this.context = context;
this.constantResolver = constantResolver;
this.calledMethodResolver = calledMethodResolver;
}
public void setVariableTracer(VariableTracer variableTracer) {
this.variableTracer = variableTracer;
}
public void setConstructorAnalyzer(ConstructorAnalyzer constructorAnalyzer) {
this.constructorAnalyzer = constructorAnalyzer;
}
public void setExpressionAccessClassifier(ExpressionAccessClassifier expressionAccessClassifier) {
this.expressionAccessClassifier = expressionAccessClassifier;
}
public void setAccessorResolver(AccessorResolver accessorResolver) {
this.accessorResolver = accessorResolver;
}
public void extractConstantsFromExpression(Expression expr, List<String> constants) {
if (constantResolver != null) {
String resolved = constantResolver.resolve(expr, context);
if (resolved != null && !shouldInspectExpressionStructure(expr, resolved)) {
addResolvedConstant(resolved, constants);
return;
}
}
if (expr instanceof StringLiteral sl) {
constants.add(sl.getLiteralValue());
} else if (expr instanceof ConditionalExpression ce) {
extractConstantsFromExpression(ce.getThenExpression(), constants);
extractConstantsFromExpression(ce.getElseExpression(), constants);
} else if (expr instanceof ParenthesizedExpression pe) {
extractConstantsFromExpression(pe.getExpression(), constants);
} else if (expr instanceof CastExpression ce) {
extractConstantsFromExpression(ce.getExpression(), constants);
} else if (expr instanceof Assignment assignment) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
} else if (expr instanceof SwitchExpression se) {
extractConstantsFromSwitchLike(se, constants);
} else if (expr instanceof ArrayAccess aa) {
extractConstantsFromExpression(aa.getArray(), constants);
} else if (expr instanceof ArrayCreation ac && ac.getInitializer() != null) {
for (Object expObj : ac.getInitializer().expressions()) {
extractConstantsFromArgument((Expression) expObj, constants);
}
} else if (expr instanceof ArrayInitializer ai) {
for (Object expObj : ai.expressions()) {
extractConstantsFromArgument((Expression) expObj, constants);
}
} else if (expr instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier();
if (log.isTraceEnabled()) {
log.trace("Processing mi: {}", mi);
}
if (("get".equals(methodName) || "getOrDefault".equals(methodName)) && mi.getExpression() != null) {
if (isKeyedLookupMethod(mi)) {
extractMapReceiverConstants(mi.getExpression(), constants);
return;
}
if ("get".equals(methodName) && mi.arguments().isEmpty()) {
extractMapReceiverConstants(mi.getExpression(), constants);
return;
}
}
if (LibraryUnwrapRegistry.isUnwrapMethodName(methodName)) {
for (Object argObj : mi.arguments()) {
extractConstantsFromExpression((Expression) argObj, constants);
}
return;
}
if ((methodName.startsWith("get") || methodName.equals("type") || methodName.equals("event")) && mi.getExpression() instanceof SimpleName sn) {
String varName = sn.getIdentifier();
String propName = methodName.startsWith("get") ? methodName.substring(3) : methodName;
Block block = findEnclosingBlock(mi);
if (block != null) {
for (Object stmtObj : block.statements()) {
if (stmtObj == mi.getParent() || stmtObj == mi) break;
if (stmtObj instanceof ExpressionStatement es) {
if (es.getExpression() instanceof MethodInvocation setterMi) {
if (setterMi.getName().getIdentifier().equalsIgnoreCase("set" + propName) || setterMi.getName().getIdentifier().equalsIgnoreCase(propName)) {
if (setterMi.getExpression() instanceof SimpleName setterSn && setterSn.getIdentifier().equals(varName)) {
if (!setterMi.arguments().isEmpty()) {
extractConstantsFromExpression((Expression) setterMi.arguments().get(0), constants);
return;
}
}
}
} else if (es.getExpression() instanceof Assignment assignment) {
if (assignment.getLeftHandSide() instanceof FieldAccess fa) {
if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) {
if (fa.getName().getIdentifier().equalsIgnoreCase(propName)) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
return;
}
}
} else if (assignment.getLeftHandSide() instanceof QualifiedName qqn) {
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
if (qqn.getName().getIdentifier().equalsIgnoreCase(propName)) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
return;
}
}
}
}
}
}
}
}
List<Expression> receivers = new ArrayList<>();
if (mi.getExpression() != null) {
if (mi.getExpression() instanceof ClassInstanceCreation) {
receivers.add(mi.getExpression());
} else if (variableTracer != null) {
receivers.addAll(variableTracer.traceVariableAll(mi.getExpression()));
}
}
for (Expression receiverExpr : receivers) {
if (receiverExpr instanceof ClassInstanceCreation cic) {
if (cic.getAnonymousClassDeclaration() != null) {
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
if (declObj instanceof MethodDeclaration mdecl) {
if (mdecl.getName().getIdentifier().equals(methodName) && mdecl.getBody() != null) {
mdecl.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement rs) {
if (rs.getExpression() != null) {
extractConstantsFromExpression(rs.getExpression(), constants);
}
return super.visit(rs);
}
});
}
}
}
} else if (constructorAnalyzer != null) {
boolean handledByLombok = false;
if (methodName.startsWith("get") && methodName.length() > 3) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
TypeDeclaration typeDecl = context.getTypeDeclaration(typeName);
if (typeDecl != null) {
String fieldName = Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
int fieldIndex = constructorAnalyzer.findConstructorArgumentIndexForLombokField(typeDecl, fieldName);
if (fieldIndex >= 0 && fieldIndex < cic.arguments().size()) {
extractConstantsFromArgument((Expression) cic.arguments().get(fieldIndex), constants);
handledByLombok = true;
}
}
}
if (!handledByLombok && methodName != null && !methodName.isEmpty()) {
List<String> narrowed = constructorAnalyzer.resolveInlineInstantiationGetterResult(
cic, methodName, context, new HashSet<>());
if (narrowed.isEmpty()) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
CompilationUnit contextCu = expr.getRoot() instanceof CompilationUnit ? (CompilationUnit) expr.getRoot() : null;
TypeDeclaration typeDecl = contextCu != null ? context.getTypeDeclaration(typeName, contextCu) : context.getTypeDeclaration(typeName);
if (typeDecl == null) typeDecl = context.getTypeDeclaration(typeName);
if (typeDecl != null) {
narrowed = resolveMethodReturnConstant(context.getFqn(typeDecl), methodName, 0, new HashSet<>(), contextCu);
}
}
if (narrowed != null && !narrowed.isEmpty()) {
constants.addAll(narrowed);
}
}
}
}
}
Expression currentMi = mi;
while (currentMi instanceof MethodInvocation chainMi) {
String chainName = chainMi.getName().getIdentifier();
if (LibraryUnwrapRegistry.isHeaderSetterMethodName(chainName) && chainMi.arguments().size() == 2) {
extractConstantsFromExpression((Expression) chainMi.arguments().get(1), constants);
} else if (LibraryUnwrapRegistry.isEventSetterMethodName(chainName) && chainMi.arguments().size() == 1) {
extractConstantsFromExpression((Expression) chainMi.arguments().get(0), constants);
}
currentMi = chainMi.getExpression();
}
TypeDeclaration td = findEnclosingType(mi);
if (td != null && (mi.getExpression() == null || mi.getExpression() instanceof ThisExpression)) {
List<String> values = resolveMethodReturnConstant(context.getFqn(td), methodName, 0, new HashSet<>(), null);
if (values != null) constants.addAll(values);
}
}
}
private boolean shouldInspectExpressionStructure(Expression expr, String resolved) {
if (expr == null || resolved == null) {
return false;
}
if (!(resolved.startsWith("<SYMBOLIC:") || resolved.contains("SYMBOLIC:") || resolved.endsWith(".*>"))) {
return false;
}
return expr instanceof SwitchExpression
|| expr instanceof ConditionalExpression;
}
public void extractConstantsFromArgument(Expression argObj, List<String> constants) {
if (argObj instanceof ClassInstanceCreation innerCic) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(innerCic.getType());
if ("String".equals(typeName)) {
extractConstantsFromExpression(argObj, constants);
}
} else {
extractConstantsFromExpression(argObj, constants);
}
}
public List<String> resolveClassConstantReturns(String className, CodebaseContext context, CompilationUnit contextCu) {
if (className != null && className.contains(".")) {
String prefix = className.substring(0, className.lastIndexOf('.'));
String suffix = className.substring(className.lastIndexOf('.') + 1);
TypeDeclaration prefixTd = contextCu != null ? context.getTypeDeclaration(prefix, contextCu) : context.getTypeDeclaration(prefix);
if (prefixTd == null) prefixTd = context.getTypeDeclaration(prefix);
boolean isKnownType = prefixTd != null || context.getEnumValues(prefix) != null;
if (isKnownType) {
String simplePrefix = prefix.contains(".") ? prefix.substring(prefix.lastIndexOf('.') + 1) : prefix;
return Collections.singletonList(simplePrefix + "." + suffix);
}
}
TypeDeclaration td = contextCu != null ? context.getTypeDeclaration(className, contextCu) : context.getTypeDeclaration(className);
if (td == null) td = context.getTypeDeclaration(className);
if (td == null) return null;
final List<String> resolvedConstants = new ArrayList<>();
for (MethodDeclaration md : td.getMethods()) {
if (md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
public boolean visit(ReturnStatement node) {
if (variableTracer != null) {
List<Expression> tracedReturns = variableTracer.traceVariableAll(node.getExpression());
for (Expression tracedRet : tracedReturns) {
extractConstantsFromExpression(tracedRet, resolvedConstants);
}
}
return super.visit(node);
}
});
}
if (!resolvedConstants.isEmpty()) {
return resolvedConstants;
}
}
return null;
}
public List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) {
return resolveMethodReturnConstant(className, methodName, depth, visited, contextCu, ResolutionBudget.defaults());
}
public List<String> resolveMethodReturnConstant(
String className,
String methodName,
int depth,
Set<String> visited,
CompilationUnit contextCu,
ResolutionBudget budget) {
return resolveMethodReturnConstant(className, methodName, depth, visited, contextCu, budget, true);
}
public List<String> resolveMethodReturnConstant(
String className,
String methodName,
int depth,
Set<String> visited,
CompilationUnit contextCu,
ResolutionBudget budget,
boolean allowWidenToImplementations) {
final boolean debug = log.isDebugEnabled();
if (debug) {
log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName);
log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth);
}
if (budget == null) {
budget = ResolutionBudget.defaults();
}
final ResolutionBudget activeBudget = budget;
if (activeBudget.isMethodReturnExhausted(depth)) {
if (debug) {
log.debug("Exiting resolveMethodReturnConstant early (depth limit) for {}.{}", className, methodName);
}
return Collections.emptyList();
}
String fqn = className + "." + methodName;
if (!visited.add(fqn)) {
if (debug) {
log.debug("Exiting resolveMethodReturnConstant early (cycle detected) for fqn={}", fqn);
}
return Collections.emptyList();
}
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(className, methodName, activeBudget);
TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null;
if (tempTd == null) {
tempTd = context.getTypeDeclaration(className);
}
final TypeDeclaration td = tempTd;
boolean widenToImplementations = allowWidenToImplementations
&& (td == null
|| td.isInterface()
|| Modifier.isAbstract(td.getModifiers()));
if (widenToImplementations && depth == 0) {
Optional<List<String>> precomputed = context.getPolymorphicAccessorIndex()
.widenedReturnConstants(className, methodName);
if (precomputed.isPresent()) {
visited.remove(fqn);
return new ArrayList<>(precomputed.get());
}
}
List<String> constants = new ArrayList<>();
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
boolean indexedOnRequestedType = className.equals(indexedAccessor.get().ownerFqn());
if (!widenToImplementations || indexedOnRequestedType) {
if (indexedAccessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER) {
List<String> constantGetterValues = extractConstantGetterReturn(
indexedAccessor.get().ownerFqn(), methodName, contextCu, visited);
if (!constantGetterValues.isEmpty()) {
if (!widenToImplementations) {
visited.remove(fqn);
return constantGetterValues;
}
constants.addAll(constantGetterValues);
}
}
if (constants.isEmpty()) {
List<String> indexedConstants = resolveIndexedGetterFieldConstants(
indexedAccessor.get(), className, contextCu, visited);
if (!indexedConstants.isEmpty()) {
if (!widenToImplementations) {
visited.remove(fqn);
return indexedConstants;
}
constants.addAll(indexedConstants);
}
}
}
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
for (Object stmtObj : md.getBody().statements()) {
if (stmtObj instanceof SwitchStatement switchStatement) {
extractConstantsFromSwitchStatement(switchStatement, constants);
}
}
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
Expression retExpr = node.getExpression();
if (retExpr != null) {
boolean handled = false;
if (retExpr instanceof MethodInvocation mi && calledMethodResolver != null) {
String called = calledMethodResolver.apply(mi);
if (called != null && called.contains(".")) {
if (visited.contains(called)) {
handled = true;
} else {
String cName = called.substring(0, called.lastIndexOf('.'));
String mName = called.substring(called.lastIndexOf('.') + 1);
TypeDeclaration targetTd = contextCu != null ? context.getTypeDeclaration(cName, contextCu) : context.getTypeDeclaration(cName);
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(
cName, mName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
constants.addAll(delegationResult);
handled = true;
}
}
}
} else if (retExpr instanceof SuperMethodInvocation smi) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
String called = superFqn + "." + smi.getName().getIdentifier();
if (visited.contains(called)) {
handled = true;
} else {
String cName = superFqn;
String mName = smi.getName().getIdentifier();
TypeDeclaration targetTd = contextCu != null ? context.getTypeDeclaration(cName, contextCu) : context.getTypeDeclaration(cName);
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(
cName, mName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
constants.addAll(delegationResult);
handled = true;
}
}
}
}
if (!handled && variableTracer != null && constructorAnalyzer != null) {
List<Expression> tracedRetExprs = variableTracer.traceVariableAll(retExpr);
if (tracedRetExprs.isEmpty() && retExpr != null) {
tracedRetExprs = List.of(retExpr);
}
for (Expression tracedRetExpr : tracedRetExprs) {
extractConstantsFromExpression(tracedRetExpr, constants);
String val = constantResolver.resolve(tracedRetExpr, context);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal);
if (!constants.contains(parsed)) {
constants.add(parsed);
}
}
} else if (val.startsWith("<SYMBOLIC:") || val.contains("SYMBOLIC:") || val.endsWith(".*>")) {
if (!constants.contains(val)) {
constants.add(val);
}
} else {
if (!constants.contains(val)) constants.add(val);
}
} else if (tracedRetExpr instanceof SimpleName sn) {
List<String> consts = constructorAnalyzer.traceFieldInConstructors(td, sn.getIdentifier(), context, visited);
if (!consts.isEmpty()) {
constants.addAll(consts);
}
} else if (tracedRetExpr instanceof FieldAccess fa) {
List<String> consts = constructorAnalyzer.traceFieldInConstructors(td, fa.getName().getIdentifier(), context, visited);
if (!consts.isEmpty()) {
constants.addAll(consts);
}
}
}
}
}
return super.visit(node);
}
});
}
if (widenToImplementations) {
List<String> impls = context.getImplementations(className);
if (impls != null) {
for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant(
implName, methodName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
for (String value : delegationResult) {
if (!constants.contains(value)) {
constants.add(value);
}
}
}
}
}
} else if (widenToImplementations) {
List<String> impls = context.getImplementations(className);
if (impls != null) {
for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant(
implName, methodName, depth + 1, visited, contextCu, activeBudget);
for (String value : delegationResult) {
if (!constants.contains(value)) {
constants.add(value);
}
}
}
}
}
visited.remove(fqn);
if (debug) {
log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants);
}
return constants;
}
public List<String> resolveIndexedGetterFieldConstants(
AccessorSummary accessor,
String requestingTypeFqn,
CompilationUnit contextCu,
Set<String> visited) {
if (accessor != null && isSafeForPrecomputedFieldConstants(accessor)) {
Optional<List<String>> precomputed = context.getIndexedFieldConstantsIndex()
.lookup(accessor.ownerFqn(), accessor.methodName());
if (precomputed.isPresent()) {
return new ArrayList<>(precomputed.get());
}
}
return AccessorFieldResolver.resolveFieldConstants(
accessor,
requestingTypeFqn,
context,
constructorAnalyzer,
contextCu,
visited,
this::extractConstantsFromExpression);
}
public Optional<AccessorSummary> lookupIndexedGetter(String className, String methodName) {
return lookupIndexedGetter(className, methodName, ResolutionBudget.defaults());
}
private Optional<AccessorSummary> lookupIndexedGetter(String className, String methodName, ResolutionBudget budget) {
if (accessorResolver != null) {
return accessorResolver.lookupIndexedGetter(className, methodName);
}
if (className == null || className.isBlank()) {
return Optional.empty();
}
return context.getAccessorIndex().lookup(className, methodName);
}
private List<String> extractConstantGetterReturn(
String ownerFqn,
String methodName,
CompilationUnit contextCu,
Set<String> visited) {
return GetterBodyConstantScanner.extractConstantGetterReturn(
context, this, constantResolver, ownerFqn, methodName, contextCu, visited);
}
private boolean isSafeForPrecomputedFieldConstants(AccessorSummary accessor) {
if (accessor.fieldName() == null || accessor.declaringFqn() == null) {
return accessor.kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER;
}
if (!accessor.ownerFqn().equals(accessor.declaringFqn())) {
return false;
}
TypeDeclaration ownerType = context.getTypeDeclaration(accessor.ownerFqn());
return ownerType != null
&& !ownerType.isInterface()
&& !Modifier.isAbstract(ownerType.getModifiers());
}
public String parseEnumSetElement(String eVal) {
if (eVal != null && (eVal.startsWith("<SYMBOLIC:") || eVal.contains("SYMBOLIC:") || eVal.endsWith(".*>"))) {
return eVal;
}
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
}
public void extractConstantsFromSwitchStatement(SwitchStatement switchStatement, List<String> constants) {
if (switchStatement == null) {
return;
}
extractConstantsFromSwitchLike(switchStatement, constants);
}
private void extractConstantsFromSwitchLike(ASTNode switchNode, List<String> constants) {
switchNode.accept(new ASTVisitor() {
@Override
public boolean visit(YieldStatement ys) {
extractConstantsFromExpression(ys.getExpression(), constants);
return super.visit(ys);
}
@Override
public boolean visit(ExpressionStatement es) {
extractConstantsFromExpression(es.getExpression(), constants);
return super.visit(es);
}
@Override
public boolean visit(ReturnStatement rs) {
extractConstantsFromExpression(rs.getExpression(), constants);
return super.visit(rs);
}
});
}
private boolean isKeyedLookupMethod(MethodInvocation mi) {
if (expressionAccessClassifier != null) {
return expressionAccessClassifier.isKeyedLookup(mi, null);
}
return "getOrDefault".equals(mi.getName().getIdentifier())
|| ("get".equals(mi.getName().getIdentifier()) && !mi.arguments().isEmpty());
}
/**
* Resolves constants from the map/collection receiver of {@code .get(key)}.
* Uses {@link ConstantResolver} for static fields ({@code ROUTES = Map.of(...)}) first;
* falls back to dataflow tracing for locals ({@code Map map = ...; map.get(k)}).
*/
private void extractMapReceiverConstants(Expression receiver, List<String> constants) {
if (tryAddResolvedExpression(receiver, constants)) {
return;
}
if (receiver instanceof MethodInvocation || receiver instanceof ArrayCreation || receiver instanceof ArrayInitializer) {
extractConstantsFromExpression(receiver, constants);
if (!constants.isEmpty()) {
return;
}
}
if (variableTracer == null) {
return;
}
String receiverText = receiver.toString();
for (Expression traced : variableTracer.traceVariableAll(receiver)) {
if (traced == null || receiverText.equals(traced.toString())) {
continue;
}
if (tryAddResolvedExpression(traced, constants)) {
continue;
}
extractConstantsFromExpression(traced, constants);
}
}
private boolean tryAddResolvedExpression(Expression expr, List<String> constants) {
if (constantResolver == null) {
return false;
}
String resolved = constantResolver.resolve(expr, context);
if (resolved == null) {
return false;
}
addResolvedConstant(resolved, constants);
return true;
}
private void addResolvedConstant(String resolved, List<String> constants) {
if (resolved.startsWith("ENUM_SET:")) {
for (String eVal : resolved.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal);
if (!constants.contains(parsed)) {
constants.add(parsed);
}
}
} else if (resolved.startsWith("MAP:")) {
List<String> mapValues = ConstantResolver.decodeMapLiteralValues(resolved);
if (mapValues != null) {
for (String mapValue : mapValues) {
if (!constants.contains(mapValue)) {
constants.add(mapValue);
}
}
}
} else if (resolved.startsWith("ARRAY:")) {
for (String arrayValue : resolved.substring(6).split("\\|", -1)) {
if (!constants.contains(arrayValue)) {
constants.add(arrayValue);
}
}
} else if (!constants.contains(resolved)) {
constants.add(resolved);
}
}
private Block findEnclosingBlock(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof Block)) {
parent = parent.getParent();
}
return (Block) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
}
}

View File

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

View File

@@ -0,0 +1,22 @@
package click.kamil.springstatemachineexporter.analysis.service;
import lombok.extern.slf4j.Slf4j;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
/**
* Placeholder kept for API stability. The exporter never executes Maven or Gradle against the
* analyzed project. Multi-module layout is discovered statically via
* {@link click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver}
* (parsed {@code settings.gradle}, {@code pom.xml}, {@code build.gradle}).
*/
@Slf4j
public class DynamicClasspathResolver {
public List<String> resolveClasspath(Path projectRoot) {
log.debug("Skipping external JAR classpath: analysis uses scanned source files only.");
return Collections.emptyList();
}
}

View File

@@ -0,0 +1,148 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.AnalysisEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.path.ForwardPathEstimationEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.List;
public class EnrichmentService {
private final List<AnalysisEnricher> prePropertyEnrichers;
private final List<AnalysisEnricher> postPropertyEnrichers;
/** Single-phase pipeline (all enrichers run together; used by focused tests). */
public EnrichmentService(List<AnalysisEnricher> enrichers) {
this(enrichers, List.of());
}
public EnrichmentService(
List<AnalysisEnricher> prePropertyEnrichers,
List<AnalysisEnricher> postPropertyEnrichers) {
this.prePropertyEnrichers = prePropertyEnrichers;
this.postPropertyEnrichers = postPropertyEnrichers;
}
public static EnrichmentService createDefault() {
return new EnrichmentService(
List.of(
new TriggerEnricher(),
new EntryPointEnricher(),
new PropertyEnricher()),
List.of(
new CallChainEnricher(),
new ForwardPathEstimationEnricher(),
new TriggerCanonicalizationEnricher(),
new TransitionLinkerEnricher()));
}
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
enrichPreProperty(result, context, intelligence);
enrichPostProperty(result, context, intelligence);
}
public void enrichPreProperty(
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
runEnrichers(prePropertyEnrichers, result, context, intelligence);
}
public void enrichPostProperty(
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
runEnrichers(postPropertyEnrichers, result, context, intelligence);
}
/** Re-runs transition linking after property resolution without rebuilding call chains. */
public void relinkAfterPropertyResolution(
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
runEnrichers(
List.of(new TriggerCanonicalizationEnricher(), new TransitionLinkerEnricher()),
result,
context,
intelligence);
}
/**
* JSON re-export path: when source and entry points are available, rebuild call chains from the
* call graph so dispatcher triggers regain {@code polymorphicEvents}/{@code external}, then relink.
*/
public void refreshCallChainsAndRelink(
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() != null && context != null && intelligence != null) {
List<CallChain> refreshed = CallChainEnricher.buildCallChainsForMachine(result, context, intelligence);
if (refreshed != null) {
List<CallChain> merged = mergeCallChainsByEntryPoint(result.getMetadata().getCallChains(), refreshed);
result.setMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.triggers(result.getMetadata().getTriggers())
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(merged)
.properties(result.getMetadata().getProperties())
.build());
}
}
relinkAfterPropertyResolution(result, context, intelligence);
}
private static List<CallChain> mergeCallChainsByEntryPoint(
List<CallChain> existing,
List<CallChain> refreshed) {
Map<String, CallChain> byEntryPoint = new LinkedHashMap<>();
if (existing != null) {
for (CallChain chain : existing) {
byEntryPoint.putIfAbsent(callChainKey(chain), chain);
}
}
for (CallChain chain : refreshed) {
byEntryPoint.put(callChainKey(chain), chain);
}
return new ArrayList<>(byEntryPoint.values());
}
private static String callChainKey(CallChain chain) {
if (chain == null) {
return "";
}
String entryPointKey = entryPointKey(chain.getEntryPoint());
if (!entryPointKey.isBlank()) {
return entryPointKey;
}
if (chain.getTriggerPoint() != null
&& chain.getTriggerPoint().getClassName() != null
&& chain.getTriggerPoint().getMethodName() != null) {
return chain.getTriggerPoint().getClassName() + "#" + chain.getTriggerPoint().getMethodName();
}
return "";
}
private static String entryPointKey(EntryPoint entryPoint) {
if (entryPoint == null) {
return "";
}
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
return entryPoint.getName();
}
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
}
private static void runEnrichers(
List<AnalysisEnricher> enrichers,
AnalysisResult result,
CodebaseContext context,
CodebaseIntelligenceProvider intelligence) {
for (AnalysisEnricher enricher : enrichers) {
enricher.enrich(result, context, intelligence);
}
}
}

View File

@@ -0,0 +1,183 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
/**
* Expands REST entry points whose {@code @PathVariable} parameters flow into string-keyed
* switch mappers (e.g. {@code StringCommandMapper.fromString}) into concrete binding variants.
*/
public final class EntryPointBindingExpander {
private EntryPointBindingExpander() {
}
public static List<Map<String, String>> expandPathVariableBindings(
EntryPoint entryPoint,
CodebaseContext context,
Map<String, List<CallEdge>> callGraph) {
if (entryPoint == null || entryPoint.getParameters() == null || entryPoint.getParameters().isEmpty()) {
return List.of();
}
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
if (!isPathOrQueryVariable(parameter)) {
continue;
}
List<String> cases = resolveStringCasesForParameter(
entryPoint.getClassName() + "." + entryPoint.getMethodName(),
parameter.getName(),
context,
callGraph);
if (cases.isEmpty()) {
continue;
}
List<Map<String, String>> variants = new ArrayList<>();
for (String caseValue : cases) {
variants.add(Map.of(parameter.getName(), caseValue));
}
return variants;
}
return List.of();
}
public static EntryPoint withResolvedPath(EntryPoint entryPoint, Map<String, String> bindings) {
if (entryPoint == null || bindings == null || bindings.isEmpty()) {
return entryPoint;
}
String name = entryPoint.getName();
Map<String, String> metadata = entryPoint.getMetadata() == null
? new LinkedHashMap<>()
: new LinkedHashMap<>(entryPoint.getMetadata());
for (Map.Entry<String, String> binding : bindings.entrySet()) {
String placeholder = "{" + binding.getKey() + "}";
if (name != null) {
name = name.replace(placeholder, binding.getValue());
}
String path = metadata.get("path");
if (path != null) {
metadata.put("path", path.replace(placeholder, binding.getValue()));
}
}
return EntryPoint.builder()
.type(entryPoint.getType())
.name(name)
.className(entryPoint.getClassName())
.methodName(entryPoint.getMethodName())
.sourceFile(entryPoint.getSourceFile())
.metadata(metadata)
.parameters(entryPoint.getParameters())
.build();
}
private static boolean isPathOrQueryVariable(EntryPoint.Parameter parameter) {
if (parameter.getAnnotations() == null) {
return false;
}
return parameter.getAnnotations().stream()
.anyMatch(a -> "PathVariable".equals(a) || "RequestParam".equals(a));
}
private static List<String> resolveStringCasesForParameter(
String startMethod,
String paramName,
CodebaseContext context,
Map<String, List<CallEdge>> callGraph) {
ArrayDeque<String> queue = new ArrayDeque<>();
Set<String> visited = new HashSet<>();
queue.add(startMethod);
while (!queue.isEmpty()) {
String methodFqn = queue.poll();
if (!visited.add(methodFqn)) {
continue;
}
if (visited.size() > 12) {
break;
}
List<String> localCases = extractStringSwitchCases(methodFqn, paramName, context);
if (!localCases.isEmpty()) {
return localCases;
}
List<CallEdge> edges = callGraph.get(methodFqn);
if (edges == null) {
continue;
}
for (CallEdge edge : edges) {
if (edge.getTargetMethod() != null) {
queue.add(edge.getTargetMethod());
}
}
}
return List.of();
}
static List<String> extractStringSwitchCases(String methodFqn, String paramName, CodebaseContext context) {
MethodDeclaration method = findMethodDeclaration(methodFqn, context);
if (method == null || method.getBody() == null) {
return List.of();
}
if (!hasParameter(method, paramName)) {
return List.of();
}
LinkedHashSet<String> cases = new LinkedHashSet<>();
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(SwitchStatement node) {
collectCasesFromSwitch(node.getExpression(), node.statements(), paramName, cases);
return super.visit(node);
}
@Override
public boolean visit(SwitchExpression node) {
collectCasesFromSwitch(node.getExpression(), node.statements(), paramName, cases);
return super.visit(node);
}
});
return List.copyOf(cases);
}
private static void collectCasesFromSwitch(
Expression selector,
List<?> statements,
String paramName,
Set<String> cases) {
if (!(selector instanceof SimpleName simpleName) || !paramName.equals(simpleName.getIdentifier())) {
return;
}
for (Object statementObj : statements) {
if (statementObj instanceof SwitchCase switchCase && !switchCase.isDefault()) {
for (Object exprObj : switchCase.expressions()) {
if (exprObj instanceof StringLiteral stringLiteral) {
cases.add(stringLiteral.getLiteralValue());
}
}
}
}
}
private static boolean hasParameter(MethodDeclaration method, String paramName) {
for (Object paramObj : method.parameters()) {
if (paramObj instanceof SingleVariableDeclaration param
&& paramName.equals(param.getName().getIdentifier())) {
return true;
}
}
return false;
}
private static MethodDeclaration findMethodDeclaration(String methodFqn, CodebaseContext context) {
if (methodFqn == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration == null) {
return null;
}
return context.findMethodDeclaration(typeDeclaration, methodName, true);
}
}

View File

@@ -0,0 +1,297 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Function;
/**
* Classifies trailing expression access (map lookup vs traceable accessor) using AST structure and
* declared receiver types instead of string heuristics.
*/
public final class ExpressionAccessClassifier {
public enum AccessKind {
/** {@code Map.get(key)}, {@code list.get(i)} — keep intact for {@link ConstantExtractor}. */
KEYED_LOOKUP,
/** {@code getEvent()}, {@code supplier.get()} — split receiver/suffix for call-path tracing. */
TRACEABLE_ACCESSOR,
NONE
}
private final CodebaseContext context;
private final VariableTracer variableTracer;
private final ConstantResolver constantResolver;
private final Function<String, ASTNode> expressionParser;
public ExpressionAccessClassifier(
CodebaseContext context,
VariableTracer variableTracer,
ConstantResolver constantResolver,
Function<String, ASTNode> expressionParser) {
this.context = context;
this.variableTracer = variableTracer;
this.constantResolver = constantResolver;
this.expressionParser = expressionParser;
}
public AccessKind classifyTrailingAccess(String expression, String scopeMethod) {
if (expression == null || expression.isBlank()) {
return AccessKind.NONE;
}
ASTNode node = expressionParser.apply(expression);
while (node instanceof ParenthesizedExpression pe) {
node = pe.getExpression();
}
if (node instanceof ArrayAccess) {
return AccessKind.NONE;
}
if (node instanceof MethodInvocation mi) {
return classifyMethodInvocation(mi, scopeMethod);
}
return AccessKind.NONE;
}
public AccessKind classifyMethodInvocation(MethodInvocation mi, String scopeMethod) {
if (mi == null) {
return AccessKind.NONE;
}
String methodName = mi.getName().getIdentifier();
int argCount = mi.arguments().size();
if ("getOrDefault".equals(methodName) && argCount >= 1) {
return AccessKind.KEYED_LOOKUP;
}
if ("get".equals(methodName)) {
String receiverType = resolveReceiverType(mi.getExpression(), scopeMethod);
if (argCount == 0) {
if (isMapLikeType(receiverType)) {
return AccessKind.KEYED_LOOKUP;
}
return AccessKind.TRACEABLE_ACCESSOR;
}
if (isMapLikeType(receiverType) || isIndexedCollectionType(receiverType)) {
return AccessKind.KEYED_LOOKUP;
}
if (receiverExpressionIsMapLiteral(mi.getExpression())) {
return AccessKind.KEYED_LOOKUP;
}
return AccessKind.TRACEABLE_ACCESSOR;
}
if (argCount == 0 && (isBeanAccessorName(methodName) || isRecordStyleAccessor(methodName))) {
return AccessKind.TRACEABLE_ACCESSOR;
}
return AccessKind.TRACEABLE_ACCESSOR;
}
public boolean isKeyedLookup(String expression, String scopeMethod) {
return classifyTrailingAccess(expression, scopeMethod) == AccessKind.KEYED_LOOKUP;
}
public boolean isKeyedLookup(MethodInvocation mi, String scopeMethod) {
return classifyMethodInvocation(mi, scopeMethod) == AccessKind.KEYED_LOOKUP;
}
/**
* Whether a trailing suffix like {@code .getEvent()} or {@code .type()} should be resolved via accessor tracing.
*/
public boolean isTraceableAccessorSuffix(String suffix) {
if (suffix == null || !suffix.startsWith(".")) {
return false;
}
String methodPart = suffix.substring(1);
if (methodPart.endsWith("()")) {
methodPart = methodPart.substring(0, methodPart.length() - 2);
}
return isBeanAccessorName(methodPart) || isRecordStyleAccessor(methodPart);
}
public boolean isTraceableAccessorExpression(String expression, String scopeMethod) {
if (expression == null || expression.isBlank()) {
return false;
}
AccessKind kind = classifyTrailingAccess(expression, scopeMethod);
return kind == AccessKind.TRACEABLE_ACCESSOR;
}
private boolean receiverExpressionIsMapLiteral(Expression receiver) {
if (constantResolver == null || context == null || receiver == null) {
return false;
}
String resolved = constantResolver.resolve(receiver, context);
return resolved != null && resolved.startsWith("MAP:");
}
private String resolveReceiverType(Expression receiver, String scopeMethod) {
if (receiver == null) {
return null;
}
if (receiverExpressionIsMapLiteral(receiver)) {
return "Map";
}
if (receiver instanceof SimpleName sn) {
String declared = lookupDeclaredType(scopeMethod, sn.getIdentifier());
if (declared != null) {
return declared;
}
return lookupFieldType(classNameFromScopeMethod(scopeMethod), sn.getIdentifier());
}
if (receiver instanceof ThisExpression) {
return classNameFromScopeMethod(scopeMethod);
}
if (receiver instanceof FieldAccess fa) {
String fieldName = fa.getName().getIdentifier();
if (fa.getExpression() instanceof ThisExpression) {
return lookupFieldType(classNameFromScopeMethod(scopeMethod), fieldName);
}
if (fa.getExpression() instanceof SimpleName outer) {
String outerType = lookupDeclaredType(scopeMethod, outer.getIdentifier());
return lookupFieldType(rawTypeName(outerType), fieldName);
}
if (fa.getExpression() instanceof QualifiedName qn) {
String outerType = resolveQualifiedTypeName(qn);
return lookupFieldType(outerType, fieldName);
}
}
if (receiver instanceof QualifiedName qn) {
String typeName = resolveQualifiedTypeName(qn);
if (typeName != null) {
String fieldName = qn.getName().getIdentifier();
String fieldType = lookupFieldType(typeName, fieldName);
if (fieldType != null) {
return fieldType;
}
}
}
if (receiver instanceof MethodInvocation chainMi) {
AccessKind kind = classifyMethodInvocation(chainMi, scopeMethod);
if (kind == AccessKind.TRACEABLE_ACCESSOR) {
String getterName = chainMi.getName().getIdentifier();
String baseType = resolveReceiverType(chainMi.getExpression(), scopeMethod);
return lookupMethodReturnType(rawTypeName(baseType), getterName);
}
}
return null;
}
private String lookupDeclaredType(String scopeMethod, String name) {
if (variableTracer == null || scopeMethod == null || name == null) {
return null;
}
return variableTracer.getVariableDeclaredType(scopeMethod, name);
}
private String lookupFieldType(String typeName, String fieldName) {
if (context == null || typeName == null || fieldName == null) {
return null;
}
TypeDeclaration td = context.getTypeDeclaration(typeName);
if (td == null) {
return null;
}
return findFieldType(td, fieldName, new HashSet<>());
}
private String findFieldType(TypeDeclaration td, String fieldName, Set<String> visited) {
String typeFqn = context.getFqn(td);
if (typeFqn == null || !visited.add(typeFqn)) {
return null;
}
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName)) {
return fd.getType().toString();
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
String inherited = findFieldType(superTd, fieldName, visited);
if (inherited != null) {
return inherited;
}
}
}
return null;
}
private String lookupMethodReturnType(String typeName, String methodName) {
if (context == null || typeName == null || methodName == null) {
return null;
}
TypeDeclaration td = context.getTypeDeclaration(typeName);
if (td == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getReturnType2() != null) {
return md.getReturnType2().toString();
}
return null;
}
private String resolveQualifiedTypeName(QualifiedName qn) {
String full = qn.getFullyQualifiedName();
if (full.contains(".")) {
return full.substring(0, full.lastIndexOf('.'));
}
return classNameFromScopeMethod(null);
}
private static String classNameFromScopeMethod(String scopeMethod) {
if (scopeMethod == null || !scopeMethod.contains(".")) {
return null;
}
return scopeMethod.substring(0, scopeMethod.lastIndexOf('.'));
}
private static boolean isBeanAccessorName(String methodName) {
return (methodName.startsWith("get") && methodName.length() > 3)
|| (methodName.startsWith("is") && methodName.length() > 2);
}
private static boolean isRecordStyleAccessor(String methodName) {
return "type".equals(methodName) || "event".equals(methodName) || "name".equals(methodName);
}
static boolean isMapLikeType(String type) {
String raw = rawTypeName(type);
if (raw == null) {
return false;
}
return "Map".equals(raw) || raw.endsWith("Map");
}
static boolean isIndexedCollectionType(String type) {
String raw = rawTypeName(type);
if (raw == null) {
return false;
}
return "List".equals(raw) || raw.endsWith("List")
|| "Set".equals(raw) || raw.endsWith("Set")
|| raw.endsWith("Deque") || "Queue".equals(raw);
}
static String rawTypeName(String type) {
if (type == null) {
return null;
}
String raw = type.trim();
if (raw.contains("<")) {
raw = raw.substring(0, raw.indexOf('<'));
}
if (raw.contains(".")) {
raw = raw.substring(raw.lastIndexOf('.') + 1);
}
return raw;
}
}

View File

@@ -0,0 +1,840 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
@Slf4j
public class GenericEventDetector {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private final List<LibraryHint> hints;
private final TypeResolver typeResolver;
public GenericEventDetector(CodebaseContext context, ConstantResolver constantResolver, List<LibraryHint> hints) {
this.context = context;
this.constantResolver = constantResolver;
this.hints = hints;
this.typeResolver = new TypeResolver(context);
}
/** Parameter names that route to a machine, not SM state enum constants. */
private static final Set<String> ROUTING_PARAMETER_NAMES = Set.of(
"machineType",
"machineId",
"machineName",
"domain",
"type",
"action",
"eventString",
"version");
private static final Set<String> TRIGGER_METHOD_NAMES = Set.of(
"sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger");
public List<TriggerPoint> detect(CompilationUnit cu) {
List<TriggerPoint> triggers = new ArrayList<>();
String fileName = "unknown";
try {
if (cu.getJavaElement() != null) fileName = cu.getJavaElement().getElementName();
} catch (Exception e) {}
log.debug("Scanning for triggers in {}", fileName);
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
String methodName = node.getName().getIdentifier();
if (TRIGGER_METHOD_NAMES.contains(methodName)) {
processSendEvent(node, cu, triggers);
} else {
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String refMethod = emr.getName().getIdentifier();
if (TRIGGER_METHOD_NAMES.contains(refMethod)) {
processSendEventMethodReference(node, emr, cu, triggers);
}
}
}
}
processHints(node, cu, triggers);
return super.visit(node);
}
});
return triggers;
}
private void processSendEventMethodReference(MethodInvocation node, ExpressionMethodReference emr, CompilationUnit cu, List<TriggerPoint> triggers) {
Expression receiver = node.getExpression();
String eventValue = extractEventFromReceiver(receiver);
if (eventValue == null) {
eventValue = receiver != null ? receiver.toString() : null;
}
MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
if (type == null) return;
String sourceState = extractSourceState(node);
String[] smTypes = resolveStateMachineTypeArgumentsForExpression(emr.getExpression(), node);
boolean external = false;
if (receiver != null) {
AssignmentDag dag = new AssignmentDag(method);
external = dag.isExternal(receiver, new java.util.HashSet<>());
}
if (eventValue != null) {
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
triggers.add(TriggerPoint.builder()
.event(eventValue)
.sourceState(sourceState)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
.lineNumber(cu.getLineNumber(node.getStartPosition()))
.stateTypeFqn(smTypes[0])
.eventTypeFqn(smTypes[1])
.external(external)
.constraint(constraint)
.build());
}
}
private String extractEventFromReceiver(Expression receiver) {
String reactivePayload = ReactiveExpressionSupport.extractPayload(receiver, constantResolver, context);
if (reactivePayload != null) {
return reactivePayload;
}
if (receiver == null) return null;
if (receiver instanceof MethodInvocation mi) {
String mName = mi.getName().getIdentifier();
if (("just".equals(mName) || "withPayload".equals(mName) || "success".equals(mName)) && !mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0);
String resolved = constantResolver.resolve(arg, context);
return resolved != null ? resolved : arg.toString();
}
if (mi.getExpression() != null) {
return extractEventFromReceiver(mi.getExpression());
}
}
if (receiver instanceof SimpleName sn) {
return sn.getIdentifier();
}
return null;
}
private String[] resolveStateMachineTypeArgumentsForExpression(Expression receiver, MethodInvocation node) {
if (receiver == null) return new String[]{null, null};
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
ITypeBinding smBinding = findStateMachineSupertype(binding, new java.util.HashSet<>());
if (smBinding != null) {
ITypeBinding[] typeArgs = smBinding.getTypeArguments();
if (typeArgs.length >= 2) {
return new String[]{typeArgs[0].getQualifiedName(), typeArgs[1].getQualifiedName()};
}
}
}
Type declType = findReceiverTypeManually(receiver, node);
if (declType instanceof ParameterizedType pt) {
List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
CompilationUnit compilationUnit = (CompilationUnit) node.getRoot();
String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit);
String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit);
return new String[]{stateType, eventType};
}
}
return new String[]{null, null};
}
private void processSendEvent(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, null);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
if (log.isDebugEnabled()) {
log.debug("Successfully built trigger point: {}", trigger.getEvent());
}
triggers.add(trigger);
}
}
}
private void processHints(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
if (hints == null || hints.isEmpty()) return;
String calledMethod = resolveCalledMethodName(node);
if (calledMethod == null) return;
for (LibraryHint hint : hints) {
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) {
String eventToUse = hint.getEvent();
if (eventToUse == null && hint.getEventArgumentIndex() != null) {
if (node.arguments().size() > hint.getEventArgumentIndex()) {
Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex());
eventToUse = argExpr.toString();
if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) {
eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()";
}
}
}
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
if (log.isDebugEnabled()) {
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
}
triggers.add(trigger);
}
}
}
}
}
private boolean isHintMatch(String called, String hintFqn) {
return hintFqn.endsWith("." + called);
}
private String resolveCalledMethodName(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
if (receiver == null) return methodName;
if (receiver instanceof SimpleName sn) {
String name = sn.getIdentifier();
AbstractTypeDeclaration enclosing = findEnclosingAbstractType(node);
if (enclosing != null) {
Type memberType = lookupMemberType(enclosing, name);
if (memberType != null) {
return memberType + "." + methodName;
}
}
return name + "." + methodName;
}
return receiver.toString() + "." + methodName;
}
private List<TriggerPoint> buildTriggerPoints(MethodInvocation node, CompilationUnit cu, String forcedEvent) {
String eventValue = null;
if (forcedEvent != null) {
eventValue = context.resolveString(forcedEvent);
} else {
if (node.arguments().isEmpty()) return Collections.emptyList();
Expression eventExpr = (Expression) node.arguments().get(0);
// Try MessageBuilder extraction first
eventValue = extractEventFromMessageBuilder(eventExpr);
if (eventValue != null) {
eventValue = context.resolveString(eventValue);
} else {
eventValue = context.resolveExpression(eventExpr);
}
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetter(
eventExpr, constantResolver, context);
if (remapped != null) {
eventValue = remapped;
}
}
MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
if (type == null) return Collections.emptyList();
String sourceState = extractSourceState(node);
String[] smTypes = resolveStateMachineTypeArguments(node);
boolean external = false;
if (!node.arguments().isEmpty()) {
Expression eventExpr = (Expression) node.arguments().get(0);
AssignmentDag dag = new AssignmentDag(method);
external = dag.isExternal(eventExpr, new java.util.HashSet<>());
}
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
List<TriggerPoint> results = new ArrayList<>();
if (eventValue != null && eventValue.startsWith("ENUM_SET:")) {
String[] parts = eventValue.substring(9).split(",");
for (String part : parts) {
if (!part.trim().isEmpty()) {
results.add(TriggerPoint.builder()
.event(part.trim())
.sourceState(sourceState)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
.lineNumber(cu.getLineNumber(node.getStartPosition()))
.stateTypeFqn(smTypes[0])
.eventTypeFqn(smTypes[1])
.external(external)
.constraint(constraint)
.build());
}
}
} else {
results.add(TriggerPoint.builder()
.event(eventValue)
.sourceState(sourceState)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
.lineNumber(cu.getLineNumber(node.getStartPosition()))
.stateTypeFqn(smTypes[0])
.eventTypeFqn(smTypes[1])
.external(external)
.constraint(constraint)
.build());
}
return results;
}
private String extractSourceState(ASTNode node) {
ASTNode current = node;
while (current != null && !(current instanceof MethodDeclaration)) {
ASTNode parent = current.getParent();
// 1. Direct parent is IfStatement wrapper (e.g., if (state == X) { sm.sendEvent() })
if (parent instanceof IfStatement ifStmt) {
// Only consider if our current node is in the 'then' or 'else' part, not the condition
if (ifStmt.getExpression() != current) {
Expression expr = ifStmt.getExpression();
String state = extractStateFromExpression(expr);
if (state != null) return state;
}
}
// 2. Parent is a Block or SwitchStatement: perform sibling traversal
if (parent instanceof Block block) {
String state = extractStateFromSiblings(current, block.statements());
if (state != null) return state;
} else if (parent instanceof SwitchStatement switchStmt) {
if (!isRoutingParameter(switchStmt.getExpression())) {
String state = extractStateFromSiblings(current, switchStmt.statements());
if (state != null) return state;
}
}
current = parent;
}
return null;
}
private String extractStateFromSiblings(ASTNode currentNode, List<?> statements) {
int index = statements.indexOf(currentNode);
if (index <= 0) return null; // No previous siblings
// Walk backwards through siblings
for (int i = index - 1; i >= 0; i--) {
Object stmtObj = statements.get(i);
if (stmtObj instanceof SwitchCase switchCase) {
if (!switchCase.expressions().isEmpty()) {
return getSimpleNameString((Expression) switchCase.expressions().get(0));
}
} else if (stmtObj instanceof IfStatement ifStmt) {
// Guard clause detection: check if the 'then' block has a return/throw
if (isGuardClause(ifStmt.getThenStatement())) {
Expression expr = ifStmt.getExpression();
// If it's a guard clause, it usually checks `state != EXPECTED`, so the true state *is* EXPECTED
String state = extractStateFromExpression(expr);
if (state != null) return state;
}
}
}
return null;
}
private boolean isGuardClause(Statement thenStatement) {
if (thenStatement instanceof ReturnStatement || thenStatement instanceof ThrowStatement) {
return true;
}
if (thenStatement instanceof Block block) {
for (Object obj : block.statements()) {
if (obj instanceof ReturnStatement || obj instanceof ThrowStatement) {
return true;
}
}
}
return false;
}
private String extractStateFromExpression(Expression expr) {
if (expr instanceof InfixExpression infix) {
if (infix.getOperator() == InfixExpression.Operator.EQUALS || infix.getOperator() == InfixExpression.Operator.NOT_EQUALS) {
Expression left = infix.getLeftOperand();
Expression right = infix.getRightOperand();
// Ignore null checks entirely
if (left instanceof NullLiteral || right instanceof NullLiteral) {
return null;
}
if (isRoutingParameter(left) || isRoutingParameter(right)) {
return null;
}
// Usually one is a method call like getState() or a variable like `state`
// and the other is the constant enum like `OrderState.PENDING` or `"PENDING"`
// If one is a QualifiedName (enum constant) or StringLiteral, it's likely the state
if (left instanceof QualifiedName || left instanceof StringLiteral || left instanceof FieldAccess) {
return getSimpleNameString(left);
}
if (right instanceof QualifiedName || right instanceof StringLiteral || right instanceof FieldAccess) {
return getSimpleNameString(right);
}
// Fallback
return getSimpleNameString(right);
}
} else if (expr instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier();
if (("equals".equals(methodName) || "equalsIgnoreCase".equals(methodName)) && !mi.arguments().isEmpty()) {
Expression receiver = mi.getExpression();
Expression arg = (Expression) mi.arguments().get(0);
if (isRoutingParameter(receiver) || isRoutingParameter(arg)) {
return null;
}
// If receiver is null (e.g., implicit this), fall back to arg
if (receiver == null) {
return getSimpleNameString(arg);
}
// Prioritize the one that looks like a constant (QualifiedName or StringLiteral)
if (receiver instanceof QualifiedName || receiver instanceof StringLiteral || receiver instanceof FieldAccess) {
return getSimpleNameString(receiver);
}
if (arg instanceof QualifiedName || arg instanceof StringLiteral || arg instanceof FieldAccess) {
return getSimpleNameString(arg);
}
// Fallback to receiver
return getSimpleNameString(receiver);
}
}
return null;
}
private boolean isRoutingParameter(Expression expr) {
if (expr instanceof SimpleName sn) {
return ROUTING_PARAMETER_NAMES.contains(sn.getIdentifier());
}
return false;
}
private String getSimpleNameString(Expression expr) {
if (expr instanceof QualifiedName qn) {
return qn.getName().getIdentifier();
} else if (expr instanceof FieldAccess fa) {
return fa.getName().getIdentifier();
} else if (expr instanceof StringLiteral sl) {
return sl.getLiteralValue();
}
return expr.toString();
}
private String extractEventFromMessageBuilder(Expression expr) {
if (expr instanceof CastExpression ce) {
return extractEventFromMessageBuilder(ce.getExpression());
}
if (expr instanceof SimpleName sn) {
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(expr);
if (enclosingMethod != null) {
// Find variable declaration
final Expression[] initializer = new Expression[1];
enclosingMethod.accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializer[0] = node.getInitializer();
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
initializer[0] = node.getRightHandSide();
}
return super.visit(node);
}
});
if (initializer[0] != null) {
return extractEventFromMessageBuilder(initializer[0]); // recursive
}
// If it has NO initializer, it might be a method parameter!
for (Object paramObj : enclosingMethod.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(varName)) {
return varName;
}
}
}
}
}
if (expr instanceof ClassInstanceCreation cic) {
return cic.getType().toString();
}
if (!(expr instanceof MethodInvocation mi)) return null;
// Trace back chain: MessageBuilder.withPayload(event).setHeader(...).build()
MethodInvocation current = mi;
while (current != null) {
String name = current.getName().getIdentifier();
if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) {
Expression payloadExpr = (Expression) current.arguments().get(0);
// If it's Mono.just(msg), where msg is a variable
if ("just".equals(name)) {
String extracted = extractEventFromMessageBuilder(payloadExpr);
if (extracted != null) return extracted;
}
String resolved = constantResolver.resolve(payloadExpr, context);
if (resolved != null) return resolved;
// If not a constant, it might be a parameter like 'event'
if (payloadExpr instanceof CastExpression ce) {
payloadExpr = ce.getExpression();
}
if (payloadExpr instanceof SimpleName sn) {
String extracted = extractEventFromMessageBuilder(payloadExpr);
if (extracted != null) {
return extracted;
}
return sn.getIdentifier(); // Fall back to returning the parameter name
}
return payloadExpr.toString();
} else if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) {
// If the event is obtained by calling a method on a provider/supplier parameter,
// return the provider's name so the call-graph engine can trace the lambda argument
String traced = extractEventFromMessageBuilder(sn);
return traced != null ? traced : sn.getIdentifier();
}
Expression receiver = current.getExpression();
if (receiver instanceof MethodInvocation nextMi) {
current = nextMi;
} else {
current = null;
}
}
return null;
}
private AbstractTypeDeclaration findEnclosingAbstractType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof AbstractTypeDeclaration)) {
parent = parent.getParent();
}
return (AbstractTypeDeclaration) parent;
}
private List<FieldDeclaration> getFieldDeclarations(AbstractTypeDeclaration type) {
if (type instanceof TypeDeclaration td) {
return List.of(td.getFields());
}
if (type instanceof RecordDeclaration rd) {
return List.of(rd.getFields());
}
return List.of();
}
private Type lookupMemberType(AbstractTypeDeclaration enclosingType, String memberName) {
if (enclosingType == null || memberName == null) {
return null;
}
for (FieldDeclaration fd : getFieldDeclarations(enclosingType)) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(memberName)) {
return fd.getType();
}
}
}
if (enclosingType instanceof RecordDeclaration rd) {
for (Object compObj : rd.recordComponents()) {
if (compObj instanceof SingleVariableDeclaration svd
&& svd.getName().getIdentifier().equals(memberName)) {
return svd.getType();
}
}
}
return null;
}
private String[] resolveStateMachineTypeArguments(MethodInvocation node) {
Expression receiver = node.getExpression();
// Trace back the receiver if it's a builder chain
while (receiver instanceof MethodInvocation mi) {
if (mi.getName().getIdentifier().equals("sendEvent")) {
receiver = mi.getExpression();
break;
}
receiver = mi.getExpression();
}
// Also if the node itself is sendEvent, receiver is already correct
if (node.getName().getIdentifier().equals("sendEvent")) {
receiver = node.getExpression();
}
if (receiver == null) return new String[]{null, null};
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
ITypeBinding smBinding = findStateMachineSupertype(binding, new java.util.HashSet<>());
if (smBinding != null) {
ITypeBinding[] typeArgs = smBinding.getTypeArguments();
if (typeArgs.length >= 2) {
return new String[]{typeArgs[0].getQualifiedName(), typeArgs[1].getQualifiedName()};
}
}
}
// Fallback: search enclosing class/method for variable declaration
Type declType = findReceiverTypeManually(receiver, node);
if (declType instanceof ParameterizedType pt) {
List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
CompilationUnit cu = (CompilationUnit) node.getRoot();
String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), cu);
String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), cu);
if (stateType != null && stateType.length() == 1) {
stateType = resolveGenericTypeVariable(stateType, node, cu);
}
if (eventType != null && eventType.length() == 1) {
eventType = resolveGenericTypeVariable(eventType, node, cu);
}
return new String[]{stateType, eventType};
}
}
return new String[]{null, null};
}
private String resolveGenericTypeVariable(String typeVarName, ASTNode node, CompilationUnit cu) {
AbstractTypeDeclaration enclosingClass = findEnclosingAbstractType(node);
if (enclosingClass == null) return typeVarName;
String className = context.getFqn(enclosingClass);
if (className == null) return typeVarName;
List<String> impls = context.getImplementations(className);
if (impls == null || impls.isEmpty()) return typeVarName;
for (String implFqn : impls) {
org.eclipse.jdt.core.dom.AbstractTypeDeclaration implNode = context.getAbstractTypeDeclaration(implFqn);
if (implNode instanceof TypeDeclaration td) {
Type superclassType = td.getSuperclassType();
if (superclassType instanceof ParameterizedType pt) {
Type baseType = pt.getType();
String baseName = typeResolver.resolveTypeToFqn(baseType, (CompilationUnit) implNode.getRoot());
if (className.equals(baseName) || className.endsWith("." + baseName)) {
int typeIndex = -1;
List<?> typeParams = enclosingClass instanceof TypeDeclaration typeDecl
? typeDecl.typeParameters()
: enclosingClass instanceof RecordDeclaration recordDecl
? recordDecl.typeParameters()
: List.of();
for (int i = 0; i < typeParams.size(); i++) {
TypeParameter tp = (TypeParameter) typeParams.get(i);
if (tp.getName().getIdentifier().equals(typeVarName)) {
typeIndex = i;
break;
}
}
if (typeIndex >= 0 && typeIndex < pt.typeArguments().size()) {
Type concreteType = (Type) pt.typeArguments().get(typeIndex);
return typeResolver.resolveTypeToFqn(concreteType, (CompilationUnit) implNode.getRoot());
}
}
}
}
}
return typeVarName;
}
private ITypeBinding findStateMachineSupertype(ITypeBinding binding, java.util.Set<String> visited) {
ITypeBinding current = binding;
while (current != null) {
String erasureName = current.getErasure().getQualifiedName();
if (erasureName != null && !visited.add(erasureName)) {
return null;
}
if ("org.springframework.statemachine.StateMachine".equals(erasureName)) {
return current;
}
for (ITypeBinding iface : current.getInterfaces()) {
ITypeBinding res = findStateMachineSupertype(iface, visited);
if (res != null) return res;
}
current = current.getSuperclass();
}
return null;
}
private Type findReceiverTypeManually(Expression receiver, MethodInvocation node) {
if (!(receiver instanceof SimpleName sn)) return null;
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
if (enclosingMethod != null) {
// Check params
for (Object paramObj : enclosingMethod.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(varName)) {
return svd.getType();
}
}
}
// Check local variables
if (enclosingMethod.getBody() != null) {
Type[] found = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(varName)) {
found[0] = vds.getType();
}
}
}
return super.visit(vds);
}
});
if (found[0] != null) return found[0];
}
}
// Check fields and record components
AbstractTypeDeclaration enclosingType = findEnclosingAbstractType(node);
if (enclosingType != null) {
Type memberType = lookupMemberType(enclosingType, varName);
if (memberType != null) {
return memberType;
}
}
return null;
}
private static class AssignmentDag {
final java.util.Map<String, List<Expression>> assignments = new java.util.HashMap<>();
final java.util.Set<SingleVariableDeclaration> parameters = new java.util.HashSet<>();
AssignmentDag(MethodDeclaration method) {
if (method == null) return;
for (Object paramObj : method.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd) {
parameters.add(svd);
}
}
if (method.getBody() != null) {
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment fragment) {
String varName = fragment.getName().getIdentifier();
if (fragment.getInitializer() != null) {
assignments.computeIfAbsent(varName, k -> new ArrayList<>()).add(fragment.getInitializer());
}
return super.visit(fragment);
}
@Override
public boolean visit(Assignment assignment) {
if (assignment.getLeftHandSide() instanceof SimpleName sn) {
String varName = sn.getIdentifier();
assignments.computeIfAbsent(varName, k -> new ArrayList<>()).add(assignment.getRightHandSide());
}
return super.visit(assignment);
}
});
}
}
boolean isExternal(Expression expr, java.util.Set<String> visited) {
if (expr == null) return false;
Expression peeled = expr;
while (peeled instanceof ParenthesizedExpression pe) {
peeled = pe.getExpression();
}
while (peeled instanceof CastExpression ce) {
peeled = ce.getExpression();
}
if (peeled instanceof SimpleName sn) {
String varName = sn.getIdentifier();
for (SingleVariableDeclaration svd : parameters) {
if (svd.getName().getIdentifier().equals(varName)) {
return isAnnotatedAsExternal(svd);
}
}
if (assignments.containsKey(varName)) {
if (!visited.add(varName)) {
return false;
}
List<Expression> defs = assignments.get(varName);
for (Expression def : defs) {
if (isExternal(def, visited)) {
return true;
}
}
}
} else if (peeled instanceof MethodInvocation mi) {
if (mi.getExpression() != null && isExternal(mi.getExpression(), visited)) {
return true;
}
for (Object arg : mi.arguments()) {
if (isExternal((Expression) arg, visited)) {
return true;
}
}
}
return false;
}
private boolean isAnnotatedAsExternal(SingleVariableDeclaration svd) {
for (Object modifier : svd.modifiers()) {
if (modifier instanceof Annotation annotation) {
String typeName = annotation.getTypeName().getFullyQualifiedName();
if (typeName.endsWith("PathVariable") || typeName.endsWith("RequestBody") || typeName.endsWith("RequestParam")
|| typeName.endsWith("PathParam") || typeName.endsWith("QueryParam")) {
return true;
}
}
}
return false;
}
}
}

View File

@@ -0,0 +1,70 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
@Slf4j
public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
public HeuristicCallGraphEngine(CodebaseContext context) {
super(context);
}
@Override
protected String callGraphCacheKey() {
return "heuristicCallGraph";
}
@Override
protected String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
if (receiver instanceof MethodInvocation miReceiver) {
String returnType = resolveMethodInvocationReturnType(miReceiver);
if (returnType != null) {
return returnType + "." + methodName;
}
}
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInTypeHierarchy(td, methodName);
}
return null;
}
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
return binding.getQualifiedName() + "." + methodName;
}
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
}
if (receiver instanceof SimpleName sn) {
String fallbackTypeFqn = resolveReceiverTypeFallback(sn);
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
return sn.getIdentifier() + "." + methodName;
}
if (receiver instanceof QualifiedName qn) {
String fqn = qn.getFullyQualifiedName();
TypeDeclaration td = context.getTypeDeclaration(fqn);
if (td != null) {
return fqn + "." + methodName;
}
return fqn + "." + methodName;
}
return null;
}
}

View File

@@ -0,0 +1,75 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.RequiredArgsConstructor;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RequiredArgsConstructor
public class InterceptorDetector {
private final CodebaseContext context;
public List<EntryPoint> detect(CompilationUnit cu) {
List<EntryPoint> entryPoints = new ArrayList<>();
cu.accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
if (isInterceptor(node)) {
processInterceptor(node, entryPoints);
}
return super.visit(node);
}
});
return entryPoints;
}
private boolean isInterceptor(TypeDeclaration node) {
if (node.getSuperclassType() != null) {
// Check for common adapter/base classes if needed
}
for (Object interfaceType : node.superInterfaceTypes()) {
String name = interfaceType.toString();
if (name.contains("HandlerInterceptor") || name.contains("Filter") || name.contains("WebRequestInterceptor")) {
return true;
}
}
return false;
}
private void processInterceptor(TypeDeclaration node, List<EntryPoint> entryPoints) {
String fqn = context.getFqn(node);
for (MethodDeclaration method : node.getMethods()) {
String methodName = method.getName().getIdentifier();
if (isInterceptorMethod(methodName)) {
Map<String, String> metadata = new HashMap<>();
metadata.put("interceptorType", getInterceptorType(node));
entryPoints.add(EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.name("INTERCEPTOR: " + node.getName().getIdentifier() + "." + methodName)
.className(fqn)
.methodName(methodName)
.metadata(metadata)
.build());
}
}
}
private boolean isInterceptorMethod(String name) {
return name.equals("preHandle") || name.equals("postHandle") || name.equals("afterCompletion") || name.equals("doFilter");
}
private String getInterceptorType(TypeDeclaration node) {
for (Object interfaceType : node.superInterfaceTypes()) {
String name = interfaceType.toString();
if (name.contains("HandlerInterceptor")) return "Spring MVC Interceptor";
if (name.contains("Filter")) return "Servlet Filter";
}
return "Generic Interceptor";
}
}

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