109 Commits

Author SHA1 Message Date
342efaef8e agents.md from glm 8b 2026-07-15 19:36:41 +02:00
9a3bd3394f cursor out of token 2026-07-15 16:29:41 +02:00
d6754b7464 Unify getter and path-binding resolution through anchored dataflow.
Route chained getters, path bindings, and setter-before-getter reads through JdtDataFlowModel instead of parallel mini-interpreters, with regression tests covering interface hops, anonymous classes, and anchored vs detached AST resolution.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 08:40:02 +02:00
0558fd2bb7 Add layered regression tests for functional-interface dataflow linking.
Lock Supplier.get(), Runnable.run(), and call-site enum resolution to the dataflow pipeline so a parallel resolver cannot creep back in unnoticed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 07:41:09 +02:00
d5a6adafcc Remove CallSiteArgumentResolver; route functional args through dataflow.
Delete the parallel Supplier/Runnable bridging layer and resolve functional call-site arguments via VariableTracer, PathBindingEvaluator, and JdtDataFlowModel instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 07:37:21 +02:00
9a54616e5f Fix Runnable lambda and rich-event predicate linking gaps.
Trace execute→Runnable.run arguments via source AST and JdtDataFlowModel void side-effects, and narrow polymorphic events using domain isCorrect() predicates on rich event types.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 07:28:53 +02:00
dbc22bf1af Fix zero transition links when config event types are unresolved.
Derive machine enum types from transition events for poly narrowing, scan sendEvent literals in caller methods, and keep generic REST templates external without blocking dedicated endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 20:22:32 +02:00
758d433726 Narrow wide polymorphic events per call chain before linking.
Use multi-source endpoint evidence so shared dispatchers resolve to one event per endpoint without over-linking or fail-closed AMBIGUOUS_WIDEN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 20:08:09 +02:00
1957266a98 Require endpoint-narrow polymorphic evidence before linking transitions.
Fail closed on broad enum widens so dispatchers link one event per endpoint instead of every transition on the machine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:55:18 +02:00
33ccc4a6c5 Link transitions when the same event maps to multiple sources in one state enum.
Stop treating multi-source same-event triggers as ambiguous widen failures; trust bare polymorphic constant names when the machine event type is known.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:44:38 +02:00
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
264 changed files with 36498 additions and 3398 deletions

63
AGENTS.md Normal file
View File

@@ -0,0 +1,63 @@
# AGENTS.md
## Project Overview
Spring State Machine Explorer is a static analysis tool that extracts and visualizes Spring State Machines from source code. It does **not** run Maven/Gradle on target projects.
## Build System
- **Java 21** required (toolchain configured in all build.gradle files)
- **Gradle** build system with shadow plugin for fat JARs
- Run from root: `./gradlew <task>`
## Key Commands
### Run the exporter
```bash
./gradlew :state_machine_exporter:run --args="-i ./my-project -o ./out -f json"
```
### Run the HTML generator
```bash
./gradlew :state_machine_exporter_html:run --args="-i ./my-project -o ./out_html"
```
### Update golden test files
```bash
./gradlew :state_machine_exporter:runGolden
```
### Run tests
```bash
./gradlew :state_machine_exporter:test
./gradlew :state_machine_exporter_html:test
```
## Module Structure
- **state_machine_exporter**: Core Java AST analyzer (Main class: `click.kamil.springstatemachineexporter.Main`)
- **state_machine_exporter_html**: HTML portal generator (Main class: `click.kamil.springstatemachineexporter.html.Main`)
- **state_machine_exporter_spring_based**: Spring-based exporter sample
- **file_combine**: Utility for concatenating Java files
- **state_machines/**: Sample state machine projects for testing
## Architecture Notes
- **Source-first analysis**: Reads source + build metadata (pom.xml, settings.gradle) statically
- **Accessor inlining**: Enabled by default (O(1) lookup for trivial getters/setters). Disable with `--no-inline-accessors`
- **Generated sources**: Scans `target/`/`build/` by default. Control with `--include-generated-sources`
- **Debug mode**: Use `--debug` flag for verbose logging and unresolved chain diagnostics
- **Spring profiles**: Pass with `-p, --profiles` for property placeholder resolution
## Testing
- **Golden tests**: JSON/PNG/PUML/DOT/SCXML outputs are compared against golden files in `state_machine_exporter/src/test/resources/golden/`
- **E2E tests**: Tagged with `@Tag("e2e")` in `PlantUmlE2ETest.java`
- **Test scenarios**: Defined in `TestScenario.java` record with name, inputPath, goldenPath, baseName, activeProfiles
## Important Constraints
- **No Maven/Gradle execution**: The tool does not run the target project's build
- **JDT bindings**: Limited to library types (Spring Boot 3.2.0, Spring State Machine 3.2.0) - external JARs not resolved
- **Monorepo structure**: All modules in root; state_machines/ contains sample projects
- **Java 21 only**: All modules require Java 21 toolchain

View File

@@ -56,11 +56,17 @@ Define sequence of events in `src/main/resources/flows.json`:
{ {
"name": "Order Success", "name": "Order Success",
"description": "Happy path for order placement", "description": "Happy path for order placement",
"steps": ["PAY", "CHECK_AVAILABILITY->PENDING", "SHIP"] "steps": [
"PAY",
"CHECK_AVAILABILITY->PENDING",
{ "source": "com.example.order.OrderState.PAID", "event": "com.example.order.OrderEvent.SHIP" }
]
} }
] ]
``` ```
Event-only strings remain supported but do not highlight in the HTML explorer unless paired with a source state. Use `{source, event}` objects for precise transition highlighting.
## JSON Structure ## JSON Structure
- `metadata.entryPoints`: REST, WebFlux, and JMS entry points. - `metadata.entryPoints`: REST, WebFlux, and JMS entry points.
- `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`). - `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`).

View File

@@ -3,15 +3,16 @@ package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.Map;
@Slf4j @Slf4j
public class CallChainEnricher implements AnalysisEnricher { public class CallChainEnricher implements AnalysisEnricher {
@@ -19,30 +20,88 @@ public class CallChainEnricher implements AnalysisEnricher {
@Override @Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) { public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with call chains", result.getName()); log.info("Enriching {} with call chains", result.getName());
List<CallChain> chains = buildCallChainsForMachine(result, context, intelligence, true);
StateMachineTypeResolver.MachineTypes machineTypes = if (chains == null) {
StateMachineTypeResolver.resolveTypes(result.getName(), context); return;
}
List<TriggerPoint> canonicalTriggers = intelligence.findTriggerPoints().stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
.collect(Collectors.toList());
List<CallChain> chains = intelligence.findCallChains(
result.getMetadata().getEntryPoints(),
canonicalTriggers
);
chains = chains.stream()
.map(chain -> chain.getTriggerPoint() == null
? chain
: chain.toBuilder()
.triggerPoint(MachineEnumCanonicalizer.canonicalizeTriggerPoint(
chain.getTriggerPoint(), machineTypes))
.build())
.collect(Collectors.toList());
chains = MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context);
result.addMetadata(CodebaseMetadata.builder() result.addMetadata(CodebaseMetadata.builder()
.callChains(chains) .callChains(chains)
.build()); .build());
} }
/**
* Re-resolves call chains from the codebase call graph for the given machine.
* Used on JSON re-export when source is available to refresh {@code polymorphicEvents},
* {@code external}, and branch {@code constraint} on trigger points.
*/
public static List<CallChain> buildCallChainsForMachine(
AnalysisResult result,
CodebaseContext context,
CodebaseIntelligenceProvider intelligence) {
return buildCallChainsForMachine(result, context, intelligence, false);
}
public static List<CallChain> buildCallChainsForMachine(
AnalysisResult result,
CodebaseContext context,
CodebaseIntelligenceProvider intelligence,
boolean preferMetadataTriggers) {
if (result.getMetadata() == null || intelligence == null) {
return null;
}
List<EntryPoint> entryPoints = result.getMetadata().getEntryPoints();
if (entryPoints == null || entryPoints.isEmpty()) {
entryPoints = synthesizeEntryPoints(result.getMetadata().getCallChains());
}
if (entryPoints == null || entryPoints.isEmpty()) {
return null;
}
List<TriggerPoint> scopedTriggers = null;
if (preferMetadataTriggers) {
scopedTriggers = result.getMetadata().getTriggers();
}
if (scopedTriggers == null || scopedTriggers.isEmpty()) {
scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
intelligence.findTriggerPoints(),
result.getName(),
context);
}
List<CallChain> chains = intelligence.findCallChains(entryPoints, scopedTriggers);
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,287 @@
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.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.ExternalTriggerPolicy;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.List;
/**
* Central rules for transition linking outcomes derived from existing trigger evidence only.
*/
public final class CallChainLinkPolicy {
private CallChainLinkPolicy() {
}
/**
* Resolves the machine event enum FQN from config AST, embedded analysis fields, or transition events.
* Abstract/inherited configs often omit AST type args; transition {@code fullIdentifier} values still
* carry the correct enum type for endpoint narrowing and linking.
*/
public static String resolveMachineEventTypeFqn(
String machineConfigName,
String embeddedEventTypeFqn,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Transition> machineTransitions,
CodebaseContext context) {
if (machineTypes != null && machineTypes.eventTypeFqn() != null && !machineTypes.eventTypeFqn().isBlank()) {
return machineTypes.eventTypeFqn();
}
if (embeddedEventTypeFqn != null && !embeddedEventTypeFqn.isBlank()) {
return embeddedEventTypeFqn;
}
if (context != null && machineConfigName != null) {
String[] types = StateMachineTypeResolver.resolve(machineConfigName, context);
if (types != null && types.length > 1 && types[1] != null && !types[1].isBlank()) {
return types[1];
}
}
return eventTypeFromTransitions(machineTransitions);
}
public static StateMachineTypeResolver.MachineTypes resolveEffectiveMachineTypes(
String machineConfigName,
String embeddedStateTypeFqn,
String embeddedEventTypeFqn,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Transition> machineTransitions,
CodebaseContext context) {
String eventTypeFqn = resolveMachineEventTypeFqn(
machineConfigName, embeddedEventTypeFqn, machineTypes, machineTransitions, context);
String stateTypeFqn = machineTypes != null && machineTypes.stateTypeFqn() != null
? machineTypes.stateTypeFqn()
: embeddedStateTypeFqn;
if ((stateTypeFqn == null || stateTypeFqn.isBlank()) && context != null && machineConfigName != null) {
String[] types = StateMachineTypeResolver.resolve(machineConfigName, context);
if (types != null && types.length > 0 && types[0] != null && !types[0].isBlank()) {
stateTypeFqn = types[0];
}
}
if ((stateTypeFqn == null || stateTypeFqn.isBlank()) && machineTransitions != null) {
stateTypeFqn = stateTypeFromTransitions(machineTransitions);
}
return new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn);
}
private static String eventTypeFromTransitions(List<Transition> machineTransitions) {
if (machineTransitions == null) {
return null;
}
for (Transition transition : machineTransitions) {
Event event = transition.getEvent();
if (event == null) {
continue;
}
String fullIdentifier = event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
if (fullIdentifier != null && fullIdentifier.contains(".")) {
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
}
}
return null;
}
private static String stateTypeFromTransitions(List<Transition> machineTransitions) {
if (machineTransitions == null) {
return null;
}
for (Transition transition : machineTransitions) {
if (transition.getSourceStates() == null) {
continue;
}
for (var state : transition.getSourceStates()) {
String fullIdentifier = state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName();
if (fullIdentifier != null && fullIdentifier.contains(".")) {
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
}
}
}
return null;
}
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint trigger) {
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, null, null);
}
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(
TriggerPoint trigger,
String machineEventTypeFqn) {
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, null);
}
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(
TriggerPoint trigger,
String machineEventTypeFqn,
CodebaseContext context) {
if (trigger == null || trigger.isExternal() || !trigger.isAmbiguous()) {
return false;
}
String eventTypeFqn = trigger.getEventTypeFqn();
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
eventTypeFqn = machineEventTypeFqn;
}
List<String> concrete = concretePolymorphicCandidates(
trigger.getPolymorphicEvents(), eventTypeFqn, context);
if (concrete.size() <= 1) {
return false;
}
String event = trigger.getEvent();
if (event != null && event.startsWith("ENUM_SET:")) {
return true;
}
return MachineEnumCanonicalizer.isDynamicTriggerExpression(event);
}
/**
* True when call-graph evidence resolves to a single concrete machine enum event for this chain.
* Dispatcher/rich-event paths should narrow to one constant per endpoint before linking.
*/
public static boolean isEndpointNarrowPolyEvidence(
TriggerPoint trigger,
String machineEventTypeFqn,
CodebaseContext context) {
if (trigger == null) {
return false;
}
String eventTypeFqn = trigger.getEventTypeFqn();
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
eventTypeFqn = machineEventTypeFqn;
}
List<String> concrete = concretePolymorphicCandidates(
trigger.getPolymorphicEvents(), eventTypeFqn, context);
return concrete.size() == 1;
}
public static boolean isEndpointNarrowPolyEvidence(TriggerPoint trigger) {
return isEndpointNarrowPolyEvidence(trigger, null, null);
}
public static boolean isEndpointNarrowPolyEvidence(
TriggerPoint trigger,
String machineEventTypeFqn) {
return isEndpointNarrowPolyEvidence(trigger, machineEventTypeFqn, null);
}
/**
* True when every concrete polymorphic candidate belongs to the trigger's declared event type.
* Used to allow machine-scoped symbolic expansion for generic dispatchers while rejecting
* cross-package or unqualified widens.
*/
static boolean isTrustedEnumPolymorphicWiden(TriggerPoint trigger) {
return isTrustedEnumPolymorphicWiden(trigger, null, null);
}
static boolean isTrustedEnumPolymorphicWiden(TriggerPoint trigger, String machineEventTypeFqn) {
return isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, null);
}
static boolean isTrustedEnumPolymorphicWiden(
TriggerPoint trigger,
String machineEventTypeFqn,
CodebaseContext context) {
if (trigger == null) {
return false;
}
String eventTypeFqn = trigger.getEventTypeFqn();
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
eventTypeFqn = machineEventTypeFqn;
}
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
return false;
}
List<String> poly = trigger.getPolymorphicEvents();
List<String> concrete = concretePolymorphicCandidates(poly, eventTypeFqn, context);
if (concrete.size() != 1) {
return false;
}
return MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(
concrete.get(0), eventTypeFqn, context);
}
static List<String> concretePolymorphicCandidates(
List<String> poly,
String eventTypeFqn,
CodebaseContext context) {
if (poly == null || poly.isEmpty()) {
return List.of();
}
List<String> concrete = new ArrayList<>();
for (String pe : poly) {
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:")) {
continue;
}
String qualified = pe;
if (!pe.contains(".")) {
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
continue;
}
qualified = eventTypeFqn + "." + pe;
} else if (eventTypeFqn != null && !eventTypeFqn.isBlank()) {
qualified = MachineEnumCanonicalizer.canonicalizeLabel(pe, eventTypeFqn, context);
}
if (qualified != null && qualified.contains(".")) {
concrete.add(qualified);
}
}
return concrete;
}
public static LinkResolution resolveLinkResolution(
TriggerPoint trigger,
List<MatchedTransition> matched,
boolean ambiguousSource) {
return resolveLinkResolution(trigger, matched, ambiguousSource, null, null);
}
public static LinkResolution resolveLinkResolution(
TriggerPoint trigger,
List<MatchedTransition> matched,
boolean ambiguousSource,
String machineEventTypeFqn) {
return resolveLinkResolution(trigger, matched, ambiguousSource, machineEventTypeFqn, null);
}
public static LinkResolution resolveLinkResolution(
TriggerPoint trigger,
List<MatchedTransition> matched,
boolean ambiguousSource,
String machineEventTypeFqn,
CodebaseContext context) {
if (trigger != null && trigger.isExternal()) {
return LinkResolution.UNRESOLVED_EXTERNAL;
}
if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) {
return LinkResolution.AMBIGUOUS_WIDEN;
}
if (ambiguousSource) {
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,543 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.enricher.path.SymbolicPathValueEstimator;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Narrows wide call-graph polymorphic widens to endpoint-specific enum constants.
*
* <p>Uses multiple independent evidence sources (REST path, command keys, constraints, static trigger
* events, method-chain literals, camelCase method names) and only narrows when evidence converges.
*/
public final class CallChainPolyNarrower {
private static final int STRONG = 3;
private static final int MEDIUM = 2;
private static final int WEAK = 1;
private static final Pattern QUOTED_EQUALS_PARAM = Pattern.compile(
"\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)");
private static final Pattern PARAM_EQUALS_LITERAL = Pattern.compile(
"(\\w+)\\.equals(?:IgnoreCase)?\\(\"([^\"]+)\"\\)");
private static final Pattern ENUM_EQ = Pattern.compile(
"([A-Za-z_][\\w.]*)\\s*==\\s*([A-Za-z_][\\w.]*)");
private static final Pattern ROUTE_LITERAL = Pattern.compile(
"route\\(\\s*\"([^\"]+)\"\\s*,\\s*\"([^\"]+)\"\\s*\\)");
private static final Set<String> FIRE_METHOD_NAMES = Set.of(
"sendEvent", "fire", "fireEvent", "dispatchEvent", "send", "trigger");
private CallChainPolyNarrower() {
}
public static TriggerPoint narrowForCallChain(
TriggerPoint trigger,
CallChain chain,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Transition> machineTransitions,
CodebaseContext context) {
return narrowForCallChain(trigger, chain, machineTypes, null, machineTransitions, context);
}
public static TriggerPoint narrowForCallChain(
TriggerPoint trigger,
CallChain chain,
StateMachineTypeResolver.MachineTypes machineTypes,
String embeddedEventTypeFqn,
List<Transition> machineTransitions,
CodebaseContext context) {
if (trigger == null) {
return trigger;
}
List<String> poly = trigger.getPolymorphicEvents();
if (poly == null || poly.size() <= 1) {
return trigger;
}
String eventTypeFqn = CallChainLinkPolicy.resolveMachineEventTypeFqn(
null,
embeddedEventTypeFqn != null ? embeddedEventTypeFqn : trigger.getEventTypeFqn(),
machineTypes,
machineTransitions,
context);
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
return trigger;
}
Set<String> configuredConstants = configuredEventConstants(machineTransitions, eventTypeFqn, context);
if (configuredConstants.isEmpty()) {
return trigger;
}
HintVotes votes = new HintVotes();
collectHints(trigger, chain, configuredConstants, context, votes);
String chosen = votes.choose(configuredConstants);
if (chosen == null) {
return trigger;
}
List<String> narrowed = filterPolyToConstant(poly, chosen, eventTypeFqn, context);
if (narrowed.size() == 1) {
return trigger.toBuilder()
.polymorphicEvents(narrowed)
.ambiguous(false)
.build();
}
return trigger.toBuilder()
.polymorphicEvents(List.of(eventTypeFqn + "." + chosen))
.ambiguous(false)
.build();
}
private static void collectHints(
TriggerPoint trigger,
CallChain chain,
Set<String> configuredConstants,
CodebaseContext context,
HintVotes votes) {
collectFromStaticTriggerEvent(trigger, configuredConstants, votes);
collectFromEntryPoint(chain != null ? chain.getEntryPoint() : null, configuredConstants, votes);
collectFromConstraint(trigger.getConstraint(), configuredConstants, votes);
if (chain != null) {
collectFromMethodChain(chain.getMethodChain(), configuredConstants, votes);
collectFromSendEventLiterals(chain.getMethodChain(), configuredConstants, context, votes);
collectFromPathEstimator(chain, configuredConstants, context, votes);
}
}
private static void collectFromStaticTriggerEvent(
TriggerPoint trigger,
Set<String> configuredConstants,
HintVotes votes) {
String event = trigger.getEvent();
if (event == null || event.isBlank()) {
return;
}
if (MachineEnumCanonicalizer.isDynamicTriggerExpression(event)) {
return;
}
addEnumReference(event, configuredConstants, votes, STRONG);
}
private static void collectFromEntryPoint(
EntryPoint entryPoint,
Set<String> configuredConstants,
HintVotes votes) {
if (entryPoint == null) {
return;
}
if (entryPoint.getName() != null) {
collectFromPath(entryPoint.getName(), configuredConstants, votes);
}
if (entryPoint.getMetadata() != null) {
Object path = entryPoint.getMetadata().get("path");
if (path instanceof String pathValue) {
collectFromPath(pathValue, configuredConstants, votes);
}
}
if (entryPoint.getMethodName() != null) {
addUniqueIdentifierMatch(entryPoint.getMethodName(), configuredConstants, votes, WEAK);
}
}
private static void collectFromPath(String rawPath, Set<String> configuredConstants, HintVotes votes) {
if (rawPath == null || rawPath.isBlank()) {
return;
}
int space = rawPath.indexOf(' ');
String path = space >= 0 ? rawPath.substring(space + 1).trim() : rawPath.trim();
if (path.isBlank()) {
return;
}
for (String segment : path.split("/")) {
if (segment.isBlank() || segment.contains("{")) {
continue;
}
addPathToken(segment, configuredConstants, votes, STRONG);
}
}
private static void collectFromConstraint(
String constraint,
Set<String> configuredConstants,
HintVotes votes) {
if (constraint == null || constraint.isBlank()) {
return;
}
Matcher quoted = QUOTED_EQUALS_PARAM.matcher(constraint);
while (quoted.find()) {
String literal = quoted.group(1);
String param = quoted.group(2);
if ("event".equals(param) || "eventString".equals(param) || "actionKey".equals(param)
|| "action".equals(param)) {
addPathToken(literal, configuredConstants, votes, MEDIUM);
}
if ("commandKey".equals(param) || literal.contains(".")) {
addPathToken(literal, configuredConstants, votes, MEDIUM);
}
}
Matcher route = ROUTE_LITERAL.matcher(constraint);
while (route.find()) {
addPathToken(route.group(2), configuredConstants, votes, MEDIUM);
}
Matcher enumEq = ENUM_EQ.matcher(constraint);
while (enumEq.find()) {
addEnumReference(enumEq.group(1), configuredConstants, votes, MEDIUM);
addEnumReference(enumEq.group(2), configuredConstants, votes, MEDIUM);
}
Matcher reverseEquals = PARAM_EQUALS_LITERAL.matcher(constraint);
while (reverseEquals.find()) {
String param = reverseEquals.group(1);
String literal = reverseEquals.group(2);
if ("event".equals(param) || "eventString".equals(param) || "actionKey".equals(param)
|| "action".equals(param) || "commandKey".equals(param)) {
addPathToken(literal, configuredConstants, votes, MEDIUM);
}
}
}
private static void collectFromMethodChain(
List<String> methodChain,
Set<String> configuredConstants,
HintVotes votes) {
if (methodChain == null) {
return;
}
for (String methodFqn : methodChain) {
if (methodFqn == null || !methodFqn.contains(".")) {
continue;
}
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
addUniqueIdentifierMatch(methodName, configuredConstants, votes, WEAK);
for (String token : splitCamelCase(methodName)) {
addPathToken(token, configuredConstants, votes, WEAK);
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
int nestedDot = className.lastIndexOf('.');
if (nestedDot >= 0) {
addUniqueIdentifierMatch(className.substring(nestedDot + 1), configuredConstants, votes, WEAK);
}
}
}
private static void collectFromSendEventLiterals(
List<String> methodChain,
Set<String> configuredConstants,
CodebaseContext context,
HintVotes votes) {
if (context == null || methodChain == null) {
return;
}
ConstantResolver resolver = new ConstantResolver();
for (String methodFqn : methodChain) {
if (methodFqn == null || !methodFqn.contains(".")) {
continue;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration == null) {
continue;
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null) {
continue;
}
for (Object parameter : methodDeclaration.parameters()) {
if (parameter instanceof SingleVariableDeclaration variable) {
addUniqueIdentifierMatch(variable.getType().toString(), configuredConstants, votes, MEDIUM);
}
}
if (methodDeclaration.getBody() == null) {
continue;
}
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (!FIRE_METHOD_NAMES.contains(node.getName().getIdentifier())) {
return super.visit(node);
}
for (Object argument : node.arguments()) {
if (!(argument instanceof Expression expression)) {
continue;
}
if (expression instanceof QualifiedName qualifiedName) {
addEnumReference(qualifiedName.getFullyQualifiedName(), configuredConstants, votes, STRONG);
} else if (expression instanceof SimpleName simpleName) {
addEnumReference(simpleName.getIdentifier(), configuredConstants, votes, STRONG);
} else {
String resolved = resolver.resolve(expression, context);
if (resolved != null) {
addEnumReference(resolved, configuredConstants, votes, STRONG);
}
}
}
return super.visit(node);
}
});
}
}
private static void collectFromPathEstimator(
CallChain chain,
Set<String> configuredConstants,
CodebaseContext context,
HintVotes votes) {
if (context == null) {
return;
}
List<String> estimated = new SymbolicPathValueEstimator().estimatePossibleValues(chain, context);
if (estimated == null || estimated.isEmpty()) {
return;
}
int weight = estimated.size() == 1 ? MEDIUM : WEAK;
for (String value : estimated) {
addEnumReference(value, configuredConstants, votes, weight);
}
}
private static void addPathToken(
String token,
Set<String> configuredConstants,
HintVotes votes,
int weight) {
if (token == null || token.isBlank()) {
return;
}
addPathTokenVariants(token, configuredConstants, votes, weight);
if (token.contains(".")) {
addPathTokenVariants(token.substring(token.lastIndexOf('.') + 1), configuredConstants, votes, weight);
}
}
private static void addPathTokenVariants(
String token,
Set<String> configuredConstants,
HintVotes votes,
int weight) {
maybeVoteConstant(votes, normalizeToken(token), configuredConstants, weight);
for (String part : token.split("[-_.]")) {
if (!part.isBlank()) {
maybeVoteConstant(votes, normalizeToken(part), configuredConstants, weight);
}
}
if (token.toUpperCase(Locale.ROOT).contains("_")) {
String tail = token.substring(token.lastIndexOf('_') + 1);
maybeVoteConstant(votes, normalizeToken(tail), configuredConstants, weight);
}
}
private static void addEnumReference(
String reference,
Set<String> configuredConstants,
HintVotes votes,
int weight) {
if (reference == null || reference.isBlank()) {
return;
}
maybeVoteConstant(votes, constantName(reference), configuredConstants, weight);
if (reference.contains(".")) {
maybeVoteConstant(votes, constantName(reference), configuredConstants, weight);
}
String upper = reference.toUpperCase(Locale.ROOT);
if (upper.contains("_")) {
maybeVoteConstant(votes, upper.substring(upper.lastIndexOf('_') + 1), configuredConstants, weight);
}
}
private static void addUniqueIdentifierMatch(
String identifier,
Set<String> configuredConstants,
HintVotes votes,
int weight) {
if (identifier == null || identifier.isBlank()) {
return;
}
String upper = identifier.toUpperCase(Locale.ROOT);
List<String> matched = new ArrayList<>();
for (String constant : configuredConstants) {
if (upper.contains(constant)) {
matched.add(constant);
}
}
if (matched.size() == 1) {
votes.add(matched.get(0), weight);
}
}
private static void maybeVoteConstant(
HintVotes votes,
String candidate,
Set<String> configuredConstants,
int weight) {
if (candidate == null || candidate.isBlank()) {
return;
}
String normalized = candidate.toUpperCase(Locale.ROOT);
if (configuredConstants.contains(normalized)) {
votes.add(normalized, weight);
}
}
private static List<String> filterPolyToConstant(
List<String> poly,
String constant,
String eventTypeFqn,
CodebaseContext context) {
List<String> narrowed = new ArrayList<>();
for (String candidate : poly) {
if (candidate == null || candidate.startsWith("<SYMBOLIC:") || candidate.startsWith("ENUM_SET:")) {
continue;
}
if (!constant.equalsIgnoreCase(constantName(candidate))) {
continue;
}
String canonical = candidate.contains(".")
? MachineEnumCanonicalizer.canonicalizeLabel(candidate, eventTypeFqn, context)
: eventTypeFqn + "." + constant;
if (!narrowed.contains(canonical)) {
narrowed.add(canonical);
}
}
return narrowed;
}
private static String normalizeToken(String token) {
return token.replace('-', '_').toUpperCase(Locale.ROOT);
}
private static List<String> splitCamelCase(String identifier) {
if (identifier == null || identifier.isBlank()) {
return List.of();
}
List<String> tokens = new ArrayList<>();
StringBuilder current = new StringBuilder();
for (int i = 0; i < identifier.length(); i++) {
char ch = identifier.charAt(i);
if (Character.isUpperCase(ch) && !current.isEmpty()) {
tokens.add(current.toString());
current.setLength(0);
}
current.append(ch);
}
if (!current.isEmpty()) {
tokens.add(current.toString());
}
return tokens;
}
private static Set<String> configuredEventConstants(
List<Transition> transitions,
String eventTypeFqn,
CodebaseContext context) {
Set<String> constants = new LinkedHashSet<>();
for (String event : MachineEnumCanonicalizer.polymorphicEventsFromTransitions(
transitions, eventTypeFqn, context)) {
constants.add(constantName(event).toUpperCase(Locale.ROOT));
}
return constants;
}
private static String constantName(String ref) {
int dot = ref.lastIndexOf('.');
return (dot >= 0 ? ref.substring(dot + 1) : ref).toUpperCase(Locale.ROOT);
}
private static final class HintVotes {
private final Map<String, Integer> scores = new HashMap<>();
private final Map<String, Integer> maxWeight = new HashMap<>();
void add(String constant, int weight) {
if (constant == null || constant.isBlank()) {
return;
}
String key = constant.toUpperCase(Locale.ROOT);
scores.merge(key, weight, Integer::sum);
maxWeight.merge(key, weight, Math::max);
}
String choose(Set<String> configured) {
String strongUnique = uniqueAboveWeight(configured, STRONG);
if (strongUnique != null) {
return strongUnique;
}
String mediumUnique = uniqueAboveWeight(configured, MEDIUM);
if (mediumUnique != null) {
return mediumUnique;
}
String best = null;
int bestScore = 0;
for (String constant : configured) {
int score = scores.getOrDefault(constant, 0);
if (score > bestScore) {
bestScore = score;
best = constant;
}
}
if (best == null || bestScore < MEDIUM) {
return null;
}
final int winningScore = bestScore;
List<String> tied = new ArrayList<>();
for (String constant : configured) {
if (scores.getOrDefault(constant, 0) == winningScore) {
tied.add(constant);
}
}
if (tied.size() == 1) {
return best;
}
String maxWeightWinner = null;
int bestMaxWeight = 0;
for (String constant : tied) {
int weight = maxWeight.getOrDefault(constant, 0);
if (weight > bestMaxWeight) {
bestMaxWeight = weight;
maxWeightWinner = constant;
} else if (weight == bestMaxWeight && weight > 0) {
maxWeightWinner = null;
}
}
return maxWeightWinner;
}
private String uniqueAboveWeight(Set<String> configured, int minWeight) {
String chosen = null;
for (String constant : configured) {
if (maxWeight.getOrDefault(constant, 0) >= minWeight) {
if (chosen != null) {
return null;
}
chosen = constant;
}
}
return chosen;
}
}
}

View File

@@ -16,8 +16,9 @@ public class EntryPointEnricher implements AnalysisEnricher {
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) { public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with entry points", result.getName()); log.info("Enriching {} with entry points", result.getName());
// Keep all entry points; machine scoping is applied after call-chain resolution
// when source evidence (trigger type FQNs, literals, constraints) is available.
List<EntryPoint> entryPoints = intelligence.findEntryPoints(); List<EntryPoint> entryPoints = intelligence.findEntryPoints();
entryPoints = MachineScopeFilter.filterEntryPointsForMachine(entryPoints, result.getName(), context);
result.addMetadata(CodebaseMetadata.builder() result.addMetadata(CodebaseMetadata.builder()
.entryPoints(entryPoints) .entryPoints(entryPoints)

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

@@ -5,11 +5,13 @@ import click.kamil.springstatemachineexporter.analysis.enricher.routing.Heuristi
import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; 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.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
/** /**
* Filters codebase-wide analysis artifacts to those relevant for a single state machine config. * Filters codebase-wide analysis artifacts to those relevant for a single state machine config.
@@ -23,12 +25,20 @@ public final class MachineScopeFilter {
public static List<TriggerPoint> filterTriggersForMachine( public static List<TriggerPoint> filterTriggersForMachine(
List<TriggerPoint> triggers, String machineName, CodebaseContext context) { 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) { if (triggers == null || machineName == null) {
return triggers == null ? List.of() : triggers; return triggers == null ? List.of() : triggers;
} }
List<TriggerPoint> filtered = new ArrayList<>(); List<TriggerPoint> filtered = new ArrayList<>();
for (TriggerPoint trigger : triggers) { for (TriggerPoint trigger : triggers) {
if (isTriggerForMachine(trigger, machineName, context)) { if (isTriggerForMachine(trigger, machineName, context, machineTransitions)) {
filtered.add(trigger); filtered.add(trigger);
} }
} }
@@ -37,27 +47,68 @@ public final class MachineScopeFilter {
public static List<CallChain> filterCallChainsForMachine( public static List<CallChain> filterCallChainsForMachine(
List<CallChain> chains, String machineName, CodebaseContext context) { 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) { if (chains == null || machineName == null) {
return chains == null ? List.of() : chains; return chains == null ? List.of() : chains;
} }
List<CallChain> filtered = new ArrayList<>(); List<CallChain> filtered = new ArrayList<>();
String machineEventTypeFqn = resolveMachineEventTypeFqn(machineName, context, machineTransitions);
for (CallChain chain : chains) { for (CallChain chain : chains) {
if (ROUTING.isRoutedToCorrectMachine(chain, machineName, context)) { 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); filtered.add(chain);
} }
} }
return filtered; 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( public static List<EntryPoint> filterEntryPointsForMachine(
List<EntryPoint> entryPoints, String machineName, CodebaseContext context) { List<EntryPoint> entryPoints, String machineName, CodebaseContext context) {
if (entryPoints == null || machineName == null) { if (entryPoints == null || machineName == null) {
return entryPoints == null ? List.of() : entryPoints; return entryPoints == null ? List.of() : entryPoints;
} }
String machineDomain = extractMachineDomainKey(machineName);
List<EntryPoint> filtered = new ArrayList<>(); List<EntryPoint> filtered = new ArrayList<>();
for (EntryPoint entryPoint : entryPoints) { for (EntryPoint entryPoint : entryPoints) {
if (isEntryPointForMachine(entryPoint, machineDomain, machineName, context)) { if (isEntryPointForMachine(entryPoint, machineName, context)) {
filtered.add(entryPoint); filtered.add(entryPoint);
} }
} }
@@ -65,78 +116,33 @@ public final class MachineScopeFilter {
} }
private static boolean isEntryPointForMachine( private static boolean isEntryPointForMachine(
EntryPoint entryPoint, String machineDomain, String machineName, CodebaseContext context) { EntryPoint entryPoint, String machineName, CodebaseContext context) {
if (entryPoint == null) { if (entryPoint == null) {
return false; return false;
} }
String path = entryPoint.getMetadata() != null ? entryPoint.getMetadata().get("path") : null; if (EntryPointScopeResolver.hasPathVariablePlaceholder(entryPoint)) {
if (path != null) { EntryPointScopeResolver.Affinity affinity =
String pathDomain = inferDomainFromPath(path); EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
if (pathDomain != null && machineDomain != null && !domainsMatch(pathDomain, machineDomain)) { return affinity != EntryPointScopeResolver.Affinity.AGAINST_MACHINE;
return false;
} }
if (pathDomain == null && path.contains("{")) {
EntryPointScopeResolver.Affinity affinity =
EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
if (affinity == EntryPointScopeResolver.Affinity.FOR_MACHINE) {
return true; return true;
} }
if (affinity == EntryPointScopeResolver.Affinity.AGAINST_MACHINE) {
return false;
} }
CallChain probe = CallChain.builder() return false;
.entryPoint(entryPoint)
.methodChain(List.of(entryPoint.getClassName() + "." + entryPoint.getMethodName()))
.build();
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context);
} }
private static String inferDomainFromPath(String path) { private static boolean isTriggerForMachine(
String lower = path.toLowerCase(Locale.ROOT); TriggerPoint trigger, String machineName, CodebaseContext context, List<Transition> machineTransitions) {
if (lower.contains("/orders") || lower.contains("order.")) {
return "ORDER";
}
if (lower.contains("/documents") || lower.contains("document.")) {
return "DOCUMENT";
}
return null;
}
private static boolean domainsMatch(String pathDomain, String machineDomain) {
String normalizedPath = normalizeDomainKey(pathDomain);
String normalizedMachine = normalizeDomainKey(machineDomain);
return normalizedPath.equalsIgnoreCase(normalizedMachine)
|| normalizedMachine.startsWith(normalizedPath)
|| normalizedPath.startsWith(normalizedMachine);
}
private 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;
}
private static boolean isTriggerForMachine(TriggerPoint trigger, String machineName, CodebaseContext context) {
if (trigger == null) { if (trigger == null) {
return false; return false;
} }
CallChain probe = CallChain.builder().triggerPoint(trigger).methodChain(List.of()).build(); CallChain probe = CallChain.builder().triggerPoint(trigger).methodChain(List.of()).build();
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context); return ROUTING.hasProvenMachineAffinity(probe, machineName, context, machineTransitions);
} }
} }

View File

@@ -5,27 +5,33 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers; import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers;
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.State; import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition; import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.Map;
import java.util.Set;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine; import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine; import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine; import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine; import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator; 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 { public class TransitionLinkerEnricher implements AnalysisEnricher {
private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine(); private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
private final EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine();
@Override @Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) { public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
@@ -33,8 +39,25 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return; return;
} }
EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine(context);
List<CallChain> updatedChains = new ArrayList<>(); List<CallChain> updatedChains = new ArrayList<>();
List<Transition> stateMachineTransitions = result.getTransitions(); List<Transition> stateMachineTransitions = result.getTransitions();
StateMachineTypeResolver.MachineTypes machineTypes =
JsonExportContextFactory.resolveMachineTypes(
result.getName(),
result.getStateTypeFqn(),
result.getEventTypeFqn(),
context);
StateMachineTypeResolver.MachineTypes effectiveMachineTypes =
CallChainLinkPolicy.resolveEffectiveMachineTypes(
result.getName(),
result.getStateTypeFqn(),
result.getEventTypeFqn(),
machineTypes,
stateMachineTransitions,
context);
String effectiveEventTypeFqn = effectiveMachineTypes.eventTypeFqn();
for (CallChain chain : result.getMetadata().getCallChains()) { for (CallChain chain : result.getMetadata().getCallChains()) {
TriggerPoint tp = chain.getTriggerPoint(); TriggerPoint tp = chain.getTriggerPoint();
@@ -43,22 +66,104 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
continue; continue;
} }
tp = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints(
tp, effectiveMachineTypes, context, chain.getEntryPoint());
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
tp, effectiveMachineTypes, context, stateMachineTransitions, true);
tp = CallChainPolyNarrower.narrowForCallChain(
tp, chain, effectiveMachineTypes, result.getEventTypeFqn(), stateMachineTransitions, context);
tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, effectiveMachineTypes, context);
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
tp = tp.toBuilder()
.eventTypeFqn(tp.getEventTypeFqn() != null
? tp.getEventTypeFqn()
: effectiveEventTypeFqn)
.stateTypeFqn(tp.getStateTypeFqn() != null
? tp.getStateTypeFqn()
: effectiveMachineTypes.stateTypeFqn())
.build();
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) { if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
updatedChains.add(chain); updatedChains.add(chain);
continue; continue;
} }
String triggerEvent = simplify(tp.getEvent()); if (tp.isExternal() && isUnresolvedGenericRestEndpoint(chain.getEntryPoint())) {
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null; updatedChains.add(chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(null)
.linkResolution(LinkResolution.UNRESOLVED_EXTERNAL)
.build());
continue;
}
String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null;
if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
tp,
effectiveEventTypeFqn,
context)) {
updatedChains.add(chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(null)
.linkResolution(LinkResolution.AMBIGUOUS_WIDEN)
.build());
continue;
}
boolean ambiguousSource = false;
if (triggerSource == null) {
Map<String, Set<String>> sourcesByEvent = new LinkedHashMap<>();
Map<String, Set<String>> stateTypesByEvent = new LinkedHashMap<>();
Map<String, String> canonicalSourceBySimplified = new LinkedHashMap<>();
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)
&& isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp,
effectiveEventTypeFqn)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) {
String smSourceForLink = canonicalState(smSourceState);
String smSource = simplifySourceState(smSourceForLink);
sourcesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
.add(smSource);
stateTypesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
.add(extractStateEnumType(smSourceForLink));
canonicalSourceBySimplified.putIfAbsent(smSource, smSourceForLink);
}
}
}
boolean crossStateTypeConflict = stateTypesByEvent.values().stream()
.anyMatch(types -> types.size() > 1);
if (crossStateTypeConflict) {
ambiguousSource = true;
} else if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) {
ambiguousSource = !CallChainLinkPolicy.isEndpointNarrowPolyEvidence(
tp,
effectiveEventTypeFqn,
context);
} else {
Set<String> allDistinctSources = new LinkedHashSet<>();
sourcesByEvent.values().forEach(allDistinctSources::addAll);
if (allDistinctSources.size() == 1) {
String inferredSimplifiedSource = allDistinctSources.iterator().next();
String inferredCanonicalSource = canonicalSourceBySimplified.get(inferredSimplifiedSource);
tp = tp.toBuilder().sourceState(inferredCanonicalSource).build();
triggerSource = inferredSimplifiedSource;
}
}
}
List<MatchedTransition> matched = new ArrayList<>(); List<MatchedTransition> matched = new ArrayList<>();
if (!ambiguousSource) {
for (Transition t : stateMachineTransitions) { for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) { if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
String smEventForLink = canonicalEvent(t.getEvent()); String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) { for (State smSourceState : t.getSourceStates()) {
String smSourceForLink = canonicalState(smSourceState); String smSourceForLink = canonicalState(smSourceState);
String smSource = simplify(smSourceForLink); String smSource = simplifySourceState(smSourceForLink);
if (triggerSource == null || triggerSource.equals(smSource)) { if (triggerSource == null || triggerSource.equals(smSource)) {
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) { if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
MatchedTransition mt = MatchedTransition.builder() MatchedTransition mt = MatchedTransition.builder()
@@ -66,7 +171,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.targetState(smSourceForLink) .targetState(smSourceForLink)
.event(smEventForLink) .event(smEventForLink)
.build(); .build();
if (isRoutedToCorrectMachine(chain, result.getName(), context) if (isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp,
effectiveEventTypeFqn)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
@@ -78,7 +185,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.targetState(targetForLink) .targetState(targetForLink)
.event(smEventForLink) .event(smEventForLink)
.build(); .build();
if (isRoutedToCorrectMachine(chain, result.getName(), context) if (isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp,
effectiveEventTypeFqn)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
@@ -88,24 +197,46 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
} }
} }
} }
}
if (ambiguousSource) {
tp = tp.toBuilder().ambiguous(true).build();
}
LinkResolution linkResolution = CallChainLinkPolicy.resolveLinkResolution(
tp,
matched,
ambiguousSource,
effectiveEventTypeFqn,
context);
if (!matched.isEmpty()) { if (!matched.isEmpty()) {
CallChain newChain = chain.toBuilder().matchedTransitions(matched).build(); CallChain newChain = chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(matched)
.linkResolution(linkResolution)
.build();
updatedChains.add(newChain); updatedChains.add(newChain);
} else { } else {
updatedChains.add(chain); updatedChains.add(chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(null)
.linkResolution(linkResolution)
.build());
} }
} }
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine( List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
result.getMetadata().getTriggers(), result.getName(), context); result.getMetadata().getTriggers(), result.getName(), context, stateMachineTransitions);
List<EntryPoint> scopedEntryPoints = MachineScopeFilter.filterEntryPointsForMachine( List<CallChain> scopedChains = MachineScopeFilter.filterCallChainsForMachine(
result.getMetadata().getEntryPoints(), result.getName(), context); updatedChains, result.getName(), context, stateMachineTransitions);
List<EntryPoint> scopedEntryPoints = EntryPointScopeResolver.scopeFromCallChains(
scopedChains, result.getMetadata().getEntryPoints());
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder() CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
.triggers(scopedTriggers) .triggers(scopedTriggers)
.entryPoints(scopedEntryPoints) .entryPoints(scopedEntryPoints)
.callChains(updatedChains) .callChains(scopedChains)
.properties(result.getMetadata().getProperties()) .properties(result.getMetadata().getProperties())
.build(); .build();
@@ -113,55 +244,77 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
} }
private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) { private boolean isRoutedToCorrectMachine(
return routingEngine.isRoutedToCorrectMachine(chain, currentMachineName, context); 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) { private boolean isConstraintCompatible(String constraint, String machineName) {
if (constraint == null || machineName == null) { if (constraint == null || machineName == null) {
return true; return true;
} }
String machineConstraint = stripEventBindingClauses(constraint);
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain( return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
constraint, extractMachineDomainKey(machineName)); machineConstraint, MachineDomainKeys.extractMachineDomainKey(machineName));
} }
private String extractMachineDomainKey(String machineName) { private static String stripEventBindingClauses(String constraint) {
String simple = machineName.substring(machineName.lastIndexOf('.') + 1); if (constraint == null || constraint.isBlank()) {
String upper = simple.toUpperCase(); return constraint;
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) {
int idx = upper.indexOf(marker);
if (idx > 0) {
return upper.substring(0, idx);
} }
String stripped = constraint.replaceAll("\"[^\"]+\"\\.equalsIgnoreCase\\(event\\)", "true");
stripped = stripped.replaceAll("&&\\s*&&+", "&&");
stripped = stripped.replaceAll("^\\s*&&\\s*", "");
stripped = stripped.replaceAll("\\s*&&\\s*$", "");
return stripped.trim();
} }
int stateIdx = upper.indexOf("STATE"); private String extractStateEnumType(String stateIdentifier) {
if (stateIdx > 0) { if (stateIdentifier == null || stateIdentifier.isBlank()) {
return upper.substring(0, stateIdx); return stateIdentifier;
}
int lastDot = stateIdentifier.lastIndexOf('.');
if (lastDot <= 0) {
return stateIdentifier;
}
return stateIdentifier.substring(0, lastDot);
} }
return upper; private final java.util.Map<String, String> simplifySourceCache = new java.util.HashMap<>();
}
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>(); private String simplifySourceState(String name) {
private final java.util.Map<java.util.Map.Entry<String, String>, Boolean> guardedCache = new java.util.HashMap<>();
private static final java.util.regex.Pattern SUFFIX_PATTERN = java.util.regex.Pattern.compile("(?i)(.+)(Event|Action|Transition|Command)(s)?$");
private static final java.util.regex.Pattern FQN_PATTERN = java.util.regex.Pattern.compile("^.*\\.([A-Z0-9_]+)$");
private String simplify(String name) {
if (name == null) return null; if (name == null) return null;
if (simplifyCache.containsKey(name)) return simplifyCache.get(name); if (simplifySourceCache.containsKey(name)) return simplifySourceCache.get(name);
// Strip common suffixes // For source states we must preserve enum type context to avoid collisions between
String simplified = SUFFIX_PATTERN.matcher(name).replaceAll("$1"); // different state enums that share a constant name (e.g. OrderState.NEW vs InvoiceState.NEW).
if (simplified.isEmpty()) { // Use EnumFormat.fn-like suffix (Type.CONST) when possible.
simplified = name; String simplified = name;
int lastDot = name.lastIndexOf('.');
if (lastDot > 0) {
int prevDot = name.lastIndexOf('.', lastDot - 1);
if (prevDot > 0) {
simplified = name.substring(prevDot + 1);
}
} }
// Simplify full identifiers to just the last part (enum name)
simplified = FQN_PATTERN.matcher(simplified).replaceAll("$1");
simplifyCache.put(name, simplified); simplifySourceCache.put(name, simplified);
return simplified; return simplified;
} }
@@ -179,41 +332,19 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(); return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
} }
private boolean isGuardedContains(String smEvent, String triggerEvent) { private static boolean isUnresolvedGenericRestEndpoint(EntryPoint entryPoint) {
if (smEvent == null || triggerEvent == null) return false; if (entryPoint == null) {
// Use a strictly immutable pair to guarantee 100% collision-free caching regardless of string content
java.util.Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(smEvent, triggerEvent);
if (guardedCache.containsKey(cacheKey)) return guardedCache.get(cacheKey);
boolean result = calculateGuardedContains(smEvent, triggerEvent);
guardedCache.put(cacheKey, result);
return result;
}
private boolean calculateGuardedContains(String smEvent, String triggerEvent) {
// Only fire if it looks like a simple identifier (no spaces, no parens)
// Prevents unresolved AST nodes (e.g. "event.getPayload()") from accidentally matching "Event"
if (smEvent.contains(" ") || smEvent.contains("(") || smEvent.contains(")") ||
triggerEvent.contains(" ") || triggerEvent.contains("(") || triggerEvent.contains(")")) {
return false; return false;
} }
if (entryPoint.getName() != null && entryPoint.getName().contains("{")) {
String shorter = smEvent.length() < triggerEvent.length() ? smEvent : triggerEvent;
String longer = smEvent.length() >= triggerEvent.length() ? smEvent : triggerEvent;
shorter = shorter.toLowerCase();
longer = longer.toLowerCase();
if (longer.contains(shorter)) {
// Guard: If the matching part is very short (< 4 chars), it must be bounded by word separators
// to avoid matching completely unrelated words (e.g., "PAY" matching "PAYMENT" or "E" matching "UPDATE")
if (shorter.length() < 4) {
return longer.matches(".*(^|_|-)" + java.util.regex.Pattern.quote(shorter) + "(_|-|$).*");
}
// For longer strings, a straight contains is usually safe enough
return true; return true;
} }
if (entryPoint.getMetadata() != null) {
Object path = entryPoint.getMetadata().get("path");
if (path instanceof String pathValue && pathValue.contains("{")) {
return true;
}
}
return false; return false;
} }

View File

@@ -7,6 +7,7 @@ import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -22,16 +23,20 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
@Override @Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) { public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || context == null) { if (result.getMetadata() == null) {
return; return;
} }
StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.MachineTypes machineTypes = JsonExportContextFactory.resolveMachineTypes(
StateMachineTypeResolver.resolveTypes(result.getName(), context); result.getName(), result.getStateTypeFqn(), result.getEventTypeFqn(), context);
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) {
return;
}
List<TriggerPoint> triggers = result.getMetadata().getTriggers(); List<TriggerPoint> triggers = result.getMetadata().getTriggers();
List<TriggerPoint> canonicalTriggers = triggers == null ? null : triggers.stream() List<TriggerPoint> canonicalTriggers = triggers == null ? null : triggers.stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes)) .map(trigger -> MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
trigger, machineTypes, context))
.collect(Collectors.toList()); .collect(Collectors.toList());
List<CallChain> callChains = result.getMetadata().getCallChains(); List<CallChain> callChains = result.getMetadata().getCallChains();
@@ -40,10 +45,14 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
if (chain.getTriggerPoint() == null) { if (chain.getTriggerPoint() == null) {
return chain; return chain;
} }
return chain.toBuilder() TriggerPoint expanded = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
.triggerPoint(MachineEnumCanonicalizer.canonicalizeTriggerPoint( chain.getTriggerPoint(), machineTypes, context, result.getTransitions(), true);
chain.getTriggerPoint(), machineTypes)) TriggerPoint canonical = expanded.getEventTypeFqn() == null && expanded.getStateTypeFqn() == null
.build(); ? MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(
expanded, machineTypes, context)
: MachineEnumCanonicalizer.canonicalizeTriggerPoint(
expanded, machineTypes, context);
return chain.toBuilder().triggerPoint(canonical).build();
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());

View File

@@ -1,11 +1,23 @@
package click.kamil.springstatemachineexporter.analysis.enricher.matching; package click.kamil.springstatemachineexporter.analysis.enricher.matching;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event; import click.kamil.springstatemachineexporter.model.Event;
import java.util.List; import java.util.List;
public class StrictFqnMatchingEngine implements EventMatchingEngine { public class StrictFqnMatchingEngine implements EventMatchingEngine {
private final CodebaseContext context;
public StrictFqnMatchingEngine() {
this(null);
}
public StrictFqnMatchingEngine(CodebaseContext context) {
this.context = context;
}
@Override @Override
public boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint) { public boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint) {
if (stateMachineEvent == null || triggerPoint == null || triggerPoint.getEvent() == null) { if (stateMachineEvent == null || triggerPoint == null || triggerPoint.getEvent() == null) {
@@ -51,7 +63,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null
&& !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) { && !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.')); String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
return enumTypesMatch(triggerPoint.getEventTypeFqn(), smEnumType); return typesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
} }
} }
} }
@@ -61,16 +73,18 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
} }
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) { if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
if (triggerPoint.getEventTypeFqn() != null) { if (triggerPoint.getEventTypeFqn() != null && !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
if (smEventRaw.startsWith(triggerPoint.getEventTypeFqn() + ".")) {
return true;
}
if (isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn()) && !smEventRaw.contains(".")) {
return true;
}
return false; return false;
} }
return true; // 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()); return matchEventNames(rawTriggerEvent, smEventRaw, triggerPoint.getEventTypeFqn());
@@ -85,9 +99,10 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
if (!tConst.equals(smConst)) return false; if (!tConst.equals(smConst)) return false;
// Prevent matching string/primitive triggers to enum transitions // String-typed triggers may match configured string transition events by constant name.
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) { if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
return false; String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
return isStringTypeOrPrimitive(smEnumType);
} }
// Prevent matching enum triggers to string transitions // Prevent matching enum triggers to string transitions
@@ -99,12 +114,12 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.')); String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
if (eventTypeFqn != null) { if (eventTypeFqn != null) {
return enumTypesMatch(eventTypeFqn, smEnumType); return typesMatch(eventTypeFqn, smEnumType);
} }
if (triggerEvent.contains(".")) { if (triggerEvent.contains(".")) {
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.')); String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
return enumTypesMatch(triggerEnumType, smEnumType); return typesMatch(triggerEnumType, smEnumType);
} }
// Bare constant name (e.g. after Enum.name()) with no type context must not match every enum // Bare constant name (e.g. after Enum.name()) with no type context must not match every enum
@@ -115,28 +130,22 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
return !triggerEvent.contains("."); 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) { private String constantName(String event) {
return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event; return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event;
} }
private boolean enumTypesMatch(String type1, String type2) {
if (type1 == null || type2 == null) return false;
if (type1.equals(type2)) return true;
if (type1.endsWith("." + type2) || type2.endsWith("." + type1)) return true;
String simple1 = simpleName(type1);
String simple2 = simpleName(type2);
if (!simple1.equals(simple2)) return false;
// Same simple name only matches when at least one side is package-less (import-style reference).
// Prevents com.foo.OrderEvents from matching com.bar.OrderEvents.
return !type1.contains(".") || !type2.contains(".");
}
private String simpleName(String name) {
return name.contains(".") ? name.substring(name.lastIndexOf('.') + 1) : name;
}
private boolean isStringTypeOrPrimitive(String typeFqn) { private boolean isStringTypeOrPrimitive(String typeFqn) {
if (typeFqn == null) return false; if (typeFqn == null) return false;
return typeFqn.equals("String") || typeFqn.equals("java.lang.String") || return typeFqn.equals("String") || typeFqn.equals("java.lang.String") ||

View File

@@ -2,8 +2,19 @@ package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.List;
public interface BeanResolutionEngine { public interface BeanResolutionEngine {
boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context); 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

@@ -1,121 +1,261 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing; 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.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import org.eclipse.jdt.core.dom.TypeDeclaration; import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { 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 @Override
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) { public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
// Precise FQN Type argument match MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
if (chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
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 triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn();
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn(); String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn();
if (triggerEventFqn != null || triggerStateFqn != null) { if (triggerEventFqn != null || triggerStateFqn != null) {
String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context); String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context);
String machineStateFqn = machineTypes[0]; String machineStateFqn = machineTypes[0];
String machineEventFqn = machineTypes[1]; String machineEventFqn = machineTypes[1];
boolean matched = false;
boolean mismatched = false;
if (triggerEventFqn != null && machineEventFqn != null) { if (triggerEventFqn != null && machineEventFqn != null) {
if (eraseGenerics(triggerEventFqn).equals(eraseGenerics(machineEventFqn))) { if (typesEquivalent(triggerEventFqn, machineEventFqn)) {
return true; matched = true;
} else if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
mismatched = true;
} else if (isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) {
matched = true;
} }
if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
return false;
}
return false;
} }
if (triggerStateFqn != null && machineStateFqn != null) { if (triggerStateFqn != null && machineStateFqn != null) {
if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) { if (typesEquivalent(triggerStateFqn, machineStateFqn)) {
return true; // Match! matched = true;
} } else if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
if (isTypeMismatched(triggerStateFqn, machineStateFqn)) { mismatched = true;
return false; // Mismatch! } else if (isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) {
matched = true;
} }
} }
} if (matched) {
} if (!isAmbiguousSharedGenericMatch(
triggerEventFqn, triggerStateFqn, machineStateFqn, machineEventFqn)
String targetVar = chain.getContextMachineId(); || isSingleStateMachine(context)) {
if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId();
}
String simplifiedMachineName = currentMachineName != null ? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase() : "";
if (targetVar != null && !targetVar.isEmpty()) {
targetVar = targetVar.toLowerCase();
if (targetVar.endsWith("statemachine")) {
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
if (!prefix.isEmpty()) {
if (simplifiedMachineName.contains(prefix)) {
return true; // Explicit positive match
} else {
return false; // Explicit negative match
}
}
} else if (simplifiedMachineName.contains(targetVar)) {
return true; return true;
} }
} }
if (mismatched) {
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) { return false;
String smPackage = getPackageName(currentMachineName);
String smSimple = getSimpleClassName(currentMachineName);
String smPrefix = getFirstCamelCaseWord(smSimple);
boolean hasPositiveMatch = false;
boolean hasStrongMismatch = false;
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
String chainClass = chain.getTriggerPoint().getClassName();
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
} }
// Fail closed only when both trigger and machine generic types were resolved but
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) { // could not be reconciled. When machine config AST does not expose type args (stub
hasPositiveMatch = true; // config classes in tests, generated configs), fall through to single-SM / package routing.
} boolean machineTypesKnown = machineEventFqn != null || machineStateFqn != null;
} if (machineTypesKnown) {
for (String method : chain.getMethodChain()) {
String chainClass = getClassNameOnly(method);
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix, smSimple, chainSimple)) {
hasStrongMismatch = true;
}
}
if (hasPositiveMatch) {
return true;
}
if (hasStrongMismatch) {
return false; return 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); 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) { private int countStateMachines(CodebaseContext context) {
if (context == null) { if (context == null) {
return 0; return 0;
@@ -146,160 +286,6 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
java.util.Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory")).size(); java.util.Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory")).size();
} }
private boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
return false;
}
if (smPackage.equals(chainPackage)) {
return true;
}
if (!chainPackage.startsWith(smPackage + ".") && !smPackage.startsWith(chainPackage + ".")) {
return false;
}
// Find deepest common package root
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
int matchIdx = -1;
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
if (p1[i].equals(p2[i])) {
matchIdx = i;
} else {
break;
}
}
// If the state machine's prefix is part of the shared common package,
// then the state machine represents the parent domain of the caller.
if (smPrefix != null && matchIdx >= 0) {
String lowerPrefix = smPrefix.toLowerCase();
for (int i = 0; i <= matchIdx; i++) {
if (p1[i].toLowerCase().contains(lowerPrefix) || lowerPrefix.contains(p1[i].toLowerCase())) {
return true;
}
}
}
return false;
}
private boolean isDomainMismatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix, String smSimple, String chainSimple) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
if (smPrefix == null || chainPrefix == null) return false;
String smLower = smPrefix.toLowerCase();
String chainLower = chainPrefix.toLowerCase();
// Ignore generic/technical class prefixes
if (smLower.equals("state") || smLower.equals("statemachine") || smLower.equals("config") || smLower.equals("configuration") || smLower.equals("adapter") ||
smLower.equals("enterprise") || smLower.equals("app") || smLower.equals("global") || smLower.equals("core") || smLower.equals("project") || smLower.equals("main") || smLower.equals("base") || smLower.equals("common") || smLower.equals("shared") ||
chainLower.equals("state") || chainLower.equals("statemachine") || chainLower.equals("config") || chainLower.equals("configuration") || chainLower.equals("adapter") ||
chainLower.equals("enterprise") || chainLower.equals("app") || chainLower.equals("global") || chainLower.equals("core") || chainLower.equals("project") || chainLower.equals("main") || chainLower.equals("base") || chainLower.equals("common") || chainLower.equals("shared")) {
return false;
}
// If class prefixes diverge, check if they are from different domains
if (!smLower.equals(chainLower)) {
// If one prefix is present as a domain/feature segment in the other package, they are related
if (chainPackage.toLowerCase().contains(smLower) || smPackage.toLowerCase().contains(chainLower)) {
return false;
}
// Otherwise, if they diverge under a shared project package root, it is a mismatch
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
int matchIdx = -1;
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
if (p1[i].equals(p2[i])) {
matchIdx = i;
} else {
break;
}
}
if (matchIdx >= 1) {
// If they diverge under a shared root, check if the divergence is only on generic package layers
boolean onlyGenericDivergence = true;
Set<String> genericLayers = Set.of(
"config", "configuration", "service", "services", "impl", "api", "web",
"controller", "controllers", "messaging", "listener", "listeners",
"repository", "repositories", "db", "model", "models", "domain",
"constants", "utils", "helper", "helpers", "event", "events",
"state", "states", "transition", "transitions"
);
for (int i = matchIdx + 1; i < p1.length; i++) {
if (!genericLayers.contains(p1[i].toLowerCase())) {
onlyGenericDivergence = false;
break;
}
}
if (onlyGenericDivergence) {
for (int i = matchIdx + 1; i < p2.length; i++) {
if (!genericLayers.contains(p2[i].toLowerCase())) {
onlyGenericDivergence = false;
break;
}
}
}
if (!onlyGenericDivergence) {
return true; // Mismatch under a shared root
}
}
}
return false;
}
private Set<String> extractDomainTerms(String pkg, String prefix) {
Set<String> terms = new HashSet<>();
if (pkg != null && !pkg.isEmpty()) {
String[] segments = pkg.split("\\.");
// Take segments from index 2 onwards if length > 2
int start = segments.length > 2 ? 2 : 0;
for (int i = start; i < segments.length; i++) {
terms.add(segments[i].toLowerCase());
}
}
if (prefix != null && !prefix.isEmpty()) {
terms.add(prefix.toLowerCase());
}
return terms;
}
private String getPackageName(String fqn) {
if (fqn == null || !fqn.contains(".")) return "";
return fqn.substring(0, fqn.lastIndexOf('.'));
}
private String getSimpleClassName(String fqn) {
if (fqn == null) return "";
if (!fqn.contains(".")) return fqn;
return fqn.substring(fqn.lastIndexOf('.') + 1);
}
private String getClassNameOnly(String methodFqn) {
if (methodFqn == null) return "";
String clean = methodFqn;
if (clean.contains("(")) {
clean = clean.substring(0, clean.indexOf('('));
}
if (clean.contains(".")) {
clean = clean.substring(0, clean.lastIndexOf('.'));
}
return clean;
}
private String getFirstCamelCaseWord(String simpleName) {
if (simpleName == null || simpleName.isEmpty()) return null;
String[] words = simpleName.split("(?<!^)(?=[A-Z])");
if (words.length > 0) {
return words[0];
}
return null;
}
private String eraseGenerics(String type) { private String eraseGenerics(String type) {
if (type == null) return null; if (type == null) return null;
int idx = type.indexOf('<'); int idx = type.indexOf('<');
@@ -309,6 +295,27 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return type; 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) { private boolean isTypeMismatched(String type1, String type2) {
if (type1 == null || type2 == null) return false; if (type1 == null || type2 == null) return false;
type1 = eraseGenerics(type1); type1 = eraseGenerics(type1);
@@ -319,6 +326,15 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return !type1.equals(type2); 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) { private boolean isSingleStateMachine(CodebaseContext context) {
return countStateMachines(context) <= 1; 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

@@ -22,6 +22,7 @@ public final class AccessorFieldResolver {
public static List<String> resolveFieldConstants( public static List<String> resolveFieldConstants(
AccessorSummary accessor, AccessorSummary accessor,
String requestingTypeFqn,
CodebaseContext context, CodebaseContext context,
ConstructorAnalyzer constructorAnalyzer, ConstructorAnalyzer constructorAnalyzer,
CompilationUnit contextCu, CompilationUnit contextCu,
@@ -35,11 +36,14 @@ public final class AccessorFieldResolver {
return List.of(); return List.of();
} }
String traceTypeFqn = requestingTypeFqn != null && !requestingTypeFqn.isBlank()
? requestingTypeFqn
: accessor.declaringFqn();
TypeDeclaration typeDeclaration = contextCu != null TypeDeclaration typeDeclaration = contextCu != null
? context.getTypeDeclaration(accessor.declaringFqn(), contextCu) ? context.getTypeDeclaration(traceTypeFqn, contextCu)
: null; : null;
if (typeDeclaration == null) { if (typeDeclaration == null) {
typeDeclaration = context.getTypeDeclaration(accessor.declaringFqn()); typeDeclaration = context.getTypeDeclaration(traceTypeFqn);
} }
if (typeDeclaration == null) { if (typeDeclaration == null) {
return List.of(); return List.of();
@@ -53,7 +57,7 @@ public final class AccessorFieldResolver {
if (constants.isEmpty()) { if (constants.isEmpty()) {
collectFieldInitializerConstants( collectFieldInitializerConstants(
typeDeclaration, accessor.fieldName(), constantExtractor, constants); typeDeclaration, accessor.fieldName(), constantExtractor, constants, context);
} }
return constants; return constants;
} }
@@ -62,8 +66,9 @@ public final class AccessorFieldResolver {
TypeDeclaration typeDeclaration, TypeDeclaration typeDeclaration,
String fieldName, String fieldName,
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor, BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor,
List<String> constants) { List<String> constants,
if (constantExtractor == null) { CodebaseContext context) {
if (constantExtractor == null || typeDeclaration == null) {
return; return;
} }
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) { for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
@@ -75,5 +80,14 @@ public final class AccessorFieldResolver {
} }
} }
} }
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,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,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

@@ -7,6 +7,9 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition; import click.kamil.springstatemachineexporter.model.Transition;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@@ -44,6 +47,10 @@ public class AnalysisResult {
private CodebaseMetadata metadata = CodebaseMetadata.empty(); private CodebaseMetadata metadata = CodebaseMetadata.empty();
public void applyResolution(Map<String, String> properties) { public void applyResolution(Map<String, String> properties) {
applyResolution(properties, null);
}
public void applyResolution(Map<String, String> properties, CodebaseContext context) {
var resolver = new click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver(); var resolver = new click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver();
// 1. Resolve start/end states strings // 1. Resolve start/end states strings
@@ -87,12 +94,12 @@ public class AnalysisResult {
if (metadata != null) { if (metadata != null) {
List<TriggerPoint> resolvedTriggers = metadata.getTriggers() == null ? null List<TriggerPoint> resolvedTriggers = metadata.getTriggers() == null ? null
: metadata.getTriggers().stream() : metadata.getTriggers().stream()
.map(trigger -> resolveTrigger(trigger, resolver, properties)) .map(trigger -> resolveTrigger(trigger, resolver, properties, context))
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
List<CallChain> resolvedCallChains = metadata.getCallChains() == null ? null List<CallChain> resolvedCallChains = metadata.getCallChains() == null ? null
: metadata.getCallChains().stream() : metadata.getCallChains().stream()
.map(chain -> resolveCallChain(chain, resolver, properties)) .map(chain -> resolveCallChain(chain, resolver, properties, context))
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
if (metadata.getEntryPoints() != null) { if (metadata.getEntryPoints() != null) {
@@ -117,20 +124,43 @@ public class AnalysisResult {
.properties(metadata.getProperties()) .properties(metadata.getProperties())
.build(); .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( private static TriggerPoint resolveTrigger(
TriggerPoint trigger, TriggerPoint trigger,
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver, click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
Map<String, String> properties) { Map<String, String> properties,
CodebaseContext context) {
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream() : trigger.getPolymorphicEvents().stream()
.map(value -> resolver.resolveValue(value, properties)) .map(value -> resolver.resolveValue(value, properties))
.map(value -> MachineEnumCanonicalizer.qualifyEventIdentifier(
value, trigger.getEventTypeFqn(), context))
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
return trigger.toBuilder() String resolvedEvent = trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null;
.event(trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null) String resolvedSource = trigger.getSourceState() != null
.sourceState(trigger.getSourceState() != null
? resolver.resolveValue(trigger.getSourceState(), properties) ? 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) : null)
.polymorphicEvents(polymorphicEvents) .polymorphicEvents(polymorphicEvents)
.build(); .build();
@@ -139,10 +169,11 @@ public class AnalysisResult {
private static CallChain resolveCallChain( private static CallChain resolveCallChain(
CallChain chain, CallChain chain,
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver, click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
Map<String, String> properties) { Map<String, String> properties,
CodebaseContext context) {
TriggerPoint trigger = chain.getTriggerPoint() == null TriggerPoint trigger = chain.getTriggerPoint() == null
? null ? null
: resolveTrigger(chain.getTriggerPoint(), resolver, properties); : resolveTrigger(chain.getTriggerPoint(), resolver, properties, context);
List<MatchedTransition> matchedTransitions = chain.getMatchedTransitions() == null ? null List<MatchedTransition> matchedTransitions = chain.getMatchedTransitions() == null ? null
: chain.getMatchedTransitions().stream() : chain.getMatchedTransitions().stream()
.map(matched -> MatchedTransition.builder() .map(matched -> MatchedTransition.builder()

View File

@@ -8,11 +8,11 @@ import lombok.extern.jackson.Jacksonized;
import java.util.List; import java.util.List;
@Data @Data
@Builder @Builder(toBuilder = true)
@Jacksonized @Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public class BusinessFlow { public class BusinessFlow {
private final String name; private final String name;
private final String description; private final String description;
private final List<String> steps; // List of Event names in order private final List<FlowStep> steps;
} }

View File

@@ -1,11 +1,13 @@
package click.kamil.springstatemachineexporter.analysis.model; package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.extern.jackson.Jacksonized; import lombok.extern.jackson.Jacksonized;
import java.util.List; import java.util.List;
import java.util.Map;
@Data @Data
@Builder(toBuilder = true) @Builder(toBuilder = true)
@@ -17,4 +19,8 @@ public class CallChain {
private final TriggerPoint triggerPoint; private final TriggerPoint triggerPoint;
private final String contextMachineId; private final String contextMachineId;
private final List<MatchedTransition> matchedTransitions; private final List<MatchedTransition> matchedTransitions;
/** How this chain's trigger was linked; derived from trigger flags and matchedTransitions. */
private final LinkResolution linkResolution;
@JsonIgnore
private final Map<String, String> pathBindings;
} }

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,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

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

View File

@@ -50,18 +50,12 @@ public class TriggerPoint {
this.sourceFile = sourceFile; this.sourceFile = sourceFile;
this.sourceModule = sourceModule; this.sourceModule = sourceModule;
this.stateMachineId = stateMachineId; this.stateMachineId = stateMachineId;
this.sourceState = MachineEnumCanonicalizer.canonicalizeLabel(sourceState, stateTypeFqn); this.sourceState = sourceState;
this.lineNumber = lineNumber; this.lineNumber = lineNumber;
this.stateTypeFqn = stateTypeFqn; this.stateTypeFqn = stateTypeFqn;
this.eventTypeFqn = eventTypeFqn; this.eventTypeFqn = eventTypeFqn;
this.event = MachineEnumCanonicalizer.qualifyEventIdentifier(event, eventTypeFqn); this.event = event;
if (polymorphicEvents != null) { this.polymorphicEvents = polymorphicEvents;
this.polymorphicEvents = polymorphicEvents.stream()
.map(pe -> MachineEnumCanonicalizer.qualifyEventIdentifier(pe, eventTypeFqn))
.collect(java.util.stream.Collectors.toList());
} else {
this.polymorphicEvents = null;
}
this.external = external; this.external = external;
this.constraint = constraint; this.constraint = constraint;
this.ambiguous = ambiguous; this.ambiguous = ambiguous;

View File

@@ -3,6 +3,8 @@ package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.index.AccessorKind; import click.kamil.springstatemachineexporter.analysis.index.AccessorKind;
import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver; import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; 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.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer; import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor; import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
@@ -46,6 +48,7 @@ public final class AccessorResolver {
private final ConstantResolver constantResolver; private final ConstantResolver constantResolver;
private final FieldInitializerFinder fieldInitializerFinder; private final FieldInitializerFinder fieldInitializerFinder;
private final Map<String, List<String>> sessionCache = new HashMap<>(); private final Map<String, List<String>> sessionCache = new HashMap<>();
private final Map<String, Optional<AccessorSummary>> indexedGetterCache = new HashMap<>();
public AccessorResolver( public AccessorResolver(
CodebaseContext context, CodebaseContext context,
@@ -63,6 +66,16 @@ public final class AccessorResolver {
if (className == null || className.isBlank()) { if (className == null || className.isBlank()) {
return Optional.empty(); 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); Optional<AccessorSummary> direct = context.getAccessorIndex().lookup(className, methodName);
if (direct.isPresent()) { if (direct.isPresent()) {
return direct; return direct;
@@ -78,9 +91,14 @@ public final class AccessorResolver {
} }
} }
List<String> implementations = context.getImplementations(className); List<String> implementations = context.getImplementations(className);
if (implementations == null) { if (implementations == null || implementations.isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
Optional<AccessorSummary> precomputed = context.getPolymorphicAccessorIndex()
.representativeImplAccessor(className, methodName);
if (precomputed.isPresent()) {
return precomputed;
}
for (String implementation : implementations) { for (String implementation : implementations) {
Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName); Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName);
if (implAccessor.isPresent()) { if (implAccessor.isPresent()) {
@@ -114,7 +132,7 @@ public final class AccessorResolver {
} }
List<String> resolved = resolveInternal(ownerFqn, methodName, receiverContext, budget, visited); List<String> resolved = resolveInternal(ownerFqn, methodName, receiverContext, budget, visited);
if (!resolved.isEmpty()) { if (!resolved.isEmpty() || isStaticCacheable(receiverContext)) {
List<String> cachedCopy = List.copyOf(resolved); List<String> cachedCopy = List.copyOf(resolved);
sessionCache.put(cacheKey, cachedCopy); sessionCache.put(cacheKey, cachedCopy);
return cachedCopy; return cachedCopy;
@@ -127,6 +145,12 @@ public final class AccessorResolver {
return 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); Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(ownerFqn, getterName);
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) { if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
AccessorSummary accessor = indexedAccessor.get(); AccessorSummary accessor = indexedAccessor.get();
@@ -165,8 +189,20 @@ public final class AccessorResolver {
return 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() { public void clearSessionCache() {
sessionCache.clear(); sessionCache.clear();
indexedGetterCache.clear();
} }
private List<String> resolveInternal( private List<String> resolveInternal(
@@ -184,25 +220,42 @@ public final class AccessorResolver {
|| org.eclipse.jdt.core.dom.Modifier.isAbstract(ownerType.getModifiers()); || org.eclipse.jdt.core.dom.Modifier.isAbstract(ownerType.getModifiers());
if (widenToImplementations) { if (widenToImplementations) {
List<String> widened = new ArrayList<>(); List<String> results = new ArrayList<>();
List<String> impls = context.getImplementations(ownerFqn);
if (impls != null) { if (receiverContext != null && receiverContext.cic() != null) {
for (String implFqn : impls) { String cicOwnerFqn = resolveCicOwnerFqn(receiverContext);
TypeDeclaration implType = context.getTypeDeclaration(implFqn); if (cicOwnerFqn != null) {
widened.addAll(resolve( TypeDeclaration cicOwnerType = context.getTypeDeclaration(
implFqn, 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, methodName,
new ReceiverContext(implType, null, cicReceiverContext,
receiverContext != null ? receiverContext.contextCu() : null, indexedAccessor.orElse(null),
false, visited);
receiverContext != null ? receiverContext.fieldValues() : null), if (!cicValues.isEmpty()) {
budget, return deduplicatePreservingOrder(cicValues);
visited));
} }
} }
if (!widened.isEmpty()) { return List.of();
return deduplicatePreservingOrder(widened);
} }
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); Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(ownerFqn, methodName);
@@ -262,8 +315,11 @@ public final class AccessorResolver {
if (constantExtractor != null) { if (constantExtractor != null) {
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : 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( List<String> fieldConstants = constantExtractor.resolveIndexedGetterFieldConstants(
accessor, contextCu, visited); accessor, requestingTypeFqn, contextCu, visited);
if (!fieldConstants.isEmpty()) { if (!fieldConstants.isEmpty()) {
return fieldConstants; return fieldConstants;
} }
@@ -335,7 +391,7 @@ public final class AccessorResolver {
} }
if (constantExtractor != null) { if (constantExtractor != null) {
List<String> indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants( List<String> indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants(
indexedAccessor, receiverContext.contextCu(), visited); indexedAccessor, ownerFqn, receiverContext.contextCu(), visited);
if (!indexedConstants.isEmpty()) { if (!indexedConstants.isEmpty()) {
return indexedConstants; return indexedConstants;
} }
@@ -345,34 +401,8 @@ public final class AccessorResolver {
} }
private List<String> extractConstantGetterReturn(String ownerFqn, String methodName) { private List<String> extractConstantGetterReturn(String ownerFqn, String methodName) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn); return GetterBodyConstantScanner.extractConstantGetterReturn(
if (typeDeclaration == null) { context, constantExtractor, constantResolver, ownerFqn, methodName);
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;
} }
private static String qualifyEnumLikeResult(MethodDeclaration getter, String result) { private static String qualifyEnumLikeResult(MethodDeclaration getter, String result) {
@@ -400,6 +430,28 @@ public final class AccessorResolver {
return accessorOpt.get().fieldName(); 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) { private static List<String> deduplicatePreservingOrder(List<String> values) {
List<String> deduped = new ArrayList<>(); List<String> deduped = new ArrayList<>();
for (String value : values) { for (String value : values) {

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,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

@@ -14,6 +14,10 @@ public final class LibraryUnwrapRegistry {
private static final Set<String> UNWRAP_METHOD_NAMES = Set.of( private static final Set<String> UNWRAP_METHOD_NAMES = Set.of(
"of", "ofEntries", "asList", "entry", "just", "withPayload", "success"); "of", "ofEntries", "asList", "entry", "just", "withPayload", "success");
private static final Set<String> REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success");
private static final Set<String> REACTIVE_TRANSFORM_METHODS = Set.of("map", "flatMap", "switchMap", "concatMap");
private static final String[] STOP_UNWRAP_PREFIXES = { private static final String[] STOP_UNWRAP_PREFIXES = {
"map", "parse", "convert", "to", "resolve", "build", "create", "from", "map", "parse", "convert", "to", "resolve", "build", "create", "from",
"transform", "translate", "derive", "determine", "calculate", "decode", "transform", "translate", "derive", "determine", "calculate", "decode",
@@ -31,6 +35,14 @@ public final class LibraryUnwrapRegistry {
return methodName != null && UNWRAP_METHOD_NAMES.contains(methodName); return methodName != null && UNWRAP_METHOD_NAMES.contains(methodName);
} }
public static boolean isReactiveFactoryMethod(String methodName) {
return methodName != null && REACTIVE_FACTORY_METHODS.contains(methodName);
}
public static boolean isReactiveTransformMethod(String methodName) {
return methodName != null && REACTIVE_TRANSFORM_METHODS.contains(methodName);
}
public static boolean shouldStopUnwrap(String methodName) { public static boolean shouldStopUnwrap(String methodName) {
if (methodName == null) { if (methodName == null) {
return true; return true;

View File

@@ -0,0 +1,303 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.ASTNode;
/**
* Extracts terminal payload literals from reactive factory chains ({@code Mono.just}, nested transforms).
*/
public final class ReactiveExpressionSupport {
private ReactiveExpressionSupport() {
}
public static String extractPayload(Expression expression, ConstantResolver constantResolver, CodebaseContext context) {
return extractPayload(expression, null, constantResolver, context);
}
/**
* Peels {@code just}/{@code withPayload}/{@code success} wrappers on an invocation chain and returns
* the payload {@link Expression} for dataflow analysis.
*/
public static Expression peelFactoryPayloadExpression(Expression expression) {
if (!(expression instanceof MethodInvocation mi)) {
return null;
}
MethodInvocation current = mi;
while (current != null) {
String methodName = current.getName().getIdentifier();
if (LibraryUnwrapRegistry.isReactiveFactoryMethod(methodName) && !current.arguments().isEmpty()) {
return MethodInvocationUnwrapper.peelExpression((Expression) current.arguments().get(0));
}
Expression receiver = current.getExpression();
if (receiver instanceof MethodInvocation nextMi) {
current = nextMi;
} else {
break;
}
}
return null;
}
public static Expression lambdaBodyExpression(LambdaExpression lambda) {
if (lambda.getBody() instanceof Expression bodyExpression) {
return bodyExpression;
}
if (lambda.getBody() instanceof Block block) {
for (Object statement : block.statements()) {
if (statement instanceof ReturnStatement returnStatement
&& returnStatement.getExpression() != null) {
return returnStatement.getExpression();
}
}
}
return null;
}
/**
* Rewrites {@code p.getEvent()} inside a reactive transform lambda to {@code payload.getEvent()}
* when {@code p} is fed by {@code Mono.just(payload)} (or a chained reactive source).
*/
public static String remapLambdaParameterGetter(
Expression eventExpr,
ConstantResolver constantResolver,
CodebaseContext context) {
if (!(eventExpr instanceof MethodInvocation getterCall)) {
return null;
}
if (!(getterCall.getExpression() instanceof SimpleName paramName)) {
return null;
}
LambdaExpression lambda = findEnclosingLambda(getterCall);
if (lambda == null) {
return null;
}
MethodInvocation transformCall = findEnclosingReactiveTransform(lambda);
if (transformCall == null) {
return null;
}
String mappedReceiver = mapLambdaParameterToSource(
lambda,
paramName.getIdentifier(),
transformCall.getExpression(),
constantResolver,
context);
if (mappedReceiver == null) {
return null;
}
return mappedReceiver + "." + getterCall.getName().getIdentifier() + "()";
}
/**
* Same as {@link #remapLambdaParameterGetter(Expression, ConstantResolver, CodebaseContext)} but locates
* the getter in a real method body when {@code expressionText} came from a detached parse tree.
*/
public static String remapLambdaParameterGetterInMethod(
String expressionText,
String methodFqn,
ConstantResolver constantResolver,
CodebaseContext context) {
MethodInvocation getter = AstUtils.findMethodInvocationInMethod(methodFqn, expressionText, context);
if (getter == null) {
return null;
}
String remapped = remapLambdaParameterGetter(getter, constantResolver, context);
if (remapped != null) {
return remapped;
}
return null;
}
private static LambdaExpression findEnclosingLambda(ASTNode node) {
ASTNode current = node.getParent();
while (current != null) {
if (current instanceof LambdaExpression lambda) {
return lambda;
}
current = current.getParent();
}
return null;
}
private static MethodInvocation findEnclosingReactiveTransform(ASTNode node) {
ASTNode current = node.getParent();
while (current != null) {
if (current instanceof MethodInvocation mi
&& LibraryUnwrapRegistry.isReactiveTransformMethod(mi.getName().getIdentifier())) {
return mi;
}
current = current.getParent();
}
return null;
}
private static String extractPayload(
Expression expression,
Expression flatMapReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (expression == null) {
return null;
}
if (expression instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier();
if (LibraryUnwrapRegistry.isReactiveTransformMethod(methodName) && !mi.arguments().isEmpty()) {
String fromArgument = extractTransformArgumentPayload(
(Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context);
if (fromArgument != null) {
return fromArgument;
}
}
if (LibraryUnwrapRegistry.isReactiveFactoryMethod(methodName) && !mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0);
if (constantResolver != null && context != null) {
String resolved = constantResolver.resolve(arg, context);
if (resolved != null) {
return resolved;
}
}
return arg.toString();
}
if (mi.getExpression() != null) {
return extractPayload(mi.getExpression(), flatMapReceiver, constantResolver, context);
}
}
return null;
}
private static String extractTransformArgumentPayload(
Expression argument,
Expression transformReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (argument instanceof LambdaExpression lambda) {
Expression bodyExpression = lambdaBodyExpression(lambda);
if (bodyExpression != null) {
String lambdaPayload = extractLambdaBodyPayload(
bodyExpression, lambda, transformReceiver, constantResolver, context);
if (lambdaPayload != null) {
return lambdaPayload;
}
return extractPayload(bodyExpression, transformReceiver, constantResolver, context);
}
}
return extractPayload(argument, transformReceiver, constantResolver, context);
}
private static String extractLambdaBodyPayload(
Expression bodyExpression,
LambdaExpression lambda,
Expression transformReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (!(bodyExpression instanceof MethodInvocation factoryCall)) {
return null;
}
if (!LibraryUnwrapRegistry.isReactiveFactoryMethod(factoryCall.getName().getIdentifier())
|| factoryCall.arguments().isEmpty()) {
return null;
}
Expression factoryArg = (Expression) factoryCall.arguments().get(0);
if (!(factoryArg instanceof MethodInvocation getterCall)) {
return null;
}
String getterSuffix = "." + getterCall.getName().getIdentifier() + "()";
Expression getterReceiver = getterCall.getExpression();
if (getterReceiver instanceof SimpleName paramName) {
String mappedReceiver = mapLambdaParameterToSource(
lambda, paramName.getIdentifier(), transformReceiver, constantResolver, context);
if (mappedReceiver != null) {
return mappedReceiver + getterSuffix;
}
}
if (getterReceiver != null) {
return getterReceiver.toString() + getterSuffix;
}
return null;
}
private static String mapLambdaParameterToSource(
LambdaExpression lambda,
String paramName,
Expression transformReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (lambda.parameters().size() != 1) {
return null;
}
Object parameter = lambda.parameters().get(0);
if (!(parameter instanceof org.eclipse.jdt.core.dom.VariableDeclaration variableDeclaration)) {
return null;
}
if (!paramName.equals(variableDeclaration.getName().getIdentifier())) {
return null;
}
if (transformReceiver instanceof MethodInvocation receiverTransform
&& LibraryUnwrapRegistry.isReactiveTransformMethod(receiverTransform.getName().getIdentifier())
&& !receiverTransform.arguments().isEmpty()
&& receiverTransform.arguments().get(0) instanceof LambdaExpression feederLambda
&& feederLambda != lambda) {
Expression feederBody = lambdaBodyExpression(feederLambda);
if (feederBody != null) {
String feederPayload = extractLambdaBodyPayload(
feederBody, feederLambda, receiverTransform.getExpression(), constantResolver, context);
if (feederPayload != null) {
return feederPayload;
}
String extracted = extractPayload(
feederBody, receiverTransform.getExpression(), constantResolver, context);
if (extracted != null) {
return extracted;
}
}
}
Expression source = peelJustArgument(transformReceiver);
if (source instanceof MethodInvocation getterMi
&& getterMi.getExpression() instanceof SimpleName innerParamName) {
LambdaExpression outerLambda = findEnclosingLambda(lambda);
if (outerLambda != null && outerLambda != lambda) {
MethodInvocation outerTransform = findEnclosingReactiveTransform(outerLambda);
if (outerTransform != null) {
String mappedBase = mapLambdaParameterToSource(
outerLambda,
innerParamName.getIdentifier(),
outerTransform.getExpression(),
constantResolver,
context);
if (mappedBase != null) {
return mappedBase + "." + getterMi.getName().getIdentifier() + "()";
}
}
}
}
if (source != null) {
if (constantResolver != null && context != null) {
String resolved = constantResolver.resolve(source, context);
if (resolved != null) {
return resolved;
}
}
return source.toString();
}
return extractPayload(transformReceiver, null, constantResolver, context);
}
private static Expression peelJustArgument(Expression expression) {
Expression peeled = peelFactoryPayloadExpression(expression);
if (peeled != null) {
return peeled;
}
if (expression instanceof MethodInvocation mi && mi.getExpression() != null) {
return peelJustArgument(mi.getExpression());
}
return null;
}
}

View File

@@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.resolver;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher; import java.util.regex.Matcher;
@@ -58,6 +59,7 @@ public final class BooleanConstraintEvaluator {
try { try {
return parseExpression(expression.replaceAll("\\s+", "")); return parseExpression(expression.replaceAll("\\s+", ""));
} catch (Exception e) { } catch (Exception e) {
// Be conservative in pruning: if we can't parse the expression, do not discard paths.
return !expression.contains("false"); return !expression.contains("false");
} }
} }
@@ -82,11 +84,191 @@ public final class BooleanConstraintEvaluator {
} }
String expr = constraint; String expr = constraint;
for (Map.Entry<String, String> entry : bindings.entrySet()) { for (Map.Entry<String, String> entry : bindings.entrySet()) {
if (!isConcreteBindingValue(entry.getKey(), entry.getValue())) {
continue;
}
expr = substituteVariableBindings(expr, entry.getKey(), entry.getValue()); expr = substituteVariableBindings(expr, entry.getKey(), entry.getValue());
expr = substituteEqualsLiteralBindings(expr, entry.getKey(), entry.getValue());
} }
return evaluateBooleanExpression(expr); 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) { private static Set<String> extractEqualityVariables(String constraint) {
Set<String> vars = new HashSet<>(); Set<String> vars = new HashSet<>();
Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*=="); Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*==");
@@ -105,7 +287,6 @@ public final class BooleanConstraintEvaluator {
if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) { if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) {
cleanValue = cleanValue.substring(1, cleanValue.length() - 1); cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
} }
String suffix = cleanValue.contains(".") ? cleanValue.substring(cleanValue.lastIndexOf('.') + 1) : cleanValue;
Pattern eqPattern = Pattern.compile( Pattern eqPattern = Pattern.compile(
"(?i)" + Pattern.quote(varName) + "\\s*==\\s*([\\w.\"']+)"); "(?i)" + Pattern.quote(varName) + "\\s*==\\s*([\\w.\"']+)");
@@ -113,16 +294,83 @@ public final class BooleanConstraintEvaluator {
StringBuffer sb = new StringBuffer(); StringBuffer sb = new StringBuffer();
while (matcher.find()) { while (matcher.find()) {
String rhs = matcher.group(1).replace("\"", "").replace("'", ""); String rhs = matcher.group(1).replace("\"", "").replace("'", "");
boolean matches = cleanValue.equals(rhs) boolean matches = constraintValuesMatch(cleanValue, rhs);
|| cleanValue.endsWith("." + rhs)
|| suffix.equalsIgnoreCase(rhs)
|| cleanValue.equalsIgnoreCase(rhs);
matcher.appendReplacement(sb, matches ? "true" : "false"); matcher.appendReplacement(sb, matches ? "true" : "false");
} }
matcher.appendTail(sb); 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(); 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}. * Returns {@code true}/{@code false} when both sides are compile-time string literals; otherwise {@code null}.
*/ */

View File

@@ -1,7 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.resolver; package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
@@ -11,6 +10,7 @@ import java.util.Set;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map;
@Slf4j @Slf4j
public class ConstantResolver { public class ConstantResolver {
@@ -19,6 +19,36 @@ public class ConstantResolver {
return resolveInternal(expr, context, new HashSet<>()); return resolveInternal(expr, context, new HashSet<>());
} }
/**
* Resolves {@code map.get(key)} when the map receiver and key are statically known.
*/
public String resolveKeyedMapLookup(MethodInvocation getCall, CodebaseContext context) {
if (getCall == null || getCall.getExpression() == null || getCall.arguments().isEmpty()) {
return null;
}
String methodName = getCall.getName().getIdentifier();
if (!"get".equals(methodName) && !"getOrDefault".equals(methodName)) {
return null;
}
return LiteralExpressionSupport.resolveMapGet(
getCall, context, new HashSet<>(), (e, v) -> resolveInternal(e, context, v));
}
/**
* Resolves a value from an encoded {@code MAP:...} literal or map-typed expression at {@code key}.
*/
public String resolveKeyedMapLookup(Expression mapExpr, String key, CodebaseContext context) {
if (mapExpr == null || key == null) {
return null;
}
String encoded = resolveInternal(mapExpr, context, new HashSet<>());
if (encoded == null || !encoded.startsWith("MAP:")) {
return null;
}
Map<String, String> entries = LiteralExpressionSupport.parseMapLiteral(encoded);
return entries != null ? entries.get(key) : null;
}
public static List<String> decodeMapLiteralValues(String encoded) { public static List<String> decodeMapLiteralValues(String encoded) {
return LiteralExpressionSupport.mapLiteralValues(encoded); return LiteralExpressionSupport.mapLiteralValues(encoded);
} }
@@ -314,7 +344,7 @@ public class ConstantResolver {
String varName = fragment.getName().getIdentifier(); String varName = fragment.getName().getIdentifier();
declaredLocals.add(varName); declaredLocals.add(varName);
if (fragment.getInitializer() != null) { if (fragment.getInitializer() != null) {
String rhsVal = resolveExpressionWithParams(fragment.getInitializer(), localVars, context); String rhsVal = resolveExpressionWithParams(fragment.getInitializer(), localVars, context, visited);
if (rhsVal == null) rhsVal = resolveInternal(fragment.getInitializer(), context, visited); if (rhsVal == null) rhsVal = resolveInternal(fragment.getInitializer(), context, visited);
if (rhsVal != null) { if (rhsVal != null) {
localVars.put(varName, rhsVal); localVars.put(varName, rhsVal);
@@ -349,7 +379,7 @@ public class ConstantResolver {
} }
for (int i = 0; i < node.arguments().size() && i < sideEffectMd.parameters().size(); i++) { for (int i = 0; i < node.arguments().size() && i < sideEffectMd.parameters().size(); i++) {
Expression arg = (Expression) node.arguments().get(i); Expression arg = (Expression) node.arguments().get(i);
String val = resolveExpressionWithParams(arg, localVars, context); String val = resolveExpressionWithParams(arg, localVars, context, visited);
if (val == null) val = resolveInternal(arg, context, visited); if (val == null) val = resolveInternal(arg, context, visited);
if (val != null) { if (val != null) {
org.eclipse.jdt.core.dom.SingleVariableDeclaration param = (org.eclipse.jdt.core.dom.SingleVariableDeclaration) sideEffectMd.parameters().get(i); org.eclipse.jdt.core.dom.SingleVariableDeclaration param = (org.eclipse.jdt.core.dom.SingleVariableDeclaration) sideEffectMd.parameters().get(i);
@@ -366,7 +396,7 @@ public class ConstantResolver {
} }
} }
} }
return super.visit(node); return false;
} }
@Override @Override
@@ -379,7 +409,7 @@ public class ConstantResolver {
varName = "this." + fa.getName().getIdentifier(); varName = "this." + fa.getName().getIdentifier();
} }
if (varName != null) { if (varName != null) {
String rhsVal = resolveExpressionWithParams(assignment.getRightHandSide(), localVars, context); String rhsVal = resolveExpressionWithParams(assignment.getRightHandSide(), localVars, context, visited);
if (rhsVal == null) rhsVal = resolveInternal(assignment.getRightHandSide(), context, visited); if (rhsVal == null) rhsVal = resolveInternal(assignment.getRightHandSide(), context, visited);
if (rhsVal != null) { if (rhsVal != null) {
localVars.put(varName, rhsVal); localVars.put(varName, rhsVal);
@@ -419,7 +449,7 @@ public class ConstantResolver {
String result = evaluateSwitchExpression(se, localVars, context, visited); String result = evaluateSwitchExpression(se, localVars, context, visited);
if (result != null) finalResult[0] = result; if (result != null) finalResult[0] = result;
} else if (rs.getExpression() != null) { } else if (rs.getExpression() != null) {
String result = resolveExpressionWithParams(rs.getExpression(), localVars, context); String result = resolveExpressionWithParams(rs.getExpression(), localVars, context, visited);
if (result == null) result = resolveInternal(rs.getExpression(), context, visited); if (result == null) result = resolveInternal(rs.getExpression(), context, visited);
if (result != null) finalResult[0] = result; if (result != null) finalResult[0] = result;
} }
@@ -432,7 +462,7 @@ public class ConstantResolver {
} }
private String evaluateSwitchStatement(org.eclipse.jdt.core.dom.SwitchStatement ss, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) { private String evaluateSwitchStatement(org.eclipse.jdt.core.dom.SwitchStatement ss, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
String switchVar = resolveExpressionWithParams(ss.getExpression(), paramValues, context); String switchVar = resolveExpressionWithParams(ss.getExpression(), paramValues, context, visited);
if (switchVar == null) { if (switchVar == null) {
Set<String> values = new LinkedHashSet<>(); Set<String> values = new LinkedHashSet<>();
for (Object stmtObj : ss.statements()) { for (Object stmtObj : ss.statements()) {
@@ -484,7 +514,7 @@ public class ConstantResolver {
matchingCase = true; matchingCase = true;
} else { } else {
for (Object exprObj : sc.expressions()) { for (Object exprObj : sc.expressions()) {
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context); String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context, visited);
// Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback // Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback
if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) { if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) {
caseVal = caseSn.getIdentifier(); caseVal = caseSn.getIdentifier();
@@ -519,7 +549,7 @@ public class ConstantResolver {
} }
private String evaluateSwitchExpression(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) { private String evaluateSwitchExpression(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
String switchVar = resolveExpressionWithParams(se.getExpression(), paramValues, context); String switchVar = resolveExpressionWithParams(se.getExpression(), paramValues, context, visited);
if (switchVar == null) { if (switchVar == null) {
Set<String> values = new LinkedHashSet<>(); Set<String> values = new LinkedHashSet<>();
for (Object stmtObj : se.statements()) { for (Object stmtObj : se.statements()) {
@@ -571,7 +601,7 @@ public class ConstantResolver {
matchingCase = true; matchingCase = true;
} else { } else {
for (Object exprObj : sc.expressions()) { for (Object exprObj : sc.expressions()) {
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context); String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context, visited);
// Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback // Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback
if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) { if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) {
caseVal = caseSn.getIdentifier(); caseVal = caseSn.getIdentifier();
@@ -605,7 +635,8 @@ public class ConstantResolver {
return null; return null;
} }
private String resolveExpressionWithParams(Expression expr, java.util.Map<String, String> paramValues, CodebaseContext context) { private String resolveExpressionWithParams(
Expression expr, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
if (expr instanceof SimpleName sn) { if (expr instanceof SimpleName sn) {
String name = sn.getIdentifier(); String name = sn.getIdentifier();
if (paramValues.containsKey(name)) return paramValues.get(name); if (paramValues.containsKey(name)) return paramValues.get(name);
@@ -627,7 +658,7 @@ public class ConstantResolver {
arg = (Expression) mi.arguments().get(1); arg = (Expression) mi.arguments().get(1);
} }
if (arg != null) { if (arg != null) {
String resolvedArg = resolveExpressionWithParams(arg, paramValues, context); String resolvedArg = resolveExpressionWithParams(arg, paramValues, context, visited);
if (resolvedArg != null && resolvedArg.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) { if (resolvedArg != null && resolvedArg.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
String enumFqn = null; String enumFqn = null;
if (mi.arguments().size() == 2) { if (mi.arguments().size() == 2) {
@@ -674,14 +705,14 @@ public class ConstantResolver {
} }
for (int i = 0; i < mi.arguments().size() && i < sideEffectMd.parameters().size(); i++) { for (int i = 0; i < mi.arguments().size() && i < sideEffectMd.parameters().size(); i++) {
Expression arg = (Expression) mi.arguments().get(i); Expression arg = (Expression) mi.arguments().get(i);
String val = resolveExpressionWithParams(arg, paramValues, context); String val = resolveExpressionWithParams(arg, paramValues, context, visited);
if (val == null) val = resolveInternal(arg, context, new java.util.HashSet<>()); if (val == null) val = resolveInternal(arg, context, visited);
if (val != null) { if (val != null) {
org.eclipse.jdt.core.dom.SingleVariableDeclaration param = (org.eclipse.jdt.core.dom.SingleVariableDeclaration) sideEffectMd.parameters().get(i); org.eclipse.jdt.core.dom.SingleVariableDeclaration param = (org.eclipse.jdt.core.dom.SingleVariableDeclaration) sideEffectMd.parameters().get(i);
inlineLocals.put(param.getName().getIdentifier(), val); inlineLocals.put(param.getName().getIdentifier(), val);
} }
} }
String ret = evaluateMethodBodyWithLocals(sideEffectMd, inlineLocals, context, new java.util.HashSet<>()); String ret = evaluateMethodBodyWithLocals(sideEffectMd, inlineLocals, context, visited);
for (java.util.Map.Entry<String, String> entry : inlineLocals.entrySet()) { for (java.util.Map.Entry<String, String> entry : inlineLocals.entrySet()) {
if (entry.getKey().startsWith("this.")) { if (entry.getKey().startsWith("this.")) {
paramValues.put(entry.getKey(), entry.getValue()); paramValues.put(entry.getKey(), entry.getValue());
@@ -705,7 +736,7 @@ public class ConstantResolver {
if (!"build".equals(methodName) && !"builder".equals(methodName) && !"toBuilder".equals(methodName)) { if (!"build".equals(methodName) && !"builder".equals(methodName) && !"toBuilder".equals(methodName)) {
if (current.arguments().size() == 1) { if (current.arguments().size() == 1) {
Expression arg = (Expression) current.arguments().get(0); Expression arg = (Expression) current.arguments().get(0);
String val = resolveExpressionWithParams(arg, localVars, context); String val = resolveExpressionWithParams(arg, localVars, context, visited);
if (val == null) val = resolveInternal(arg, context, visited); if (val == null) val = resolveInternal(arg, context, visited);
if (val != null) fields.put(methodName, val); if (val != null) fields.put(methodName, val);
} }
@@ -818,14 +849,15 @@ public class ConstantResolver {
} }
} }
td = typeFromCurrentCallPath(context); for (String classFqn : classesFromCurrentCallPath(context)) {
if (td != null) { TypeDeclaration pathTd = context.getTypeDeclaration(classFqn);
String fqn = context.getFqn(td); if (pathTd != null) {
String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited); String result = resolveFieldInType(pathTd, sn.getIdentifier(), classFqn, context, visited);
if (result != null) { if (result != null) {
return result; return result;
} }
} }
}
// Fallback: Static imports // Fallback: Static imports
ASTNode root = sn.getRoot(); ASTNode root = sn.getRoot();
@@ -889,7 +921,7 @@ public class ConstantResolver {
if (td != null) { if (td != null) {
return td; return td;
} }
String entryClass = getCurrentCallPathEntryClass(); String entryClass = getCurrentCallPathEntryClass(context);
if (entryClass == null) { if (entryClass == null) {
return null; return null;
} }
@@ -901,21 +933,35 @@ public class ConstantResolver {
} }
private TypeDeclaration typeFromCurrentCallPath(CodebaseContext context) { private TypeDeclaration typeFromCurrentCallPath(CodebaseContext context) {
String entryClass = getCurrentCallPathEntryClass(); List<String> classes = classesFromCurrentCallPath(context);
if (entryClass == null) { if (classes.isEmpty()) {
return null; return null;
} }
return context.getTypeDeclaration(entryClass); return context.getTypeDeclaration(classes.get(0));
} }
private String getCurrentCallPathEntryClass() { private List<String> classesFromCurrentCallPath(CodebaseContext context) {
List<String> path = JdtDataFlowModel.getCurrentPath(); List<String> path = context.getAnalysisCallPath();
if (path == null || path.isEmpty()) { if (path == null || path.isEmpty()) {
return null; return List.of();
} }
String entryMethod = path.get(0); List<String> classes = new ArrayList<>();
int dot = entryMethod.lastIndexOf('.'); for (int i = path.size() - 1; i >= 0; i--) {
return dot > 0 ? entryMethod.substring(0, dot) : null; String methodFqn = path.get(i);
int dot = methodFqn.lastIndexOf('.');
if (dot > 0) {
String className = methodFqn.substring(0, dot);
if (!classes.contains(className)) {
classes.add(className);
}
}
}
return classes;
}
private String getCurrentCallPathEntryClass(CodebaseContext context) {
List<String> classes = classesFromCurrentCallPath(context);
return classes.isEmpty() ? null : classes.get(classes.size() - 1);
} }
private String resolveFieldInType(TypeDeclaration td, String fieldName, String typeFqn, CodebaseContext context, Set<String> visited) { private String resolveFieldInType(TypeDeclaration td, String fieldName, String typeFqn, CodebaseContext context, Set<String> visited) {
@@ -1027,13 +1073,18 @@ public class ConstantResolver {
if (ann instanceof SingleMemberAnnotation sma) { if (ann instanceof SingleMemberAnnotation sma) {
valueExpr = sma.getValue(); valueExpr = sma.getValue();
} else if (ann instanceof NormalAnnotation na) { } else if (ann instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) { String raw = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(ann, "value");
MemberValuePair pair = (MemberValuePair) pairObj; if (!raw.isEmpty()) {
if ("value".equals(pair.getName().getIdentifier())) { if (raw.startsWith("\"") && raw.endsWith("\"")) {
valueExpr = pair.getValue(); raw = raw.substring(1, raw.length() - 1);
break;
} }
if (raw.contains("${")) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
raw, java.util.Collections.emptyMap());
} }
return raw;
}
return null;
} }
if (valueExpr instanceof StringLiteral sl) { if (valueExpr instanceof StringLiteral sl) {
@@ -1053,11 +1104,7 @@ public class ConstantResolver {
} }
private TypeDeclaration findEnclosingType(ASTNode node) { private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode current = node; return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
while (current != null && !(current instanceof TypeDeclaration)) {
current = current.getParent();
}
return (TypeDeclaration) current;
} }
private MethodDeclaration findInvokedMethod(org.eclipse.jdt.core.dom.MethodInvocation mi, CodebaseContext context) { private MethodDeclaration findInvokedMethod(org.eclipse.jdt.core.dom.MethodInvocation mi, CodebaseContext context) {
@@ -1079,14 +1126,7 @@ public class ConstantResolver {
} }
private MethodDeclaration findEnclosingMethod(ASTNode node) { private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent(); return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
while (parent != null) {
if (parent instanceof MethodDeclaration md) {
return md;
}
parent = parent.getParent();
}
return null;
} }
private String resolveLocalType(SimpleName sn) { private String resolveLocalType(SimpleName sn) {

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,212 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.TypeResolver;
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.Expression;
import org.eclipse.jdt.core.dom.InfixExpression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Evaluates boolean predicates on rich/domain event types (e.g. {@code myEvent.isCorrect()})
* and maps matching implementations to machine enum constants via {@code getType()}.
*/
public final class RichEventPredicateEvaluator {
private RichEventPredicateEvaluator() {
}
public static List<String> filterEnumConstants(
List<String> candidates,
String constraint,
String machineEnumFqn,
TriggerPoint trigger,
CodebaseContext context) {
if (candidates == null || candidates.isEmpty() || constraint == null || context == null || trigger == null) {
return List.of();
}
List<EnumMemberPredicateEvaluator.PredicateCall> predicates =
EnumMemberPredicateEvaluator.extractPredicateCalls(constraint);
if (predicates.isEmpty()) {
return List.of();
}
EnumMemberPredicateEvaluator.PredicateCall predicate = predicates.get(0);
String receiverTypeFqn = resolveReceiverTypeFqn(trigger, predicate.receiverName(), context);
if (receiverTypeFqn == null) {
return List.of();
}
Set<String> ownerTypes = new LinkedHashSet<>();
ownerTypes.add(receiverTypeFqn);
ownerTypes.addAll(context.getImplementations(receiverTypeFqn));
Set<String> matched = new LinkedHashSet<>();
for (String ownerFqn : ownerTypes) {
if (!predicateMatches(ownerFqn, predicate, context)) {
continue;
}
String typeConstant = resolveDirectEnumFromPredicateMethod(ownerFqn, "getType", context);
if (typeConstant != null && matchesCandidate(typeConstant, candidates, machineEnumFqn, context)) {
matched.add(canonicalize(typeConstant, machineEnumFqn, context));
}
String directConstant = resolveDirectEnumFromPredicateMethod(ownerFqn, predicate.methodName(), context);
if (directConstant != null && matchesCandidate(directConstant, candidates, machineEnumFqn, context)) {
matched.add(canonicalize(directConstant, machineEnumFqn, context));
}
}
List<String> filtered = new ArrayList<>();
for (String candidate : candidates) {
String canonical = canonicalize(candidate, machineEnumFqn, context);
if (matched.contains(canonical)) {
filtered.add(candidate);
}
}
return filtered;
}
private static String resolveReceiverTypeFqn(TriggerPoint trigger, String receiverName, CodebaseContext context) {
if (trigger.getClassName() == null || trigger.getMethodName() == null || receiverName == null) {
return null;
}
TypeDeclaration owner = context.getTypeDeclaration(trigger.getClassName());
if (owner == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(owner, trigger.getMethodName(), true);
if (method == null) {
return null;
}
TypeResolver typeResolver = new TypeResolver(context);
for (Object parameter : method.parameters()) {
if (parameter instanceof org.eclipse.jdt.core.dom.SingleVariableDeclaration variable
&& receiverName.equals(variable.getName().getIdentifier())) {
return typeResolver.resolveTypeToFqn(variable.getType(), owner.getRoot());
}
}
return null;
}
private static boolean predicateMatches(
String ownerFqn,
EnumMemberPredicateEvaluator.PredicateCall predicate,
CodebaseContext context) {
TypeDeclaration type = context.getTypeDeclaration(ownerFqn);
if (type == null) {
return false;
}
MethodDeclaration method = context.findMethodDeclaration(type, predicate.methodName(), true);
if (method == null || method.getBody() == null) {
return false;
}
Boolean[] result = new Boolean[1];
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (result[0] == null && node.getExpression() != null) {
result[0] = evaluateBoolean(node.getExpression());
}
return super.visit(node);
}
});
if (result[0] == null) {
return false;
}
return predicate.negated() ? !result[0] : result[0];
}
private static Boolean evaluateBoolean(Expression expression) {
if (expression instanceof org.eclipse.jdt.core.dom.BooleanLiteral literal) {
return literal.booleanValue();
}
if (expression instanceof MethodInvocation invocation
&& "isEvent".equals(invocation.getName().getIdentifier())
&& invocation.getExpression() instanceof MethodInvocation getType
&& "getType".equals(getType.getName().getIdentifier())) {
return true;
}
if (expression instanceof InfixExpression infix && infix.getOperator() == InfixExpression.Operator.EQUALS) {
return true;
}
return null;
}
private static String resolveDirectEnumFromPredicateMethod(
String ownerFqn,
String predicateMethod,
CodebaseContext context) {
TypeDeclaration type = context.getTypeDeclaration(ownerFqn);
if (type == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(type, predicateMethod, true);
if (method == null || method.getBody() == null) {
return null;
}
final String[] constant = new String[1];
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (constant[0] != null || node.getExpression() == null) {
return super.visit(node);
}
constant[0] = extractEnumConstant(node.getExpression());
return super.visit(node);
}
});
return constant[0];
}
private static String extractEnumConstant(Expression expression) {
if (expression instanceof QualifiedName qualifiedName) {
return qualifiedName.getFullyQualifiedName();
}
if (expression instanceof InfixExpression infix && infix.getOperator() == InfixExpression.Operator.EQUALS) {
String left = extractEnumConstant(infix.getLeftOperand());
if (left != null) {
return left;
}
return extractEnumConstant(infix.getRightOperand());
}
if (expression instanceof MethodInvocation invocation
&& invocation.getExpression() instanceof MethodInvocation getType
&& "getType".equals(getType.getName().getIdentifier())) {
if (invocation.getExpression() instanceof MethodInvocation inner
&& inner.getExpression() instanceof ClassInstanceCreation creation
&& !creation.arguments().isEmpty()) {
return extractEnumConstant((Expression) creation.arguments().get(0));
}
}
return null;
}
private static boolean matchesCandidate(
String constant,
List<String> candidates,
String machineEnumFqn,
CodebaseContext context) {
if (constant == null) {
return false;
}
String canonical = canonicalize(constant, machineEnumFqn, context);
for (String candidate : candidates) {
if (canonical.equals(canonicalize(candidate, machineEnumFqn, context))) {
return true;
}
}
return false;
}
private static String canonicalize(String constant, String machineEnumFqn, CodebaseContext context) {
return MachineEnumCanonicalizer.canonicalizeLabel(constant, machineEnumFqn, context);
}
}

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.resolver; package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.service.TypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
@@ -204,51 +205,6 @@ public final class StateMachineTypeResolver {
CompilationUnit cu, CompilationUnit cu,
CodebaseContext context, CodebaseContext context,
Map<String, String> typeVarBindings) { Map<String, String> typeVarBindings) {
if (type == null) { return new TypeResolver(context).resolveTypeToFqn(type, cu, typeVarBindings);
return null;
}
if (type instanceof WildcardType wildcardType) {
Type bound = wildcardType.getBound();
return bound != null ? resolveTypeToFqn(bound, cu, context, typeVarBindings) : null;
}
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), cu, context, typeVarBindings);
} else {
simpleName = type.toString();
}
if (simpleName.contains("<")) {
simpleName = simpleName.substring(0, simpleName.indexOf('<'));
}
if (typeVarBindings.containsKey(simpleName)) {
return typeVarBindings.get(simpleName);
}
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) {
return context.getFqn(localTd);
}
}
return simpleName;
} }
} }

View File

@@ -43,7 +43,7 @@ public final class AnalysisResultFinalizer {
} }
persistMachineTypes(result, machineTypes); persistMachineTypes(result, machineTypes);
applyCanonicalization(result, machineTypes); applyCanonicalizationFields(result, context, machineTypes);
if (context != null) { if (context != null) {
AnalysisCanonicalFormValidator.enforce(result, context); AnalysisCanonicalFormValidator.enforce(result, context);
@@ -59,24 +59,48 @@ public final class AnalysisResultFinalizer {
result.setEventTypeFqn(machineTypes.eventTypeFqn()); result.setEventTypeFqn(machineTypes.eventTypeFqn());
} }
private static void applyCanonicalization( /**
* 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, AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes) { StateMachineTypeResolver.MachineTypes machineTypes) {
MachineEnumCanonicalizer.canonicalizeTransitions(result.getTransitions(), 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) { if (result.getStates() != null) {
result.setStates(MachineEnumCanonicalizer.canonicalizeStates( result.setStates(MachineEnumCanonicalizer.canonicalizeStates(
result.getStates(), machineTypes.stateTypeFqn())); result.getStates(), machineTypes.stateTypeFqn(), context));
} }
result.setStartStates(MachineEnumCanonicalizer.canonicalizeStateLabels( result.setStartStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
result.getStartStates(), machineTypes.stateTypeFqn())); result.getStartStates(), machineTypes.stateTypeFqn(), context));
result.setEndStates(MachineEnumCanonicalizer.canonicalizeStateLabels( result.setEndStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
result.getEndStates(), machineTypes.stateTypeFqn())); result.getEndStates(), machineTypes.stateTypeFqn(), context));
if (result.getMetadata() != null) { if (result.getMetadata() != null) {
CodebaseMetadata metadata = result.getMetadata(); CodebaseMetadata metadata = result.getMetadata();
List<TriggerPoint> triggers = canonicalizeTriggers(metadata.getTriggers(), machineTypes); List<TriggerPoint> triggers = canonicalizeTriggers(metadata.getTriggers(), context, machineTypes);
List<CallChain> callChains = canonicalizeCallChains(metadata.getCallChains(), machineTypes); List<CallChain> callChains = canonicalizeCallChains(metadata.getCallChains(), context, machineTypes);
result.setMetadata(CodebaseMetadata.builder() result.setMetadata(CodebaseMetadata.builder()
.triggers(triggers) .triggers(triggers)
.entryPoints(metadata.getEntryPoints()) .entryPoints(metadata.getEntryPoints())
@@ -88,17 +112,20 @@ public final class AnalysisResultFinalizer {
private static List<TriggerPoint> canonicalizeTriggers( private static List<TriggerPoint> canonicalizeTriggers(
List<TriggerPoint> triggers, List<TriggerPoint> triggers,
CodebaseContext context,
StateMachineTypeResolver.MachineTypes machineTypes) { StateMachineTypeResolver.MachineTypes machineTypes) {
if (triggers == null) { if (triggers == null) {
return null; return null;
} }
return triggers.stream() return triggers.stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes)) .map(trigger -> MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
trigger, machineTypes, context))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
private static List<CallChain> canonicalizeCallChains( private static List<CallChain> canonicalizeCallChains(
List<CallChain> callChains, List<CallChain> callChains,
CodebaseContext context,
StateMachineTypeResolver.MachineTypes machineTypes) { StateMachineTypeResolver.MachineTypes machineTypes) {
if (callChains == null) { if (callChains == null) {
return null; return null;
@@ -107,9 +134,10 @@ public final class AnalysisResultFinalizer {
.map(chain -> { .map(chain -> {
TriggerPoint trigger = chain.getTriggerPoint() == null TriggerPoint trigger = chain.getTriggerPoint() == null
? null ? null
: MachineEnumCanonicalizer.canonicalizeTriggerPoint(chain.getTriggerPoint(), machineTypes); : MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
chain.getTriggerPoint(), machineTypes, context);
List<MatchedTransition> matched = canonicalizeMatchedTransitions( List<MatchedTransition> matched = canonicalizeMatchedTransitions(
chain.getMatchedTransitions(), machineTypes); chain.getMatchedTransitions(), machineTypes, context);
return chain.toBuilder() return chain.toBuilder()
.triggerPoint(trigger) .triggerPoint(trigger)
.matchedTransitions(matched) .matchedTransitions(matched)
@@ -120,18 +148,19 @@ public final class AnalysisResultFinalizer {
private static List<MatchedTransition> canonicalizeMatchedTransitions( private static List<MatchedTransition> canonicalizeMatchedTransitions(
List<MatchedTransition> matchedTransitions, List<MatchedTransition> matchedTransitions,
StateMachineTypeResolver.MachineTypes machineTypes) { StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
if (matchedTransitions == null) { if (matchedTransitions == null) {
return null; return null;
} }
return matchedTransitions.stream() return matchedTransitions.stream()
.map(matched -> MatchedTransition.builder() .map(matched -> MatchedTransition.builder()
.event(MachineEnumCanonicalizer.canonicalizeLabel( .event(MachineEnumCanonicalizer.canonicalizeLabel(
matched.getEvent(), machineTypes.eventTypeFqn())) matched.getEvent(), machineTypes.eventTypeFqn(), context))
.sourceState(MachineEnumCanonicalizer.canonicalizeLabel( .sourceState(MachineEnumCanonicalizer.canonicalizeLabel(
matched.getSourceState(), machineTypes.stateTypeFqn())) matched.getSourceState(), machineTypes.stateTypeFqn(), context))
.targetState(MachineEnumCanonicalizer.canonicalizeLabel( .targetState(MachineEnumCanonicalizer.canonicalizeLabel(
matched.getTargetState(), machineTypes.stateTypeFqn())) matched.getTargetState(), machineTypes.stateTypeFqn(), context))
.build()) .build())
.collect(Collectors.toList()); .collect(Collectors.toList());
} }

View File

@@ -18,6 +18,12 @@ public class CallGraphPathFinder {
this.context = context; this.context = context;
} }
public void clearAnalysisCaches() {
heuristicCache.clear();
memoCache.clear();
unreachableCache.clear();
}
public List<String> findPath(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) { public List<String> findPath(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) {
if (start.equals(target)) return new ArrayList<>(List.of(start)); if (start.equals(target)) return new ArrayList<>(List.of(start));
if (!visited.add(start)) { if (!visited.add(start)) {
@@ -355,22 +361,10 @@ public class CallGraphPathFinder {
if (classNeighbor.equals(classTarget)) return true; if (classNeighbor.equals(classTarget)) return true;
String simpleClassNeighbor = classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor; if (context.areSameTypeOrUnambiguousSimpleMatch(classNeighbor, classTarget)) return true;
String simpleClassTarget = classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
if (simpleClassNeighbor.equals(simpleClassTarget)) return true; if (context.areClassesPolymorphicallyCompatible(classNeighbor, classTarget)) {
return true;
List<String> impls = context.getImplementations(classTarget);
if (impls != null) {
for (String impl : impls) {
if (impl.equals(classNeighbor) || impl.endsWith("." + classNeighbor)) return true;
}
}
List<String> implsN = context.getImplementations(classNeighbor);
if (implsN != null) {
for (String impl : implsN) {
if (impl.equals(classTarget) || impl.endsWith("." + classTarget)) return true;
}
} }
return false; return false;
@@ -386,13 +380,22 @@ public class CallGraphPathFinder {
String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor; String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
if (simpleClassNeighbor != null && simpleClassTarget != null) { if (simpleClassNeighbor != null && simpleClassTarget != null) {
if (simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) return true; String leftClass = classNeighbor != null ? classNeighbor : simpleClassNeighbor;
String rightClass = classTarget != null ? classTarget : simpleClassTarget;
if (context.areSameTypeOrUnambiguousSimpleMatch(leftClass, rightClass)) return true;
if (!context.isAmbiguousSimpleName(simpleClassNeighbor)
&& !context.isAmbiguousSimpleName(simpleClassTarget)
&& simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) {
return true;
}
// e.g. "this" vs "com.example.Machine" // e.g. "this" vs "com.example.Machine"
if (simpleClassNeighbor.equals("this") || simpleClassNeighbor.equals("super")) return true; if (simpleClassNeighbor.equals("this") || simpleClassNeighbor.equals("super")) return true;
return false; return false;
} }
return true; // If we have no class ownership information for either side, we can't prove a match beyond the
// method name. Fail closed to avoid accidental cross-class linking.
return false;
} }
private boolean isFullyQualifiedMethod(String methodFqn) { private boolean isFullyQualifiedMethod(String methodFqn) {

View File

@@ -0,0 +1,225 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
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.IMethodBinding;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Matches {@link CallEdge}s to concrete {@link MethodInvocation}s in caller source when
* multiple call sites target the same method.
*/
public final class CallSiteMatcher {
private static final Pattern ENUM_LITERAL = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*\\.[A-Z_][A-Z0-9_]*");
private CallSiteMatcher() {
}
public static CallEdge selectEdge(
String caller,
String target,
List<CallEdge> edges,
Map<String, String> bindings) {
if (edges == null || edges.isEmpty()) {
return null;
}
List<CallEdge> matching = new ArrayList<>();
for (CallEdge edge : edges) {
if (edge.getTargetMethod().equals(target) || heuristicTargetMatch(edge.getTargetMethod(), target)) {
matching.add(edge);
}
}
if (matching.isEmpty()) {
return null;
}
if (matching.size() == 1) {
return matching.get(0);
}
List<CallEdge> constraintFiltered = matching;
if (bindings != null && !bindings.isEmpty()) {
List<CallEdge> compatible = new ArrayList<>();
for (CallEdge edge : matching) {
String constraint = edge.getConstraint();
if (constraint == null
|| constraint.isBlank()
|| BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, bindings)) {
compatible.add(edge);
}
}
if (!compatible.isEmpty()) {
constraintFiltered = compatible;
}
}
if (constraintFiltered.size() == 1) {
return constraintFiltered.get(0);
}
if (bindings != null && !bindings.isEmpty()) {
for (CallEdge edge : constraintFiltered) {
if (edge.getArguments() == null) {
continue;
}
for (String edgeArg : edge.getArguments()) {
for (String bindingValue : bindings.values()) {
if (edgeArg == null || bindingValue == null) {
continue;
}
if (edgeArg.contains(bindingValue) || bindingValue.contains(edgeArg)) {
return edge;
}
}
}
}
}
for (CallEdge edge : constraintFiltered) {
if (findMatchingInvocation(caller, target, edge, null) != null) {
return edge;
}
}
return constraintFiltered.get(0);
}
public static MethodInvocation findMatchingInvocation(
String caller,
String callee,
CallEdge edge,
CodebaseContext context) {
List<MethodInvocation> invocations = findCallInvocations(caller, callee, context);
if (invocations.isEmpty()) {
return null;
}
if (edge == null || invocations.size() == 1) {
return invocations.get(0);
}
for (MethodInvocation invocation : invocations) {
if (invocationMatchesEdge(invocation, edge)) {
return invocation;
}
}
return null;
}
public static Expression findCallSiteArgumentExpression(
String caller,
String callee,
int paramIndex,
CallEdge edge,
CodebaseContext context) {
MethodInvocation invocation = findMatchingInvocation(caller, callee, edge, context);
if (invocation == null) {
return null;
}
if (paramIndex < 0 || paramIndex >= invocation.arguments().size()) {
return null;
}
return (Expression) invocation.arguments().get(paramIndex);
}
static List<MethodInvocation> findCallInvocations(String caller, String callee, CodebaseContext context) {
if (caller == null || callee == null || !caller.contains(".") || !callee.contains(".") || context == null) {
return List.of();
}
String callerClass = caller.substring(0, caller.lastIndexOf('.'));
String callerMethod = caller.substring(caller.lastIndexOf('.') + 1);
String calleeMethod = callee.substring(callee.lastIndexOf('.') + 1);
String calleeClass = callee.substring(0, callee.lastIndexOf('.'));
TypeDeclaration callerType = context.getTypeDeclaration(callerClass);
if (callerType == null) {
return List.of();
}
MethodDeclaration method = context.findMethodDeclaration(callerType, callerMethod, true);
if (method == null || method.getBody() == null) {
return List.of();
}
List<MethodInvocation> invocations = new ArrayList<>();
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (!calleeMethod.equals(node.getName().getIdentifier())) {
return super.visit(node);
}
IMethodBinding binding = node.resolveMethodBinding();
if (binding != null && binding.getDeclaringClass() != null) {
String declaringFqn = binding.getDeclaringClass().getErasure().getQualifiedName();
if (!declaringFqn.equals(calleeClass) && !declaringFqn.endsWith("." + calleeClass)) {
String calleeSimple = calleeClass.substring(calleeClass.lastIndexOf('.') + 1);
if (!declaringFqn.endsWith("." + calleeSimple)) {
return super.visit(node);
}
}
}
invocations.add(node);
return super.visit(node);
}
});
return invocations;
}
static boolean invocationMatchesEdge(MethodInvocation invocation, CallEdge edge) {
if (edge == null || edge.getArguments() == null) {
return true;
}
List<?> astArgs = invocation.arguments();
List<String> edgeArgs = edge.getArguments();
int count = Math.min(astArgs.size(), edgeArgs.size());
if (count == 0) {
return edgeArgs.isEmpty();
}
int matched = 0;
for (int i = 0; i < count; i++) {
if (argumentTokensMatch(((Expression) astArgs.get(i)).toString(), edgeArgs.get(i))) {
matched++;
}
}
return matched == count;
}
static boolean argumentTokensMatch(String astArg, String edgeArg) {
if (astArg == null || edgeArg == null) {
return false;
}
String left = normalizeArgument(astArg);
String right = normalizeArgument(edgeArg);
if (left.equals(right)) {
return true;
}
String leftEnum = extractEnumLiteral(left);
String rightEnum = extractEnumLiteral(right);
return leftEnum != null && leftEnum.equals(rightEnum);
}
private static String normalizeArgument(String value) {
return value.replaceAll("\\s+", "");
}
private static String extractEnumLiteral(String value) {
var matcher = ENUM_LITERAL.matcher(value);
return matcher.find() ? matcher.group() : null;
}
private static boolean heuristicTargetMatch(String edgeTarget, String pathTarget) {
if (edgeTarget == null || pathTarget == null) {
return false;
}
if (edgeTarget.equals(pathTarget)) {
return true;
}
String edgeSimple = edgeTarget.substring(edgeTarget.lastIndexOf('.') + 1);
String pathSimple = pathTarget.substring(pathTarget.lastIndexOf('.') + 1);
return edgeSimple.equals(pathSimple);
}
}

View File

@@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver; import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver; import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
import click.kamil.springstatemachineexporter.analysis.pipeline.GetterBodyConstantScanner;
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry; import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget; import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
@@ -47,7 +48,7 @@ public class ConstantExtractor {
public void extractConstantsFromExpression(Expression expr, List<String> constants) { public void extractConstantsFromExpression(Expression expr, List<String> constants) {
if (constantResolver != null) { if (constantResolver != null) {
String resolved = constantResolver.resolve(expr, context); String resolved = constantResolver.resolve(expr, context);
if (resolved != null) { if (resolved != null && !shouldInspectExpressionStructure(expr, resolved)) {
addResolvedConstant(resolved, constants); addResolvedConstant(resolved, constants);
return; return;
} }
@@ -65,23 +66,7 @@ public class ConstantExtractor {
} else if (expr instanceof Assignment assignment) { } else if (expr instanceof Assignment assignment) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants); extractConstantsFromExpression(assignment.getRightHandSide(), constants);
} else if (expr instanceof SwitchExpression se) { } else if (expr instanceof SwitchExpression se) {
se.accept(new ASTVisitor() { extractConstantsFromSwitchLike(se, constants);
@Override
public boolean visit(YieldStatement ys) {
extractConstantsFromExpression(ys.getExpression(), constants);
return super.visit(ys);
}
@Override
public boolean visit(ExpressionStatement es) {
extractConstantsFromExpression(es.getExpression(), constants);
return super.visit(es);
}
@Override
public boolean visit(ReturnStatement rs) {
extractConstantsFromExpression(rs.getExpression(), constants);
return super.visit(rs);
}
});
} else if (expr instanceof ArrayAccess aa) { } else if (expr instanceof ArrayAccess aa) {
extractConstantsFromExpression(aa.getArray(), constants); extractConstantsFromExpression(aa.getArray(), constants);
} else if (expr instanceof ArrayCreation ac && ac.getInitializer() != null) { } else if (expr instanceof ArrayCreation ac && ac.getInitializer() != null) {
@@ -93,9 +78,23 @@ public class ConstantExtractor {
extractConstantsFromArgument((Expression) expObj, constants); extractConstantsFromArgument((Expression) expObj, constants);
} }
} else if (expr instanceof MethodInvocation mi) { } else if (expr instanceof MethodInvocation mi) {
if (variableTracer != null) {
int sizeBefore = constants.size();
for (Expression def : variableTracer.traceVariableAll(mi)) {
if (def != null && !def.toString().equals(mi.toString())) {
extractConstantsFromExpression(def, constants);
}
}
if (constants.size() > sizeBefore) {
return;
}
}
String methodName = mi.getName().getIdentifier(); String methodName = mi.getName().getIdentifier();
if (log.isTraceEnabled()) {
log.trace("Processing mi: {}", mi); log.trace("Processing mi: {}", mi);
}
if (("get".equals(methodName) || "getOrDefault".equals(methodName)) && mi.getExpression() != null) { if (("get".equals(methodName) || "getOrDefault".equals(methodName)) && mi.getExpression() != null) {
if (isKeyedLookupMethod(mi)) { if (isKeyedLookupMethod(mi)) {
extractMapReceiverConstants(mi.getExpression(), constants); extractMapReceiverConstants(mi.getExpression(), constants);
@@ -113,44 +112,22 @@ public class ConstantExtractor {
return; return;
} }
if ((methodName.startsWith("get") || methodName.equals("type") || methodName.equals("event")) && mi.getExpression() instanceof SimpleName sn) { if ((methodName.startsWith("get") || methodName.equals("type") || methodName.equals("event"))
String varName = sn.getIdentifier(); && mi.getExpression() instanceof SimpleName sn
String propName = methodName.startsWith("get") ? methodName.substring(3) : methodName; && variableTracer != null) {
org.eclipse.jdt.core.dom.MethodDeclaration md =
Block block = findEnclosingBlock(mi); click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(mi);
if (block != null) { TypeDeclaration td = findEnclosingType(mi);
for (Object stmtObj : block.statements()) { if (md != null && td != null) {
if (stmtObj == mi.getParent() || stmtObj == mi) break; String methodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
if (stmtObj instanceof ExpressionStatement es) { Expression setterArg = variableTracer.traceLocalSetter(methodFqn, sn.getIdentifier(), methodName);
if (es.getExpression() instanceof MethodInvocation setterMi) { if (setterArg != null) {
if (setterMi.getName().getIdentifier().equalsIgnoreCase("set" + propName) || setterMi.getName().getIdentifier().equalsIgnoreCase(propName)) { extractConstantsFromExpression(setterArg, constants);
if (setterMi.getExpression() instanceof SimpleName setterSn && setterSn.getIdentifier().equals(varName)) { if (!constants.isEmpty()) {
if (!setterMi.arguments().isEmpty()) {
extractConstantsFromExpression((Expression) setterMi.arguments().get(0), constants);
return; return;
} }
} }
} }
} else if (es.getExpression() instanceof Assignment assignment) {
if (assignment.getLeftHandSide() instanceof FieldAccess fa) {
if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) {
if (fa.getName().getIdentifier().equalsIgnoreCase(propName)) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
return;
}
}
} else if (assignment.getLeftHandSide() instanceof QualifiedName qqn) {
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
if (qqn.getName().getIdentifier().equalsIgnoreCase(propName)) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
return;
}
}
}
}
}
}
}
} }
List<Expression> receivers = new ArrayList<>(); List<Expression> receivers = new ArrayList<>();
@@ -233,6 +210,17 @@ public class ConstantExtractor {
} }
} }
private boolean shouldInspectExpressionStructure(Expression expr, String resolved) {
if (expr == null || resolved == null) {
return false;
}
if (!(resolved.startsWith("<SYMBOLIC:") || resolved.contains("SYMBOLIC:") || resolved.endsWith(".*>"))) {
return false;
}
return expr instanceof SwitchExpression
|| expr instanceof ConditionalExpression;
}
public void extractConstantsFromArgument(Expression argObj, List<String> constants) { public void extractConstantsFromArgument(Expression argObj, List<String> constants) {
if (argObj instanceof ClassInstanceCreation innerCic) { if (argObj instanceof ClassInstanceCreation innerCic) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(innerCic.getType()); String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(innerCic.getType());
@@ -294,19 +282,37 @@ public class ConstantExtractor {
Set<String> visited, Set<String> visited,
CompilationUnit contextCu, CompilationUnit contextCu,
ResolutionBudget budget) { ResolutionBudget budget) {
return resolveMethodReturnConstant(className, methodName, depth, visited, contextCu, budget, true);
}
public List<String> resolveMethodReturnConstant(
String className,
String methodName,
int depth,
Set<String> visited,
CompilationUnit contextCu,
ResolutionBudget budget,
boolean allowWidenToImplementations) {
final boolean debug = log.isDebugEnabled();
if (debug) {
log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName); log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName);
log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth); log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth);
}
if (budget == null) { if (budget == null) {
budget = ResolutionBudget.defaults(); budget = ResolutionBudget.defaults();
} }
final ResolutionBudget activeBudget = budget; final ResolutionBudget activeBudget = budget;
if (activeBudget.isMethodReturnExhausted(depth)) { if (activeBudget.isMethodReturnExhausted(depth)) {
if (debug) {
log.debug("Exiting resolveMethodReturnConstant early (depth limit) for {}.{}", className, methodName); log.debug("Exiting resolveMethodReturnConstant early (depth limit) for {}.{}", className, methodName);
}
return Collections.emptyList(); return Collections.emptyList();
} }
String fqn = className + "." + methodName; String fqn = className + "." + methodName;
if (!visited.add(fqn)) { if (!visited.add(fqn)) {
if (debug) {
log.debug("Exiting resolveMethodReturnConstant early (cycle detected) for fqn={}", fqn); log.debug("Exiting resolveMethodReturnConstant early (cycle detected) for fqn={}", fqn);
}
return Collections.emptyList(); return Collections.emptyList();
} }
@@ -316,31 +322,56 @@ public class ConstantExtractor {
tempTd = context.getTypeDeclaration(className); tempTd = context.getTypeDeclaration(className);
} }
final TypeDeclaration td = tempTd; final TypeDeclaration td = tempTd;
boolean widenToImplementations = td == null boolean widenToImplementations = allowWidenToImplementations
&& (td == null
|| td.isInterface() || td.isInterface()
|| Modifier.isAbstract(td.getModifiers()); || Modifier.isAbstract(td.getModifiers()));
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter() && !widenToImplementations) { if (widenToImplementations && depth == 0) {
if (indexedAccessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER) { Optional<List<String>> precomputed = context.getPolymorphicAccessorIndex()
List<String> constantGetterValues = extractConstantGetterReturn( .widenedReturnConstants(className, methodName);
indexedAccessor.get().ownerFqn(), methodName, contextCu, visited); if (precomputed.isPresent()) {
if (!constantGetterValues.isEmpty()) {
visited.remove(fqn); visited.remove(fqn);
return constantGetterValues; return new ArrayList<>(precomputed.get());
}
}
List<String> indexedConstants = resolveIndexedGetterFieldConstants(
indexedAccessor.get(), contextCu, visited);
if (!indexedConstants.isEmpty()) {
visited.remove(fqn);
return indexedConstants;
} }
} }
List<String> constants = new ArrayList<>(); List<String> constants = new ArrayList<>();
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
boolean indexedOnRequestedType = className.equals(indexedAccessor.get().ownerFqn());
if (!widenToImplementations || indexedOnRequestedType) {
if (indexedAccessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER) {
List<String> constantGetterValues = extractConstantGetterReturn(
indexedAccessor.get().ownerFqn(), methodName, contextCu, visited);
if (!constantGetterValues.isEmpty()) {
if (!widenToImplementations) {
visited.remove(fqn);
return constantGetterValues;
}
constants.addAll(constantGetterValues);
}
}
if (constants.isEmpty()) {
List<String> indexedConstants = resolveIndexedGetterFieldConstants(
indexedAccessor.get(), className, contextCu, visited);
if (!indexedConstants.isEmpty()) {
if (!widenToImplementations) {
visited.remove(fqn);
return indexedConstants;
}
constants.addAll(indexedConstants);
}
}
}
}
if (td != null) { if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) { if (md != null && md.getBody() != null) {
for (Object stmtObj : md.getBody().statements()) {
if (stmtObj instanceof SwitchStatement switchStatement) {
extractConstantsFromSwitchStatement(switchStatement, constants);
}
}
md.getBody().accept(new ASTVisitor() { md.getBody().accept(new ASTVisitor() {
@Override @Override
public boolean visit(ReturnStatement node) { public boolean visit(ReturnStatement node) {
@@ -361,7 +392,7 @@ public class ConstantExtractor {
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) { if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant( List<String> delegationResult = resolveMethodReturnConstant(
cName, mName, depth + 1, visited, contextCu, activeBudget); cName, mName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
constants.addAll(delegationResult); constants.addAll(delegationResult);
handled = true; handled = true;
} }
@@ -381,7 +412,7 @@ public class ConstantExtractor {
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) { if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant( List<String> delegationResult = resolveMethodReturnConstant(
cName, mName, depth + 1, visited, contextCu, activeBudget); cName, mName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
constants.addAll(delegationResult); constants.addAll(delegationResult);
handled = true; handled = true;
} }
@@ -400,7 +431,13 @@ public class ConstantExtractor {
if (val.startsWith("ENUM_SET:")) { if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) { for (String eVal : val.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal); String parsed = parseEnumSetElement(eVal);
if (!constants.contains(parsed)) constants.add(parsed); if (!constants.contains(parsed)) {
constants.add(parsed);
}
}
} else if (val.startsWith("<SYMBOLIC:") || val.contains("SYMBOLIC:") || val.endsWith(".*>")) {
if (!constants.contains(val)) {
constants.add(val);
} }
} else { } else {
if (!constants.contains(val)) constants.add(val); if (!constants.contains(val)) constants.add(val);
@@ -423,29 +460,56 @@ public class ConstantExtractor {
} }
}); });
} }
if (constants.isEmpty() && widenToImplementations) { if (widenToImplementations) {
List<String> impls = context.getImplementations(className);
if (impls != null) {
for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant(
implName, methodName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
for (String value : delegationResult) {
if (!constants.contains(value)) {
constants.add(value);
}
}
}
}
}
} else if (widenToImplementations) {
List<String> impls = context.getImplementations(className); List<String> impls = context.getImplementations(className);
if (impls != null) { if (impls != null) {
for (String implName : impls) { for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant( List<String> delegationResult = resolveMethodReturnConstant(
implName, methodName, depth + 1, visited, contextCu, activeBudget); implName, methodName, depth + 1, visited, contextCu, activeBudget);
constants.addAll(delegationResult); for (String value : delegationResult) {
if (!constants.contains(value)) {
constants.add(value);
}
} }
} }
} }
} }
visited.remove(fqn); visited.remove(fqn);
if (debug) {
log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants); log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants);
log.debug("resolveMethodReturnConstant for class={}, method={} resolved constants: {}", className, methodName, constants); }
return constants; return constants;
} }
public List<String> resolveIndexedGetterFieldConstants( public List<String> resolveIndexedGetterFieldConstants(
AccessorSummary accessor, AccessorSummary accessor,
String requestingTypeFqn,
CompilationUnit contextCu, CompilationUnit contextCu,
Set<String> visited) { Set<String> visited) {
if (accessor != null && isSafeForPrecomputedFieldConstants(accessor)) {
Optional<List<String>> precomputed = context.getIndexedFieldConstantsIndex()
.lookup(accessor.ownerFqn(), accessor.methodName());
if (precomputed.isPresent()) {
return new ArrayList<>(precomputed.get());
}
}
return AccessorFieldResolver.resolveFieldConstants( return AccessorFieldResolver.resolveFieldConstants(
accessor, accessor,
requestingTypeFqn,
context, context,
constructorAnalyzer, constructorAnalyzer,
contextCu, contextCu,
@@ -464,31 +528,7 @@ public class ConstantExtractor {
if (className == null || className.isBlank()) { if (className == null || className.isBlank()) {
return Optional.empty(); return Optional.empty();
} }
Optional<AccessorSummary> direct = context.getAccessorIndex().lookup(className, methodName); return 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() && !Modifier.isAbstract(typeDeclaration.getModifiers())) {
return Optional.empty();
}
}
List<String> implementations = context.getImplementations(className);
if (implementations == null) {
return Optional.empty();
}
for (String implementation : implementations) {
Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName);
if (implAccessor.isPresent()) {
return implAccessor;
}
}
return Optional.empty();
} }
private List<String> extractConstantGetterReturn( private List<String> extractConstantGetterReturn(
@@ -496,36 +536,60 @@ public class ConstantExtractor {
String methodName, String methodName,
CompilationUnit contextCu, CompilationUnit contextCu,
Set<String> visited) { Set<String> visited) {
TypeDeclaration typeDeclaration = contextCu != null return GetterBodyConstantScanner.extractConstantGetterReturn(
? context.getTypeDeclaration(ownerFqn, contextCu) context, this, constantResolver, ownerFqn, methodName, contextCu, visited);
: null;
if (typeDeclaration == null) {
typeDeclaration = context.getTypeDeclaration(ownerFqn);
} }
if (typeDeclaration == null) {
return List.of();
} private boolean isSafeForPrecomputedFieldConstants(AccessorSummary accessor) {
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true); if (accessor.fieldName() == null || accessor.declaringFqn() == null) {
if (methodDeclaration == null || methodDeclaration.getBody() == null) { return accessor.kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER;
return List.of();
}
List<String> constants = new ArrayList<>();
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
extractConstantsFromExpression(node.getExpression(), constants);
} }
if (!accessor.ownerFqn().equals(accessor.declaringFqn())) {
return false; return false;
} }
}); TypeDeclaration ownerType = context.getTypeDeclaration(accessor.ownerFqn());
return constants; return ownerType != null
&& !ownerType.isInterface()
&& !Modifier.isAbstract(ownerType.getModifiers());
} }
public String parseEnumSetElement(String eVal) { public String parseEnumSetElement(String eVal) {
if (eVal != null && (eVal.startsWith("<SYMBOLIC:") || eVal.contains("SYMBOLIC:") || eVal.endsWith(".*>"))) {
return eVal;
}
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal; return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
} }
public void extractConstantsFromSwitchStatement(SwitchStatement switchStatement, List<String> constants) {
if (switchStatement == null) {
return;
}
extractConstantsFromSwitchLike(switchStatement, constants);
}
private void extractConstantsFromSwitchLike(ASTNode switchNode, List<String> constants) {
switchNode.accept(new ASTVisitor() {
@Override
public boolean visit(YieldStatement ys) {
extractConstantsFromExpression(ys.getExpression(), constants);
return super.visit(ys);
}
@Override
public boolean visit(ExpressionStatement es) {
extractConstantsFromExpression(es.getExpression(), constants);
return super.visit(es);
}
@Override
public boolean visit(ReturnStatement rs) {
extractConstantsFromExpression(rs.getExpression(), constants);
return super.visit(rs);
}
});
}
private boolean isKeyedLookupMethod(MethodInvocation mi) { private boolean isKeyedLookupMethod(MethodInvocation mi) {
if (expressionAccessClassifier != null) { if (expressionAccessClassifier != null) {
return expressionAccessClassifier.isKeyedLookup(mi, null); return expressionAccessClassifier.isKeyedLookup(mi, null);
@@ -604,19 +668,7 @@ public class ConstantExtractor {
} }
} }
private Block findEnclosingBlock(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof Block)) {
parent = parent.getParent();
}
return (Block) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) { private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent(); return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
} }
} }

View File

@@ -14,6 +14,8 @@ public class ConstructorAnalyzer {
private final ConstantResolver constantResolver; private final ConstantResolver constantResolver;
private VariableTracer variableTracer; private VariableTracer variableTracer;
private ConstantExtractor constantExtractor; private ConstantExtractor constantExtractor;
private final Map<String, ClassInstanceCreation> returnedCicCache = new HashMap<>();
private final Set<String> returnedCicAbsent = new HashSet<>();
@FunctionalInterface @FunctionalInterface
public interface RhsResolver { public interface RhsResolver {
@@ -33,6 +35,11 @@ public class ConstructorAnalyzer {
this.constantExtractor = constantExtractor; this.constantExtractor = constantExtractor;
} }
public void clearReturnedCicCache() {
returnedCicCache.clear();
returnedCicAbsent.clear();
}
public List<String> traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set<String> visited) { public List<String> traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set<String> visited) {
return traceFieldInConstructors(td, fieldName, context, visited, ResolutionBudget.defaults()); return traceFieldInConstructors(td, fieldName, context, visited, ResolutionBudget.defaults());
} }
@@ -480,7 +487,20 @@ public class ConstructorAnalyzer {
public ClassInstanceCreation findReturnedCIC( public ClassInstanceCreation findReturnedCIC(
String className, String methodName, CodebaseContext context) { String className, String methodName, CodebaseContext context) {
return findReturnedCIC(className, methodName, context, new HashSet<>(), 0); String cacheKey = className + "#" + methodName;
if (returnedCicAbsent.contains(cacheKey)) {
return null;
}
if (returnedCicCache.containsKey(cacheKey)) {
return returnedCicCache.get(cacheKey);
}
ClassInstanceCreation resolved = findReturnedCIC(className, methodName, context, new HashSet<>(), 0);
if (resolved != null) {
returnedCicCache.put(cacheKey, resolved);
} else {
returnedCicAbsent.add(cacheKey);
}
return resolved;
} }
private ClassInstanceCreation findReturnedCIC( private ClassInstanceCreation findReturnedCIC(
@@ -575,11 +595,15 @@ public class ConstructorAnalyzer {
try { try {
Map<String, String> fieldValues = buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor); Map<String, String> fieldValues = buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor);
if (log.isDebugEnabled()) {
log.debug("RESOLVE_GETTER: td={}, fieldValues={}", context.getFqn(td), fieldValues); log.debug("RESOLVE_GETTER: td={}, fieldValues={}", context.getFqn(td), fieldValues);
}
if (fieldValues.isEmpty()) return Collections.emptyList(); if (fieldValues.isEmpty()) return Collections.emptyList();
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, visited); String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, visited);
if (log.isDebugEnabled()) {
log.debug("RESOLVE_GETTER: result={}", result); log.debug("RESOLVE_GETTER: result={}", result);
}
return result != null ? List.of(result) : Collections.emptyList(); return result != null ? List.of(result) : Collections.emptyList();
} finally { } finally {
visited.remove(getterKey); visited.remove(getterKey);
@@ -797,18 +821,10 @@ public class ConstructorAnalyzer {
} }
private MethodDeclaration findEnclosingMethod(ASTNode node) { private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent(); return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
} }
private TypeDeclaration findEnclosingType(ASTNode node) { private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent(); return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
} }
} }

View File

@@ -2,6 +2,13 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.AnalysisEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.AnalysisEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher; import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher;
@@ -67,6 +74,68 @@ public class EnrichmentService {
intelligence); intelligence);
} }
/**
* JSON re-export path: when source and entry points are available, rebuild call chains from the
* call graph so dispatcher triggers regain {@code polymorphicEvents}/{@code external}, then relink.
*/
public void refreshCallChainsAndRelink(
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() != null && context != null && intelligence != null) {
List<CallChain> refreshed = CallChainEnricher.buildCallChainsForMachine(result, context, intelligence);
if (refreshed != null) {
List<CallChain> merged = mergeCallChainsByEntryPoint(result.getMetadata().getCallChains(), refreshed);
result.setMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.triggers(result.getMetadata().getTriggers())
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(merged)
.properties(result.getMetadata().getProperties())
.build());
}
}
relinkAfterPropertyResolution(result, context, intelligence);
}
private static List<CallChain> mergeCallChainsByEntryPoint(
List<CallChain> existing,
List<CallChain> refreshed) {
Map<String, CallChain> byEntryPoint = new LinkedHashMap<>();
if (existing != null) {
for (CallChain chain : existing) {
byEntryPoint.putIfAbsent(callChainKey(chain), chain);
}
}
for (CallChain chain : refreshed) {
byEntryPoint.put(callChainKey(chain), chain);
}
return new ArrayList<>(byEntryPoint.values());
}
private static String callChainKey(CallChain chain) {
if (chain == null) {
return "";
}
String entryPointKey = entryPointKey(chain.getEntryPoint());
if (!entryPointKey.isBlank()) {
return entryPointKey;
}
if (chain.getTriggerPoint() != null
&& chain.getTriggerPoint().getClassName() != null
&& chain.getTriggerPoint().getMethodName() != null) {
return chain.getTriggerPoint().getClassName() + "#" + chain.getTriggerPoint().getMethodName();
}
return "";
}
private static String entryPointKey(EntryPoint entryPoint) {
if (entryPoint == null) {
return "";
}
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
return entryPoint.getName();
}
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
}
private static void runEnrichers( private static void runEnrichers(
List<AnalysisEnricher> enrichers, List<AnalysisEnricher> enrichers,
AnalysisResult result, AnalysisResult result,

View File

@@ -136,7 +136,11 @@ public final class ExpressionAccessClassifier {
return "Map"; return "Map";
} }
if (receiver instanceof SimpleName sn) { if (receiver instanceof SimpleName sn) {
return lookupDeclaredType(scopeMethod, sn.getIdentifier()); String declared = lookupDeclaredType(scopeMethod, sn.getIdentifier());
if (declared != null) {
return declared;
}
return lookupFieldType(classNameFromScopeMethod(scopeMethod), sn.getIdentifier());
} }
if (receiver instanceof ThisExpression) { if (receiver instanceof ThisExpression) {
return classNameFromScopeMethod(scopeMethod); return classNameFromScopeMethod(scopeMethod);

View File

@@ -0,0 +1,348 @@
package click.kamil.springstatemachineexporter.analysis.service;
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.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.HashSet;
import java.util.Set;
/**
* Single source-derived policy for marking REST triggers as external vs internal.
*/
public final class ExternalTriggerPolicy {
private ExternalTriggerPolicy() {
}
public static boolean isExternalFromSource(
EntryPoint entryPoint,
TriggerPoint trigger,
String entryMethodFqn,
String resolvedEventParamName,
CodebaseContext context) {
if (trigger == null) {
return false;
}
if (entryPoint != null && entryPointHasUnboundPlaceholders(entryPoint)
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
String paramName = resolveRestEventParameterName(entryPoint, resolvedEventParamName);
if (paramName != null) {
RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context);
if (binding != null && binding.pathVariable() && binding.enumType()) {
return false;
}
}
return true;
}
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) {
return false;
}
if (trigger.getPolymorphicEvents() != null
&& !trigger.getPolymorphicEvents().isEmpty()
&& MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) {
return false;
}
if (entryPoint != null && entryPoint.getType() == EntryPoint.Type.REST) {
String paramName = resolveRestEventParameterName(entryPoint, resolvedEventParamName);
if (paramName != null) {
RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context);
if (binding != null) {
if (binding.pathOrQueryVariable() && !binding.enumType()) {
if (binding.pathVariable() && !entryPointHasUnboundPlaceholders(entryPoint)) {
return false;
}
return true;
}
if (binding.requestBodyEnum()) {
return false;
}
}
if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) {
RestParamBinding requestBodyField = findRequestBodyEnumFieldBinding(
entryPoint.getClassName() + "." + entryPoint.getMethodName(), paramName, context);
if (requestBodyField != null && requestBodyField.requestBodyEnum()) {
return false;
}
}
}
if (entryPoint.getName() != null && entryPoint.getName().contains("{")
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
if (paramName != null) {
RestParamBinding pathBinding = findRestParameterBinding(entryPoint, paramName, context);
if (pathBinding != null && pathBinding.pathVariable() && pathBinding.enumType()) {
return false;
}
}
return true;
}
}
if (entryMethodFqn != null && resolvedEventParamName != null) {
RestParamBinding binding = findMethodParameterBinding(entryMethodFqn, resolvedEventParamName, context);
if (binding != null) {
if (binding.pathOrQueryVariable() && !binding.enumType()) {
return true;
}
if (binding.requestBodyEnum()) {
return false;
}
}
RestParamBinding requestBodyField = findRequestBodyEnumFieldBinding(
entryMethodFqn, resolvedEventParamName, context);
if (requestBodyField != null && requestBodyField.requestBodyEnum()) {
return false;
}
}
if (entryPoint != null && entryPoint.getType() == EntryPoint.Type.REST
&& !entryPointHasUnboundPlaceholders(entryPoint)
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
return false;
}
return trigger.isExternal();
}
private static boolean entryPointHasUnboundPlaceholders(EntryPoint entryPoint) {
if (entryPoint == null) {
return false;
}
if (entryPoint.getName() != null && entryPoint.getName().contains("{")) {
return true;
}
if (entryPoint.getMetadata() != null) {
String path = entryPoint.getMetadata().get("path");
if (path != null && path.contains("{")) {
return true;
}
}
return false;
}
private static String resolveRestEventParameterName(EntryPoint entryPoint, String resolvedEventParamName) {
if (resolvedEventParamName != null && !resolvedEventParamName.isBlank()) {
return resolvedEventParamName;
}
if (entryPoint.getParameters() == null) {
return null;
}
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
if (parameter.getAnnotations() == null) {
continue;
}
for (String annotation : parameter.getAnnotations()) {
if ("PathVariable".equals(annotation) || "RequestParam".equals(annotation)) {
return parameter.getName();
}
}
}
return null;
}
private static RestParamBinding findRestParameterBinding(
EntryPoint entryPoint, String paramName, CodebaseContext context) {
if (entryPoint.getParameters() == null || paramName == null) {
return null;
}
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
if (!paramName.equals(parameter.getName())) {
continue;
}
return RestParamBinding.fromEntryPointParameter(parameter);
}
if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) {
return findMethodParameterBinding(
entryPoint.getClassName() + "." + entryPoint.getMethodName(), paramName, context);
}
return null;
}
private static RestParamBinding findMethodParameterBinding(
String methodFqn, String paramName, CodebaseContext context) {
if (methodFqn == null || !methodFqn.contains(".") || paramName == null || context == null) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (method == null) {
return null;
}
for (Object paramObj : method.parameters()) {
if (!(paramObj instanceof SingleVariableDeclaration param)) {
continue;
}
if (!paramName.equals(param.getName().getIdentifier())) {
continue;
}
return RestParamBinding.fromAstParameter(param);
}
return null;
}
private static RestParamBinding findRequestBodyEnumFieldBinding(
String methodFqn, String fieldName, CodebaseContext context) {
if (methodFqn == null || !methodFqn.contains(".") || fieldName == null || context == null) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (method == null) {
return null;
}
for (Object paramObj : method.parameters()) {
if (!(paramObj instanceof SingleVariableDeclaration param)) {
continue;
}
if (!hasParameterAstAnnotation(param, "RequestBody") || param.getType() == null) {
continue;
}
ITypeBinding bodyBinding = param.getType().resolveBinding();
if (bodyBinding == null || bodyBinding.isEnum()) {
continue;
}
if (isEnumRecordComponent(bodyBinding, fieldName)) {
return new RestParamBinding(false, false, true, true);
}
if (isEnumDtoField(bodyBinding, fieldName, context)) {
return new RestParamBinding(false, false, true, true);
}
if (containsNestedEnumField(bodyBinding, fieldName, context)) {
return new RestParamBinding(false, false, true, true);
}
}
return null;
}
private static boolean isEnumRecordComponent(ITypeBinding bodyBinding, String fieldName) {
if (bodyBinding == null || fieldName == null) {
return false;
}
for (IVariableBinding component : bodyBinding.getDeclaredFields()) {
if (!fieldName.equals(component.getName())) {
continue;
}
ITypeBinding fieldType = component.getType();
return fieldType != null && fieldType.isEnum();
}
return false;
}
private static boolean isEnumDtoField(ITypeBinding bodyBinding, String fieldName, CodebaseContext context) {
if (bodyBinding == null || fieldName == null || context == null) {
return false;
}
String bodyFqn = bodyBinding.getQualifiedName();
if (bodyFqn == null) {
return false;
}
AbstractTypeDeclaration bodyType = context.getAbstractTypeDeclaration(bodyFqn);
if (!(bodyType instanceof TypeDeclaration td)) {
return false;
}
for (FieldDeclaration field : td.getFields()) {
for (Object fragObj : field.fragments()) {
if (!(fragObj instanceof VariableDeclarationFragment fragment)) {
continue;
}
if (!fieldName.equals(fragment.getName().getIdentifier())) {
continue;
}
ITypeBinding fieldBinding = field.getType().resolveBinding();
return fieldBinding != null && fieldBinding.isEnum();
}
}
return false;
}
private static boolean containsNestedEnumField(
ITypeBinding bodyBinding, String fieldName, CodebaseContext context) {
return containsNestedEnumField(bodyBinding, fieldName, context, new HashSet<>());
}
private static boolean containsNestedEnumField(
ITypeBinding bodyBinding,
String fieldName,
CodebaseContext context,
Set<String> visited) {
if (bodyBinding == null || fieldName == null || context == null) {
return false;
}
String bodyFqn = bodyBinding.getQualifiedName();
if (bodyFqn == null || !visited.add(bodyFqn)) {
return false;
}
for (IVariableBinding component : bodyBinding.getDeclaredFields()) {
ITypeBinding nestedType = component.getType();
if (nestedType == null || nestedType.isPrimitive() || nestedType.isEnum()) {
continue;
}
if (isEnumRecordComponent(nestedType, fieldName) || isEnumDtoField(nestedType, fieldName, context)) {
return true;
}
if (containsNestedEnumField(nestedType, fieldName, context, visited)) {
return true;
}
}
return false;
}
private static boolean hasParameterAstAnnotation(SingleVariableDeclaration param, String simpleName) {
for (Object modifier : param.modifiers()) {
if (modifier instanceof Annotation annotation
&& annotation.getTypeName().getFullyQualifiedName().endsWith(simpleName)) {
return true;
}
}
return false;
}
private record RestParamBinding(boolean pathVariable, boolean requestParam, boolean requestBody, boolean enumType) {
boolean pathOrQueryVariable() {
return pathVariable || requestParam;
}
boolean requestBodyEnum() {
return requestBody && enumType;
}
static RestParamBinding fromEntryPointParameter(EntryPoint.Parameter parameter) {
boolean pathVariable = hasAnnotation(parameter.getAnnotations(), "PathVariable");
boolean requestParam = hasAnnotation(parameter.getAnnotations(), "RequestParam");
boolean requestBody = hasAnnotation(parameter.getAnnotations(), "RequestBody");
boolean enumType = isEnumTypeName(parameter.getType());
return new RestParamBinding(pathVariable, requestParam, requestBody, enumType);
}
static RestParamBinding fromAstParameter(SingleVariableDeclaration param) {
boolean pathVariable = hasParameterAstAnnotation(param, "PathVariable");
boolean requestParam = hasParameterAstAnnotation(param, "RequestParam");
boolean requestBody = hasParameterAstAnnotation(param, "RequestBody");
boolean enumType = param.getType() != null && param.getType().resolveBinding() != null
&& param.getType().resolveBinding().isEnum();
return new RestParamBinding(pathVariable, requestParam, requestBody, enumType);
}
private static boolean hasAnnotation(java.util.List<String> annotations, String name) {
return annotations != null && annotations.stream().anyMatch(name::equals);
}
private static boolean isEnumTypeName(String typeName) {
if (typeName == null || typeName.isBlank()) {
return false;
}
int lastDot = typeName.lastIndexOf('.');
String simple = lastDot >= 0 ? typeName.substring(lastDot + 1) : typeName;
return Character.isUpperCase(simple.charAt(0)) && !simple.endsWith("String");
}
}
}

View File

@@ -0,0 +1,84 @@
package click.kamil.springstatemachineexporter.analysis.service;
import java.util.Set;
/**
* Recognizes JDK functional interface parameters used as delayed event providers
* ({@code Supplier.get()}, {@code Runnable.run()}, {@code Consumer.accept()}, etc.)
* in call-graph argument tracing.
*/
public final class FunctionalInterfaceTypes {
private static final Set<String> SAM_METHOD_NAMES = Set.of(
"run", "get", "call", "accept", "apply", "test");
private FunctionalInterfaceTypes() {
}
public static boolean isSamMethodName(String methodName) {
return methodName != null && SAM_METHOD_NAMES.contains(methodName);
}
/**
* Whether {@code typeName.methodName} is a single-abstract-method invocation on a functional interface
* (e.g. {@code java.lang.Runnable.run}, {@code java.util.function.Consumer.accept}).
*/
public static boolean isFunctionalSamMethod(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) {
return false;
}
int lastDot = methodFqn.lastIndexOf('.');
String methodName = methodFqn.substring(lastDot + 1);
String typeName = methodFqn.substring(0, lastDot);
return isSamMethodName(methodName) && isFunctionalInterface(typeName);
}
public static boolean isFunctionalInterface(String typeName) {
if (typeName == null || typeName.isBlank()) {
return false;
}
String simple = typeName.contains(".") ? typeName.substring(typeName.lastIndexOf('.') + 1) : typeName;
if (simple.contains("<")) {
simple = simple.substring(0, simple.indexOf('<'));
}
return switch (simple) {
case "Runnable", "Supplier", "Function", "Callable", "Consumer", "BiConsumer", "Predicate", "BiFunction" ->
true;
default -> simple.contains("Supplier") || simple.contains("Function") || simple.contains("Callable");
};
}
public static boolean isLambdaArgument(String argValue) {
if (argValue == null) {
return false;
}
String trimmed = argValue.trim();
return trimmed.contains("->")
&& (trimmed.startsWith("(") || trimmed.matches("^\\w+\\s*->.*"));
}
/**
* When the caller passes a lambda or enum literal into a functional parameter, skip
* incompatible-type rejection between {@code Supplier<T>} and resolved {@code T}.
*/
public static boolean isProvablyResolvedCallSiteArgument(String argValue, String expectedType) {
if (argValue == null || argValue.isBlank() || expectedType == null) {
return false;
}
if (!isFunctionalInterface(expectedType)) {
return false;
}
if (looksLikeEnumConstant(argValue)) {
return true;
}
return isLambdaArgument(argValue);
}
static boolean looksLikeEnumConstant(String value) {
if (value == null || value.isBlank()) {
return false;
}
String name = value.contains(".") ? value.substring(value.lastIndexOf('.') + 1) : value;
return name.matches("[A-Z_][A-Z0-9_]*");
}
}

View File

@@ -2,9 +2,9 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint; import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
@@ -14,11 +14,18 @@ import java.util.List;
import java.util.Set; import java.util.Set;
@Slf4j @Slf4j
@RequiredArgsConstructor
public class GenericEventDetector { public class GenericEventDetector {
private final CodebaseContext context; private final CodebaseContext context;
private final ConstantResolver constantResolver; private final ConstantResolver constantResolver;
private final List<LibraryHint> hints; private final List<LibraryHint> hints;
private final TypeResolver typeResolver;
public GenericEventDetector(CodebaseContext context, ConstantResolver constantResolver, List<LibraryHint> hints) {
this.context = context;
this.constantResolver = constantResolver;
this.hints = hints;
this.typeResolver = new TypeResolver(context);
}
/** Parameter names that route to a machine, not SM state enum constants. */ /** Parameter names that route to a machine, not SM state enum constants. */
private static final Set<String> ROUTING_PARAMETER_NAMES = Set.of( private static final Set<String> ROUTING_PARAMETER_NAMES = Set.of(
@@ -34,6 +41,11 @@ public class GenericEventDetector {
private static final Set<String> TRIGGER_METHOD_NAMES = Set.of( private static final Set<String> TRIGGER_METHOD_NAMES = Set.of(
"sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger"); "sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger");
/** Built-in source patterns for Spring State Machine and messaging APIs (no hints.json required). */
private static final List<LibraryHint> BUILTIN_SOURCE_PATTERNS = List.of(
LibraryHint.builder().methodFqn("org.springframework.messaging.support.MessageBuilder.build").eventArgumentIndex(0).build(),
LibraryHint.builder().methodFqn("org.springframework.messaging.support.GenericMessage.getPayload").eventArgumentMethod("getPayload").build());
public List<TriggerPoint> detect(CompilationUnit cu) { public List<TriggerPoint> detect(CompilationUnit cu) {
List<TriggerPoint> triggers = new ArrayList<>(); List<TriggerPoint> triggers = new ArrayList<>();
String fileName = "unknown"; String fileName = "unknown";
@@ -61,6 +73,7 @@ public class GenericEventDetector {
} }
} }
processBuiltInPatterns(node, cu, triggers);
processHints(node, cu, triggers); processHints(node, cu, triggers);
return super.visit(node); return super.visit(node);
@@ -76,11 +89,15 @@ public class GenericEventDetector {
eventValue = receiver != null ? receiver.toString() : null; eventValue = receiver != null ? receiver.toString() : null;
} }
MethodDeclaration method = findEnclosingMethod(node); MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
AbstractTypeDeclaration type = findEnclosingAbstractType(node); AbstractTypeDeclaration type = findEnclosingAbstractType(node);
if (type == null) return; if (type == null) return;
String sourceState = extractSourceState(node); String sourceState = extractSourceState(node);
if (sourceState == null) {
sourceState = extractSourceStateFromArguments(node);
}
String stateMachineId = extractStateMachineId(node);
String[] smTypes = resolveStateMachineTypeArgumentsForExpression(emr.getExpression(), node); String[] smTypes = resolveStateMachineTypeArgumentsForExpression(emr.getExpression(), node);
boolean external = false; boolean external = false;
@@ -94,6 +111,7 @@ public class GenericEventDetector {
triggers.add(TriggerPoint.builder() triggers.add(TriggerPoint.builder()
.event(eventValue) .event(eventValue)
.sourceState(sourceState) .sourceState(sourceState)
.stateMachineId(stateMachineId)
.className(context.getFqn(type)) .className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer") .methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type))) .sourceFile(context.getRelativePath(context.getFqn(type)))
@@ -107,17 +125,15 @@ public class GenericEventDetector {
} }
private String extractEventFromReceiver(Expression receiver) { private String extractEventFromReceiver(Expression receiver) {
String reactivePayload = ReactiveExpressionSupport.extractPayload(receiver, constantResolver, context);
if (reactivePayload != null) {
return reactivePayload;
}
if (receiver == null) return null; if (receiver == null) return null;
if (receiver instanceof MethodInvocation mi) { Expression factoryPayload = ReactiveExpressionSupport.peelFactoryPayloadExpression(receiver);
String mName = mi.getName().getIdentifier(); if (factoryPayload != null) {
if (("just".equals(mName) || "withPayload".equals(mName) || "success".equals(mName)) && !mi.arguments().isEmpty()) { String resolved = constantResolver.resolve(factoryPayload, context);
Expression arg = (Expression) mi.arguments().get(0); return resolved != null ? resolved : factoryPayload.toString();
String resolved = constantResolver.resolve(arg, context);
return resolved != null ? resolved : arg.toString();
}
if (mi.getExpression() != null) {
return extractEventFromReceiver(mi.getExpression());
}
} }
if (receiver instanceof SimpleName sn) { if (receiver instanceof SimpleName sn) {
return sn.getIdentifier(); return sn.getIdentifier();
@@ -144,8 +160,8 @@ public class GenericEventDetector {
List<?> typeArgs = pt.typeArguments(); List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) { if (typeArgs.size() >= 2) {
CompilationUnit compilationUnit = (CompilationUnit) node.getRoot(); CompilationUnit compilationUnit = (CompilationUnit) node.getRoot();
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit); String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit);
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit); String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit);
return new String[]{stateType, eventType}; return new String[]{stateType, eventType};
} }
} }
@@ -157,20 +173,71 @@ public class GenericEventDetector {
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, null); List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, null);
if (builtTriggers != null) { if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) { for (TriggerPoint trigger : builtTriggers) {
if (log.isDebugEnabled()) {
log.debug("Successfully built trigger point: {}", trigger.getEvent()); log.debug("Successfully built trigger point: {}", trigger.getEvent());
}
triggers.add(trigger); triggers.add(trigger);
} }
} }
} }
private void processBuiltInPatterns(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
String methodName = node.getName().getIdentifier();
if (TRIGGER_METHOD_NAMES.contains(methodName)) {
return;
}
String calledMethod = resolveCalledMethodName(node);
if (calledMethod == null) {
return;
}
for (LibraryHint pattern : BUILTIN_SOURCE_PATTERNS) {
if (!matchesHint(calledMethod, pattern.getMethodFqn())) {
continue;
}
String eventToUse = resolveHintEvent(node, pattern);
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
if (log.isDebugEnabled()) {
log.debug("Built trigger from built-in source pattern {}: {}", pattern.getMethodFqn(), trigger.getEvent());
}
triggers.add(trigger);
}
}
}
}
private void processHints(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) { private void processHints(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
if (hints == null || hints.isEmpty()) return; if (hints == null || hints.isEmpty()) {
return;
}
String calledMethod = resolveCalledMethodName(node); String calledMethod = resolveCalledMethodName(node);
if (calledMethod == null) return; if (calledMethod == null) {
return;
}
for (LibraryHint hint : hints) { for (LibraryHint hint : hints) {
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) { if (!matchesHint(calledMethod, hint.getMethodFqn())) {
continue;
}
if (log.isDebugEnabled()) {
log.debug("Applying optional hints.json override for {}", hint.getMethodFqn());
}
String eventToUse = resolveHintEvent(node, hint);
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
if (log.isDebugEnabled()) {
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
}
triggers.add(trigger);
}
}
}
}
private static String resolveHintEvent(MethodInvocation node, LibraryHint hint) {
String eventToUse = hint.getEvent(); String eventToUse = hint.getEvent();
if (eventToUse == null && hint.getEventArgumentIndex() != null) { if (eventToUse == null && hint.getEventArgumentIndex() != null) {
if (node.arguments().size() > hint.getEventArgumentIndex()) { if (node.arguments().size() > hint.getEventArgumentIndex()) {
@@ -181,20 +248,11 @@ public class GenericEventDetector {
} }
} }
} }
return eventToUse;
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
triggers.add(trigger);
}
}
}
}
} }
private boolean isHintMatch(String called, String hintFqn) { private static boolean matchesHint(String calledMethod, String hintFqn) {
return hintFqn.endsWith("." + called); return calledMethod.equals(hintFqn) || hintFqn.endsWith("." + calledMethod);
} }
private String resolveCalledMethodName(MethodInvocation node) { private String resolveCalledMethodName(MethodInvocation node) {
@@ -233,14 +291,23 @@ public class GenericEventDetector {
} else { } else {
eventValue = context.resolveExpression(eventExpr); eventValue = context.resolveExpression(eventExpr);
} }
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetter(
eventExpr, constantResolver, context);
if (remapped != null) {
eventValue = remapped;
}
} }
MethodDeclaration method = findEnclosingMethod(node); MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
AbstractTypeDeclaration type = findEnclosingAbstractType(node); AbstractTypeDeclaration type = findEnclosingAbstractType(node);
if (type == null) return Collections.emptyList(); if (type == null) return Collections.emptyList();
String sourceState = extractSourceState(node); String sourceState = extractSourceState(node);
if (sourceState == null && node instanceof MethodInvocation mi) {
sourceState = extractSourceStateFromArguments(mi);
}
String stateMachineId = extractStateMachineId(node);
String[] smTypes = resolveStateMachineTypeArguments(node); String[] smTypes = resolveStateMachineTypeArguments(node);
boolean external = false; boolean external = false;
@@ -260,6 +327,7 @@ public class GenericEventDetector {
results.add(TriggerPoint.builder() results.add(TriggerPoint.builder()
.event(part.trim()) .event(part.trim())
.sourceState(sourceState) .sourceState(sourceState)
.stateMachineId(stateMachineId)
.className(context.getFqn(type)) .className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer") .methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type))) .sourceFile(context.getRelativePath(context.getFqn(type)))
@@ -275,6 +343,7 @@ public class GenericEventDetector {
results.add(TriggerPoint.builder() results.add(TriggerPoint.builder()
.event(eventValue) .event(eventValue)
.sourceState(sourceState) .sourceState(sourceState)
.stateMachineId(stateMachineId)
.className(context.getFqn(type)) .className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer") .methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type))) .sourceFile(context.getRelativePath(context.getFqn(type)))
@@ -313,6 +382,21 @@ public class GenericEventDetector {
String state = extractStateFromSiblings(current, switchStmt.statements()); String state = extractStateFromSiblings(current, switchStmt.statements());
if (state != null) return state; if (state != null) return state;
} }
} else if (parent instanceof SwitchExpression switchExpr) {
if (!isRoutingParameter(switchExpr.getExpression())) {
String state = extractStateFromSiblings(current, switchExpr.statements());
if (state != null) return state;
}
} else if (parent instanceof SwitchCase switchCase && !switchCase.isDefault()) {
Expression selector = resolveSwitchSelector(switchCase);
if (selector != null && !isRoutingParameter(selector) && !switchCase.expressions().isEmpty()) {
Expression caseExpr = (Expression) switchCase.expressions().get(0);
String resolved = resolveProvableStateLiteral(caseExpr);
if (resolved != null) {
return resolved;
}
return getSimpleNameString(caseExpr);
}
} }
current = parent; current = parent;
@@ -320,6 +404,251 @@ public class GenericEventDetector {
return null; return null;
} }
private Expression resolveSwitchSelector(SwitchCase switchCase) {
ASTNode switchParent = switchCase.getParent();
if (switchParent instanceof SwitchStatement switchStatement) {
return switchStatement.getExpression();
}
if (switchParent instanceof SwitchExpression switchExpression) {
return switchExpression.getExpression();
}
return null;
}
private String extractStateMachineId(MethodInvocation node) {
if (!TRIGGER_METHOD_NAMES.contains(node.getName().getIdentifier())) {
return null;
}
Expression receiver = node.getExpression();
while (receiver instanceof MethodInvocation methodInvocation) {
receiver = methodInvocation.getExpression();
}
if (receiver == null) {
return null;
}
AbstractTypeDeclaration enclosing = findEnclosingAbstractType(node);
if (enclosing == null) {
return null;
}
return resolveQualifierForReceiver(receiver, enclosing);
}
private String resolveQualifierForReceiver(Expression receiver, AbstractTypeDeclaration enclosing) {
String fieldName = null;
if (receiver instanceof SimpleName simpleName) {
fieldName = simpleName.getIdentifier();
IBinding binding = simpleName.resolveBinding();
if (binding instanceof IVariableBinding variableBinding) {
String fromBinding = extractQualifierFromBinding(variableBinding);
if (fromBinding != null) {
return fromBinding;
}
}
} else if (receiver instanceof FieldAccess fieldAccess) {
fieldName = fieldAccess.getName().getIdentifier();
}
if (fieldName == null) {
return null;
}
return findQualifierOnField(enclosing, fieldName);
}
private String findQualifierOnField(AbstractTypeDeclaration enclosing, String fieldName) {
for (FieldDeclaration fieldDeclaration : getFieldDeclarations(enclosing)) {
for (Object fragmentObj : fieldDeclaration.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(fieldName)) {
String qualifier = extractQualifierAnnotation(fieldDeclaration.modifiers());
if (qualifier != null) {
return qualifier;
}
}
}
}
if (enclosing instanceof TypeDeclaration typeDeclaration) {
for (MethodDeclaration method : typeDeclaration.getMethods()) {
if (!hasAutowiredAnnotation(method) || method.parameters().size() != 1) {
continue;
}
SingleVariableDeclaration param = (SingleVariableDeclaration) method.parameters().get(0);
if (!matchesInjectionTarget(method, fieldName, param)) {
continue;
}
String qualifier = extractQualifierAnnotation(param.modifiers());
if (qualifier != null) {
return qualifier;
}
}
}
return null;
}
private static boolean hasAutowiredAnnotation(MethodDeclaration method) {
for (Object modifierObj : method.modifiers()) {
if (modifierObj instanceof Annotation annotation
&& annotation.getTypeName().toString().endsWith("Autowired")) {
return true;
}
}
return false;
}
private static boolean matchesInjectionTarget(
MethodDeclaration method, String fieldName, SingleVariableDeclaration param) {
if (fieldName.equals(param.getName().getIdentifier())) {
return true;
}
String methodName = method.getName().getIdentifier();
if (methodName.startsWith("set") && methodName.length() > 3) {
String propertyName = methodName.substring(3);
return propertyName.equalsIgnoreCase(fieldName)
|| (Character.toLowerCase(propertyName.charAt(0)) + propertyName.substring(1)).equals(fieldName);
}
return false;
}
private String extractQualifierFromBinding(IVariableBinding binding) {
if (binding == null) {
return null;
}
String qualifier = readQualifierFromAnnotations(binding.getAnnotations());
if (qualifier != null) {
return qualifier;
}
if (binding.isField() && binding.getDeclaringClass() != null) {
for (IMethodBinding method : binding.getDeclaringClass().getDeclaredMethods()) {
boolean isAutowiredMethod = false;
for (IAnnotationBinding ann : method.getAnnotations()) {
if (ann.getAnnotationType() != null
&& "org.springframework.beans.factory.annotation.Autowired"
.equals(ann.getAnnotationType().getQualifiedName())) {
isAutowiredMethod = true;
break;
}
}
if (!method.isConstructor() && !isAutowiredMethod) {
continue;
}
for (int i = 0; i < method.getParameterTypes().length; i++) {
if (!method.getParameterTypes()[i].isEqualTo(binding.getType().getErasure())) {
continue;
}
if (method.isConstructor() && !binding.getName().equals(method.getParameterNames()[i])) {
continue;
}
if (!method.isConstructor() && !matchesSetterParameterName(method.getName(), binding.getName())) {
continue;
}
String paramQualifier = readQualifierFromAnnotations(method.getParameterAnnotations(i));
if (paramQualifier != null) {
return paramQualifier;
}
}
}
}
return null;
}
private static boolean matchesSetterParameterName(String methodName, String fieldName) {
if (methodName == null || fieldName == null) {
return false;
}
if (methodName.startsWith("set") && methodName.length() > 3) {
String propertyName = methodName.substring(3);
return propertyName.equalsIgnoreCase(fieldName)
|| (Character.toLowerCase(propertyName.charAt(0)) + propertyName.substring(1)).equals(fieldName);
}
return fieldName.equals(methodName);
}
private String readQualifierFromAnnotations(IAnnotationBinding[] annotations) {
if (annotations == null) {
return null;
}
for (IAnnotationBinding annotation : annotations) {
if (annotation.getAnnotationType() == null
|| !"org.springframework.beans.factory.annotation.Qualifier"
.equals(annotation.getAnnotationType().getQualifiedName())) {
continue;
}
for (IMemberValuePairBinding pair : annotation.getDeclaredMemberValuePairs()) {
Object value = pair.getValue();
if (value instanceof String stringValue) {
return stringValue;
}
}
}
return null;
}
private String extractQualifierAnnotation(List<?> modifiers) {
if (modifiers == null) {
return null;
}
for (Object modifierObj : modifiers) {
if (!(modifierObj instanceof Annotation annotation)) {
continue;
}
String typeName = annotation.getTypeName().toString();
if (!typeName.endsWith("Qualifier")) {
continue;
}
String value = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(
annotation, "value");
if (value == null || value.isBlank()) {
value = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(
annotation, "name");
}
return stripAnnotationQuotes(value);
}
return null;
}
private static String stripAnnotationQuotes(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;
}
/**
* Infers source state from a literal second argument to sendEvent(event, sourceState) when provable from AST.
*/
private String extractSourceStateFromArguments(MethodInvocation node) {
if (node.arguments().size() < 2) {
return null;
}
Expression stateExpr = (Expression) node.arguments().get(1);
return resolveProvableStateLiteral(stateExpr);
}
private String resolveProvableStateLiteral(Expression expr) {
if (expr == null) {
return null;
}
Expression peeled = expr;
while (peeled instanceof ParenthesizedExpression pe) {
peeled = pe.getExpression();
}
while (peeled instanceof CastExpression ce) {
peeled = ce.getExpression();
}
if (peeled instanceof QualifiedName || peeled instanceof FieldAccess || peeled instanceof StringLiteral) {
String resolved = constantResolver.resolve(peeled, context);
if (resolved != null && !resolved.isBlank()) {
return getSimpleNameString(peeled);
}
if (peeled instanceof StringLiteral sl) {
return sl.getLiteralValue();
}
}
return null;
}
private String extractStateFromSiblings(ASTNode currentNode, List<?> statements) { private String extractStateFromSiblings(ASTNode currentNode, List<?> statements) {
int index = statements.indexOf(currentNode); int index = statements.indexOf(currentNode);
if (index <= 0) return null; // No previous siblings if (index <= 0) return null; // No previous siblings
@@ -374,19 +703,14 @@ public class GenericEventDetector {
return null; return null;
} }
// Usually one is a method call like getState() or a variable like `state` String leftState = resolveProvableStateLiteral(left);
// and the other is the constant enum like `OrderState.PENDING` or `"PENDING"` if (leftState != null) {
return leftState;
// If one is a QualifiedName (enum constant) or StringLiteral, it's likely the state
if (left instanceof QualifiedName || left instanceof StringLiteral || left instanceof FieldAccess) {
return getSimpleNameString(left);
} }
if (right instanceof QualifiedName || right instanceof StringLiteral || right instanceof FieldAccess) { String rightState = resolveProvableStateLiteral(right);
return getSimpleNameString(right); if (rightState != null) {
return rightState;
} }
// Fallback
return getSimpleNameString(right);
} }
} else if (expr instanceof MethodInvocation mi) { } else if (expr instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier(); String methodName = mi.getName().getIdentifier();
@@ -398,21 +722,11 @@ public class GenericEventDetector {
return null; return null;
} }
// If receiver is null (e.g., implicit this), fall back to arg String receiverState = resolveProvableStateLiteral(receiver);
if (receiver == null) { if (receiverState != null) {
return getSimpleNameString(arg); return receiverState;
} }
return resolveProvableStateLiteral(arg);
// Prioritize the one that looks like a constant (QualifiedName or StringLiteral)
if (receiver instanceof QualifiedName || receiver instanceof StringLiteral || receiver instanceof FieldAccess) {
return getSimpleNameString(receiver);
}
if (arg instanceof QualifiedName || arg instanceof StringLiteral || arg instanceof FieldAccess) {
return getSimpleNameString(arg);
}
// Fallback to receiver
return getSimpleNameString(receiver);
} }
} }
return null; return null;
@@ -443,7 +757,7 @@ public class GenericEventDetector {
if (expr instanceof SimpleName sn) { if (expr instanceof SimpleName sn) {
String varName = sn.getIdentifier(); String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(expr); MethodDeclaration enclosingMethod = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(expr);
if (enclosingMethod != null) { if (enclosingMethod != null) {
// Find variable declaration // Find variable declaration
final Expression[] initializer = new Expression[1]; final Expression[] initializer = new Expression[1];
@@ -484,38 +798,35 @@ public class GenericEventDetector {
if (!(expr instanceof MethodInvocation mi)) return null; if (!(expr instanceof MethodInvocation mi)) return null;
// Trace back chain: MessageBuilder.withPayload(event).setHeader(...).build() Expression factoryPayload = ReactiveExpressionSupport.peelFactoryPayloadExpression(mi);
MethodInvocation current = mi; if (factoryPayload != null) {
while (current != null) { String extracted = extractEventFromMessageBuilder(factoryPayload);
String name = current.getName().getIdentifier();
if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) {
Expression payloadExpr = (Expression) current.arguments().get(0);
// If it's Mono.just(msg), where msg is a variable
if ("just".equals(name)) {
String extracted = extractEventFromMessageBuilder(payloadExpr);
if (extracted != null) return extracted;
}
String resolved = constantResolver.resolve(payloadExpr, context);
if (resolved != null) return resolved;
// If not a constant, it might be a parameter like 'event'
if (payloadExpr instanceof CastExpression ce) {
payloadExpr = ce.getExpression();
}
if (payloadExpr instanceof SimpleName sn) {
String extracted = extractEventFromMessageBuilder(payloadExpr);
if (extracted != null) { if (extracted != null) {
return extracted; return extracted;
} }
return sn.getIdentifier(); // Fall back to returning the parameter name String resolved = constantResolver.resolve(factoryPayload, context);
if (resolved != null) {
return resolved;
}
Expression payloadExpr = factoryPayload;
if (payloadExpr instanceof CastExpression ce) {
payloadExpr = ce.getExpression();
}
if (payloadExpr instanceof SimpleName sn) {
String traced = extractEventFromMessageBuilder(payloadExpr);
if (traced != null) {
return traced;
}
return sn.getIdentifier();
} }
return payloadExpr.toString(); return payloadExpr.toString();
} else if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) { }
MethodInvocation current = mi;
while (current != null) {
if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) {
// If the event is obtained by calling a method on a provider/supplier parameter, // If the event is obtained by calling a method on a provider/supplier parameter,
// return the provider's name so CallGraphBuilder can trace the lambda argument // return the provider's name so the call-graph engine can trace the lambda argument
String traced = extractEventFromMessageBuilder(sn); String traced = extractEventFromMessageBuilder(sn);
return traced != null ? traced : sn.getIdentifier(); return traced != null ? traced : sn.getIdentifier();
} }
@@ -530,19 +841,6 @@ public class GenericEventDetector {
return null; return null;
} }
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
return type instanceof TypeDeclaration td ? td : null;
}
private AbstractTypeDeclaration findEnclosingAbstractType(ASTNode node) { private AbstractTypeDeclaration findEnclosingAbstractType(ASTNode node) {
ASTNode parent = node.getParent(); ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof AbstractTypeDeclaration)) { while (parent != null && !(parent instanceof AbstractTypeDeclaration)) {
@@ -620,8 +918,8 @@ public class GenericEventDetector {
List<?> typeArgs = pt.typeArguments(); List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) { if (typeArgs.size() >= 2) {
CompilationUnit cu = (CompilationUnit) node.getRoot(); CompilationUnit cu = (CompilationUnit) node.getRoot();
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), cu); String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), cu);
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), cu); String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), cu);
if (stateType != null && stateType.length() == 1) { if (stateType != null && stateType.length() == 1) {
stateType = resolveGenericTypeVariable(stateType, node, cu); stateType = resolveGenericTypeVariable(stateType, node, cu);
@@ -651,7 +949,7 @@ public class GenericEventDetector {
Type superclassType = td.getSuperclassType(); Type superclassType = td.getSuperclassType();
if (superclassType instanceof ParameterizedType pt) { if (superclassType instanceof ParameterizedType pt) {
Type baseType = pt.getType(); Type baseType = pt.getType();
String baseName = resolveTypeToFqn(baseType, (CompilationUnit) implNode.getRoot()); String baseName = typeResolver.resolveTypeToFqn(baseType, (CompilationUnit) implNode.getRoot());
if (className.equals(baseName) || className.endsWith("." + baseName)) { if (className.equals(baseName) || className.endsWith("." + baseName)) {
int typeIndex = -1; int typeIndex = -1;
List<?> typeParams = enclosingClass instanceof TypeDeclaration typeDecl List<?> typeParams = enclosingClass instanceof TypeDeclaration typeDecl
@@ -668,7 +966,7 @@ public class GenericEventDetector {
} }
if (typeIndex >= 0 && typeIndex < pt.typeArguments().size()) { if (typeIndex >= 0 && typeIndex < pt.typeArguments().size()) {
Type concreteType = (Type) pt.typeArguments().get(typeIndex); Type concreteType = (Type) pt.typeArguments().get(typeIndex);
return resolveTypeToFqn(concreteType, (CompilationUnit) implNode.getRoot()); return typeResolver.resolveTypeToFqn(concreteType, (CompilationUnit) implNode.getRoot());
} }
} }
} }
@@ -700,7 +998,7 @@ public class GenericEventDetector {
if (!(receiver instanceof SimpleName sn)) return null; if (!(receiver instanceof SimpleName sn)) return null;
String varName = sn.getIdentifier(); String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(node); MethodDeclaration enclosingMethod = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
if (enclosingMethod != null) { if (enclosingMethod != null) {
// Check params // Check params
for (Object paramObj : enclosingMethod.parameters()) { for (Object paramObj : enclosingMethod.parameters()) {
@@ -742,41 +1040,6 @@ public class GenericEventDetector {
return null; return null;
} }
private String resolveTypeToFqn(Type type, CompilationUnit cu) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), cu);
} else {
simpleName = type.toString();
}
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
// Check package name
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) return context.getFqn(localTd);
}
return simpleName;
}
private static class AssignmentDag { private static class AssignmentDag {
final java.util.Map<String, List<Expression>> assignments = new java.util.HashMap<>(); final java.util.Map<String, List<Expression>> assignments = new java.util.HashMap<>();
final java.util.Set<SingleVariableDeclaration> parameters = new java.util.HashSet<>(); final java.util.Set<SingleVariableDeclaration> parameters = new java.util.HashSet<>();

View File

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

View File

@@ -0,0 +1,65 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
/**
* Resolves call-graph target FQNs for {@code this}, implicit, and {@code super} dispatch hops.
* Uses the declaring type of the resolved method (parent class for inherited members).
*/
final class InheritanceCallTargetResolver {
private InheritanceCallTargetResolver() {
}
static String resolveInstanceMethod(
CodebaseContext context,
TypeDeclaration enclosingType,
String methodName) {
if (context == null || enclosingType == null || methodName == null || methodName.isBlank()) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(enclosingType, methodName, true);
if (method == null) {
return null;
}
TypeDeclaration declaringType = findEnclosingTypeDeclaration(method);
if (declaringType == null) {
return null;
}
String fqn = context.getFqn(declaringType);
return fqn == null ? null : fqn + "." + methodName;
}
static String resolveSuperMethod(
CodebaseContext context,
TypeDeclaration enclosingType,
String methodName) {
if (context == null || enclosingType == null || methodName == null || methodName.isBlank()) {
return null;
}
String superFqn = context.getSuperclassFqn(enclosingType);
if (superFqn == null) {
return null;
}
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
if (superType == null) {
return superFqn + "." + methodName;
}
String resolved = resolveInstanceMethod(context, superType, methodName);
return resolved != null ? resolved : superFqn + "." + methodName;
}
private static TypeDeclaration findEnclosingTypeDeclaration(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof TypeDeclaration typeDeclaration) {
return typeDeclaration;
}
current = current.getParent();
}
return null;
}
}

View File

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

View File

@@ -31,7 +31,6 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
private final SpringComponentDetector componentDetector; private final SpringComponentDetector componentDetector;
private List<TriggerPoint> cachedTriggers; private List<TriggerPoint> cachedTriggers;
private List<EntryPoint> cachedEntryPoints; private List<EntryPoint> cachedEntryPoints;
private List<CallChain> cachedCallChains;
public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) { public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) {
this.context = context; this.context = context;
@@ -94,17 +93,15 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
@Override @Override
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) { public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
if (cachedCallChains != null) { List<EntryPoint> scopedEntryPoints = entryPoints != null && !entryPoints.isEmpty()
return cachedCallChains; ? entryPoints
} : findEntryPoints();
List<EntryPoint> allEntryPoints = findEntryPoints(); List<TriggerPoint> scopedTriggers = triggers != null && !triggers.isEmpty()
List<TriggerPoint> allTriggers = triggers != null && !triggers.isEmpty()
? triggers ? triggers
: findTriggerPoints(); : findTriggerPoints();
log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers", log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers",
allEntryPoints.size(), allTriggers.size()); scopedEntryPoints.size(), scopedTriggers.size());
cachedCallChains = callGraphEngine.findChains(allEntryPoints, allTriggers); return callGraphEngine.findChains(scopedEntryPoints, scopedTriggers);
return cachedCallChains;
} }
@Override @Override

View File

@@ -16,12 +16,14 @@ public final class JsonExportContextFactory {
private JsonExportContextFactory() { private JsonExportContextFactory() {
} }
/**
* Returns the nearest ancestor directory that looks like a Gradle/Maven module
* ({@code build.gradle} or {@code pom.xml}), not the monorepo {@code settings.gradle} root.
*/
public static Path findProjectRoot(Path start) { public static Path findProjectRoot(Path start) {
Path current = start != null ? start.toAbsolutePath().normalize() : null; Path current = start != null ? start.toAbsolutePath().normalize() : null;
while (current != null) { while (current != null) {
if (Files.exists(current.resolve("settings.gradle")) if (Files.exists(current.resolve("build.gradle")) || Files.exists(current.resolve("pom.xml"))) {
|| Files.exists(current.resolve("pom.xml"))
|| Files.exists(current.resolve("build.gradle"))) {
return current; return current;
} }
current = current.getParent(); current = current.getParent();
@@ -29,6 +31,29 @@ public final class JsonExportContextFactory {
return null; return null;
} }
/** True when the scanned context actually contains classes referenced by the imported JSON. */
public static boolean contextContainsAnalysisSources(
click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result,
CodebaseContext context) {
if (context == null || result == null) {
return false;
}
if (result.getName() != null && context.getTypeDeclaration(result.getName()) != null) {
return true;
}
if (result.getMetadata() == null || result.getMetadata().getEntryPoints() == null) {
return false;
}
for (click.kamil.springstatemachineexporter.analysis.model.EntryPoint entryPoint
: result.getMetadata().getEntryPoints()) {
if (entryPoint.getClassName() != null
&& context.getTypeDeclaration(entryPoint.getClassName()) != null) {
return true;
}
}
return false;
}
public static CodebaseContext scanProjectRoot(Path projectRoot, List<String> activeProfiles) throws IOException { public static CodebaseContext scanProjectRoot(Path projectRoot, List<String> activeProfiles) throws IOException {
CodebaseContext context = new CodebaseContext(); CodebaseContext context = new CodebaseContext();
if (activeProfiles != null) { if (activeProfiles != null) {

View File

@@ -31,8 +31,8 @@ public class LifecycleDetector {
} }
private TriggerPoint buildLifecyclePoint(MethodInvocation node, CompilationUnit cu, String type) { private TriggerPoint buildLifecyclePoint(MethodInvocation node, CompilationUnit cu, String type) {
MethodDeclaration method = findEnclosingMethod(node); MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
TypeDeclaration typeDecl = findEnclosingType(node); TypeDeclaration typeDecl = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
if (typeDecl == null) return null; if (typeDecl == null) return null;
@@ -43,20 +43,4 @@ public class LifecycleDetector {
.lineNumber(cu.getLineNumber(node.getStartPosition())) .lineNumber(cu.getLineNumber(node.getStartPosition()))
.build(); .build();
} }
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
} }

View File

@@ -4,11 +4,11 @@ import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator; import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.StringJoiner;
/** /**
* Walks a call path forward, propagating known parameter / local bindings so switch-case * Walks a call path forward, propagating known parameter / local bindings so switch-case
@@ -20,24 +20,54 @@ public class PathBindingEvaluator {
private final VariableTracer variableTracer; private final VariableTracer variableTracer;
private final ConstantResolver constantResolver; private final ConstantResolver constantResolver;
private final TypeResolver typeResolver; private final TypeResolver typeResolver;
private final PathBindingExpressionResolver expressionResolver;
private final Map<String, Map<String, String>> traceBindingsCache = new HashMap<>();
public PathBindingEvaluator( public PathBindingEvaluator(
CodebaseContext context, CodebaseContext context,
VariableTracer variableTracer, VariableTracer variableTracer,
ConstantResolver constantResolver, ConstantResolver constantResolver,
TypeResolver typeResolver) { TypeResolver typeResolver) {
this(context, variableTracer, constantResolver, typeResolver, new PathBindingExpressionResolver(context, variableTracer, constantResolver));
}
PathBindingEvaluator(
CodebaseContext context,
VariableTracer variableTracer,
ConstantResolver constantResolver,
TypeResolver typeResolver,
PathBindingExpressionResolver expressionResolver) {
this.context = context; this.context = context;
this.variableTracer = variableTracer; this.variableTracer = variableTracer;
this.constantResolver = constantResolver; this.constantResolver = constantResolver;
this.typeResolver = typeResolver; this.typeResolver = typeResolver;
this.expressionResolver = expressionResolver;
} }
/** Visible for tests: bindings accumulated while walking a path forward (ignores constraint rejection). */ /** Visible for tests: bindings accumulated while walking a path forward (ignores constraint rejection). */
Map<String, String> traceBindings(List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) { Map<String, String> traceBindings(List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
Map<String, String> bindings = new HashMap<>();
if (path == null || path.size() < 2) { if (path == null || path.size() < 2) {
return bindings; return Map.of();
} }
String cacheKey = pathCacheKey(path);
Map<String, String> cached = traceBindingsCache.get(cacheKey);
if (cached != null) {
return new HashMap<>(cached);
}
Map<String, String> bindings = computeTraceBindings(path, callGraph, pathFinder);
traceBindingsCache.put(cacheKey, Map.copyOf(bindings));
return new HashMap<>(bindings);
}
public void clearAnalysisCaches() {
traceBindingsCache.clear();
}
private Map<String, String> computeTraceBindings(
List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
context.setAnalysisCallPath(path);
try {
Map<String, String> bindings = new HashMap<>();
for (int i = 0; i < path.size() - 1; i++) { for (int i = 0; i < path.size() - 1; i++) {
String caller = path.get(i); String caller = path.get(i);
String target = path.get(i + 1); String target = path.get(i + 1);
@@ -48,6 +78,17 @@ public class PathBindingEvaluator {
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings); enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings);
} }
return bindings; return bindings;
} finally {
context.clearAnalysisCallPath();
}
}
private static String pathCacheKey(List<String> path) {
StringJoiner joiner = new StringJoiner("\0");
for (String hop : path) {
joiner.add(hop);
}
return joiner.toString();
} }
public boolean isPathCompatible( public boolean isPathCompatible(
@@ -117,7 +158,24 @@ public class PathBindingEvaluator {
Map<String, List<CallEdge>> callGraph, Map<String, List<CallEdge>> callGraph,
CallGraphPathFinder pathFinder, CallGraphPathFinder pathFinder,
Map<String, String> bindings) { Map<String, String> bindings) {
Map<String, String> paramValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, pathIndex); if (FunctionalInterfaceTypes.isFunctionalSamMethod(target) && pathIndex >= 2) {
int paramIndex = variableTracer.findFunctionalParameterIndex(caller);
if (paramIndex < 0) {
paramIndex = 0;
}
String functionalCaller = path.get(pathIndex - 2);
CallEdge functionalEdge = CallSiteMatcher.selectEdge(
functionalCaller, caller, callGraph.get(functionalCaller), bindings);
List<String> constants = variableTracer.resolveConstantsFromCallSiteArgument(
functionalCaller, caller, paramIndex, functionalEdge);
for (String constant : constants) {
if (constant != null && !constant.isBlank()) {
bindings.putIfAbsent(constant, constant);
}
}
}
Map<String, String> paramValues = variableTracer.buildParameterValuesMap(
caller, target, callGraph, path, pathIndex, bindings);
mergeResolvedBindings(bindings, paramValues, caller); mergeResolvedBindings(bindings, paramValues, caller);
List<String> targetParams = typeResolver.getParameterNames(target); List<String> targetParams = typeResolver.getParameterNames(target);
@@ -127,6 +185,14 @@ public class PathBindingEvaluator {
for (int i = 0; i < edge.getArguments().size() && i < targetParams.size(); i++) { for (int i = 0; i < edge.getArguments().size() && i < targetParams.size(); i++) {
String arg = edge.getArguments().get(i); String arg = edge.getArguments().get(i);
String paramName = targetParams.get(i); String paramName = targetParams.get(i);
if (typeResolver != null
&& FunctionalInterfaceTypes.isFunctionalInterface(typeResolver.getParameterType(target, i))) {
List<String> constants = variableTracer.resolveConstantsFromCallSiteArgument(caller, target, i, edge);
if (constants.size() == 1) {
bindings.put(paramName, constants.get(0));
continue;
}
}
String resolved = resolveBindingValue(caller, arg, bindings); String resolved = resolveBindingValue(caller, arg, bindings);
if (shouldStoreBinding(paramName, resolved)) { if (shouldStoreBinding(paramName, resolved)) {
bindings.put(paramName, resolved); bindings.put(paramName, resolved);
@@ -171,7 +237,7 @@ public class PathBindingEvaluator {
if (traced.startsWith("\"") || isEnumLikeConstant(traced)) { if (traced.startsWith("\"") || isEnumLikeConstant(traced)) {
return traced; return traced;
} }
String fromCall = resolveMethodCallFromSource(callerFqn, rawValue, bindings); String fromCall = expressionResolver.resolveMethodCallFromSource(callerFqn, rawValue, bindings);
if (fromCall != null) { if (fromCall != null) {
return fromCall; return fromCall;
} }
@@ -182,155 +248,6 @@ public class PathBindingEvaluator {
return rawValue; return rawValue;
} }
private String resolveMethodCallFromSource(String callerFqn, String varName, Map<String, String> bindings) {
MethodDeclaration md = findMethodDeclaration(callerFqn);
if (md == null || md.getBody() == null) {
return null;
}
final Expression[] initializer = new Expression[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializer[0] = node.getInitializer();
}
return super.visit(node);
}
});
if (initializer[0] == null) {
return null;
}
return evaluateExpressionValue(initializer[0], callerFqn, bindings);
}
private String evaluateExpressionValue(Expression expr, String callerFqn, Map<String, String> bindings) {
if (expr instanceof StringLiteral sl) {
return "\"" + sl.getLiteralValue() + "\"";
}
if (expr instanceof QualifiedName qn) {
return qn.getFullyQualifiedName();
}
if (expr instanceof SimpleName sn) {
return bindings.getOrDefault(sn.getIdentifier(), sn.getIdentifier());
}
if (expr instanceof MethodInvocation mi) {
return evaluateMethodInvocationReturn(mi, callerFqn, bindings);
}
if (expr instanceof SwitchExpression se) {
return constantResolver.evaluateSwitchWithParams(se, bindings, context);
}
String resolved = constantResolver.resolve(expr, context);
return resolved;
}
private String evaluateMethodInvocationReturn(MethodInvocation mi, String callerFqn, Map<String, String> bindings) {
String ownerFqn = resolveMethodOwnerFqn(mi, callerFqn);
if (ownerFqn == null) {
return null;
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration owner = context.getTypeDeclaration(ownerFqn);
if (owner == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
Map<String, String> callBindings = new HashMap<>(bindings);
for (int i = 0; i < md.parameters().size() && i < mi.arguments().size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i);
String paramName = param.getName().getIdentifier();
String argValue = evaluateExpressionValue((Expression) mi.arguments().get(i), callerFqn, callBindings);
if (argValue != null) {
callBindings.put(paramName, argValue);
}
}
final String[] result = new String[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() instanceof SwitchExpression se) {
result[0] = constantResolver.evaluateSwitchWithParams(se, callBindings, context);
} else if (node.getExpression() != null) {
result[0] = evaluateExpressionValue(node.getExpression(), ownerFqn + "." + methodName, callBindings);
}
return super.visit(node);
}
});
return result[0];
}
private String resolveFieldTypeFqn(String callerFqn, String fieldName) {
if (callerFqn == null || !callerFqn.contains(".")) {
return null;
}
String className = callerFqn.substring(0, callerFqn.lastIndexOf('.'));
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
for (FieldDeclaration field : td.getFields()) {
for (Object fragmentObj : field.fragments()) {
VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj;
if (fragment.getName().getIdentifier().equals(fieldName)) {
IVariableBinding binding = fragment.resolveBinding();
if (binding != null && binding.getType() != null) {
return binding.getType().getErasure().getQualifiedName();
}
String simpleType = field.getType().toString();
TypeDeclaration resolved = context.getTypeDeclaration(simpleType);
if (resolved != null) {
return context.getFqn(resolved);
}
if (td.getRoot() instanceof CompilationUnit cu && cu.getPackage() != null) {
String pkg = cu.getPackage().getName().getFullyQualifiedName();
resolved = context.getTypeDeclaration(pkg + "." + simpleType);
if (resolved != null) {
return context.getFqn(resolved);
}
}
return simpleType;
}
}
}
return null;
}
private String resolveMethodOwnerFqn(MethodInvocation mi, String callerFqn) {
IMethodBinding methodBinding = mi.resolveMethodBinding();
if (methodBinding != null && methodBinding.getDeclaringClass() != null) {
return methodBinding.getDeclaringClass().getErasure().getQualifiedName();
}
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
return resolveFieldTypeFqn(callerFqn, sn.getIdentifier());
}
if (receiver instanceof FieldAccess fa) {
IVariableBinding fieldBinding = fa.resolveFieldBinding();
if (fieldBinding != null && fieldBinding.getType() != null) {
return fieldBinding.getType().getErasure().getQualifiedName();
}
return resolveFieldTypeFqn(callerFqn, fa.getName().getIdentifier());
}
return null;
}
private MethodDeclaration findMethodDeclaration(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
return context.findMethodDeclaration(td, methodName, true);
}
private static String normalizeLiteral(String value) { private static String normalizeLiteral(String value) {
if (value != null && value.startsWith("\"") && value.endsWith("\"")) { if (value != null && value.startsWith("\"") && value.endsWith("\"")) {
return value.substring(1, value.length() - 1); return value.substring(1, value.length() - 1);

View File

@@ -0,0 +1,221 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Resolves expression values under path-binding context using the dataflow pipeline
* ({@link VariableTracer} / {@link click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel})
* instead of a parallel AST mini-interpreter.
*/
public class PathBindingExpressionResolver {
private final CodebaseContext context;
private final VariableTracer variableTracer;
private final ConstantResolver constantResolver;
public PathBindingExpressionResolver(
CodebaseContext context,
VariableTracer variableTracer,
ConstantResolver constantResolver) {
this.context = context;
this.variableTracer = variableTracer;
this.constantResolver = constantResolver;
}
public String resolveMethodCallFromSource(String callerFqn, String varName, Map<String, String> bindings) {
MethodDeclaration md = findMethodDeclaration(callerFqn);
if (md == null || md.getBody() == null) {
return null;
}
final Expression[] initializer = new Expression[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializer[0] = node.getInitializer();
}
return super.visit(node);
}
});
if (initializer[0] == null) {
return null;
}
return resolve(initializer[0], callerFqn, bindings);
}
public String resolve(Expression expr, String callerFqn, Map<String, String> bindings) {
if (expr == null) {
return null;
}
Map<String, String> effectiveBindings = bindings == null ? Map.of() : new HashMap<>(bindings);
context.setAnalysisNamedBindings(effectiveBindings);
try {
return resolveInternal(expr, callerFqn, effectiveBindings);
} finally {
context.clearAnalysisNamedBindings();
}
}
private String resolveInternal(Expression expr, String callerFqn, Map<String, String> bindings) {
if (expr instanceof StringLiteral sl) {
return "\"" + sl.getLiteralValue() + "\"";
}
if (expr instanceof QualifiedName qn) {
return qn.getFullyQualifiedName();
}
if (expr instanceof SimpleName sn) {
return bindings.getOrDefault(sn.getIdentifier(), sn.getIdentifier());
}
if (expr instanceof SwitchExpression se) {
return constantResolver.evaluateSwitchWithParams(se, bindings, context);
}
if (expr instanceof MethodInvocation mi) {
return resolveMethodInvocationReturn(mi, callerFqn, bindings);
}
return resolveViaDataflow(expr);
}
private String resolveMethodInvocationReturn(MethodInvocation mi, String callerFqn, Map<String, String> bindings) {
String ownerFqn = resolveMethodOwnerFqn(mi, callerFqn);
if (ownerFqn == null) {
return null;
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration owner = context.getTypeDeclaration(ownerFqn);
if (owner == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true);
if (md == null) {
return null;
}
Map<String, String> callBindings = new HashMap<>(bindings);
for (int i = 0; i < md.parameters().size() && i < mi.arguments().size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i);
String paramName = param.getName().getIdentifier();
String argValue = resolveInternal((Expression) mi.arguments().get(i), callerFqn, callBindings);
if (argValue != null) {
callBindings.put(paramName, argValue);
}
}
context.setAnalysisNamedBindings(callBindings);
try {
return resolveViaDataflow(mi);
} finally {
context.setAnalysisNamedBindings(bindings);
}
}
private String resolveViaDataflow(Expression expr) {
Set<String> constants = new LinkedHashSet<>();
for (Expression def : variableTracer.traceVariableAll(expr)) {
String val = constantResolver.resolve(def, context);
if (val != null && !val.isBlank()) {
constants.add(val);
}
}
if (constants.size() == 1) {
return formatResolvedConstant(constants.iterator().next());
}
if (constants.isEmpty()) {
String direct = constantResolver.resolve(expr, context);
return direct != null ? formatResolvedConstant(direct) : null;
}
return null;
}
private static String formatResolvedConstant(String value) {
if (value == null) {
return null;
}
if (value.startsWith("ENUM_SET:")
|| value.startsWith("<SYMBOLIC:")
|| value.contains("SYMBOLIC:")
|| value.endsWith(".*>")) {
return value;
}
if (value.startsWith("\"") || AstUtils.isEnumLikeConstantName(value)) {
return value;
}
return "\"" + value + "\"";
}
private String resolveMethodOwnerFqn(MethodInvocation mi, String callerFqn) {
IMethodBinding methodBinding = mi.resolveMethodBinding();
if (methodBinding != null && methodBinding.getDeclaringClass() != null) {
return methodBinding.getDeclaringClass().getErasure().getQualifiedName();
}
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
return resolveFieldTypeFqn(callerFqn, sn.getIdentifier());
}
if (receiver instanceof FieldAccess fa) {
IVariableBinding fieldBinding = fa.resolveFieldBinding();
if (fieldBinding != null && fieldBinding.getType() != null) {
return fieldBinding.getType().getErasure().getQualifiedName();
}
return resolveFieldTypeFqn(callerFqn, fa.getName().getIdentifier());
}
return null;
}
private String resolveFieldTypeFqn(String callerFqn, String fieldName) {
if (callerFqn == null || !callerFqn.contains(".")) {
return null;
}
String className = callerFqn.substring(0, callerFqn.lastIndexOf('.'));
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
for (FieldDeclaration field : td.getFields()) {
for (Object fragmentObj : field.fragments()) {
VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj;
if (fragment.getName().getIdentifier().equals(fieldName)) {
IVariableBinding binding = fragment.resolveBinding();
if (binding != null && binding.getType() != null) {
return binding.getType().getErasure().getQualifiedName();
}
String simpleType = field.getType().toString();
TypeDeclaration resolved = context.getTypeDeclaration(simpleType);
if (resolved != null) {
return context.getFqn(resolved);
}
if (td.getRoot() instanceof CompilationUnit cu && cu.getPackage() != null) {
String pkg = cu.getPackage().getName().getFullyQualifiedName();
resolved = context.getTypeDeclaration(pkg + "." + simpleType);
if (resolved != null) {
return context.getFqn(resolved);
}
}
return simpleType;
}
}
}
return null;
}
private MethodDeclaration findMethodDeclaration(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
return context.findMethodDeclaration(td, methodName, true);
}
}

View File

@@ -6,10 +6,7 @@ import lombok.RequiredArgsConstructor;
import org.eclipse.jdt.core.dom.ASTVisitor; import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Annotation; import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.CompilationUnit; import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodDeclaration; import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.TypeDeclaration; import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList; import java.util.ArrayList;
@@ -36,13 +33,13 @@ public class SpringComponentDetector {
String typeName = annotation.getTypeName().getFullyQualifiedName(); String typeName = annotation.getTypeName().getFullyQualifiedName();
if (typeName.endsWith("Scheduled")) { if (typeName.endsWith("Scheduled")) {
Map<String, String> meta = new HashMap<>(); Map<String, String> meta = new HashMap<>();
String cron = extractAnnotationValue(annotation, "cron"); String cron = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "cron");
if (!cron.isEmpty()) if (!cron.isEmpty())
meta.put("cron", cron); meta.put("cron", cron);
String fixedRate = extractAnnotationValue(annotation, "fixedRate"); String fixedRate = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "fixedRate");
if (!fixedRate.isEmpty()) if (!fixedRate.isEmpty())
meta.put("fixedRate", fixedRate); meta.put("fixedRate", fixedRate);
String fixedDelay = extractAnnotationValue(annotation, "fixedDelay"); String fixedDelay = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "fixedDelay");
if (!fixedDelay.isEmpty()) if (!fixedDelay.isEmpty())
meta.put("fixedDelay", fixedDelay); meta.put("fixedDelay", fixedDelay);
@@ -56,10 +53,10 @@ public class SpringComponentDetector {
.build()); .build());
} else if (typeName.endsWith("EventListener")) { } else if (typeName.endsWith("EventListener")) {
Map<String, String> meta = new HashMap<>(); Map<String, String> meta = new HashMap<>();
String classes = extractAnnotationValue(annotation, "classes"); String classes = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "classes");
if (!classes.isEmpty()) if (!classes.isEmpty())
meta.put("classes", classes); meta.put("classes", classes);
String condition = extractAnnotationValue(annotation, "condition"); String condition = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "condition");
if (!condition.isEmpty()) if (!condition.isEmpty())
meta.put("condition", condition); meta.put("condition", condition);
@@ -73,11 +70,11 @@ public class SpringComponentDetector {
.build()); .build());
} else if (typeName.endsWith("Around") || typeName.endsWith("Before") || typeName.endsWith("After") || typeName.endsWith("AfterReturning") || typeName.endsWith("AfterThrowing")) { } else if (typeName.endsWith("Around") || typeName.endsWith("Before") || typeName.endsWith("After") || typeName.endsWith("AfterReturning") || typeName.endsWith("AfterThrowing")) {
Map<String, String> meta = new HashMap<>(); Map<String, String> meta = new HashMap<>();
String value = extractAnnotationValue(annotation, "value"); String value = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "value");
if (!value.isEmpty()) if (!value.isEmpty())
meta.put("pointcut", value); meta.put("pointcut", value);
else { else {
String pointcut = extractAnnotationValue(annotation, "pointcut"); String pointcut = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "pointcut");
if (!pointcut.isEmpty()) if (!pointcut.isEmpty())
meta.put("pointcut", pointcut); meta.put("pointcut", pointcut);
} }
@@ -98,20 +95,4 @@ public class SpringComponentDetector {
}); });
return entryPoints; return entryPoints;
} }
private String extractAnnotationValue(Annotation annotation, String memberName) {
if (annotation instanceof SingleMemberAnnotation sma) {
if ("value".equals(memberName)) {
return sma.getValue().toString();
}
} else if (annotation instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if (pair.getName().getIdentifier().equals(memberName)) {
return pair.getValue().toString();
}
}
}
return "";
}
} }

View File

@@ -23,7 +23,7 @@ public class SpringMvcDetector {
cu.accept(new ASTVisitor() { cu.accept(new ASTVisitor() {
@Override @Override
public boolean visit(TypeDeclaration node) { public boolean visit(TypeDeclaration node) {
if (isRestController(node)) { if (isRestController(node) && !node.isInterface()) {
processController(node, entryPoints); processController(node, entryPoints);
} }
// Support WebFlux RouterFunction beans // Support WebFlux RouterFunction beans

View File

@@ -4,14 +4,111 @@ import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
import java.util.*; import java.util.*;
import org.eclipse.jdt.core.dom.WildcardType;
public class TypeResolver { public class TypeResolver {
private final CodebaseContext context; private final CodebaseContext context;
private static final Set<String> GENERIC_EVENT_PARAMETER_NAMES = Set.of("e", "ev");
public TypeResolver(CodebaseContext context) { public TypeResolver(CodebaseContext context) {
this.context = context; this.context = context;
} }
public String resolveTypeToFqn(Type type, ASTNode contextNode) {
CompilationUnit cu = contextNode != null && contextNode.getRoot() instanceof CompilationUnit rootCu
? rootCu
: null;
return resolveTypeToFqn(type, cu, Map.of());
}
public String resolveTypeToFqn(Type type, CompilationUnit cu) {
return resolveTypeToFqn(type, cu, Map.of());
}
public String resolveTypeToFqn(Type type, CompilationUnit cu, Map<String, String> typeVarBindings) {
if (type == null) {
return null;
}
if (type instanceof WildcardType wildcardType) {
Type bound = wildcardType.getBound();
return bound != null ? resolveTypeToFqn(bound, cu, typeVarBindings) : null;
}
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), cu, typeVarBindings);
} else {
simpleName = type.toString();
}
if (simpleName.contains("<")) {
simpleName = simpleName.substring(0, simpleName.indexOf('<'));
}
if (typeVarBindings.containsKey(simpleName)) {
return typeVarBindings.get(simpleName);
}
if (cu != null) {
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) {
return context.getFqn(localTd);
}
}
} else {
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName);
if (td != null) {
return context.getFqn(td);
}
}
return preferBindingFqn(type, simpleName);
}
private String preferBindingFqn(Type type, String heuristic) {
if (!context.isResolveBindings() || heuristic == null || heuristic.contains(".")) {
return heuristic;
}
ITypeBinding binding = type.resolveBinding();
if (binding == null || binding.isRecovered()) {
return heuristic;
}
String bindingFqn = binding.getErasure().getQualifiedName();
if (bindingFqn == null || bindingFqn.isBlank() || !bindingFqn.contains(".") || bindingFqn.startsWith("<")) {
return heuristic;
}
if (bindingFqn.startsWith("java.") || bindingFqn.startsWith("javax.") || bindingFqn.startsWith("jakarta.")) {
return heuristic;
}
return bindingFqn;
}
public int getParameterIndex(String methodFqn, String paramName) { public int getParameterIndex(String methodFqn, String paramName) {
return getParameterIndex(methodFqn, paramName, false);
}
/**
* When {@code allowSyntheticEventPlaceholder} is true, maps generic trigger placeholders
* such as {@code e} onto the sole parameter of single-arg methods (e.g. {@code sendEvent}).
*/
public int getParameterIndex(String methodFqn, String paramName, boolean allowSyntheticEventPlaceholder) {
if (methodFqn == null || !methodFqn.contains(".")) return -1; if (methodFqn == null || !methodFqn.contains(".")) return -1;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
@@ -25,11 +122,20 @@ public class TypeResolver {
return i; return i;
} }
} }
if (allowSyntheticEventPlaceholder
&& md.parameters().size() == 1
&& isGenericEventParameterName(paramName)) {
return 0;
}
} }
} }
return -1; return -1;
} }
private static boolean isGenericEventParameterName(String paramName) {
return paramName != null && GENERIC_EVENT_PARAMETER_NAMES.contains(paramName);
}
public List<String> getParameterNames(String methodFqn) { public List<String> getParameterNames(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) { if (methodFqn == null || !methodFqn.contains(".")) {
return null; return null;
@@ -82,6 +188,43 @@ public class TypeResolver {
return null; return null;
} }
/**
* Resolves parameter type from the actual call site in {@code callerFqn}, so overloaded
* targets (e.g. {@code send(OrderEvent)} vs {@code send(DocumentEvent)}) use the invoked overload.
*/
public String getParameterTypeAtCallSite(String callerFqn, String calleeFqn, int paramIndex) {
if (callerFqn == null || calleeFqn == null || paramIndex < 0
|| !callerFqn.contains(".") || !calleeFqn.contains(".")) {
return null;
}
String callerClass = callerFqn.substring(0, callerFqn.lastIndexOf('.'));
String callerMethod = callerFqn.substring(callerFqn.lastIndexOf('.') + 1);
String calleeMethod = calleeFqn.substring(calleeFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(callerClass);
if (td == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(td, callerMethod, true);
if (md == null || md.getBody() == null) {
return null;
}
final String[] result = new String[1];
md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(org.eclipse.jdt.core.dom.MethodInvocation node) {
if (!calleeMethod.equals(node.getName().getIdentifier())) {
return super.visit(node);
}
org.eclipse.jdt.core.dom.IMethodBinding binding = node.resolveMethodBinding();
if (binding != null && binding.getParameterTypes().length > paramIndex) {
result[0] = binding.getParameterTypes()[paramIndex].getErasure().getQualifiedName();
}
return super.visit(node);
}
});
return result[0];
}
public boolean isTypeCompatible(String actualType, String expectedType) { public boolean isTypeCompatible(String actualType, String expectedType) {
if (actualType == null || expectedType == null) return true; if (actualType == null || expectedType == null) return true;
if (expectedType.equals("Object") || expectedType.equals("java.lang.Object")) return true; if (expectedType.equals("Object") || expectedType.equals("java.lang.Object")) return true;
@@ -120,6 +263,17 @@ public class TypeResolver {
} }
if (actualType.equals(expectedType)) return true; if (actualType.equals(expectedType)) return true;
String expectedSimple = expectedType.contains(".")
? expectedType.substring(expectedType.lastIndexOf('.') + 1)
: expectedType;
String actualSimple = actualType.contains(".")
? actualType.substring(actualType.lastIndexOf('.') + 1)
: actualType;
if (expectedSimple.equals(actualSimple) && context.isAmbiguousSimpleName(expectedSimple)) {
return false;
}
if (actualType.endsWith("." + expectedType) || expectedType.endsWith("." + actualType)) return true; if (actualType.endsWith("." + expectedType) || expectedType.endsWith("." + actualType)) return true;
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(actualType); AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(actualType);

View File

@@ -1,12 +1,17 @@
package click.kamil.springstatemachineexporter.analysis.service; package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper; import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget; import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorNaming;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector; import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
import java.util.*; import java.util.*;
@@ -14,19 +19,34 @@ public class VariableTracer {
private final CodebaseContext context; private final CodebaseContext context;
private final ConstantResolver constantResolver; private final ConstantResolver constantResolver;
private ConstantExtractor constantExtractor; private ConstantExtractor constantExtractor;
private TypeResolver typeResolver;
private final click.kamil.springstatemachineexporter.ast.common.DataFlowModel dataFlowModel; private final click.kamil.springstatemachineexporter.ast.common.DataFlowModel dataFlowModel;
private final Map<String, String> variableDeclaredTypeCache = new HashMap<>();
private final FieldInitializerFinder fieldInitializerFinder;
public VariableTracer(CodebaseContext context, ConstantResolver constantResolver) { public VariableTracer(CodebaseContext context, ConstantResolver constantResolver) {
this.context = context; this.context = context;
this.constantResolver = constantResolver; this.constantResolver = constantResolver;
this.dataFlowModel = new click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel(context); this.dataFlowModel = new click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel(context);
this.fieldInitializerFinder = new FieldInitializerFinder(context);
} }
public void setConstantExtractor(ConstantExtractor constantExtractor) { public void setConstantExtractor(ConstantExtractor constantExtractor) {
this.constantExtractor = constantExtractor; this.constantExtractor = constantExtractor;
} }
public void setTypeResolver(TypeResolver typeResolver) {
this.typeResolver = typeResolver;
}
public void clearAnalysisCaches() {
variableDeclaredTypeCache.clear();
if (dataFlowModel instanceof JdtDataFlowModel jdtDataFlowModel) {
jdtDataFlowModel.clearAnalysisCaches();
}
}
public Expression traceVariable(Expression expr) { public Expression traceVariable(Expression expr) {
List<Expression> all = traceVariableAll(expr); List<Expression> all = traceVariableAll(expr);
return all.isEmpty() ? expr : all.get(all.size() - 1); return all.isEmpty() ? expr : all.get(all.size() - 1);
@@ -55,17 +75,19 @@ public class VariableTracer {
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className); TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) { if (td == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) { if (md == null || md.getBody() == null) {
final Expression[] setterArg = new Expression[1]; return null;
String propName; }
String setterMethodName; String setterMethodName;
String indexedFieldName; String indexedFieldName;
if (AccessorNaming.isBeanStyleAccessorName(getterName)) {
if (isBeanStyleAccessorName(getterName)) { String propName = AccessorNaming.propertyNameFromAccessor(getterName);
propName = propertyNameFromAccessor(getterName); setterMethodName = "set" + AccessorNaming.capitalizeProperty(propName);
setterMethodName = "set" + capitalizeProperty(propName);
indexedFieldName = propName; indexedFieldName = propName;
String varTypeName = getVariableDeclaredType(methodFqn, varName); String varTypeName = getVariableDeclaredType(methodFqn, varName);
@@ -87,135 +109,59 @@ public class VariableTracer {
} }
} }
} else { } else {
propName = getterName.startsWith("get") ? getterName.substring(3) : getterName; String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName;
setterMethodName = "set" + propName; setterMethodName = "set" + propName;
indexedFieldName = propName; indexedFieldName = propName;
} }
final String resolvedSetterName = setterMethodName; if (dataFlowModel instanceof JdtDataFlowModel jdtDataFlowModel) {
final String resolvedFieldName = indexedFieldName; return jdtDataFlowModel.findLocalSetterArgument(
md.getBody().accept(new ASTVisitor() { md, varName, getterName, setterMethodName, indexedFieldName);
@Override
public boolean visit(MethodInvocation node) {
if (node.getName().getIdentifier().equals(resolvedSetterName)
|| node.getName().getIdentifier().equalsIgnoreCase("set" + resolvedFieldName)
|| node.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) {
if (node.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) {
if (!node.arguments().isEmpty()) {
setterArg[0] = (Expression) node.arguments().get(0);
}
}
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof FieldAccess fa) {
if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) {
if (fa.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) {
setterArg[0] = node.getRightHandSide();
}
}
} else if (node.getLeftHandSide() instanceof QualifiedName qqn) {
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
if (qqn.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) {
setterArg[0] = node.getRightHandSide();
}
}
}
return super.visit(node);
}
});
return setterArg[0];
}
} }
return null; return null;
} }
private static boolean isBeanStyleAccessorName(String accessorName) {
return (accessorName.startsWith("get") && accessorName.length() > 3)
|| (accessorName.startsWith("is") && accessorName.length() > 2)
|| (accessorName.startsWith("set") && accessorName.length() > 3);
}
private static String propertyNameFromAccessor(String getterName) {
if (getterName.startsWith("get") && getterName.length() > 3) {
return TrivialAccessorDetector.decapitalize(getterName.substring(3));
}
if (getterName.startsWith("is") && getterName.length() > 2) {
return TrivialAccessorDetector.decapitalize(getterName.substring(2));
}
return getterName;
}
private 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);
}
private String getFieldType(TypeDeclaration td, String fieldName, CodebaseContext context, java.util.Set<String> visited) { private String getFieldType(TypeDeclaration td, String fieldName, CodebaseContext context, java.util.Set<String> visited) {
if (td == null) return null; if (td == null) {
return null;
}
String typeFqn = context.getFqn(td); String typeFqn = context.getFqn(td);
if (!visited.add(typeFqn)) return null; if (!visited.add(typeFqn)) {
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName)) {
return fd.getType().toString();
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
String result = getFieldType(superTd, fieldName, context, visited);
if (result != null) return result;
}
}
for (Object intfObj : td.superInterfaceTypes()) {
Type intfType = (Type) intfObj;
String intfName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(intfType);
String resolvedIntf = resolveTypeFqn(intfName, context, td);
if (resolvedIntf != null) {
TypeDeclaration intfTd = context.getTypeDeclaration(resolvedIntf);
if (intfTd != null) {
String result = getFieldType(intfTd, fieldName, context, visited);
if (result != null) return result;
}
}
}
return null; return null;
} }
private String resolveTypeFqn(String simpleName, CodebaseContext context, TypeDeclaration td) { Optional<String> indexed = context.getFieldTypeIndex().lookup(typeFqn, fieldName);
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null; if (indexed.isPresent()) {
if (cu != null) { return indexed.get();
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String name = imp.getName().getFullyQualifiedName();
if (name.endsWith("." + simpleName)) {
return name;
} }
return fieldInitializerFinder.resolveFieldTypeName(td, fieldName, visited);
} }
if (cu.getPackage() != null) {
return cu.getPackage().getName().getFullyQualifiedName() + "." + simpleName; private MethodDeclaration findEnclosingMethod(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
} }
}
return simpleName; private TypeDeclaration findEnclosingType(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
} }
public String getVariableDeclaredType(String methodFqn, String cleanFieldName) { public String getVariableDeclaredType(String methodFqn, String cleanFieldName) {
if (methodFqn == null) return null; if (methodFqn == null) {
return null;
}
String cacheKey = methodFqn + "|" + cleanFieldName;
String cached = variableDeclaredTypeCache.get(cacheKey);
if (cached != null) {
return cached.isEmpty() ? null : cached;
}
String resolved = resolveVariableDeclaredTypeUncached(methodFqn, cleanFieldName);
variableDeclaredTypeCache.put(cacheKey, resolved == null ? "" : resolved);
return resolved;
}
private String resolveVariableDeclaredTypeUncached(String methodFqn, String cleanFieldName) {
int lastDot = methodFqn.lastIndexOf('.'); int lastDot = methodFqn.lastIndexOf('.');
if (lastDot > 0) { if (lastDot > 0) {
String fqn = methodFqn.substring(0, lastDot); String fqn = methodFqn.substring(0, lastDot);
@@ -337,23 +283,25 @@ public class VariableTracer {
if (fieldType != null) return fieldType; if (fieldType != null) return fieldType;
} }
} }
// Final fallback: ask constantExtractor to resolve it via method return constant
if (constantExtractor != null && methodFqn.contains(".")) {
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
CompilationUnit cu = td != null && td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
List<String> values = constantExtractor.resolveMethodReturnConstant(cleanFieldName, methodName, 0, new HashSet<>(), cu);
if (values != null && !values.isEmpty()) {
// If it resolves to some class constant, we could infer type or return a fallback
return null;
}
}
return null; return null;
} }
public String traceLocalVariable(String methodFqn, String varName) { public String traceLocalVariable(String methodFqn, String varName) {
return traceLocalVariable(methodFqn, varName, null);
}
public String traceLocalVariable(String methodFqn, String varName, Map<String, String> branchBindings) {
String result = traceLocalVariableInternal(methodFqn, varName, branchBindings);
if (result != null && branchBindings != null && !branchBindings.isEmpty()) {
String switchEvaluated = evaluateSwitchAssignment(methodFqn, varName, branchBindings);
if (switchEvaluated != null) {
return switchEvaluated;
}
}
return result;
}
private String traceLocalVariableInternal(String methodFqn, String varName, Map<String, String> branchBindings) {
if (methodFqn == null || !methodFqn.contains(".")) return null; if (methodFqn == null || !methodFqn.contains(".")) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
@@ -361,27 +309,33 @@ public class VariableTracer {
if (td != null) { if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) { if (md != null && md.getBody() != null) {
List<Expression> initializers = new ArrayList<>(); List<BranchAssignment> assignments = new ArrayList<>();
md.getBody().accept(new ASTVisitor() { md.getBody().accept(new ASTVisitor() {
@Override @Override
public boolean visit(VariableDeclarationFragment node) { public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializers.add(peelExpression(node.getInitializer())); assignments.add(new BranchAssignment(
peelExpression(node.getInitializer()),
click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node)));
} }
return super.visit(node); return super.visit(node);
} }
@Override @Override
public boolean visit(Assignment node) { public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) { if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
initializers.add(peelExpression(node.getRightHandSide())); assignments.add(new BranchAssignment(
peelExpression(node.getRightHandSide()),
click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node)));
} }
return super.visit(node); return super.visit(node);
} }
}); });
if (!initializers.isEmpty()) { List<BranchAssignment> filtered = filterAssignmentsByBindings(assignments, branchBindings);
if (!filtered.isEmpty()) {
List<String> stringified = new ArrayList<>(); List<String> stringified = new ArrayList<>();
for (Expression expr : initializers) { for (BranchAssignment assignment : filtered) {
Expression expr = assignment.expression();
Expression traced = traceVariable(expr); Expression traced = traceVariable(expr);
if (traced instanceof MethodInvocation mi) { if (traced instanceof MethodInvocation mi) {
Expression innerMost = unwrapMethodInvocation(mi, 0, methodFqn); Expression innerMost = unwrapMethodInvocation(mi, 0, methodFqn);
@@ -454,40 +408,35 @@ public class VariableTracer {
return null; return null;
} }
public String traceLocalVariable(String methodFqn, String varName, Map<String, String> parameterValues) { private String evaluateSwitchAssignment(String methodFqn, String varName, Map<String, String> parameterValues) {
String result = traceLocalVariable(methodFqn, varName);
if (result != null && parameterValues != null && !parameterValues.isEmpty()) {
TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.'))); TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
if (td != null) { if (td == null) {
return null;
}
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) { if (md == null || md.getBody() == null) {
return null;
}
final ASTNode[] switchNode = new ASTNode[1]; final ASTNode[] switchNode = new ASTNode[1];
md.getBody().accept(new ASTVisitor() { md.getBody().accept(new ASTVisitor() {
@Override @Override
public boolean visit(VariableDeclarationFragment node) { public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
Expression init = node.getInitializer(); Expression init = node.getInitializer();
if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION) { if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION || init.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = init;
return false;
}
if (init.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = init; switchNode[0] = init;
return false; return false;
} }
} }
return super.visit(node); return super.visit(node);
} }
@Override @Override
public boolean visit(Assignment node) { public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) { if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
Expression rhs = node.getRightHandSide(); Expression rhs = node.getRightHandSide();
if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION) { if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION || rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = rhs;
return false;
}
if (rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = rhs; switchNode[0] = rhs;
return false; return false;
} }
@@ -495,23 +444,71 @@ public class VariableTracer {
return super.visit(node); return super.visit(node);
} }
}); });
if (switchNode[0] != null) {
if (switchNode[0] instanceof SwitchExpression se) { if (switchNode[0] instanceof SwitchExpression se) {
String evaluated = constantResolver.evaluateSwitchWithParams(se, parameterValues, context); return constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
if (evaluated != null) return evaluated;
} else if (switchNode[0] instanceof SwitchStatement ss) {
String evaluated = constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
if (evaluated != null) return evaluated;
} }
if (switchNode[0] instanceof SwitchStatement ss) {
return constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
} }
} return null;
}
}
return result;
} }
private static List<BranchAssignment> filterAssignmentsByBindings(
List<BranchAssignment> assignments, Map<String, String> branchBindings) {
if (assignments.isEmpty()) {
return assignments;
}
if (branchBindings == null || branchBindings.isEmpty()) {
return assignments;
}
List<BranchAssignment> compatible = new ArrayList<>();
List<BranchAssignment> unconstrained = new ArrayList<>();
for (BranchAssignment assignment : assignments) {
String constraint = assignment.constraint();
if (constraint == null || constraint.isBlank()) {
unconstrained.add(assignment);
continue;
}
if (BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, branchBindings)) {
compatible.add(assignment);
}
}
if (!compatible.isEmpty()) {
return compatible;
}
return unconstrained;
}
private record BranchAssignment(Expression expression, String constraint) {}
public Map<String, String> buildParameterValuesMap(String caller, String target, Map<String, List<CallEdge>> callGraph, List<String> path, int pathIndex) { public Map<String, String> buildParameterValuesMap(String caller, String target, Map<String, List<CallEdge>> callGraph, List<String> path, int pathIndex) {
return buildParameterValuesMap(caller, target, callGraph, path, pathIndex, Map.of());
}
public Map<String, String> buildParameterValuesMap(
String caller,
String target,
Map<String, List<CallEdge>> callGraph,
List<String> path,
int pathIndex,
Map<String, String> bindings) {
Map<String, String> paramValues = new HashMap<>(); Map<String, String> paramValues = new HashMap<>();
context.setAnalysisCallPath(path);
try {
return buildParameterValuesMapInternal(caller, target, callGraph, path, pathIndex, bindings, paramValues);
} finally {
context.clearAnalysisCallPath();
}
}
private Map<String, String> buildParameterValuesMapInternal(
String caller,
String target,
Map<String, List<CallEdge>> callGraph,
List<String> path,
int pathIndex,
Map<String, String> bindings,
Map<String, String> paramValues) {
List<CallEdge> edges = callGraph.get(caller); List<CallEdge> edges = callGraph.get(caller);
boolean hasEdge = false; boolean hasEdge = false;
@@ -544,23 +541,32 @@ public class VariableTracer {
return paramValues; return paramValues;
} }
// We also want to support heuristic matches using a helper if isHeuristicMatch is not directly available, but let's check CallEdge selectedEdge = CallSiteMatcher.selectEdge(caller, target, edges, bindings);
// We can check if name equals or if it resolves to target if (selectedEdge == null) {
for (CallEdge edge : edges) { return paramValues;
if (edge.getTargetMethod().equals(target) || isHeuristicMatch(edge.getTargetMethod(), target)) { }
List<String> args = edge.getArguments();
if (args == null || args.isEmpty()) break; List<String> args = selectedEdge.getArguments();
if (args == null || args.isEmpty()) {
return paramValues;
}
int lastDot = target.lastIndexOf('.'); int lastDot = target.lastIndexOf('.');
if (lastDot < 0) break; if (lastDot < 0) {
return paramValues;
}
String className = target.substring(0, lastDot); String className = target.substring(0, lastDot);
String methodName = target.substring(lastDot + 1); String methodName = target.substring(lastDot + 1);
TypeDeclaration td = context.getTypeDeclaration(className); TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) break; if (td == null) {
return paramValues;
}
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null) break; if (md == null) {
return paramValues;
}
List<String> paramNames = new ArrayList<>(); List<String> paramNames = new ArrayList<>();
for (Object paramObj : md.parameters()) { for (Object paramObj : md.parameters()) {
@@ -572,11 +578,15 @@ public class VariableTracer {
for (int j = 0; j < minSize; j++) { for (int j = 0; j < minSize; j++) {
String argValue = args.get(j); String argValue = args.get(j);
String resolvedArgValue = resolveArgumentValue(argValue, caller, path, pathIndex, callGraph); String resolvedArgValue = resolveArgumentValue(argValue, caller, path, pathIndex, callGraph);
if (typeResolver != null
&& FunctionalInterfaceTypes.isFunctionalInterface(typeResolver.getParameterType(target, j))) {
List<String> constants = resolveConstantsFromCallSiteArgument(caller, target, j, selectedEdge);
if (constants.size() == 1) {
resolvedArgValue = constants.get(0);
}
}
paramValues.put(paramNames.get(j), resolvedArgValue); paramValues.put(paramNames.get(j), resolvedArgValue);
} }
break;
}
}
return paramValues; return paramValues;
} }
@@ -667,9 +677,7 @@ public class VariableTracer {
String classTarget = target.substring(0, target.lastIndexOf('.')); String classTarget = target.substring(0, target.lastIndexOf('.'));
if (classNeighbor.equals(classTarget)) return true; if (classNeighbor.equals(classTarget)) return true;
String simpleClassNeighbor = classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor; if (context.areSameTypeOrUnambiguousSimpleMatch(classNeighbor, classTarget)) return true;
String simpleClassTarget = classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
if (simpleClassNeighbor.equals(simpleClassTarget)) return true;
List<String> impls = context.getImplementations(classTarget); List<String> impls = context.getImplementations(classTarget);
if (impls != null) { if (impls != null) {
@@ -697,7 +705,14 @@ public class VariableTracer {
String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor; String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
if (simpleClassNeighbor != null && simpleClassTarget != null) { if (simpleClassNeighbor != null && simpleClassTarget != null) {
if (simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) return true; String leftClass = classNeighbor != null ? classNeighbor : simpleClassNeighbor;
String rightClass = classTarget != null ? classTarget : simpleClassTarget;
if (context.areSameTypeOrUnambiguousSimpleMatch(leftClass, rightClass)) return true;
if (!context.isAmbiguousSimpleName(simpleClassNeighbor)
&& !context.isAmbiguousSimpleName(simpleClassTarget)
&& simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) {
return true;
}
if (simpleClassNeighbor.equals("this") || simpleClassNeighbor.equals("super")) return true; if (simpleClassNeighbor.equals("this") || simpleClassNeighbor.equals("super")) return true;
return false; return false;
} }
@@ -705,22 +720,6 @@ public class VariableTracer {
return true; return true;
} }
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) { private Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) {
return MethodInvocationUnwrapper.unwrap(mi, depth, methodFqn, context, miArg -> resolveTargetMethodFqn(miArg, methodFqn)); return MethodInvocationUnwrapper.unwrap(mi, depth, methodFqn, context, miArg -> resolveTargetMethodFqn(miArg, methodFqn));
} }
@@ -744,4 +743,86 @@ public class VariableTracer {
} }
return null; return null;
} }
/**
* Resolves enum/constants passed at a call site using source AST + {@link JdtDataFlowModel}.
* Used for {@code Supplier}, {@code Runnable}, and similar functional parameters.
*/
public List<String> resolveConstantsFromCallSiteArgument(String caller, String callee, int paramIndex) {
return resolveConstantsFromCallSiteArgument(caller, callee, paramIndex, null);
}
public List<String> resolveConstantsFromCallSiteArgument(
String caller, String callee, int paramIndex, CallEdge edge) {
if (constantExtractor == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
Expression sourceArg = CallSiteMatcher.findCallSiteArgumentExpression(
caller, callee, paramIndex, edge, context);
if (sourceArg != null) {
resolveConstantsFromExpressionViaDataflow(sourceArg, constants);
}
return constants;
}
public int findFunctionalParameterIndex(String methodFqn) {
if (methodFqn == null || typeResolver == null) {
return -1;
}
for (int i = 0; i < 8; i++) {
String paramType = typeResolver.getParameterType(methodFqn, i);
if (paramType == null) {
break;
}
if (FunctionalInterfaceTypes.isFunctionalInterface(paramType)) {
return i;
}
}
return -1;
}
public static boolean isFunctionalSamMethod(String methodFqn) {
return FunctionalInterfaceTypes.isFunctionalSamMethod(methodFqn);
}
public void resolveConstantsViaDataflow(Expression expression, List<String> constants) {
resolveConstantsFromExpressionViaDataflow(expression, constants);
}
public List<String> resolveConstantsFromSourceText(
String expressionText, List<String> searchMethods, List<String> callPath) {
if (expressionText == null || expressionText.isBlank()) {
return List.of();
}
context.setAnalysisCallPath(callPath);
try {
Expression anchored = AstUtils.findExpressionInMethods(searchMethods, expressionText, context);
Expression expr = anchored != null ? anchored : AstUtils.parseExpression(expressionText);
if (expr == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
resolveConstantsViaDataflow(expr, constants);
return constants.stream()
.filter(c -> c != null && !c.isBlank())
.distinct()
.toList();
} finally {
context.clearAnalysisCallPath();
}
}
private void resolveConstantsFromExpressionViaDataflow(Expression expression, List<String> constants) {
for (Expression def : dataFlowModel.getReachingDefinitions(expression)) {
constantExtractor.extractConstantsFromExpression(def, constants);
}
if (constants.isEmpty()) {
constantExtractor.extractConstantsFromExpression(expression, constants);
}
}
Expression findCallSiteArgumentExpression(String caller, String callee, int paramIndex) {
return CallSiteMatcher.findCallSiteArgumentExpression(caller, callee, paramIndex, null, context);
}
} }

View File

@@ -62,9 +62,9 @@ public class InjectionPointAnalyzer {
try { try {
IAnnotationBinding[] paramAnns = method.getParameterAnnotations(i); IAnnotationBinding[] paramAnns = method.getParameterAnnotations(i);
String paramQual = getQualifierValue(paramAnns); String paramQual = getQualifierValue(paramAnns);
if (paramQual != null && paramType.getErasure().isEqualTo(binding.getType().getErasure())) { if (paramQual != null
// For setters, verify it roughly matches the field name or just rely on type. && paramType.getErasure().isEqualTo(binding.getType().getErasure())
// We will rely on type equality for now as a heuristic. && parameterMatchesField(method, i, binding.getName())) {
return paramQual; return paramQual;
} }
} catch (Exception e) { } catch (Exception e) {
@@ -90,4 +90,29 @@ public class InjectionPointAnalyzer {
} }
return null; return null;
} }
private static boolean parameterMatchesField(
org.eclipse.jdt.core.dom.IMethodBinding method,
int parameterIndex,
String fieldName) {
if (method == null || fieldName == null || fieldName.isBlank() || parameterIndex < 0) {
return false;
}
String[] names;
try {
names = method.getParameterNames();
} catch (Exception ignored) {
names = null;
}
if (names != null && parameterIndex < names.length) {
return fieldName.equals(names[parameterIndex]);
}
// If parameter names aren't available, only accept clear setter-like methods.
String methodName = method.getName();
if (methodName != null && methodName.startsWith("set") && method.getParameterTypes().length == 1) {
String expected = "set" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1);
return expected.equals(methodName);
}
return false;
}
} }

View File

@@ -31,7 +31,17 @@ public class SpringDependencyResolver {
.filter(bean -> requiredTypeFqn.equals(bean.getTypeFqn()) || bean.getAssignableTypes().contains(requiredTypeFqn)) .filter(bean -> requiredTypeFqn.equals(bean.getTypeFqn()) || bean.getAssignableTypes().contains(requiredTypeFqn))
.collect(Collectors.toList()); .collect(Collectors.toList());
log.debug("CANDIDATES AFTER TYPE: {}", candidates.stream().map(c -> c.getTypeFqn() + " (primary=" + c.isPrimary() + " names=" + c.getBeanNames() + " qual=" + c.getQualifiers() + ")").collect(Collectors.toList())); log.debug("CANDIDATES AFTER TYPE: {}", candidates.size());
if (log.isDebugEnabled()) {
log.debug(
"CANDIDATE DETAILS: {}",
candidates.stream()
.map(c -> c.getTypeFqn()
+ " (primary=" + c.isPrimary()
+ " names=" + c.getBeanNames()
+ " qual=" + c.getQualifiers() + ")")
.collect(Collectors.toList()));
}
if (candidates.isEmpty() || candidates.size() == 1) { if (candidates.isEmpty() || candidates.size() == 1) {
log.debug("RETURNING: {}", candidates.size()); log.debug("RETURNING: {}", candidates.size());
return candidates; return candidates;

View File

@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.analysis.validation;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
@@ -49,7 +50,7 @@ public final class AnalysisCanonicalFormValidator {
return violations; return violations;
} }
validateFields(result, machineTypes, violations); validateFields(result, machineTypes, violations, context);
return violations; return violations;
} }
@@ -65,7 +66,7 @@ public final class AnalysisCanonicalFormValidator {
return violations; return violations;
} }
validateFields(result, machineTypes, violations); validateFields(result, machineTypes, violations, null);
return violations; return violations;
} }
@@ -88,15 +89,18 @@ public final class AnalysisCanonicalFormValidator {
private static void validateFields( private static void validateFields(
AnalysisResult result, AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) { List<Violation> violations,
validateTransitions(result.getTransitions(), machineTypes, violations); CodebaseContext context) {
validateStateCollection(result.getStates(), machineTypes.stateTypeFqn(), "states", violations); validateTransitions(result.getTransitions(), machineTypes, violations, context);
validateStateLabels(result.getStartStates(), machineTypes.stateTypeFqn(), "startStates", violations); validateStateCollection(result.getStates(), machineTypes.stateTypeFqn(), "states", violations, context);
validateStateLabels(result.getEndStates(), machineTypes.stateTypeFqn(), "endStates", violations); validateStateRawNameSync(result.getStates(), machineTypes.stateTypeFqn(), "states", violations);
validateStateLabels(result.getStartStates(), machineTypes.stateTypeFqn(), "startStates", violations, context);
validateStateLabels(result.getEndStates(), machineTypes.stateTypeFqn(), "endStates", violations, context);
if (result.getMetadata() != null) { if (result.getMetadata() != null) {
validateTriggers(result.getMetadata().getTriggers(), machineTypes, violations); validateTriggers(result.getMetadata().getTriggers(), machineTypes, violations, context);
validateCallChains(result.getMetadata().getCallChains(), machineTypes, violations); validateCallChains(
result.getMetadata().getCallChains(), result.getTransitions(), machineTypes, violations, context);
} }
} }
@@ -111,7 +115,8 @@ public final class AnalysisCanonicalFormValidator {
private static void validateTransitions( private static void validateTransitions(
List<Transition> transitions, List<Transition> transitions,
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (transitions == null) { if (transitions == null) {
return; return;
} }
@@ -123,27 +128,43 @@ public final class AnalysisCanonicalFormValidator {
prefix + ".event.fullIdentifier", prefix + ".event.fullIdentifier",
transition.getEvent().fullIdentifier(), transition.getEvent().fullIdentifier(),
machineTypes.eventTypeFqn(), machineTypes.eventTypeFqn(),
violations,
context);
requireRawNameSync(
prefix + ".event.rawName",
transition.getEvent().rawName(),
transition.getEvent().fullIdentifier(),
machineTypes.eventTypeFqn(),
violations); violations);
} }
validateStateList(transition.getSourceStates(), machineTypes.stateTypeFqn(), prefix + ".sourceStates", violations); validateStateListWithRawSync(
validateStateList(transition.getTargetStates(), machineTypes.stateTypeFqn(), prefix + ".targetStates", violations); transition.getSourceStates(), machineTypes.stateTypeFqn(), prefix + ".sourceStates", violations, context);
validateStateListWithRawSync(
transition.getTargetStates(), machineTypes.stateTypeFqn(), prefix + ".targetStates", violations, context);
} }
} }
private static void validateStateList( private static void validateStateListWithRawSync(
List<State> states, List<State> states,
String stateTypeFqn, String stateTypeFqn,
String pathPrefix, String pathPrefix,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (states == null) { if (states == null) {
return; return;
} }
for (int i = 0; i < states.size(); i++) { for (int i = 0; i < states.size(); i++) {
requireCanonical(pathPrefix + "[" + i + "].fullIdentifier", states.get(i).fullIdentifier(), stateTypeFqn, violations); State state = states.get(i);
requireCanonical(pathPrefix + "[" + i + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations, context);
requireRawNameSync(
pathPrefix + "[" + i + "].rawName",
state.rawName(),
state.fullIdentifier(),
stateTypeFqn,
violations);
} }
} }
private static void validateStateRawNameSync(
private static void validateStateCollection(
Set<State> states, Set<State> states,
String stateTypeFqn, String stateTypeFqn,
String pathPrefix, String pathPrefix,
@@ -153,7 +174,27 @@ public final class AnalysisCanonicalFormValidator {
} }
int i = 0; int i = 0;
for (State state : states) { for (State state : states) {
requireCanonical(pathPrefix + "[" + i++ + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations); requireRawNameSync(
pathPrefix + "[" + i++ + "].rawName",
state.rawName(),
state.fullIdentifier(),
stateTypeFqn,
violations);
}
}
private static void validateStateCollection(
Set<State> states,
String stateTypeFqn,
String pathPrefix,
List<Violation> violations,
CodebaseContext context) {
if (states == null) {
return;
}
int i = 0;
for (State state : states) {
requireCanonical(pathPrefix + "[" + i++ + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations, context);
} }
} }
@@ -161,20 +202,22 @@ public final class AnalysisCanonicalFormValidator {
Set<String> labels, Set<String> labels,
String stateTypeFqn, String stateTypeFqn,
String pathPrefix, String pathPrefix,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (labels == null) { if (labels == null) {
return; return;
} }
int i = 0; int i = 0;
for (String label : labels) { for (String label : labels) {
requireCanonical(pathPrefix + "[" + i++ + "]", label, stateTypeFqn, violations); requireCanonical(pathPrefix + "[" + i++ + "]", label, stateTypeFqn, violations, context);
} }
} }
private static void validateTriggers( private static void validateTriggers(
List<TriggerPoint> triggers, List<TriggerPoint> triggers,
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (triggers == null) { if (triggers == null) {
return; return;
} }
@@ -184,16 +227,17 @@ public final class AnalysisCanonicalFormValidator {
String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn()); String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn());
String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn()); String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn());
requireCanonical(prefix + ".event", trigger.getEvent(), eventTypeFqn, violations); requireCanonical(prefix + ".event", trigger.getEvent(), eventTypeFqn, violations, context);
requireCanonical(prefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations); validateTriggerEventForm(prefix + ".event", trigger.getEvent(), violations);
requireCanonical(prefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations, context);
validateDynamicIdentifierForm(prefix + ".sourceState", trigger.getSourceState(), violations);
if (trigger.getPolymorphicEvents() != null) { if (trigger.getPolymorphicEvents() != null) {
for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) { for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) {
requireCanonical( String polyPath = prefix + ".polymorphicEvents[" + j + "]";
prefix + ".polymorphicEvents[" + j + "]", String polyEvent = trigger.getPolymorphicEvents().get(j);
trigger.getPolymorphicEvents().get(j), requireCanonical(polyPath, polyEvent, eventTypeFqn, violations, context);
eventTypeFqn, validatePolymorphicEventForm(polyPath, polyEvent, violations);
violations);
} }
} }
} }
@@ -201,8 +245,10 @@ public final class AnalysisCanonicalFormValidator {
private static void validateCallChains( private static void validateCallChains(
List<CallChain> callChains, List<CallChain> callChains,
List<Transition> transitions,
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (callChains == null) { if (callChains == null) {
return; return;
} }
@@ -214,25 +260,30 @@ public final class AnalysisCanonicalFormValidator {
String triggerPrefix = prefix + ".triggerPoint"; String triggerPrefix = prefix + ".triggerPoint";
String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn()); String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn());
String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn()); String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn());
requireCanonical(triggerPrefix + ".event", trigger.getEvent(), eventTypeFqn, violations); requireCanonical(triggerPrefix + ".event", trigger.getEvent(), eventTypeFqn, violations, context);
requireCanonical(triggerPrefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations); validateTriggerEventForm(triggerPrefix + ".event", trigger.getEvent(), violations);
requireCanonical(triggerPrefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations, context);
validateDynamicIdentifierForm(triggerPrefix + ".sourceState", trigger.getSourceState(), violations);
if (trigger.getPolymorphicEvents() != null) { if (trigger.getPolymorphicEvents() != null) {
for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) { for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) {
requireCanonical( String polyPath = triggerPrefix + ".polymorphicEvents[" + j + "]";
triggerPrefix + ".polymorphicEvents[" + j + "]", String polyEvent = trigger.getPolymorphicEvents().get(j);
trigger.getPolymorphicEvents().get(j), requireCanonical(polyPath, polyEvent, eventTypeFqn, violations, context);
eventTypeFqn, validatePolymorphicEventForm(polyPath, polyEvent, violations);
violations);
} }
} }
validateMatchedTransitionsWhenResolvable(
prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations, context);
validateOverLinkedPolymorphicEvents(
prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations, context);
} }
if (chain.getMatchedTransitions() != null) { if (chain.getMatchedTransitions() != null) {
for (int j = 0; j < chain.getMatchedTransitions().size(); j++) { for (int j = 0; j < chain.getMatchedTransitions().size(); j++) {
MatchedTransition matched = chain.getMatchedTransitions().get(j); MatchedTransition matched = chain.getMatchedTransitions().get(j);
String matchedPrefix = prefix + ".matchedTransitions[" + j + "]"; String matchedPrefix = prefix + ".matchedTransitions[" + j + "]";
requireCanonical(matchedPrefix + ".event", matched.getEvent(), machineTypes.eventTypeFqn(), violations); requireCanonical(matchedPrefix + ".event", matched.getEvent(), machineTypes.eventTypeFqn(), violations, context);
requireCanonical(matchedPrefix + ".sourceState", matched.getSourceState(), machineTypes.stateTypeFqn(), violations); requireCanonical(matchedPrefix + ".sourceState", matched.getSourceState(), machineTypes.stateTypeFqn(), violations, context);
requireCanonical(matchedPrefix + ".targetState", matched.getTargetState(), machineTypes.stateTypeFqn(), violations); requireCanonical(matchedPrefix + ".targetState", matched.getTargetState(), machineTypes.stateTypeFqn(), violations, context);
} }
} }
} }
@@ -249,13 +300,279 @@ public final class AnalysisCanonicalFormValidator {
String path, String path,
String value, String value,
String enumTypeFqn, String enumTypeFqn,
List<Violation> violations) { List<Violation> violations,
if (!MachineEnumCanonicalizer.isMachineEnumReference(value, enumTypeFqn)) { CodebaseContext context) {
if (!MachineEnumCanonicalizer.isMachineEnumReference(value, enumTypeFqn, context)) {
return; return;
} }
String expected = MachineEnumCanonicalizer.canonicalizeLabel(value, enumTypeFqn); String expected = MachineEnumCanonicalizer.canonicalizeLabel(value, enumTypeFqn, context);
if (!expected.equals(value)) { if (!expected.equals(value)) {
violations.add(new Violation(path, value, expected)); violations.add(new Violation(path, value, expected));
} }
} }
private static void requireRawNameSync(
String path,
String rawName,
String fullIdentifier,
String enumTypeFqn,
List<Violation> violations) {
if (MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier(rawName, fullIdentifier, enumTypeFqn)) {
return;
}
String expected = MachineEnumCanonicalizer.expectedFnRawName(fullIdentifier, enumTypeFqn);
violations.add(new Violation(path, rawName, expected));
}
private static void validatePolymorphicEventForm(String path, String event, List<Violation> violations) {
if (event == null || event.isBlank()) {
return;
}
if (!MachineEnumCanonicalizer.isValidPolymorphicEventForm(event)) {
MachineEnumCanonicalizer.TriggerEventKind kind = MachineEnumCanonicalizer.classifyTriggerEvent(event);
String expected = kind == MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION
&& MachineEnumCanonicalizer.hasCorruptedPackageDynamicEvent(event)
? MachineEnumCanonicalizer.denormalizeCorruptedDynamicEvent(event)
: "CANONICAL_ENUM or SYMBOLIC";
violations.add(new Violation(path, event, expected));
}
}
private static void validateDynamicIdentifierForm(String path, String value, List<Violation> violations) {
if (value == null || value.isBlank()) {
return;
}
if (MachineEnumCanonicalizer.hasCorruptedPackageDynamicEvent(value)) {
violations.add(new Violation(
path,
value,
MachineEnumCanonicalizer.denormalizeCorruptedDynamicEvent(value)));
}
}
private static void validateOverLinkedPolymorphicEvents(
String chainPrefix,
CallChain chain,
TriggerPoint trigger,
List<Transition> transitions,
String eventTypeFqn,
List<Violation> violations,
CodebaseContext context) {
if (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty()) {
return;
}
if (!MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) {
return;
}
List<String> transitionEvents =
MachineEnumCanonicalizer.polymorphicEventsFromTransitions(transitions, eventTypeFqn, context);
if (transitionEvents.isEmpty()) {
return;
}
List<String> extras = new ArrayList<>();
for (String polyEvent : trigger.getPolymorphicEvents()) {
if (!transitionEvents.contains(polyEvent)) {
extras.add(polyEvent);
}
}
if (!extras.isEmpty()) {
violations.add(new Violation(
chainPrefix + ".triggerPoint.polymorphicEvents",
String.valueOf(trigger.getPolymorphicEvents().size()),
"subset of configured transition events; unexpected: " + extras));
return;
}
int matchingTransitionCount = countMatchingConfiguredTransitions(
transitions, trigger.getPolymorphicEvents(), eventTypeFqn, context);
if (matchingTransitionCount > 0
&& chain.getMatchedTransitions() != null
&& chain.getMatchedTransitions().size() > matchingTransitionCount) {
violations.add(new Violation(
chainPrefix + ".matchedTransitions",
String.valueOf(chain.getMatchedTransitions().size()),
"at most " + matchingTransitionCount + " for configured transitions"));
}
}
private static int countMatchingConfiguredTransitions(
List<Transition> transitions,
List<String> polymorphicEvents,
String eventTypeFqn,
CodebaseContext context) {
if (transitions == null || transitions.isEmpty() || polymorphicEvents == null) {
return 0;
}
int count = 0;
for (Transition transition : transitions) {
if (transition.getEvent() == null) {
continue;
}
String smEvent = transition.getEvent().fullIdentifier() != null
? transition.getEvent().fullIdentifier()
: transition.getEvent().rawName();
for (String polyEvent : polymorphicEvents) {
if (eventsMatch(polyEvent, smEvent, eventTypeFqn, context)) {
count++;
break;
}
}
}
return count;
}
private static void validateMatchedTransitionsWhenResolvable(
String chainPrefix,
CallChain chain,
TriggerPoint trigger,
List<Transition> transitions,
String eventTypeFqn,
List<Violation> violations,
CodebaseContext context) {
if (LifecycleTriggerMarkers.isLifecycle(trigger.getEvent())) {
return;
}
if (isIntentionalAmbiguousFailClosed(trigger)) {
return;
}
if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) {
return;
}
if (chain.getLinkResolution() == click.kamil.springstatemachineexporter.analysis.model.LinkResolution.NO_MATCH
|| chain.getLinkResolution()
== click.kamil.springstatemachineexporter.analysis.model.LinkResolution.UNRESOLVED_EXTERNAL) {
return;
}
MachineEnumCanonicalizer.TriggerEventKind eventKind =
MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent());
if (eventKind == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) {
if (eventMatchesAnyTransition(trigger.getEvent(), transitions, eventTypeFqn, context)) {
violations.add(new Violation(
chainPrefix + ".matchedTransitions",
"empty",
"non-empty when trigger has concrete machine enum event matching transitions"));
}
return;
}
if (!MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) {
return;
}
if (eventKind != MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
return;
}
if (!polymorphicEventsMatchAnyTransition(trigger.getPolymorphicEvents(), transitions, eventTypeFqn, context)) {
return;
}
violations.add(new Violation(
chainPrefix + ".matchedTransitions",
"empty",
"non-empty when dynamic trigger has concrete polymorphicEvents matching transitions"));
}
private static boolean isIntentionalAmbiguousFailClosed(TriggerPoint trigger) {
if (trigger == null) {
return false;
}
if (trigger.isAmbiguous() && !trigger.isExternal()) {
return true;
}
String event = trigger.getEvent();
if (event != null && event.startsWith("ENUM_SET:")) {
return true;
}
if (trigger.isExternal()
&& trigger.isAmbiguous()
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(event)
&& (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty())) {
return true;
}
return false;
}
private static boolean eventMatchesAnyTransition(
String triggerEvent,
List<Transition> transitions,
String eventTypeFqn,
CodebaseContext context) {
if (transitions == null || transitions.isEmpty() || triggerEvent == null) {
return false;
}
for (Transition transition : transitions) {
if (transition.getEvent() == null) {
continue;
}
String smEvent = transition.getEvent().fullIdentifier() != null
? transition.getEvent().fullIdentifier()
: transition.getEvent().rawName();
if (eventsMatch(triggerEvent, smEvent, eventTypeFqn, context)) {
return true;
}
}
return false;
}
private static boolean polymorphicEventsMatchAnyTransition(
List<String> polymorphicEvents,
List<Transition> transitions,
String eventTypeFqn,
CodebaseContext context) {
if (transitions == null || transitions.isEmpty()) {
return false;
}
for (String polyEvent : polymorphicEvents) {
for (Transition transition : transitions) {
if (transition.getEvent() == null) {
continue;
}
String smEvent = transition.getEvent().fullIdentifier() != null
? transition.getEvent().fullIdentifier()
: transition.getEvent().rawName();
if (eventsMatch(polyEvent, smEvent, eventTypeFqn, context)) {
return true;
}
}
}
return false;
}
private static boolean eventsMatch(
String triggerEvent,
String smEvent,
String eventTypeFqn,
CodebaseContext context) {
if (triggerEvent == null || smEvent == null) {
return false;
}
if (triggerEvent.equals(smEvent)) {
return true;
}
String triggerConst = constantName(triggerEvent);
String smConst = constantName(smEvent);
if (!triggerConst.equals(smConst)) {
return false;
}
if (MachineEnumCanonicalizer.isMachineEnumReference(triggerEvent, eventTypeFqn, context)
&& MachineEnumCanonicalizer.isMachineEnumReference(smEvent, eventTypeFqn, context)) {
return true;
}
return false;
}
private static String constantName(String ref) {
int dot = ref.lastIndexOf('.');
return dot >= 0 ? ref.substring(dot + 1) : ref;
}
private static void validateTriggerEventForm(String path, String event, List<Violation> violations) {
if (event == null || event.isBlank()) {
return;
}
if (MachineEnumCanonicalizer.hasCorruptedPackageDynamicEvent(event)) {
violations.add(new Violation(
path,
event,
MachineEnumCanonicalizer.denormalizeCorruptedDynamicEvent(event)));
}
}
} }

View File

@@ -7,6 +7,7 @@ import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Event; import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.Transition; import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.model.TransitionType; import click.kamil.springstatemachineexporter.model.TransitionType;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList; import java.util.ArrayList;
@@ -349,7 +350,7 @@ public class AstTransitionParser {
} }
} else { } else {
// FALLBACK: Manual resolution by name if binding failed // FALLBACK: Manual resolution by name if binding failed
TypeDeclaration currentClass = findEnclosingType(mi); TypeDeclaration currentClass = AstUtils.findEnclosingType(mi);
if (currentClass != null) { if (currentClass != null) {
return resolveMethodManually(mi.getName().getIdentifier(), currentClass, context); return resolveMethodManually(mi.getName().getIdentifier(), currentClass, context);
} }
@@ -393,7 +394,7 @@ public class AstTransitionParser {
typeFqn = binding.getQualifiedName(); typeFqn = binding.getQualifiedName();
} else if (expr instanceof SimpleName sn) { } else if (expr instanceof SimpleName sn) {
// Check enclosing method first for local variables // Check enclosing method first for local variables
MethodDeclaration enclosingMethod = findEnclosingMethod(expr); MethodDeclaration enclosingMethod = AstUtils.findEnclosingMethod(expr);
if (enclosingMethod != null) { if (enclosingMethod != null) {
final String[] foundType = {null}; final String[] foundType = {null};
final Expression[] foundInitializer = {null}; final Expression[] foundInitializer = {null};
@@ -424,7 +425,7 @@ public class AstTransitionParser {
} }
if (typeFqn == null) { if (typeFqn == null) {
TypeDeclaration enclosingClass = findEnclosingType(expr); TypeDeclaration enclosingClass = AstUtils.findEnclosingType(expr);
if (enclosingClass != null) { if (enclosingClass != null) {
for (FieldDeclaration fd : enclosingClass.getFields()) { for (FieldDeclaration fd : enclosingClass.getFields()) {
for (Object fragObj : fd.fragments()) { for (Object fragObj : fd.fragments()) {
@@ -480,15 +481,6 @@ public class AstTransitionParser {
return null; return null;
} }
private static MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof MethodDeclaration md) return md;
current = current.getParent();
}
return null;
}
private static String resolveMethodManually(String methodName, TypeDeclaration td, CodebaseContext context) { private static String resolveMethodManually(String methodName, TypeDeclaration td, CodebaseContext context) {
// Search in this class // Search in this class
for (MethodDeclaration md : td.getMethods()) { for (MethodDeclaration md : td.getMethods()) {
@@ -520,21 +512,12 @@ public class AstTransitionParser {
return null; return null;
} }
private static TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof TypeDeclaration td) return td;
current = current.getParent();
}
return null;
}
private static void parseGuard(Object arg, Transition t, CompilationUnit cu, CodebaseContext context) { private static void parseGuard(Object arg, Transition t, CompilationUnit cu, CodebaseContext context) {
QuotedExpression quotedExpr = QuotedExpression.of(arg); QuotedExpression quotedExpr = QuotedExpression.of(arg);
if (quotedExpr != null) { if (quotedExpr != null) {
String internalLogic = extractInternalLogic(quotedExpr.getExpression(), cu, context); String internalLogic = extractInternalLogic(quotedExpr.getExpression(), cu, context);
int lineNumber = cu.getLineNumber(quotedExpr.getExpression().getStartPosition()); int lineNumber = cu.getLineNumber(quotedExpr.getExpression().getStartPosition());
TypeDeclaration td = findEnclosingType(quotedExpr.getExpression()); TypeDeclaration td = AstUtils.findEnclosingType(quotedExpr.getExpression());
String fqn = td != null ? context.getFqn(td) : null; String fqn = td != null ? context.getFqn(td) : null;
String sourceFile = fqn != null ? context.getRelativePath(fqn) : null; String sourceFile = fqn != null ? context.getRelativePath(fqn) : null;
t.getGuards().add(Guard.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression()), internalLogic, lineNumber, fqn, sourceFile)); t.getGuards().add(Guard.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression()), internalLogic, lineNumber, fqn, sourceFile));
@@ -546,7 +529,7 @@ public class AstTransitionParser {
if (quotedExpr != null) { if (quotedExpr != null) {
String internalLogic = extractInternalLogic(quotedExpr.getExpression(), cu, context); String internalLogic = extractInternalLogic(quotedExpr.getExpression(), cu, context);
int lineNumber = cu.getLineNumber(quotedExpr.getExpression().getStartPosition()); int lineNumber = cu.getLineNumber(quotedExpr.getExpression().getStartPosition());
TypeDeclaration td = findEnclosingType(quotedExpr.getExpression()); TypeDeclaration td = AstUtils.findEnclosingType(quotedExpr.getExpression());
String fqn = td != null ? context.getFqn(td) : null; String fqn = td != null ? context.getFqn(td) : null;
String sourceFile = fqn != null ? context.getRelativePath(fqn) : null; String sourceFile = fqn != null ? context.getRelativePath(fqn) : null;
t.getActions().add(Action.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression()), internalLogic, lineNumber, fqn, sourceFile)); t.getActions().add(Action.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression()), internalLogic, lineNumber, fqn, sourceFile));

View File

@@ -1,7 +1,20 @@
package click.kamil.springstatemachineexporter.ast.common; package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.ArrayType; import org.eclipse.jdt.core.dom.ArrayType;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.Name; import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.NameQualifiedType; import org.eclipse.jdt.core.dom.NameQualifiedType;
import org.eclipse.jdt.core.dom.ParameterizedType; import org.eclipse.jdt.core.dom.ParameterizedType;
import org.eclipse.jdt.core.dom.PrimitiveType; import org.eclipse.jdt.core.dom.PrimitiveType;
@@ -9,11 +22,62 @@ import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.QualifiedType; import org.eclipse.jdt.core.dom.QualifiedType;
import org.eclipse.jdt.core.dom.SimpleType; import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.Type; import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.List;
import java.util.Map;
public final class AstUtils { public final class AstUtils {
private AstUtils() { private AstUtils() {
} }
public static MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
public static TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
public static String simpleName(String fqn) {
if (fqn == null || fqn.isEmpty()) {
return fqn;
}
int dot = fqn.lastIndexOf('.');
return dot >= 0 ? fqn.substring(dot + 1) : fqn;
}
public static String qualifier(String fqn) {
if (fqn == null || !fqn.contains(".")) {
return null;
}
return fqn.substring(0, fqn.lastIndexOf('.'));
}
public static String extractAnnotationMember(Annotation annotation, String memberName) {
if (annotation instanceof SingleMemberAnnotation sma) {
if ("value".equals(memberName)) {
return sma.getValue().toString();
}
} else if (annotation instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if (pair.getName().getIdentifier().equals(memberName)) {
return pair.getValue().toString();
}
}
}
return "";
}
public static String extractSimpleTypeName(Type type) { public static String extractSimpleTypeName(Type type) {
if (type.isSimpleType()) { if (type.isSimpleType()) {
Name name = ((SimpleType) type).getName(); Name name = ((SimpleType) type).getName();
@@ -209,4 +273,145 @@ public final class AstUtils {
combined.append(')'); combined.append(')');
return combined.toString(); return combined.toString();
} }
public static Expression parseExpression(String snippet) {
if (snippet == null || snippet.isBlank()) {
return null;
}
ASTParser parser = ASTParser.newParser(AST.JLS17);
Map<String, String> options = JavaCore.getOptions();
JavaCore.setComplianceOptions(JavaCore.VERSION_17, options);
parser.setCompilerOptions(options);
parser.setKind(ASTParser.K_EXPRESSION);
parser.setSource(snippet.toCharArray());
try {
ASTNode node = parser.createAST(null);
if (node instanceof Expression expression) {
return expression;
}
if (node instanceof CompilationUnit) {
ASTParser fallbackParser = ASTParser.newParser(AST.JLS17);
fallbackParser.setCompilerOptions(options);
fallbackParser.setKind(ASTParser.K_COMPILATION_UNIT);
fallbackParser.setSource(("class A { Object o = " + snippet + "; }").toCharArray());
CompilationUnit cu = (CompilationUnit) fallbackParser.createAST(null);
if (!cu.types().isEmpty() && cu.types().get(0) instanceof TypeDeclaration td) {
if (!td.bodyDeclarations().isEmpty() && td.bodyDeclarations().get(0) instanceof FieldDeclaration fd) {
if (!fd.fragments().isEmpty() && fd.fragments().get(0) instanceof VariableDeclarationFragment vdf) {
return vdf.getInitializer();
}
}
}
}
} catch (RuntimeException ignored) {
return null;
}
return null;
}
public static Expression findExpressionInMethod(String methodFqn, String expressionText, CodebaseContext context) {
if (methodFqn == null || expressionText == null || !methodFqn.contains(".") || context == null) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
final Expression[] match = new Expression[1];
md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(org.eclipse.jdt.core.dom.MethodInvocation node) {
if (expressionText.equals(node.toString())) {
match[0] = node;
return false;
}
return super.visit(node);
}
@Override
public boolean visit(org.eclipse.jdt.core.dom.SuperMethodInvocation node) {
if (expressionText.equals(node.toString())) {
match[0] = node;
return false;
}
return super.visit(node);
}
@Override
public boolean visit(QualifiedName node) {
if (expressionText.equals(node.toString())) {
match[0] = node;
return false;
}
return super.visit(node);
}
@Override
public boolean visit(org.eclipse.jdt.core.dom.SimpleName node) {
if (expressionText.equals(node.getIdentifier())) {
match[0] = node;
return false;
}
return super.visit(node);
}
});
return match[0];
}
public static org.eclipse.jdt.core.dom.MethodInvocation findMethodInvocationInMethod(
String methodFqn, String expressionText, CodebaseContext context) {
Expression found = findExpressionInMethod(methodFqn, expressionText, context);
return found instanceof org.eclipse.jdt.core.dom.MethodInvocation mi ? mi : null;
}
public static Expression findExpressionInMethods(
List<String> methodFqns, String expressionText, CodebaseContext context) {
if (methodFqns == null || expressionText == null) {
return null;
}
for (String methodFqn : methodFqns) {
Expression found = findExpressionInMethod(methodFqn, expressionText, context);
if (found != null) {
return found;
}
}
return null;
}
public static String combineExpressionText(String base, String suffix) {
if (suffix == null || suffix.isEmpty()) {
return base;
}
return base + suffix;
}
public static String toParseSnippet(String boundValue) {
if (boundValue == null || boundValue.isBlank()) {
return boundValue;
}
if (boundValue.startsWith("\"") && boundValue.endsWith("\"")) {
return boundValue;
}
if (isEnumLikeConstantName(boundValue)) {
return boundValue;
}
return "\"" + boundValue.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
public static boolean isEnumLikeConstantName(String value) {
if (value == null || value.isEmpty()) {
return false;
}
int dot = value.lastIndexOf('.');
if (dot <= 0 || dot >= value.length() - 1) {
return false;
}
return Character.isUpperCase(value.charAt(dot + 1));
}
} }

View File

@@ -2,6 +2,17 @@ package click.kamil.springstatemachineexporter.ast.common;
import click.kamil.springstatemachineexporter.analysis.index.AccessorIndex; import click.kamil.springstatemachineexporter.analysis.index.AccessorIndex;
import click.kamil.springstatemachineexporter.analysis.index.AccessorIndexBuilder; import click.kamil.springstatemachineexporter.analysis.index.AccessorIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.FieldTypeIndex;
import click.kamil.springstatemachineexporter.analysis.index.FieldTypeIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.GetterChainIndex;
import click.kamil.springstatemachineexporter.analysis.index.GetterChainIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.IndexedFieldConstantsIndex;
import click.kamil.springstatemachineexporter.analysis.index.IndexedFieldConstantsIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.PolymorphicAccessorIndex;
import click.kamil.springstatemachineexporter.analysis.index.PolymorphicAccessorIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.PolymorphicMethodExpansionIndex;
import click.kamil.springstatemachineexporter.analysis.index.PolymorphicMethodExpansionIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.pipeline.ClassCompatibilityCache;
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint; import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver; import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
@@ -20,6 +31,7 @@ import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -28,6 +40,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
public class CodebaseContext { public class CodebaseContext {
private final Map<String, CompilationUnit> classes = new HashMap<>(); private final Map<String, CompilationUnit> classes = new HashMap<>();
private final Map<String, TypeDeclaration> typeDeclarations = new HashMap<>();
private final Map<String, org.eclipse.jdt.core.dom.RecordDeclaration> recordDeclarations = new HashMap<>(); private final Map<String, org.eclipse.jdt.core.dom.RecordDeclaration> recordDeclarations = new HashMap<>();
private final Map<String, Path> classPaths = new HashMap<>(); private final Map<String, Path> classPaths = new HashMap<>();
private final Map<String, String> simpleNameToFqn = new HashMap<>(); private final Map<String, String> simpleNameToFqn = new HashMap<>();
@@ -46,6 +59,16 @@ public class CodebaseContext {
private Path projectRoot; private Path projectRoot;
private final Map<String, Object> cache = new HashMap<>(); private final Map<String, Object> cache = new HashMap<>();
private AccessorIndex accessorIndex = AccessorIndex.empty(); private AccessorIndex accessorIndex = AccessorIndex.empty();
private PolymorphicAccessorIndex polymorphicAccessorIndex = PolymorphicAccessorIndex.empty();
private GetterChainIndex getterChainIndex = GetterChainIndex.empty();
private IndexedFieldConstantsIndex indexedFieldConstantsIndex = IndexedFieldConstantsIndex.empty();
private FieldTypeIndex fieldTypeIndex = FieldTypeIndex.empty();
private PolymorphicMethodExpansionIndex polymorphicMethodExpansionIndex =
PolymorphicMethodExpansionIndex.empty();
private final ClassCompatibilityCache classCompatibilityCache = new ClassCompatibilityCache();
private final Map<String, MethodDeclaration> methodDeclarationCache = new HashMap<>();
private final Map<String, String> superclassFqnCache = new HashMap<>();
private final Map<String, List<String>> implementationsCache = new HashMap<>();
private boolean inlineAccessors = true; private boolean inlineAccessors = true;
public Map<String, Object> getCache() { public Map<String, Object> getCache() {
@@ -56,6 +79,40 @@ public class CodebaseContext {
return accessorIndex; return accessorIndex;
} }
public PolymorphicAccessorIndex getPolymorphicAccessorIndex() {
return polymorphicAccessorIndex;
}
public GetterChainIndex getGetterChainIndex() {
return getterChainIndex;
}
public IndexedFieldConstantsIndex getIndexedFieldConstantsIndex() {
return indexedFieldConstantsIndex;
}
public FieldTypeIndex getFieldTypeIndex() {
return fieldTypeIndex;
}
public PolymorphicMethodExpansionIndex getPolymorphicMethodExpansionIndex() {
return polymorphicMethodExpansionIndex;
}
public ClassCompatibilityCache getClassCompatibilityCache() {
return classCompatibilityCache;
}
public void clearAnalysisCaches() {
methodDeclarationCache.clear();
superclassFqnCache.clear();
classCompatibilityCache.clear();
}
public Set<String> getIndexedTypeFqns() {
return Collections.unmodifiableSet(classes.keySet());
}
public boolean isInlineAccessors() { public boolean isInlineAccessors() {
return inlineAccessors; return inlineAccessors;
} }
@@ -82,9 +139,9 @@ public class CodebaseContext {
public void loadLibraryHints(Path hintsFile) throws IOException { public void loadLibraryHints(Path hintsFile) throws IOException {
if (Files.exists(hintsFile)) { if (Files.exists(hintsFile)) {
System.out.println("Loading hints from " + hintsFile.toAbsolutePath()); log.debug("Loading optional hints.json override from {}", hintsFile.toAbsolutePath());
this.libraryHints = objectMapper.readValue(hintsFile.toFile(), new TypeReference<List<LibraryHint>>() {}); this.libraryHints = objectMapper.readValue(hintsFile.toFile(), new TypeReference<List<LibraryHint>>() {});
System.out.println("Loaded " + libraryHints.size() + " library hints"); log.debug("Loaded {} optional library hints from hints.json", libraryHints.size());
} }
} }
@@ -120,6 +177,32 @@ public class CodebaseContext {
private String[] classpath = new String[0]; private String[] classpath = new String[0];
private String[] sourcepath = new String[0]; private String[] sourcepath = new String[0];
private boolean resolveBindings = false; private boolean resolveBindings = false;
private transient List<String> analysisCallPath;
private transient Map<String, String> analysisNamedBindings;
public void setAnalysisCallPath(List<String> path) {
this.analysisCallPath = path;
}
public List<String> getAnalysisCallPath() {
return analysisCallPath;
}
public void clearAnalysisCallPath() {
this.analysisCallPath = null;
}
public void setAnalysisNamedBindings(Map<String, String> bindings) {
this.analysisNamedBindings = bindings;
}
public Map<String, String> getAnalysisNamedBindings() {
return analysisNamedBindings;
}
public void clearAnalysisNamedBindings() {
this.analysisNamedBindings = null;
}
public Map<String, Map<String, String>> getProperties() { public Map<String, Map<String, String>> getProperties() {
return Collections.unmodifiableMap(allProperties); return Collections.unmodifiableMap(allProperties);
@@ -137,6 +220,10 @@ public class CodebaseContext {
this.resolveBindings = resolveBindings; this.resolveBindings = resolveBindings;
} }
public boolean isResolveBindings() {
return resolveBindings;
}
public void setActiveProfiles(List<String> profiles) { public void setActiveProfiles(List<String> profiles) {
this.activeProfiles.clear(); this.activeProfiles.clear();
if (profiles != null) { if (profiles != null) {
@@ -240,6 +327,7 @@ public class CodebaseContext {
List<Path> javaFiles = FileUtils.findFilesWithExtension(rootDirs, ".java", activeIgnore); List<Path> javaFiles = FileUtils.findFilesWithExtension(rootDirs, ".java", activeIgnore);
log.info("CodebaseContext found {} java files across {} root directories", javaFiles.size(), rootDirs.size()); log.info("CodebaseContext found {} java files across {} root directories", javaFiles.size(), rootDirs.size());
typeDeclarations.clear();
for (Path javaFile : javaFiles) { for (Path javaFile : javaFiles) {
String source = Files.readString(javaFile); String source = Files.readString(javaFile);
CompilationUnit cu = parse(source, javaFile.toAbsolutePath().toString()); CompilationUnit cu = parse(source, javaFile.toAbsolutePath().toString());
@@ -257,9 +345,22 @@ public class CodebaseContext {
} }
} }
this.implementationsCache.clear();
this.accessorIndex = inlineAccessors this.accessorIndex = inlineAccessors
? new AccessorIndexBuilder().build(this) ? new AccessorIndexBuilder().build(this)
: AccessorIndex.empty(); : AccessorIndex.empty();
this.polymorphicAccessorIndex = inlineAccessors
? new PolymorphicAccessorIndexBuilder().build(this, accessorIndex)
: PolymorphicAccessorIndex.empty();
this.getterChainIndex = inlineAccessors
? new GetterChainIndexBuilder().build(this, accessorIndex)
: GetterChainIndex.empty();
this.indexedFieldConstantsIndex = inlineAccessors
? new IndexedFieldConstantsIndexBuilder().build(this, accessorIndex)
: IndexedFieldConstantsIndex.empty();
this.fieldTypeIndex = new FieldTypeIndexBuilder().build(this);
this.polymorphicMethodExpansionIndex = new PolymorphicMethodExpansionIndexBuilder().build(this);
clearAnalysisCaches();
if (inlineAccessors) { if (inlineAccessors) {
log.info("Built accessor index: {} trivial accessors across {} types", log.info("Built accessor index: {} trivial accessors across {} types",
accessorIndex.trivialCount(), accessorIndex.typeCount()); accessorIndex.trivialCount(), accessorIndex.typeCount());
@@ -274,6 +375,7 @@ public class CodebaseContext {
classes.put(fqn, (CompilationUnit) td.getRoot()); classes.put(fqn, (CompilationUnit) td.getRoot());
classPaths.put(fqn, javaFile); classPaths.put(fqn, javaFile);
typeDeclarations.put(fqn, td);
if (simpleNameToFqn.containsKey(simpleName)) { if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName); String existingFqn = simpleNameToFqn.get(simpleName);
@@ -342,13 +444,21 @@ public class CodebaseContext {
} }
public List<String> getImplementations(String interfaceName) { public List<String> getImplementations(String interfaceName) {
String cleanName = interfaceName; if (interfaceName == null) {
if (interfaceName != null && interfaceName.contains("<")) { return List.of();
cleanName = interfaceName.substring(0, interfaceName.indexOf('<'));
} }
Set<String> allImpls = new HashSet<>(); String cleanName = interfaceName.contains("<")
? interfaceName.substring(0, interfaceName.indexOf('<'))
: interfaceName;
return implementationsCache.computeIfAbsent(cleanName, this::computeImplementations);
}
private List<String> computeImplementations(String cleanName) {
Set<String> allImpls = new LinkedHashSet<>();
collectImplementations(cleanName, allImpls, new HashSet<>()); collectImplementations(cleanName, allImpls, new HashSet<>());
return new ArrayList<>(allImpls); List<String> sorted = new ArrayList<>(allImpls);
Collections.sort(sorted);
return Collections.unmodifiableList(sorted);
} }
private void collectImplementations(String typeName, Set<String> results, Set<String> visited) { private void collectImplementations(String typeName, Set<String> results, Set<String> visited) {
@@ -358,6 +468,12 @@ public class CodebaseContext {
} }
if (!visited.add(cleanName)) return; if (!visited.add(cleanName)) return;
// Fail closed on ambiguous simple type names: we must not pick an arbitrary FQN and widen
// to unrelated implementations across packages.
if (cleanName != null && !cleanName.contains(".") && ambiguousSimpleNames.contains(cleanName)) {
return;
}
// Try direct match // Try direct match
List<String> directImpls = interfaceToImpls.get(cleanName); List<String> directImpls = interfaceToImpls.get(cleanName);
@@ -370,9 +486,23 @@ public class CodebaseContext {
} }
// Try simple name match if input was FQN // Try simple name match if input was FQN
if (directImpls == null && typeName.contains(".")) { if (directImpls == null && cleanName.contains(".")) {
String simpleName = typeName.substring(typeName.lastIndexOf('.') + 1); String simpleName = cleanName.substring(cleanName.lastIndexOf('.') + 1);
directImpls = interfaceToImpls.get(simpleName); List<String> simpleImpls = interfaceToImpls.get(simpleName);
if (simpleImpls != null) {
if (ambiguousSimpleNames.contains(simpleName)) {
String pkg = cleanName.substring(0, cleanName.lastIndexOf('.'));
List<String> pkgScoped = new ArrayList<>();
for (String impl : simpleImpls) {
if (impl.startsWith(pkg + ".")) {
pkgScoped.add(impl);
}
}
directImpls = pkgScoped.isEmpty() ? null : pkgScoped;
} else {
directImpls = simpleImpls;
}
}
} }
if (directImpls != null) { if (directImpls != null) {
@@ -398,7 +528,12 @@ public class CodebaseContext {
} }
} }
enumValues.put(fqn, values); enumValues.put(fqn, values);
if (!simpleNameToFqn.containsKey(simpleName)) { if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName);
if (existingFqn != null && !existingFqn.equals(fqn)) {
ambiguousSimpleNames.add(simpleName);
}
} else {
simpleNameToFqn.put(simpleName, fqn); simpleNameToFqn.put(simpleName, fqn);
} }
@@ -418,6 +553,36 @@ public class CodebaseContext {
return enumValues; return enumValues;
} }
/**
* Returns true when multiple types share the same simple name across packages, so simple-name
* lookup must fail closed.
*/
public boolean isAmbiguousSimpleName(String name) {
if (name == null || name.isBlank() || name.contains(".")) {
return false;
}
return ambiguousSimpleNames.contains(name);
}
/**
* True when two type references denote the same type, or share an unambiguous simple name.
* Fails closed when the shared simple name exists in multiple packages.
*/
public boolean areSameTypeOrUnambiguousSimpleMatch(String leftFqn, String rightFqn) {
if (leftFqn == null || rightFqn == null) {
return false;
}
if (leftFqn.equals(rightFqn)) {
return true;
}
String leftSimple = simpleTypeName(leftFqn);
String rightSimple = simpleTypeName(rightFqn);
if (!leftSimple.equals(rightSimple)) {
return false;
}
return !isAmbiguousSimpleName(leftSimple);
}
public List<String> getEnumValues(String fqnOrSimpleName) { public List<String> getEnumValues(String fqnOrSimpleName) {
if (fqnOrSimpleName == null) return null; if (fqnOrSimpleName == null) return null;
@@ -432,6 +597,9 @@ public class CodebaseContext {
List<String> values = enumValues.get(cleanName); List<String> values = enumValues.get(cleanName);
if (values == null) { if (values == null) {
if (!cleanName.contains(".") && ambiguousSimpleNames.contains(cleanName)) {
return null;
}
String fqn = simpleNameToFqn.get(cleanName); String fqn = simpleNameToFqn.get(cleanName);
if (fqn != null) values = enumValues.get(fqn); if (fqn != null) values = enumValues.get(fqn);
} }
@@ -462,10 +630,20 @@ public class CodebaseContext {
} }
public TypeDeclaration getTypeDeclaration(String name) { public TypeDeclaration getTypeDeclaration(String name) {
TypeDeclaration cached = typeDeclarations.get(name);
if (cached != null) {
return cached;
}
// Try exact FQN match first // Try exact FQN match first
CompilationUnit cu = classes.get(name); CompilationUnit cu = classes.get(name);
if (cu != null) if (cu != null) {
return findTypeInCu(cu, name); TypeDeclaration resolved = findTypeInCu(cu, name);
if (resolved != null) {
typeDeclarations.put(name, resolved);
}
return resolved;
}
// If it's a simple name, check if it's ambiguous // If it's a simple name, check if it's ambiguous
if (ambiguousSimpleNames.contains(name)) { if (ambiguousSimpleNames.contains(name)) {
@@ -476,9 +654,18 @@ public class CodebaseContext {
// If not ambiguous, return the one we found during scan // If not ambiguous, return the one we found during scan
String fqn = simpleNameToFqn.get(name); String fqn = simpleNameToFqn.get(name);
if (fqn != null) { if (fqn != null) {
cached = typeDeclarations.get(fqn);
if (cached != null) {
return cached;
}
cu = classes.get(fqn); cu = classes.get(fqn);
if (cu != null) if (cu != null) {
return findTypeInCu(cu, fqn); TypeDeclaration resolved = findTypeInCu(cu, fqn);
if (resolved != null) {
typeDeclarations.put(fqn, resolved);
}
return resolved;
}
} }
return null; return null;
@@ -538,22 +725,51 @@ public class CodebaseContext {
} }
public String getSuperclassFqn(TypeDeclaration td) { public String getSuperclassFqn(TypeDeclaration td) {
if (td == null) return null; if (td == null) {
return null;
}
String typeFqn = getFqn(td);
if (typeFqn != null && superclassFqnCache.containsKey(typeFqn)) {
return superclassFqnCache.get(typeFqn);
}
String resolved = resolveSuperclassFqn(td);
if (typeFqn != null) {
superclassFqnCache.put(typeFqn, resolved);
}
return resolved;
}
private String resolveSuperclassFqn(TypeDeclaration td) {
String bindingSuper = null;
ITypeBinding binding = td.resolveBinding(); ITypeBinding binding = td.resolveBinding();
if (binding != null) { if (binding != null) {
ITypeBinding superBinding = binding.getSuperclass(); ITypeBinding superBinding = binding.getSuperclass();
if (superBinding != null) { if (superBinding != null) {
return superBinding.getErasure().getQualifiedName(); bindingSuper = superBinding.getErasure().getQualifiedName();
} }
} }
Type superType = td.getSuperclassType(); Type superType = td.getSuperclassType();
if (superType == null) return null; if (superType == null) {
return bindingSuper;
}
String superName = extractTypeName(superType); String superName = extractTypeName(superType);
CompilationUnit cu = (td.getRoot() instanceof CompilationUnit) ? (CompilationUnit) td.getRoot() : null; CompilationUnit cu = (td.getRoot() instanceof CompilationUnit) ? (CompilationUnit) td.getRoot() : null;
TypeDeclaration superTd = getTypeDeclaration(superName, cu); TypeDeclaration superTd = getTypeDeclaration(superName, cu);
return superTd != null ? getFqn(superTd) : superName; String astSuper = superTd != null ? getFqn(superTd) : null;
if (astSuper != null) {
if (bindingSuper == null || !astSuper.equals(bindingSuper)) {
if (bindingSuper == null
|| getTypeDeclaration(bindingSuper) == null
|| classes.containsKey(astSuper)) {
return astSuper;
}
}
return astSuper;
}
return bindingSuper != null ? bindingSuper : superName;
} }
private String extractTypeName(Type type) { private String extractTypeName(Type type) {
@@ -568,7 +784,23 @@ public class CodebaseContext {
} }
public MethodDeclaration findMethodDeclaration(TypeDeclaration td, String methodName, boolean includeSuper) { public MethodDeclaration findMethodDeclaration(TypeDeclaration td, String methodName, boolean includeSuper) {
if (td == null) return null; if (td == null) {
return null;
}
String typeFqn = getFqn(td);
if (typeFqn != null) {
String cacheKey = typeFqn + "#" + methodName + "#" + includeSuper;
if (methodDeclarationCache.containsKey(cacheKey)) {
return methodDeclarationCache.get(cacheKey);
}
MethodDeclaration resolved = findMethodDeclarationUncached(td, methodName, includeSuper);
methodDeclarationCache.put(cacheKey, resolved);
return resolved;
}
return findMethodDeclarationUncached(td, methodName, includeSuper);
}
private MethodDeclaration findMethodDeclarationUncached(TypeDeclaration td, String methodName, boolean includeSuper) {
ITypeBinding binding = td.resolveBinding(); ITypeBinding binding = td.resolveBinding();
if (binding != null && includeSuper) { if (binding != null && includeSuper) {
IMethodBinding methodBinding = findMethodBinding(binding, methodName); IMethodBinding methodBinding = findMethodBinding(binding, methodName);
@@ -647,6 +879,42 @@ public class CodebaseContext {
return null; return null;
} }
public boolean areClassesPolymorphicallyCompatible(String classNeighbor, String classTarget) {
if (classNeighbor == null || classTarget == null) {
return false;
}
if (classNeighbor.equals(classTarget)) {
return true;
}
if (areSameTypeOrUnambiguousSimpleMatch(classNeighbor, classTarget)) {
return true;
}
return classCompatibilityCache.areClassesCompatible(
classNeighbor,
classTarget,
this::computePolymorphicClassCompatibility);
}
private boolean computePolymorphicClassCompatibility(String classNeighbor, String classTarget) {
List<String> targetImplementations = getImplementations(classTarget);
for (String implementation : targetImplementations) {
if (implementation.equals(classNeighbor) || implementation.endsWith("." + classNeighbor)) {
return true;
}
}
List<String> neighborImplementations = getImplementations(classNeighbor);
for (String implementation : neighborImplementations) {
if (implementation.equals(classTarget) || implementation.endsWith("." + classTarget)) {
return true;
}
}
return false;
}
private static String simpleTypeName(String typeFqn) {
return typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn;
}
public org.eclipse.jdt.core.dom.RecordDeclaration getRecordDeclaration(String fqn) { public org.eclipse.jdt.core.dom.RecordDeclaration getRecordDeclaration(String fqn) {
if (fqn == null || fqn.isEmpty()) { if (fqn == null || fqn.isEmpty()) {
return null; return null;

View File

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

View File

@@ -23,7 +23,7 @@ public class StateResolver {
// 2. Check for @Value fields (SimpleName) // 2. Check for @Value fields (SimpleName)
if (expr instanceof SimpleName sn) { if (expr instanceof SimpleName sn) {
String fieldName = sn.getIdentifier(); String fieldName = sn.getIdentifier();
TypeDeclaration td = findEnclosingType(sn); TypeDeclaration td = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(sn);
if (td != null) { if (td != null) {
String value = findValueFromField(td, fieldName); String value = findValueFromField(td, fieldName);
if (value != null) { if (value != null) {
@@ -69,21 +69,11 @@ public class StateResolver {
} }
private String extractAnnotationValue(Annotation ann) { private String extractAnnotationValue(Annotation ann) {
String raw; String raw = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(ann, "value");
if (ann instanceof SingleMemberAnnotation sma) { if (raw.isEmpty()) {
raw = stripQuotes(sma.getValue().toString());
} else if (ann instanceof NormalAnnotation na) {
raw = null;
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if ("value".equals(pair.getName().getIdentifier())) {
raw = stripQuotes(pair.getValue().toString());
break;
}
}
} else {
return null; return null;
} }
raw = stripQuotes(raw);
if (raw != null && raw.contains("${")) { if (raw != null && raw.contains("${")) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve( return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
raw, java.util.Collections.emptyMap()); raw, java.util.Collections.emptyMap());
@@ -96,14 +86,6 @@ public class StateResolver {
return s.replaceAll("^\"|\"$", ""); return s.replaceAll("^\"|\"$", "");
} }
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private String resolveQualifiedName(QualifiedName qn, CompilationUnit cu) { private String resolveQualifiedName(QualifiedName qn, CompilationUnit cu) {
String qualifier = qn.getQualifier().toString(); String qualifier = qn.getQualifier().toString();
String name = qn.getName().getIdentifier(); String name = qn.getName().getIdentifier();

View File

@@ -63,10 +63,10 @@ public class Dot implements StateMachineExporter {
// Start/End nodes // Start/End nodes
sb.append(" _start [shape=circle, label=\"\", fillcolor=black, width=0.1];\n"); sb.append(" _start [shape=circle, label=\"\", fillcolor=black, width=0.1];\n");
for (String start : startStates) { for (String start : startStates) {
sb.append(" _start -> ").append(simplify(start)).append(";\n"); sb.append(" _start -> ").append(simplify(options.formatStateLabel(start))).append(";\n");
} }
for (String end : endStates) { for (String end : endStates) {
sb.append(" ").append(simplify(end)).append(" [fillcolor=lightgray];\n"); sb.append(" ").append(simplify(options.formatStateLabel(end))).append(" [fillcolor=lightgray];\n");
} }
// Transitions // Transitions

View File

@@ -30,6 +30,12 @@ public class ExportOptions {
return format(event.rawName(), event.fullIdentifier(), eventFormat); return format(event.rawName(), event.fullIdentifier(), eventFormat);
} }
/** Formats a bare state label (e.g. start/end state string) using the same rules as {@link #formatState}. */
public String formatStateLabel(String label) {
if (label == null) return null;
return format(label, label, stateFormat);
}
private String format(String raw, String fqn, EnumFormat format) { private String format(String raw, String fqn, EnumFormat format) {
if (fqn == null) return raw; if (fqn == null) return raw;
switch (format) { switch (format) {

View File

@@ -55,7 +55,7 @@ public class PlantUml implements StateMachineExporter {
sb.append("\n"); sb.append("\n");
for (String start : startStates) { for (String start : startStates) {
sb.append("[*] --> ").append(simplify(start)).append("\n"); sb.append("[*] --> ").append(simplify(options.formatStateLabel(start))).append("\n");
} }
sb.append("\n"); sb.append("\n");
@@ -143,7 +143,7 @@ public class PlantUml implements StateMachineExporter {
sb.append("\n"); sb.append("\n");
for (String end : endStates) { for (String end : endStates) {
sb.append(simplify(end)).append(" --> [*]\n"); sb.append(simplify(options.formatStateLabel(end))).append(" --> [*]\n");
} }
sb.append("@enduml\n"); sb.append("@enduml\n");

View File

@@ -26,7 +26,7 @@ public class Scxml implements StateMachineExporter {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
sb.append("<scxml xmlns=\"http://www.w3.org/2005/07/scxml\" version=\"1.0\" initial=\""); sb.append("<scxml xmlns=\"http://www.w3.org/2005/07/scxml\" version=\"1.0\" initial=\"");
sb.append(startStates.isEmpty() ? "" : simplify(startStates.iterator().next())); sb.append(startStates.isEmpty() ? "" : simplify(options.formatStateLabel(startStates.iterator().next())));
sb.append("\">\n"); sb.append("\">\n");
Set<String> allStates = new java.util.LinkedHashSet<>(); Set<String> allStates = new java.util.LinkedHashSet<>();
@@ -34,8 +34,8 @@ public class Scxml implements StateMachineExporter {
t.getSourceStates().forEach(s -> allStates.add(options.formatState(s))); t.getSourceStates().forEach(s -> allStates.add(options.formatState(s)));
t.getTargetStates().forEach(s -> allStates.add(options.formatState(s))); t.getTargetStates().forEach(s -> allStates.add(options.formatState(s)));
} }
allStates.addAll(startStates); startStates.forEach(s -> allStates.add(options.formatStateLabel(s)));
allStates.addAll(endStates); endStates.forEach(s -> allStates.add(options.formatStateLabel(s)));
for (String state : allStates) { for (String state : allStates) {
sb.append(" <state id=\"").append(simplify(state)).append("\">\n"); sb.append(" <state id=\"").append(simplify(state)).append("\">\n");

View File

@@ -0,0 +1,60 @@
package click.kamil.springstatemachineexporter.exporter;
/**
* Builds SVG/HTML transition link keys ({@code Source__Event}) using the same formatting rules as
* {@link PlantUml} embedded identifiers and the HTML explorer {@code buildLinkKey} helper.
*/
public final class TransitionLinkKey {
private TransitionLinkKey() {
}
public static String build(String sourceStateIdentifier, String eventIdentifier) {
return build(sourceStateIdentifier, eventIdentifier, EnumFormat.fn, EnumFormat.fn);
}
public static String build(
String sourceStateIdentifier,
String eventIdentifier,
EnumFormat stateFormat,
EnumFormat eventFormat) {
if (eventIdentifier == null || eventIdentifier.isBlank()) {
return "";
}
String formattedEvent = formatIdentifier(eventIdentifier, eventFormat);
if (sourceStateIdentifier == null || sourceStateIdentifier.isBlank()) {
return "__" + normalize(formattedEvent);
}
return normalize(formatIdentifier(sourceStateIdentifier, stateFormat))
+ "__"
+ normalize(formattedEvent);
}
static String formatIdentifier(String identifier, EnumFormat format) {
if (identifier == null || identifier.isBlank()) {
return "";
}
return switch (format) {
case fqn -> identifier;
case sn -> {
int lastDot = identifier.lastIndexOf('.');
yield lastDot >= 0 ? identifier.substring(lastDot + 1) : identifier;
}
case fn -> {
int lastDot = identifier.lastIndexOf('.');
if (lastDot <= 0) {
yield identifier;
}
int prevDot = identifier.lastIndexOf('.', lastDot - 1);
yield prevDot > 0 ? identifier.substring(prevDot + 1) : identifier;
}
};
}
static String normalize(String value) {
if (value == null || value.isBlank()) {
return "";
}
return value.replaceAll("[^a-zA-Z0-9]", "_");
}
}

View File

@@ -125,7 +125,7 @@ public class ExportService {
} }
if (Files.exists(hintsFile)) { if (Files.exists(hintsFile)) {
log.info("Loading hints from {}", hintsFile.toAbsolutePath()); log.debug("Loading optional hints.json override from {}", hintsFile.toAbsolutePath());
context.loadLibraryHints(hintsFile); context.loadLibraryHints(hintsFile);
} }
@@ -167,8 +167,6 @@ public class ExportService {
JsonImportService jsonImportService = new JsonImportService(); JsonImportService jsonImportService = new JsonImportService();
AnalysisResult result = jsonImportService.importAnalysisResult(jsonFile); AnalysisResult result = jsonImportService.importAnalysisResult(jsonFile);
resolveProperties(result, activeProfiles);
CodebaseContext context = null; CodebaseContext context = null;
Path sourceRoot = optionalSourceDir; Path sourceRoot = optionalSourceDir;
if (sourceRoot == null) { if (sourceRoot == null) {
@@ -177,13 +175,17 @@ public class ExportService {
} }
if (sourceRoot != null) { if (sourceRoot != null) {
log.info("JSON re-export: scanning source project at {}", sourceRoot); log.info("JSON re-export: scanning source project at {}", sourceRoot);
try {
context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
.scanProjectRoot(sourceRoot, activeProfiles); .scanProjectRoot(sourceRoot, activeProfiles);
} catch (RuntimeException ex) {
log.warn("JSON re-export: source scan failed; using embedded machine types if present", ex);
}
} else { } else {
log.info("JSON re-export: no source project found; using embedded machine types if present"); log.info("JSON re-export: no source project found; using embedded machine types if present");
} }
enrichmentService.relinkAfterPropertyResolution(result, context, null); resolveProperties(result, activeProfiles, context);
StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.MachineTypes machineTypes =
click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory.resolveMachineTypes( click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory.resolveMachineTypes(
@@ -191,12 +193,29 @@ public class ExportService {
result.getStateTypeFqn(), result.getStateTypeFqn(),
result.getEventTypeFqn(), result.getEventTypeFqn(),
context); context);
// Canonicalize transitions/states for relink matching; trigger/callChain canonicalization
// here is superseded by refreshCallChainsAndRelink / relinkAfterPropertyResolution
// (TriggerCanonicalizationEnricher runs again before TransitionLinkerEnricher).
AnalysisResultFinalizer.applyCanonicalization(result, context, machineTypes);
if (context != null && sourceRoot != null
&& click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
.contextContainsAnalysisSources(result, context)
&& result.getMetadata() != null
&& ((result.getMetadata().getEntryPoints() != null
&& !result.getMetadata().getEntryPoints().isEmpty())
|| (result.getMetadata().getCallChains() != null
&& !result.getMetadata().getCallChains().isEmpty()))) {
JdtIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, sourceRoot);
enrichmentService.refreshCallChainsAndRelink(result, context, intelligence);
} else {
enrichmentService.relinkAfterPropertyResolution(result, context, null);
}
AnalysisResultFinalizer.finalizeResult(result, context, machineTypes); AnalysisResultFinalizer.finalizeResult(result, context, machineTypes);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat); generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
} }
private void resolveProperties(AnalysisResult result, List<String> activeProfiles) { private void resolveProperties(AnalysisResult result, List<String> activeProfiles, CodebaseContext context) {
Map<String, Map<String, String>> allProps = result.getMetadata().getProperties(); Map<String, Map<String, String>> allProps = result.getMetadata().getProperties();
if (allProps == null || allProps.isEmpty()) return; if (allProps == null || allProps.isEmpty()) return;
@@ -214,7 +233,7 @@ public class ExportService {
} }
// 2. Delegate to result for resolution // 2. Delegate to result for resolution
result.applyResolution(merged); result.applyResolution(merged, context);
} }
private void exportAll(CodebaseContext context, CodebaseIntelligenceProvider intelligence, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows, String machineFilter, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException { private void exportAll(CodebaseContext context, CodebaseIntelligenceProvider intelligence, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<BusinessFlow> flows, String machineFilter, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
@@ -253,13 +272,13 @@ public class ExportService {
List<Transition> transitions = aggregator.aggregateTransitions(td); List<Transition> transitions = aggregator.aggregateTransitions(td);
StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.resolveTypes(className, context); StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.resolveTypes(className, context);
MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes); MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes, context);
aggregator.aggregateStates(td); aggregator.aggregateStates(td);
Set<String> initialStatesAst = MachineEnumCanonicalizer.canonicalizeStateLabels( Set<String> initialStatesAst = MachineEnumCanonicalizer.canonicalizeStateLabels(
aggregator.getInitialStates(), machineTypes.stateTypeFqn()); aggregator.getInitialStates(), machineTypes.stateTypeFqn(), context);
Set<String> endStatesAst = MachineEnumCanonicalizer.canonicalizeStateLabels( Set<String> endStatesAst = MachineEnumCanonicalizer.canonicalizeStateLabels(
aggregator.getEndStates(), machineTypes.stateTypeFqn()); aggregator.getEndStates(), machineTypes.stateTypeFqn(), context);
log.debug("Start States Ast: {}", initialStatesAst); log.debug("Start States Ast: {}", initialStatesAst);
log.debug("End States Ast: {}", endStatesAst); log.debug("End States Ast: {}", endStatesAst);
@@ -268,7 +287,7 @@ public class ExportService {
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst); Set<String> endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst);
Set<click.kamil.springstatemachineexporter.model.State> allStates = MachineEnumCanonicalizer.canonicalizeStates( Set<click.kamil.springstatemachineexporter.model.State> allStates = MachineEnumCanonicalizer.canonicalizeStates(
TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst), TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst),
machineTypes.stateTypeFqn()); machineTypes.stateTypeFqn(), context);
if (allStates.isEmpty() && transitions.isEmpty()) { if (allStates.isEmpty() && transitions.isEmpty()) {
log.info("Skipping empty state machine config: {}", className); log.info("Skipping empty state machine config: {}", className);
@@ -286,7 +305,7 @@ public class ExportService {
.build(); .build();
enrichmentService.enrichPreProperty(result, context, intelligence); enrichmentService.enrichPreProperty(result, context, intelligence);
resolveProperties(result, activeProfiles); resolveProperties(result, activeProfiles, context);
enrichmentService.enrichPostProperty(result, context, intelligence); enrichmentService.enrichPostProperty(result, context, intelligence);
AnalysisResultFinalizer.finalizeResult(result, context); AnalysisResultFinalizer.finalizeResult(result, context);
@@ -304,13 +323,13 @@ public class ExportService {
List<Transition> transitions = AstTransitionParser.parseTransitions(m, context); List<Transition> transitions = AstTransitionParser.parseTransitions(m, context);
StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.resolveTypes(parentFqn, context); StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.resolveTypes(parentFqn, context);
MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes); MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes, context);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions, null); Set<String> startStates = TransitionStateUtils.findStartStates(transitions, null);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, null); Set<String> endStates = TransitionStateUtils.findEndStates(transitions, null);
Set<click.kamil.springstatemachineexporter.model.State> allStates = MachineEnumCanonicalizer.canonicalizeStates( Set<click.kamil.springstatemachineexporter.model.State> allStates = MachineEnumCanonicalizer.canonicalizeStates(
TransitionStateUtils.findAllStates(transitions, null, null), TransitionStateUtils.findAllStates(transitions, null, null),
machineTypes.stateTypeFqn()); machineTypes.stateTypeFqn(), context);
if (allStates.isEmpty() && transitions.isEmpty()) { if (allStates.isEmpty() && transitions.isEmpty()) {
log.info("Skipping empty state machine bean: {}", uniqueName); log.info("Skipping empty state machine bean: {}", uniqueName);
@@ -328,7 +347,7 @@ public class ExportService {
.build(); .build();
enrichmentService.enrichPreProperty(result, context, intelligence); enrichmentService.enrichPreProperty(result, context, intelligence);
resolveProperties(result, activeProfiles); resolveProperties(result, activeProfiles, context);
enrichmentService.enrichPostProperty(result, context, intelligence); enrichmentService.enrichPostProperty(result, context, intelligence);
AnalysisResultFinalizer.finalizeResult(result, context); AnalysisResultFinalizer.finalizeResult(result, context);

View File

@@ -172,6 +172,12 @@ public class GoldenUpdater {
Path.of("src/test/resources/golden/ExtendedStateMachineConfig"), Path.of("src/test/resources/golden/ExtendedStateMachineConfig"),
"ExtendedStateMachineConfig" "ExtendedStateMachineConfig"
), ),
new TestScenario(
"Payment State Machine (Extended Sample)",
Path.of("state_machines/extended_analysis_sample"),
Path.of("src/test/resources/golden/PaymentStateMachineConfig"),
"PaymentStateMachineConfig"
),
new TestScenario( new TestScenario(
"Extended Analysis Sample (PROD)", "Extended Analysis Sample (PROD)",
Path.of("state_machines/extended_analysis_sample"), Path.of("state_machines/extended_analysis_sample"),

View File

@@ -113,6 +113,12 @@ public class PlantUmlE2ETest {
Path.of("src/test/resources/golden/ExtendedStateMachineConfig"), Path.of("src/test/resources/golden/ExtendedStateMachineConfig"),
"ExtendedStateMachineConfig" "ExtendedStateMachineConfig"
), ),
new TestScenario(
"Payment State Machine (Extended Sample)",
root.resolve("state_machines/extended_analysis_sample"),
Path.of("src/test/resources/golden/PaymentStateMachineConfig"),
"PaymentStateMachineConfig"
),
new TestScenario( new TestScenario(
"Extended Analysis Sample (PROD)", "Extended Analysis Sample (PROD)",
root.resolve("state_machines/extended_analysis_sample"), root.resolve("state_machines/extended_analysis_sample"),

View File

@@ -113,6 +113,12 @@ public class RegressionTest {
Path.of("src/test/resources/golden/ExtendedStateMachineConfig"), Path.of("src/test/resources/golden/ExtendedStateMachineConfig"),
"ExtendedStateMachineConfig" "ExtendedStateMachineConfig"
), ),
new TestScenario(
"Payment State Machine (Extended Sample)",
root.resolve("state_machines/extended_analysis_sample"),
Path.of("src/test/resources/golden/PaymentStateMachineConfig"),
"PaymentStateMachineConfig"
),
new TestScenario( new TestScenario(
"Extended Analysis Sample (PROD)", "Extended Analysis Sample (PROD)",
root.resolve("state_machines/extended_analysis_sample"), root.resolve("state_machines/extended_analysis_sample"),

View File

@@ -0,0 +1,133 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class CallChainLinkPolicyTest {
@Test
void shouldFailClosedOnEnumSetAmbiguousWiden() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("ENUM_SET:com.example.OrderEvent.PAY,com.example.OrderEvent.SHIP")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP"))
.build();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isTrue();
assertThat(CallChainLinkPolicy.resolveLinkResolution(trigger, List.of(), false))
.isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldMarkExternalTriggersUnresolved() {
TriggerPoint trigger = TriggerPoint.builder()
.external(true)
.event("eventString")
.build();
assertThat(CallChainLinkPolicy.resolveLinkResolution(trigger, List.of(), false))
.isEqualTo(LinkResolution.UNRESOLVED_EXTERNAL);
}
@Test
void shouldFailClosedOnValueOfAmbiguousWiden() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("a.OrderEvent.PAY", "a.OrderEvent.SHIP"))
.build();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isTrue();
assertThat(CallChainLinkPolicy.resolveLinkResolution(trigger, List.of(), false))
.isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldAllowEndpointNarrowSingleConcretePoly() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.eventTypeFqn("com.example.OrderEvent")
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.build();
assertThat(CallChainLinkPolicy.isEndpointNarrowPolyEvidence(trigger)).isTrue();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isTrue();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isFalse();
}
@Test
void shouldFailClosedWhenMultipleConcretePolyCandidatesRemain() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP"))
.build();
assertThat(CallChainLinkPolicy.isEndpointNarrowPolyEvidence(
trigger, "com.example.OrderEvent")).isFalse();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
trigger, "com.example.OrderEvent")).isFalse();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.OrderEvent")).isTrue();
}
@Test
void shouldResolveLinkWhenEndpointNarrowPolyHasMatches() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.build();
var matched = List.of(
click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder()
.event("com.example.order.OrderEvent.PAY")
.build());
assertThat(CallChainLinkPolicy.resolveLinkResolution(
trigger,
matched,
false,
"com.example.order.OrderEvent")).isEqualTo(LinkResolution.RESOLVED);
}
@Test
void shouldNotResolveWhenBroadPolyWidenHasMatches() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
.build();
var matched = List.of(
click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder()
.event("com.example.order.OrderEvent.PAY")
.build());
assertThat(CallChainLinkPolicy.resolveLinkResolution(
trigger,
matched,
false,
"com.example.order.OrderEvent")).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldTrustImportStyleSingleConcretePolyWhenMachineEventTypeIsKnown() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.build();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
trigger, "com.example.order.OrderEvent")).isTrue();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.order.OrderEvent")).isFalse();
}
}

View File

@@ -0,0 +1,372 @@
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.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class CallChainPolyNarrowerTest {
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
@Test
void shouldNarrowWidePolyUsingExpandedRestPathSegment() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/machine/ORDER/transition/PAY")
.className("com.example.MachineController")
.methodName("transition")
.build())
.methodChain(List.of(
"com.example.MachineController.transition",
"com.example.StateMachineDispatcher.fireOrder",
"com.example.StateMachine.send"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
.containsExactly("com.example.OrderEvent.PAY");
}
@Test
void shouldNarrowWidePolyUsingRichDispatcherMethodName() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/orders/rich/pay")
.className("com.example.RichOrderController")
.methodName("payViaRichEvent")
.build())
.methodChain(List.of(
"com.example.RichOrderController.payViaRichEvent",
"com.example.CommandGateway.payOrderViaRichEvent",
"com.example.CentralEventDispatcher.orderPayViaRichEvent",
"com.example.CentralEventDispatcher.sendOrderEvent"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("richEvent.getType()")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void shouldNarrowUsingDottedCommandKeyPath() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/commands/order.pay")
.className("com.example.GenericCommandController")
.methodName("execute")
.build())
.methodChain(List.of(
"com.example.GenericCommandController.execute",
"com.example.CommandGateway.executeViaMapper",
"com.example.CentralEventDispatcher.orderPay",
"com.example.CentralEventDispatcher.sendOrderEvent"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(actionKey)")
.constraint("\"order.pay\".equalsIgnoreCase(commandKey) && command == ORDER_PAY")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void shouldNarrowWidePolyUsingSendEventLiteralInCallerMethod(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class OrderController {
CentralEventDispatcher dispatcher;
public void pay() { dispatcher.orderPay(); }
}
class CentralEventDispatcher {
void orderPay() { sendOrderEvent(OrderEvent.PAY); }
void orderShip() { sendOrderEvent(OrderEvent.SHIP); }
private void sendOrderEvent(OrderEvent event) {
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/orders/pay")
.className("com.example.OrderController")
.methodName("pay")
.build())
.methodChain(List.of(
"com.example.OrderController.pay",
"com.example.CentralEventDispatcher.orderPay",
"com.example.CentralEventDispatcher.sendOrderEvent",
"com.example.StateMachine.sendEvent"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("event")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, context, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void shouldNarrowWidePolyUsingRichEventParameterType(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class RichOrderController {
CentralEventDispatcher dispatcher;
public void payViaRichEvent() {
dispatcher.orderPayViaRichEvent(new PayRichOrderEvent());
}
}
class PayRichOrderEvent {
OrderEvent getType() { return OrderEvent.PAY; }
}
class CentralEventDispatcher {
void orderPayViaRichEvent(PayRichOrderEvent event) {
sendOrderEvent(event.getType());
}
private void sendOrderEvent(OrderEvent event) {
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/orders/rich/pay")
.className("com.example.RichOrderController")
.methodName("payViaRichEvent")
.build())
.methodChain(List.of(
"com.example.RichOrderController.payViaRichEvent",
"com.example.CentralEventDispatcher.orderPayViaRichEvent",
"com.example.CentralEventDispatcher.sendOrderEvent",
"com.example.StateMachine.sendEvent"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("event.getType()")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, context, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void shouldNarrowWidePolyWhenConfigEventTypeFqnIsMissing() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/machine/ORDER/transition/PAY")
.className("com.example.MachineController")
.methodName("transition")
.build())
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void shouldFailClosedWhenWidePolyHasNoEndpointSpecificHint() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/machine/{machineType}/transition/{event}")
.className("com.example.MachineController")
.methodName("transition")
.build())
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.external(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
assertThat(linked.getLinkResolution()).isIn(
LinkResolution.AMBIGUOUS_WIDEN,
LinkResolution.UNRESOLVED_EXTERNAL);
}
private static Transition transition(String source, String target, String eventFqn) {
Transition transition = new Transition();
transition.setSourceStates(List.of(State.of(source, "com.example.OrderState." + source)));
transition.setTargetStates(List.of(State.of(target, "com.example.OrderState." + target)));
transition.setEvent(Event.of(eventFqn.substring(eventFqn.lastIndexOf('.') + 1), eventFqn));
return transition;
}
}

View File

@@ -0,0 +1,89 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Regression: dispatcher REST chains with {@code external=false}, dynamic trigger event, and empty
* {@code polymorphicEvents} must still link transitions after package-canonical FQN normalization.
*/
class DispatcherExternalFalseMatchedTransitionsRegressionTest {
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
@Test
void shouldLinkWhenExternalFalseDynamicTriggerAndPolymorphicEventsWereLost() {
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("OrderState.NEW", "com.example.order.OrderState.NEW")));
pay.setTargetStates(List.of(State.of("OrderState.PAID", "com.example.order.OrderState.PAID")));
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("event")
.external(false)
.polymorphicEvents(null)
.constraint("\"ORDER\".equalsIgnoreCase(machineType)")
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(pay))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions())
.hasSize(1)
.first()
.extracting("event")
.isEqualTo("com.example.order.OrderEvent.PAY");
}
@Test
void shouldLinkWhenPolymorphicEventsUseWrongEnumPrefixAfterFqnNormalization() {
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("OrderState.NEW", "com.example.order.OrderState.NEW")));
pay.setTargetStates(List.of(State.of("OrderState.PAID", "com.example.order.OrderState.PAID")));
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("e")
.external(false)
.polymorphicEvents(List.of("OrderEvents.PAY"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(pay))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TriggerCanonicalizationEnricher().enrich(result, null, null);
enricher.enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions())
.hasSize(1)
.first()
.extracting("event")
.isEqualTo("com.example.order.OrderEvent.PAY");
}
}

View File

@@ -0,0 +1,139 @@
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 click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class EnricherPipelineRegressionTest {
@Test
void shouldCanonicalizeChainTriggerEventsBeforeLinker(@TempDir Path tempDir) throws IOException {
writeSampleMachine(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TriggerPoint rawTrigger = TriggerPoint.builder()
.className("com.example.web.OrderController")
.methodName("pay")
.event("OrderEvent.PAY")
.sourceState("OrderState.NEW")
.eventTypeFqn("OrderEvent")
.stateTypeFqn("OrderState")
.build();
CallChain rawChain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/orders/pay")
.className("com.example.web.OrderController")
.methodName("pay")
.build())
.triggerPoint(rawTrigger)
.build();
CodebaseIntelligenceProvider intelligence = new CodebaseIntelligenceProvider() {
@Override
public List<TriggerPoint> findTriggerPoints() {
return List.of(rawTrigger);
}
@Override
public List<EntryPoint> findEntryPoints() {
return List.of();
}
@Override
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
return List.of(rawChain);
}
@Override
public Map<String, Map<String, String>> resolveProperties() {
return Map.of();
}
};
Transition transition = new Transition();
transition.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
transition.setSourceStates(List.of(State.of("OrderState.NEW", "com.example.order.OrderState.NEW")));
transition.setTargetStates(List.of(State.of("OrderState.PAID", "com.example.order.OrderState.PAID")));
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.transitions(List.of(transition))
.metadata(CodebaseMetadata.builder()
.entryPoints(List.of(rawChain.getEntryPoint()))
.triggers(List.of(rawTrigger))
.callChains(List.of(rawChain))
.build())
.build();
new TriggerCanonicalizationEnricher().enrich(result, context, intelligence);
new TransitionLinkerEnricher().enrich(result, context, intelligence);
TriggerPoint chainTrigger = result.getMetadata().getCallChains().get(0).getTriggerPoint();
assertThat(chainTrigger.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(chainTrigger.getSourceState()).isEqualTo("com.example.order.OrderState.NEW");
assertThat(result.getMetadata().getTriggers().get(0).getEvent())
.isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.order.OrderEvent.PAY");
}
private static void writeSampleMachine(Path tempDir) throws IOException {
Path orderPkg = tempDir.resolve("com/example/order");
Path configPkg = tempDir.resolve("com/example/config");
Path webPkg = tempDir.resolve("com/example/web");
Files.createDirectories(orderPkg);
Files.createDirectories(configPkg);
Files.createDirectories(webPkg);
Files.writeString(orderPkg.resolve("OrderState.java"),
"""
package com.example.order;
public enum OrderState { NEW, PAID }
""");
Files.writeString(orderPkg.resolve("OrderEvent.java"),
"""
package com.example.order;
public enum OrderEvent { PAY }
""");
Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"),
"""
package com.example.config;
import com.example.order.OrderEvent;
import com.example.order.OrderState;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
public class OrderStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
}
""");
Files.writeString(webPkg.resolve("OrderController.java"),
"""
package com.example.web;
public class OrderController {
public void pay() {}
}
""");
}
}

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