256 Commits

Author SHA1 Message Date
6ca8e64750 Restore machine-scoped enum linking when global simple names are ambiguous.
Call-graph ambiguous metadata must not blank matchedTransitions; trust import-style polymorphic widen when the machine event type is known.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:34:08 +02:00
5b0778301a Harden cross-module map lookup and add sibling-module regression tests.
Resolve static map field declaring types via import-aware lookup when JDT binding uses the wrong package, and cover qualified inherited maps, static factory receivers, and RequestParam expansion across modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:20:18 +02:00
d19d4f20ef Prefer import-aware superclass FQN when JDT binding mis-resolves.
Fix inherited static-block map expansion across compilation units by resolving superclass types from imports when binding points at the wrong package.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:10:48 +02:00
3e0f8f18b8 Expand RequestParam bindings and restrict map lookup to static sources.
Append query parameters for @RequestParam expansion, resolve static factory map receivers and inherited static-block maps, and fail closed on instance field maps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:06:28 +02:00
9455a2c8a9 Resolve inherited and interface static route map fields.
Walk superclass and interface hierarchies when locating compile-time map initializers for Map.get path binding expansion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:56:11 +02:00
13de69862b Follow nested static factories and fix cross-module factory resolution.
Delegate single-return static factories through nested calls and static field references with cycle detection, resolve factories without JDT bindings via receiver type lookup, and add regression coverage including sibling-module factories and fail-closed loop or recursive cases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:54:38 +02:00
bb28e72bbe Expand route maps from static factory methods with single Map.of return.
Follow one-return static factories that directly yield compile-time map literals for field initializers, static-block assignments, and putAll sources while failing closed on runtime-built maps or multiple returns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:51:59 +02:00
a675a773da Add regression coverage for advanced static route map patterns.
Cover Map.ofEntries static assignments, static-import putAll, double-brace put/putAll mixes, and sibling-module putAll seeds without production changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:50:17 +02:00
01c8c58277 Expand double-brace and linked-local route map putAll bindings.
Support putAll from compile-time maps inside anonymous HashMap initializers and add regression coverage for linked locals, mixed put/putAll, and empty inline HashMap fields populated in static blocks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:48:48 +02:00
dd25db0109 Expand static-block route maps from putAll compile-time sources.
Resolve putAll arguments from Map.of literals and cross-class static map fields inside static initializers while failing closed on runtime-built maps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:47:31 +02:00
280c346761 Expand static-block map bindings via locals and Map.of assignment.
Follow HashMap locals assigned to static route fields and direct Map.of or double-brace assignments inside static initializers while ignoring unlinked local puts and failing closed on computed keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:46:02 +02:00
a9dbf17ced Expand path bindings from double-brace HashMap put entries.
Resolve compile-time keys from anonymous subclass instance initializers used for inline route maps while remaining fail-closed when any put key is computed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:44:25 +02:00
60cd6511d4 Expand path bindings from static initializer map put entries.
When a static map field is populated in a static block via put calls with compile-time string keys, expand endpoint path parameters through those entries while remaining fail-closed for computed keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:43:12 +02:00
85af06c89d Add regression coverage for constant-based path binding patterns.
Lock in cross-class, static-import, reversed-equals, sibling-module, and fail-closed static-initializer map behavior for commandKey and machineType expansion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:41:37 +02:00
73f047e0aa Expand if-equals path bindings through static string constants.
Resolve ORDER.equals(machineType) and Objects.equals(CONST, param) by reusing compile-time static final string constant folding for both equals sides.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:38:30 +02:00
24cd248a22 Resolve chained static string constants for path binding cases.
Follow one-hop static final indirection for Map.of keys and switch case labels so route constants can alias literals without losing fail-closed behavior on cycles or computed values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:36:50 +02:00
b9db614cb1 Expand map route keys from static final string constants.
Resolve Map.of keys through compile-time static final String fields so route tables can use named constants while still failing closed on computed or non-static keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:34:59 +02:00
fba7a0c90c Resolve static-import map routes for path binding expansion.
Follow import static CommandRoutes.ROUTES to the declaring type when proving Map.get keys, and add regression coverage for switch expressions, sibling-module routes, FQN lookups, and fail-closed non-literal map keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:33:24 +02:00
12db9dbef4 Harden static map path binding for cross-class and ofEntries routes.
Resolve Routes.ROUTES-style QualifiedName receivers and Map.ofEntries initializers so commandKey expansion works for typical route-table dispatch patterns.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:30:34 +02:00
79bb4e2aba Expand path bindings from static Map.get lookup keys.
Prove commandKey variants from compile-time Map.of routes instead of failing closed when dispatch uses map lookup rather than switch or enum valueOf.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:25:27 +02:00
80b784da29 Resolve nested boolean predicate helpers for path binding.
Recursively peel predicate chains like matchesOrder -> isOrderKey(normalize(raw)) when extracting if-chain literals and inlining branch constraints, with regression tests for static-import, multi-module, and nested forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:21:04 +02:00
610c83a7ec Expand path bindings through boolean predicate helpers safely.
Recognize single-literal equals predicates in if-chains, inline them (including negated else-if constraints) for branch compatibility, skip traversing into predicate helpers during alias walks, and fail closed when a path variable cannot be proven.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:18:14 +02:00
fd21cfa440 Support Objects.equals for path binding and branch constraints.
Expand machineType routing when dispatchers use java.util.Objects.equals with helper-wrapped parameters, and evaluate those compound constraints without breaking existing receiver-equals forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:06:00 +02:00
8d7e04cc33 Expand path bindings for if-equals on helper-wrapped parameters.
Use balanced-paren receiver-equals substitution so compound branch constraints stay correct while supporting forms like "ORDER".equalsIgnoreCase(normalizeType(machineType)).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 18:03:40 +02:00
2d478d5779 Add multi-module path binding and enterprise flow linkKey regressions.
Verify sibling-module scan resolves cross-module EventNormalizer helpers for path expansion and assert HTML export embeds linkKey on structured business-flow steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 17:50:59 +02:00
252255f8b0 Resolve path bindings through static-imported helper methods.
When helper invocations have no receiver, look up single static imports and static star imports before giving up; add cross-package, chained-helper, and fail-closed expander regressions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 17:49:25 +02:00
a70059331b Resolve path bindings through cross-class static helpers.
EntryPointBindingExpander follows single-arg normalize helpers declared on other types and accepts switch selectors wrapped by those helpers when extracting string case arms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 17:47:45 +02:00
8ef6e5ef8f Resolve path bindings through single-arg string helper methods.
Enum.valueOf(normalize(event)) now expands when normalize trivially forwards its parameter; add expander regression test and assert flow highlight helper in enterprise HTML export.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 17:46:09 +02:00
5348dafb69 Follow intra-method parameter aliases during path binding expansion.
Local renames like eventCode = event or normalized = event.trim() no longer break Enum.valueOf case extraction; add multi-hop and normalization coverage in EntryPointBindingExpanderTest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 17:44:21 +02:00
a3789ceb3c Add enterprise business flows and fix layered sample compile.
Structured {source, event} steps in state_machine_enterprise flows.json enable HTML flow highlighting; fix additional-state configuration in layered_dispatcher_sample and refresh enterprise golden JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 17:42:09 +02:00
2e7afd6d42 Match HTML sidebar chains by entry point name when paths differ.
Expanded REST endpoints share the same controller method, so class/method matching grouped unrelated call chains; prefer entry point name when present and cover with enterprise HTML tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 17:39:58 +02:00
e3f3350398 Link expanded generic REST paths via parameter alias tracking
Follow renamed path variables through call-graph aliases, expand machineType×event bindings from branch-scoped valueOf, treat fully resolved URLs as internal, and synthesize concrete polymorphic events from path constraints for transition linking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 07:15:16 +02:00
5d432e8893 Improve REST path binding for enum path variables and valueOf
Treat enum @PathVariable parameters as internal triggers, expand multiple path placeholders via cartesian bindings, derive event cases from proven Enum.valueOf chains, and add enterprise HTML regression coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:52:27 +02:00
24fe8dfe00 Support structured source/event steps in business flows
Add FlowStep with backward-compatible JSON (strings or objects), precompute linkKey in HTML export, and document precise flow highlighting in flows.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:48:33 +02:00
00d1f24709 Precompute matched-transition link keys for HTML export
Embed linkKey on matchedTransitions in HTML metadata so explorer highlighting uses the same SVG #link_* suffix as PlantUML, avoiding JS/Java format drift while keeping golden JSON unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:46:23 +02:00
32fe96041c Surface ambiguous widen chains in HTML explorer
AMBIGUOUS_WIDEN call chains with matched transitions now appear in the sidebar with an Ambiguous link badge, use dashed highlight styling, and participate in endpoint hover when link keys are available.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:42:57 +02:00
7799f8ce5f Align HTML SVG link keys with export enum formats
HtmlExporter now propagates event/state formats into PlantUML anchors and template.js buildLinkKey, with layered-dispatcher and fqn regression tests guarding endpoint highlight consistency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:38:57 +02:00
d11f8afd79 Thread context through transition and state canonicalization
Machine-scoped transition/state labels and polymorphic event harvesting now use context-aware canonicalizeLabel so ambiguous import-style enum names are not rewritten during export or validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:33:37 +02:00
dbeab64e1b Fail closed on cross-package enum switch constraints
Switch-arm equality now requires compatible enum types when both sides are package-qualified, while still accepting import-style vs FQN pairs like DomainCommand.ORDER_PAY.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:31:11 +02:00
d589f65cc1 Use context when canonicalizing matched transitions
Matched transition enum labels now respect ambiguous simple-name fail-closed during finalization instead of guessing package from machine types alone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:31:11 +02:00
4a8b6ae9d9 Thread CodebaseContext through canonical form validator
requireCanonical and event matching now use context-aware enum checks so ambiguous import-style identifiers are not falsely rewritten during export validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:28:56 +02:00
be1ac91beb Scan source context before JSON property resolution
Property placeholders on JSON re-export now qualify through applyResolution with CodebaseContext so ambiguous enum names fail closed instead of guessing without bindings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:26:42 +02:00
8c4f15133e Defer trigger event qualification and resolve types from bindings
Stop rewriting trigger events in TriggerPoint construction; qualify with context during property resolution and export. Upgrade heuristic simple names to JDT binding FQNs for user types only, preserving java.* platform types.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:24:40 +02:00
57686c575e Thread context through link policy and symbolic enum expansion
Use machine event type and CodebaseContext for trusted valueOf widen checks and resolveLinkResolution, and apply context-aware enum type matching when expanding symbolic polymorphic placeholders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:17:30 +02:00
5bc65be30d Align enricher pipeline with context-aware enum canonicalization
Use label-only canonicalization for shared-infrastructure triggers, resolve valueOf enum types from JDT bindings when available, and allow trusted valueOf widens using the machine event type when the trigger omits type FQNs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:13:34 +02:00
5c0d9c466d Defer polymorphic qualification to linker with context
Stop rewriting polymorphic events in TriggerPoint construction without ambiguity context; canonicalize labels at link time while preserving type FQNs for shared-infrastructure routing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:09:00 +02:00
bd9136a45c Require package-qualified enum types for trusted valueOf widen
Reject import-style polymorphic candidates when deciding whether an ambiguous valueOf widen is machine-scoped and safe to link.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 06:06:02 +02:00
cef9dd1f9a Fail closed on ambiguous import-style enum canonicalization
Stop rewriting OrderEvent.PAY to the machine package when the simple name is ambiguous, and apply the same guard when capping polymorphic events to configured transitions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 05:52:37 +02:00
26560fee6d Fail closed on ambiguous enum type matching during linking
Pass CodebaseContext into StrictFqnMatchingEngine so import-style OrderEvent.PAY
cannot match a.OrderEvent.PAY when OrderEvent is ambiguous across packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 05:51:03 +02:00
8869ebc706 Align call-graph class matching with ambiguous simple-name guard
Use package-aware simple-name compatibility in VariableTracer and preserve
case-insensitive field-to-type matching only when the simple name is unambiguous.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 05:49:26 +02:00
6401a58c20 Fail closed on ambiguous simple-name type compatibility
Reject cross-package class matching and enum parameter compatibility when
types share an ambiguous simple name but differ by package-qualified FQN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 05:46:54 +02:00
82f4e5c087 Scope implementation and transition capping to package-qualified types
When an interface simple name is ambiguous, resolve FQN implementation
lookups within the requested package only, and stop capping polymorphic
candidates to machine transitions by constant name across enum types.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 05:45:23 +02:00
828f1260cd Stop inferring machine transitions onto ambiguous triggers
Do not backfill polymorphic events from configured transitions when a
trigger is ambiguous without enum-member constraints, and block symbolic
inference when the symbolic type simple name is ambiguous across packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 05:43:12 +02:00
fb3218e1a2 Tighten bare-constant filtering and valueOf widen policy
Scope bare polymorphic enum constants to the trigger parameter type (fail
closed on ambiguous simple names and cross-enum constant names), and fail
closed on runtime valueOf widens unless every candidate is package-qualified
for the trigger's declared event type.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 05:40:17 +02:00
5972b9df50 Fail closed on ambiguous symbolic enum expansion
Block expandSymbolicPolymorphicEvents from widening <SYMBOLIC: OrderEvent.*>
when the simple type name is ambiguous across packages, and emit symbolic
markers from the call graph when enum values cannot be resolved.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 05:29:20 +02:00
a94490712f Stop enum expansion from bypassing ambiguous simple-name guard
Remove direct enumValuesMap scan in expandDeclaredEnumValues so ambiguous
OrderEvent-style names fail closed instead of picking an arbitrary package.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 05:26:24 +02:00
dcae599d21 Add regression for ambiguous enum simple-name linking
Ensures the call graph and linker fail closed when multiple enums share the same simple name across packages and bindings are unavailable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 21:59:51 +02:00
4ca42ae3dc Fail closed on ambiguous simple types in implementation widening
Prevent getImplementations("Name") from widening to unrelated classes when multiple types share the same simple name across packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 21:53:37 +02:00
f3c82d5d7f Fail closed on ambiguous simple enum names
Track enum name collisions during scan and refuse to resolve enum values by simple name when multiple enums share the same identifier across packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 21:51:22 +02:00
bbfb54c51b Avoid source-state inference collisions across enum types
When inferring missing trigger sourceState from the transition table, preserve enum type context (Type.CONST) so different state enums sharing a constant name do not collapse into a single inferred source.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 21:48:15 +02:00
44497afd32 Avoid borrowing @Qualifier from unrelated injection parameters
Require parameter-name or setter-name agreement when inferring a field qualifier from constructor/autowired parameter annotations, preventing false-positive routing for multi-bean StateMachine<String,String> injections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 21:41:16 +02:00
69361e5a60 Treat dotted routing keys as concrete boolean bindings
Allow BooleanConstraintEvaluator to substitute simple routing-key bindings like order.pay while avoiding accidental substitution of variable names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 21:36:47 +02:00
c1085a2bd3 Fail closed when call-graph heuristic lacks class info
Tighten CallGraphPathFinder heuristic matching to avoid cross-class linking when only method names are available and class ownership is unknown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 21:24:08 +02:00
308dac557e Add parity test for record component access
Ensures record component access (req.type()) flows into sendEvent() consistently between heuristic and JDT call-graph engines.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 21:21:59 +02:00
be5386b972 Add parity test for DTO field access
Ensures public field access (req.type) flows into sendEvent() consistently between heuristic and JDT call-graph engines.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 21:19:21 +02:00
4dd64c6cdf Add parity test for static routing helper
Covers dispatcher routing via static helper methods (e.g. OrderRoutingHelper.payAction()) to ensure JDT and heuristic call-graph engines resolve identical polymorphic events.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 21:17:03 +02:00
73754cf6ea Extend reactive operator support for map and switchMap lambda remapping.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:56:14 +02:00
1146cdbae6 Respect linkResolution in HTML sidebar and hover highlighting.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:51:25 +02:00
4f3d6c40e7 Gate shared-service multi-attach to provable shared infrastructure.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:44:58 +02:00
4a7b3ff124 Extract package-name routing heuristics for single-machine fallback.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:41:30 +02:00
60b5ceea8c Extract machine routing helpers and gate Spring DI to ambiguous generics.
Keep package heuristics for single-machine codebases and fail closed only when String/Object generic triggers are provably ambiguous across multiple beans.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:39:51 +02:00
423bb4e819 Extract call-site and link policy helpers to clarify analysis pipeline rules.
Move functional-interface checks, cross-frame Supplier resolution, and transition link resolution into dedicated types so call graph and linker share one DRY implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:22:33 +02:00
c7e826c0fb Trace Supplier lambda arguments across call frames so JMS cancel chains resolve the concrete event.
When callee code calls eventProvider.get(), walk back to the caller's Supplier argument instead of stopping at the parameter name; also skip Supplier-vs-enum type checks for already-resolved lambda/enum call-site arguments under JDT bindings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:16:58 +02:00
024458d638 Exclude cross-machine call chains from per-machine exports and link string-literal sendEvent triggers.
Shared String/Object generic types no longer prove machine affinity when multiple configs coexist, so Payment exports omit Order/Audit NO_MATCH stubs while @Qualifier-backed chains stay RESOLVED. String literal events now match configured string transition names for provable linking.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 19:14:25 +02:00
c3c5d66031 Split boolean ternary endpoints into provable branches and scope payment exports.
Boolean request-param ternaries now expand into true/false binding variants so each call chain resolves a single event instead of AMBIGUOUS_WIDEN. Setter/constructor @Qualifier injection is traced for stateMachineId, and PaymentStateMachineConfig gets a dedicated golden with payment endpoints linked via paymentStateMachine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 18:55:36 +02:00
ad567ace0c Scope triggers by @Qualifier bean name and infer source state from switch expressions.
Extract stateMachineId from @Qualifier on sendEvent receivers, route named beans via @EnableStateMachine before String/String type widening, and extend sourceState detection to SwitchExpression arrow cases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 18:20:58 +02:00
169fae88ab Export linkResolution in JSON and tighten source-proven trigger scoping.
Wire enterprise regression tests for linkResolution, extend REST body enum detection to nested DTOs/records, infer if-branch source states only from provable literals, and fix String/java.lang.String routing equivalence so shared-service scoping stays fail-closed without dropping valid chains.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 18:10:13 +02:00
f8f64487d1 Link REST endpoints via source-proven call-graph scoping and branch expansion.
Scope machines from call-chain type evidence, expand path bindings from switch/if literals, unify external trigger policy, add linkResolution export metadata, and built-in trigger detection without hints.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:46:14 +02:00
1bf75c8a34 Fail closed on ambiguous polymorphic widening to prevent over-linking transitions.
Tighten linker, matcher, and canonicalizer guards for null sourceState inference, dynamic getter wildcards, predicate ceiling, and interface accessor ENUM_SET widen; update goldens for ambiguous internal triggers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 06:40:28 +02:00
2f853fb971 Resolve inheritance call targets and cap polymorphic event transitions.
Add InheritanceCallTargetResolver for super/this/implicit dispatch across
call-graph engines, apply transition-ceiling to polymorphic events in
MachineEnumCanonicalizer, and cover JDT parity, deep hierarchy, and
pipeline behavior with new tests plus golden update.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:46:48 +02:00
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
636 changed files with 85119 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);
}
}
}

105
README.md
View File

@@ -1,34 +1,87 @@
# 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",
{ "source": "com.example.order.OrderState.PAID", "event": "com.example.order.OrderEvent.SHIP" }
]
}
]
```
plantuml statemachine.scxml
Event-only strings remain supported but do not highlight in the HTML explorer unless paired with a source state. Use `{source, event}` objects for precise transition highlighting.
## JSON Structure
- `metadata.entryPoints`: REST, WebFlux, and JMS entry points.
- `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`).
- `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,145 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.service.ExternalTriggerPolicy;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.List;
/**
* Central rules for transition linking outcomes derived from existing trigger evidence only.
*/
public final class CallChainLinkPolicy {
private CallChainLinkPolicy() {
}
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint trigger) {
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, null, null);
}
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(
TriggerPoint trigger,
String machineEventTypeFqn) {
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, null);
}
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(
TriggerPoint trigger,
String machineEventTypeFqn,
CodebaseContext context) {
if (trigger == null || trigger.isExternal() || !trigger.isAmbiguous()) {
return false;
}
List<String> poly = trigger.getPolymorphicEvents();
if (poly == null || poly.size() <= 1) {
return false;
}
String event = trigger.getEvent();
if (event != null && event.startsWith("ENUM_SET:")) {
return true;
}
if (event != null && event.contains(".valueOf(")) {
return MachineEnumCanonicalizer.isDynamicTriggerExpression(event)
&& !isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context);
}
return MachineEnumCanonicalizer.isDynamicTriggerExpression(event)
&& event != null
&& !isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context);
}
/**
* True when every concrete polymorphic candidate belongs to the trigger's declared event type.
* Used to allow machine-scoped symbolic expansion for generic dispatchers while rejecting
* cross-package or unqualified widens.
*/
static boolean isTrustedEnumPolymorphicWiden(TriggerPoint trigger) {
return isTrustedEnumPolymorphicWiden(trigger, null, null);
}
static boolean isTrustedEnumPolymorphicWiden(TriggerPoint trigger, String machineEventTypeFqn) {
return isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, null);
}
static boolean isTrustedEnumPolymorphicWiden(
TriggerPoint trigger,
String machineEventTypeFqn,
CodebaseContext context) {
if (trigger == null) {
return false;
}
String eventTypeFqn = trigger.getEventTypeFqn();
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
eventTypeFqn = machineEventTypeFqn;
}
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
return false;
}
List<String> poly = trigger.getPolymorphicEvents();
if (poly == null || poly.size() <= 1) {
return false;
}
for (String pe : poly) {
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:") || !pe.contains(".")) {
return false;
}
if (!MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(pe, eventTypeFqn, context)) {
return false;
}
}
return true;
}
public static LinkResolution resolveLinkResolution(
TriggerPoint trigger,
List<MatchedTransition> matched,
boolean ambiguousSource) {
return resolveLinkResolution(trigger, matched, ambiguousSource, null, null);
}
public static LinkResolution resolveLinkResolution(
TriggerPoint trigger,
List<MatchedTransition> matched,
boolean ambiguousSource,
String machineEventTypeFqn) {
return resolveLinkResolution(trigger, matched, ambiguousSource, machineEventTypeFqn, null);
}
public static LinkResolution resolveLinkResolution(
TriggerPoint trigger,
List<MatchedTransition> matched,
boolean ambiguousSource,
String machineEventTypeFqn,
CodebaseContext context) {
if (trigger != null && trigger.isExternal()) {
return LinkResolution.UNRESOLVED_EXTERNAL;
}
if (ambiguousSource
|| shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) {
return LinkResolution.AMBIGUOUS_WIDEN;
}
if (matched != null && !matched.isEmpty()) {
return LinkResolution.RESOLVED;
}
return LinkResolution.NO_MATCH;
}
public static TriggerPoint applyRestExternalPolicy(TriggerPoint trigger, CallChain chain, CodebaseContext context) {
if (trigger == null || chain.getEntryPoint() == null) {
return trigger;
}
EntryPoint entryPoint = chain.getEntryPoint();
String entryMethod = entryPoint.getClassName() + "." + entryPoint.getMethodName();
boolean external = ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, entryMethod, trigger.getEvent(), context);
if (external == trigger.isExternal()) {
return trigger;
}
return trigger.toBuilder().external(external).build();
}
}

View File

@@ -0,0 +1,27 @@
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());
// Keep all entry points; machine scoping is applied after call-chain resolution
// when source evidence (trigger type FQNs, literals, constraints) is available.
List<EntryPoint> entryPoints = intelligence.findEntryPoints();
result.addMetadata(CodebaseMetadata.builder()
.entryPoints(entryPoints)
.build());
}
}

View File

@@ -0,0 +1,160 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.GenericEventDetector;
import click.kamil.springstatemachineexporter.analysis.service.JdtCallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.CompilationUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Scopes REST entry points to a machine using call-graph reachability and trigger type evidence,
* not URL path segment guessing.
*/
public final class EntryPointScopeResolver {
private static final BeanResolutionEngine ROUTING = new HeuristicBeanResolutionEngine();
private static final String TRIGGERS_CACHE = "entryPointScope.triggers";
public enum Affinity {
FOR_MACHINE,
AGAINST_MACHINE,
UNKNOWN
}
private EntryPointScopeResolver() {
}
public static List<EntryPoint> scopeFromCallChains(List<CallChain> chains, List<EntryPoint> allEntryPoints) {
Map<String, EntryPoint> scoped = new LinkedHashMap<>();
if (chains != null) {
for (CallChain chain : chains) {
EntryPoint entryPoint = chain.getEntryPoint();
if (entryPoint != null) {
scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint);
}
}
}
if (allEntryPoints != null) {
for (EntryPoint entryPoint : allEntryPoints) {
if (hasPathVariablePlaceholder(entryPoint)) {
scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint);
}
}
}
return new ArrayList<>(scoped.values());
}
public static Affinity resolveAffinity(EntryPoint entryPoint, String machineName, CodebaseContext context) {
if (entryPoint == null || machineName == null) {
return Affinity.UNKNOWN;
}
if (entryPoint.getClassName() == null || entryPoint.getMethodName() == null) {
return Affinity.UNKNOWN;
}
if (context == null || context.getCompilationUnits().isEmpty()) {
return Affinity.UNKNOWN;
}
CallGraphEngine engine = createCallGraphEngine(context);
List<CallChain> chains = engine.findChains(List.of(entryPoint), getOrCollectTriggers(context));
if (chains.isEmpty()) {
return Affinity.UNKNOWN;
}
boolean matched = false;
boolean mismatched = false;
for (CallChain chain : chains) {
TriggerPoint trigger = chain.getTriggerPoint();
if (trigger == null || !hasTypeEvidence(trigger)) {
continue;
}
if (ROUTING.isRoutedToCorrectMachine(chain, machineName, context)) {
matched = true;
} else {
mismatched = true;
}
}
if (matched) {
return Affinity.FOR_MACHINE;
}
if (mismatched) {
return Affinity.AGAINST_MACHINE;
}
return Affinity.UNKNOWN;
}
public static boolean hasPathVariablePlaceholder(EntryPoint entryPoint) {
if (entryPoint == null) {
return false;
}
String name = entryPoint.getName();
if (name != null && name.contains("{")) {
return true;
}
Map<String, String> metadata = entryPoint.getMetadata();
if (metadata != null) {
String path = metadata.get("path");
return path != null && path.contains("{");
}
return false;
}
private static boolean hasTypeEvidence(TriggerPoint trigger) {
if (trigger.getEventTypeFqn() != null || trigger.getStateTypeFqn() != null) {
return true;
}
return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM;
}
@SuppressWarnings("unchecked")
private static List<TriggerPoint> getOrCollectTriggers(CodebaseContext context) {
Object cached = context.getCache().get(TRIGGERS_CACHE);
if (cached instanceof List<?> list) {
return (List<TriggerPoint>) list;
}
List<TriggerPoint> triggers = new ArrayList<>();
GenericEventDetector detector =
new GenericEventDetector(context, context.getConstantResolver(), context.getLibraryHints());
for (CompilationUnit cu : context.getCompilationUnits()) {
triggers.addAll(detector.detect(cu));
}
context.getCache().put(TRIGGERS_CACHE, triggers);
return triggers;
}
private static CallGraphEngine createCallGraphEngine(CodebaseContext context) {
SpringBeanRegistry registry = new SpringBeanRegistry();
SpringContextScanner scanner = new SpringContextScanner(registry);
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(scanner);
}
SpringDependencyResolver dependencyResolver = new SpringDependencyResolver(registry);
InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(dependencyResolver);
return new JdtCallGraphEngine(context, injectionAnalyzer);
}
private static String entryPointKey(EntryPoint entryPoint) {
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
return entryPoint.getName();
}
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
}
}

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,148 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.List;
/**
* Filters codebase-wide analysis artifacts to those relevant for a single state machine config.
*/
public final class MachineScopeFilter {
private static final BeanResolutionEngine ROUTING = new HeuristicBeanResolutionEngine();
private MachineScopeFilter() {
}
public static List<TriggerPoint> filterTriggersForMachine(
List<TriggerPoint> triggers, String machineName, CodebaseContext context) {
return filterTriggersForMachine(triggers, machineName, context, null);
}
public static List<TriggerPoint> filterTriggersForMachine(
List<TriggerPoint> triggers,
String machineName,
CodebaseContext context,
List<Transition> machineTransitions) {
if (triggers == null || machineName == null) {
return triggers == null ? List.of() : triggers;
}
List<TriggerPoint> filtered = new ArrayList<>();
for (TriggerPoint trigger : triggers) {
if (isTriggerForMachine(trigger, machineName, context, machineTransitions)) {
filtered.add(trigger);
}
}
return filtered;
}
public static List<CallChain> filterCallChainsForMachine(
List<CallChain> chains, String machineName, CodebaseContext context) {
return filterCallChainsForMachine(chains, machineName, context, null);
}
public static List<CallChain> filterCallChainsForMachine(
List<CallChain> chains,
String machineName,
CodebaseContext context,
List<Transition> machineTransitions) {
if (chains == null || machineName == null) {
return chains == null ? List.of() : chains;
}
List<CallChain> filtered = new ArrayList<>();
String machineEventTypeFqn = resolveMachineEventTypeFqn(machineName, context, machineTransitions);
for (CallChain chain : chains) {
if (ROUTING.hasProvenMachineAffinity(chain, machineName, context, machineTransitions)) {
filtered.add(chain);
continue;
}
TriggerPoint trigger = chain.getTriggerPoint();
if (trigger != null
&& machineEventTypeFqn != null
&& CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context)) {
filtered.add(chain);
}
}
return filtered;
}
private static String resolveMachineEventTypeFqn(
String machineName,
CodebaseContext context,
List<Transition> machineTransitions) {
if (context != null && machineName != null) {
String[] types = StateMachineTypeResolver.resolve(machineName, context);
if (types != null && types.length > 1 && types[1] != null && !types[1].isBlank()) {
return types[1];
}
}
if (machineTransitions == null) {
return null;
}
for (Transition transition : machineTransitions) {
Event event = transition.getEvent();
if (event == null) {
continue;
}
String fullIdentifier = event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
if (fullIdentifier != null && fullIdentifier.contains(".")) {
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
}
}
return null;
}
public static List<EntryPoint> filterEntryPointsForMachine(
List<EntryPoint> entryPoints, String machineName, CodebaseContext context) {
if (entryPoints == null || machineName == null) {
return entryPoints == null ? List.of() : entryPoints;
}
List<EntryPoint> filtered = new ArrayList<>();
for (EntryPoint entryPoint : entryPoints) {
if (isEntryPointForMachine(entryPoint, machineName, context)) {
filtered.add(entryPoint);
}
}
return filtered;
}
private static boolean isEntryPointForMachine(
EntryPoint entryPoint, String machineName, CodebaseContext context) {
if (entryPoint == null) {
return false;
}
if (EntryPointScopeResolver.hasPathVariablePlaceholder(entryPoint)) {
EntryPointScopeResolver.Affinity affinity =
EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
return affinity != EntryPointScopeResolver.Affinity.AGAINST_MACHINE;
}
EntryPointScopeResolver.Affinity affinity =
EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
if (affinity == EntryPointScopeResolver.Affinity.FOR_MACHINE) {
return true;
}
if (affinity == EntryPointScopeResolver.Affinity.AGAINST_MACHINE) {
return false;
}
return false;
}
private static boolean isTriggerForMachine(
TriggerPoint trigger, String machineName, CodebaseContext context, List<Transition> machineTransitions) {
if (trigger == null) {
return false;
}
CallChain probe = CallChain.builder().triggerPoint(trigger).methodChain(List.of()).build();
return ROUTING.hasProvenMachineAffinity(probe, machineName, context, machineTransitions);
}
}

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,296 @@
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.LinkResolution;
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.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
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();
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
return;
}
EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine(context);
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.expandBoundValueOfFromConstraints(
tp, machineTypes, context, chain.getEntryPoint());
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
tp, machineTypes, context, stateMachineTransitions, true);
tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, machineTypes, context);
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
if (machineTypes != null) {
tp = tp.toBuilder()
.eventTypeFqn(tp.getEventTypeFqn() != null
? tp.getEventTypeFqn()
: machineTypes.eventTypeFqn())
.stateTypeFqn(tp.getStateTypeFqn() != null
? tp.getStateTypeFqn()
: machineTypes.stateTypeFqn())
.build();
}
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
updatedChains.add(chain);
continue;
}
String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null;
if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null,
context)) {
updatedChains.add(chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(null)
.linkResolution(LinkResolution.AMBIGUOUS_WIDEN)
.build());
continue;
}
boolean ambiguousSource = false;
if (triggerSource == null) {
Map<String, Set<String>> sourcesByEvent = new LinkedHashMap<>();
Map<String, String> canonicalSourceBySimplified = new LinkedHashMap<>();
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)
&& isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) {
String smSourceForLink = canonicalState(smSourceState);
String smSource = simplifySourceState(smSourceForLink);
sourcesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
.add(smSource);
canonicalSourceBySimplified.putIfAbsent(smSource, smSourceForLink);
}
}
}
if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) {
ambiguousSource = true;
} else {
Set<String> allDistinctSources = new LinkedHashSet<>();
sourcesByEvent.values().forEach(allDistinctSources::addAll);
if (allDistinctSources.size() == 1) {
String inferredSimplifiedSource = allDistinctSources.iterator().next();
String inferredCanonicalSource = canonicalSourceBySimplified.get(inferredSimplifiedSource);
tp = tp.toBuilder().sourceState(inferredCanonicalSource).build();
triggerSource = inferredSimplifiedSource;
}
}
}
List<MatchedTransition> matched = new ArrayList<>();
if (!ambiguousSource) {
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) {
String smSourceForLink = canonicalState(smSourceState);
String smSource = simplifySourceState(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, stateMachineTransitions, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt);
}
} else {
for (State smTargetState : t.getTargetStates()) {
String targetForLink = canonicalState(smTargetState);
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceForLink)
.targetState(targetForLink)
.event(smEventForLink)
.build();
if (isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt);
}
}
}
}
}
}
}
}
if (ambiguousSource) {
tp = tp.toBuilder().ambiguous(true).build();
}
LinkResolution linkResolution = CallChainLinkPolicy.resolveLinkResolution(
tp,
matched,
ambiguousSource,
machineTypes != null ? machineTypes.eventTypeFqn() : null,
context);
if (!matched.isEmpty()) {
CallChain newChain = chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(matched)
.linkResolution(linkResolution)
.build();
updatedChains.add(newChain);
} else {
updatedChains.add(chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(null)
.linkResolution(linkResolution)
.build());
}
}
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
result.getMetadata().getTriggers(), result.getName(), context, stateMachineTransitions);
List<CallChain> scopedChains = MachineScopeFilter.filterCallChainsForMachine(
updatedChains, result.getName(), context, stateMachineTransitions);
List<EntryPoint> scopedEntryPoints = EntryPointScopeResolver.scopeFromCallChains(
scopedChains, result.getMetadata().getEntryPoints());
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
.triggers(scopedTriggers)
.entryPoints(scopedEntryPoints)
.callChains(scopedChains)
.properties(result.getMetadata().getProperties())
.build();
result.setMetadata(updatedMetadata);
}
private boolean isRoutedToCorrectMachine(
CallChain chain,
String currentMachineName,
CodebaseContext context,
List<Transition> machineTransitions,
TriggerPoint triggerPoint,
String machineEventTypeFqn) {
if (triggerPoint != null
&& machineEventTypeFqn != null
&& CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(triggerPoint, machineEventTypeFqn, context)) {
return true;
}
return routingEngine.hasProvenMachineAffinity(chain, currentMachineName, context, machineTransitions);
}
private boolean isRoutedToCorrectMachine(
CallChain chain, String currentMachineName, CodebaseContext context, List<Transition> machineTransitions) {
return isRoutedToCorrectMachine(
chain, currentMachineName, context, machineTransitions, chain.getTriggerPoint(), null);
}
private boolean isConstraintCompatible(String constraint, String machineName) {
if (constraint == null || machineName == null) {
return true;
}
String machineConstraint = stripEventBindingClauses(constraint);
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
machineConstraint, MachineDomainKeys.extractMachineDomainKey(machineName));
}
private static String stripEventBindingClauses(String constraint) {
if (constraint == null || constraint.isBlank()) {
return constraint;
}
String stripped = constraint.replaceAll("\"[^\"]+\"\\.equalsIgnoreCase\\(event\\)", "true");
stripped = stripped.replaceAll("&&\\s*&&+", "&&");
stripped = stripped.replaceAll("^\\s*&&\\s*", "");
stripped = stripped.replaceAll("\\s*&&\\s*$", "");
return stripped.trim();
}
private final java.util.Map<String, String> simplifySourceCache = new java.util.HashMap<>();
private String simplifySourceState(String name) {
if (name == null) return null;
if (simplifySourceCache.containsKey(name)) return simplifySourceCache.get(name);
// For source states we must preserve enum type context to avoid collisions between
// different state enums that share a constant name (e.g. OrderState.NEW vs InvoiceState.NEW).
// Use EnumFormat.fn-like suffix (Type.CONST) when possible.
String simplified = name;
int lastDot = name.lastIndexOf('.');
if (lastDot > 0) {
int prevDot = name.lastIndexOf('.', lastDot - 1);
if (prevDot > 0) {
simplified = name.substring(prevDot + 1);
}
}
simplifySourceCache.put(name, simplified);
return simplified;
}
private String canonicalState(State state) {
if (state == null) {
return null;
}
return state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName();
}
private String canonicalEvent(click.kamil.springstatemachineexporter.model.Event event) {
if (event == null) {
return null;
}
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
}
}

View File

@@ -0,0 +1,72 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.stream.Collectors;
/**
* Canonicalizes trigger-side enum identifiers ({@code event}, {@code polymorphicEvents},
* {@code sourceState}) using the owning machine config's {@code <State, Event>} types.
*/
@Slf4j
public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null) {
return;
}
StateMachineTypeResolver.MachineTypes machineTypes = JsonExportContextFactory.resolveMachineTypes(
result.getName(), result.getStateTypeFqn(), result.getEventTypeFqn(), context);
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) {
return;
}
List<TriggerPoint> triggers = result.getMetadata().getTriggers();
List<TriggerPoint> canonicalTriggers = triggers == null ? null : triggers.stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
trigger, machineTypes, context))
.collect(Collectors.toList());
List<CallChain> callChains = result.getMetadata().getCallChains();
List<CallChain> canonicalChains = callChains == null ? null : callChains.stream()
.map(chain -> {
if (chain.getTriggerPoint() == null) {
return chain;
}
TriggerPoint expanded = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
chain.getTriggerPoint(), machineTypes, context, result.getTransitions(), true);
TriggerPoint canonical = expanded.getEventTypeFqn() == null && expanded.getStateTypeFqn() == null
? MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(
expanded, machineTypes, context)
: MachineEnumCanonicalizer.canonicalizeTriggerPoint(
expanded, machineTypes, context);
return chain.toBuilder().triggerPoint(canonical).build();
})
.collect(Collectors.toList());
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,184 @@
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import java.util.List;
public class StrictFqnMatchingEngine implements EventMatchingEngine {
private final CodebaseContext context;
public StrictFqnMatchingEngine() {
this(null);
}
public StrictFqnMatchingEngine(CodebaseContext context) {
this.context = context;
}
@Override
public boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint) {
if (stateMachineEvent == null || triggerPoint == null || triggerPoint.getEvent() == null) {
return false;
}
if (click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers
.isLifecycle(triggerPoint.getEvent())) {
return false;
}
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
String rawTriggerEvent = triggerPoint.getEvent();
if (triggerPoint.isExternal()) {
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null
? triggerPoint.getPolymorphicEvents()
: java.util.Collections.emptyList();
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
return false;
}
}
if (rawTriggerEvent != null && rawTriggerEvent.startsWith("<SYMBOLIC: ") && rawTriggerEvent.endsWith(".*>")) {
return false;
}
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
for (String pe : polyEvents) {
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
continue;
}
String eventTypeFqn = triggerPoint.getEventTypeFqn();
if (pe.contains(".") && 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 typesMatch(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;
}
// Getter/method-call expressions (e.g. richEvent.getId(), getType()) without resolved
// polymorphic events must not wildcard-match bare or enum transition events.
if (rawTriggerEvent.contains(".") || rawTriggerEvent.endsWith("()")) {
return false;
}
if (triggerPoint.getEventTypeFqn() != null && isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
return !smEventRaw.contains(".");
}
return !smEventRaw.contains(".");
}
return matchEventNames(rawTriggerEvent, smEventRaw, triggerPoint.getEventTypeFqn());
}
private boolean matchEventNames(String triggerEvent, String smEvent, String eventTypeFqn) {
if (triggerEvent == null || smEvent == null) return false;
if (triggerEvent.equals(smEvent)) return true;
String tConst = constantName(triggerEvent);
String smConst = constantName(smEvent);
if (!tConst.equals(smConst)) return false;
// String-typed triggers may match configured string transition events by constant name.
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
return isStringTypeOrPrimitive(smEnumType);
}
// 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 typesMatch(eventTypeFqn, smEnumType);
}
if (triggerEvent.contains(".")) {
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
return typesMatch(triggerEnumType, smEnumType);
}
// Bare constant name (e.g. after Enum.name()) with no type context must not match every enum
return false;
}
// Both sides are unqualified identifiers (string/primitive events)
return !triggerEvent.contains(".");
}
private boolean typesMatch(String type1, String type2) {
if (MachineEnumCanonicalizer.enumTypesMatch(type1, type2, context)) {
return true;
}
if (type1 != null && type2 != null) {
String machineFqn = type1.contains(".") ? type1 : type2;
String candidate = type1.contains(".") ? type2 : type1;
return MachineEnumCanonicalizer.enumTypesMatchForMachine(machineFqn, candidate, context);
}
return false;
}
private String constantName(String event) {
return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event;
}
private boolean 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,20 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.List;
public interface BeanResolutionEngine {
boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context);
default boolean hasProvenMachineAffinity(
CallChain chain,
String currentMachineName,
CodebaseContext context,
List<Transition> machineTransitions) {
return isRoutedToCorrectMachine(chain, currentMachineName, context);
}
}

View File

@@ -0,0 +1,87 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
/**
* Maps {@code @EnableStateMachine(name=…)} bean names to configuration class FQNs.
*/
final class EnableStateMachineBeanRouting {
private EnableStateMachineBeanRouting() {
}
static Boolean matchesNamedBean(String beanName, String currentMachineConfigFqn, CodebaseContext context) {
String normalized = stripQuotes(beanName);
if (normalized == null || normalized.isEmpty() || context == null || currentMachineConfigFqn == null) {
return null;
}
String matchingConfig = findConfigFqnByBeanName(normalized, context);
if (matchingConfig == null) {
return null;
}
return currentMachineConfigFqn.equals(matchingConfig);
}
static String findConfigFqnByBeanName(String beanName, CodebaseContext context) {
for (TypeDeclaration configType : context.findEntryPointClasses(java.util.List.of("EnableStateMachine"))) {
String configFqn = context.getFqn(configType);
String configuredName = extractEnableStateMachineName(configType);
if (configuredName == null || configuredName.isEmpty()) {
configuredName = defaultBeanName(configType.getName().getIdentifier());
}
if (beanName.equals(configuredName)) {
return configFqn;
}
}
return null;
}
static String extractEnableStateMachineName(TypeDeclaration configType) {
for (Object modifierObj : configType.modifiers()) {
if (!(modifierObj instanceof Annotation annotation)) {
continue;
}
if (!annotation.getTypeName().toString().endsWith("EnableStateMachine")) {
continue;
}
String name = AstUtils.extractAnnotationMember(annotation, "name");
if (name != null && !name.isBlank()) {
return stripQuotes(name);
}
String value = AstUtils.extractAnnotationMember(annotation, "value");
if (value != null && !value.isBlank()) {
return stripQuotes(value);
}
}
return null;
}
static String defaultBeanNameFromFqn(String fqn) {
if (fqn == null || !fqn.contains(".")) {
return fqn;
}
String simple = fqn.substring(fqn.lastIndexOf('.') + 1);
return defaultBeanName(simple);
}
private static String defaultBeanName(String simpleClassName) {
if (simpleClassName == null || simpleClassName.isEmpty()) {
return simpleClassName;
}
return Character.toLowerCase(simpleClassName.charAt(0)) + simpleClassName.substring(1);
}
private static String stripQuotes(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
String trimmed = raw.trim();
if (trimmed.length() >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
return trimmed.substring(1, trimmed.length() - 1);
}
return trimmed;
}
}

View File

@@ -0,0 +1,341 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
private enum DistinctTypeAffinity {
FOR_MACHINE,
AGAINST_MACHINE,
UNKNOWN
}
@Override
public boolean hasProvenMachineAffinity(
CallChain chain,
String currentMachineName,
CodebaseContext context,
List<Transition> machineTransitions) {
if (chain == null) {
return false;
}
if (currentMachineName == null) {
return isRoutedToCorrectMachine(chain, currentMachineName, context);
}
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
String explicitTarget = evidence.explicitBeanName();
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null) {
Boolean namedTarget = EnableStateMachineBeanRouting.matchesNamedBean(
explicitTarget, currentMachineName, context);
if (namedTarget != null) {
return namedTarget;
}
}
if (chain.getTriggerPoint() != null && context != null) {
DistinctTypeAffinity typeAffinity =
resolveDistinctTypeAffinity(chain.getTriggerPoint(), currentMachineName, context);
if (typeAffinity == DistinctTypeAffinity.FOR_MACHINE) {
return true;
}
if (typeAffinity == DistinctTypeAffinity.AGAINST_MACHINE) {
return false;
}
}
if (context == null || isSingleStateMachine(context)) {
return isRoutedToCorrectMachine(chain, currentMachineName, context);
}
TriggerPoint trigger = chain.getTriggerPoint();
if (trigger != null
&& SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)
&& matchesConfiguredTransitionEvent(trigger, machineTransitions, context)) {
return true;
}
return isRoutedToCorrectMachine(chain, currentMachineName, context);
}
@Override
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
String explicitTarget = evidence.explicitBeanName();
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null && currentMachineName != null) {
Boolean namedTarget = EnableStateMachineBeanRouting.matchesNamedBean(
explicitTarget, currentMachineName, context);
if (namedTarget != null) {
return namedTarget;
}
}
boolean hasExplicitBeanTarget = explicitTarget != null && !explicitTarget.isEmpty();
// Precise FQN Type argument match from JDT bindings at sendEvent site
if (!hasExplicitBeanTarget && chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn();
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn();
if (triggerEventFqn != null || triggerStateFqn != null) {
String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context);
String machineStateFqn = machineTypes[0];
String machineEventFqn = machineTypes[1];
boolean matched = false;
boolean mismatched = false;
if (triggerEventFqn != null && machineEventFqn != null) {
if (typesEquivalent(triggerEventFqn, machineEventFqn)) {
matched = true;
} else if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
mismatched = true;
} else if (isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) {
matched = true;
}
}
if (triggerStateFqn != null && machineStateFqn != null) {
if (typesEquivalent(triggerStateFqn, machineStateFqn)) {
matched = true;
} else if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
mismatched = true;
} else if (isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) {
matched = true;
}
}
if (matched) {
if (!isAmbiguousSharedGenericMatch(
triggerEventFqn, triggerStateFqn, machineStateFqn, machineEventFqn)
|| isSingleStateMachine(context)) {
return true;
}
}
if (mismatched) {
return false;
}
// Fail closed only when both trigger and machine generic types were resolved but
// could not be reconciled. When machine config AST does not expose type args (stub
// config classes in tests, generated configs), fall through to single-SM / package routing.
boolean machineTypesKnown = machineEventFqn != null || machineStateFqn != null;
if (machineTypesKnown) {
return false;
}
}
}
if (!isSingleStateMachine(context) && isAmbiguousSharedGenericTrigger(chain.getTriggerPoint())) {
Boolean injectionMatch = SpringInjectionRouting.matchesMachineConfig(
chain.getTriggerPoint(), currentMachineName, context);
if (injectionMatch != null) {
return injectionMatch;
}
return false;
}
Boolean packageMatch = PackageNameRoutingHeuristics.matches(chain, currentMachineName, explicitTarget);
if (packageMatch != null) {
return packageMatch;
}
return context == null || isSingleStateMachine(context);
}
private DistinctTypeAffinity resolveDistinctTypeAffinity(
TriggerPoint trigger, String machineName, CodebaseContext context) {
String triggerEventFqn = trigger.getEventTypeFqn();
String triggerStateFqn = trigger.getStateTypeFqn();
if (triggerEventFqn == null && triggerStateFqn == null) {
return DistinctTypeAffinity.UNKNOWN;
}
String[] machineTypes = StateMachineTypeResolver.resolve(machineName, context);
String machineStateFqn = machineTypes[0];
String machineEventFqn = machineTypes[1];
boolean matched = false;
boolean mismatched = false;
if (triggerEventFqn != null && machineEventFqn != null) {
if (typesEquivalent(triggerEventFqn, machineEventFqn)
&& !isAmbiguousSharedGenericType(triggerEventFqn)) {
matched = true;
} else if (isDistinctTypeMismatched(triggerEventFqn, machineEventFqn)) {
mismatched = true;
}
}
if (triggerStateFqn != null && machineStateFqn != null) {
if (typesEquivalent(triggerStateFqn, machineStateFqn)
&& !isAmbiguousSharedGenericType(triggerStateFqn)) {
matched = true;
} else if (isDistinctTypeMismatched(triggerStateFqn, machineStateFqn)) {
mismatched = true;
}
}
if (matched) {
return DistinctTypeAffinity.FOR_MACHINE;
}
if (mismatched) {
return DistinctTypeAffinity.AGAINST_MACHINE;
}
return DistinctTypeAffinity.UNKNOWN;
}
private boolean matchesConfiguredTransitionEvent(
TriggerPoint trigger,
List<Transition> machineTransitions,
CodebaseContext context) {
if (trigger == null || machineTransitions == null || machineTransitions.isEmpty()) {
return false;
}
EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine(context);
for (Transition transition : machineTransitions) {
if (transition.getEvent() != null && matchingEngine.matches(transition.getEvent(), trigger)) {
return true;
}
}
return false;
}
private boolean isAmbiguousSharedGenericMatch(
String triggerEventFqn,
String triggerStateFqn,
String machineStateFqn,
String machineEventFqn) {
boolean eventShared = triggerEventFqn != null
&& machineEventFqn != null
&& typesEquivalent(triggerEventFqn, machineEventFqn)
&& isAmbiguousSharedGenericType(triggerEventFqn);
boolean stateShared = triggerStateFqn != null
&& machineStateFqn != null
&& typesEquivalent(triggerStateFqn, machineStateFqn)
&& isAmbiguousSharedGenericType(triggerStateFqn);
return eventShared || stateShared;
}
private boolean isAmbiguousSharedGenericType(String typeFqn) {
return isErasedOrOpaqueType(typeFqn) || isStringType(typeFqn);
}
private boolean isDistinctTypeMismatched(String type1, String type2) {
if (isAmbiguousSharedGenericType(type1) || isAmbiguousSharedGenericType(type2)) {
return false;
}
return isTypeMismatched(type1, type2);
}
private boolean isStringType(String typeFqn) {
if (typeFqn == null) {
return false;
}
String erased = eraseGenerics(typeFqn);
return "java.lang.String".equals(erased) || "String".equals(erased);
}
private Boolean resolveNamedBeanTarget(String beanName, String currentMachineName, CodebaseContext context) {
return EnableStateMachineBeanRouting.matchesNamedBean(beanName, currentMachineName, context);
}
private boolean isAmbiguousSharedGenericTrigger(TriggerPoint trigger) {
if (trigger == null) {
return false;
}
return isAmbiguousSharedGenericType(trigger.getEventTypeFqn())
|| isAmbiguousSharedGenericType(trigger.getStateTypeFqn());
}
private int countStateMachines(CodebaseContext context) {
if (context == null) {
return 0;
}
Set<String> machines = new HashSet<>();
for (TypeDeclaration td : context.findEntryPointClasses(
java.util.List.of("EnableStateMachineFactory", "EnableStateMachine"))) {
String fqn = context.getFqn(td);
if (fqn != null) {
machines.add(fqn);
}
}
for (TypeDeclaration td : context.getTypeDeclarations()) {
if (td == null || td.isInterface()) {
continue;
}
if (context.extendsStateMachineConfigurerAdapter(td)) {
String fqn = context.getFqn(td);
if (fqn != null) {
machines.add(fqn);
}
}
}
if (!machines.isEmpty()) {
return machines.size();
}
return context.findBeanMethodsReturning(
java.util.Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory")).size();
}
private String eraseGenerics(String type) {
if (type == null) return null;
int idx = type.indexOf('<');
if (idx != -1) {
return type.substring(0, idx);
}
return type;
}
private boolean typesEquivalent(String type1, String type2) {
if (type1 == null || type2 == null) {
return false;
}
String erased1 = normalizePrimitiveFqn(eraseGenerics(type1));
String erased2 = normalizePrimitiveFqn(eraseGenerics(type2));
return erased1.equals(erased2);
}
private String normalizePrimitiveFqn(String type) {
if (type == null) {
return null;
}
return switch (type) {
case "java.lang.String" -> "String";
case "java.lang.Object" -> "Object";
case "java.io.Serializable" -> "Serializable";
default -> type;
};
}
private boolean isTypeMismatched(String type1, String type2) {
if (type1 == null || type2 == null) return false;
type1 = eraseGenerics(type1);
type2 = eraseGenerics(type2);
if (type1.length() == 1 || type2.length() == 1) return false;
if (type1.equals("java.lang.String") || type1.equals("String") || type1.equals("java.lang.Object") || type1.equals("Object")) return false;
if (type2.equals("java.lang.String") || type2.equals("String") || type2.equals("java.lang.Object") || type2.equals("Object")) return false;
return !type1.equals(type2);
}
private boolean 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,44 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
/**
* Source-derived evidence used to decide whether a call chain belongs to a state-machine export.
*/
public record MachineRoutingEvidence(
String explicitBeanName,
String stateMachineId,
String eventTypeFqn,
String stateTypeFqn,
String triggerClassFqn,
String triggerMethod,
Integer triggerLine) {
public static MachineRoutingEvidence from(CallChain chain) {
if (chain == null) {
return new MachineRoutingEvidence(null, null, null, null, null, null, null);
}
TriggerPoint trigger = chain.getTriggerPoint();
String explicit = chain.getContextMachineId();
String stateMachineId = null;
Integer line = null;
if (trigger != null) {
if (explicit == null) {
explicit = trigger.getStateMachineId();
}
stateMachineId = trigger.getStateMachineId();
if (trigger.getLineNumber() > 0) {
line = trigger.getLineNumber();
}
}
return new MachineRoutingEvidence(
explicit,
stateMachineId,
trigger != null ? trigger.getEventTypeFqn() : null,
trigger != null ? trigger.getStateTypeFqn() : null,
trigger != null ? trigger.getClassName() : null,
trigger != null ? trigger.getMethodName() : null,
line);
}
}

View File

@@ -0,0 +1,311 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import java.util.Set;
/**
* Legacy package-prefix and domain-name matching for single-machine / null-context fallback routing.
*/
final class PackageNameRoutingHeuristics {
private static final Set<String> GENERIC_CLASS_PREFIXES = Set.of(
"state", "statemachine", "config", "configuration", "adapter",
"enterprise", "app", "global", "core", "project", "main",
"base", "common", "shared");
private static final Set<String> GENERIC_PACKAGE_SEGMENTS = Set.of(
"config", "configuration", "service", "services", "impl", "api", "web",
"controller", "controllers", "messaging", "listener", "listeners",
"repository", "repositories", "db", "model", "models", "domain",
"constants", "utils", "helper", "helpers", "event", "events",
"state", "states", "transition", "transitions", "shared", "common",
"global", "core", "base", "main", "app", "enterprise");
private PackageNameRoutingHeuristics() {
}
/**
* @return true when any class in the call chain carries a vertical domain prefix or package segment
*/
static boolean hasVerticalDomainOwnership(CallChain chain) {
if (chain == null) {
return false;
}
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
if (classHasVerticalDomainOwnership(chain.getTriggerPoint().getClassName())) {
return true;
}
}
if (chain.getMethodChain() != null) {
for (String method : chain.getMethodChain()) {
if (classHasVerticalDomainOwnership(getClassNameOnly(method))) {
return true;
}
}
}
return false;
}
/**
* @return true/false if package heuristics decide, null if inconclusive
*/
static Boolean matches(CallChain chain, String currentMachineName, String explicitBeanTarget) {
if (chain == null) {
return null;
}
boolean hasExplicitBeanTarget = explicitBeanTarget != null && !explicitBeanTarget.isEmpty();
String targetVar = hasExplicitBeanTarget ? explicitBeanTarget : chain.getContextMachineId();
if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId();
}
String simplifiedMachineName = currentMachineName != null
? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase()
: "";
if (targetVar != null && !targetVar.isEmpty()) {
targetVar = targetVar.toLowerCase();
if (targetVar.endsWith("statemachine")) {
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
if (!prefix.isEmpty()) {
if (simplifiedMachineName.contains(prefix)) {
return true;
} else {
return false;
}
}
} else if (simplifiedMachineName.contains(targetVar)) {
return true;
}
}
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) {
String smPackage = getPackageName(currentMachineName);
String smSimple = getSimpleClassName(currentMachineName);
String smPrefix = getFirstCamelCaseWord(smSimple);
boolean hasPositiveMatch = false;
boolean hasStrongMismatch = false;
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
String chainClass = chain.getTriggerPoint().getClassName();
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (hasMatchingDomainPrefix(smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
}
for (String method : chain.getMethodChain()) {
String chainClass = getClassNameOnly(method);
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (hasMatchingDomainPrefix(smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix, smSimple, chainSimple)) {
hasStrongMismatch = true;
}
}
if (hasPositiveMatch) {
return true;
}
if (hasStrongMismatch) {
return false;
}
}
return null;
}
private static boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
return false;
}
if (smPackage.equals(chainPackage)) {
return true;
}
if (!chainPackage.startsWith(smPackage + ".") && !smPackage.startsWith(chainPackage + ".")) {
return false;
}
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
int matchIdx = -1;
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
if (p1[i].equals(p2[i])) {
matchIdx = i;
} else {
break;
}
}
if (smPrefix != null && matchIdx >= 0) {
String lowerPrefix = smPrefix.toLowerCase();
for (int i = 0; i <= matchIdx; i++) {
if (p1[i].toLowerCase().contains(lowerPrefix) || lowerPrefix.contains(p1[i].toLowerCase())) {
return true;
}
}
}
return false;
}
private static boolean isDomainMismatch(
String smPackage,
String chainPackage,
String smPrefix,
String chainPrefix,
String smSimple,
String chainSimple) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
return false;
}
if (smPrefix == null || chainPrefix == null) {
return false;
}
String smLower = smPrefix.toLowerCase();
String chainLower = chainPrefix.toLowerCase();
if (isGenericClassPrefix(smLower) || isGenericClassPrefix(chainLower)) {
return false;
}
if (!smLower.equals(chainLower)) {
if (chainPackage.toLowerCase().contains(smLower) || smPackage.toLowerCase().contains(chainLower)) {
return false;
}
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
int matchIdx = -1;
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
if (p1[i].equals(p2[i])) {
matchIdx = i;
} else {
break;
}
}
if (matchIdx >= 1) {
boolean onlyGenericDivergence = true;
for (int i = matchIdx + 1; i < p1.length; i++) {
if (!isGenericPackageSegment(p1[i])) {
onlyGenericDivergence = false;
break;
}
}
if (onlyGenericDivergence) {
for (int i = matchIdx + 1; i < p2.length; i++) {
if (!isGenericPackageSegment(p2[i])) {
onlyGenericDivergence = false;
break;
}
}
}
if (!onlyGenericDivergence) {
return true;
}
}
}
return false;
}
private static String getPackageName(String fqn) {
if (fqn == null || !fqn.contains(".")) {
return "";
}
return fqn.substring(0, fqn.lastIndexOf('.'));
}
private static String getSimpleClassName(String fqn) {
if (fqn == null) {
return "";
}
if (!fqn.contains(".")) {
return fqn;
}
return fqn.substring(fqn.lastIndexOf('.') + 1);
}
private static String getClassNameOnly(String methodFqn) {
if (methodFqn == null) {
return "";
}
String clean = methodFqn;
if (clean.contains("(")) {
clean = clean.substring(0, clean.indexOf('('));
}
if (clean.contains(".")) {
clean = clean.substring(0, clean.lastIndexOf('.'));
}
return clean;
}
private static String getFirstCamelCaseWord(String simpleName) {
if (simpleName == null || simpleName.isEmpty()) {
return null;
}
String[] words = simpleName.split("(?<!^)(?=[A-Z])");
if (words.length > 0) {
return words[0];
}
return null;
}
private static boolean classHasVerticalDomainOwnership(String classFqn) {
if (classFqn == null || classFqn.isEmpty()) {
return false;
}
String prefix = getFirstCamelCaseWord(getSimpleClassName(classFqn));
if (prefix != null && !isGenericClassPrefix(prefix)) {
return true;
}
return hasVerticalPackageSegment(getPackageName(classFqn));
}
private static boolean hasVerticalPackageSegment(String packageName) {
if (packageName == null || packageName.isEmpty()) {
return false;
}
String[] segments = packageName.split("\\.");
for (int i = 2; i < segments.length; i++) {
if (!isGenericPackageSegment(segments[i])) {
return true;
}
}
return false;
}
private static boolean hasMatchingDomainPrefix(String smPrefix, String chainPrefix) {
return smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix);
}
private static boolean isGenericClassPrefix(String prefix) {
return prefix != null && GENERIC_CLASS_PREFIXES.contains(prefix.toLowerCase());
}
private static boolean isGenericPackageSegment(String segment) {
return segment != null && GENERIC_PACKAGE_SEGMENTS.contains(segment.toLowerCase());
}
}

View File

@@ -0,0 +1,31 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
/**
* Decides whether a call chain is provably shared infrastructure that may intentionally
* attach to multiple state machines when they share the same transition event.
* <p>
* Shared infrastructure requires source-derived evidence only: no distinct machine enum types
* at the sendEvent site and no vertical domain ownership in the call chain classes/packages.
* Event-name matching alone is never sufficient outside this policy.
*/
final class SharedServiceRoutingPolicy {
private SharedServiceRoutingPolicy() {
}
/**
* @return true when the chain may multi-attach to every machine that shares its transition event
*/
static boolean isProvablySharedInfrastructure(CallChain chain) {
if (chain == null) {
return false;
}
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
if (evidence.eventTypeFqn() != null || evidence.stateTypeFqn() != null) {
return false;
}
return !PackageNameRoutingHeuristics.hasVerticalDomainOwnership(chain);
}
}

View File

@@ -0,0 +1,99 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.Set;
/**
* Resolves which {@code @EnableStateMachine} configuration owns a trigger's {@code StateMachine} receiver
* using Spring injection analysis (JDT bindings), not package-name guessing.
*/
final class SpringInjectionRouting {
private static final Set<String> TRIGGER_METHODS = Set.of(
"sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger");
private SpringInjectionRouting() {
}
static Boolean matchesMachineConfig(TriggerPoint trigger, String machineConfigFqn, CodebaseContext context) {
if (trigger == null || machineConfigFqn == null || context == null || !context.isResolveBindings()) {
return null;
}
String configFqn = resolveConfigurationFqn(trigger, context);
if (configFqn == null) {
return null;
}
return machineConfigFqn.equals(configFqn);
}
static String resolveConfigurationFqn(TriggerPoint trigger, CodebaseContext context) {
if (trigger == null || trigger.getClassName() == null || trigger.getMethodName() == null) {
return null;
}
if (!context.isResolveBindings()) {
return null;
}
InjectionPointAnalyzer analyzer = createAnalyzer(context);
TypeDeclaration type = context.getTypeDeclaration(trigger.getClassName());
if (type == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(type, trigger.getMethodName(), true);
if (method == null || method.getBody() == null) {
return null;
}
CompilationUnit cu = (CompilationUnit) type.getRoot();
final IVariableBinding[] receiverBinding = new IVariableBinding[1];
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (trigger.getLineNumber() > 0 && cu != null
&& cu.getLineNumber(node.getStartPosition()) != trigger.getLineNumber()) {
return true;
}
if (!TRIGGER_METHODS.contains(node.getName().getIdentifier())) {
return true;
}
Expression receiver = node.getExpression();
if (receiver instanceof SimpleName sn) {
IBinding binding = sn.resolveBinding();
if (binding instanceof IVariableBinding vb) {
receiverBinding[0] = vb;
}
} else if (receiver instanceof FieldAccess fa) {
receiverBinding[0] = fa.resolveFieldBinding();
}
return false;
}
});
if (receiverBinding[0] == null) {
return null;
}
String injectedFqn = analyzer.resolveInjectedBeanFqn(receiverBinding[0]);
if (injectedFqn == null) {
return null;
}
TypeDeclaration injectedType = context.getTypeDeclaration(injectedFqn);
if (injectedType != null && context.extendsStateMachineConfigurerAdapter(injectedType)) {
return injectedFqn;
}
return EnableStateMachineBeanRouting.findConfigFqnByBeanName(
EnableStateMachineBeanRouting.defaultBeanNameFromFqn(injectedFqn), context);
}
private static InjectionPointAnalyzer createAnalyzer(CodebaseContext context) {
SpringBeanRegistry registry = new SpringBeanRegistry();
SpringContextScanner scanner = new SpringContextScanner(registry);
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(scanner);
}
return new InjectionPointAnalyzer(new SpringDependencyResolver(registry));
}
}

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,213 @@
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.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import lombok.Builder;
import lombok.Getter;
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) {
applyResolution(properties, null);
}
public void applyResolution(Map<String, String> properties, CodebaseContext context) {
var resolver = new click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver();
// 1. Resolve start/end states strings
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, context))
.collect(java.util.stream.Collectors.toList());
List<CallChain> resolvedCallChains = metadata.getCallChains() == null ? null
: metadata.getCallChains().stream()
.map(chain -> resolveCallChain(chain, resolver, properties, context))
.collect(java.util.stream.Collectors.toList());
if (metadata.getEntryPoints() != null) {
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,
CodebaseContext context) {
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream()
.map(value -> resolver.resolveValue(value, properties))
.map(value -> MachineEnumCanonicalizer.qualifyEventIdentifier(
value, trigger.getEventTypeFqn(), context))
.collect(java.util.stream.Collectors.toList());
String resolvedEvent = trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null;
String resolvedSource = trigger.getSourceState() != null
? resolver.resolveValue(trigger.getSourceState(), properties)
: null;
return trigger.toBuilder()
.event(resolvedEvent != null
? MachineEnumCanonicalizer.qualifyEventIdentifier(
resolvedEvent, trigger.getEventTypeFqn(), context)
: null)
.sourceState(resolvedSource != null
? MachineEnumCanonicalizer.canonicalizeLabel(
resolvedSource, trigger.getStateTypeFqn(), context)
: null)
.polymorphicEvents(polymorphicEvents)
.build();
}
private static CallChain resolveCallChain(
CallChain chain,
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
Map<String, String> properties,
CodebaseContext context) {
TriggerPoint trigger = chain.getTriggerPoint() == null
? null
: resolveTrigger(chain.getTriggerPoint(), resolver, properties, context);
List<MatchedTransition> matchedTransitions = chain.getMatchedTransitions() == null ? null
: chain.getMatchedTransitions().stream()
.map(matched -> MatchedTransition.builder()
.event(resolver.resolveValue(matched.getEvent(), properties))
.sourceState(resolver.resolveValue(matched.getSourceState(), properties))
.targetState(resolver.resolveValue(matched.getTargetState(), properties))
.build())
.collect(java.util.stream.Collectors.toList());
return chain.toBuilder()
.triggerPoint(trigger)
.matchedTransitions(matchedTransitions)
.build();
}
public void addMetadata(CodebaseMetadata newMetadata) {
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(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class BusinessFlow {
private final String name;
private final String description;
private final List<FlowStep> steps;
}

View File

@@ -0,0 +1,22 @@
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;
/** How this chain's trigger was linked; derived from trigger flags and matchedTransitions. */
private final LinkResolution linkResolution;
}

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,29 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import lombok.Builder;
import lombok.Data;
@Data
@Builder(toBuilder = true)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonDeserialize(using = FlowStepDeserializer.class)
@JsonSerialize(using = FlowStepSerializer.class)
public class FlowStep {
/** Source state identifier (package-canonical FQN or short form). */
@JsonInclude(JsonInclude.Include.NON_NULL)
private final String source;
/** Event identifier or {@code Source->Target} for anonymous transitions. */
@JsonInclude(JsonInclude.Include.NON_NULL)
private final String event;
/** Precomputed {@code #link_*} suffix for HTML/SVG highlight. */
@JsonInclude(JsonInclude.Include.NON_NULL)
private final String linkKey;
public static FlowStep ofEvent(String event) {
return FlowStep.builder().event(event).build();
}
}

View File

@@ -0,0 +1,33 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
class FlowStepDeserializer extends JsonDeserializer<FlowStep> {
@Override
public FlowStep deserialize(JsonParser parser, DeserializationContext context) throws IOException {
JsonNode node = parser.getCodec().readTree(parser);
if (node.isTextual()) {
return FlowStep.builder().event(node.asText()).build();
}
return FlowStep.builder()
.source(textOrNull(node, "source"))
.event(textOrNull(node, "event"))
.linkKey(textOrNull(node, "linkKey"))
.build();
}
private static String textOrNull(JsonNode node, String field) {
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
String text = value.asText();
return text.isBlank() ? null : text;
}
}

View File

@@ -0,0 +1,35 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
class FlowStepSerializer extends JsonSerializer<FlowStep> {
@Override
public void serialize(FlowStep step, JsonGenerator generator, SerializerProvider serializers) throws IOException {
if (step == null) {
generator.writeNull();
return;
}
boolean structured = step.getSource() != null && !step.getSource().isBlank()
|| step.getLinkKey() != null && !step.getLinkKey().isBlank();
if (!structured && step.getEvent() != null && !step.getEvent().isBlank()) {
generator.writeString(step.getEvent());
return;
}
generator.writeStartObject();
if (step.getSource() != null && !step.getSource().isBlank()) {
generator.writeStringField("source", step.getSource());
}
if (step.getEvent() != null && !step.getEvent().isBlank()) {
generator.writeStringField("event", step.getEvent());
}
if (step.getLinkKey() != null && !step.getLinkKey().isBlank()) {
generator.writeStringField("linkKey", step.getLinkKey());
}
generator.writeEndObject();
}
}

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;
/**
* Export metadata describing how a call-chain trigger was linked to state-machine transitions.
* Derived from existing trigger flags only — no additional heuristics.
*/
public enum LinkResolution {
/** Concrete trigger linked to one or more matched transitions. */
RESOLVED,
/** REST or other external trigger that cannot be statically linked. */
UNRESOLVED_EXTERNAL,
/** Call-graph widening or source-state ambiguity prevented linking. */
AMBIGUOUS_WIDEN,
/** Static trigger with no matching transition link. */
NO_MATCH
}

View File

@@ -0,0 +1,20 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
@Data
@Builder(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class MatchedTransition {
private final String sourceState;
private final String targetState;
private final String event;
/** Precomputed {@code #link_*} suffix for HTML/SVG highlight; optional in JSON exports. */
@JsonInclude(JsonInclude.Include.NON_NULL)
private final String linkKey;
}

View File

@@ -0,0 +1,63 @@
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 = sourceState;
this.lineNumber = lineNumber;
this.stateTypeFqn = stateTypeFqn;
this.eventTypeFqn = eventTypeFqn;
this.event = event;
this.polymorphicEvents = polymorphicEvents;
this.external = external;
this.constraint = constraint;
this.ambiguous = ambiguous;
}
}

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,315 @@
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 transforms).
*/
public final class ReactiveExpressionSupport {
private static final Set<String> REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success");
private static final Set<String> REACTIVE_TRANSFORM_METHODS = Set.of("map", "flatMap", "switchMap", "concatMap");
private ReactiveExpressionSupport() {
}
public static String extractPayload(Expression expression, ConstantResolver constantResolver, CodebaseContext context) {
return extractPayload(expression, null, constantResolver, context);
}
/**
* Rewrites {@code p.getEvent()} inside a reactive transform lambda to {@code payload.getEvent()}
* when {@code p} is fed by {@code Mono.just(payload)} (or a chained reactive source).
*/
public static String remapLambdaParameterGetter(
Expression eventExpr,
ConstantResolver constantResolver,
CodebaseContext context) {
if (!(eventExpr instanceof MethodInvocation getterCall)) {
return null;
}
if (!(getterCall.getExpression() instanceof SimpleName paramName)) {
return null;
}
LambdaExpression lambda = findEnclosingLambda(getterCall);
if (lambda == null) {
return null;
}
MethodInvocation transformCall = findEnclosingReactiveTransform(lambda);
if (transformCall == null) {
return null;
}
String mappedReceiver = mapLambdaParameterToSource(
lambda,
paramName.getIdentifier(),
transformCall.getExpression(),
constantResolver,
context);
if (mappedReceiver == null) {
return null;
}
return mappedReceiver + "." + getterCall.getName().getIdentifier() + "()";
}
/**
* Same as {@link #remapLambdaParameterGetter(Expression, ConstantResolver, CodebaseContext)} but locates
* the getter in a real method body when {@code expressionText} came from a detached parse tree.
*/
public static String remapLambdaParameterGetterInMethod(
String expressionText,
String methodFqn,
ConstantResolver constantResolver,
CodebaseContext context) {
MethodInvocation getter = 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 findEnclosingReactiveTransform(ASTNode node) {
ASTNode current = node.getParent();
while (current != null) {
if (current instanceof MethodInvocation mi
&& REACTIVE_TRANSFORM_METHODS.contains(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 (REACTIVE_TRANSFORM_METHODS.contains(methodName) && !mi.arguments().isEmpty()) {
String fromArgument = extractTransformArgumentPayload(
(Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context);
if (fromArgument != null) {
return fromArgument;
}
}
if (REACTIVE_FACTORY_METHODS.contains(methodName) && !mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0);
if (constantResolver != null && context != null) {
String resolved = constantResolver.resolve(arg, context);
if (resolved != null) {
return resolved;
}
}
return arg.toString();
}
if (mi.getExpression() != null) {
return extractPayload(mi.getExpression(), flatMapReceiver, constantResolver, context);
}
}
return null;
}
private static String extractTransformArgumentPayload(
Expression argument,
Expression transformReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (argument instanceof LambdaExpression lambda) {
Expression bodyExpression = lambdaBodyExpression(lambda);
if (bodyExpression != null) {
String lambdaPayload = extractLambdaBodyPayload(
bodyExpression, lambda, transformReceiver, constantResolver, context);
if (lambdaPayload != null) {
return lambdaPayload;
}
return extractPayload(bodyExpression, transformReceiver, constantResolver, context);
}
}
return extractPayload(argument, transformReceiver, constantResolver, context);
}
private static String extractLambdaBodyPayload(
Expression bodyExpression,
LambdaExpression lambda,
Expression transformReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (!(bodyExpression instanceof MethodInvocation factoryCall)) {
return null;
}
if (!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(), transformReceiver, constantResolver, context);
if (mappedReceiver != null) {
return mappedReceiver + getterSuffix;
}
}
if (getterReceiver != null) {
return getterReceiver.toString() + getterSuffix;
}
return null;
}
private static String mapLambdaParameterToSource(
LambdaExpression lambda,
String paramName,
Expression transformReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (lambda.parameters().size() != 1) {
return null;
}
Object parameter = lambda.parameters().get(0);
if (!(parameter instanceof org.eclipse.jdt.core.dom.VariableDeclaration variableDeclaration)) {
return null;
}
if (!paramName.equals(variableDeclaration.getName().getIdentifier())) {
return null;
}
if (transformReceiver instanceof MethodInvocation receiverTransform
&& REACTIVE_TRANSFORM_METHODS.contains(receiverTransform.getName().getIdentifier())
&& !receiverTransform.arguments().isEmpty()
&& receiverTransform.arguments().get(0) instanceof LambdaExpression feederLambda
&& feederLambda != lambda) {
Expression feederBody = lambdaBodyExpression(feederLambda);
if (feederBody != null) {
String feederPayload = extractLambdaBodyPayload(
feederBody, feederLambda, receiverTransform.getExpression(), constantResolver, context);
if (feederPayload != null) {
return feederPayload;
}
String extracted = extractPayload(
feederBody, receiverTransform.getExpression(), constantResolver, context);
if (extracted != null) {
return extracted;
}
}
}
Expression source = peelJustArgument(transformReceiver);
if (source instanceof MethodInvocation getterMi
&& getterMi.getExpression() instanceof SimpleName innerParamName) {
LambdaExpression outerLambda = findEnclosingLambda(lambda);
if (outerLambda != null && outerLambda != lambda) {
MethodInvocation outerTransform = findEnclosingReactiveTransform(outerLambda);
if (outerTransform != null) {
String mappedBase = mapLambdaParameterToSource(
outerLambda,
innerParamName.getIdentifier(),
outerTransform.getExpression(),
constantResolver,
context);
if (mappedBase != null) {
return mappedBase + "." + getterMi.getName().getIdentifier() + "()";
}
}
}
}
if (source != null) {
if (constantResolver != null && context != null) {
String resolved = constantResolver.resolve(source, context);
if (resolved != null) {
return resolved;
}
}
return source.toString();
}
return extractPayload(transformReceiver, null, constantResolver, context);
}
private static Expression peelJustArgument(Expression expression) {
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,438 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
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) {
// Be conservative in pruning: if we can't parse the expression, do not discard paths.
return !expression.contains("false");
}
}
/**
* Evaluates a constraint against known variable bindings gathered while walking a call path forward.
* Supports {@code var == VALUE} where VALUE may be a quoted string or enum constant; suffix matching
* is used so {@code command == ORDER_PAY} matches {@code DomainCommand.ORDER_PAY}.
*/
public static boolean isCompatibleWithBindings(String constraint, Map<String, String> bindings) {
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()) {
if (!isConcreteBindingValue(entry.getKey(), entry.getValue())) {
continue;
}
expr = substituteVariableBindings(expr, entry.getKey(), entry.getValue());
expr = substituteEqualsLiteralBindings(expr, entry.getKey(), entry.getValue());
}
return evaluateBooleanExpression(expr);
}
private static boolean isConcreteBindingValue(String paramName, String boundValue) {
if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) {
return false;
}
if (boundValue.contains("(") && !boundValue.contains("valueOf")) {
return false;
}
if (boundValue.startsWith("\"") && boundValue.endsWith("\"")) {
return true;
}
if ("true".equalsIgnoreCase(boundValue) || "false".equalsIgnoreCase(boundValue)) {
return true;
}
if (boundValue.contains(".") && Character.isUpperCase(boundValue.charAt(boundValue.lastIndexOf('.') + 1))) {
return true;
}
// Treat simple routing keys (e.g. order.pay) as concrete string bindings, but avoid
// mistaking variable names (e.g. machineType) for concrete values.
if ((boundValue.contains(".") || boundValue.contains("/") || boundValue.contains("-"))
&& boundValue.matches("^[a-zA-Z0-9._/-]+$")) {
return true;
}
return boundValue.equals(boundValue.toUpperCase(Locale.ROOT)) && boundValue.chars().allMatch(ch ->
Character.isUpperCase(ch) || ch == '_');
}
private static String substituteEqualsLiteralBindings(String expr, String varName, String boundValue) {
if (boundValue == null || boundValue.isBlank()) {
return expr;
}
String cleanValue = boundValue;
if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) {
cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
}
String literalPattern = Pattern.quote(cleanValue);
String varToken = Pattern.quote(varName);
String argumentEquals = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
+ literalPattern + "[\"']?\\s*\\)";
expr = replaceReceiverEqualsForVariable(expr, varName, cleanValue, "true");
expr = replaceObjectsEqualsForVariable(expr, varName, cleanValue, "true");
expr = expr.replaceAll(argumentEquals, "true");
Set<String> allLiterals = extractStringLiterals(expr);
for (String literal : allLiterals) {
if (literal.equalsIgnoreCase(cleanValue)) {
continue;
}
String otherLiteral = Pattern.quote(literal);
String otherArgument = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
+ otherLiteral + "[\"']?\\s*\\)";
expr = replaceReceiverEqualsForVariable(expr, varName, literal, "false");
expr = replaceObjectsEqualsForVariable(expr, varName, literal, "false");
expr = expr.replaceAll(otherArgument, "false");
}
return expr;
}
private static String replaceObjectsEqualsForVariable(
String expr, String varName, String literalValue, String replacement) {
Pattern head = Pattern.compile("(?is)(?:java\\.util\\.)?Objects\\.equals\\s*\\(");
Matcher matcher = head.matcher(expr);
StringBuilder result = new StringBuilder();
int cursor = 0;
while (matcher.find()) {
int matchStart = matcher.start();
int openParen = matcher.end() - 1;
int closeParen = findMatchingCloseParen(expr, openParen);
if (closeParen < 0) {
break;
}
String[] args = splitTopLevelComma(expr.substring(openParen + 1, closeParen));
result.append(expr, cursor, matchStart);
if (args != null
&& args.length == 2
&& objectsEqualsArgumentMatches(args[0], args[1], varName, literalValue)) {
result.append(replacement);
} else {
result.append(expr, matchStart, closeParen + 1);
}
cursor = closeParen + 1;
}
result.append(expr.substring(cursor));
return result.toString();
}
private static boolean objectsEqualsArgumentMatches(
String left, String right, String varName, String literalValue) {
String leftLiteral = stripConstraintLiteral(left);
String rightLiteral = stripConstraintLiteral(right);
if (leftLiteral != null
&& leftLiteral.equalsIgnoreCase(literalValue)
&& constraintArgumentReferencesVariable(right, varName)) {
return true;
}
return rightLiteral != null
&& rightLiteral.equalsIgnoreCase(literalValue)
&& constraintArgumentReferencesVariable(left, varName);
}
private static String stripConstraintLiteral(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
if (trimmed.length() >= 2
&& ((trimmed.startsWith("\"") && trimmed.endsWith("\""))
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
return trimmed.substring(1, trimmed.length() - 1);
}
return null;
}
private static boolean constraintArgumentReferencesVariable(String argument, String varName) {
return argument != null && argument.matches("(?s).*\\b" + Pattern.quote(varName) + "\\b.*");
}
private static String[] splitTopLevelComma(String args) {
int depth = 0;
for (int i = 0; i < args.length(); i++) {
char c = args.charAt(i);
if (c == '(') {
depth++;
} else if (c == ')') {
depth--;
} else if (c == ',' && depth == 0) {
return new String[] {args.substring(0, i), args.substring(i + 1)};
}
}
return null;
}
private static String replaceReceiverEqualsForVariable(
String expr, String varName, String literalValue, String replacement) {
Pattern head = Pattern.compile(
"(?is)[\"']?" + Pattern.quote(literalValue) + "[\"']?\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(");
Matcher matcher = head.matcher(expr);
StringBuilder result = new StringBuilder();
int cursor = 0;
while (matcher.find()) {
int matchStart = matcher.start();
int openParen = matcher.end() - 1;
int closeParen = findMatchingCloseParen(expr, openParen);
if (closeParen < 0) {
break;
}
String argument = expr.substring(openParen + 1, closeParen).trim();
result.append(expr, cursor, matchStart);
if (argument.equals(varName)
|| argument.matches("(?s).*\\b" + Pattern.quote(varName) + "\\b.*")) {
result.append(replacement);
} else {
result.append(expr, matchStart, closeParen + 1);
}
cursor = closeParen + 1;
}
result.append(expr.substring(cursor));
return result.toString();
}
private static int findMatchingCloseParen(String expr, int openIdx) {
int depth = 0;
for (int i = openIdx; i < expr.length(); i++) {
char c = expr.charAt(i);
if (c == '(') {
depth++;
} else if (c == ')') {
depth--;
if (depth == 0) {
return i;
}
}
}
return -1;
}
private static Set<String> extractEqualityVariables(String constraint) {
Set<String> vars = new HashSet<>();
Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*==");
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);
}
Pattern eqPattern = Pattern.compile(
"(?i)" + Pattern.quote(varName) + "\\s*==\\s*([\\w.\"']+)");
Matcher matcher = eqPattern.matcher(expr);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String rhs = matcher.group(1).replace("\"", "").replace("'", "");
boolean matches = constraintValuesMatch(cleanValue, rhs);
matcher.appendReplacement(sb, matches ? "true" : "false");
}
matcher.appendTail(sb);
if ("true".equalsIgnoreCase(cleanValue) || "false".equalsIgnoreCase(cleanValue)) {
Pattern bareVar = Pattern.compile("(?<![\\w.])" + Pattern.quote(varName) + "(?![\\w])");
return bareVar.matcher(expr).replaceAll(cleanValue.toLowerCase(Locale.ROOT));
}
return sb.toString();
}
private static boolean constraintValuesMatch(String boundValue, String rhs) {
if (boundValue == null || rhs == null) {
return false;
}
String bound = stripOuterQuotes(boundValue);
String rhsClean = stripOuterQuotes(rhs);
if (bound.equals(rhsClean) || bound.equalsIgnoreCase(rhsClean)) {
return true;
}
if (bound.endsWith("." + rhsClean) || rhsClean.endsWith("." + bound)) {
return true;
}
String boundConstant = enumConstantName(bound);
String rhsConstant = enumConstantName(rhsClean);
if (!boundConstant.equalsIgnoreCase(rhsConstant)) {
return false;
}
String boundType = enumTypePart(bound);
String rhsType = enumTypePart(rhsClean);
if (boundType == null || rhsType == null) {
return true;
}
if (boundType.equals(rhsType)) {
return true;
}
String boundSimple = simpleTypeName(boundType);
String rhsSimple = simpleTypeName(rhsType);
if (!boundSimple.equals(rhsSimple)) {
return false;
}
boolean boundImportStyle = !boundType.contains(".");
boolean rhsImportStyle = !rhsType.contains(".");
return boundImportStyle || rhsImportStyle;
}
private static String stripOuterQuotes(String value) {
if (value == null) {
return null;
}
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
return value.substring(1, value.length() - 1);
}
return value;
}
private static String enumConstantName(String ref) {
int dot = ref.lastIndexOf('.');
return dot >= 0 ? ref.substring(dot + 1) : ref;
}
private static String enumTypePart(String ref) {
int dot = ref.lastIndexOf('.');
if (dot <= 0) {
return null;
}
return ref.substring(0, dot);
}
private static String simpleTypeName(String typePart) {
int dot = typePart.lastIndexOf('.');
return dot >= 0 ? typePart.substring(dot + 1) : typePart;
}
/**
* Returns {@code true}/{@code false} when both sides are compile-time string literals; otherwise {@code null}.
*/
public static Boolean evaluateStringEquals(String leftLiteral, String rightLiteral, boolean ignoreCase) {
if (leftLiteral == null || rightLiteral == null) {
return null;
}
return ignoreCase
? leftLiteral.equalsIgnoreCase(rightLiteral)
: leftLiteral.equals(rightLiteral);
}
private static Set<String> extractStringLiterals(String constraint) {
Set<String> literals = new HashSet<>();
Matcher matcher = STRING_LITERAL_PATTERN.matcher(constraint);
while (matcher.find()) {
literals.add(matcher.group(1));
}
return literals;
}
private static boolean parseExpression(String expression) {
if (expression.isEmpty()) {
return true;
}
int parenDepth = 0;
for (int i = expression.length() - 1; i >= 0; i--) {
char c = expression.charAt(i);
if (c == ')') {
parenDepth++;
} else if (c == '(') {
parenDepth--;
} else if (parenDepth == 0 && i > 0 && expression.charAt(i) == '|' && expression.charAt(i - 1) == '|') {
return parseExpression(expression.substring(0, i - 1)) || parseExpression(expression.substring(i + 1));
}
}
parenDepth = 0;
for (int i = expression.length() - 1; i >= 0; i--) {
char c = expression.charAt(i);
if (c == ')') {
parenDepth++;
} else if (c == '(') {
parenDepth--;
} else if (parenDepth == 0 && i > 0 && expression.charAt(i) == '&' && expression.charAt(i - 1) == '&') {
return parseExpression(expression.substring(0, i - 1)) && parseExpression(expression.substring(i + 1));
}
}
if (expression.startsWith("!")) {
return !parseExpression(expression.substring(1));
}
if (expression.startsWith("(") && expression.endsWith(")")) {
return parseExpression(expression.substring(1, expression.length() - 1));
}
if ("true".equalsIgnoreCase(expression)) {
return true;
}
if ("false".equalsIgnoreCase(expression)) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,557 @@
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;
}
/**
* True when every predicate method in the constraint exists on the enum (or its interfaces).
* Used to distinguish inconclusive filtering from an intentional empty result.
*/
public static boolean hasResolvablePredicateMethods(
String constraint,
String enumTypeFqn,
CodebaseContext context) {
List<PredicateCall> predicates = extractPredicateCalls(constraint);
if (predicates.isEmpty()) {
return true;
}
EnumDeclaration enumDecl = findEnumDeclaration(enumTypeFqn, context);
if (enumDecl == null) {
return false;
}
for (PredicateCall predicate : predicates) {
MethodDeclaration method = findParameterlessMethod(enumDecl, predicate.methodName());
if (method == null && context != null) {
method = findInterfaceParameterlessMethod(enumDecl, predicate.methodName(), context);
}
if (method == null) {
return false;
}
}
return true;
}
private static boolean satisfiesPredicates(
String canonicalConstantFqn,
List<PredicateCall> predicates,
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,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,167 @@
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, context);
if (result.getStates() != null) {
result.setStates(MachineEnumCanonicalizer.canonicalizeStates(
result.getStates(), machineTypes.stateTypeFqn(), context));
}
result.setStartStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
result.getStartStates(), machineTypes.stateTypeFqn(), context));
result.setEndStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
result.getEndStates(), machineTypes.stateTypeFqn(), context));
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, context);
return chain.toBuilder()
.triggerPoint(trigger)
.matchedTransitions(matched)
.build();
})
.collect(Collectors.toList());
}
private static List<MatchedTransition> canonicalizeMatchedTransitions(
List<MatchedTransition> matchedTransitions,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
if (matchedTransitions == null) {
return null;
}
return matchedTransitions.stream()
.map(matched -> MatchedTransition.builder()
.event(MachineEnumCanonicalizer.canonicalizeLabel(
matched.getEvent(), machineTypes.eventTypeFqn(), context))
.sourceState(MachineEnumCanonicalizer.canonicalizeLabel(
matched.getSourceState(), machineTypes.stateTypeFqn(), context))
.targetState(MachineEnumCanonicalizer.canonicalizeLabel(
matched.getTargetState(), machineTypes.stateTypeFqn(), context))
.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);
}

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