102 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
234 changed files with 30246 additions and 4751 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

@@ -1,18 +1,18 @@
package click.kamil.springstatemachineexporter.analysis.enricher; package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.enricher.MachineScopeFilter;
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 {
@@ -20,32 +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<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
canonicalTriggers, result.getName(), context);
List<CallChain> chains = intelligence.findCallChains(
result.getMetadata().getEntryPoints(),
scopedTriggers
);
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,88 +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 && isKnownDomainScopedMachine(machineDomain) return affinity != EntryPointScopeResolver.Affinity.AGAINST_MACHINE;
&& domainsMatch(pathDomain, machineDomain)) {
return true;
}
if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain)
&& !domainsMatch(pathDomain, machineDomain)) {
return false;
}
if (pathDomain == null && path.contains("{")) {
return true;
}
} }
CallChain probe = CallChain.builder()
.entryPoint(entryPoint) EntryPointScopeResolver.Affinity affinity =
.methodChain(List.of(entryPoint.getClassName() + "." + entryPoint.getMethodName())) EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
.build(); if (affinity == EntryPointScopeResolver.Affinity.FOR_MACHINE) {
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context); return true;
}
if (affinity == EntryPointScopeResolver.Affinity.AGAINST_MACHINE) {
return false;
}
return false;
} }
private static boolean isKnownDomainScopedMachine(String machineDomain) { private static boolean isTriggerForMachine(
String normalized = normalizeDomainKey(machineDomain); TriggerPoint trigger, String machineName, CodebaseContext context, List<Transition> machineTransitions) {
return "ORDER".equals(normalized) || "DOCUMENT".equals(normalized) || "USER".equals(normalized);
}
private static String inferDomainFromPath(String path) {
String lower = path.toLowerCase(Locale.ROOT);
if (lower.contains("/orders") || lower.contains("order.")) {
return "ORDER";
}
if (lower.contains("/documents") || lower.contains("document.")) {
return "DOCUMENT";
}
return null;
}
private static boolean domainsMatch(String pathDomain, String machineDomain) {
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,8 +171,10 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.targetState(smSourceForLink) .targetState(smSourceForLink)
.event(smEventForLink) .event(smEventForLink)
.build(); .build();
if (isRoutedToCorrectMachine(chain, result.getName(), context) if (isRoutedToCorrectMachine(
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { chain, result.getName(), context, stateMachineTransitions, tp,
effectiveEventTypeFqn)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
} else { } else {
@@ -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;
}
String stripped = constraint.replaceAll("\"[^\"]+\"\\.equalsIgnoreCase\\(event\\)", "true");
stripped = stripped.replaceAll("&&\\s*&&+", "&&");
stripped = stripped.replaceAll("^\\s*&&\\s*", "");
stripped = stripped.replaceAll("\\s*&&\\s*$", "");
return stripped.trim();
}
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) { private String extractStateEnumType(String stateIdentifier) {
int idx = upper.indexOf(marker); if (stateIdentifier == null || stateIdentifier.isBlank()) {
if (idx > 0) { return stateIdentifier;
return upper.substring(0, idx); }
int lastDot = stateIdentifier.lastIndexOf('.');
if (lastDot <= 0) {
return stateIdentifier;
}
return stateIdentifier.substring(0, lastDot);
}
private final java.util.Map<String, String> simplifySourceCache = new java.util.HashMap<>();
private String simplifySourceState(String name) {
if (name == null) return null;
if (simplifySourceCache.containsKey(name)) return simplifySourceCache.get(name);
// For source states we must preserve enum type context to avoid collisions between
// different state enums that share a constant name (e.g. OrderState.NEW vs InvoiceState.NEW).
// Use EnumFormat.fn-like suffix (Type.CONST) when possible.
String simplified = name;
int lastDot = name.lastIndexOf('.');
if (lastDot > 0) {
int prevDot = name.lastIndexOf('.', lastDot - 1);
if (prevDot > 0) {
simplified = name.substring(prevDot + 1);
} }
} }
int stateIdx = upper.indexOf("STATE"); simplifySourceCache.put(name, simplified);
if (stateIdx > 0) {
return upper.substring(0, stateIdx);
}
return upper;
}
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>();
private final java.util.Map<java.util.Map.Entry<String, String>, Boolean> guardedCache = new java.util.HashMap<>();
private static final java.util.regex.Pattern SUFFIX_PATTERN = java.util.regex.Pattern.compile("(?i)(.+)(Event|Action|Transition|Command)(s)?$");
private static final java.util.regex.Pattern FQN_PATTERN = java.util.regex.Pattern.compile("^.*\\.([A-Z0-9_]+)$");
private String simplify(String name) {
if (name == null) return null;
if (simplifyCache.containsKey(name)) return simplifyCache.get(name);
// Strip common suffixes
String simplified = SUFFIX_PATTERN.matcher(name).replaceAll("$1");
if (simplified.isEmpty()) {
simplified = name;
}
// Simplify full identifiers to just the last part (enum name)
simplified = FQN_PATTERN.matcher(simplified).replaceAll("$1");
simplifyCache.put(name, simplified);
return simplified; 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,128 +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)) {
if (isTypeMismatched(triggerEventFqn, machineEventFqn)) { mismatched = true;
return false; } else if (isSingleStateMachine(context)
}
if (context != null && isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) { && (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) {
return true; matched = true;
} }
} }
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)
}
if (context != null && isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) { && (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) {
matched = true;
}
}
if (matched) {
if (!isAmbiguousSharedGenericMatch(
triggerEventFqn, triggerStateFqn, machineStateFqn, machineEventFqn)
|| isSingleStateMachine(context)) {
return true; return true;
} }
} }
if (mismatched) {
return false;
}
// Fail closed only when both trigger and machine generic types were resolved but
// could not be reconciled. When machine config AST does not expose type args (stub
// config classes in tests, generated configs), fall through to single-SM / package routing.
boolean machineTypesKnown = machineEventFqn != null || machineStateFqn != null;
if (machineTypesKnown) {
return false;
}
} }
} }
String targetVar = chain.getContextMachineId(); if (!isSingleStateMachine(context) && isAmbiguousSharedGenericTrigger(chain.getTriggerPoint())) {
if (targetVar == null && chain.getTriggerPoint() != null) { Boolean injectionMatch = SpringInjectionRouting.matchesMachineConfig(
targetVar = chain.getTriggerPoint().getStateMachineId(); chain.getTriggerPoint(), currentMachineName, context);
} if (injectionMatch != null) {
return injectionMatch;
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 false;
} }
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) { Boolean packageMatch = PackageNameRoutingHeuristics.matches(chain, currentMachineName, explicitTarget);
String smPackage = getPackageName(currentMachineName); if (packageMatch != null) {
String smSimple = getSimpleClassName(currentMachineName); return packageMatch;
String smPrefix = getFirstCamelCaseWord(smSimple);
boolean hasPositiveMatch = false;
boolean hasStrongMismatch = false;
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
String chainClass = chain.getTriggerPoint().getClassName();
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
}
for (String method : chain.getMethodChain()) {
String chainClass = getClassNameOnly(method);
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix, smSimple, chainSimple)) {
hasStrongMismatch = true;
}
}
if (hasPositiveMatch) {
return true;
}
if (hasStrongMismatch) {
return false;
}
} }
return context == null || isSingleStateMachine(context); 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;
@@ -153,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('<');
@@ -316,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);

View File

@@ -0,0 +1,44 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
/**
* Source-derived evidence used to decide whether a call chain belongs to a state-machine export.
*/
public record MachineRoutingEvidence(
String explicitBeanName,
String stateMachineId,
String eventTypeFqn,
String stateTypeFqn,
String triggerClassFqn,
String triggerMethod,
Integer triggerLine) {
public static MachineRoutingEvidence from(CallChain chain) {
if (chain == null) {
return new MachineRoutingEvidence(null, null, null, null, null, null, null);
}
TriggerPoint trigger = chain.getTriggerPoint();
String explicit = chain.getContextMachineId();
String stateMachineId = null;
Integer line = null;
if (trigger != null) {
if (explicit == null) {
explicit = trigger.getStateMachineId();
}
stateMachineId = trigger.getStateMachineId();
if (trigger.getLineNumber() > 0) {
line = trigger.getLineNumber();
}
}
return new MachineRoutingEvidence(
explicit,
stateMachineId,
trigger != null ? trigger.getEventTypeFqn() : null,
trigger != null ? trigger.getStateTypeFqn() : null,
trigger != null ? trigger.getClassName() : null,
trigger != null ? trigger.getMethodName() : null,
line);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -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());
String resolvedEvent = trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null;
String resolvedSource = trigger.getSourceState() != null
? resolver.resolveValue(trigger.getSourceState(), properties)
: null;
return trigger.toBuilder() return trigger.toBuilder()
.event(trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null) .event(resolvedEvent != null
.sourceState(trigger.getSourceState() != null ? MachineEnumCanonicalizer.qualifyEventIdentifier(
? resolver.resolveValue(trigger.getSourceState(), properties) 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

@@ -4,6 +4,7 @@ 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.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;
@@ -400,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) {

View File

@@ -10,6 +10,7 @@ import java.util.concurrent.ConcurrentHashMap;
public final class ClassCompatibilityCache { public final class ClassCompatibilityCache {
private final Map<Map.Entry<String, String>, Boolean> cache = new ConcurrentHashMap<>(); 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) { public boolean areClassesCompatible(String classNeighbor, String classTarget, ClassCompatibilityChecker checker) {
if (classNeighbor == null || classTarget == null) { if (classNeighbor == null || classTarget == null) {
@@ -22,8 +23,18 @@ public final class ClassCompatibilityCache {
return cache.computeIfAbsent(key, ignored -> checker.check(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() { public void clear() {
cache.clear(); cache.clear();
memoCache.clear();
} }
private static Map.Entry<String, String> cacheKey(String classNeighbor, String classTarget) { private static Map.Entry<String, String> cacheKey(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

@@ -1,6 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.pipeline; package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
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.Block; import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.Expression; import org.eclipse.jdt.core.dom.Expression;
@@ -10,15 +11,11 @@ import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.SimpleName; import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.ASTNode; import org.eclipse.jdt.core.dom.ASTNode;
import java.util.Set;
/** /**
* Extracts terminal payload literals from reactive factory chains ({@code Mono.just}, nested {@code flatMap}). * Extracts terminal payload literals from reactive factory chains ({@code Mono.just}, nested transforms).
*/ */
public final class ReactiveExpressionSupport { public final class ReactiveExpressionSupport {
private static final Set<String> REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success");
private ReactiveExpressionSupport() { private ReactiveExpressionSupport() {
} }
@@ -27,7 +24,46 @@ public final class ReactiveExpressionSupport {
} }
/** /**
* Rewrites {@code p.getEvent()} inside a {@code flatMap(p -> ...)} lambda to {@code payload.getEvent()} * 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). * when {@code p} is fed by {@code Mono.just(payload)} (or a chained reactive source).
*/ */
public static String remapLambdaParameterGetter( public static String remapLambdaParameterGetter(
@@ -44,14 +80,14 @@ public final class ReactiveExpressionSupport {
if (lambda == null) { if (lambda == null) {
return null; return null;
} }
MethodInvocation flatMapCall = findEnclosingFlatMap(lambda); MethodInvocation transformCall = findEnclosingReactiveTransform(lambda);
if (flatMapCall == null) { if (transformCall == null) {
return null; return null;
} }
String mappedReceiver = mapLambdaParameterToSource( String mappedReceiver = mapLambdaParameterToSource(
lambda, lambda,
paramName.getIdentifier(), paramName.getIdentifier(),
flatMapCall.getExpression(), transformCall.getExpression(),
constantResolver, constantResolver,
context); context);
if (mappedReceiver == null) { if (mappedReceiver == null) {
@@ -69,7 +105,7 @@ public final class ReactiveExpressionSupport {
String methodFqn, String methodFqn,
ConstantResolver constantResolver, ConstantResolver constantResolver,
CodebaseContext context) { CodebaseContext context) {
MethodInvocation getter = findMethodInvocationInMethod(methodFqn, expressionText, context); MethodInvocation getter = AstUtils.findMethodInvocationInMethod(methodFqn, expressionText, context);
if (getter == null) { if (getter == null) {
return null; return null;
} }
@@ -80,37 +116,6 @@ public final class ReactiveExpressionSupport {
return null; return null;
} }
private static MethodInvocation findMethodInvocationInMethod(
String methodFqn,
String expressionText,
CodebaseContext context) {
if (methodFqn == null || expressionText == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
org.eclipse.jdt.core.dom.MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
final MethodInvocation[] match = new MethodInvocation[1];
md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (expressionText.equals(node.toString())) {
match[0] = node;
return false;
}
return super.visit(node);
}
});
return match[0];
}
private static LambdaExpression findEnclosingLambda(ASTNode node) { private static LambdaExpression findEnclosingLambda(ASTNode node) {
ASTNode current = node.getParent(); ASTNode current = node.getParent();
while (current != null) { while (current != null) {
@@ -122,10 +127,11 @@ public final class ReactiveExpressionSupport {
return null; return null;
} }
private static MethodInvocation findEnclosingFlatMap(ASTNode node) { private static MethodInvocation findEnclosingReactiveTransform(ASTNode node) {
ASTNode current = node.getParent(); ASTNode current = node.getParent();
while (current != null) { while (current != null) {
if (current instanceof MethodInvocation mi && "flatMap".equals(mi.getName().getIdentifier())) { if (current instanceof MethodInvocation mi
&& LibraryUnwrapRegistry.isReactiveTransformMethod(mi.getName().getIdentifier())) {
return mi; return mi;
} }
current = current.getParent(); current = current.getParent();
@@ -143,14 +149,14 @@ public final class ReactiveExpressionSupport {
} }
if (expression instanceof MethodInvocation mi) { if (expression instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier(); String methodName = mi.getName().getIdentifier();
if ("flatMap".equals(methodName) && !mi.arguments().isEmpty()) { if (LibraryUnwrapRegistry.isReactiveTransformMethod(methodName) && !mi.arguments().isEmpty()) {
String fromArgument = extractFlatMapArgumentPayload( String fromArgument = extractTransformArgumentPayload(
(Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context); (Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context);
if (fromArgument != null) { if (fromArgument != null) {
return fromArgument; return fromArgument;
} }
} }
if (REACTIVE_FACTORY_METHODS.contains(methodName) && !mi.arguments().isEmpty()) { if (LibraryUnwrapRegistry.isReactiveFactoryMethod(methodName) && !mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0); Expression arg = (Expression) mi.arguments().get(0);
if (constantResolver != null && context != null) { if (constantResolver != null && context != null) {
String resolved = constantResolver.resolve(arg, context); String resolved = constantResolver.resolve(arg, context);
@@ -167,35 +173,35 @@ public final class ReactiveExpressionSupport {
return null; return null;
} }
private static String extractFlatMapArgumentPayload( private static String extractTransformArgumentPayload(
Expression argument, Expression argument,
Expression flatMapReceiver, Expression transformReceiver,
ConstantResolver constantResolver, ConstantResolver constantResolver,
CodebaseContext context) { CodebaseContext context) {
if (argument instanceof LambdaExpression lambda) { if (argument instanceof LambdaExpression lambda) {
Expression bodyExpression = lambdaBodyExpression(lambda); Expression bodyExpression = lambdaBodyExpression(lambda);
if (bodyExpression != null) { if (bodyExpression != null) {
String lambdaPayload = extractLambdaBodyPayload( String lambdaPayload = extractLambdaBodyPayload(
bodyExpression, lambda, flatMapReceiver, constantResolver, context); bodyExpression, lambda, transformReceiver, constantResolver, context);
if (lambdaPayload != null) { if (lambdaPayload != null) {
return lambdaPayload; return lambdaPayload;
} }
return extractPayload(bodyExpression, flatMapReceiver, constantResolver, context); return extractPayload(bodyExpression, transformReceiver, constantResolver, context);
} }
} }
return extractPayload(argument, flatMapReceiver, constantResolver, context); return extractPayload(argument, transformReceiver, constantResolver, context);
} }
private static String extractLambdaBodyPayload( private static String extractLambdaBodyPayload(
Expression bodyExpression, Expression bodyExpression,
LambdaExpression lambda, LambdaExpression lambda,
Expression flatMapReceiver, Expression transformReceiver,
ConstantResolver constantResolver, ConstantResolver constantResolver,
CodebaseContext context) { CodebaseContext context) {
if (!(bodyExpression instanceof MethodInvocation factoryCall)) { if (!(bodyExpression instanceof MethodInvocation factoryCall)) {
return null; return null;
} }
if (!REACTIVE_FACTORY_METHODS.contains(factoryCall.getName().getIdentifier()) if (!LibraryUnwrapRegistry.isReactiveFactoryMethod(factoryCall.getName().getIdentifier())
|| factoryCall.arguments().isEmpty()) { || factoryCall.arguments().isEmpty()) {
return null; return null;
} }
@@ -207,7 +213,7 @@ public final class ReactiveExpressionSupport {
Expression getterReceiver = getterCall.getExpression(); Expression getterReceiver = getterCall.getExpression();
if (getterReceiver instanceof SimpleName paramName) { if (getterReceiver instanceof SimpleName paramName) {
String mappedReceiver = mapLambdaParameterToSource( String mappedReceiver = mapLambdaParameterToSource(
lambda, paramName.getIdentifier(), flatMapReceiver, constantResolver, context); lambda, paramName.getIdentifier(), transformReceiver, constantResolver, context);
if (mappedReceiver != null) { if (mappedReceiver != null) {
return mappedReceiver + getterSuffix; return mappedReceiver + getterSuffix;
} }
@@ -221,7 +227,7 @@ public final class ReactiveExpressionSupport {
private static String mapLambdaParameterToSource( private static String mapLambdaParameterToSource(
LambdaExpression lambda, LambdaExpression lambda,
String paramName, String paramName,
Expression flatMapReceiver, Expression transformReceiver,
ConstantResolver constantResolver, ConstantResolver constantResolver,
CodebaseContext context) { CodebaseContext context) {
if (lambda.parameters().size() != 1) { if (lambda.parameters().size() != 1) {
@@ -234,36 +240,36 @@ public final class ReactiveExpressionSupport {
if (!paramName.equals(variableDeclaration.getName().getIdentifier())) { if (!paramName.equals(variableDeclaration.getName().getIdentifier())) {
return null; return null;
} }
if (flatMapReceiver instanceof MethodInvocation receiverFlatMap if (transformReceiver instanceof MethodInvocation receiverTransform
&& "flatMap".equals(receiverFlatMap.getName().getIdentifier()) && LibraryUnwrapRegistry.isReactiveTransformMethod(receiverTransform.getName().getIdentifier())
&& !receiverFlatMap.arguments().isEmpty() && !receiverTransform.arguments().isEmpty()
&& receiverFlatMap.arguments().get(0) instanceof LambdaExpression feederLambda && receiverTransform.arguments().get(0) instanceof LambdaExpression feederLambda
&& feederLambda != lambda) { && feederLambda != lambda) {
Expression feederBody = lambdaBodyExpression(feederLambda); Expression feederBody = lambdaBodyExpression(feederLambda);
if (feederBody != null) { if (feederBody != null) {
String feederPayload = extractLambdaBodyPayload( String feederPayload = extractLambdaBodyPayload(
feederBody, feederLambda, receiverFlatMap.getExpression(), constantResolver, context); feederBody, feederLambda, receiverTransform.getExpression(), constantResolver, context);
if (feederPayload != null) { if (feederPayload != null) {
return feederPayload; return feederPayload;
} }
String extracted = extractPayload( String extracted = extractPayload(
feederBody, receiverFlatMap.getExpression(), constantResolver, context); feederBody, receiverTransform.getExpression(), constantResolver, context);
if (extracted != null) { if (extracted != null) {
return extracted; return extracted;
} }
} }
} }
Expression source = peelJustArgument(flatMapReceiver); Expression source = peelJustArgument(transformReceiver);
if (source instanceof MethodInvocation getterMi if (source instanceof MethodInvocation getterMi
&& getterMi.getExpression() instanceof SimpleName innerParamName) { && getterMi.getExpression() instanceof SimpleName innerParamName) {
LambdaExpression outerLambda = findEnclosingLambda(lambda); LambdaExpression outerLambda = findEnclosingLambda(lambda);
if (outerLambda != null && outerLambda != lambda) { if (outerLambda != null && outerLambda != lambda) {
MethodInvocation outerFlatMap = findEnclosingFlatMap(outerLambda); MethodInvocation outerTransform = findEnclosingReactiveTransform(outerLambda);
if (outerFlatMap != null) { if (outerTransform != null) {
String mappedBase = mapLambdaParameterToSource( String mappedBase = mapLambdaParameterToSource(
outerLambda, outerLambda,
innerParamName.getIdentifier(), innerParamName.getIdentifier(),
outerFlatMap.getExpression(), outerTransform.getExpression(),
constantResolver, constantResolver,
context); context);
if (mappedBase != null) { if (mappedBase != null) {
@@ -281,33 +287,17 @@ public final class ReactiveExpressionSupport {
} }
return source.toString(); return source.toString();
} }
return extractPayload(flatMapReceiver, null, constantResolver, context); return extractPayload(transformReceiver, null, constantResolver, context);
} }
private static Expression peelJustArgument(Expression expression) { private static Expression peelJustArgument(Expression expression) {
if (expression instanceof MethodInvocation mi Expression peeled = peelFactoryPayloadExpression(expression);
&& REACTIVE_FACTORY_METHODS.contains(mi.getName().getIdentifier()) if (peeled != null) {
&& !mi.arguments().isEmpty()) { return peeled;
return (Expression) mi.arguments().get(0);
} }
if (expression instanceof MethodInvocation mi && mi.getExpression() != null) { if (expression instanceof MethodInvocation mi && mi.getExpression() != null) {
return peelJustArgument(mi.getExpression()); return peelJustArgument(mi.getExpression());
} }
return null; return null;
} }
private static Expression lambdaBodyExpression(LambdaExpression lambda) {
if (lambda.getBody() instanceof Expression bodyExpression) {
return bodyExpression;
}
if (lambda.getBody() instanceof Block block) {
for (Object statement : block.statements()) {
if (statement instanceof ReturnStatement returnStatement
&& returnStatement.getExpression() != null) {
return returnStatement.getExpression();
}
}
}
return null;
}
} }

View File

@@ -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.*;
@@ -345,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);
@@ -380,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);
@@ -397,7 +396,7 @@ public class ConstantResolver {
} }
} }
} }
return super.visit(node); return false;
} }
@Override @Override
@@ -410,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);
@@ -450,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;
} }
@@ -463,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()) {
@@ -515,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();
@@ -550,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()) {
@@ -602,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();
@@ -636,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);
@@ -658,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) {
@@ -705,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());
@@ -736,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);
} }
@@ -849,7 +849,7 @@ public class ConstantResolver {
} }
} }
for (String classFqn : classesFromCurrentCallPath()) { for (String classFqn : classesFromCurrentCallPath(context)) {
TypeDeclaration pathTd = context.getTypeDeclaration(classFqn); TypeDeclaration pathTd = context.getTypeDeclaration(classFqn);
if (pathTd != null) { if (pathTd != null) {
String result = resolveFieldInType(pathTd, sn.getIdentifier(), classFqn, context, visited); String result = resolveFieldInType(pathTd, sn.getIdentifier(), classFqn, context, visited);
@@ -921,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;
} }
@@ -933,15 +933,15 @@ public class ConstantResolver {
} }
private TypeDeclaration typeFromCurrentCallPath(CodebaseContext context) { private TypeDeclaration typeFromCurrentCallPath(CodebaseContext context) {
List<String> classes = classesFromCurrentCallPath(); List<String> classes = classesFromCurrentCallPath(context);
if (classes.isEmpty()) { if (classes.isEmpty()) {
return null; return null;
} }
return context.getTypeDeclaration(classes.get(0)); return context.getTypeDeclaration(classes.get(0));
} }
private List<String> classesFromCurrentCallPath() { 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 List.of(); return List.of();
} }
@@ -959,8 +959,8 @@ public class ConstantResolver {
return classes; return classes;
} }
private String getCurrentCallPathEntryClass() { private String getCurrentCallPathEntryClass(CodebaseContext context) {
List<String> classes = classesFromCurrentCallPath(); List<String> classes = classesFromCurrentCallPath(context);
return classes.isEmpty() ? null : classes.get(classes.size() - 1); return classes.isEmpty() ? null : classes.get(classes.size() - 1);
} }
@@ -1073,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) {
@@ -1099,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) {
@@ -1125,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

@@ -1,780 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@Slf4j
public class CallGraphBuilder {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private String currentMethodFqn;
private Map<String, List<CallEdge>> graph;
@lombok.Data
private static class CallEdge {
private final String targetMethod;
private final List<String> arguments;
}
public CallGraphBuilder(CodebaseContext context) {
this.context = context;
this.constantResolver = new ConstantResolver();
}
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
Map<String, List<CallEdge>> callGraph = buildCallGraph();
List<CallChain> chains = new ArrayList<>();
for (EntryPoint ep : entryPoints) {
String startMethod = ep.getClassName() + "." + ep.getMethodName();
boolean foundAny = false;
for (TriggerPoint tp : triggers) {
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<String> path = findPath(startMethod, targetMethod, callGraph, new HashSet<>());
if (path != null) {
foundAny = true;
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph);
String contextMachineId = extractContextMachineId(path, callGraph);
chains.add(CallChain.builder()
.entryPoint(ep)
.triggerPoint(resolvedTp)
.methodChain(path)
.contextMachineId(contextMachineId)
.build());
}
}
if (!foundAny && log.isDebugEnabled()) {
log.debug("Entry point {} ({}) did not reach any trigger points. Graph nodes available: {}", ep.getName(), startMethod, callGraph.keySet());
}
}
return chains;
}
private TriggerPoint resolveTriggerPointParameters(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> callGraph) {
if (path.size() < 2) return tp;
String event = tp.getEvent();
if (event == null || event.isEmpty() || event.contains("\"")) return tp;
String currentParamName = event;
String resolvedValue = event;
String methodSuffix = "";
// Extract method calls like .getType() so we can trace the base parameter
int dotIndex = currentParamName.indexOf('.');
if (dotIndex > 0 && dotIndex + 1 < currentParamName.length()) {
char nextChar = currentParamName.charAt(dotIndex + 1);
if (Character.isLowerCase(nextChar)) {
methodSuffix = currentParamName.substring(dotIndex);
currentParamName = currentParamName.substring(0, dotIndex);
}
}
// Walk backwards up the call chain
for (int i = path.size() - 1; i > 0; i--) {
String target = path.get(i);
String caller = path.get(i - 1);
// Find parameter index in target method
int paramIndex = getParameterIndex(target, currentParamName);
if (paramIndex < 0) {
// Not a parameter. Maybe it's a local variable initialized from a parameter or method call?
String tracedVar = traceLocalVariable(target, currentParamName);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
// Extract method calls like .getType() from the traced variable
int dotIdx = tracedVar.indexOf('.');
if (dotIdx > 0 && dotIdx + 1 < tracedVar.length() && Character.isLowerCase(tracedVar.charAt(dotIdx + 1))) {
methodSuffix = tracedVar.substring(dotIdx) + methodSuffix;
tracedVar = tracedVar.substring(0, dotIdx);
}
currentParamName = tracedVar;
resolvedValue = tracedVar + methodSuffix;
paramIndex = getParameterIndex(target, currentParamName);
}
}
if (paramIndex < 0) {
break; // Parameter name changed or not found, stop tracing
}
// Find the edge from caller to target to get the argument passed
List<CallEdge> edges = callGraph.get(caller);
boolean found = false;
if (edges != null) {
for (CallEdge edge : edges) {
if (edge.getTargetMethod().equals(target) || isHeuristicMatch(edge.getTargetMethod(), target)) {
if (paramIndex < edge.getArguments().size()) {
String arg = edge.getArguments().get(paramIndex);
if (arg != null) {
// If the argument passed has a method call, extract it
int dotIdx = arg.indexOf('.');
if (dotIdx > 0 && dotIdx + 1 < arg.length() && Character.isLowerCase(arg.charAt(dotIdx + 1))) {
methodSuffix = arg.substring(dotIdx) + methodSuffix;
arg = arg.substring(0, dotIdx);
}
currentParamName = arg;
resolvedValue = arg + methodSuffix;
found = true;
break;
}
}
}
}
}
if (!found) break; // Could not map argument
}
// Final check on the entry method
String entryMethod = path.get(0);
int entryParamIndex = getParameterIndex(entryMethod, currentParamName);
if (entryParamIndex < 0) {
String tracedVar = traceLocalVariable(entryMethod, currentParamName);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
int dotIdx = tracedVar.indexOf('.');
if (dotIdx > 0 && dotIdx + 1 < tracedVar.length() && Character.isLowerCase(tracedVar.charAt(dotIdx + 1))) {
methodSuffix = tracedVar.substring(dotIdx) + methodSuffix;
tracedVar = tracedVar.substring(0, dotIdx);
}
currentParamName = tracedVar;
resolvedValue = tracedVar + methodSuffix;
}
}
List<String> polymorphicEvents = new ArrayList<>();
if (!methodSuffix.isEmpty() && methodSuffix.matches("\\.[a-zA-Z0-9_]+\\(\\)")
&& currentParamName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
String varName = currentParamName;
String methodName = methodSuffix.substring(1, methodSuffix.length() - 2);
// Resolve in the first method in the path where the variable might be declared
for (String methodFqn : path) {
String declaredType = getVariableDeclaredType(methodFqn, varName);
if (declaredType != null) {
List<String> typesToInspect = new ArrayList<>();
typesToInspect.add(declaredType);
typesToInspect.addAll(context.getImplementations(declaredType));
for (String type : typesToInspect) {
Set<String> visited = new HashSet<>();
// We must find the compilation unit to pass for simple names!
// Let's pass the first available CU for this class
TypeDeclaration baseTd = context.getTypeDeclaration(type);
CompilationUnit cuToUse = null;
if (baseTd != null && baseTd.getRoot() instanceof CompilationUnit) {
cuToUse = (CompilationUnit) baseTd.getRoot();
} else {
String entryClassName = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
TypeDeclaration entryTd = context.getTypeDeclaration(entryClassName);
if (entryTd != null && entryTd.getRoot() instanceof CompilationUnit) {
cuToUse = (CompilationUnit) entryTd.getRoot();
}
}
List<String> constants = resolveMethodReturnConstant(type, methodName, 0, visited, cuToUse);
for (String constant : constants) {
if (!polymorphicEvents.contains(constant)) {
polymorphicEvents.add(constant);
}
}
}
break;
}
}
}
if (!resolvedValue.equals(event) || !polymorphicEvents.isEmpty()) {
return TriggerPoint.builder()
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(polymorphicEvents)
.build();
}
return tp;
}
private int getParameterIndex(String methodFqn, String paramName) {
if (methodFqn == null || !methodFqn.contains(".")) return -1;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
for (int i = 0; i < md.parameters().size(); i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) md.parameters().get(i);
if (svd.getName().getIdentifier().equals(paramName)) {
return i;
}
}
}
}
return -1;
}
private String getVariableDeclaredType(String methodFqn, String varName) {
if (methodFqn == null || !methodFqn.contains(".")) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
for (Object pObj : md.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) pObj;
if (svd.getName().getIdentifier().equals(varName)) {
return svd.getType().toString();
}
}
final String[] foundType = new String[1];
if (md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType().toString();
}
}
return super.visit(node);
}
});
}
return foundType[0];
}
}
return null;
}
private List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) {
if (depth > 20) return Collections.emptyList();
String fqn = className + "." + methodName;
if (!visited.add(fqn)) return Collections.emptyList();
List<String> constants = new ArrayList<>();
TypeDeclaration td = contextCu != null ? context.getTypeDeclaration(className, contextCu) : context.getTypeDeclaration(className);
if (td == null) {
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
Expression retExpr = node.getExpression();
if (retExpr != null) {
boolean handled = false;
if (retExpr instanceof MethodInvocation mi) {
// Follow delegation first
String called = resolveCalledMethod(mi);
if (called != null && called.contains(".")) {
if (visited.contains(called)) {
handled = true;
} else {
String cName = called.substring(0, called.lastIndexOf('.'));
String mName = called.substring(called.lastIndexOf('.') + 1);
TypeDeclaration targetTd = contextCu != null ? context.getTypeDeclaration(cName, contextCu) : context.getTypeDeclaration(cName);
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
handled = true;
}
}
}
}
if (!handled) {
String val = constantResolver.resolve(retExpr, context);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
constants.add(eVal.substring(eVal.lastIndexOf('.') + 1));
}
} else {
constants.add(val);
}
} else if (retExpr instanceof QualifiedName qn) {
constants.add(qn.toString());
} else if (retExpr instanceof SimpleName sn) {
constants.add(sn.toString());
}
}
}
return super.visit(node);
}
});
}
}
visited.remove(fqn);
return constants;
}
private String traceLocalVariable(String methodFqn, String varName) {
if (methodFqn == null || !methodFqn.contains(".")) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
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);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
initializer[0] = node.getRightHandSide();
}
return super.visit(node);
}
});
if (initializer[0] != null) {
Expression expr = traceVariable(initializer[0]);
if (expr instanceof MethodInvocation mi) {
// Unwrapper logic: If wrapper method is called, extract its arguments recursively
Expression innerMost = unwrapMethodInvocation(mi, 0);
if (innerMost instanceof MethodInvocation innerMi) {
if (innerMi.getExpression() instanceof SimpleName sn) {
return sn.getIdentifier() + "." + innerMi.getName().getIdentifier() + "()";
}
return innerMi.getName().getIdentifier() + "()";
}
if (innerMost instanceof SimpleName sn) {
return sn.getIdentifier();
}
return innerMost.toString();
}
if (expr instanceof SimpleName sn) {
return sn.getIdentifier();
}
return expr.toString();
}
}
}
return null;
}
private Expression unwrapMethodInvocation(MethodInvocation mi, int depth) {
if (depth > 5) return mi;
if (!mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0);
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1);
}
return arg;
}
return mi;
}
private String extractContextMachineId(List<String> path, Map<String, List<CallEdge>> callGraph) {
for (String node : path) {
List<CallEdge> edges = callGraph.get(node);
if (edges != null) {
for (CallEdge edge : edges) {
String target = edge.getTargetMethod();
if (target != null && (target.contains(".restore") || target.contains(".read"))) {
// Persister signatures usually like: restore(stateMachine, contextObj)
if (edge.getArguments().size() >= 2) {
return edge.getArguments().get(1); // The contextObj / machineId
}
}
}
}
}
return null;
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private Map<String, List<CallEdge>> buildCallGraph() {
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
for (String calledMethod : calledMethods) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
}
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) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(refMethod, args));
}
}
} 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();
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(impl + "." + emr.getName().getIdentifier(), args));
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
}
}
}
}
return super.visit(node);
}
});
}
return graph;
}
private List<String> resolveArguments(List<?> astArguments) {
List<String> args = new ArrayList<>();
for (Object argObj : astArguments) {
Expression expr = (Expression) argObj;
// Extract from lambda
if (expr instanceof LambdaExpression le) {
ASTNode body = le.getBody();
if (body instanceof Expression bodyExpr) {
expr = bodyExpr;
} else if (body instanceof Block block) {
for (Object stmtObj : block.statements()) {
if (stmtObj instanceof ReturnStatement rs && rs.getExpression() != null) {
expr = rs.getExpression();
break;
}
}
}
}
if (expr instanceof ExpressionMethodReference emr) {
TypeDeclaration td = findEnclosingType(expr);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, emr.getName().getIdentifier(), true);
if (md != null && md.getBody() != null) {
for (Object stmtObj : md.getBody().statements()) {
if (stmtObj instanceof ReturnStatement rs && rs.getExpression() != null) {
expr = rs.getExpression();
break;
}
}
}
}
}
// Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...))
Expression tracedExpr = traceVariable(expr);
if (tracedExpr instanceof QualifiedName || tracedExpr instanceof ClassInstanceCreation || tracedExpr instanceof StringLiteral || tracedExpr instanceof NumberLiteral) {
expr = tracedExpr; // Only accept trace if it resolves to a pure constant/primitive/constructor
}
if (expr instanceof ClassInstanceCreation cic && !cic.arguments().isEmpty()) {
Expression firstArg = (Expression) cic.arguments().get(0);
String resolved = constantResolver.resolve(firstArg, context);
if (resolved != null) {
expr = firstArg; // Only unwrap if it's actually a constant
}
}
String val;
if (expr instanceof MethodInvocation mi && mi.getExpression() instanceof SimpleName
&& mi.arguments().isEmpty()) {
// Keep local getter chains (e.g. event.getType()) for downstream parameter tracing.
val = expr.toString();
} else {
val = constantResolver.resolve(expr, context);
if (val == null) {
val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc)
}
}
args.add(val);
}
return args;
}
private List<String> resolveCalledMethodsPolymorphic(MethodInvocation node) {
String baseCalled = resolveCalledMethod(node);
if (baseCalled == null) return Collections.emptyList();
List<String> allResolved = new ArrayList<>();
allResolved.add(baseCalled);
if (baseCalled.contains(".")) {
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1);
List<String> impls = context.getImplementations(className);
for (String impl : impls) {
allResolved.add(impl + "." + methodName);
}
}
return allResolved;
}
private String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInType(td, methodName);
}
return null;
}
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
return binding.getQualifiedName() + "." + methodName;
}
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
}
if (receiver instanceof SimpleName sn) {
String fallbackTypeFqn = resolveReceiverTypeFallback(sn);
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
String receiverName = sn.getIdentifier();
return receiverName + "." + methodName;
}
return null;
}
private String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier();
// 1. Check local variables in enclosing method
MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode);
if (enclosingMethod != null) {
// Check parameters
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(svd.getType(), receiverNameNode);
}
}
// Check method body (local variables)
if (enclosingMethod.getBody() != null) {
Type[] foundType = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType();
}
}
return super.visit(node);
}
});
if (foundType[0] != null) {
return resolveTypeToFqn(foundType[0], receiverNameNode);
}
}
}
// 2. Check fields in enclosing class
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
if (enclosingType != null) {
for (FieldDeclaration field : enclosingType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(field.getType(), receiverNameNode);
}
}
}
}
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 String resolveMethodInType(TypeDeclaration td, String methodName) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
TypeDeclaration declaringTd = findEnclosingType(md);
return context.getFqn(declaringTd) + "." + methodName;
}
return null;
}
private List<String> findPath(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) {
if (start.equals(target)) return new ArrayList<>(List.of(start));
if (!visited.add(start)) return null; // Path-scoped cycle detection
List<CallEdge> neighbors = graph.get(start);
if (neighbors != null) {
for (CallEdge edge : neighbors) {
String neighbor = edge.getTargetMethod();
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
visited.remove(start);
return new ArrayList<>(List.of(start, target));
}
List<String> path = findPath(neighbor, target, graph, visited);
if (path != null) {
path.add(0, start);
visited.remove(start);
return path;
}
}
}
if (log.isDebugEnabled()) {
log.debug("Path search dead-end at {} when looking for {}", start, target);
}
visited.remove(start);
return null;
}
private boolean isHeuristicMatch(String neighbor, String target) {
if (target.endsWith("." + neighbor)) return true;
String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target;
String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor;
return simpleNeighbor.equals(simpleTarget);
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private Expression traceVariable(Expression expr) {
if (expr instanceof SimpleName sn) {
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(expr);
if (enclosingMethod != null) {
final Expression[] initializer = new Expression[1];
enclosingMethod.accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializer[0] = node.getInitializer();
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
initializer[0] = node.getRightHandSide();
}
return super.visit(node);
}
});
if (initializer[0] != null) {
return traceVariable(initializer[0]);
}
}
}
return expr;
}
}

View File

@@ -361,10 +361,7 @@ 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)) { if (context.areClassesPolymorphicallyCompatible(classNeighbor, classTarget)) {
return true; return true;
@@ -383,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,6 +78,18 @@ 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()) { if (log.isTraceEnabled()) {
@@ -114,42 +111,20 @@ 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()) { return;
extractConstantsFromExpression((Expression) setterMi.arguments().get(0), constants);
return;
}
}
}
} else if (es.getExpression() instanceof Assignment assignment) {
if (assignment.getLeftHandSide() instanceof FieldAccess fa) {
if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) {
if (fa.getName().getIdentifier().equalsIgnoreCase(propName)) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
return;
}
}
} else if (assignment.getLeftHandSide() instanceof QualifiedName qqn) {
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
if (qqn.getName().getIdentifier().equalsIgnoreCase(propName)) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
return;
}
}
}
}
} }
} }
} }
@@ -235,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());
@@ -381,6 +367,11 @@ public class ConstantExtractor {
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) {
@@ -440,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);
@@ -531,36 +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 || implementations.isEmpty()) {
return Optional.empty();
}
Optional<AccessorSummary> precomputed = context.getPolymorphicAccessorIndex()
.representativeImplAccessor(className, methodName);
if (precomputed.isPresent()) {
return precomputed;
}
for (String implementation : implementations) {
Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName);
if (implAccessor.isPresent()) {
return implAccessor;
}
}
return Optional.empty();
} }
private List<String> extractConstantGetterReturn( private List<String> extractConstantGetterReturn(
@@ -568,30 +536,8 @@ 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();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
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);
}
return false;
}
});
return constants;
} }
@@ -609,9 +555,41 @@ public class ConstantExtractor {
} }
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);
@@ -690,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

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

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

@@ -5,7 +5,6 @@ import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport; 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.*;
@@ -15,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(
@@ -35,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";
@@ -62,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);
@@ -77,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;
@@ -95,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)))
@@ -113,16 +130,10 @@ public class GenericEventDetector {
return reactivePayload; 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();
@@ -149,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};
} }
} }
@@ -162,44 +173,86 @@ 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) {
log.debug("Successfully built trigger point: {}", trigger.getEvent()); if (log.isDebugEnabled()) {
log.debug("Successfully built trigger point: {}", trigger.getEvent());
}
triggers.add(trigger); triggers.add(trigger);
} }
} }
} }
private void processHints(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) { private void processBuiltInPatterns(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
if (hints == null || hints.isEmpty()) return; String methodName = node.getName().getIdentifier();
if (TRIGGER_METHOD_NAMES.contains(methodName)) {
return;
}
String calledMethod = resolveCalledMethodName(node); String calledMethod = resolveCalledMethodName(node);
if (calledMethod == null) return; if (calledMethod == null) {
return;
for (LibraryHint hint : hints) { }
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) { for (LibraryHint pattern : BUILTIN_SOURCE_PATTERNS) {
String eventToUse = hint.getEvent(); if (!matchesHint(calledMethod, pattern.getMethodFqn())) {
if (eventToUse == null && hint.getEventArgumentIndex() != null) { continue;
if (node.arguments().size() > hint.getEventArgumentIndex()) { }
Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex()); String eventToUse = resolveHintEvent(node, pattern);
eventToUse = argExpr.toString(); List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) { if (builtTriggers != null) {
eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()"; for (TriggerPoint trigger : builtTriggers) {
} if (log.isDebugEnabled()) {
} log.debug("Built trigger from built-in source pattern {}: {}", pattern.getMethodFqn(), trigger.getEvent());
}
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);
} }
triggers.add(trigger);
} }
} }
} }
} }
private boolean isHintMatch(String called, String hintFqn) { private void processHints(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
return hintFqn.endsWith("." + called); if (hints == null || hints.isEmpty()) {
return;
}
String calledMethod = resolveCalledMethodName(node);
if (calledMethod == null) {
return;
}
for (LibraryHint hint : hints) {
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();
if (eventToUse == null && hint.getEventArgumentIndex() != null) {
if (node.arguments().size() > hint.getEventArgumentIndex()) {
Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex());
eventToUse = argExpr.toString();
if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) {
eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()";
}
}
}
return eventToUse;
}
private static boolean matchesHint(String calledMethod, String hintFqn) {
return calledMethod.equals(hintFqn) || hintFqn.endsWith("." + calledMethod);
} }
private String resolveCalledMethodName(MethodInvocation node) { private String resolveCalledMethodName(MethodInvocation node) {
@@ -245,12 +298,16 @@ public class GenericEventDetector {
} }
} }
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;
@@ -270,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)))
@@ -285,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)))
@@ -323,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;
@@ -330,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
@@ -383,20 +702,15 @@ public class GenericEventDetector {
if (isRoutingParameter(left) || isRoutingParameter(right)) { if (isRoutingParameter(left) || isRoutingParameter(right)) {
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();
@@ -407,22 +721,12 @@ public class GenericEventDetector {
if (isRoutingParameter(receiver) || isRoutingParameter(arg)) { if (isRoutingParameter(receiver) || isRoutingParameter(arg)) {
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;
@@ -453,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];
@@ -494,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);
if (factoryPayload != null) {
String extracted = extractEventFromMessageBuilder(factoryPayload);
if (extracted != null) {
return extracted;
}
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();
}
MethodInvocation current = mi; MethodInvocation current = mi;
while (current != null) { while (current != null) {
String name = current.getName().getIdentifier(); if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) {
if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) {
Expression payloadExpr = (Expression) current.arguments().get(0);
// If it's Mono.just(msg), where msg is a variable
if ("just".equals(name)) {
String extracted = extractEventFromMessageBuilder(payloadExpr);
if (extracted != null) return extracted;
}
String resolved = constantResolver.resolve(payloadExpr, context);
if (resolved != null) return resolved;
// If not a constant, it might be a parameter like 'event'
if (payloadExpr instanceof CastExpression ce) {
payloadExpr = ce.getExpression();
}
if (payloadExpr instanceof SimpleName sn) {
String extracted = extractEventFromMessageBuilder(payloadExpr);
if (extracted != null) {
return extracted;
}
return sn.getIdentifier(); // Fall back to returning the parameter name
}
return payloadExpr.toString();
} else if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) {
// If the event is obtained by calling a method on a provider/supplier parameter, // 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();
} }
@@ -540,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)) {
@@ -630,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);
@@ -661,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
@@ -678,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());
} }
} }
} }
@@ -710,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()) {
@@ -752,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,157 +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") @Override
protected Map<String, List<CallEdge>> buildCallGraph() { protected String callGraphCacheKey() {
Map<String, List<CallEdge>> cached = (Map<String, List<CallEdge>>) context.getCache().get("heuristicCallGraph"); return "heuristicCallGraph";
if (cached != null) {
this.graph = cached;
return cached;
}
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
for (String calledMethod : calledMethods) {
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(resolveConstraint(node, calledMethod, constraint));
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration td2 = findEnclosingType(node);
if (td2 != null) {
String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) {
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(refMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args, receiver);
implEdge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(implEdge);
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
String receiver = "super";
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
}
}
return super.visit(node);
}
});
}
context.getCache().put("heuristicCallGraph", graph);
return graph;
}
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;
} }
@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();
@@ -163,12 +28,14 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
} }
} }
TypeDeclaration enclosingType = findEnclosingType(node);
if (receiver instanceof SuperMethodInvocation) {
return InheritanceCallTargetResolver.resolveSuperMethod(context, enclosingType, methodName);
}
if (receiver == null) { if (receiver == null) {
TypeDeclaration td = findEnclosingType(node); return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
if (td != null) {
return resolveMethodInTypeHierarchy(td, methodName);
}
return null;
} }
ITypeBinding binding = receiver.resolveTypeBinding(); ITypeBinding binding = receiver.resolveTypeBinding();
@@ -177,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) {
@@ -188,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) {
@@ -198,66 +61,9 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
if (td != null) { if (td != null) {
return fqn + "." + methodName; return fqn + "." + methodName;
} }
return fqn + "." + methodName; return fqn + "." + methodName;
} }
return null; return null;
} }
}
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,171 +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") @Override
protected Map<String, List<CallEdge>> buildCallGraph() { protected String callGraphCacheKey() {
Map<String, List<CallEdge>> cached = (Map<String, List<CallEdge>>) context.getCache().get("jdtCallGraph"); return "jdtCallGraph";
if (cached != null) {
this.graph = cached;
return cached;
}
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
for (String calledMethod : calledMethods) {
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(resolveConstraint(node, calledMethod, constraint));
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration td2 = findEnclosingType(node);
if (td2 != null) {
String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) {
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(refMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args, receiver);
implEdge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(implEdge);
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
String receiver = "super";
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
}
}
return super.visit(node);
}
});
}
context.getCache().put("jdtCallGraph", graph);
return graph;
} }
@Override @Override
protected String resolveArgument(org.eclipse.jdt.core.dom.Expression expr) { protected String resolveArgument(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+", "-> ");
@@ -173,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();
@@ -184,12 +57,14 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
} }
} }
TypeDeclaration enclosingType = findEnclosingType(node);
if (receiver instanceof SuperMethodInvocation) {
return InheritanceCallTargetResolver.resolveSuperMethod(context, enclosingType, methodName);
}
if (receiver == null) { if (receiver == null) {
TypeDeclaration td = findEnclosingType(node); return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
if (td != null) {
return resolveMethodInType(td, methodName);
}
return null;
} }
if (injectionAnalyzer != null) { if (injectionAnalyzer != null) {
@@ -209,15 +84,11 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn); log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn);
} }
return concreteFqn + "." + methodName; return concreteFqn + "." + methodName;
} else { } else if (log.isDebugEnabled()) {
if (log.isDebugEnabled()) { log.debug(" -> RESOLVE FAILED, RETURNED NULL");
log.debug(" -> RESOLVE FAILED, RETURNED NULL");
}
}
} else {
if (log.isDebugEnabled()) {
log.debug("CALLGRAPH NAME BINDING IS NOT VARIABLE: {} calling {}", nameBinding, methodName);
} }
} else if (log.isDebugEnabled()) {
log.debug("CALLGRAPH NAME BINDING IS NOT VARIABLE: {} calling {}", nameBinding, methodName);
} }
} }
@@ -231,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
} }
@@ -246,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;
} }
@@ -254,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) {
@@ -265,80 +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 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

@@ -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,7 +4,6 @@ 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;
@@ -21,6 +20,7 @@ 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<>(); private final Map<String, Map<String, String>> traceBindingsCache = new HashMap<>();
public PathBindingEvaluator( public PathBindingEvaluator(
@@ -28,10 +28,20 @@ public class PathBindingEvaluator {
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). */
@@ -55,17 +65,22 @@ public class PathBindingEvaluator {
private Map<String, String> computeTraceBindings( private Map<String, String> computeTraceBindings(
List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) { List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
Map<String, String> bindings = new HashMap<>(); context.setAnalysisCallPath(path);
for (int i = 0; i < path.size() - 1; i++) { try {
String caller = path.get(i); Map<String, String> bindings = new HashMap<>();
String target = path.get(i + 1); for (int i = 0; i < path.size() - 1; i++) {
CallEdge edge = findEdge(caller, target, callGraph, pathFinder); String caller = path.get(i);
if (edge == null) { String target = path.get(i + 1);
continue; CallEdge edge = findEdge(caller, target, callGraph, pathFinder);
if (edge == null) {
continue;
}
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings);
} }
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings); return bindings;
} finally {
context.clearAnalysisCallPath();
} }
return bindings;
} }
private static String pathCacheKey(List<String> path) { private static String pathCacheKey(List<String> path) {
@@ -143,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);
@@ -153,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);
@@ -197,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;
} }
@@ -208,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

@@ -4,6 +4,8 @@ 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;
@@ -13,6 +15,91 @@ public class TypeResolver {
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); return getParameterIndex(methodFqn, paramName, false);
} }
@@ -101,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;
@@ -139,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,11 +1,15 @@
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 click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
@@ -15,20 +19,27 @@ 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 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() { public void clearAnalysisCaches() {
variableDeclaredTypeCache.clear(); variableDeclaredTypeCache.clear();
if (dataFlowModel instanceof JdtDataFlowModel jdtDataFlowModel) { if (dataFlowModel instanceof JdtDataFlowModel jdtDataFlowModel) {
@@ -74,9 +85,9 @@ public class VariableTracer {
String setterMethodName; String setterMethodName;
String indexedFieldName; String indexedFieldName;
if (isBeanStyleAccessorName(getterName)) { if (AccessorNaming.isBeanStyleAccessorName(getterName)) {
String propName = propertyNameFromAccessor(getterName); String propName = AccessorNaming.propertyNameFromAccessor(getterName);
setterMethodName = "set" + capitalizeProperty(propName); setterMethodName = "set" + AccessorNaming.capitalizeProperty(propName);
indexedFieldName = propName; indexedFieldName = propName;
String varTypeName = getVariableDeclaredType(methodFqn, varName); String varTypeName = getVariableDeclaredType(methodFqn, varName);
@@ -110,32 +121,6 @@ public class VariableTracer {
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) { if (td == null) {
return null; return null;
@@ -150,55 +135,15 @@ public class VariableTracer {
return indexed.get(); return indexed.get();
} }
for (FieldDeclaration fd : td.getFields()) { return fieldInitializerFinder.resolveFieldTypeName(td, fieldName, visited);
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName)) {
return fd.getType().toString();
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
String result = getFieldType(superTd, fieldName, context, visited);
if (result != null) return result;
}
}
for (Object intfObj : td.superInterfaceTypes()) {
Type intfType = (Type) intfObj;
String intfName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(intfType);
String resolvedIntf = resolveTypeFqn(intfName, context, td);
if (resolvedIntf != null) {
TypeDeclaration intfTd = context.getTypeDeclaration(resolvedIntf);
if (intfTd != null) {
String result = getFieldType(intfTd, fieldName, context, visited);
if (result != null) return result;
}
}
}
return null;
} }
private String resolveTypeFqn(String simpleName, CodebaseContext context, TypeDeclaration td) { private MethodDeclaration findEnclosingMethod(ASTNode node) {
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null; return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
if (cu != null) { }
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj; private TypeDeclaration findEnclosingType(ASTNode node) {
String name = imp.getName().getFullyQualifiedName(); return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
if (name.endsWith("." + simpleName)) {
return name;
}
}
if (cu.getPackage() != null) {
return cu.getPackage().getName().getFullyQualifiedName() + "." + simpleName;
}
}
return simpleName;
} }
public String getVariableDeclaredType(String methodFqn, String cleanFieldName) { public String getVariableDeclaredType(String methodFqn, String cleanFieldName) {
@@ -342,6 +287,21 @@ public class VariableTracer {
} }
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);
@@ -349,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);
@@ -383,7 +349,7 @@ public class VariableTracer {
if (stringified.size() == 1) { if (stringified.size() == 1) {
return stringified.get(0).replaceAll("->\\s*yield\\s+", "-> "); return stringified.get(0).replaceAll("->\\s*yield\\s+", "-> ");
} }
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (int i = 0; i < stringified.size() - 1; i++) { for (int i = 0; i < stringified.size() - 1; i++) {
sb.append("true ? ").append(stringified.get(i)).append(" : "); sb.append("true ? ").append(stringified.get(i)).append(" : ");
@@ -442,64 +408,107 @@ 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); TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
if (result != null && parameterValues != null && !parameterValues.isEmpty()) { if (td == null) {
TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.'))); return null;
if (td != 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) {
final ASTNode[] switchNode = new ASTNode[1]; return null;
md.getBody().accept(new ASTVisitor() { }
@Override final ASTNode[] switchNode = new ASTNode[1];
public boolean visit(VariableDeclarationFragment node) { md.getBody().accept(new ASTVisitor() {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { @Override
Expression init = node.getInitializer(); public boolean visit(VariableDeclarationFragment node) {
if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION) { if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
switchNode[0] = init; Expression init = node.getInitializer();
return false; if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION || init.getNodeType() == ASTNode.SWITCH_STATEMENT) {
} switchNode[0] = init;
if (init.getNodeType() == ASTNode.SWITCH_STATEMENT) { return false;
switchNode[0] = init;
return false;
}
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
Expression rhs = node.getRightHandSide();
if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION) {
switchNode[0] = rhs;
return false;
}
if (rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = rhs;
return false;
}
}
return super.visit(node);
}
});
if (switchNode[0] != null) {
if (switchNode[0] instanceof SwitchExpression se) {
String evaluated = constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
if (evaluated != null) return evaluated;
} else if (switchNode[0] instanceof SwitchStatement ss) {
String evaluated = constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
if (evaluated != null) return evaluated;
}
} }
} }
return super.visit(node);
} }
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
Expression rhs = node.getRightHandSide();
if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION || rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = rhs;
return false;
}
}
return super.visit(node);
}
});
if (switchNode[0] instanceof SwitchExpression se) {
return constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
} }
return result; if (switchNode[0] instanceof SwitchStatement ss) {
return constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
}
return null;
} }
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;
@@ -532,38 +541,51 @@ 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;
int lastDot = target.lastIndexOf('.'); List<String> args = selectedEdge.getArguments();
if (lastDot < 0) break; if (args == null || args.isEmpty()) {
String className = target.substring(0, lastDot); return paramValues;
String methodName = target.substring(lastDot + 1); }
TypeDeclaration td = context.getTypeDeclaration(className); int lastDot = target.lastIndexOf('.');
if (td == null) break; if (lastDot < 0) {
return paramValues;
}
String className = target.substring(0, lastDot);
String methodName = target.substring(lastDot + 1);
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); TypeDeclaration td = context.getTypeDeclaration(className);
if (md == null) break; if (td == null) {
return paramValues;
}
List<String> paramNames = new ArrayList<>(); MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
for (Object paramObj : md.parameters()) { if (md == null) {
SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj; return paramValues;
paramNames.add(param.getName().getIdentifier()); }
List<String> paramNames = new ArrayList<>();
for (Object paramObj : md.parameters()) {
SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj;
paramNames.add(param.getName().getIdentifier());
}
int minSize = Math.min(paramNames.size(), args.size());
for (int j = 0; j < minSize; j++) {
String argValue = args.get(j);
String resolvedArgValue = resolveArgumentValue(argValue, caller, path, pathIndex, callGraph);
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);
} }
int minSize = Math.min(paramNames.size(), args.size());
for (int j = 0; j < minSize; j++) {
String argValue = args.get(j);
String resolvedArgValue = resolveArgumentValue(argValue, caller, path, pathIndex, callGraph);
paramValues.put(paramNames.get(j), resolvedArgValue);
}
break;
} }
paramValues.put(paramNames.get(j), resolvedArgValue);
} }
return paramValues; return paramValues;
} }
@@ -655,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) {
@@ -685,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;
} }
@@ -693,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));
} }
@@ -732,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

@@ -139,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());
} }
} }
@@ -177,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);
@@ -194,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) {
@@ -438,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);
@@ -450,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) {
@@ -478,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);
} }
@@ -498,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;
@@ -512,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);
} }
@@ -652,21 +740,36 @@ public class CodebaseContext {
} }
private String resolveSuperclassFqn(TypeDeclaration td) { 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) {
@@ -783,9 +886,7 @@ public class CodebaseContext {
if (classNeighbor.equals(classTarget)) { if (classNeighbor.equals(classTarget)) {
return true; return true;
} }
String simpleClassNeighbor = simpleTypeName(classNeighbor); if (areSameTypeOrUnambiguousSimpleMatch(classNeighbor, classTarget)) {
String simpleClassTarget = simpleTypeName(classTarget);
if (simpleClassNeighbor.equals(simpleClassTarget)) {
return true; return true;
} }
return classCompatibilityCache.areClassesCompatible( return classCompatibilityCache.areClassesCompatible(

View File

@@ -1,7 +1,12 @@
package click.kamil.springstatemachineexporter.ast.common; package click.kamil.springstatemachineexporter.ast.common;
import click.kamil.springstatemachineexporter.analysis.index.AccessorInlining; import click.kamil.springstatemachineexporter.analysis.index.AccessorInlining;
import click.kamil.springstatemachineexporter.analysis.index.AccessorKind;
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.GetterChainEdge;
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget; import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
import java.util.*; import java.util.*;
@@ -14,39 +19,17 @@ public class JdtDataFlowModel implements DataFlowModel {
private final Map<MethodDeclaration, ControlFlowGraph> cfgCache = new HashMap<>(); private final Map<MethodDeclaration, ControlFlowGraph> cfgCache = new HashMap<>();
private final Map<MethodDeclaration, ReachingDefinitions> rdCache = new HashMap<>(); private final Map<MethodDeclaration, ReachingDefinitions> rdCache = new HashMap<>();
private final Map<ASTNode, Map<IVariableBinding, Expression>> objectFieldBindings = new HashMap<>(); private final Map<ASTNode, Map<IVariableBinding, Expression>> objectFieldBindings = new HashMap<>();
private final Map<String, Set<String>> compatibleContextTypesCache = new HashMap<>();
private static final ThreadLocal<List<String>> CURRENT_PATH = new ThreadLocal<>();
public static void setCurrentPath(List<String> path) {
CURRENT_PATH.set(path);
}
public static List<String> getCurrentPath() {
return CURRENT_PATH.get();
}
public static void clearCurrentPath() {
CURRENT_PATH.remove();
}
public JdtDataFlowModel(CodebaseContext context) { public JdtDataFlowModel(CodebaseContext context) {
this.context = context; this.context = context;
} }
public void clearAnalysisCaches() { public void clearAnalysisCaches() {
compatibleContextTypesCache.clear(); context.getClassCompatibilityCache().clearMemoCache();
} }
private MethodDeclaration findEnclosingMethod(ASTNode node) { private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode current = node; return AstUtils.findEnclosingMethod(node);
while (current != null) {
if (current instanceof MethodDeclaration) {
return (MethodDeclaration) current;
}
current = current.getParent();
}
return null;
} }
private ReachingDefinitions getReachingDefinitionsForMethod(MethodDeclaration method) { private ReachingDefinitions getReachingDefinitionsForMethod(MethodDeclaration method) {
@@ -72,6 +55,13 @@ public class JdtDataFlowModel implements DataFlowModel {
return List.of(); return List.of();
} }
if (expr instanceof LambdaExpression lambda) {
List<Expression> resolved = resolveFunctionalBoundExpression(
lambda, visited, paramBindings, instanceFieldBindings, depth);
visited.remove(expr);
return resolved;
}
// 1. Handle Ternary / Conditional Expression // 1. Handle Ternary / Conditional Expression
if (expr instanceof ConditionalExpression ce) { if (expr instanceof ConditionalExpression ce) {
String condVal = resolveValue(ce.getExpression(), context); String condVal = resolveValue(ce.getExpression(), context);
@@ -119,6 +109,18 @@ public class JdtDataFlowModel implements DataFlowModel {
} }
} }
Map<String, String> namedBindings = context.getAnalysisNamedBindings();
if (namedBindings != null && namedBindings.containsKey(sn.getIdentifier())) {
String boundValue = namedBindings.get(sn.getIdentifier());
Expression synthetic = AstUtils.parseExpression(AstUtils.toParseSnippet(boundValue));
if (synthetic != null) {
List<Expression> resolved = getReachingDefinitions(
synthetic, visited, paramBindings, instanceFieldBindings, depth + 1);
visited.remove(expr);
return resolved;
}
}
// Fallback: Local Reaching Definitions // Fallback: Local Reaching Definitions
MethodDeclaration md = findEnclosingMethod(sn); MethodDeclaration md = findEnclosingMethod(sn);
ReachingDefinitions rd = getReachingDefinitionsForMethod(md); ReachingDefinitions rd = getReachingDefinitionsForMethod(md);
@@ -201,13 +203,59 @@ public class JdtDataFlowModel implements DataFlowModel {
// 4. Handle Method Invocations (Static Factory methods / Transforming Getters) // 4. Handle Method Invocations (Static Factory methods / Transforming Getters)
if (expr instanceof MethodInvocation mi) { if (expr instanceof MethodInvocation mi) {
String mName = mi.getName().getIdentifier(); Expression factoryPayload = ReactiveExpressionSupport.peelFactoryPayloadExpression(mi);
if (("just".equals(mName) || "withPayload".equals(mName) || "success".equals(mName)) && !mi.arguments().isEmpty()) { if (factoryPayload != null) {
List<Expression> resolved = getReachingDefinitions((Expression) mi.arguments().get(0), visited, paramBindings, instanceFieldBindings, depth + 1); List<Expression> resolved = getReachingDefinitions(
factoryPayload, visited, paramBindings, instanceFieldBindings, depth + 1);
visited.remove(expr); visited.remove(expr);
return resolved; return resolved;
} }
String mName = mi.getName().getIdentifier();
if (LibraryUnwrapRegistry.isReactiveTransformMethod(mName) && !mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0);
if (arg instanceof LambdaExpression lambda && lambda.parameters().size() == 1) {
Expression source = ReactiveExpressionSupport.peelFactoryPayloadExpression(mi.getExpression());
if (source != null) {
Object parameter = lambda.parameters().get(0);
if (parameter instanceof SingleVariableDeclaration svd) {
IVariableBinding paramBinding = svd.resolveBinding();
if (paramBinding != null) {
Map<IVariableBinding, Expression> transformBindings = new HashMap<>(paramBindings);
transformBindings.put(paramBinding, source);
Expression bodyExpression = ReactiveExpressionSupport.lambdaBodyExpression(lambda);
if (bodyExpression != null) {
Expression nestedFactory = ReactiveExpressionSupport.peelFactoryPayloadExpression(bodyExpression);
if (nestedFactory != null) {
List<Expression> resolved = getReachingDefinitions(
nestedFactory, visited, transformBindings, instanceFieldBindings, depth + 1);
if (!resolved.isEmpty()) {
visited.remove(expr);
return resolved;
}
}
List<Expression> resolved = getReachingDefinitions(
bodyExpression, visited, transformBindings, instanceFieldBindings, depth + 1);
if (!resolved.isEmpty()) {
visited.remove(expr);
return resolved;
}
}
}
}
}
}
}
if (isFunctionalSamInvocation(mi)) {
List<Expression> functional = resolveFunctionalInterfaceInvocation(
mi, visited, paramBindings, instanceFieldBindings, depth);
if (!functional.isEmpty()) {
visited.remove(expr);
return functional;
}
}
List<Expression> inlined = tryInlineAccessorInvocation( List<Expression> inlined = tryInlineAccessorInvocation(
mi, new HashSet<>(visited), paramBindings, instanceFieldBindings, depth); mi, new HashSet<>(visited), paramBindings, instanceFieldBindings, depth);
if (!inlined.isEmpty()) { if (!inlined.isEmpty()) {
@@ -261,6 +309,9 @@ public class JdtDataFlowModel implements DataFlowModel {
} }
} }
} }
} else {
collectSideEffectExpressions(
md.getBody(), results, visited, newParamBindings, target.fieldBindings, depth);
} }
} }
} }
@@ -360,6 +411,11 @@ public class JdtDataFlowModel implements DataFlowModel {
receiverDefs = getReachingDefinitions(receiver, visited, paramBindings, instanceFieldBindings, depth + 1); receiverDefs = getReachingDefinitions(receiver, visited, paramBindings, instanceFieldBindings, depth + 1);
accessor = AccessorInlining.lookupAccessor(context, mi, receiverDefs); accessor = AccessorInlining.lookupAccessor(context, mi, receiverDefs);
if (accessor.isEmpty()) { if (accessor.isEmpty()) {
List<Expression> anonymous = inlineAnonymousFromReceiverDefs(
methodName, receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
if (!anonymous.isEmpty()) {
return anonymous;
}
return List.of(); return List.of();
} }
} }
@@ -388,6 +444,11 @@ public class JdtDataFlowModel implements DataFlowModel {
} }
if (accessor.isEmpty()) { if (accessor.isEmpty()) {
List<Expression> anonymous = inlineAnonymousFromReceiverDefs(
methodName, receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
if (!anonymous.isEmpty()) {
return anonymous;
}
return List.of(); return List.of();
} }
if (!accessor.get().isGetter()) { if (!accessor.get().isGetter()) {
@@ -402,9 +463,72 @@ public class JdtDataFlowModel implements DataFlowModel {
return inlineRecordComponentRead(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth); return inlineRecordComponentRead(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
} }
List<Expression> indexedHop = tryIndexedGetterChainHop(
mi, accessor.get(), visited, paramBindings, instanceFieldBindings, depth);
if (!indexedHop.isEmpty()) {
return indexedHop;
}
return inlineAccessorGetter(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth); return inlineAccessorGetter(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
} }
/**
* Scan-time {@link GetterChainEdge} fast path for trivial field-default getter hops.
* Skipped when the receiver may have been mutated before this use site.
*/
private List<Expression> tryIndexedGetterChainHop(
MethodInvocation mi,
AccessorSummary accessor,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (accessor.kind() != AccessorKind.GETTER || hasReceiverMutationsBeforeUse(mi)) {
return List.of();
}
Optional<GetterChainEdge> edge = context.getGetterChainIndex().lookup(accessor.ownerFqn(), accessor.methodName());
if (edge.isEmpty() || edge.get().fieldInitializerCic() == null) {
return List.of();
}
ClassInstanceCreation fieldDefaultCic = edge.get().fieldInitializerCic();
return getReachingDefinitions(
fieldDefaultCic, visited, paramBindings, instanceFieldBindings, depth + 1);
}
private boolean hasReceiverMutationsBeforeUse(MethodInvocation useSite) {
Expression receiver = useSite.getExpression();
if (!(receiver instanceof SimpleName receiverName)) {
return false;
}
MethodDeclaration enclosingMethod = findEnclosingMethod(useSite);
if (enclosingMethod == null || enclosingMethod.getBody() == null) {
return false;
}
int usePosition = useSite.getStartPosition();
final boolean[] mutationFound = { false };
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (mutationFound[0] || node.getStartPosition() >= usePosition) {
return false;
}
if (!(node.getExpression() instanceof SimpleName callReceiver)) {
return true;
}
if (!callReceiver.getIdentifier().equals(receiverName.getIdentifier())) {
return true;
}
String callee = node.getName().getIdentifier();
if (AccessorNaming.isBeanStyleAccessorName(callee) && callee.startsWith("set")) {
mutationFound[0] = true;
return false;
}
return true;
}
});
return mutationFound[0];
}
private List<Expression> inlineConstantGetter( private List<Expression> inlineConstantGetter(
AccessorSummary accessor, AccessorSummary accessor,
Set<ASTNode> visited, Set<ASTNode> visited,
@@ -631,11 +755,257 @@ public class JdtDataFlowModel implements DataFlowModel {
if (fieldValue != null) { if (fieldValue != null) {
results.addAll(getReachingDefinitions(fieldValue, visited, paramBindings, fieldBindings, depth + 1)); results.addAll(getReachingDefinitions(fieldValue, visited, paramBindings, fieldBindings, depth + 1));
} }
if (results.isEmpty() && cic.getAnonymousClassDeclaration() != null) {
results.addAll(inlineAnonymousGetterReturns(
cic, accessor.methodName(), visited, paramBindings, fieldBindings, depth));
}
} else {
TypeDeclaration receiverType = resolveExpressionTypeDeclaration(
candidate, paramBindings, instanceFieldBindings);
if (receiverType != null) {
Map<IVariableBinding, Expression> typeFieldBindings =
buildTypeDefaultFieldBindings(receiverType);
if (receiver instanceof SimpleName sn && candidate == sn) {
applyFlowSensitiveMutationsForLocalReceiver(
sn, mi, accessor, typeFieldBindings, paramBindings, visited, depth);
}
Expression fieldValue = readFieldValue(accessor, typeFieldBindings, mi);
if (fieldValue != null) {
results.addAll(getReachingDefinitions(
fieldValue, visited, paramBindings, typeFieldBindings, depth + 1));
}
if (results.isEmpty()) {
results.addAll(inlineGetterMethodReturns(
accessor, receiverType, visited, paramBindings, typeFieldBindings, depth));
}
if (results.isEmpty() && receiverType.isInterface()) {
results.addAll(inlineConcreteAccessorFromReceiverDefs(
accessor, receiverDefs, visited, paramBindings, typeFieldBindings, depth));
}
}
} }
} }
return results; return results;
} }
private List<Expression> inlineAnonymousGetterReturns(
ClassInstanceCreation cic,
String methodName,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> fieldBindings,
int depth) {
if (cic.getAnonymousClassDeclaration() == null || methodName == null) {
return List.of();
}
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
if (declObj instanceof MethodDeclaration methodDeclaration
&& methodName.equals(methodDeclaration.getName().getIdentifier())
&& methodDeclaration.getBody() != null) {
List<Expression> results = new ArrayList<>();
for (ReturnStatement rs : findReturnStatements(methodDeclaration.getBody())) {
if (rs.getExpression() != null) {
results.addAll(getReachingDefinitions(
rs.getExpression(), visited, paramBindings, fieldBindings, depth + 1));
}
}
return results;
}
}
return List.of();
}
private List<Expression> inlineConcreteAccessorFromReceiverDefs(
AccessorSummary accessor,
List<Expression> receiverDefs,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> fieldBindings,
int depth) {
List<Expression> results = new ArrayList<>();
for (Expression receiverDef : receiverDefs) {
String concreteFqn = concreteReceiverTypeFqn(receiverDef);
if (concreteFqn == null || concreteFqn.equals(accessor.ownerFqn())) {
continue;
}
TypeDeclaration concreteType = context.getTypeDeclaration(concreteFqn);
if (concreteType == null) {
continue;
}
Map<IVariableBinding, Expression> concreteBindings = buildTypeDefaultFieldBindings(concreteType);
if (receiverDef instanceof ClassInstanceCreation cic) {
concreteBindings = getOrCreateFieldBindings(cic, paramBindings, fieldBindings);
}
Expression fieldValue = readFieldValue(accessor, concreteBindings, null);
if (fieldValue != null) {
results.addAll(getReachingDefinitions(
fieldValue, visited, paramBindings, concreteBindings, depth + 1));
}
if (results.isEmpty()) {
results.addAll(inlineGetterMethodReturns(
accessor, concreteType, visited, paramBindings, concreteBindings, depth));
}
}
return results;
}
private List<Expression> inlineAnonymousFromReceiverDefs(
String methodName,
List<Expression> receiverDefs,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (receiverDefs == null || methodName == null) {
return List.of();
}
for (Expression receiverDef : receiverDefs) {
if (receiverDef instanceof ClassInstanceCreation cic) {
List<Expression> inlined = inlineAnonymousGetterReturns(
cic, methodName, visited, paramBindings, instanceFieldBindings, depth);
if (!inlined.isEmpty()) {
return inlined;
}
}
}
return List.of();
}
private void applyFlowSensitiveMutationsForLocalReceiver(
SimpleName receiverName,
MethodInvocation useSite,
AccessorSummary accessor,
Map<IVariableBinding, Expression> fieldBindings,
Map<IVariableBinding, Expression> paramBindings,
Set<ASTNode> visited,
int depth) {
MethodDeclaration enclosingMethod = findEnclosingMethod(useSite);
if (enclosingMethod == null) {
return;
}
ASTNode defNode = findVariableDefinitionNode(enclosingMethod, receiverName.getIdentifier());
if (defNode instanceof SingleVariableDeclaration) {
String fieldName = accessor.fieldName();
if (fieldName == null || fieldName.isEmpty()) {
return;
}
String setterName = "set" + Character.toUpperCase(fieldName.charAt(0))
+ fieldName.substring(1);
Expression setterArg = findLocalSetterArgument(
enclosingMethod,
receiverName.getIdentifier(),
accessor.methodName(),
setterName,
accessor.fieldName());
if (setterArg != null) {
IVariableBinding fieldBinding = findFieldVariableBinding(
accessor.declaringFqn(), accessor.fieldName(), useSite);
if (fieldBinding != null) {
List<Expression> resolved = getReachingDefinitions(
setterArg, visited, paramBindings, fieldBindings, depth + 1);
fieldBindings.put(fieldBinding, resolved.isEmpty() ? setterArg : resolved.get(0));
}
}
return;
}
if (defNode != null) {
applyIntermediateMutations(
receiverName, defNode, useSite, enclosingMethod, fieldBindings, paramBindings, visited, depth);
}
}
private ASTNode findVariableDefinitionNode(MethodDeclaration methodDeclaration, String varName) {
for (Object paramObj : methodDeclaration.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd
&& varName.equals(svd.getName().getIdentifier())) {
return svd;
}
}
if (methodDeclaration.getBody() == null) {
return null;
}
final ASTNode[] found = new ASTNode[1];
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragmentObj : node.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& varName.equals(fragment.getName().getIdentifier())) {
found[0] = node;
return false;
}
}
return super.visit(node);
}
});
return found[0];
}
private List<Expression> inlineGetterMethodReturns(
AccessorSummary accessor,
TypeDeclaration ownerType,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> fieldBindings,
int depth) {
MethodDeclaration getter = context.findMethodDeclaration(ownerType, accessor.methodName(), true);
if (getter == null || getter.getBody() == null) {
return List.of();
}
List<Expression> results = new ArrayList<>();
for (ReturnStatement rs : findReturnStatements(getter.getBody())) {
if (rs.getExpression() != null) {
results.addAll(getReachingDefinitions(
rs.getExpression(), visited, paramBindings, fieldBindings, depth + 1));
}
}
return results;
}
private Map<IVariableBinding, Expression> buildTypeDefaultFieldBindings(TypeDeclaration typeDeclaration) {
Map<IVariableBinding, Expression> fieldBindings = new HashMap<>();
if (typeDeclaration == null) {
return fieldBindings;
}
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
for (Object fragmentObj : fieldDeclaration.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getInitializer() != null) {
IVariableBinding binding = fragment.resolveBinding();
if (binding != null) {
fieldBindings.put(binding, fragment.getInitializer());
}
}
}
}
return fieldBindings;
}
private TypeDeclaration resolveExpressionTypeDeclaration(
Expression expr,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings) {
if (expr == null) {
return null;
}
ITypeBinding typeBinding = expr.resolveTypeBinding();
if (typeBinding == null) {
IVariableBinding variableBinding = getVariableBinding(expr);
if (variableBinding != null && variableBinding.getType() != null) {
typeBinding = variableBinding.getType();
}
}
if (typeBinding == null) {
return null;
}
String fqn = typeBinding.getErasure().getQualifiedName();
TypeDeclaration td = context.getTypeDeclaration(fqn);
if (td == null && expr.getRoot() instanceof CompilationUnit cu) {
td = context.getTypeDeclaration(fqn, cu);
}
return td;
}
private void applyFlowSensitiveMutationsBeforeUse( private void applyFlowSensitiveMutationsBeforeUse(
SimpleName receiverName, SimpleName receiverName,
MethodInvocation useSite, MethodInvocation useSite,
@@ -1118,7 +1488,7 @@ public class JdtDataFlowModel implements DataFlowModel {
if (declaringClassFqn != null && !declaringClassFqn.isEmpty()) { if (declaringClassFqn != null && !declaringClassFqn.isEmpty()) {
// Find all known implementation subclasses in the codebase // Find all known implementation subclasses in the codebase
List<String> implClassFqns = context.getImplementations(declaringClassFqn); List<String> implClassFqns = context.getImplementations(declaringClassFqn);
List<String> currentPath = CURRENT_PATH.get(); List<String> currentPath = activeAnalysisPath();
if (currentPath != null && implClassFqns != null && !implClassFqns.isEmpty()) { if (currentPath != null && implClassFqns != null && !implClassFqns.isEmpty()) {
Set<String> contextTypes = getCompatibleContextTypes(currentPath, declaringClassFqn); Set<String> contextTypes = getCompatibleContextTypes(currentPath, declaringClassFqn);
List<String> filteredImpls = new ArrayList<>(); List<String> filteredImpls = new ArrayList<>();
@@ -1176,6 +1546,10 @@ public class JdtDataFlowModel implements DataFlowModel {
return targets; return targets;
} }
private List<String> activeAnalysisPath() {
return context.getAnalysisCallPath();
}
private boolean isExpressionWrapping(Expression outer, Expression inner) { private boolean isExpressionWrapping(Expression outer, Expression inner) {
Expression current = outer; Expression current = outer;
while (current != null) { while (current != null) {
@@ -1368,6 +1742,9 @@ public class JdtDataFlowModel implements DataFlowModel {
if (cfg == null) return; if (cfg == null) return;
ControlFlowGraph.CfgNode defCfgNode = findCfgNode(cfg, defNode); ControlFlowGraph.CfgNode defCfgNode = findCfgNode(cfg, defNode);
if (defCfgNode == null && defNode instanceof SingleVariableDeclaration) {
defCfgNode = cfg.getEntryNode();
}
ControlFlowGraph.CfgNode useCfgNode = findCfgNode(cfg, useNode); ControlFlowGraph.CfgNode useCfgNode = findCfgNode(cfg, useNode);
if (defCfgNode != null && useCfgNode != null) { if (defCfgNode != null && useCfgNode != null) {
@@ -1471,6 +1848,145 @@ public class JdtDataFlowModel implements DataFlowModel {
return findMethodDeclarationInType(mb.getDeclaringClass(), mb); return findMethodDeclarationInType(mb.getDeclaringClass(), mb);
} }
private static boolean isFunctionalSamInvocation(MethodInvocation mi) {
String methodName = mi.getName().getIdentifier();
if (!isKnownSamMethodName(methodName)) {
return false;
}
if ("get".equals(methodName) && !mi.arguments().isEmpty()) {
return false;
}
Expression receiver = mi.getExpression();
if (receiver == null) {
return false;
}
ITypeBinding typeBinding = receiver.resolveTypeBinding();
if (typeBinding == null) {
return false;
}
ITypeBinding erasure = typeBinding.getErasure();
return isKnownFunctionalInterfaceName(erasure.getQualifiedName());
}
private static boolean isKnownSamMethodName(String methodName) {
return switch (methodName) {
case "run", "get", "call", "accept", "apply", "test" -> true;
default -> false;
};
}
private static boolean isKnownFunctionalInterfaceName(String fqn) {
if (fqn == null || fqn.isBlank()) {
return false;
}
String simple = fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
return switch (simple) {
case "Runnable", "Supplier", "Function", "Callable", "Consumer", "BiConsumer", "Predicate", "BiFunction" ->
true;
default -> fqn.contains("Supplier") || fqn.contains("Function") || fqn.contains("Callable");
};
}
private List<Expression> resolveFunctionalInterfaceInvocation(
MethodInvocation mi,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
IBinding binding = sn.resolveBinding();
if (binding instanceof IVariableBinding variableBinding && paramBindings.containsKey(variableBinding)) {
return resolveFunctionalBoundExpression(
paramBindings.get(variableBinding), visited, paramBindings, instanceFieldBindings, depth + 1);
}
}
List<Expression> receiverDefs = getReachingDefinitions(receiver, visited, paramBindings, instanceFieldBindings, depth + 1);
List<Expression> results = new ArrayList<>();
for (Expression receiverDef : receiverDefs) {
results.addAll(resolveFunctionalBoundExpression(
receiverDef, visited, paramBindings, instanceFieldBindings, depth + 1));
}
return results;
}
private List<Expression> resolveFunctionalBoundExpression(
Expression bound,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (bound == null) {
return List.of();
}
if (bound instanceof LambdaExpression lambda) {
ASTNode body = lambda.getBody();
if (body instanceof Expression bodyExpr) {
List<Expression> sideEffectArgs = collectVoidCallArgumentDefinitions(
bodyExpr, visited, paramBindings, instanceFieldBindings, depth);
if (!sideEffectArgs.isEmpty()) {
return sideEffectArgs;
}
return getReachingDefinitions(bodyExpr, visited, paramBindings, instanceFieldBindings, depth);
}
if (body instanceof Block block) {
List<Expression> results = new ArrayList<>();
collectSideEffectExpressions(block, results, visited, paramBindings, instanceFieldBindings, depth);
return results;
}
}
return getReachingDefinitions(bound, visited, paramBindings, instanceFieldBindings, depth);
}
private List<Expression> collectVoidCallArgumentDefinitions(
Expression bodyExpr,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (!(bodyExpr instanceof MethodInvocation mi) || !isVoidMethodInvocation(mi)) {
return List.of();
}
List<Expression> results = new ArrayList<>();
for (Object argObj : mi.arguments()) {
results.addAll(getReachingDefinitions(
(Expression) argObj, visited, paramBindings, instanceFieldBindings, depth + 1));
}
return results;
}
private static boolean isVoidMethodInvocation(MethodInvocation mi) {
IMethodBinding binding = mi.resolveMethodBinding();
return binding != null && binding.getReturnType() != null && "void".equals(binding.getReturnType().getName());
}
private void collectSideEffectExpressions(
Block body,
List<Expression> results,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> fieldBindings,
int depth) {
if (body == null) {
return;
}
body.accept(new ASTVisitor() {
@Override
public boolean visit(ExpressionStatement node) {
Expression statementExpr = node.getExpression();
if (statementExpr instanceof MethodInvocation nested) {
for (Object argObj : nested.arguments()) {
results.addAll(getReachingDefinitions(
(Expression) argObj, visited, paramBindings, fieldBindings, depth + 1));
}
}
results.addAll(getReachingDefinitions(
statementExpr, visited, paramBindings, fieldBindings, depth + 1));
return super.visit(node);
}
});
}
private List<ReturnStatement> findReturnStatements(ASTNode node) { private List<ReturnStatement> findReturnStatements(ASTNode node) {
List<ReturnStatement> returns = new ArrayList<>(); List<ReturnStatement> returns = new ArrayList<>();
node.accept(new ASTVisitor() { node.accept(new ASTVisitor() {
@@ -1785,8 +2301,8 @@ public class JdtDataFlowModel implements DataFlowModel {
} }
private Set<String> getCompatibleContextTypes(List<String> currentPath, String declaringClassFqn) { private Set<String> getCompatibleContextTypes(List<String> currentPath, String declaringClassFqn) {
String cacheKey = String.valueOf(currentPath) + "#" + declaringClassFqn; String cacheKey = "contextTypes:" + currentPath + "#" + declaringClassFqn;
return compatibleContextTypesCache.computeIfAbsent( return context.getClassCompatibilityCache().memoize(
cacheKey, ignored -> computeCompatibleContextTypes(currentPath, declaringClassFqn)); cacheKey, ignored -> computeCompatibleContextTypes(currentPath, declaringClassFqn));
} }

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);
context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory try {
.scanProjectRoot(sourceRoot, activeProfiles); context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
.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() {}
}
""");
}
}

View File

@@ -1,9 +1,14 @@
package click.kamil.springstatemachineexporter.analysis.enricher; 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.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test; 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 java.util.List;
import java.util.Map; import java.util.Map;
@@ -12,77 +17,232 @@ import static org.assertj.core.api.Assertions.assertThat;
class MachineScopeFilterEntryPointTest { class MachineScopeFilterEntryPointTest {
@Test @Test
void shouldKeepOrderEndpointsOnOrderMachineOnly() { void shouldDeriveScopedEntryPointsFromCallChainsAndKeepGenericPathVariables() {
EntryPoint orderPay = EntryPoint.builder() EntryPoint orderPay = EntryPoint.builder()
.type(EntryPoint.Type.REST) .type(EntryPoint.Type.REST)
.name("POST /api/orders/pay") .name("POST /api/machine/order/pay")
.className("click.kamil.examples.statemachine.layered.web.OrderController") .className("com.example.StateMachineController")
.methodName("pay") .methodName("payOrder")
.metadata(Map.of("path", "/api/orders/pay"))
.build(); .build();
EntryPoint documentSubmit = EntryPoint.builder() EntryPoint genericTransition = EntryPoint.builder()
.type(EntryPoint.Type.REST) .type(EntryPoint.Type.REST)
.name("POST /api/documents/submit") .name("POST /api/machine/{machineType}/transition/{event}")
.className("click.kamil.examples.statemachine.layered.web.DocumentController") .className("com.example.StateMachineController")
.methodName("submit") .methodName("transition")
.metadata(Map.of("path", "/api/documents/submit")) .metadata(Map.of("path", "/api/machine/{machineType}/transition/{event}"))
.build();
EntryPoint genericCommand = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/commands/{commandKey}")
.className("click.kamil.examples.statemachine.layered.web.GenericCommandController")
.methodName("execute")
.metadata(Map.of("path", "/api/commands/{commandKey}"))
.build(); .build();
CallChain payChain = CallChain.builder()
.entryPoint(orderPay)
.triggerPoint(TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
.eventTypeFqn("com.example.OrderEvent")
.build())
.build();
List<EntryPoint> scoped = EntryPointScopeResolver.scopeFromCallChains(
List.of(payChain),
List.of(orderPay, genericTransition));
assertThat(scoped).extracting(EntryPoint::getName)
.containsExactlyInAnyOrder(
"POST /api/machine/order/pay",
"POST /api/machine/{machineType}/transition/{event}");
}
@Test
void shouldScopeEnterpriseDedicatedPayEndpointToOrderMachine(@TempDir Path tempDir) throws Exception {
writeEnterpriseWebLayer(tempDir);
CodebaseContext context = new CodebaseContext(); CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
EntryPoint payOrder = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/machine/order/pay")
.className("com.example.StateMachineController")
.methodName("payOrder")
.metadata(Map.of("path", "/api/machine/order/pay"))
.build();
EntryPoint submitDocument = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/machine/document/submit")
.className("com.example.StateMachineController")
.methodName("submitDocument")
.metadata(Map.of("path", "/api/machine/document/submit"))
.build();
List<EntryPoint> orderScoped = MachineScopeFilter.filterEntryPointsForMachine( List<EntryPoint> orderScoped = MachineScopeFilter.filterEntryPointsForMachine(
List.of(orderPay, documentSubmit, genericCommand), List.of(payOrder, submitDocument),
"click.kamil.examples.statemachine.layered.order.config.StandardOrderStateMachineConfiguration", "com.example.OrderStateMachineConfiguration",
context); context);
assertThat(orderScoped).extracting(EntryPoint::getName) assertThat(orderScoped).extracting(EntryPoint::getName)
.contains("POST /api/orders/pay", "POST /api/commands/{commandKey}") .containsExactly("POST /api/machine/order/pay");
.doesNotContain("POST /api/documents/submit");
} }
@Test @Test
void shouldKeepDedicatedOrderEndpointOnStandardOrderMachineInMultiModuleCodebase() { void shouldExcludeDocumentDedicatedEndpointFromOrderMachine(@TempDir Path tempDir) throws Exception {
EntryPoint orderPay = EntryPoint.builder() writeEnterpriseWebLayer(tempDir);
.type(EntryPoint.Type.REST)
.name("POST /api/orders/pay")
.className("click.kamil.examples.statemachine.layered.web.OrderController")
.methodName("pay")
.metadata(Map.of("path", "/api/orders/pay"))
.build();
CodebaseContext context = new CodebaseContext(); CodebaseContext context = new CodebaseContext();
List<EntryPoint> scoped = MachineScopeFilter.filterEntryPointsForMachine( context.setResolveBindings(true);
List.of(orderPay), context.scan(tempDir);
"click.kamil.examples.statemachine.layered.order.config.StandardOrderStateMachineConfiguration",
EntryPoint submitDocument = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/machine/document/submit")
.className("com.example.StateMachineController")
.methodName("submitDocument")
.metadata(Map.of("path", "/api/machine/document/submit"))
.build();
List<EntryPoint> orderScoped = MachineScopeFilter.filterEntryPointsForMachine(
List.of(submitDocument),
"com.example.OrderStateMachineConfiguration",
context); context);
assertThat(scoped).extracting(EntryPoint::getName) assertThat(orderScoped).isEmpty();
.containsExactly("POST /api/orders/pay");
} }
@Test @Test
void shouldKeepOrdersPathOnNonDomainNamedMachineConfig() { void shouldFilterSharedDispatcherCallChainsByProvenMachineTypes(@TempDir Path tempDir) throws Exception {
EntryPoint ordersSubmit = EntryPoint.builder() writeEnterpriseWebLayer(tempDir);
.type(EntryPoint.Type.REST)
.name("POST /api/v2/orders/submit")
.className("click.kamil.examples.statemachine.inheritance.api.OrderControllerImpl")
.methodName("submitOrder")
.metadata(Map.of("path", "/api/v2/orders/submit"))
.build();
CodebaseContext context = new CodebaseContext(); CodebaseContext context = new CodebaseContext();
List<EntryPoint> scoped = MachineScopeFilter.filterEntryPointsForMachine( context.setResolveBindings(true);
List.of(ordersSubmit), context.scan(tempDir);
"click.kamil.examples.statemachine.inheritance.config.InheritanceStateMachineConfig",
CallChain payChain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.className("com.example.StateMachineDispatcher")
.methodName("payOrder")
.build())
.methodChain(List.of("com.example.StateMachineController.payOrder()"))
.build();
CallChain documentChain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("com.example.DocumentEvent.SUBMIT")
.eventTypeFqn("com.example.DocumentEvent")
.stateTypeFqn("com.example.DocumentState")
.className("com.example.StateMachineDispatcher")
.methodName("submitDocument")
.build())
.methodChain(List.of("com.example.StateMachineController.submitDocument()"))
.build();
List<CallChain> orderScoped = MachineScopeFilter.filterCallChainsForMachine(
List.of(payChain, documentChain),
"com.example.OrderStateMachineConfiguration",
context); context);
assertThat(scoped).extracting(EntryPoint::getName) assertThat(orderScoped).hasSize(1);
.containsExactly("POST /api/v2/orders/submit"); assertThat(orderScoped.get(0).getTriggerPoint().getEvent()).isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void shouldExcludeOrderAndAuditChainsFromPaymentMachineExport(@TempDir Path tempDir) throws Exception {
writeExtendedStringMachineSample(tempDir);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
CallChain orderSubmit = CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/orders/submit")
.className("com.example.OrderController")
.methodName("submitOrder")
.build())
.methodChain(List.of(
"com.example.OrderController.submitOrder",
"com.example.OrderService.processSubmit"))
.triggerPoint(TriggerPoint.builder()
.event("java.lang.String.SUBMIT_EVENT")
.eventTypeFqn("java.lang.String")
.stateTypeFqn("java.lang.String")
.className("com.example.OrderService")
.methodName("processSubmit")
.build())
.build();
CallChain paymentAuthorize = CallChain.builder()
.methodChain(List.of("com.example.PaymentService.processPayment"))
.triggerPoint(TriggerPoint.builder()
.event("java.lang.String.AUTHORIZE")
.eventTypeFqn("java.lang.String")
.stateTypeFqn("java.lang.String")
.stateMachineId("paymentStateMachine")
.className("com.example.PaymentService")
.methodName("processPayment")
.build())
.build();
List<CallChain> paymentScoped = MachineScopeFilter.filterCallChainsForMachine(
List.of(orderSubmit, paymentAuthorize),
"com.example.PaymentStateMachineConfig",
context,
List.of());
assertThat(paymentScoped).hasSize(1);
assertThat(paymentScoped.get(0).getTriggerPoint().getEvent()).isEqualTo("java.lang.String.AUTHORIZE");
}
private static void writeExtendedStringMachineSample(Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("Machines.java"), """
package com.example;
@org.springframework.statemachine.config.EnableStateMachine(name = "paymentStateMachine")
public class PaymentStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
@org.springframework.statemachine.config.EnableStateMachine(name = "extendedStateMachine")
public class ExtendedStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
class OrderController {
private final OrderService orderService = new OrderService();
public void submitOrder() { orderService.processSubmit(); }
}
class OrderService {
org.springframework.statemachine.StateMachine<String, String> stateMachine;
public void processSubmit() { stateMachine.sendEvent("SUBMIT_EVENT"); }
}
class PaymentService {
@org.springframework.beans.factory.annotation.Qualifier("paymentStateMachine")
org.springframework.statemachine.StateMachine<String, String> stateMachine;
public void processPayment() { stateMachine.sendEvent("AUTHORIZE"); }
}
""");
}
private static void writeEnterpriseWebLayer(Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("App.java"), """
package com.example;
public class StateMachineController {
private final StateMachineDispatcher dispatcher = new StateMachineDispatcher();
public void payOrder() { dispatcher.payOrder(); }
public void submitDocument() { dispatcher.submitDocument(); }
}
class StateMachineDispatcher {
void payOrder() {
org.springframework.statemachine.StateMachine<OrderState, OrderEvent> machine = null;
machine.sendEvent(OrderEvent.PAY);
}
void submitDocument() {
org.springframework.statemachine.StateMachine<DocumentState, DocumentEvent> machine = null;
machine.sendEvent(DocumentEvent.SUBMIT);
}
}
enum OrderEvent { PAY }
enum OrderState { NEW, PAID }
enum DocumentEvent { SUBMIT }
enum DocumentState { DRAFT }
class OrderStateMachineConfiguration
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<OrderState, OrderEvent> {}
class DocumentStateMachineConfiguration
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<DocumentState, DocumentEvent> {}
""");
} }
} }

View File

@@ -3,6 +3,8 @@ 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.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event; import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State; import click.kamil.springstatemachineexporter.model.State;
@@ -42,6 +44,103 @@ class TransitionLinkerEnricherTest {
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
} }
@Test
void shouldExportResolvedLinkResolutionForDedicatedLiteralEndpoint() {
Transition payT = new Transition();
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/order/pay")
.className("com.example.Api")
.methodName("pay")
.build())
.triggerPoint(TriggerPoint.builder()
.event("OrderEvent.PAY")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.external(false)
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.transitions(List.of(payT))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updated = result.getMetadata().getCallChains().get(0);
assertThat(updated.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(updated.getMatchedTransitions()).hasSize(1);
}
@Test
void shouldExportUnresolvedExternalForGenericPathVariableEndpoint() {
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/machine/{machineType}/transition/{event}")
.className("com.example.Api")
.methodName("transition")
.build())
.triggerPoint(TriggerPoint.builder()
.event("eventString.toUpperCase()")
.external(true)
.ambiguous(true)
.build())
.build();
Transition payT = new Transition();
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.transitions(List.of(payT))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updated = result.getMetadata().getCallChains().get(0);
assertThat(updated.getLinkResolution()).isEqualTo(LinkResolution.UNRESOLVED_EXTERNAL);
assertThat(updated.getMatchedTransitions()).isNullOrEmpty();
}
@Test
void shouldLinkWhenSymbolicPolymorphicEventsExpandToMachineEnumConstants() {
Transition payT = new Transition();
payT.setSourceStates(List.of(
State.of("OrderState.NEW", "com.example.order.OrderState.NEW")));
payT.setTargetStates(List.of(
State.of("OrderState.PAID", "com.example.order.OrderState.PAID")));
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("eventString")
.polymorphicEvents(List.of("com.example.order.OrderEvent.PAY"))
.constraint("\"ORDER\".equalsIgnoreCase(machineType)")
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(payT))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
}
@Test @Test
void shouldRespectDispatcherBranchConstraintForMachineDomain() { void shouldRespectDispatcherBranchConstraintForMachineDomain() {
Transition payT = new Transition(); Transition payT = new Transition();
@@ -91,11 +190,143 @@ class TransitionLinkerEnricherTest {
enricher.enrich(result, null, null); enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isTrue();
}
@Test
void shouldLinkTransitionByEventWhenSourceStateIsUniquelyDeterminedByTransitionTable() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("PAID", "PAID")));
t1.setEvent(Event.of("PAY", "PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PAY").build())
.build();
AnalysisResult result = AnalysisResult.builder()
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
assertThat(updatedChain.getMatchedTransitions().get(0).getSourceState()).isEqualTo("NEW");
assertThat(updatedChain.getMatchedTransitions().get(0).getTargetState()).isEqualTo("PAID");
assertThat(updatedChain.getTriggerPoint().getSourceState()).isEqualTo("NEW");
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isFalse();
}
@Test
void shouldLinkWhenSameEventAppearsFromMultipleSourcesWithinSameStateEnum() {
Transition payFromNew = new Transition();
payFromNew.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
payFromNew.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
payFromNew.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
Transition payFromPending = new Transition();
payFromPending.setSourceStates(List.of(State.of("PENDING", "com.example.order.OrderState.PENDING")));
payFromPending.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
payFromPending.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("com.example.order.OrderEvent.PAY"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.eventTypeFqn("com.example.order.OrderEvent")
.stateTypeFqn("com.example.order.OrderState")
.transitions(List.of(payFromNew, payFromPending))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0); CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).hasSize(2); assertThat(updatedChain.getMatchedTransitions()).hasSize(2);
assertThat(updatedChain.getMatchedTransitions()) assertThat(updatedChain.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
.extracting("sourceState") }
.containsExactlyInAnyOrder("NEW", "FAILED");
@Test
void shouldNotLinkAllTransitionsWhenPolyContainsManyEnumConstants() {
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
pay.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
pay.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setSourceStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
ship.setTargetStates(List.of(State.of("SHIPPED", "com.example.order.OrderState.SHIPPED")));
ship.setEvent(Event.of("SHIP", "com.example.order.OrderEvent.SHIP"));
Transition cancel = new Transition();
cancel.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
cancel.setTargetStates(List.of(State.of("CANCELLED", "com.example.order.OrderState.CANCELLED")));
cancel.setEvent(Event.of("CANCEL", "com.example.order.OrderEvent.CANCEL"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderEvent.SHIP",
"com.example.order.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.eventTypeFqn("com.example.order.OrderEvent")
.stateTypeFqn("com.example.order.OrderState")
.transitions(List.of(pay, ship, cancel))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
assertThat(updatedChain.getLinkResolution()).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldNotInferSourceStateWhenDifferentEnumsShareSameConstantName() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
t1.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
t1.setEvent(Event.of("PAY", "PAY"));
Transition t2 = new Transition();
t2.setSourceStates(List.of(State.of("NEW", "com.example.invoice.InvoiceState.NEW")));
t2.setTargetStates(List.of(State.of("PAID", "com.example.invoice.InvoiceState.PAID")));
t2.setEvent(Event.of("PAY", "PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PAY").build())
.build();
AnalysisResult result = AnalysisResult.builder()
.transitions(List.of(t1, t2))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getTriggerPoint().getSourceState()).isNull();
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isTrue();
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
assertThat(updatedChain.getLinkResolution()).isEqualTo(click.kamil.springstatemachineexporter.analysis.model.LinkResolution.AMBIGUOUS_WIDEN);
} }
@Test @Test
@@ -278,7 +509,7 @@ class TransitionLinkerEnricherTest {
enricher.enrich(result, null, null); enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0); CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).hasSize(1); assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
} }
@Test @Test
@@ -390,8 +621,7 @@ class TransitionLinkerEnricherTest {
.build(); .build();
enricher.enrich(resultComputer, null, null); enricher.enrich(resultComputer, null, null);
CallChain updatedChainComp = resultComputer.getMetadata().getCallChains().get(0); assertThat(resultComputer.getMetadata().getCallChains()).isEmpty();
assertThat(updatedChainComp.getMatchedTransitions()).isNullOrEmpty();
} }
@@ -492,27 +722,22 @@ class TransitionLinkerEnricherTest {
// Test Electronics SM - Should only keep Electronics Chain // Test Electronics SM - Should only keep Electronics Chain
enricher.enrich(resElec, null, null); enricher.enrich(resElec, null, null);
assertThat(resElec.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); // Elec assertThat(resElec.getMetadata().getCallChains()).hasSize(1);
assertThat(resElec.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn assertThat(resElec.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
assertThat(resElec.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc
// Test Furniture SM - Should only keep Furniture Chain // Test Furniture SM - Should only keep Furniture Chain
enricher.enrich(resFurn, null, null); enricher.enrich(resFurn, null, null);
assertThat(resFurn.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec assertThat(resFurn.getMetadata().getCallChains()).hasSize(1);
assertThat(resFurn.getMetadata().getCallChains().get(1).getMatchedTransitions()).hasSize(1); // Furn assertThat(resFurn.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
assertThat(resFurn.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc
// Test Groceries SM - Should only keep Groceries Chain // Test Groceries SM - Should only keep Groceries Chain
enricher.enrich(resGroc, null, null); enricher.enrich(resGroc, null, null);
assertThat(resGroc.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec assertThat(resGroc.getMetadata().getCallChains()).hasSize(1);
assertThat(resGroc.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn assertThat(resGroc.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
assertThat(resGroc.getMetadata().getCallChains().get(2).getMatchedTransitions()).hasSize(1); // Groc
// Test ComputerStore SM - Should reject ALL because none belong to computerstore // Test ComputerStore SM - Should reject ALL because none belong to computerstore
enricher.enrich(resComp, null, null); enricher.enrich(resComp, null, null);
assertThat(resComp.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec assertThat(resComp.getMetadata().getCallChains()).isEmpty();
assertThat(resComp.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn
assertThat(resComp.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc
} }
@Test @Test

View File

@@ -0,0 +1,72 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.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;
class TransitionLinkerMultiSourceStateTest {
@Test
void shouldLinkEachSourceStatePairWithoutCrossMultiplication() {
TriggerPoint trigger = TriggerPoint.builder()
.event("com.example.OrderEvent.CANCEL")
.sourceState("com.example.OrderState.PENDING")
.build();
CallChain chain = CallChain.builder()
.entryPoint(click.kamil.springstatemachineexporter.analysis.model.EntryPoint.builder()
.type(click.kamil.springstatemachineexporter.analysis.model.EntryPoint.Type.REST)
.name("POST /cancel")
.className("com.example.Api")
.methodName("cancel")
.build())
.triggerPoint(trigger)
.methodChain(List.of("com.example.Api.cancel", "com.example.Service.fire"))
.build();
Transition fromPending = transition(
"com.example.OrderEvent.CANCEL",
"com.example.OrderState.PENDING",
"com.example.OrderState.CANCELLED");
Transition fromPaid = transition(
"com.example.OrderEvent.CANCEL",
"com.example.OrderState.PAID",
"com.example.OrderState.CANCELLED");
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.OrderState")
.eventTypeFqn("com.example.OrderEvent")
.transitions(List.of(fromPending, fromPaid))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions())
.hasSize(1)
.first()
.satisfies(mt -> {
assertThat(mt.getSourceState()).isEqualTo("com.example.OrderState.PENDING");
assertThat(mt.getEvent()).isEqualTo("com.example.OrderEvent.CANCEL");
});
}
private static Transition transition(String event, String source, String target) {
Transition transition = new Transition();
transition.setEvent(Event.of(event, event));
transition.setSourceStates(List.of(
State.of(source, source),
State.of("com.example.OrderState.OTHER", "com.example.OrderState.OTHER")));
transition.setTargetStates(List.of(State.of(target, target)));
return transition;
}
}

View File

@@ -0,0 +1,117 @@
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.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;
/**
* Regression for enterprise multi-module scans: a globally ambiguous enum simple name must not
* block machine-scoped {@code valueOf} widens from linking when polymorphic events use import-style
* labels and the machine event type is known.
*/
class TrustedEnumWidenLinkingRegressionTest {
@Test
void shouldLinkTrustedImportStyleWidenWhenEnumSimpleNameIsGloballyAmbiguous(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
"package a; public enum OrderEvent { PAY }");
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { CANCEL }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(context.isAmbiguousSimpleName("OrderEvent")).isTrue();
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
pay.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
pay.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setSourceStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
ship.setTargetStates(List.of(State.of("SHIPPED", "com.example.order.OrderState.SHIPPED")));
ship.setEvent(Event.of("SHIP", "com.example.order.OrderEvent.SHIP"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("OrderEvent.valueOf(eventStr)")
.ambiguous(true)
.external(false)
.polymorphicEvents(List.of("OrderEvent.PAY"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.eventTypeFqn("com.example.order.OrderEvent")
.stateTypeFqn("com.example.order.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSizeGreaterThanOrEqualTo(1);
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
.containsExactly("com.example.order.OrderEvent.PAY");
}
@Test
void shouldKeepTrustedWidenCallChainWhenEnumSimpleNameIsGloballyAmbiguous(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
"package a; public enum OrderEvent { PAY }");
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { CANCEL }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(context.isAmbiguousSimpleName("OrderEvent")).isTrue();
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
pay.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
pay.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setSourceStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
ship.setTargetStates(List.of(State.of("SHIPPED", "com.example.order.OrderState.SHIPPED")));
ship.setEvent(Event.of("SHIP", "com.example.order.OrderEvent.SHIP"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("OrderEvent.valueOf(eventStr)")
.ambiguous(true)
.external(false)
.polymorphicEvents(List.of("OrderEvent.PAY"))
.build())
.build();
List<CallChain> scoped = MachineScopeFilter.filterCallChainsForMachine(
List.of(chain),
"com.example.config.OrderStateMachineConfiguration",
context,
List.of(pay, ship));
assertThat(scoped).hasSize(1);
}
}

View File

@@ -3,6 +3,7 @@ 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.model.Event; import click.kamil.springstatemachineexporter.model.Event;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.util.List; import java.util.List;
@@ -101,11 +102,22 @@ class StrictFqnMatchingEngineTest {
} }
@Test @Test
void shouldMatchWildcards() { void shouldNotMatchWildcardAgainstEnumTransitionWithoutPolymorphicEvents() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("event").build(); TriggerPoint triggerPoint = TriggerPoint.builder().event("event").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldNotMatchDynamicMethodCallWithoutResolvedPolymorphicEvents() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("event.getPayload()")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
} }
@Test @Test
@@ -119,6 +131,28 @@ class StrictFqnMatchingEngineTest {
assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
} }
@Test
void shouldMatchStringLiteralTriggerToConfiguredStringTransition() {
Event smEvent = Event.of("\"AUDIT_EVENT\"", "String.AUDIT_EVENT");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("AUDIT_EVENT")
.eventTypeFqn("java.lang.String")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchCanonicalStringLiteralTriggerToConfiguredStringTransition() {
Event smEvent = Event.of("\"AUDIT_EVENT\"", "String.AUDIT_EVENT");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("java.lang.String.AUDIT_EVENT")
.eventTypeFqn("java.lang.String")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test @Test
void shouldNotMatchStringTypeToEnumFqn() { void shouldNotMatchStringTypeToEnumFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
@@ -187,13 +221,25 @@ class StrictFqnMatchingEngineTest {
} }
@Test @Test
void shouldMatchDynamicVariableWithMatchingTypeFqn() { void shouldNotMatchDynamicVariableWithoutResolvedPolymorphicEvents() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder() TriggerPoint triggerPoint = TriggerPoint.builder()
.event("myCustomEvt") // Not "event", "e", etc., but a custom variable name .event("myCustomEvt")
.eventTypeFqn("com.example.OrderEvents") .eventTypeFqn("com.example.OrderEvents")
.build(); .build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchDynamicVariableWhenPolymorphicEventsAreResolved() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("myCustomEvt")
.eventTypeFqn("com.example.OrderEvents")
.polymorphicEvents(List.of("com.example.OrderEvents.PAY"))
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
} }
@@ -209,11 +255,22 @@ class StrictFqnMatchingEngineTest {
} }
@Test @Test
void shouldMatchDynamicVariableWithUnknownTypeFqn() { void shouldNotMatchDynamicVariableWithUnknownTypeFqnWhenSmEventIsEnum() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder() TriggerPoint triggerPoint = TriggerPoint.builder()
.event("myCustomEvt") .event("myCustomEvt")
.eventTypeFqn(null) // Unknown type, fallback to true .eventTypeFqn(null)
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchDynamicVariableWithUnknownTypeFqnForStringEvents() {
Event smEvent = Event.of("PAY", "PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("myCustomEvt")
.eventTypeFqn(null)
.build(); .build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
@@ -252,13 +309,56 @@ class StrictFqnMatchingEngineTest {
} }
@Test @Test
void shouldMatchDynamicMethodCallAsVariable() { void shouldNotMatchGetterChainWithoutPolymorphicEventsAgainstBareStringEvent() {
Event smEvent = Event.of("PAY", "PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("richEvent.getId()")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldNotMatchGetterChainWithoutPolymorphicEventsAgainstEnumEvent() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder() TriggerPoint triggerPoint = TriggerPoint.builder()
.event("event.getPayload()") // Dynamic method call .event("payload.getType()")
.eventTypeFqn("com.example.OrderEvents") .eventTypeFqn("com.example.OrderEvents")
.build(); .build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldNotMatchMethodCallWithoutPolymorphicEventsAgainstBareStringEvent() {
Event smEvent = Event.of("PAY", "PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("getType()")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchImportStyleEnumToMachineFqnWhenSimpleNamesAlign(@TempDir java.nio.file.Path tempDir) throws Exception {
java.nio.file.Files.createDirectories(tempDir.resolve("a"));
java.nio.file.Files.createDirectories(tempDir.resolve("b"));
java.nio.file.Files.writeString(tempDir.resolve("a/OrderEvent.java"),
"package a; public enum OrderEvent { PAY }");
java.nio.file.Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { PAY }");
click.kamil.springstatemachineexporter.ast.common.CodebaseContext context =
new click.kamil.springstatemachineexporter.ast.common.CodebaseContext();
context.scan(tempDir);
StrictFqnMatchingEngine engine = new StrictFqnMatchingEngine(context);
Event smEvent = Event.of("PAY", "a.OrderEvent.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvent.PAY")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
} }
} }

View File

@@ -8,6 +8,7 @@ import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -35,6 +36,65 @@ class HeuristicBeanResolutionEngineRoutingTest {
context)).isFalse(); context)).isFalse();
} }
@Test
void sharedServiceWithProvenOrderTypesShouldOnlyRouteToOrderMachine(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("SharedDispatcher.java"), """
package com.example;
public class SharedDispatcher {
void payOrder() {
org.springframework.statemachine.StateMachine<OrderState, OrderEvent> machine = null;
machine.sendEvent(OrderEvent.PAY);
}
void submitDocument() {
org.springframework.statemachine.StateMachine<DocumentState, DocumentEvent> machine = null;
machine.sendEvent(DocumentEvent.SUBMIT);
}
}
enum OrderEvent { PAY }
enum OrderState { NEW, PAID }
enum DocumentEvent { SUBMIT }
enum DocumentState { DRAFT }
class OrderStateMachineConfiguration
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<OrderState, OrderEvent> {}
class DocumentStateMachineConfiguration
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<DocumentState, DocumentEvent> {}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
CallChain orderChain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.className("com.example.SharedDispatcher")
.methodName("payOrder")
.build())
.methodChain(List.of("com.example.SharedDispatcher.payOrder()"))
.build();
CallChain documentChain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("com.example.DocumentEvent.SUBMIT")
.eventTypeFqn("com.example.DocumentEvent")
.stateTypeFqn("com.example.DocumentState")
.className("com.example.SharedDispatcher")
.methodName("submitDocument")
.build())
.methodChain(List.of("com.example.SharedDispatcher.submitDocument()"))
.build();
assertThat(engine.isRoutedToCorrectMachine(
orderChain, "com.example.OrderStateMachineConfiguration", context)).isTrue();
assertThat(engine.isRoutedToCorrectMachine(
orderChain, "com.example.DocumentStateMachineConfiguration", context)).isFalse();
assertThat(engine.isRoutedToCorrectMachine(
documentChain, "com.example.DocumentStateMachineConfiguration", context)).isTrue();
assertThat(engine.isRoutedToCorrectMachine(
documentChain, "com.example.OrderStateMachineConfiguration", context)).isFalse();
}
private static void writeTwoMachineSample(Path tempDir) throws Exception { private static void writeTwoMachineSample(Path tempDir) throws Exception {
Path orderPkg = tempDir.resolve("com/example/order"); Path orderPkg = tempDir.resolve("com/example/order");
Path invoicePkg = tempDir.resolve("com/example/invoice"); Path invoicePkg = tempDir.resolve("com/example/invoice");

View File

@@ -168,6 +168,35 @@ public class HeuristicBeanResolutionEngineTest {
"Ambiguous shared gateway should not attach to every machine"); "Ambiguous shared gateway should not attach to every machine");
} }
@Test
void shouldResolveTriggerStateMachineIdAgainstEnableStateMachineName(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("PaymentConfig.java"), """
package com.example;
@org.springframework.statemachine.config.EnableStateMachine(name = "paymentStateMachine")
public class PaymentStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
Files.writeString(tempDir.resolve("OrderConfig.java"), """
package com.example;
@org.springframework.statemachine.config.EnableStateMachine(name = "orderStateMachine")
public class OrderStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.stateMachineId("paymentStateMachine")
.build())
.methodChain(List.of("com.example.PaymentService.capture()"))
.build();
assertTrue(engine.isRoutedToCorrectMachine(chain, "com.example.PaymentStateMachineConfig", context));
assertFalse(engine.isRoutedToCorrectMachine(chain, "com.example.OrderStateMachineConfig", context));
}
@Test @Test
void shouldAcceptTriggerWhenSpringErasureDiffersFromStringMachineTypes(@TempDir Path tempDir) throws Exception { void shouldAcceptTriggerWhenSpringErasureDiffersFromStringMachineTypes(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("InheritanceStateMachineConfig.java"), """ Files.writeString(tempDir.resolve("InheritanceStateMachineConfig.java"), """

View File

@@ -0,0 +1,187 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
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 SharedServiceRoutingPolicyTest {
private final HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
@Test
void commonOrderServiceChainIsProvablySharedInfrastructure() {
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PROCESS").build())
.methodChain(List.of(
"com.example.CommonOrderController.post()",
"com.example.CommonOrderService.processOrderEvent()"))
.build();
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isTrue();
}
@Test
void electronicsVerticalChainIsNotProvablySharedInfrastructure() {
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of(
"com.example.electronics.ElectronicsOrderController.post()",
"com.example.electronics.ElectronicsOrderService.processOrderEvent()"))
.build();
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isFalse();
}
@Test
void chainWithProvenTypeFqnsIsNotProvablySharedInfrastructure() {
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.build())
.methodChain(List.of("com.example.SharedDispatcher.payOrder()"))
.build();
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isFalse();
}
@Test
void sharedInfrastructureMayMultiAttachOnSharedEventInMultiMachineContext(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("OrderConfig.java"), """
package com.example;
@org.springframework.statemachine.config.EnableStateMachine
public class OrderStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
Files.writeString(tempDir.resolve("DocumentConfig.java"), """
package com.example;
@org.springframework.statemachine.config.EnableStateMachine
public class DocumentStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PROCESS").build())
.methodChain(List.of(
"com.example.CommonOrderController.post()",
"com.example.CommonOrderService.processOrderEvent()"))
.build();
Transition transition = new Transition();
transition.setSourceStates(List.of(State.of("NEW", "NEW")));
transition.setTargetStates(List.of(State.of("PROCESSED", "PROCESSED")));
transition.setEvent(Event.of("PROCESS", "PROCESS"));
List<Transition> machineTransitions = List.of(transition);
assertThat(engine.hasProvenMachineAffinity(
chain, "com.example.OrderStateMachineConfig", context, machineTransitions)).isTrue();
assertThat(engine.hasProvenMachineAffinity(
chain, "com.example.DocumentStateMachineConfig", context, machineTransitions)).isTrue();
}
@Test
void verticalChainFailsClosedOnEventMatchInMultiMachineContext(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("ElectronicsConfig.java"), """
package com.example.electronics;
@org.springframework.statemachine.config.EnableStateMachine
public class ElectronicsStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
Files.writeString(tempDir.resolve("ComputerConfig.java"), """
package com.example.computerstore;
@org.springframework.statemachine.config.EnableStateMachine
public class ComputerStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of(
"com.example.electronics.ElectronicsOrderController.post()",
"com.example.electronics.ElectronicsOrderService.processOrderEvent()"))
.build();
Transition transition = new Transition();
transition.setSourceStates(List.of(State.of("NEW", "NEW")));
transition.setTargetStates(List.of(State.of("ASSEMBLED", "ASSEMBLED")));
transition.setEvent(Event.of("ASSEMBLE", "ASSEMBLE"));
List<Transition> machineTransitions = List.of(transition);
assertThat(engine.hasProvenMachineAffinity(
chain, "com.example.electronics.ElectronicsStateMachineConfig", context, machineTransitions))
.isTrue();
assertThat(engine.hasProvenMachineAffinity(
chain, "com.example.computerstore.ComputerStateMachineConfig", context, machineTransitions))
.isFalse();
}
@Test
void provenDistinctTypesStillRouteToSingleMachine(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("SharedDispatcher.java"), """
package com.example;
public class SharedDispatcher {
void payOrder() {
org.springframework.statemachine.StateMachine<OrderState, OrderEvent> machine = null;
machine.sendEvent(OrderEvent.PAY);
}
void submitDocument() {
org.springframework.statemachine.StateMachine<DocumentState, DocumentEvent> machine = null;
machine.sendEvent(DocumentEvent.SUBMIT);
}
}
enum OrderEvent { PAY }
enum OrderState { NEW, PAID }
enum DocumentEvent { SUBMIT }
enum DocumentState { DRAFT }
class OrderStateMachineConfiguration
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<OrderState, OrderEvent> {}
class DocumentStateMachineConfiguration
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<DocumentState, DocumentEvent> {}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
CallChain orderChain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.className("com.example.SharedDispatcher")
.methodName("payOrder")
.build())
.methodChain(List.of("com.example.SharedDispatcher.payOrder()"))
.build();
Transition payTransition = new Transition();
payTransition.setSourceStates(List.of(State.of("NEW", "NEW")));
payTransition.setTargetStates(List.of(State.of("PAID", "PAID")));
payTransition.setEvent(Event.of("PAY", "PAY"));
assertThat(engine.hasProvenMachineAffinity(
orderChain, "com.example.OrderStateMachineConfiguration", context, List.of(payTransition)))
.isTrue();
assertThat(engine.hasProvenMachineAffinity(
orderChain, "com.example.DocumentStateMachineConfiguration", context, List.of(payTransition)))
.isFalse();
}
}

View File

@@ -0,0 +1,166 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class SpringInjectionRoutingTest {
@Test
void shouldRouteByQualifierWithoutPackageNameHeuristics(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("PaymentService.java"), """
package com.example;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
@Qualifier("paymentStateMachine")
private final StateMachine<String, String> stateMachine;
public PaymentService(StateMachine<String, String> stateMachine) {
this.stateMachine = stateMachine;
}
public void capture() {
stateMachine.sendEvent("CAPTURE");
}
}
@org.springframework.context.annotation.Configuration
@org.springframework.statemachine.config.EnableStateMachine(name = "paymentStateMachine")
class PaymentStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
@org.springframework.context.annotation.Configuration
@org.springframework.statemachine.config.EnableStateMachine(name = "orderStateMachine")
class OrderStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.className("com.example.PaymentService")
.methodName("capture")
.lineNumber(12)
.event("CAPTURE")
.eventTypeFqn("java.lang.String")
.stateTypeFqn("java.lang.String")
.stateMachineId("paymentStateMachine")
.build())
.methodChain(List.of("com.example.PaymentService.capture()"))
.build();
assertThat(engine.isRoutedToCorrectMachine(
chain, "com.example.PaymentStateMachineConfig", context)).isTrue();
assertThat(engine.isRoutedToCorrectMachine(
chain, "com.example.OrderStateMachineConfig", context)).isFalse();
}
@Test
void shouldNotBorrowConstructorQualifierFromUnrelatedParameter(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("PaymentService.java"), """
package com.example;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
private final StateMachine<String, String> orderMachine;
private final StateMachine<String, String> paymentMachine;
public PaymentService(StateMachine<String, String> orderMachine,
@Qualifier("paymentStateMachine") StateMachine<String, String> paymentMachine) {
this.orderMachine = orderMachine;
this.paymentMachine = paymentMachine;
}
public void captureOnOrder() {
orderMachine.sendEvent("CAPTURE");
}
}
@org.springframework.context.annotation.Configuration
@org.springframework.statemachine.config.EnableStateMachine(name = "paymentStateMachine")
class PaymentStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
@org.springframework.context.annotation.Configuration
@org.springframework.statemachine.config.EnableStateMachine(name = "orderStateMachine")
class OrderStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.className("com.example.PaymentService")
.methodName("captureOnOrder")
.lineNumber(18)
.event("CAPTURE")
.eventTypeFqn("java.lang.String")
.stateTypeFqn("java.lang.String")
.build())
.methodChain(List.of("com.example.PaymentService.captureOnOrder()"))
.build();
// Both are String,String machines and receiver has no explicit qualifier -> should fail closed.
assertThat(engine.isRoutedToCorrectMachine(chain, "com.example.PaymentStateMachineConfig", context)).isFalse();
assertThat(engine.isRoutedToCorrectMachine(chain, "com.example.OrderStateMachineConfig", context)).isFalse();
}
@Test
void shouldFailClosedForAmbiguousStringStateMachineWithoutQualifier(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("OrderService.java"), """
package com.example;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class OrderService {
private final StateMachine<String, String> stateMachine;
public void finish() {
stateMachine.sendEvent("FINISH");
}
}
@org.springframework.context.annotation.Configuration
@org.springframework.statemachine.config.EnableStateMachine(name = "machineA")
class MachineAConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
@org.springframework.context.annotation.Configuration
@org.springframework.statemachine.config.EnableStateMachine(name = "machineB")
class MachineBConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.className("com.example.OrderService")
.methodName("finish")
.lineNumber(11)
.event("FINISH")
.eventTypeFqn("java.lang.String")
.stateTypeFqn("java.lang.String")
.build())
.methodChain(List.of("com.example.OrderService.finish()"))
.build();
assertThat(engine.isRoutedToCorrectMachine(chain, "com.example.MachineAConfig", context)).isFalse();
assertThat(engine.isRoutedToCorrectMachine(chain, "com.example.MachineBConfig", context)).isFalse();
}
}

View File

@@ -0,0 +1,46 @@
package click.kamil.springstatemachineexporter.analysis.model;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class FlowStepJsonTest {
private final com.fasterxml.jackson.databind.ObjectMapper mapper =
new com.fasterxml.jackson.databind.ObjectMapper();
@Test
void shouldDeserializeLegacyStringStep() throws Exception {
FlowStep step = mapper.readValue("\"PAY\"", FlowStep.class);
assertThat(step.getEvent()).isEqualTo("PAY");
assertThat(step.getSource()).isNull();
}
@Test
void shouldDeserializeStructuredStep() throws Exception {
FlowStep step = mapper.readValue(
"{\"source\":\"com.example.order.OrderState.PAID\",\"event\":\"com.example.order.OrderEvent.SHIP\"}",
FlowStep.class);
assertThat(step.getSource()).isEqualTo("com.example.order.OrderState.PAID");
assertThat(step.getEvent()).isEqualTo("com.example.order.OrderEvent.SHIP");
}
@Test
void shouldSerializeEventOnlyStepAsString() throws Exception {
String json = mapper.writeValueAsString(FlowStep.ofEvent("PAY"));
assertThat(json).isEqualTo("\"PAY\"");
}
@Test
void shouldSerializeStructuredStepAsObject() throws Exception {
FlowStep step = FlowStep.builder()
.source("com.example.order.OrderState.PAID")
.event("com.example.order.OrderEvent.SHIP")
.linkKey("OrderState_PAID__OrderEvent_SHIP")
.build();
String json = mapper.writeValueAsString(step);
assertThat(json).contains("\"source\"");
assertThat(json).contains("\"event\"");
assertThat(json).contains("\"linkKey\"");
}
}

View File

@@ -603,5 +603,95 @@ class InheritanceAndNestedResolutionTest {
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderEvent.SHIP"); .containsExactly("OrderEvent.SHIP");
} }
@Test
void callGraphShouldResolveGetterInsideMapLambda() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
StateMachine sm;
Payload payload;
public void handle() {
Mono.just(payload).map(p -> sm.sendEvent(p.getEvent()));
}
}
class Payload {
OrderEvent event = OrderEvent.PAY;
OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono map(Object fn) { return this; }
}
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("handle")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderEvent.PAY");
}
@Test
void callGraphShouldResolveGetterThroughNestedSwitchMap() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
StateMachine sm;
Payload payload;
public void handle() {
Mono.just(payload)
.switchMap(x -> Mono.just(x.getInner()))
.switchMap(p -> sm.sendEvent(p.getEvent()));
}
}
class Payload {
Inner inner = new Inner();
Inner getInner() { return inner; }
}
class Inner {
OrderEvent event = OrderEvent.SHIP;
OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono switchMap(Object fn) { return this; }
}
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("handle")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderEvent.SHIP");
}
} }
} }

View File

@@ -91,6 +91,8 @@ class PipelineRefactorTest {
void shouldRecognizeReactiveReceiversAndFactoryMethods() { void shouldRecognizeReactiveReceiversAndFactoryMethods() {
assertThat(LibraryUnwrapRegistry.isUnwrapReceiverType("Mono")).isTrue(); assertThat(LibraryUnwrapRegistry.isUnwrapReceiverType("Mono")).isTrue();
assertThat(LibraryUnwrapRegistry.isUnwrapMethodName("just")).isTrue(); assertThat(LibraryUnwrapRegistry.isUnwrapMethodName("just")).isTrue();
assertThat(LibraryUnwrapRegistry.isReactiveFactoryMethod("withPayload")).isTrue();
assertThat(LibraryUnwrapRegistry.isReactiveTransformMethod("flatMap")).isTrue();
assertThat(LibraryUnwrapRegistry.shouldStopUnwrap("buildPayload")).isTrue(); assertThat(LibraryUnwrapRegistry.shouldStopUnwrap("buildPayload")).isTrue();
assertThat(LibraryUnwrapRegistry.isEventSetterMethodName("eventType")).isTrue(); assertThat(LibraryUnwrapRegistry.isEventSetterMethodName("eventType")).isTrue();
} }

View File

@@ -96,4 +96,46 @@ class ReactiveExpressionSupportTest {
eventExpr, context.getConstantResolver(), context); eventExpr, context.getConstantResolver(), context);
assertThat(remapped).isEqualTo("payload.getEvent()"); assertThat(remapped).isEqualTo("payload.getEvent()");
} }
@Test
void shouldRemapLambdaParameterGetterInsideMap(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class Runner {
Payload payload;
StateMachine sm;
void run() {
Mono.just(payload).map(p -> p.getEvent());
}
}
class Payload { String getEvent() { return "X"; } }
class StateMachine { void sendEvent(String e) {} }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono map(Object fn) { return this; }
}
""";
Path file = tempDir.resolve("Runner.java");
Files.writeString(file, source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
org.eclipse.jdt.core.dom.TypeDeclaration runner = context.getTypeDeclaration("com.example.Runner");
CompilationUnit cu = (CompilationUnit) runner.getRoot();
final MethodInvocation[] getEventCall = new MethodInvocation[1];
cu.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if ("getEvent".equals(node.getName().getIdentifier()) && getEventCall[0] == null) {
getEventCall[0] = node;
}
return super.visit(node);
}
});
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetter(
getEventCall[0], context.getConstantResolver(), context);
assertThat(remapped).isEqualTo("payload.getEvent()");
}
} }

View File

@@ -0,0 +1,28 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class BooleanConstraintEvaluatorBindingTest {
@Test
void shouldEvaluateEqualsIgnoreCaseAgainstPathVariableBinding() {
String constraint = "\"ORDER\".equalsIgnoreCase(machineType)";
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, Map.of("machineType", "ORDER")))
.isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, Map.of("machineType", "DOCUMENT")))
.isFalse();
}
@Test
void shouldEvaluateDocumentBranchAgainstBinding() {
String constraint = "\"DOCUMENT\".equalsIgnoreCase(machineType)";
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, Map.of("machineType", "DOCUMENT")))
.isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, Map.of("machineType", "ORDER")))
.isFalse();
}
}

View File

@@ -29,10 +29,50 @@ class BooleanConstraintEvaluatorBindingsTest {
Map.of("commandKey", "order.pay"))).isTrue(); Map.of("commandKey", "order.pay"))).isTrue();
} }
@Test
void shouldRejectCrossPackageQualifiedEnumSwitchConstraint() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"command == a.OrderEvent.PAY",
Map.of("command", "b.OrderEvent.PAY"))).isFalse();
}
@Test
void shouldAcceptPackageQualifiedRhsAgainstImportStyleBinding() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"command == com.example.DomainCommand.ORDER_PAY",
Map.of("command", "DomainCommand.ORDER_PAY"))).isTrue();
}
@Test @Test
void shouldRejectConstraintWhenBindingIsMissing() { void shouldRejectConstraintWhenBindingIsMissing() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings( assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"command == ORDER_PAY", "command == ORDER_PAY",
Map.of("commandKey", "order.pay"))).isFalse(); Map.of("commandKey", "order.pay"))).isFalse();
} }
@Test
void shouldEvaluateEqualsIgnoreCaseWithHelperWrappedParameter() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"\"ORDER\".equalsIgnoreCase(normalizeType(machineType))",
Map.of("machineType", "ORDER"))).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"\"ORDER\".equalsIgnoreCase(normalizeType(machineType))",
Map.of("machineType", "DOCUMENT"))).isFalse();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"\"DOCUMENT\".equalsIgnoreCase(normalizeType(machineType))",
Map.of("machineType", "DOCUMENT"))).isTrue();
}
@Test
void shouldEvaluateObjectsEqualsWithHelperWrappedParameter() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"java.util.Objects.equals(\"ORDER\", normalizeType(machineType))",
Map.of("machineType", "ORDER"))).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"java.util.Objects.equals(\"ORDER\", normalizeType(machineType))",
Map.of("machineType", "DOCUMENT"))).isFalse();
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"java.util.Objects.equals(normalizeType(machineType), \"DOCUMENT\")",
Map.of("machineType", "DOCUMENT"))).isTrue();
}
} }

View File

@@ -647,8 +647,7 @@ public class ConstantResolverTest {
MethodInvocation getCall = (MethodInvocation) ((VariableDeclarationFragment) fd.fragments().get(0)).getInitializer(); MethodInvocation getCall = (MethodInvocation) ((VariableDeclarationFragment) fd.fragments().get(0)).getInitializer();
ConstantExtractor extractor = new ConstantExtractor(context, new ConstantResolver(), mi -> null); ConstantExtractor extractor = new ConstantExtractor(context, new ConstantResolver(), mi -> null);
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath( context.setAnalysisCallPath(List.of("com.example.ApiController.dispatch"));
List.of("com.example.ApiController.dispatch"));
try { try {
ConstantResolver resolver = new ConstantResolver(); ConstantResolver resolver = new ConstantResolver();
SimpleName routesName = (SimpleName) getCall.getExpression(); SimpleName routesName = (SimpleName) getCall.getExpression();
@@ -660,7 +659,7 @@ public class ConstantResolverTest {
extractor.extractConstantsFromExpression(getCall, constants); extractor.extractConstantsFromExpression(getCall, constants);
assertThat(constants).contains("OrderEvent.PAY", "OrderEvent.SHIP"); assertThat(constants).contains("OrderEvent.PAY", "OrderEvent.SHIP");
} finally { } finally {
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath(); context.clearAnalysisCallPath();
} }
} }

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