78 Commits

Author SHA1 Message Date
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
146 changed files with 19781 additions and 1471 deletions

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

@@ -0,0 +1,147 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.service.ExternalTriggerPolicy;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.List;
/**
* Central rules for transition linking outcomes derived from existing trigger evidence only.
*/
public final class CallChainLinkPolicy {
private CallChainLinkPolicy() {
}
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint trigger) {
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, null, null);
}
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(
TriggerPoint trigger,
String machineEventTypeFqn) {
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, null);
}
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(
TriggerPoint trigger,
String machineEventTypeFqn,
CodebaseContext context) {
if (trigger == null || trigger.isExternal() || !trigger.isAmbiguous()) {
return false;
}
List<String> poly = trigger.getPolymorphicEvents();
if (poly == null || poly.size() <= 1) {
return false;
}
String event = trigger.getEvent();
if (event != null && event.startsWith("ENUM_SET:")) {
return true;
}
if (event != null && event.contains(".valueOf(")) {
return MachineEnumCanonicalizer.isDynamicTriggerExpression(event)
&& !isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context);
}
return MachineEnumCanonicalizer.isDynamicTriggerExpression(event)
&& event != null;
}
/**
* True when every concrete polymorphic candidate belongs to the trigger's declared event type.
* Used to allow machine-scoped symbolic expansion for generic dispatchers while rejecting
* cross-package or unqualified widens.
*/
static boolean isTrustedEnumPolymorphicWiden(TriggerPoint trigger) {
return isTrustedEnumPolymorphicWiden(trigger, null, null);
}
static boolean isTrustedEnumPolymorphicWiden(TriggerPoint trigger, String machineEventTypeFqn) {
return isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, null);
}
static boolean isTrustedEnumPolymorphicWiden(
TriggerPoint trigger,
String machineEventTypeFqn,
CodebaseContext context) {
if (trigger == null) {
return false;
}
String eventTypeFqn = trigger.getEventTypeFqn();
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
eventTypeFqn = machineEventTypeFqn;
}
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
return false;
}
List<String> poly = trigger.getPolymorphicEvents();
if (poly == null || poly.size() <= 1) {
return false;
}
for (String pe : poly) {
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:") || !pe.contains(".")) {
return false;
}
String enumType = pe.substring(0, pe.lastIndexOf('.'));
if (!enumType.contains(".")
|| !MachineEnumCanonicalizer.enumTypesMatch(eventTypeFqn, enumType, context)) {
return false;
}
}
return true;
}
public static LinkResolution resolveLinkResolution(
TriggerPoint trigger,
List<MatchedTransition> matched,
boolean ambiguousSource) {
return resolveLinkResolution(trigger, matched, ambiguousSource, null, null);
}
public static LinkResolution resolveLinkResolution(
TriggerPoint trigger,
List<MatchedTransition> matched,
boolean ambiguousSource,
String machineEventTypeFqn) {
return resolveLinkResolution(trigger, matched, ambiguousSource, machineEventTypeFqn, null);
}
public static LinkResolution resolveLinkResolution(
TriggerPoint trigger,
List<MatchedTransition> matched,
boolean ambiguousSource,
String machineEventTypeFqn,
CodebaseContext context) {
if (trigger != null && trigger.isExternal()) {
return LinkResolution.UNRESOLVED_EXTERNAL;
}
if (ambiguousSource
|| (trigger != null && trigger.isAmbiguous())
|| shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) {
return LinkResolution.AMBIGUOUS_WIDEN;
}
if (matched != null && !matched.isEmpty()) {
return LinkResolution.RESOLVED;
}
return LinkResolution.NO_MATCH;
}
public static TriggerPoint applyRestExternalPolicy(TriggerPoint trigger, CallChain chain, CodebaseContext context) {
if (trigger == null || chain.getEntryPoint() == null) {
return trigger;
}
EntryPoint entryPoint = chain.getEntryPoint();
String entryMethod = entryPoint.getClassName() + "." + entryPoint.getMethodName();
boolean external = ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, entryMethod, trigger.getEvent(), context);
if (external == trigger.isExternal()) {
return trigger;
}
return trigger.toBuilder().external(external).build();
}
}

View File

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

@@ -6,10 +6,10 @@ 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.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
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 +23,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,12 +45,20 @@ 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<>();
for (CallChain chain : chains) { for (CallChain chain : chains) {
if (ROUTING.isRoutedToCorrectMachine(chain, machineName, context)) { if (ROUTING.hasProvenMachineAffinity(chain, machineName, context, machineTransitions)) {
filtered.add(chain); filtered.add(chain);
} }
} }
@@ -54,10 +70,9 @@ public final class MachineScopeFilter {
if (entryPoints == null || machineName == null) { if (entryPoints == null || machineName == null) {
return entryPoints == null ? List.of() : entryPoints; return entryPoints == null ? List.of() : entryPoints;
} }
String machineDomain = MachineDomainKeys.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,61 +80,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)
.methodChain(List.of(entryPoint.getClassName() + "." + entryPoint.getMethodName()))
.build();
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context);
}
private static boolean isKnownDomainScopedMachine(String machineDomain) { EntryPointScopeResolver.Affinity affinity =
return "ORDER".equals(machineDomain) || "DOCUMENT".equals(machineDomain) || "USER".equals(machineDomain); EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
} if (affinity == EntryPointScopeResolver.Affinity.FOR_MACHINE) {
return true;
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.")) { if (affinity == EntryPointScopeResolver.Affinity.AGAINST_MACHINE) {
return "DOCUMENT";
}
return null;
}
private static boolean domainsMatch(String pathDomain, String machineDomain) {
if (pathDomain == null || machineDomain == null) {
return false; return false;
} }
return pathDomain.equalsIgnoreCase(machineDomain) return false;
|| machineDomain.startsWith(pathDomain)
|| pathDomain.startsWith(machineDomain);
} }
private static boolean isTriggerForMachine(TriggerPoint trigger, String machineName, CodebaseContext context) { private static boolean isTriggerForMachine(
TriggerPoint trigger, String machineName, CodebaseContext context, List<Transition> machineTransitions) {
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,6 +5,7 @@ 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;
@@ -14,7 +15,11 @@ 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.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;
@@ -27,7 +32,6 @@ import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineType
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) {
@@ -35,6 +39,8 @@ 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 = StateMachineTypeResolver.MachineTypes machineTypes =
@@ -51,25 +57,73 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
continue; continue;
} }
tp = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints(
tp, machineTypes, context, chain.getEntryPoint());
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
tp, machineTypes, context, stateMachineTransitions, true); tp, machineTypes, context, stateMachineTransitions, true);
tp = markRestEndpointExternal(tp, chain); tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, machineTypes, context);
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) { if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
updatedChains.add(chain); updatedChains.add(chain);
continue; continue;
} }
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null; String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null;
if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null,
context)) {
updatedChains.add(chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(null)
.linkResolution(LinkResolution.AMBIGUOUS_WIDEN)
.build());
continue;
}
boolean ambiguousSource = false;
if (triggerSource == null) {
Map<String, Set<String>> sourcesByEvent = new LinkedHashMap<>();
Map<String, String> canonicalSourceBySimplified = new LinkedHashMap<>();
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)
&& isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) {
String smSourceForLink = canonicalState(smSourceState);
String smSource = simplifySourceState(smSourceForLink);
sourcesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
.add(smSource);
canonicalSourceBySimplified.putIfAbsent(smSource, smSourceForLink);
}
}
}
if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) {
ambiguousSource = true;
} else {
Set<String> allDistinctSources = new LinkedHashSet<>();
sourcesByEvent.values().forEach(allDistinctSources::addAll);
if (allDistinctSources.size() == 1) {
String inferredSimplifiedSource = allDistinctSources.iterator().next();
String inferredCanonicalSource = canonicalSourceBySimplified.get(inferredSimplifiedSource);
tp = tp.toBuilder().sourceState(inferredCanonicalSource).build();
triggerSource = inferredSimplifiedSource;
}
}
}
List<MatchedTransition> matched = new ArrayList<>(); 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()
@@ -77,7 +131,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.targetState(smSourceForLink) .targetState(smSourceForLink)
.event(smEventForLink) .event(smEventForLink)
.build(); .build();
if (isRoutedToCorrectMachine(chain, result.getName(), context) if (isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
@@ -89,7 +143,7 @@ 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)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
@@ -99,30 +153,46 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
} }
} }
} }
}
if (ambiguousSource) {
tp = tp.toBuilder().ambiguous(true).build();
}
LinkResolution linkResolution = CallChainLinkPolicy.resolveLinkResolution(
tp,
matched,
ambiguousSource,
machineTypes != null ? machineTypes.eventTypeFqn() : null,
context);
if (!matched.isEmpty()) { if (!matched.isEmpty()) {
CallChain newChain = chain.toBuilder() CallChain newChain = chain.toBuilder()
.triggerPoint(tp) .triggerPoint(tp)
.matchedTransitions(matched) .matchedTransitions(matched)
.linkResolution(linkResolution)
.build(); .build();
updatedChains.add(newChain); updatedChains.add(newChain);
} else { } else {
updatedChains.add(chain.toBuilder() updatedChains.add(chain.toBuilder()
.triggerPoint(tp) .triggerPoint(tp)
.matchedTransitions(null) .matchedTransitions(null)
.linkResolution(linkResolution)
.build()); .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();
@@ -130,35 +200,50 @@ 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) {
return routingEngine.hasProvenMachineAffinity(chain, currentMachineName, context, machineTransitions);
} }
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, MachineDomainKeys.extractMachineDomainKey(machineName)); machineConstraint, MachineDomainKeys.extractMachineDomainKey(machineName));
} }
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>(); private static String stripEventBindingClauses(String constraint) {
private static final java.util.regex.Pattern SUFFIX_PATTERN = java.util.regex.Pattern.compile("(?i)(.+)(Event|Action|Transition|Command)(s)?$"); if (constraint == null || constraint.isBlank()) {
private static final java.util.regex.Pattern FQN_PATTERN = java.util.regex.Pattern.compile("^.*\\.([A-Z0-9_]+)$"); return constraint;
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) String stripped = constraint.replaceAll("\"[^\"]+\"\\.equalsIgnoreCase\\(event\\)", "true");
simplified = FQN_PATTERN.matcher(simplified).replaceAll("$1"); stripped = stripped.replaceAll("&&\\s*&&+", "&&");
stripped = stripped.replaceAll("^\\s*&&\\s*", "");
simplifyCache.put(name, simplified); stripped = stripped.replaceAll("\\s*&&\\s*$", "");
return stripped.trim();
}
private final java.util.Map<String, String> simplifySourceCache = new java.util.HashMap<>();
private String simplifySourceState(String name) {
if (name == null) return null;
if (simplifySourceCache.containsKey(name)) return simplifySourceCache.get(name);
// For source states we must preserve enum type context to avoid collisions between
// different state enums that share a constant name (e.g. OrderState.NEW vs InvoiceState.NEW).
// Use EnumFormat.fn-like suffix (Type.CONST) when possible.
String simplified = name;
int lastDot = name.lastIndexOf('.');
if (lastDot > 0) {
int prevDot = name.lastIndexOf('.', lastDot - 1);
if (prevDot > 0) {
simplified = name.substring(prevDot + 1);
}
}
simplifySourceCache.put(name, simplified);
return simplified; return simplified;
} }
@@ -176,23 +261,4 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(); return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
} }
private TriggerPoint markRestEndpointExternal(TriggerPoint trigger, CallChain chain) {
if (trigger == null || trigger.isExternal() || chain.getEntryPoint() == null) {
return trigger;
}
EntryPoint entryPoint = chain.getEntryPoint();
if (entryPoint.getType() != EntryPoint.Type.REST) {
return trigger;
}
String endpointName = entryPoint.getName();
if (endpointName == null || !endpointName.contains("{")) {
return trigger;
}
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
!= MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
return trigger;
}
return trigger.toBuilder().external(true).build();
}
} }

View File

@@ -45,11 +45,14 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
if (chain.getTriggerPoint() == null) { if (chain.getTriggerPoint() == null) {
return chain; return chain;
} }
TriggerPoint canonical = MachineEnumCanonicalizer.canonicalizeTriggerPoint( TriggerPoint expanded = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
chain.getTriggerPoint(), machineTypes); chain.getTriggerPoint(), machineTypes, context, result.getTransitions(), true);
TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( TriggerPoint canonical = expanded.getEventTypeFqn() == null && expanded.getStateTypeFqn() == null
canonical, machineTypes, context, result.getTransitions(), true); ? MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(
return chain.toBuilder().triggerPoint(enriched).build(); expanded, machineTypes, context)
: MachineEnumCanonicalizer.canonicalizeTriggerPoint(
expanded, machineTypes, context);
return chain.toBuilder().triggerPoint(canonical).build();
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());

View File

@@ -2,11 +2,22 @@ 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.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) {
@@ -52,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 MachineEnumCanonicalizer.enumTypesMatch(triggerPoint.getEventTypeFqn(), smEnumType); return typesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
} }
} }
} }
@@ -65,6 +76,11 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
if (triggerPoint.getEventTypeFqn() != null && !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) { if (triggerPoint.getEventTypeFqn() != null && !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
return false; return false;
} }
// Getter/method-call expressions (e.g. richEvent.getId(), getType()) without resolved
// polymorphic events must not wildcard-match bare or enum transition events.
if (rawTriggerEvent.contains(".") || rawTriggerEvent.endsWith("()")) {
return false;
}
if (triggerPoint.getEventTypeFqn() != null && isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) { if (triggerPoint.getEventTypeFqn() != null && isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
return !smEventRaw.contains("."); return !smEventRaw.contains(".");
} }
@@ -83,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
@@ -97,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 MachineEnumCanonicalizer.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 MachineEnumCanonicalizer.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
@@ -113,6 +130,10 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
return !triggerEvent.contains("."); return !triggerEvent.contains(".");
} }
private boolean typesMatch(String type1, String type2) {
return MachineEnumCanonicalizer.enumTypesMatch(type1, type2, context);
}
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;
} }

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,256 @@
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;
String targetVar = chain.getContextMachineId();
if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId();
}
String simplifiedMachineName = currentMachineName != null ? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase() : "";
if (targetVar != null && !targetVar.isEmpty()) {
targetVar = targetVar.toLowerCase();
if (targetVar.endsWith("statemachine")) {
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
if (!prefix.isEmpty()) {
if (simplifiedMachineName.contains(prefix)) {
return true; // Explicit positive match
} else {
return false; // Explicit negative match
}
} }
} else if (simplifiedMachineName.contains(targetVar)) { // Provable generic types but no machine match — fail closed (no package-name fallback).
return true;
}
}
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) {
String smPackage = getPackageName(currentMachineName);
String smSimple = getSimpleClassName(currentMachineName);
String smPrefix = getFirstCamelCaseWord(smSimple);
boolean hasPositiveMatch = false;
boolean hasStrongMismatch = false;
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
String chainClass = chain.getTriggerPoint().getClassName();
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
}
for (String method : chain.getMethodChain()) {
String chainClass = getClassNameOnly(method);
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix, smSimple, chainSimple)) {
hasStrongMismatch = true;
}
}
if (hasPositiveMatch) {
return true;
}
if (hasStrongMismatch) {
return false; return false;
} }
} }
if (!isSingleStateMachine(context) && isAmbiguousSharedGenericTrigger(chain.getTriggerPoint())) {
Boolean injectionMatch = SpringInjectionRouting.matchesMachineConfig(
chain.getTriggerPoint(), currentMachineName, context);
if (injectionMatch != null) {
return injectionMatch;
}
return false;
}
Boolean packageMatch = PackageNameRoutingHeuristics.matches(chain, currentMachineName, explicitTarget);
if (packageMatch != null) {
return packageMatch;
}
return context == null || isSingleStateMachine(context); return context == null || isSingleStateMachine(context);
} }
private DistinctTypeAffinity resolveDistinctTypeAffinity(
TriggerPoint trigger, String machineName, CodebaseContext context) {
String triggerEventFqn = trigger.getEventTypeFqn();
String triggerStateFqn = trigger.getStateTypeFqn();
if (triggerEventFqn == null && triggerStateFqn == null) {
return DistinctTypeAffinity.UNKNOWN;
}
String[] machineTypes = StateMachineTypeResolver.resolve(machineName, context);
String machineStateFqn = machineTypes[0];
String machineEventFqn = machineTypes[1];
boolean matched = false;
boolean mismatched = false;
if (triggerEventFqn != null && machineEventFqn != null) {
if (typesEquivalent(triggerEventFqn, machineEventFqn)
&& !isAmbiguousSharedGenericType(triggerEventFqn)) {
matched = true;
} else if (isDistinctTypeMismatched(triggerEventFqn, machineEventFqn)) {
mismatched = true;
}
}
if (triggerStateFqn != null && machineStateFqn != null) {
if (typesEquivalent(triggerStateFqn, machineStateFqn)
&& !isAmbiguousSharedGenericType(triggerStateFqn)) {
matched = true;
} else if (isDistinctTypeMismatched(triggerStateFqn, machineStateFqn)) {
mismatched = true;
}
}
if (matched) {
return DistinctTypeAffinity.FOR_MACHINE;
}
if (mismatched) {
return DistinctTypeAffinity.AGAINST_MACHINE;
}
return DistinctTypeAffinity.UNKNOWN;
}
private boolean matchesConfiguredTransitionEvent(
TriggerPoint trigger,
List<Transition> machineTransitions,
CodebaseContext context) {
if (trigger == null || machineTransitions == null || machineTransitions.isEmpty()) {
return false;
}
EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine(context);
for (Transition transition : machineTransitions) {
if (transition.getEvent() != null && matchingEngine.matches(transition.getEvent(), trigger)) {
return true;
}
}
return false;
}
private boolean isAmbiguousSharedGenericMatch(
String triggerEventFqn,
String triggerStateFqn,
String machineStateFqn,
String machineEventFqn) {
boolean eventShared = triggerEventFqn != null
&& machineEventFqn != null
&& typesEquivalent(triggerEventFqn, machineEventFqn)
&& isAmbiguousSharedGenericType(triggerEventFqn);
boolean stateShared = triggerStateFqn != null
&& machineStateFqn != null
&& typesEquivalent(triggerStateFqn, machineStateFqn)
&& isAmbiguousSharedGenericType(triggerStateFqn);
return eventShared || stateShared;
}
private boolean isAmbiguousSharedGenericType(String typeFqn) {
return isErasedOrOpaqueType(typeFqn) || isStringType(typeFqn);
}
private boolean isDistinctTypeMismatched(String type1, String type2) {
if (isAmbiguousSharedGenericType(type1) || isAmbiguousSharedGenericType(type2)) {
return false;
}
return isTypeMismatched(type1, type2);
}
private boolean isStringType(String typeFqn) {
if (typeFqn == null) {
return false;
}
String erased = eraseGenerics(typeFqn);
return "java.lang.String".equals(erased) || "String".equals(erased);
}
private Boolean resolveNamedBeanTarget(String beanName, String currentMachineName, CodebaseContext context) {
return EnableStateMachineBeanRouting.matchesNamedBean(beanName, currentMachineName, context);
}
private boolean isAmbiguousSharedGenericTrigger(TriggerPoint trigger) {
if (trigger == null) {
return false;
}
return isAmbiguousSharedGenericType(trigger.getEventTypeFqn())
|| isAmbiguousSharedGenericType(trigger.getStateTypeFqn());
}
private int countStateMachines(CodebaseContext context) { private int countStateMachines(CodebaseContext context) {
if (context == null) { if (context == null) {
return 0; return 0;
@@ -153,160 +281,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 +290,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

@@ -7,7 +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.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;
@@ -45,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
@@ -88,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) {
@@ -135,15 +141,26 @@ public class AnalysisResult {
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();
@@ -152,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

@@ -17,4 +17,6 @@ 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;
} }

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

@@ -13,11 +13,12 @@ import org.eclipse.jdt.core.dom.ASTNode;
import java.util.Set; 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 static final Set<String> REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success");
private static final Set<String> REACTIVE_TRANSFORM_METHODS = Set.of("map", "flatMap", "switchMap", "concatMap");
private ReactiveExpressionSupport() { private ReactiveExpressionSupport() {
} }
@@ -27,7 +28,7 @@ public final class ReactiveExpressionSupport {
} }
/** /**
* Rewrites {@code p.getEvent()} inside a {@code flatMap(p -> ...)} lambda to {@code payload.getEvent()} * 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 +45,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) {
@@ -122,10 +123,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
&& REACTIVE_TRANSFORM_METHODS.contains(mi.getName().getIdentifier())) {
return mi; return mi;
} }
current = current.getParent(); current = current.getParent();
@@ -143,8 +145,8 @@ 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 (REACTIVE_TRANSFORM_METHODS.contains(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;
@@ -167,29 +169,29 @@ 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)) {
@@ -207,7 +209,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 +223,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 +236,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()) && REACTIVE_TRANSFORM_METHODS.contains(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,7 +283,7 @@ 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) {

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

@@ -95,6 +95,34 @@ public final class EnumMemberPredicateEvaluator {
return filtered; 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( private static boolean satisfiesPredicates(
String canonicalConstantFqn, String canonicalConstantFqn,
List<PredicateCall> predicates, List<PredicateCall> predicates,

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.resolver; package click.kamil.springstatemachineexporter.analysis.resolver;
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.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event; import click.kamil.springstatemachineexporter.model.Event;
@@ -29,38 +30,53 @@ public final class MachineEnumCanonicalizer {
public static void canonicalizeTransitions( public static void canonicalizeTransitions(
List<Transition> transitions, List<Transition> transitions,
StateMachineTypeResolver.MachineTypes machineTypes) { StateMachineTypeResolver.MachineTypes machineTypes) {
canonicalizeTransitions(transitions, machineTypes, null);
}
public static void canonicalizeTransitions(
List<Transition> transitions,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
if (transitions == null || machineTypes == null) { if (transitions == null || machineTypes == null) {
return; return;
} }
for (Transition transition : transitions) { for (Transition transition : transitions) {
if (transition.getEvent() != null) { if (transition.getEvent() != null) {
transition.setEvent(canonicalizeEvent(transition.getEvent(), machineTypes.eventTypeFqn())); transition.setEvent(canonicalizeEvent(transition.getEvent(), machineTypes.eventTypeFqn(), context));
} }
if (transition.getSourceStates() != null) { if (transition.getSourceStates() != null) {
transition.setSourceStates(canonicalizeStates(transition.getSourceStates(), machineTypes.stateTypeFqn())); transition.setSourceStates(canonicalizeStates(transition.getSourceStates(), machineTypes.stateTypeFqn(), context));
} }
if (transition.getTargetStates() != null) { if (transition.getTargetStates() != null) {
transition.setTargetStates(canonicalizeStates(transition.getTargetStates(), machineTypes.stateTypeFqn())); transition.setTargetStates(canonicalizeStates(transition.getTargetStates(), machineTypes.stateTypeFqn(), context));
} }
} }
} }
public static Set<String> canonicalizeStateLabels(Set<String> labels, String stateTypeFqn) { public static Set<String> canonicalizeStateLabels(Set<String> labels, String stateTypeFqn) {
return canonicalizeStateLabels(labels, stateTypeFqn, null);
}
public static Set<String> canonicalizeStateLabels(Set<String> labels, String stateTypeFqn, CodebaseContext context) {
if (labels == null || labels.isEmpty()) { if (labels == null || labels.isEmpty()) {
return labels; return labels;
} }
return labels.stream() return labels.stream()
.map(label -> canonicalizeLabel(label, stateTypeFqn)) .map(label -> canonicalizeLabel(label, stateTypeFqn, context))
.collect(Collectors.toCollection(LinkedHashSet::new)); .collect(Collectors.toCollection(LinkedHashSet::new));
} }
public static Set<State> canonicalizeStates(Set<State> states, String stateTypeFqn) { public static Set<State> canonicalizeStates(Set<State> states, String stateTypeFqn) {
return canonicalizeStates(states, stateTypeFqn, null);
}
public static Set<State> canonicalizeStates(Set<State> states, String stateTypeFqn, CodebaseContext context) {
if (states == null || states.isEmpty()) { if (states == null || states.isEmpty()) {
return states; return states;
} }
LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>(); LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>();
for (State state : states) { for (State state : states) {
State canonical = canonicalizeState(state, stateTypeFqn); State canonical = canonicalizeState(state, stateTypeFqn, context);
byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical); byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical);
} }
return new LinkedHashSet<>(byFullIdentifier.values()); return new LinkedHashSet<>(byFullIdentifier.values());
@@ -69,6 +85,13 @@ public final class MachineEnumCanonicalizer {
public static TriggerPoint canonicalizeTriggerPoint( public static TriggerPoint canonicalizeTriggerPoint(
TriggerPoint trigger, TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes) { StateMachineTypeResolver.MachineTypes machineTypes) {
return canonicalizeTriggerPoint(trigger, machineTypes, null);
}
public static TriggerPoint canonicalizeTriggerPoint(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
if (trigger == null) { if (trigger == null) {
return null; return null;
} }
@@ -81,14 +104,45 @@ public final class MachineEnumCanonicalizer {
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream() : trigger.getPolymorphicEvents().stream()
.map(event -> canonicalizeLabel(event, eventTypeFqn)) .map(event -> canonicalizeLabel(event, eventTypeFqn, context))
.collect(Collectors.toList()); .collect(Collectors.toList());
return trigger.toBuilder() return trigger.toBuilder()
.eventTypeFqn(eventTypeFqn) .eventTypeFqn(eventTypeFqn)
.stateTypeFqn(stateTypeFqn) .stateTypeFqn(stateTypeFqn)
.event(canonicalizeLabel(trigger.getEvent(), eventTypeFqn)) .event(canonicalizeLabel(trigger.getEvent(), eventTypeFqn, context))
.sourceState(canonicalizeLabel(trigger.getSourceState(), stateTypeFqn)) .sourceState(canonicalizeLabel(trigger.getSourceState(), stateTypeFqn, context))
.polymorphicEvents(polymorphicEvents)
.build();
}
/**
* Canonicalizes trigger labels for linking using machine types, but preserves the trigger's own
* {@code eventTypeFqn}/{@code stateTypeFqn} so routing evidence (e.g. shared-infrastructure
* detection) is not overwritten before machine affinity is resolved.
*/
public static TriggerPoint canonicalizeTriggerLabelsForLinking(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
if (trigger == null) {
return null;
}
String eventTypeFqnForLabels = preferFullTypeFqn(
trigger.getEventTypeFqn(),
machineTypes != null ? machineTypes.eventTypeFqn() : null);
String stateTypeFqnForLabels = preferFullTypeFqn(
trigger.getStateTypeFqn(),
machineTypes != null ? machineTypes.stateTypeFqn() : null);
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream()
.map(event -> canonicalizeLabel(event, eventTypeFqnForLabels, context))
.collect(Collectors.toList());
return trigger.toBuilder()
.event(canonicalizeLabel(trigger.getEvent(), eventTypeFqnForLabels, context))
.sourceState(canonicalizeLabel(trigger.getSourceState(), stateTypeFqnForLabels, context))
.polymorphicEvents(polymorphicEvents) .polymorphicEvents(polymorphicEvents)
.build(); .build();
} }
@@ -101,6 +155,14 @@ public final class MachineEnumCanonicalizer {
List<String> polymorphicEvents, List<String> polymorphicEvents,
String machineEventTypeFqn, String machineEventTypeFqn,
CodebaseContext context) { CodebaseContext context) {
return expandSymbolicPolymorphicEvents(polymorphicEvents, machineEventTypeFqn, context, null);
}
public static List<String> expandSymbolicPolymorphicEvents(
List<String> polymorphicEvents,
String machineEventTypeFqn,
CodebaseContext context,
List<Transition> machineTransitions) {
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) { if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return polymorphicEvents; return polymorphicEvents;
} }
@@ -108,6 +170,8 @@ public final class MachineEnumCanonicalizer {
return polymorphicEvents; return polymorphicEvents;
} }
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, machineEventTypeFqn, context);
List<String> expanded = new ArrayList<>(); List<String> expanded = new ArrayList<>();
for (String pe : polymorphicEvents) { for (String pe : polymorphicEvents) {
if (pe == null) { if (pe == null) {
@@ -115,7 +179,18 @@ public final class MachineEnumCanonicalizer {
} }
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) { if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
String symbolicType = pe.substring("<SYMBOLIC: ".length(), pe.length() - 3).trim(); String symbolicType = pe.substring("<SYMBOLIC: ".length(), pe.length() - 3).trim();
if (!enumTypesMatch(machineEventTypeFqn, symbolicType)) { if (context != null && context.isAmbiguousSimpleName(symbolicType)) {
continue;
}
if (!enumTypesMatch(machineEventTypeFqn, symbolicType, context)) {
continue;
}
if (transitionEvents.size() == 1) {
expanded.addAll(transitionEvents);
continue;
}
if (!transitionEvents.isEmpty()) {
// Multiple configured transitions: do not widen symbolic placeholders to all events.
continue; continue;
} }
if (context != null) { if (context != null) {
@@ -141,7 +216,7 @@ public final class MachineEnumCanonicalizer {
TriggerPoint trigger, TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) { CodebaseContext context) {
TriggerPoint canonical = canonicalizeTriggerPoint(trigger, machineTypes); TriggerPoint canonical = canonicalizeTriggerPoint(trigger, machineTypes, context);
if (canonical == null || machineTypes == null || machineTypes.eventTypeFqn() == null) { if (canonical == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
return canonical; return canonical;
} }
@@ -202,7 +277,7 @@ public final class MachineEnumCanonicalizer {
List<Transition> machineTransitions, List<Transition> machineTransitions,
boolean skipCanonicalization) { boolean skipCanonicalization) {
TriggerPoint expanded = skipCanonicalization TriggerPoint expanded = skipCanonicalization
? expandSymbolicOnly(trigger, machineTypes, context) ? expandSymbolicOnly(trigger, machineTypes, context, machineTransitions)
: canonicalizeAndExpandTriggerPoint(trigger, machineTypes, context); : canonicalizeAndExpandTriggerPoint(trigger, machineTypes, context);
if (expanded == null || machineTypes == null || machineTypes.eventTypeFqn() == null) { if (expanded == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
return expanded; return expanded;
@@ -210,7 +285,7 @@ public final class MachineEnumCanonicalizer {
List<String> postExpand = expanded.getPolymorphicEvents(); List<String> postExpand = expanded.getPolymorphicEvents();
if (postExpand != null && postExpand.stream().anyMatch(pe -> pe != null && pe.startsWith("<SYMBOLIC:"))) { if (postExpand != null && postExpand.stream().anyMatch(pe -> pe != null && pe.startsWith("<SYMBOLIC:"))) {
postExpand = expandSymbolicPolymorphicEvents( postExpand = expandSymbolicPolymorphicEvents(
postExpand, machineTypes.eventTypeFqn(), context); postExpand, machineTypes.eventTypeFqn(), context, machineTransitions);
postExpand = narrowExpandedPolymorphicEvents( postExpand = narrowExpandedPolymorphicEvents(
postExpand, postExpand,
expanded.getConstraint(), expanded.getConstraint(),
@@ -221,7 +296,8 @@ public final class MachineEnumCanonicalizer {
expanded = expanded.toBuilder().polymorphicEvents(postExpand).build(); expanded = expanded.toBuilder().polymorphicEvents(postExpand).build();
} }
} }
if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())) { if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())
|| (expanded.getPolymorphicEvents() != null && !expanded.getPolymorphicEvents().isEmpty())) {
List<String> narrowed = narrowPolymorphicCandidates( List<String> narrowed = narrowPolymorphicCandidates(
expanded.getPolymorphicEvents(), expanded.getPolymorphicEvents(),
expanded.getConstraint(), expanded.getConstraint(),
@@ -231,12 +307,12 @@ public final class MachineEnumCanonicalizer {
if (!narrowed.equals(expanded.getPolymorphicEvents())) { if (!narrowed.equals(expanded.getPolymorphicEvents())) {
return expanded.toBuilder() return expanded.toBuilder()
.polymorphicEvents(narrowed) .polymorphicEvents(narrowed)
.ambiguous(narrowed.size() > 1) .ambiguous(narrowed.size() > 1 && expanded.isAmbiguous())
.build(); .build();
} }
return expanded; return expanded;
} }
if (shouldInferPolymorphicEvents(expanded, machineTransitions)) { if (shouldInferPolymorphicEvents(expanded, machineTransitions, context)) {
List<String> machineEvents = inferPolymorphicCandidates( List<String> machineEvents = inferPolymorphicCandidates(
expanded.getConstraint(), expanded.getConstraint(),
machineTypes.eventTypeFqn(), machineTypes.eventTypeFqn(),
@@ -255,12 +331,13 @@ public final class MachineEnumCanonicalizer {
private static TriggerPoint expandSymbolicOnly( private static TriggerPoint expandSymbolicOnly(
TriggerPoint trigger, TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) { CodebaseContext context,
List<Transition> machineTransitions) {
if (trigger == null || machineTypes == null || machineTypes.eventTypeFqn() == null) { if (trigger == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
return trigger; return trigger;
} }
List<String> expanded = expandSymbolicPolymorphicEvents( List<String> expanded = expandSymbolicPolymorphicEvents(
trigger.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context); trigger.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context, machineTransitions);
if (java.util.Objects.equals(expanded, trigger.getPolymorphicEvents())) { if (java.util.Objects.equals(expanded, trigger.getPolymorphicEvents())) {
return trigger; return trigger;
} }
@@ -269,11 +346,15 @@ public final class MachineEnumCanonicalizer {
private static boolean shouldInferPolymorphicEvents( private static boolean shouldInferPolymorphicEvents(
TriggerPoint trigger, TriggerPoint trigger,
List<Transition> machineTransitions) { List<Transition> machineTransitions,
CodebaseContext context) {
if (trigger == null || machineTransitions == null || machineTransitions.isEmpty()) { if (trigger == null || machineTransitions == null || machineTransitions.isEmpty()) {
return false; return false;
} }
if (hasOnlySymbolicPolymorphicEvents(trigger.getPolymorphicEvents())) { if (hasOnlySymbolicPolymorphicEvents(trigger.getPolymorphicEvents())) {
if (context != null && hasAmbiguousSymbolicPolymorphicType(trigger.getPolymorphicEvents(), context)) {
return false;
}
return true; return true;
} }
List<String> polyEvents = trigger.getPolymorphicEvents(); List<String> polyEvents = trigger.getPolymorphicEvents();
@@ -283,12 +364,32 @@ public final class MachineEnumCanonicalizer {
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(trigger.getConstraint())) { if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(trigger.getConstraint())) {
return true; return true;
} }
if (trigger.isExternal() || trigger.isAmbiguous()) { if (trigger.isExternal()) {
return true; return true;
} }
if (trigger.isAmbiguous()) {
return false;
}
return classifyTriggerEvent(trigger.getEvent()) == TriggerEventKind.DYNAMIC_EXPRESSION; return classifyTriggerEvent(trigger.getEvent()) == TriggerEventKind.DYNAMIC_EXPRESSION;
} }
private static boolean hasAmbiguousSymbolicPolymorphicType(
List<String> polymorphicEvents,
CodebaseContext context) {
if (polymorphicEvents == null) {
return false;
}
for (String pe : polymorphicEvents) {
if (pe != null && pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
String symbolicType = pe.substring("<SYMBOLIC: ".length(), pe.length() - 3).trim();
if (context.isAmbiguousSimpleName(symbolicType)) {
return true;
}
}
}
return false;
}
private static boolean hasOnlySymbolicPolymorphicEvents(List<String> polymorphicEvents) { private static boolean hasOnlySymbolicPolymorphicEvents(List<String> polymorphicEvents) {
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) { if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return false; return false;
@@ -314,22 +415,19 @@ public final class MachineEnumCanonicalizer {
String eventTypeFqn, String eventTypeFqn,
List<Transition> machineTransitions, List<Transition> machineTransitions,
CodebaseContext context) { CodebaseContext context) {
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn); List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context);
if (!transitionEvents.isEmpty()) { if (transitionEvents.isEmpty()) {
return List.of();
}
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) {
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants( List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, context); transitionEvents, constraint, eventTypeFqn, context);
return filtered.isEmpty() ? transitionEvents : filtered; if (!filtered.isEmpty()) {
return filtered;
}
return List.of();
} }
List<String> enumConstants = allPackageCanonicalEnumConstants(eventTypeFqn, context); return transitionEvents.size() == 1 ? transitionEvents : List.of();
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
enumConstants, constraint, eventTypeFqn, context);
if (!filtered.isEmpty()) {
return filtered;
}
if (!transitionEvents.isEmpty()) {
return transitionEvents;
}
return List.of();
} }
private static List<String> narrowPolymorphicCandidates( private static List<String> narrowPolymorphicCandidates(
@@ -338,62 +436,96 @@ public final class MachineEnumCanonicalizer {
String eventTypeFqn, String eventTypeFqn,
List<Transition> machineTransitions, List<Transition> machineTransitions,
CodebaseContext context) { CodebaseContext context) {
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn); List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context);
List<String> result = EnumMemberPredicateEvaluator.filterEnumConstants( List<String> result = current == null ? List.of() : new ArrayList<>(current);
current, constraint, eventTypeFqn, context);
if (result.isEmpty() && !transitionEvents.isEmpty()) { if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) {
result = new ArrayList<>(transitionEvents); List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
} result, constraint, eventTypeFqn, context);
if (!looksOverBroad(result, transitionEvents, constraint)) { if (!filtered.isEmpty()) {
return result; result = filtered;
} } else if (!transitionEvents.isEmpty()) {
if (!transitionEvents.isEmpty()) { filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
List<String> intersected = new ArrayList<>(); transitionEvents, constraint, eventTypeFqn, context);
for (String event : result) { if (!filtered.isEmpty()) {
if (transitionEvents.contains(event)) { result = filtered;
intersected.add(event); } else {
result = List.of();
} }
} else {
result = List.of();
} }
if (!intersected.isEmpty()) { }
return intersected;
} if (!transitionEvents.isEmpty()) {
List<String> transitionFiltered = EnumMemberPredicateEvaluator.filterEnumConstants( result = capToConfiguredTransitionEvents(result, transitionEvents, eventTypeFqn, context);
transitionEvents, constraint, eventTypeFqn, context);
return transitionFiltered.isEmpty() ? transitionEvents : transitionFiltered;
} }
return result; return result;
} }
private static boolean looksOverBroad( /**
List<String> current, * Fail-closed ceiling: polymorphic candidates must never exceed events configured on the machine.
*/
static List<String> capToConfiguredTransitionEvents(
List<String> candidates,
List<String> transitionEvents, List<String> transitionEvents,
String constraint) { String eventTypeFqn) {
if (current == null || current.isEmpty()) { return capToConfiguredTransitionEvents(candidates, transitionEvents, eventTypeFqn, null);
return false; }
static List<String> capToConfiguredTransitionEvents(
List<String> candidates,
List<String> transitionEvents,
String eventTypeFqn,
CodebaseContext context) {
if (transitionEvents == null || transitionEvents.isEmpty()) {
return candidates == null ? List.of() : candidates;
} }
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) { if (candidates == null || candidates.isEmpty()) {
return true; return List.of();
} }
if (current.size() <= 1) { List<String> capped = new ArrayList<>();
return false; for (String candidate : candidates) {
} if (candidate == null) {
if (transitionEvents.isEmpty()) { continue;
return current.size() > 1; }
} if (transitionEvents.contains(candidate)) {
if (current.size() > transitionEvents.size()) { capped.add(candidate);
return true; continue;
} }
for (String event : current) { String candidateType = enumTypeFromRef(candidate);
if (!transitionEvents.contains(event)) { String constant = constantName(candidate);
return true; for (String transitionEvent : transitionEvents) {
if (!constantName(transitionEvent).equals(constant)) {
continue;
}
String transitionType = enumTypeFromRef(transitionEvent);
if (candidate.contains(".") && transitionEvent.contains(".")) {
if (candidateType != null && transitionType != null
&& enumTypesMatch(candidateType, transitionType, context)) {
capped.add(transitionEvent);
break;
}
} else if (eventTypeFqn != null && transitionType != null
&& enumTypesMatch(eventTypeFqn, transitionType, context)) {
capped.add(transitionEvent);
break;
}
} }
} }
return false; return capped;
} }
public static List<String> polymorphicEventsFromTransitions( public static List<String> polymorphicEventsFromTransitions(
List<Transition> machineTransitions, List<Transition> machineTransitions,
String eventTypeFqn) { String eventTypeFqn) {
return polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, null);
}
public static List<String> polymorphicEventsFromTransitions(
List<Transition> machineTransitions,
String eventTypeFqn,
CodebaseContext context) {
if (machineTransitions == null || machineTransitions.isEmpty() || eventTypeFqn == null) { if (machineTransitions == null || machineTransitions.isEmpty() || eventTypeFqn == null) {
return List.of(); return List.of();
} }
@@ -408,7 +540,7 @@ public final class MachineEnumCanonicalizer {
if (identifier == null || identifier.isBlank()) { if (identifier == null || identifier.isBlank()) {
continue; continue;
} }
String canonical = canonicalizeLabel(identifier, eventTypeFqn); String canonical = canonicalizeLabel(identifier, eventTypeFqn, context);
if (classifyTriggerEvent(canonical) == TriggerEventKind.CANONICAL_ENUM if (classifyTriggerEvent(canonical) == TriggerEventKind.CANONICAL_ENUM
&& !events.contains(canonical)) { && !events.contains(canonical)) {
events.add(canonical); events.add(canonical);
@@ -423,24 +555,11 @@ public final class MachineEnumCanonicalizer {
.anyMatch(pe -> classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM); .anyMatch(pe -> classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM);
} }
private static List<String> allPackageCanonicalEnumConstants(String eventTypeFqn, CodebaseContext context) { public static String qualifyEventIdentifier(String event, String eventTypeFqn) {
if (eventTypeFqn == null || eventTypeFqn.isBlank() || context == null) { return qualifyEventIdentifier(event, eventTypeFqn, null);
return List.of();
}
String enumType = stripGenerics(eventTypeFqn);
List<String> enumValues = context.getEnumValues(enumType);
if (enumValues == null || enumValues.isEmpty()) {
return List.of();
}
List<String> canonical = new ArrayList<>();
for (String value : enumValues) {
String constant = value.contains(".") ? value.substring(value.lastIndexOf('.') + 1) : value;
canonical.add(enumType + "." + constant);
}
return canonical;
} }
public static String qualifyEventIdentifier(String event, String eventTypeFqn) { public static String qualifyEventIdentifier(String event, String eventTypeFqn, CodebaseContext context) {
if (event == null || eventTypeFqn == null || event.isEmpty()) { if (event == null || eventTypeFqn == null || event.isEmpty()) {
return event; return event;
} }
@@ -462,8 +581,8 @@ public final class MachineEnumCanonicalizer {
if (isStringOrPrimitiveType(eventTypeFqn)) { if (isStringOrPrimitiveType(eventTypeFqn)) {
return event; return event;
} }
if (isMachineEnumReference(event, eventTypeFqn)) { if (isMachineEnumReference(event, eventTypeFqn, context)) {
return canonicalizeLabel(event, stripGenerics(eventTypeFqn)); return canonicalizeLabel(event, stripGenerics(eventTypeFqn), context);
} }
return event; return event;
} }
@@ -589,25 +708,30 @@ public final class MachineEnumCanonicalizer {
return !event.equals(denormalized); return !event.equals(denormalized);
} }
private static List<State> canonicalizeStates(List<State> states, String stateTypeFqn) { private static List<State> canonicalizeStates(List<State> states, String stateTypeFqn, CodebaseContext context) {
if (states == null) { if (states == null) {
return null; return null;
} }
LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>(); LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>();
for (State state : states) { for (State state : states) {
State canonical = canonicalizeState(state, stateTypeFqn); State canonical = canonicalizeState(state, stateTypeFqn, context);
byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical); byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical);
} }
return new ArrayList<>(byFullIdentifier.values()); return new ArrayList<>(byFullIdentifier.values());
} }
static Event canonicalizeEvent(Event event, String enumTypeFqn) { static Event canonicalizeEvent(Event event, String enumTypeFqn) {
return canonicalizeEvent(event, enumTypeFqn, null);
}
static Event canonicalizeEvent(Event event, String enumTypeFqn, CodebaseContext context) {
if (event == null || enumTypeFqn == null || enumTypeFqn.isBlank()) { if (event == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return event; return event;
} }
String canonical = canonicalizeLabel( String canonical = canonicalizeLabel(
event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(), event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(),
enumTypeFqn); enumTypeFqn,
context);
String fnRaw = toFnForm(canonical, enumTypeFqn); String fnRaw = toFnForm(canonical, enumTypeFqn);
if (fnRaw == null) { if (fnRaw == null) {
fnRaw = event.rawName(); fnRaw = event.rawName();
@@ -619,12 +743,16 @@ public final class MachineEnumCanonicalizer {
} }
static State canonicalizeState(State state, String enumTypeFqn) { static State canonicalizeState(State state, String enumTypeFqn) {
return canonicalizeState(state, enumTypeFqn, null);
}
static State canonicalizeState(State state, String enumTypeFqn, CodebaseContext context) {
if (state == null || enumTypeFqn == null || enumTypeFqn.isBlank()) { if (state == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return state; return state;
} }
String full = resolvePlaceholder( String full = resolvePlaceholder(
state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName()); state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName());
String canonical = canonicalizeLabel(full, enumTypeFqn); String canonical = canonicalizeLabel(full, enumTypeFqn, context);
String raw; String raw;
if (isStringOrPrimitiveType(enumTypeFqn) && canonical.startsWith(enumTypeFqn + ".")) { if (isStringOrPrimitiveType(enumTypeFqn) && canonical.startsWith(enumTypeFqn + ".")) {
@@ -650,6 +778,10 @@ public final class MachineEnumCanonicalizer {
} }
public static boolean isMachineEnumReference(String value, String enumTypeFqn) { public static boolean isMachineEnumReference(String value, String enumTypeFqn) {
return isMachineEnumReference(value, enumTypeFqn, null);
}
public static boolean isMachineEnumReference(String value, String enumTypeFqn, CodebaseContext context) {
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) { if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return false; return false;
} }
@@ -673,10 +805,17 @@ public final class MachineEnumCanonicalizer {
if (typePart == null) { if (typePart == null) {
return true; return true;
} }
return enumTypesMatch(enumTypeFqn, typePart); if (context != null && context.isAmbiguousSimpleName(typePart)) {
return false;
}
return enumTypesMatch(enumTypeFqn, typePart, context);
} }
public static String canonicalizeLabel(String value, String enumTypeFqn) { public static String canonicalizeLabel(String value, String enumTypeFqn) {
return canonicalizeLabel(value, enumTypeFqn, null);
}
public static String canonicalizeLabel(String value, String enumTypeFqn, CodebaseContext context) {
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) { if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return value; return value;
} }
@@ -703,11 +842,15 @@ public final class MachineEnumCanonicalizer {
String constant = constantName(stripped); String constant = constantName(stripped);
String typePart = enumTypeFromRef(stripped); String typePart = enumTypeFromRef(stripped);
if (typePart != null && enumTypesMatch(enumTypeFqn, typePart)) { if (typePart != null && context != null && context.isAmbiguousSimpleName(typePart)) {
return stripped;
}
if (typePart != null && enumTypesMatch(enumTypeFqn, typePart, context)) {
return enumTypeFqn + "." + constant; return enumTypeFqn + "." + constant;
} }
if (typePart != null && !typePart.contains(".") && !enumTypesMatch(enumTypeFqn, typePart) if (typePart != null && !typePart.contains(".") && !enumTypesMatch(enumTypeFqn, typePart, context)
&& importStyleEnumTypeMatches(typePart, enumTypeFqn) && importStyleEnumTypeMatches(typePart, enumTypeFqn)
&& constant.matches("[A-Z_][A-Z0-9_]*")) { && constant.matches("[A-Z_][A-Z0-9_]*")) {
return enumTypeFqn + "." + constant; return enumTypeFqn + "." + constant;
@@ -763,6 +906,17 @@ public final class MachineEnumCanonicalizer {
return !type1.contains(".") || !type2.contains("."); return !type1.contains(".") || !type2.contains(".");
} }
public static boolean enumTypesMatch(String type1, String type2, CodebaseContext context) {
if (context != null && type1 != null && type2 != null) {
String simple1 = simpleName(type1);
String simple2 = simpleName(type2);
if (simple1.equals(simple2) && context.isAmbiguousSimpleName(simple1)) {
return type1.equals(type2);
}
}
return enumTypesMatch(type1, type2);
}
private static String simpleName(String fqn) { private static String simpleName(String fqn) {
return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn; return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
} }
@@ -846,4 +1000,86 @@ public final class MachineEnumCanonicalizer {
} }
return simpleName(enumTypeFqn) + "." + canonicalFqn.substring(lastDot + 1); return simpleName(enumTypeFqn) + "." + canonicalFqn.substring(lastDot + 1);
} }
/**
* When path bindings prove a single {@code event} literal (e.g. {@code "PAY".equalsIgnoreCase(event)})
* and the trigger is {@code Enum.valueOf(...)}, synthesize one concrete polymorphic event for linking.
*/
public static TriggerPoint expandBoundValueOfFromConstraints(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
return expandBoundValueOfFromConstraints(trigger, machineTypes, context, null);
}
public static TriggerPoint expandBoundValueOfFromConstraints(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context,
EntryPoint entryPoint) {
if (trigger == null || trigger.getEvent() == null || machineTypes == null) {
return trigger;
}
if (!isDynamicTriggerExpression(trigger.getEvent()) || !trigger.getEvent().contains(".valueOf(")) {
return trigger;
}
String constraint = trigger.getConstraint();
if (constraint == null || constraint.isBlank()) {
return trigger;
}
List<String> boundEventLiterals = extractBoundEventLiteralsFromConstraint(constraint);
if (boundEventLiterals.size() != 1) {
return trigger;
}
String eventTypeFqn = machineTypes.eventTypeFqn();
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
return trigger;
}
String literal = boundEventLiterals.get(0).toUpperCase();
if (entryPoint != null && entryPoint.getName() != null
&& !entryPoint.getName().toUpperCase().contains("/" + literal + "/")
&& !entryPoint.getName().toUpperCase().endsWith("/" + literal)) {
return trigger;
}
String machineTypeLiteral = extractBoundParamLiteralFromConstraint(constraint, "machineType");
if (machineTypeLiteral != null && entryPoint != null && entryPoint.getName() != null
&& !entryPoint.getName().toUpperCase().contains("/" + machineTypeLiteral.toUpperCase() + "/")) {
return trigger;
}
String constantFqn = eventTypeFqn + "." + literal;
if (context != null) {
List<String> machineEnumValues = context.getEnumValues(eventTypeFqn);
if (machineEnumValues == null || machineEnumValues.stream().noneMatch(constantFqn::equals)) {
return trigger;
}
}
return trigger.toBuilder()
.polymorphicEvents(List.of(constantFqn))
.ambiguous(false)
.external(false)
.build();
}
private static String extractBoundParamLiteralFromConstraint(String constraint, String paramName) {
java.util.regex.Matcher matcher = java.util.regex.Pattern
.compile("\"([^\"]+)\"\\.equalsIgnoreCase\\(" + paramName + "\\)")
.matcher(constraint);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
private static List<String> extractBoundEventLiteralsFromConstraint(String constraint) {
List<String> literals = new ArrayList<>();
java.util.regex.Matcher matcher = java.util.regex.Pattern
.compile("\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)")
.matcher(constraint);
while (matcher.find()) {
if ("event".equals(matcher.group(2))) {
literals.add(matcher.group(1));
}
}
return literals;
}
} }

View File

@@ -117,7 +117,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
startMethods.add(startMethod); startMethods.add(startMethod);
List<Map<String, String>> bindingVariants = List<Map<String, String>> bindingVariants =
EntryPointBindingExpander.expandPathVariableBindings(ep, context, callGraph); EntryPointBindingExpander.expandEntryPointBindings(ep, context, callGraph);
if (bindingVariants.isEmpty()) { if (bindingVariants.isEmpty()) {
bindingVariants = List.of(Map.of()); bindingVariants = List.of(Map.of());
} }
@@ -132,17 +132,25 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String targetMethod = tp.getClassName() + "." + tp.getMethodName(); String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<List<String>> allPaths = new ArrayList<>(); List<List<String>> allPaths = new ArrayList<>();
for (String sMethod : startMethods) { for (String sMethod : startMethods) {
allPaths.addAll(pathFinder.findAllPaths( if (initialBindings.isEmpty()) {
sMethod, targetMethod, callGraph, new HashSet<>(), allPaths.addAll(pathFinder.findAllPaths(
pathBindingEvaluator, initialBindings)); sMethod, targetMethod, callGraph, new HashSet<>()));
} else {
allPaths.addAll(pathFinder.findAllPaths(
sMethod, targetMethod, callGraph, new HashSet<>(),
pathBindingEvaluator, initialBindings));
}
} }
Set<List<String>> uniquePaths = new LinkedHashSet<>(allPaths); Set<List<String>> uniquePaths = new LinkedHashSet<>(allPaths);
for (List<String> path : uniquePaths) { for (List<String> path : uniquePaths) {
if (!pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder, initialBindings)) { boolean compatible = initialBindings.isEmpty()
? pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder)
: pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder, initialBindings);
if (!compatible) {
continue; continue;
} }
foundAny = true; foundAny = true;
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph); TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph, initialBindings);
if (resolvedTp != null) { if (resolvedTp != null) {
String contextMachineId = pathFinder.extractContextMachineId(path, callGraph); String contextMachineId = pathFinder.extractContextMachineId(path, callGraph);
chains.add(CallChain.builder() chains.add(CallChain.builder()
@@ -164,6 +172,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
protected TriggerPoint resolveTriggerPointParameters(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> callGraph) { protected TriggerPoint resolveTriggerPointParameters(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> callGraph) {
return resolveTriggerPointParameters(tp, path, callGraph, Map.of());
}
protected TriggerPoint resolveTriggerPointParameters(
TriggerPoint tp,
List<String> path,
Map<String, List<CallEdge>> callGraph,
Map<String, String> initialBindings) {
if (path.size() < 2) { if (path.size() < 2) {
boolean isExternal = isExternalParameter(tp.getClassName() + "." + tp.getMethodName(), tp.getEvent()); boolean isExternal = isExternalParameter(tp.getClassName() + "." + tp.getMethodName(), tp.getEvent());
return tp.toBuilder().external(isExternal).constraint(tp.getConstraint()).build(); return tp.toBuilder().external(isExternal).constraint(tp.getConstraint()).build();
@@ -175,26 +191,72 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
String[] finalParamNameRef = { event }; String[] finalParamNameRef = { event };
TriggerPoint resolved = resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef); TriggerPoint resolved = resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef, initialBindings);
if (resolved == null) return null; if (resolved == null) return null;
String entryMethod = path.isEmpty() ? (tp.getClassName() + "." + tp.getMethodName()) : path.get(0); String entryMethod = path.isEmpty() ? (tp.getClassName() + "." + tp.getMethodName()) : path.get(0);
boolean isExternal = isExternalTrigger(entryMethod, finalParamNameRef[0], path, callGraph); boolean isExternal = isExternalTrigger(entryMethod, finalParamNameRef[0], path, callGraph, initialBindings);
String pathConstraint = extractPathConstraints(path, callGraph); String pathConstraint = extractPathConstraints(path, callGraph, initialBindings);
String triggerConstraint = resolved.getConstraint(); String triggerConstraint = resolved.getConstraint();
String finalConstraint = null; String finalConstraint = mergeConstraints(pathConstraint, triggerConstraint);
if (pathConstraint != null && triggerConstraint != null) {
finalConstraint = pathConstraint + " && " + triggerConstraint;
} else if (pathConstraint != null) {
finalConstraint = pathConstraint;
} else {
finalConstraint = triggerConstraint;
}
return resolved.toBuilder().external(isExternal).constraint(finalConstraint).build(); return resolved.toBuilder().external(isExternal).constraint(finalConstraint).build();
} }
protected TriggerPoint resolveTriggerPointParametersOriginal(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> callGraph, String[] finalParamNameRef) { private static String mergeConstraints(String pathConstraint, String triggerConstraint) {
if (pathConstraint != null && triggerConstraint != null) {
return pathConstraint + " && " + triggerConstraint;
}
if (pathConstraint != null) {
return pathConstraint;
}
return triggerConstraint;
}
private static Expression selectTernaryBranch(ConditionalExpression cond, Map<String, String> bindings) {
if (cond == null || bindings == null || bindings.isEmpty()) {
return null;
}
Expression condition = cond.getExpression();
if (condition instanceof PrefixExpression prefixExpression
&& prefixExpression.getOperator() == PrefixExpression.Operator.NOT
&& prefixExpression.getOperand() instanceof SimpleName negatedName) {
String bound = bindings.get(negatedName.getIdentifier());
if ("true".equals(bound)) {
return cond.getElseExpression();
}
if ("false".equals(bound)) {
return cond.getThenExpression();
}
return null;
}
if (!(condition instanceof SimpleName simpleName)) {
return null;
}
String bound = bindings.get(simpleName.getIdentifier());
if ("true".equals(bound)) {
return cond.getThenExpression();
}
if ("false".equals(bound)) {
return cond.getElseExpression();
}
return null;
}
protected TriggerPoint resolveTriggerPointParametersOriginal(
TriggerPoint tp,
List<String> path,
Map<String, List<CallEdge>> callGraph,
String[] finalParamNameRef) {
return resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef, Map.of());
}
protected TriggerPoint resolveTriggerPointParametersOriginal(
TriggerPoint tp,
List<String> path,
Map<String, List<CallEdge>> callGraph,
String[] finalParamNameRef,
Map<String, String> initialBindings) {
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath(path); click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath(path);
final boolean debug = log.isDebugEnabled(); final boolean debug = log.isDebugEnabled();
try { try {
@@ -243,7 +305,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
if (paramIndex < 0) { if (paramIndex < 0) {
Map<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i); Map<String, String> parameterValues = mergeBindings(
initialBindings,
variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i));
String tracedVar = variableTracer.traceLocalVariable(target, currentParamName, parameterValues); String tracedVar = variableTracer.traceLocalVariable(target, currentParamName, parameterValues);
if (tracedVar != null && !tracedVar.equals(currentParamName)) { if (tracedVar != null && !tracedVar.equals(currentParamName)) {
String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix, target); String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix, target);
@@ -264,21 +328,26 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (edges != null) { if (edges != null) {
for (CallEdge edge : edges) { for (CallEdge edge : edges) {
if (edge.getTargetMethod().equals(target) || pathFinder.isHeuristicMatch(edge.getTargetMethod(), target)) { if (edge.getTargetMethod().equals(target) || pathFinder.isHeuristicMatch(edge.getTargetMethod(), target)) {
String expectedType = typeResolver.getParameterType(target, paramIndex); String expectedType = typeResolver.getParameterTypeAtCallSite(caller, target, paramIndex);
if (expectedType == null) {
expectedType = typeResolver.getParameterType(target, paramIndex);
}
if (expectedType != null && paramIndex < edge.getArguments().size()) { if (expectedType != null && paramIndex < edge.getArguments().size()) {
String argValue = edge.getArguments().get(paramIndex); String argValue = edge.getArguments().get(paramIndex);
String actualType = null; if (!FunctionalInterfaceTypes.isProvablyResolvedCallSiteArgument(argValue, expectedType)) {
if (argValue.contains(".") && !argValue.contains("(")) { String actualType = null;
String prefix = argValue.substring(0, argValue.lastIndexOf('.')); if (argValue.contains(".") && !argValue.contains("(")) {
if (!prefix.equals("this") && !prefix.equals("super")) { String prefix = argValue.substring(0, argValue.lastIndexOf('.'));
actualType = prefix; if (!prefix.equals("this") && !prefix.equals("super")) {
actualType = prefix;
}
}
if (actualType == null && !argValue.contains(".")) {
actualType = variableTracer.getVariableDeclaredType(caller, argValue);
}
if (actualType != null && !typeResolver.isTypeCompatible(actualType, expectedType)) {
continue;
} }
}
if (actualType == null && !argValue.contains(".")) {
actualType = variableTracer.getVariableDeclaredType(caller, argValue);
}
if (actualType != null && !typeResolver.isTypeCompatible(actualType, expectedType)) {
continue;
} }
} }
if (paramIndex < edge.getArguments().size()) { if (paramIndex < edge.getArguments().size()) {
@@ -390,25 +459,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (debug) { if (debug) {
log.debug("Early return 2: getterEvents = {}", getterEvents); log.debug("Early return 2: getterEvents = {}", getterEvents);
} }
return TriggerPoint.builder() return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, getterEvents);
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.sourceModule(tp.getSourceModule())
.stateMachineId(tp.getStateMachineId())
.sourceState(tp.getSourceState())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(getterEvents)
.constraint(tp.getConstraint())
.stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal())
.build();
} }
} }
String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName); String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName, initialBindings);
if (tracedVar != null && !tracedVar.equals(currentParamName)) { if (tracedVar != null && !tracedVar.equals(currentParamName)) {
String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix, entryMethod); String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix, entryMethod);
tracedVar = extractedFinalTraced[0]; tracedVar = extractedFinalTraced[0];
@@ -443,6 +498,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
.stateTypeFqn(tp.getStateTypeFqn()) .stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn()) .eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal()) .external(tp.isExternal())
.ambiguous(polymorphicEvents.size() > 1)
.build(); .build();
} }
@@ -655,9 +711,16 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
polymorphicEvents.add(declaredType); polymorphicEvents.add(declaredType);
} }
} else if (exprNode instanceof ConditionalExpression cond) { } else if (exprNode instanceof ConditionalExpression cond) {
Expression selectedBranch = selectTernaryBranch(cond, initialBindings);
List<Expression> branches = new ArrayList<>(); List<Expression> branches = new ArrayList<>();
branches.add(cond.getThenExpression()); if (selectedBranch != null) {
branches.add(cond.getElseExpression()); branches.add(selectedBranch);
resolvedValue = selectedBranch.toString();
exprNode = selectedBranch;
} else {
branches.add(cond.getThenExpression());
branches.add(cond.getElseExpression());
}
for (Expression branch : branches) { for (Expression branch : branches) {
if (branch instanceof ConditionalExpression nestedCond) { if (branch instanceof ConditionalExpression nestedCond) {
// traceVariableAll collapses literal "true"/"false" conditions to a single branch; // traceVariableAll collapses literal "true"/"false" conditions to a single branch;
@@ -924,6 +987,20 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
} }
} }
if (polymorphicEvents.size() > 1) {
isAmbiguous = true;
}
if (polymorphicEvents.isEmpty()
&& context.isAmbiguousSimpleName(declaredType)
&& !tp.isExternal()
&& !isRuntimeEnumParameter(exprNode instanceof Expression expression ? expression : null)
&& !(keyedMapLookupOnInitializer[0] && pathBoundMapKeyOnInitializer[0])) {
String symbolic = "<SYMBOLIC: " + declaredType + ".*>";
if (!polymorphicEvents.contains(symbolic)) {
polymorphicEvents.add(symbolic);
}
isAmbiguous = true;
}
} }
} }
} }
@@ -971,14 +1048,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
polymorphicEvents.add(methodReturn); polymorphicEvents.add(methodReturn);
} }
} }
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
&& polymorphicEvents.stream().anyMatch(pe -> pe != null && pe.startsWith("<SYMBOLIC:"))
&& declaredType != null) {
List<String> expandedEnumValues = expandDeclaredEnumValues(declaredType);
if (!expandedEnumValues.isEmpty()) {
polymorphicEvents = new ArrayList<>(expandedEnumValues);
}
}
} }
} }
} }
@@ -993,23 +1062,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
} }
polymorphicEvents = newPolyEvents; polymorphicEvents = newPolyEvents;
polymorphicEvents.removeIf(e -> {
if (e.contains(".")) return false;
String val = e;
boolean isKnownEnumVal = false;
for (List<String> vals : context.getEnumValuesMap().values()) {
for (String v : vals) {
if (v.endsWith("." + val)) {
isKnownEnumVal = true;
break;
}
}
if (isKnownEnumVal) break;
}
if (isKnownEnumVal) return false;
return !val.equals(val.toUpperCase()) || val.length() <= 1;
});
String targetMethod = path.get(path.size() - 1); String targetMethod = path.get(path.size() - 1);
int eventParamIndex = typeResolver.getParameterIndex(targetMethod, event, true); int eventParamIndex = typeResolver.getParameterIndex(targetMethod, event, true);
@@ -1017,6 +1069,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
eventParamIndex = 0; eventParamIndex = 0;
} }
String expectedType = typeResolver.getParameterType(targetMethod, eventParamIndex); String expectedType = typeResolver.getParameterType(targetMethod, eventParamIndex);
polymorphicEvents.removeIf(e -> shouldDropBarePolymorphicCandidate(e, expectedType, context));
if (expectedType != null) { if (expectedType != null) {
final String expType = expectedType; final String expType = expectedType;
boolean isExpectedEnum = context.getEnumValues(expType) != null; boolean isExpectedEnum = context.getEnumValues(expType) != null;
@@ -1087,6 +1141,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
.stateTypeFqn(tp.getStateTypeFqn()) .stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn()) .eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal()) .external(tp.isExternal())
.ambiguous(isAmbiguous || polymorphicEvents.size() > 1)
.build(); .build();
} }
@@ -1641,6 +1696,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
* and, when possible, a {@code new ...()} instance from the getter body. * and, when possible, a {@code new ...()} instance from the getter body.
*/ */
private TriggerPoint buildTriggerPointWithPolymorphicEvents(TriggerPoint tp, String resolvedValue, List<String> polymorphicEvents) { private TriggerPoint buildTriggerPointWithPolymorphicEvents(TriggerPoint tp, String resolvedValue, List<String> polymorphicEvents) {
boolean ambiguous = tp.isAmbiguous()
|| (polymorphicEvents != null && polymorphicEvents.size() > 1);
return TriggerPoint.builder() return TriggerPoint.builder()
.event(resolvedValue) .event(resolvedValue)
.className(tp.getClassName()) .className(tp.getClassName())
@@ -1655,6 +1712,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
.stateTypeFqn(tp.getStateTypeFqn()) .stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn()) .eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal()) .external(tp.isExternal())
.ambiguous(ambiguous)
.build(); .build();
} }
@@ -2112,10 +2170,17 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
for (Object modifier : svd.modifiers()) { for (Object modifier : svd.modifiers()) {
if (modifier instanceof Annotation annotation) { if (modifier instanceof Annotation annotation) {
String typeName = annotation.getTypeName().getFullyQualifiedName(); String typeName = annotation.getTypeName().getFullyQualifiedName();
if (typeName.endsWith("PathVariable") || typeName.endsWith("RequestBody") || typeName.endsWith("RequestParam") if (typeName.endsWith("PathVariable") || typeName.endsWith("RequestParam")
|| typeName.endsWith("PathParam") || typeName.endsWith("QueryParam")) { || typeName.endsWith("PathParam") || typeName.endsWith("QueryParam")) {
return true; return true;
} }
if (typeName.endsWith("RequestBody")) {
ITypeBinding typeBinding = svd.getType().resolveBinding();
if (typeBinding != null && typeBinding.isEnum()) {
return false;
}
return true;
}
} }
} }
} }
@@ -2126,8 +2191,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return false; return false;
} }
private String extractPathConstraints(List<String> path, Map<String, List<CallEdge>> callGraph) { private String extractPathConstraints(
List<String> path, Map<String, List<CallEdge>> callGraph, Map<String, String> initialBindings) {
List<String> constraints = new ArrayList<>(); List<String> constraints = new ArrayList<>();
String bindingConstraint = constraintFromBindings(initialBindings);
if (bindingConstraint != null) {
constraints.add(bindingConstraint);
}
for (int i = 0; i < path.size() - 1; i++) { for (int i = 0; i < path.size() - 1; i++) {
String caller = path.get(i); String caller = path.get(i);
String target = path.get(i + 1); String target = path.get(i + 1);
@@ -2147,6 +2217,36 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return String.join(" && ", constraints); return String.join(" && ", constraints);
} }
private static String constraintFromBindings(Map<String, String> bindings) {
if (bindings == null || bindings.isEmpty()) {
return null;
}
List<String> parts = new ArrayList<>();
for (Map.Entry<String, String> entry : bindings.entrySet()) {
String value = entry.getValue();
if (value == null || value.isBlank() || value.contains("(")) {
continue;
}
if ("true".equals(value) || "false".equals(value)) {
continue;
}
parts.add("\"" + value + "\".equalsIgnoreCase(" + entry.getKey() + ")");
}
return parts.isEmpty() ? null : String.join(" && ", parts);
}
private static Map<String, String> mergeBindings(
Map<String, String> initialBindings, Map<String, String> tracedBindings) {
Map<String, String> merged = new HashMap<>();
if (tracedBindings != null) {
merged.putAll(tracedBindings);
}
if (initialBindings != null) {
merged.putAll(initialBindings);
}
return merged;
}
protected String resolveConstraint(MethodInvocation node, String calledMethod, String baseConstraint) { protected String resolveConstraint(MethodInvocation node, String calledMethod, String baseConstraint) {
String resolvedConstraint = baseConstraint; String resolvedConstraint = baseConstraint;
if (node.getExpression() instanceof MethodInvocation miReceiver) { if (node.getExpression() instanceof MethodInvocation miReceiver) {
@@ -2309,7 +2409,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
protected abstract String resolveCalledMethod(MethodInvocation node); protected abstract String resolveCalledMethod(MethodInvocation node);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
protected Map<String, List<CallEdge>> buildCallGraph() { public Map<String, List<CallEdge>> buildCallGraph() {
Map<String, List<CallEdge>> cached = Map<String, List<CallEdge>> cached =
(Map<String, List<CallEdge>>) context.getCache().get(callGraphCacheKey()); (Map<String, List<CallEdge>>) context.getCache().get(callGraphCacheKey());
if (cached != null) { if (cached != null) {
@@ -2403,26 +2503,16 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (tdOuter != null) { if (tdOuter != null) {
String callerFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier(); String callerFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier(); String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node); String calledMethod = InheritanceCallTargetResolver.resolveSuperMethod(
if (td != null) { context, tdOuter, methodName);
String superFqn = context.getSuperclassFqn(td); if (calledMethod != null) {
if (superFqn != null) { List<String> args = resolveArguments(node.arguments());
TypeDeclaration superTd = context.getTypeDeclaration(superFqn); String receiver = "super";
String calledMethod = null; String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils
if (superTd != null) { .findConditionConstraint(node);
calledMethod = resolveMethodInType(superTd, methodName); CallEdge edge = new CallEdge(calledMethod, args, receiver);
} edge.setConstraint(constraint);
if (calledMethod == null) { graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
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(callerFqn, k -> new ArrayList<>()).add(edge);
}
} }
} }
} }
@@ -2592,6 +2682,41 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return current; return current;
} }
private boolean shouldDropBarePolymorphicCandidate(String candidate, String expectedType, CodebaseContext context) {
if (candidate == null || candidate.contains(".")
|| candidate.startsWith("<SYMBOLIC:") || candidate.startsWith("ENUM_SET:")) {
return false;
}
if (!candidate.equals(candidate.toUpperCase()) || candidate.length() <= 1) {
return true;
}
if (expectedType != null) {
if (context.isAmbiguousSimpleName(expectedType)) {
return true;
}
List<String> enumValues = context.getEnumValues(expectedType);
if (enumValues == null || enumValues.isEmpty()) {
return false;
}
for (String enumValue : enumValues) {
if (enumValue.endsWith("." + candidate)) {
return false;
}
}
return true;
}
int matchingEnumTypes = 0;
for (List<String> enumValues : context.getEnumValuesMap().values()) {
for (String enumValue : enumValues) {
if (enumValue.endsWith("." + candidate)) {
matchingEnumTypes++;
break;
}
}
}
return matchingEnumTypes != 1;
}
private void qualifyBareEnumConstants(List<String> polymorphicEvents, String expectedType) { private void qualifyBareEnumConstants(List<String> polymorphicEvents, String expectedType) {
if (expectedType == null || polymorphicEvents == null || polymorphicEvents.isEmpty()) { if (expectedType == null || polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return; return;
@@ -2631,16 +2756,52 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
private String resolveValueOfEnumTypeName(MethodInvocation mi) { private String resolveValueOfEnumTypeName(MethodInvocation mi) {
if (mi.getExpression() instanceof SimpleName sn) { if (mi == null || !"valueOf".equals(mi.getName().getIdentifier())) {
return null;
}
org.eclipse.jdt.core.dom.IMethodBinding binding = mi.resolveMethodBinding();
if (binding != null && binding.getDeclaringClass() != null) {
String fqn = binding.getDeclaringClass().getErasure().getQualifiedName();
if (isResolvedEnumTypeFqn(fqn)) {
return fqn;
}
}
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
org.eclipse.jdt.core.dom.ITypeBinding typeBinding = sn.resolveTypeBinding();
if (typeBinding != null) {
String fqn = typeBinding.getErasure().getQualifiedName();
if (isResolvedEnumTypeFqn(fqn)) {
return fqn;
}
}
return sn.getIdentifier(); return sn.getIdentifier();
} }
if (mi.getExpression() instanceof QualifiedName qn) { if (receiver instanceof QualifiedName qn) {
org.eclipse.jdt.core.dom.IBinding nameBinding = qn.resolveBinding();
if (nameBinding instanceof org.eclipse.jdt.core.dom.ITypeBinding typeBinding) {
String fqn = typeBinding.getErasure().getQualifiedName();
if (isResolvedEnumTypeFqn(fqn)) {
return fqn;
}
}
String full = qn.getFullyQualifiedName(); String full = qn.getFullyQualifiedName();
return full.contains(".") ? full.substring(full.lastIndexOf('.') + 1) : full; if (full.contains(".")) {
String typePart = full.substring(0, full.lastIndexOf('.'));
if (isResolvedEnumTypeFqn(typePart)) {
return typePart;
}
return full.substring(full.lastIndexOf('.') + 1);
}
return full;
} }
return null; return null;
} }
private static boolean isResolvedEnumTypeFqn(String fqn) {
return fqn != null && !fqn.isBlank() && fqn.contains(".") && !fqn.startsWith("<");
}
private String resolveEnumConstantFromArgument(String argName, List<String> path, Map<String, List<CallEdge>> callGraph) { private String resolveEnumConstantFromArgument(String argName, List<String> path, Map<String, List<CallEdge>> callGraph) {
if (argName == null || path == null || path.size() < 2) { if (argName == null || path == null || path.size() < 2) {
return null; return null;
@@ -2819,21 +2980,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if ((enumValues == null || enumValues.isEmpty()) && declaredType.contains(".")) { if ((enumValues == null || enumValues.isEmpty()) && declaredType.contains(".")) {
enumValues = context.getEnumValues(declaredType.substring(declaredType.lastIndexOf('.') + 1)); enumValues = context.getEnumValues(declaredType.substring(declaredType.lastIndexOf('.') + 1));
} }
if ((enumValues == null || enumValues.isEmpty())) {
String simpleDeclared = declaredType.contains(".")
? declaredType.substring(declaredType.lastIndexOf('.') + 1)
: declaredType;
for (Map.Entry<String, List<String>> entry : context.getEnumValuesMap().entrySet()) {
String enumType = entry.getKey();
String simpleType = enumType.contains(".")
? enumType.substring(enumType.lastIndexOf('.') + 1)
: enumType;
if (simpleDeclared.equals(simpleType)) {
enumValues = entry.getValue();
break;
}
}
}
if (enumValues == null || enumValues.isEmpty()) { if (enumValues == null || enumValues.isEmpty()) {
return List.of(); return List.of();
} }
@@ -2862,11 +3008,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String entryMethod, String entryMethod,
String finalParamName, String finalParamName,
List<String> path, List<String> path,
Map<String, List<CallEdge>> callGraph) { Map<String, List<CallEdge>> callGraph,
Map<String, String> initialBindings) {
if (isExternalParameter(entryMethod, finalParamName)) { if (isExternalParameter(entryMethod, finalParamName)) {
return true; return true;
} }
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
bindings = mergeBindings(initialBindings, bindings);
for (String boundName : bindings.keySet()) { for (String boundName : bindings.keySet()) {
if (isExternalParameter(entryMethod, boundName)) { if (isExternalParameter(entryMethod, boundName)) {
return true; return true;
@@ -2953,6 +3101,21 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
private void addConstantsFromTracedExpression( private void addConstantsFromTracedExpression(
Expression expr, List<String> constants, List<String> path, Map<String, List<CallEdge>> callGraph, String scopeMethod) { Expression expr, List<String> constants, List<String> path, Map<String, List<CallEdge>> callGraph, String scopeMethod) {
if (expr instanceof MethodInvocation mi
&& "get".equals(mi.getName().getIdentifier())
&& mi.arguments().isEmpty()
&& !expressionAccessClassifier.isKeyedLookup(mi, scopeMethod)) {
List<String> callerFrameConstants = CallSiteArgumentResolver.resolveFunctionalGetFromCallerFrame(
mi, path, callGraph, scopeMethod, typeResolver, pathFinder, constantExtractor, callSiteHooks());
if (!callerFrameConstants.isEmpty()) {
for (String constant : callerFrameConstants) {
if (!constants.contains(constant)) {
constants.add(constant);
}
}
return;
}
}
List<Expression> traced = variableTracer.traceVariableAll(expr); List<Expression> traced = variableTracer.traceVariableAll(expr);
for (Expression tracedExpr : traced) { for (Expression tracedExpr : traced) {
addConstantsFromSingleExpression(tracedExpr, constants, path, callGraph, scopeMethod); addConstantsFromSingleExpression(tracedExpr, constants, path, callGraph, scopeMethod);
@@ -3376,6 +3539,21 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return name.matches("[A-Z_][A-Z0-9_]*"); return name.matches("[A-Z_][A-Z0-9_]*");
} }
private CallSiteArgumentResolver.ExpressionHooks callSiteHooks() {
return new CallSiteArgumentResolver.ExpressionHooks() {
@Override
public String resolveArgument(Expression expr) {
return AbstractCallGraphEngine.this.resolveArgument(expr);
}
@Override
public Expression parseExpression(String expr) {
ASTNode node = parseExpressionString(expr);
return node instanceof Expression e ? e : null;
}
};
}
private void trackKeyedMapLookupFlags( private void trackKeyedMapLookupFlags(
Expression expr, Expression expr,
List<String> path, List<String> path,

View File

@@ -86,16 +86,16 @@ public final class AnalysisResultFinalizer {
AnalysisResult result, AnalysisResult result,
CodebaseContext context, CodebaseContext context,
StateMachineTypeResolver.MachineTypes machineTypes) { StateMachineTypeResolver.MachineTypes machineTypes) {
MachineEnumCanonicalizer.canonicalizeTransitions(result.getTransitions(), 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();
@@ -137,7 +137,7 @@ public final class AnalysisResultFinalizer {
: MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint( : MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
chain.getTriggerPoint(), machineTypes, context); 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)
@@ -148,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

@@ -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,120 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.SimpleName;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Resolves call-site arguments across stack frames (e.g. {@code Supplier.get()} → caller lambda).
*/
public final class CallSiteArgumentResolver {
public interface ExpressionHooks {
/** Unwrap lambda / enum literal argument expressions to a stable string form. */
String resolveArgument(Expression expr);
/** Parse a stringified expression back to AST for constant extraction. */
Expression parseExpression(String expr);
}
private CallSiteArgumentResolver() {
}
/**
* When {@code supplierParam.get()} is seen inside {@code scopeMethod}, walk to the caller frame
* and extract enum/constants from the argument bound to {@code supplierParam}.
*/
public static List<String> resolveFunctionalGetFromCallerFrame(
MethodInvocation getCall,
List<String> path,
Map<String, List<CallEdge>> callGraph,
String scopeMethod,
TypeResolver typeResolver,
CallGraphPathFinder pathFinder,
ConstantExtractor constantExtractor,
ExpressionHooks hooks) {
if (path == null || path.size() < 2 || scopeMethod == null || getCall.getExpression() == null) {
return List.of();
}
if (!(getCall.getExpression() instanceof SimpleName supplierParam)) {
return List.of();
}
int scopeIndex = indexOfMethod(path, scopeMethod);
if (scopeIndex <= 0) {
return List.of();
}
String callee = path.get(scopeIndex);
String caller = path.get(scopeIndex - 1);
int paramIndex = typeResolver.getParameterIndex(callee, supplierParam.getIdentifier());
if (paramIndex < 0) {
return List.of();
}
String argValue = findCallSiteArgument(caller, callee, paramIndex, callGraph, pathFinder);
if (argValue == null) {
return List.of();
}
return extractConstantsFromCallSiteArgument(argValue, constantExtractor, hooks);
}
public static String findCallSiteArgument(
String caller,
String callee,
int paramIndex,
Map<String, List<CallEdge>> callGraph,
CallGraphPathFinder pathFinder) {
List<CallEdge> edges = callGraph.get(caller);
if (edges == null) {
return null;
}
for (CallEdge edge : edges) {
if (!edge.getTargetMethod().equals(callee) && !pathFinder.isHeuristicMatch(edge.getTargetMethod(), callee)) {
continue;
}
if (paramIndex < edge.getArguments().size()) {
return edge.getArguments().get(paramIndex);
}
}
return null;
}
public static int indexOfMethod(List<String> path, String methodFqn) {
for (int i = path.size() - 1; i >= 0; i--) {
if (methodFqn.equals(path.get(i))) {
return i;
}
}
return -1;
}
public static List<String> extractConstantsFromCallSiteArgument(
String argValue,
ConstantExtractor constantExtractor,
ExpressionHooks hooks) {
if (argValue == null || argValue.isBlank()) {
return List.of();
}
Expression parsed = hooks.parseExpression(argValue);
if (parsed == null) {
if (FunctionalInterfaceTypes.looksLikeEnumConstant(argValue)) {
return List.of(argValue);
}
return List.of();
}
String resolved = hooks.resolveArgument(parsed);
Expression resolvedExpr = hooks.parseExpression(resolved);
List<String> constants = new ArrayList<>();
if (resolvedExpr != null) {
constantExtractor.extractConstantsFromExpression(resolvedExpr, constants);
}
if (constants.isEmpty() && FunctionalInterfaceTypes.looksLikeEnumConstant(resolved)) {
constants.add(resolved);
}
return constants;
}
}

View File

@@ -0,0 +1,337 @@
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 (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,56 @@
package click.kamil.springstatemachineexporter.analysis.service;
/**
* Recognizes JDK functional interface parameters used as delayed event providers
* ({@code Supplier.get()}, etc.) in call-graph argument tracing.
*/
public final class FunctionalInterfaceTypes {
private FunctionalInterfaceTypes() {
}
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 "Supplier".equals(simple) || "Function".equals(simple) || "Callable".equals(simple);
}
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

@@ -41,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";
@@ -68,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);
@@ -88,6 +94,10 @@ public class GenericEventDetector {
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;
@@ -101,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)))
@@ -176,40 +187,78 @@ public class GenericEventDetector {
} }
} }
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) {
if (log.isDebugEnabled()) {
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) {
@@ -261,6 +310,10 @@ public class GenericEventDetector {
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;
@@ -280,6 +333,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)))
@@ -295,6 +349,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)))
@@ -333,6 +388,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;
@@ -340,6 +410,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
@@ -393,20 +708,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();
@@ -417,22 +727,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;

View File

@@ -28,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();
@@ -42,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) {

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

@@ -56,12 +56,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) {
@@ -122,10 +124,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) {

View File

@@ -79,7 +79,25 @@ public class TypeResolver {
} }
} }
return simpleName; 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) {
@@ -170,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;
@@ -208,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,6 +1,7 @@
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.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;
@@ -280,6 +281,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);
@@ -287,27 +303,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);
@@ -321,7 +343,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(" : ");
@@ -380,62 +402,79 @@ 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);
}
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);
} }
} }
return result; 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) {
Map<String, String> paramValues = new HashMap<>(); Map<String, String> paramValues = new HashMap<>();
List<CallEdge> edges = callGraph.get(caller); List<CallEdge> edges = callGraph.get(caller);
@@ -593,9 +632,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) {
@@ -623,7 +660,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;
} }

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

@@ -50,7 +50,7 @@ public final class AnalysisCanonicalFormValidator {
return violations; return violations;
} }
validateFields(result, machineTypes, violations); validateFields(result, machineTypes, violations, context);
return violations; return violations;
} }
@@ -66,7 +66,7 @@ public final class AnalysisCanonicalFormValidator {
return violations; return violations;
} }
validateFields(result, machineTypes, violations); validateFields(result, machineTypes, violations, null);
return violations; return violations;
} }
@@ -89,17 +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);
validateStateCollection(result.getStates(), machineTypes.stateTypeFqn(), "states", violations, context);
validateStateRawNameSync(result.getStates(), machineTypes.stateTypeFqn(), "states", violations); validateStateRawNameSync(result.getStates(), machineTypes.stateTypeFqn(), "states", violations);
validateStateLabels(result.getStartStates(), machineTypes.stateTypeFqn(), "startStates", violations); validateStateLabels(result.getStartStates(), machineTypes.stateTypeFqn(), "startStates", violations, context);
validateStateLabels(result.getEndStates(), machineTypes.stateTypeFqn(), "endStates", violations); 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( validateCallChains(
result.getMetadata().getCallChains(), result.getTransitions(), machineTypes, violations); result.getMetadata().getCallChains(), result.getTransitions(), machineTypes, violations, context);
} }
} }
@@ -114,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;
} }
@@ -126,7 +128,8 @@ public final class AnalysisCanonicalFormValidator {
prefix + ".event.fullIdentifier", prefix + ".event.fullIdentifier",
transition.getEvent().fullIdentifier(), transition.getEvent().fullIdentifier(),
machineTypes.eventTypeFqn(), machineTypes.eventTypeFqn(),
violations); violations,
context);
requireRawNameSync( requireRawNameSync(
prefix + ".event.rawName", prefix + ".event.rawName",
transition.getEvent().rawName(), transition.getEvent().rawName(),
@@ -135,9 +138,9 @@ public final class AnalysisCanonicalFormValidator {
violations); violations);
} }
validateStateListWithRawSync( validateStateListWithRawSync(
transition.getSourceStates(), machineTypes.stateTypeFqn(), prefix + ".sourceStates", violations); transition.getSourceStates(), machineTypes.stateTypeFqn(), prefix + ".sourceStates", violations, context);
validateStateListWithRawSync( validateStateListWithRawSync(
transition.getTargetStates(), machineTypes.stateTypeFqn(), prefix + ".targetStates", violations); transition.getTargetStates(), machineTypes.stateTypeFqn(), prefix + ".targetStates", violations, context);
} }
} }
@@ -145,13 +148,14 @@ public final class AnalysisCanonicalFormValidator {
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++) {
State state = states.get(i); State state = states.get(i);
requireCanonical(pathPrefix + "[" + i + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations); requireCanonical(pathPrefix + "[" + i + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations, context);
requireRawNameSync( requireRawNameSync(
pathPrefix + "[" + i + "].rawName", pathPrefix + "[" + i + "].rawName",
state.rawName(), state.rawName(),
@@ -183,13 +187,14 @@ public final class AnalysisCanonicalFormValidator {
Set<State> states, Set<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;
} }
int i = 0; int i = 0;
for (State state : states) { for (State state : states) {
requireCanonical(pathPrefix + "[" + i++ + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations); requireCanonical(pathPrefix + "[" + i++ + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations, context);
} }
} }
@@ -197,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;
} }
@@ -220,16 +227,16 @@ 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);
validateTriggerEventForm(prefix + ".event", trigger.getEvent(), violations); validateTriggerEventForm(prefix + ".event", trigger.getEvent(), violations);
requireCanonical(prefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations); requireCanonical(prefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations, context);
validateDynamicIdentifierForm(prefix + ".sourceState", trigger.getSourceState(), violations); 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++) {
String polyPath = prefix + ".polymorphicEvents[" + j + "]"; String polyPath = prefix + ".polymorphicEvents[" + j + "]";
String polyEvent = trigger.getPolymorphicEvents().get(j); String polyEvent = trigger.getPolymorphicEvents().get(j);
requireCanonical(polyPath, polyEvent, eventTypeFqn, violations); requireCanonical(polyPath, polyEvent, eventTypeFqn, violations, context);
validatePolymorphicEventForm(polyPath, polyEvent, violations); validatePolymorphicEventForm(polyPath, polyEvent, violations);
} }
} }
@@ -240,7 +247,8 @@ public final class AnalysisCanonicalFormValidator {
List<CallChain> callChains, List<CallChain> callChains,
List<Transition> transitions, 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;
} }
@@ -252,30 +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);
validateTriggerEventForm(triggerPrefix + ".event", trigger.getEvent(), violations); validateTriggerEventForm(triggerPrefix + ".event", trigger.getEvent(), violations);
requireCanonical(triggerPrefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations); requireCanonical(triggerPrefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations, context);
validateDynamicIdentifierForm(triggerPrefix + ".sourceState", trigger.getSourceState(), violations); 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++) {
String polyPath = triggerPrefix + ".polymorphicEvents[" + j + "]"; String polyPath = triggerPrefix + ".polymorphicEvents[" + j + "]";
String polyEvent = trigger.getPolymorphicEvents().get(j); String polyEvent = trigger.getPolymorphicEvents().get(j);
requireCanonical(polyPath, polyEvent, eventTypeFqn, violations); requireCanonical(polyPath, polyEvent, eventTypeFqn, violations, context);
validatePolymorphicEventForm(polyPath, polyEvent, violations); validatePolymorphicEventForm(polyPath, polyEvent, violations);
} }
} }
validateMatchedTransitionsWhenResolvable( validateMatchedTransitionsWhenResolvable(
prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations); prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations, context);
validateOverLinkedPolymorphicEvents( validateOverLinkedPolymorphicEvents(
prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations); 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);
} }
} }
} }
@@ -292,11 +300,12 @@ 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));
} }
@@ -347,7 +356,8 @@ public final class AnalysisCanonicalFormValidator {
TriggerPoint trigger, TriggerPoint trigger,
List<Transition> transitions, List<Transition> transitions,
String eventTypeFqn, String eventTypeFqn,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty()) { if (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty()) {
return; return;
} }
@@ -355,7 +365,7 @@ public final class AnalysisCanonicalFormValidator {
return; return;
} }
List<String> transitionEvents = List<String> transitionEvents =
MachineEnumCanonicalizer.polymorphicEventsFromTransitions(transitions, eventTypeFqn); MachineEnumCanonicalizer.polymorphicEventsFromTransitions(transitions, eventTypeFqn, context);
if (transitionEvents.isEmpty()) { if (transitionEvents.isEmpty()) {
return; return;
} }
@@ -373,7 +383,7 @@ public final class AnalysisCanonicalFormValidator {
return; return;
} }
int matchingTransitionCount = countMatchingConfiguredTransitions( int matchingTransitionCount = countMatchingConfiguredTransitions(
transitions, trigger.getPolymorphicEvents(), eventTypeFqn); transitions, trigger.getPolymorphicEvents(), eventTypeFqn, context);
if (matchingTransitionCount > 0 if (matchingTransitionCount > 0
&& chain.getMatchedTransitions() != null && chain.getMatchedTransitions() != null
&& chain.getMatchedTransitions().size() > matchingTransitionCount) { && chain.getMatchedTransitions().size() > matchingTransitionCount) {
@@ -387,7 +397,8 @@ public final class AnalysisCanonicalFormValidator {
private static int countMatchingConfiguredTransitions( private static int countMatchingConfiguredTransitions(
List<Transition> transitions, List<Transition> transitions,
List<String> polymorphicEvents, List<String> polymorphicEvents,
String eventTypeFqn) { String eventTypeFqn,
CodebaseContext context) {
if (transitions == null || transitions.isEmpty() || polymorphicEvents == null) { if (transitions == null || transitions.isEmpty() || polymorphicEvents == null) {
return 0; return 0;
} }
@@ -400,7 +411,7 @@ public final class AnalysisCanonicalFormValidator {
? transition.getEvent().fullIdentifier() ? transition.getEvent().fullIdentifier()
: transition.getEvent().rawName(); : transition.getEvent().rawName();
for (String polyEvent : polymorphicEvents) { for (String polyEvent : polymorphicEvents) {
if (eventsMatch(polyEvent, smEvent, eventTypeFqn)) { if (eventsMatch(polyEvent, smEvent, eventTypeFqn, context)) {
count++; count++;
break; break;
} }
@@ -415,21 +426,42 @@ public final class AnalysisCanonicalFormValidator {
TriggerPoint trigger, TriggerPoint trigger,
List<Transition> transitions, List<Transition> transitions,
String eventTypeFqn, String eventTypeFqn,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (LifecycleTriggerMarkers.isLifecycle(trigger.getEvent())) { if (LifecycleTriggerMarkers.isLifecycle(trigger.getEvent())) {
return; return;
} }
if (!MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) { if (isIntentionalAmbiguousFailClosed(trigger)) {
return;
}
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
!= MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
return; return;
} }
if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) { if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) {
return; return;
} }
if (!polymorphicEventsMatchAnyTransition(trigger.getPolymorphicEvents(), transitions, eventTypeFqn)) { 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; return;
} }
violations.add(new Violation( violations.add(new Violation(
@@ -438,10 +470,53 @@ public final class AnalysisCanonicalFormValidator {
"non-empty when dynamic trigger has concrete polymorphicEvents matching transitions")); "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( private static boolean polymorphicEventsMatchAnyTransition(
List<String> polymorphicEvents, List<String> polymorphicEvents,
List<Transition> transitions, List<Transition> transitions,
String eventTypeFqn) { String eventTypeFqn,
CodebaseContext context) {
if (transitions == null || transitions.isEmpty()) { if (transitions == null || transitions.isEmpty()) {
return false; return false;
} }
@@ -453,7 +528,7 @@ public final class AnalysisCanonicalFormValidator {
String smEvent = transition.getEvent().fullIdentifier() != null String smEvent = transition.getEvent().fullIdentifier() != null
? transition.getEvent().fullIdentifier() ? transition.getEvent().fullIdentifier()
: transition.getEvent().rawName(); : transition.getEvent().rawName();
if (eventsMatch(polyEvent, smEvent, eventTypeFqn)) { if (eventsMatch(polyEvent, smEvent, eventTypeFqn, context)) {
return true; return true;
} }
} }
@@ -461,7 +536,11 @@ public final class AnalysisCanonicalFormValidator {
return false; return false;
} }
private static boolean eventsMatch(String triggerEvent, String smEvent, String eventTypeFqn) { private static boolean eventsMatch(
String triggerEvent,
String smEvent,
String eventTypeFqn,
CodebaseContext context) {
if (triggerEvent == null || smEvent == null) { if (triggerEvent == null || smEvent == null) {
return false; return false;
} }
@@ -473,8 +552,8 @@ public final class AnalysisCanonicalFormValidator {
if (!triggerConst.equals(smConst)) { if (!triggerConst.equals(smConst)) {
return false; return false;
} }
if (MachineEnumCanonicalizer.isMachineEnumReference(triggerEvent, eventTypeFqn) if (MachineEnumCanonicalizer.isMachineEnumReference(triggerEvent, eventTypeFqn, context)
&& MachineEnumCanonicalizer.isMachineEnumReference(smEvent, eventTypeFqn)) { && MachineEnumCanonicalizer.isMachineEnumReference(smEvent, eventTypeFqn, context)) {
return true; return true;
} }
return false; return false;

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());
} }
} }
@@ -194,6 +194,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 +442,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 +460,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 +502,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 +527,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 +571,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 +714,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 +860,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

@@ -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) {
@@ -187,6 +185,8 @@ public class ExportService {
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");
} }
resolveProperties(result, activeProfiles, context);
StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.MachineTypes machineTypes =
click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory.resolveMachineTypes( click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory.resolveMachineTypes(
result.getName(), result.getName(),
@@ -215,7 +215,7 @@ public class ExportService {
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;
@@ -233,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 {
@@ -272,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);
@@ -287,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);
@@ -305,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);
@@ -323,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);
@@ -347,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,105 @@
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 shouldAllowTrustedMachineScopedValueOfWiden() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.eventTypeFqn("com.example.OrderEvent")
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP"))
.build();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isTrue();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isFalse();
}
@Test
void shouldAllowTrustedWidenUsingMachineEventTypeWhenTriggerTypeIsNull() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP"))
.build();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
trigger, "com.example.OrderEvent")).isTrue();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.OrderEvent")).isFalse();
}
@Test
void shouldResolveLinkWhenTrustedWidenPassesUsingMachineEventType() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(false)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP"))
.build();
assertThat(CallChainLinkPolicy.resolveLinkResolution(
trigger,
List.of(),
false,
"com.example.OrderEvent")).isEqualTo(LinkResolution.NO_MATCH);
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.OrderEvent")).isFalse();
}
@Test
void shouldNotTrustImportStylePolymorphicWidenWithoutPackage() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
.build();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isFalse();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isTrue();
}
}

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,74 @@ 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 @Test
void shouldLinkWhenSymbolicPolymorphicEventsExpandToMachineEnumConstants() { void shouldLinkWhenSymbolicPolymorphicEventsExpandToMachineEnumConstants() {
Transition payT = new Transition(); Transition payT = new Transition();
@@ -121,10 +191,64 @@ 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(2); assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
assertThat(updatedChain.getMatchedTransitions()) assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isTrue();
.extracting("sourceState") }
.containsExactlyInAnyOrder("NEW", "FAILED");
@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 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
@@ -307,7 +431,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
@@ -419,8 +543,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();
} }
@@ -521,27 +644,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

@@ -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;
@@ -130,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");
@@ -284,4 +307,58 @@ class StrictFqnMatchingEngineTest {
assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
} }
@Test
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");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("payload.getType()")
.eventTypeFqn("com.example.OrderEvents")
.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 shouldNotMatchImportStyleEnumWhenSimpleNameIsAmbiguous(@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)).isFalse();
}
} }

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

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

@@ -51,14 +51,16 @@ class EnterpriseBooleanEnumPredicateExportTest {
.toList(); .toList();
assertThat(callChains).isNotEmpty(); assertThat(callChains).isNotEmpty();
JsonNode dynamicChain = callChains.stream() List<JsonNode> dispatchChains = callChains.stream()
.filter(chain -> chain.get("methodChain").toString().contains("OrderDispatcher.dispatch")) .filter(chain -> chain.get("methodChain").toString().contains("OrderDispatcher.dispatch"))
.findFirst() .toList();
.orElseThrow(() -> new AssertionError("missing OrderDispatcher.dispatch chain: " + callChains)); assertThat(dispatchChains).isNotEmpty();
JsonNode trigger = dynamicChain.get("triggerPoint"); List<String> polyEvents = dispatchChains.stream()
List<String> polyEvents = StreamSupport.stream(trigger.get("polymorphicEvents").spliterator(), false) .flatMap(chain -> StreamSupport.stream(
chain.get("triggerPoint").get("polymorphicEvents").spliterator(), false))
.map(JsonNode::asText) .map(JsonNode::asText)
.distinct()
.toList(); .toList();
assertThat(polyEvents) assertThat(polyEvents)
@@ -71,12 +73,23 @@ class EnterpriseBooleanEnumPredicateExportTest {
"com.example.order.OrderEvent.SHIP"); "com.example.order.OrderEvent.SHIP");
assertThat(polyEvents.size()).isLessThan(4); assertThat(polyEvents.size()).isLessThan(4);
List<JsonNode> matched = StreamSupport.stream(dynamicChain.get("matchedTransitions").spliterator(), false) for (JsonNode dispatchChain : dispatchChains) {
.toList(); long chainMatched = StreamSupport.stream(dispatchChain.get("matchedTransitions").spliterator(), false)
assertThat(matched.size()) .count();
assertThat(chainMatched)
.as("each dispatch chain must not cover all transitions")
.isLessThanOrEqualTo(transitionCount);
}
int matchedCount = dispatchChains.stream()
.mapToInt(chain -> StreamSupport.stream(chain.get("matchedTransitions").spliterator(), false)
.mapToInt(node -> 1)
.sum())
.sum();
assertThat(matchedCount)
.as("matchedTransitions must not cover every transition × every enum constant") .as("matchedTransitions must not cover every transition × every enum constant")
.isLessThanOrEqualTo(transitionCount); .isLessThan(transitionCount * dispatchChains.size());
Set<String> matchedEvents = matched.stream() Set<String> matchedEvents = dispatchChains.stream()
.flatMap(chain -> StreamSupport.stream(chain.get("matchedTransitions").spliterator(), false))
.map(node -> node.get("event").asText()) .map(node -> node.get("event").asText())
.collect(Collectors.toSet()); .collect(Collectors.toSet());
assertThat(matchedEvents) assertThat(matchedEvents)

View File

@@ -282,7 +282,7 @@ class EnumMemberPredicatePolymorphicInferenceTest {
} }
@Test @Test
void shouldFallBackToTransitionEventsWhenPredicateEvaluationIsInconclusive(@TempDir Path tempDir) throws Exception { void shouldFailClosedWhenPredicateMethodCannotBeResolved(@TempDir Path tempDir) throws Exception {
CodebaseContext context = scanEnumProject(tempDir, """ CodebaseContext context = scanEnumProject(tempDir, """
package com.example.order; package com.example.order;
public enum OrderCommand { public enum OrderCommand {
@@ -304,10 +304,7 @@ class EnumMemberPredicatePolymorphicInferenceTest {
TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
trigger, types, context, transitions, true); trigger, types, context, transitions, true);
assertThat(enriched.getPolymorphicEvents()) assertThat(enriched.getPolymorphicEvents()).isNullOrEmpty();
.containsExactlyInAnyOrder(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP");
} }
@Test @Test

View File

@@ -0,0 +1,33 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class MachineEnumCanonicalizerBoundValueOfTest {
@Test
void shouldExpandSingleBoundEventLiteralForValueOfTrigger() {
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.valueOf(eventString.toUpperCase())")
.constraint("\"ORDER\".equalsIgnoreCase(machineType) && \"PAY\".equalsIgnoreCase(event)")
.external(true)
.build();
StateMachineTypeResolver.MachineTypes machineTypes =
new StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState",
"com.example.order.OrderEvent");
TriggerPoint expanded = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints(
trigger, machineTypes, null);
assertThat(expanded.getPolymorphicEvents())
.containsExactly("com.example.order.OrderEvent.PAY");
assertThat(expanded.isExternal()).isFalse();
assertThat(expanded.isAmbiguous()).isFalse();
}
}

View File

@@ -1,6 +1,13 @@
package click.kamil.springstatemachineexporter.analysis.resolver; package click.kamil.springstatemachineexporter.analysis.resolver;
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.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; import static org.assertj.core.api.Assertions.assertThat;
@@ -23,4 +30,115 @@ class MachineEnumCanonicalizerCrossPackageTest {
"OrderEvents.PAY", "com.bar.OrderEvents")) "OrderEvents.PAY", "com.bar.OrderEvents"))
.isEqualTo("com.bar.OrderEvents.PAY"); .isEqualTo("com.bar.OrderEvents.PAY");
} }
@Test
void shouldNotRewriteImportStyleWhenSimpleNameIsAmbiguous(@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(MachineEnumCanonicalizer.canonicalizeLabel("OrderEvent.PAY", "a.OrderEvent", context))
.isEqualTo("OrderEvent.PAY");
}
@Test
void shouldNotCapImportStyleWhenSimpleNameIsAmbiguous(@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(MachineEnumCanonicalizer.capToConfiguredTransitionEvents(
List.of("OrderEvent.PAY"),
List.of("a.OrderEvent.PAY"),
"a.OrderEvent",
context)).isEmpty();
}
@Test
void shouldCanonicalizeLabelsForLinkingWithoutOverwritingTypeFqns(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("com/example/order"));
Files.writeString(tempDir.resolve("com/example/order/OrderEvent.java"),
"package com.example.order; public enum OrderEvent { PAY, SHIP }");
Files.writeString(tempDir.resolve("App.java"), "package com.example; class App {}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.valueOf(action)")
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
.build();
StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState", "com.example.order.OrderEvent");
TriggerPoint linked = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(
trigger, types, context);
assertThat(linked.getEventTypeFqn()).isNull();
assertThat(linked.getStateTypeFqn()).isNull();
assertThat(linked.getPolymorphicEvents()).containsExactly(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderEvent.SHIP");
}
@Test
void shouldNotQualifyAmbiguousImportStyleEventReference(@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(MachineEnumCanonicalizer.qualifyEventIdentifier(
"OrderEvent.PAY", "a.OrderEvent", context)).isEqualTo("OrderEvent.PAY");
assertThat(MachineEnumCanonicalizer.isMachineEnumReference(
"OrderEvent.PAY", "a.OrderEvent", context)).isFalse();
}
@Test
void shouldNotCanonicalizeAmbiguousImportStyleTransitionEvents(@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 }");
Files.writeString(tempDir.resolve("a/OrderState.java"),
"package a; public enum OrderState { NEW, PAID }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
click.kamil.springstatemachineexporter.model.Transition transition =
new click.kamil.springstatemachineexporter.model.Transition();
transition.setEvent(click.kamil.springstatemachineexporter.model.Event.of(
"OrderEvent.PAY", "OrderEvent.PAY"));
transition.setSourceStates(List.of(click.kamil.springstatemachineexporter.model.State.of(
"OrderState.NEW", "OrderState.NEW")));
transition.setTargetStates(List.of(click.kamil.springstatemachineexporter.model.State.of(
"OrderState.PAID", "OrderState.PAID")));
StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes(
"a.OrderState", "a.OrderEvent");
MachineEnumCanonicalizer.canonicalizeTransitions(List.of(transition), types, context);
assertThat(transition.getEvent().fullIdentifier()).isEqualTo("OrderEvent.PAY");
assertThat(transition.getSourceStates().get(0).fullIdentifier()).isEqualTo("a.OrderState.NEW");
}
} }

View File

@@ -211,6 +211,68 @@ class MachineEnumCanonicalizerTest {
"com.example.order.OrderEvent.PAY"); "com.example.order.OrderEvent.PAY");
} }
@Test
void shouldNotExpandSymbolicPlaceholderWhenSimpleEnumNameIsAmbiguous(@TempDir Path tempDir) throws IOException {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
"package a; public enum OrderEvent { PAY, SHIP }");
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { CANCEL }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
List<String> expanded = MachineEnumCanonicalizer.expandSymbolicPolymorphicEvents(
List.of("<SYMBOLIC: OrderEvent.*>"),
"a.OrderEvent",
context);
assertThat(expanded).isEmpty();
}
@Test
void shouldNotInferMachineTransitionsForAmbiguousTriggerWithoutConstraint(@TempDir Path tempDir)
throws IOException {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
"package a; public enum OrderEvent { PAY, SHIP }");
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { CANCEL }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.valueOf(eventStr)")
.ambiguous(true)
.polymorphicEvents(List.of())
.build();
StateMachineTypeResolver.MachineTypes types =
new StateMachineTypeResolver.MachineTypes(null, "a.OrderEvent");
Transition pay = new Transition();
pay.setEvent(Event.of("PAY", "a.OrderEvent.PAY"));
pay.setSourceStates(List.of(State.of("NEW", "NEW")));
pay.setTargetStates(List.of(State.of("PAID", "PAID")));
TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
trigger, types, context, List.of(pay), true);
assertThat(enriched.getPolymorphicEvents()).isNullOrEmpty();
}
@Test
void shouldNotCapForeignPackageEnumConstantToMachineTransition() {
List<String> capped = MachineEnumCanonicalizer.capToConfiguredTransitionEvents(
List.of("b.OrderEvent.PAY"),
List.of("a.OrderEvent.PAY"),
"a.OrderEvent");
assertThat(capped).isEmpty();
}
@Test @Test
void shouldValidateRawNameConsistencyForCanonicalHelpers() { void shouldValidateRawNameConsistencyForCanonicalHelpers() {
assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier( assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier(
@@ -248,7 +310,52 @@ class MachineEnumCanonicalizerTest {
} }
@Test @Test
void shouldInferPolymorphicEventsFromTransitionsWhenContextIsUnavailable() { void shouldCapFullEnumPolymorphicListToConfiguredTransitionsOnly() {
Transition pay = new Transition();
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setEvent(Event.of("OrderEvent.SHIP", "com.example.order.OrderEvent.SHIP"));
List<String> fullEnum = List.of(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderEvent.SHIP",
"com.example.order.OrderEvent.CANCEL",
"com.example.order.OrderEvent.LOG");
TriggerPoint trigger = TriggerPoint.builder()
.event("eventType")
.polymorphicEvents(fullEnum)
.external(true)
.build();
StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState", "com.example.order.OrderEvent");
TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
trigger, types, null, List.of(pay, ship));
assertThat(linked.getPolymorphicEvents())
.containsExactlyInAnyOrder(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderEvent.SHIP");
}
@Test
void shouldExpandSymbolicPolymorphicEventsToConfiguredTransitionsWhenAvailable() {
Transition pay = new Transition();
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
List<String> expanded = MachineEnumCanonicalizer.expandSymbolicPolymorphicEvents(
List.of("<SYMBOLIC: com.example.order.OrderEvent.*>"),
"com.example.order.OrderEvent",
null,
List.of(pay));
assertThat(expanded).containsExactly("com.example.order.OrderEvent.PAY");
}
@Test
void shouldNotInferMultipleTransitionEventsForUnresolvedValueOf() {
Transition pay = new Transition(); Transition pay = new Transition();
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY")); pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
Transition ship = new Transition(); Transition ship = new Transition();
@@ -264,11 +371,44 @@ class MachineEnumCanonicalizerTest {
TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
trigger, types, null, List.of(pay, ship)); trigger, types, null, List.of(pay, ship));
assertThat(linked.getPolymorphicEvents()).isNullOrEmpty();
assertThat(linked.isAmbiguous()).isFalse();
}
@Test
void shouldInferSingleTransitionEventForValueOfWhenOnlyOneConfigured() {
Transition pay = new Transition();
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.valueOf(eventString)")
.build();
StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState", "com.example.order.OrderEvent");
TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
trigger, types, null, List.of(pay));
assertThat(linked.getPolymorphicEvents()) assertThat(linked.getPolymorphicEvents())
.containsExactlyInAnyOrder( .containsExactly("com.example.order.OrderEvent.PAY");
"com.example.order.OrderEvent.PAY", assertThat(linked.isAmbiguous()).isFalse();
"com.example.order.OrderEvent.SHIP"); }
assertThat(linked.isAmbiguous()).isTrue();
@Test
void shouldNotExpandSymbolicToMultipleConfiguredTransitionEvents() {
Transition pay = new Transition();
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setEvent(Event.of("OrderEvent.SHIP", "com.example.order.OrderEvent.SHIP"));
List<String> expanded = MachineEnumCanonicalizer.expandSymbolicPolymorphicEvents(
List.of("<SYMBOLIC: com.example.order.OrderEvent.*>"),
"com.example.order.OrderEvent",
null,
List.of(pay, ship));
assertThat(expanded).isEmpty();
} }
@Test @Test

View File

@@ -0,0 +1,263 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher;
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;
/**
* Regression: when two enums share the same simple name across packages, we must not expand or link
* by guessing which enum {@code OrderEvent} refers to when bindings are unavailable.
*/
class AmbiguousSimpleEnumLinkingTest {
@Test
void shouldFailClosedWhenEnumSimpleNameIsAmbiguousAcrossPackages(@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, SHIP }");
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { CANCEL }");
Files.writeString(tempDir.resolve("App.java"), """
package com.example;
import a.OrderEvent;
public class OrderController {
Dispatcher dispatcher = new Dispatcher();
public void transition(String eventStr) {
dispatcher.dispatch(eventStr);
}
}
class Dispatcher {
void dispatch(String eventStr) {
StateMachine sm = new StateMachine();
// Unresolved valueOf(arg) must not expand to a concrete enum set via simple-name lookup.
sm.sendEvent(OrderEvent.valueOf(eventStr));
}
}
class StateMachine { void sendEvent(OrderEvent e) {} }
""");
CodebaseContext context = new CodebaseContext();
// Keep bindings off so type printing stays import-style and we rely on scan-time indexes.
context.setResolveBindings(false);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.OrderController")
.methodName("transition")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("e")
.build();
CallChain chain = engine.findChains(List.of(entry), List.of(trigger)).get(0);
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "NEW")));
pay.setTargetStates(List.of(State.of("PAID", "PAID")));
pay.setEvent(Event.of("PAY", "a.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setSourceStates(List.of(State.of("PAID", "PAID")));
ship.setTargetStates(List.of(State.of("SHIPPED", "SHIPPED")));
ship.setEvent(Event.of("SHIP", "a.OrderEvent.SHIP"));
AnalysisResult result = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfig")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, context, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
assertThat(linked.getLinkResolution()).isIn(LinkResolution.NO_MATCH, LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldFailClosedWhenEnumExpansionHintUsesAmbiguousSimpleName(@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, SHIP }");
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { CANCEL }");
Files.writeString(tempDir.resolve("App.java"), """
package com.example;
import a.OrderEvent;
public class OrderController {
Dispatcher dispatcher = new Dispatcher();
public void transition(String eventStr) {
dispatcher.dispatch(eventStr);
}
}
class Dispatcher {
void dispatch(String eventStr) {
StateMachine sm = new StateMachine();
sm.sendEvent(OrderEvent.valueOf(normalize(eventStr)));
}
String normalize(String value) {
return value.trim().toUpperCase();
}
}
class StateMachine { void sendEvent(OrderEvent e) {} }
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(false);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.OrderController")
.methodName("transition")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("e")
.build();
CallChain chain = engine.findChains(List.of(entry), List.of(trigger)).get(0);
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "NEW")));
pay.setTargetStates(List.of(State.of("PAID", "PAID")));
pay.setEvent(Event.of("PAY", "a.OrderEvent.PAY"));
AnalysisResult result = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfig")
.transitions(List.of(pay))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, context, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
assertThat(linked.getLinkResolution()).isIn(LinkResolution.NO_MATCH, LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldFailClosedWhenMachineEventTypeWouldExpandAmbiguousSymbolic(@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, SHIP }");
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { CANCEL }");
Files.writeString(tempDir.resolve("App.java"), """
package com.example;
import a.OrderEvent;
public class OrderController {
Dispatcher dispatcher = new Dispatcher();
public void transition(String eventStr) {
dispatcher.dispatch(eventStr);
}
}
class Dispatcher {
void dispatch(String eventStr) {
StateMachine sm = new StateMachine();
sm.sendEvent(OrderEvent.valueOf(eventStr));
}
}
class StateMachine { void sendEvent(OrderEvent e) {} }
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(false);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.OrderController")
.methodName("transition")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("e")
.build();
CallChain chain = engine.findChains(List.of(entry), List.of(trigger)).get(0);
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "NEW")));
pay.setTargetStates(List.of(State.of("PAID", "PAID")));
pay.setEvent(Event.of("PAY", "a.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setSourceStates(List.of(State.of("PAID", "PAID")));
ship.setTargetStates(List.of(State.of("SHIPPED", "SHIPPED")));
ship.setEvent(Event.of("SHIP", "a.OrderEvent.SHIP"));
AnalysisResult result = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfig")
.eventTypeFqn("a.OrderEvent")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, context, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
assertThat(linked.getLinkResolution()).isIn(LinkResolution.NO_MATCH, LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldNotRewriteAmbiguousImportStylePolyDuringTriggerCanonicalization(@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);
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
.ambiguous(true)
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfig")
.eventTypeFqn("a.OrderEvent")
.transitions(List.of())
.metadata(CodebaseMetadata.builder()
.callChains(List.of(CallChain.builder().triggerPoint(trigger).build()))
.build())
.build();
new TriggerCanonicalizationEnricher().enrich(result, context, null);
TriggerPoint canonical = result.getMetadata().getCallChains().get(0).getTriggerPoint();
assertThat(canonical.getPolymorphicEvents())
.containsExactly("OrderEvent.PAY", "OrderEvent.SHIP");
}
}

View File

@@ -110,9 +110,12 @@ class AnalysisResultFinalizerTest {
.metadata(CodebaseMetadata.builder().triggers(List.of(trigger)).build()) .metadata(CodebaseMetadata.builder().triggers(List.of(trigger)).build())
.build(); .build();
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
result.applyResolution(Map.of( result.applyResolution(Map.of(
"app.event", "OrderEvent.PAY", "app.event", "OrderEvent.PAY",
"app.state", "OrderState.NEW")); "app.state", "OrderState.NEW"), context);
TriggerPoint resolved = result.getMetadata().getTriggers().get(0); TriggerPoint resolved = result.getMetadata().getTriggers().get(0);
assertThat(resolved.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY"); assertThat(resolved.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY");
@@ -120,6 +123,51 @@ class AnalysisResultFinalizerTest {
assertThat(resolved.getPolymorphicEvents()).containsExactly("com.example.order.OrderEvent.PAY"); assertThat(resolved.getPolymorphicEvents()).containsExactly("com.example.order.OrderEvent.PAY");
} }
@Test
void shouldPreserveAmbiguousImportStyleMatchedTransitions(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.createDirectories(tempDir.resolve("com/example/config"));
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 }");
Files.writeString(tempDir.resolve("a/OrderState.java"), "package a; public enum OrderState { NEW, PAID }");
Files.writeString(tempDir.resolve("com/example/config/OrderStateMachineConfiguration.java"),
"""
package com.example.config;
import a.OrderEvent;
import a.OrderState;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
public class OrderStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
}
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.transitions(List.of())
.metadata(CodebaseMetadata.builder()
.callChains(List.of(click.kamil.springstatemachineexporter.analysis.model.CallChain.builder()
.matchedTransitions(List.of(
click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder()
.event("OrderEvent.PAY")
.sourceState("a.OrderState.NEW")
.targetState("a.OrderState.PAID")
.build()))
.build()))
.build())
.build();
AnalysisResultFinalizer.applyCanonicalization(result, context,
new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes(
"a.OrderState", "a.OrderEvent"));
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent())
.isEqualTo("OrderEvent.PAY");
}
private static void writeSampleConfig(Path tempDir) throws Exception { private static void writeSampleConfig(Path tempDir) throws Exception {
Path orderPkg = tempDir.resolve("com/example/order"); Path orderPkg = tempDir.resolve("com/example/order");
Path configPkg = tempDir.resolve("com/example/config"); Path configPkg = tempDir.resolve("com/example/config");

View File

@@ -0,0 +1,122 @@
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 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.Map;
import static org.assertj.core.api.Assertions.assertThat;
class BooleanTernaryBranchSplitTest {
@Test
void bindingExpanderShouldProduceBooleanVariantsForTernaryParameter(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class PolymorphicController {
OrderService orderService;
public void payTernary(boolean isPay) {
orderService.processEvent(isPay ? new PayEvent() : new CancelEvent());
}
}
class OrderService {
void processEvent(BaseEvent event) {}
}
class BaseEvent {}
class PayEvent extends BaseEvent {}
class CancelEvent extends BaseEvent {}
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.PolymorphicController")
.methodName("payTernary")
.name("POST /pay-ternary")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("isPay")
.type("boolean")
.build()))
.build();
List<Map<String, String>> variants = EntryPointBindingExpander.expandEntryPointBindings(
entryPoint, context, engine.buildCallGraph());
assertThat(variants).extracting(map -> map.get("isPay"))
.containsExactlyInAnyOrder("true", "false");
}
@Test
void shouldResolveSingleEventPerBooleanBinding(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class PolymorphicController {
OrderService orderService;
public void payTernary(boolean isPay) {
orderService.processEvent(isPay ? new PayEvent() : new CancelEvent());
}
}
class OrderService {
void processEvent(BaseEvent event) {}
}
class BaseEvent {}
class PayEvent extends BaseEvent {}
class CancelEvent extends BaseEvent {}
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.PolymorphicController")
.methodName("payTernary")
.name("POST /pay-ternary")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("isPay")
.type("boolean")
.build()))
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.OrderService")
.methodName("processEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
CallChain payChain = chains.stream()
.filter(c -> c.getEntryPoint().getName().contains("isPay=true"))
.findFirst()
.orElseThrow();
CallChain cancelChain = chains.stream()
.filter(c -> c.getEntryPoint().getName().contains("isPay=false"))
.findFirst()
.orElseThrow();
assertThat(payChain.getTriggerPoint().isAmbiguous()).isFalse();
assertThat(cancelChain.getTriggerPoint().isAmbiguous()).isFalse();
assertThat(payChain.getTriggerPoint().getEvent()).contains("PayEvent").doesNotContain("CancelEvent");
assertThat(cancelChain.getTriggerPoint().getEvent()).contains("CancelEvent").doesNotContain("PayEvent");
if (payChain.getTriggerPoint().getPolymorphicEvents() != null) {
assertThat(payChain.getTriggerPoint().getPolymorphicEvents()).hasSizeLessThanOrEqualTo(1);
}
if (cancelChain.getTriggerPoint().getPolymorphicEvents() != null) {
assertThat(cancelChain.getTriggerPoint().getPolymorphicEvents()).hasSizeLessThanOrEqualTo(1);
}
}
}

View File

@@ -7,6 +7,7 @@ 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.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.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event; import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State; import click.kamil.springstatemachineexporter.model.State;
@@ -43,7 +44,7 @@ final class CentralDispatcherTestSupport {
String controllerClass, String controllerClass,
String controllerMethod, String controllerMethod,
String stateMachineClass) { String stateMachineClass) {
return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, EngineKind.HEURISTIC); return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, "sendEvent", EngineKind.HEURISTIC);
} }
static CallChain resolveChain( static CallChain resolveChain(
@@ -52,26 +53,64 @@ final class CentralDispatcherTestSupport {
String controllerMethod, String controllerMethod,
String stateMachineClass, String stateMachineClass,
EngineKind engineKind) { EngineKind engineKind) {
return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, "sendEvent", engineKind);
}
static CallChain resolveChain(
CodebaseContext context,
String entryClass,
String entryMethod,
String triggerClass,
String triggerMethod,
EngineKind engineKind) {
AbstractCallGraphEngine engine = engineKind == EngineKind.JDT AbstractCallGraphEngine engine = engineKind == EngineKind.JDT
? new JdtCallGraphEngine(context, null) ? new JdtCallGraphEngine(context, null)
: new HeuristicCallGraphEngine(context); : new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder() EntryPoint entryPoint = EntryPoint.builder()
.className(controllerClass) .className(entryClass)
.methodName(controllerMethod) .methodName(entryMethod)
.build(); .build();
TriggerPoint trigger = TriggerPoint.builder() TriggerPoint trigger = TriggerPoint.builder()
.className(stateMachineClass) .className(triggerClass)
.methodName("sendEvent") .methodName(triggerMethod)
.event("e") .event("e")
.build(); .build();
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger)); List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains) assertThat(chains)
.as("expected one call chain from %s.%s to %s.sendEvent via %s", .as("expected one call chain from %s.%s to %s.%s via %s",
controllerClass, controllerMethod, stateMachineClass, engineKind) entryClass, entryMethod, triggerClass, triggerMethod, engineKind)
.hasSize(1); .hasSize(1);
return chains.get(0); return chains.get(0);
} }
static void assertParity(
CodebaseContext context,
String entryClass,
String entryMethod,
String triggerClass,
String triggerMethod) {
CallChain heuristic = resolveChain(
context, entryClass, entryMethod, triggerClass, triggerMethod, EngineKind.HEURISTIC);
CallChain jdt = resolveChain(
context, entryClass, entryMethod, triggerClass, triggerMethod, EngineKind.JDT);
assertThat(jdt.getMethodChain())
.containsExactlyElementsOf(heuristic.getMethodChain());
assertThat(jdt.getTriggerPoint().getEvent()).isEqualTo(heuristic.getTriggerPoint().getEvent());
assertThat(jdt.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrderElementsOf(
heuristic.getTriggerPoint().getPolymorphicEvents() == null
? List.of()
: heuristic.getTriggerPoint().getPolymorphicEvents());
}
static void assertMethodChainContains(CallChain chain, String... methodFqns) {
assertThat(chain.getMethodChain()).contains(methodFqns);
}
static void assertMethodChainContainsExact(CallChain chain, String... methodFqns) {
assertThat(chain.getMethodChain()).containsExactly(methodFqns);
}
static void assertPolyEvents(CallChain chain, String... expectedEvents) { static void assertPolyEvents(CallChain chain, String... expectedEvents) {
assertThat(chain.getTriggerPoint().getPolymorphicEvents()) assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder(expectedEvents); .containsExactlyInAnyOrder(expectedEvents);
@@ -105,4 +144,76 @@ final class CentralDispatcherTestSupport {
static void assertLinkedEvent(MatchedTransition matched, String expectedEvent) { static void assertLinkedEvent(MatchedTransition matched, String expectedEvent) {
assertThat(matched.getEvent()).isEqualTo(expectedEvent); assertThat(matched.getEvent()).isEqualTo(expectedEvent);
} }
static CallChain linkChain(
CodebaseContext context,
CallChain rawChain,
String machineConfig,
String eventTypeFqn,
String stateTypeFqn,
Transition... machineTransitions) {
AnalysisResult result = AnalysisResult.builder()
.name(machineConfig)
.eventTypeFqn(eventTypeFqn)
.stateTypeFqn(stateTypeFqn)
.transitions(List.of(machineTransitions))
.metadata(CodebaseMetadata.builder().callChains(List.of(rawChain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, context, null);
return result.getMetadata().getCallChains().get(0);
}
static CallChain resolveLinkAndAssertSingleMatch(
CodebaseContext context,
String entryClass,
String entryMethod,
String triggerClass,
String triggerMethod,
String machineConfig,
String eventTypeFqn,
String stateTypeFqn,
String expectedLinkedEvent,
EngineKind engineKind,
Transition... machineTransitions) {
CallChain raw = resolveChain(context, entryClass, entryMethod, triggerClass, triggerMethod, engineKind);
CallChain linked = linkChain(context, raw, machineConfig, eventTypeFqn, stateTypeFqn, machineTransitions);
assertPolyWithinTransitions(linked, machineTransitions.length);
assertMatchedWithinTransitions(linked, machineTransitions.length);
assertThat(linked.getMatchedTransitions())
.as("expected exactly one matched transition")
.hasSize(1);
assertLinkedEvent(linked.getMatchedTransitions().get(0), expectedLinkedEvent);
return linked;
}
static void assertPolyWithinTransitions(CallChain chain, int configuredTransitionCount) {
List<String> poly = chain.getTriggerPoint().getPolymorphicEvents();
assertThat(poly)
.as("polymorphicEvents must be present after linking")
.isNotNull();
assertThat(poly.size())
.as("polymorphicEvents must not exceed configured transition count")
.isLessThanOrEqualTo(configuredTransitionCount);
assertThat(MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(poly)).isTrue();
}
static void assertMatchedWithinTransitions(CallChain chain, int configuredTransitionCount) {
assertThat(chain.getMatchedTransitions())
.as("matchedTransitions should be populated after linking")
.isNotNull();
assertThat(chain.getMatchedTransitions().size())
.as("matchedTransitions must not exceed configured transition count")
.isLessThanOrEqualTo(configuredTransitionCount);
}
static void assertPolyCappedToTransitionEvents(
CallChain chain,
String eventTypeFqn,
Transition... machineTransitions) {
List<String> allowed = MachineEnumCanonicalizer.polymorphicEventsFromTransitions(
List.of(machineTransitions), eventTypeFqn);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.isNotNull()
.allSatisfy(pe -> assertThat(allowed).contains(pe));
}
} }

View File

@@ -0,0 +1,68 @@
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.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.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class ComplexMultiModuleCancelJmsTest {
private CodebaseContext context;
private JdtCallGraphEngine engine;
@BeforeEach
void setUp() throws IOException {
Path projectRoot = Path.of("../state_machines/complex_multi_module_sm").toAbsolutePath().normalize();
context = new CodebaseContext();
context.setProjectRoot(projectRoot);
context.setSourcepath(List.of(
projectRoot.resolve("domain/src/main/java").toString(),
projectRoot.resolve("service/src/main/java").toString(),
projectRoot.resolve("web/src/main/java").toString()));
context.setClasspath(ToolingClasspath.currentJvmJarEntries());
context.setResolveBindings(true);
context.scan(Set.of(projectRoot), Collections.emptySet());
SpringBeanRegistry registry = new SpringBeanRegistry();
SpringContextScanner scanner = new SpringContextScanner(registry);
for (var cu : context.getCompilationUnits()) {
cu.accept(scanner);
}
InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(new SpringDependencyResolver(registry));
engine = new JdtCallGraphEngine(context, injectionAnalyzer);
}
@Test
void shouldResolveCancelEventFromJmsSupplierLambda() {
EntryPoint entryPoint = EntryPoint.builder()
.className("click.kamil.web.JmsOrderListener")
.methodName("receiveCancelCommand")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("click.kamil.service.StateMachineServiceImpl")
.methodName("sendMessageWithProvider")
.event("eventProvider")
.build();
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvent.CANCEL");
}
}

View File

@@ -0,0 +1,107 @@
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 org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class CrossClassSupplierLambdaTest {
@Test
void shouldResolveEventThroughCrossClassSupplierLambda() throws IOException {
String webSource = """
package com.example.web;
import com.example.service.StateMachineService;
import com.example.domain.OrderEvent;
import org.springframework.stereotype.Component;
@Component
public class JmsOrderListener {
private final StateMachineService stateMachineService;
public JmsOrderListener(StateMachineService stateMachineService) {
this.stateMachineService = stateMachineService;
}
public void receiveCancelCommand(String message) {
stateMachineService.sendMessageWithProvider(() -> OrderEvent.CANCEL);
}
}
""";
String serviceSource = """
package com.example.service;
import com.example.domain.OrderEvent;
import org.springframework.statemachine.StateMachine;
import org.springframework.messaging.support.MessageBuilder;
import reactor.core.publisher.Mono;
import java.util.function.Supplier;
public class StateMachineServiceImpl implements StateMachineService {
private StateMachine<OrderEvent, OrderEvent> stateMachine;
@Override
public <T extends OrderEvent> void sendMessageWithProvider(Supplier<T> eventProvider) {
T event = eventProvider.get();
if (event != null) {
stateMachine.sendEvent(Mono.just(MessageBuilder.withPayload(event).build())).subscribe();
}
}
}
""";
String ifaceSource = """
package com.example.service;
import com.example.domain.OrderEvent;
import java.util.function.Supplier;
public interface StateMachineService {
<T extends OrderEvent> void sendMessageWithProvider(Supplier<T> eventProvider);
}
""";
String domainSource = """
package com.example.domain;
public enum OrderEvent { CANCEL, PROCESS }
""";
Path tempDir = Files.createTempDirectory("callgraph_cross_class_lambda");
Files.writeString(tempDir.resolve("JmsOrderListener.java"), webSource);
Files.writeString(tempDir.resolve("StateMachineServiceImpl.java"), serviceSource);
Files.writeString(tempDir.resolve("StateMachineService.java"), ifaceSource);
Files.writeString(tempDir.resolve("OrderEvent.java"), domainSource);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.web.JmsOrderListener")
.methodName("receiveCancelCommand")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.service.StateMachineServiceImpl")
.methodName("sendMessageWithProvider")
.event("eventProvider")
.build();
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderEvent.CANCEL");
}
}

View File

@@ -0,0 +1,174 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Deep inheritance dispatcher stacks: multi-level {@code super} and template-method patterns.
*/
class DeepDispatcherHierarchyTest {
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
private static final String EVENT_TYPE = "com.example.OrderEvent";
private static final String STATE_TYPE = "com.example.OrderState";
@Test
void jdtShouldMatchHeuristicForMultiLevelSuperDelegation(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
GrandChildHandler handler;
public void pay() { handler.entry(OrderEvent.PAY); }
}
class GrandChildHandler extends MiddleHandler {
void entry(OrderEvent event) { super.route(event); }
}
class MiddleHandler extends BaseHandler {
protected void route(OrderEvent event) { super.dispatch(event); }
}
class BaseHandler {
StateMachine machine;
protected void dispatch(OrderEvent event) { machine.sendEvent(event); }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent");
CallChain chain = resolveChain(
context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent", EngineKind.JDT);
assertMethodChainContains(chain,
"com.example.GrandChildHandler.entry",
"com.example.MiddleHandler.route",
"com.example.BaseHandler.dispatch");
assertPolyEvents(chain, "OrderEvent.PAY");
}
@Test
void jdtShouldMatchHeuristicForTemplateMethodProtectedDispatch(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
OrderHandler handler;
public void pay() { handler.pay(); }
}
abstract class AbstractHandler {
StateMachine machine;
public void pay() { dispatch(buildPayEvent()); }
protected abstract OrderEvent buildPayEvent();
protected void dispatch(OrderEvent event) { machine.sendEvent(event); }
}
class OrderHandler extends AbstractHandler {
@Override protected OrderEvent buildPayEvent() { return OrderEvent.PAY; }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent");
assertPolyEvents(
resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent", EngineKind.HEURISTIC),
"OrderEvent.PAY");
}
@Test
void pipelineShouldLinkMultiLevelSuperToSingleConfiguredTransition(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
GrandChildHandler handler;
public void pay() { handler.entry(OrderEvent.PAY); }
}
class GrandChildHandler extends MiddleHandler {
void entry(OrderEvent event) { super.route(event); }
}
class MiddleHandler extends BaseHandler {
protected void route(OrderEvent event) { super.dispatch(event); }
}
class BaseHandler {
StateMachine machine;
protected void dispatch(OrderEvent event) { machine.sendEvent(event); }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
CallChain linked = resolveLinkAndAssertSingleMatch(
context,
"com.example.ApiController",
"pay",
"com.example.StateMachine",
"sendEvent",
MACHINE_CONFIG,
EVENT_TYPE,
STATE_TYPE,
EVENT_TYPE + ".PAY",
EngineKind.JDT,
pay,
ship);
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
.containsExactly(EVENT_TYPE + ".PAY");
}
@Test
void pipelineShouldLinkTemplateMethodDispatchToSingleConfiguredTransition(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
OrderHandler handler;
public void pay() { handler.pay(); }
}
abstract class AbstractHandler {
StateMachine machine;
public void pay() { dispatch(buildPayEvent()); }
protected abstract OrderEvent buildPayEvent();
protected void dispatch(OrderEvent event) { machine.sendEvent(event); }
}
class OrderHandler extends AbstractHandler {
@Override protected OrderEvent buildPayEvent() { return OrderEvent.PAY; }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
CallChain linked = resolveLinkAndAssertSingleMatch(
context,
"com.example.ApiController",
"pay",
"com.example.StateMachine",
"sendEvent",
MACHINE_CONFIG,
EVENT_TYPE,
STATE_TYPE,
EVENT_TYPE + ".PAY",
EngineKind.HEURISTIC,
pay,
ship);
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
}
}

View File

@@ -0,0 +1,163 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Ensures linker-stage polymorphic narrowing caps over-broad call-graph enum lists to configured transitions.
*/
class DispatcherPolyCeilingPipelineTest {
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
private static final String EVENT_TYPE = "com.example.OrderEvent";
private static final String STATE_TYPE = "com.example.OrderState";
@Test
void linkerShouldCapInjectedFullEnumPolymorphicListToConfiguredTransitions(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay() { dispatcher.route("PAY"); }
}
class CentralDispatcher {
StateMachine machine;
public void route(String action) { machine.sendEvent(OrderEvent.valueOf(action)); }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain raw = resolveChain(
context,
"com.example.ApiController",
"pay",
"com.example.StateMachine",
"sendEvent",
EngineKind.JDT);
List<String> bloatedEnum = List.of(
EVENT_TYPE + ".PAY",
EVENT_TYPE + ".SHIP",
EVENT_TYPE + ".LOG",
EVENT_TYPE + ".META");
TriggerPoint bloatedTrigger = raw.getTriggerPoint().toBuilder()
.polymorphicEvents(bloatedEnum)
.external(true)
.build();
CallChain bloatedChain = raw.toBuilder().triggerPoint(bloatedTrigger).build();
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
CallChain linked = linkChain(
context, bloatedChain, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship);
assertPolyWithinTransitions(linked, 2);
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder(EVENT_TYPE + ".PAY", EVENT_TYPE + ".SHIP");
assertMatchedWithinTransitions(linked, 2);
assertThat(linked.getMatchedTransitions())
.extracting(MatchedTransition::getEvent)
.containsExactlyInAnyOrder(EVENT_TYPE + ".PAY", EVENT_TYPE + ".SHIP");
}
@Test
void linkerShouldNotInferAllTransitionEventsForUnresolvedValueOf(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void handle(String action) { dispatcher.route(action); }
}
class CentralDispatcher {
StateMachine machine;
public void route(String action) { machine.sendEvent(OrderEvent.valueOf(action)); }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain raw = resolveChain(
context,
"com.example.ApiController",
"handle",
"com.example.StateMachine",
"sendEvent",
EngineKind.JDT);
TriggerPoint stripped = raw.getTriggerPoint().toBuilder()
.polymorphicEvents(null)
.build();
CallChain chain = raw.toBuilder().triggerPoint(stripped).build();
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
CallChain linked = linkChain(
context, chain, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship);
assertThat(linked.getTriggerPoint().getPolymorphicEvents()).isNullOrEmpty();
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
}
@Test
void linkerShouldCapCallGraphPolyForDeepDispatcherToTransitionCeiling(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
OrderService orderService;
public void pay() { orderService.handlePay(); }
}
class OrderService {
CentralDispatcher dispatcher;
protected void handlePay() { dispatcher.route("PAY"); }
}
class CentralDispatcher {
StateMachine machine;
public void route(String action) { machine.sendEvent(OrderEvent.valueOf(action)); }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
CallChain linked = resolveLinkAndAssertSingleMatch(
context,
"com.example.ApiController",
"pay",
"com.example.StateMachine",
"sendEvent",
MACHINE_CONFIG,
EVENT_TYPE,
STATE_TYPE,
EVENT_TYPE + ".PAY",
EngineKind.JDT,
pay,
ship);
assertThat(linked.getTriggerPoint().getPolymorphicEvents().size()).isLessThan(4);
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
}
}

View File

@@ -0,0 +1,73 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.service.ExportService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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;
/**
* Enterprise dedicated REST endpoints must link to transitions when call-graph resolution
* proves a concrete machine enum literal on the dispatcher branch.
*/
class EnterpriseDedicatedEndpointLinkingTest {
private static final String PAY_ENDPOINT = "POST /api/machine/order/pay";
@Test
void astExportDedicatedPayEndpointShouldLinkToOrderPayTransition(@TempDir Path tempDir) throws Exception {
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runExporter(enterprise, tempDir, List.of("json"), true, List.of(), null, null,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
JsonNode json = readMachineJson(tempDir, "OrderStateMachineConfiguration", new ObjectMapper());
JsonNode chain = findPayEndpointChain(json);
assertThat(chain.path("triggerPoint").path("event").asText())
.isEqualTo("click.kamil.enterprise.machines.order.OrderEvent.PAY");
assertThat(chain.path("matchedTransitions").isArray()).isTrue();
assertThat(chain.path("matchedTransitions")).isNotEmpty();
assertThat(chain.path("matchedTransitions").get(0).path("event").asText())
.isEqualTo("click.kamil.enterprise.machines.order.OrderEvent.PAY");
assertThat(chain.path("linkResolution").asText()).isEqualTo("RESOLVED");
}
private static JsonNode findPayEndpointChain(JsonNode machineJson) {
for (JsonNode chain : machineJson.path("metadata").path("callChains")) {
if (PAY_ENDPOINT.equals(chain.path("entryPoint").path("name").asText())) {
return chain;
}
}
throw new IllegalStateException("pay endpoint chain not found");
}
private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper)
throws Exception {
Path machineDir;
try (var stream = Files.list(outputDir)) {
machineDir = stream
.filter(Files::isDirectory)
.filter(path -> path.getFileName().toString().endsWith(configBaseName))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No output for " + configBaseName));
}
return mapper.readTree(machineDir.resolve(machineDir.getFileName() + ".json").toFile());
}
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
return current;
}
}

View File

@@ -0,0 +1,175 @@
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 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.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Per-branch dispatcher splitting: if-else machineType arms must resolve to branch-specific
* valueOf triggers and constraints, not a collapsed ternary across machines.
*/
class EnterpriseDispatcherBranchSplitTest {
@Test
void shouldResolveBranchSpecificValueOfForEachMachineTypeBinding(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class MachineController {
StateMachineDispatcher dispatcher;
public void transition(String machineType, String event) {
dispatcher.dispatch(machineType, event);
}
}
class StateMachineDispatcher {
void dispatch(String machineType, String eventString) {
if ("ORDER".equalsIgnoreCase(machineType)) {
fireOrder(eventString);
} else if ("DOCUMENT".equalsIgnoreCase(machineType)) {
fireDocument(eventString);
} else if ("USER".equalsIgnoreCase(machineType)) {
fireUser(eventString);
}
}
void fireOrder(String eventString) {
OrderEvent event = OrderEvent.valueOf(eventString.toUpperCase());
send(event);
}
void fireDocument(String eventString) {
DocumentEvent event = DocumentEvent.valueOf(eventString.toUpperCase());
send(event);
}
void fireUser(String eventString) {
UserEvent event = UserEvent.valueOf(eventString.toUpperCase());
send(event);
}
void send(OrderEvent event) {}
void send(DocumentEvent event) {}
void send(UserEvent event) {}
}
enum OrderEvent { PAY }
enum DocumentEvent { SUBMIT }
enum UserEvent { VERIFY }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.MachineController")
.methodName("transition")
.name("POST /api/machine/{machineType}/transition/{event}")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("machineType")
.type("String")
.annotations(List.of("PathVariable"))
.build(),
EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
TriggerPoint orderTrigger = TriggerPoint.builder()
.className("com.example.StateMachineDispatcher")
.methodName("send")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(orderTrigger));
CallChain orderChain = chains.stream()
.filter(c -> c.getEntryPoint().getName().contains("/ORDER/"))
.findFirst()
.orElseThrow();
List<CallChain> documentChains = chains.stream()
.filter(c -> c.getEntryPoint().getName().contains("/DOCUMENT/"))
.toList();
assertThat(documentChains).isNotEmpty();
CallChain documentChain = documentChains.stream()
.filter(c -> c.getMethodChain().contains("com.example.StateMachineDispatcher.fireDocument"))
.findFirst()
.orElseThrow();
assertThat(orderChain.getTriggerPoint().getEvent())
.contains("OrderEvent.valueOf")
.doesNotContain("DocumentEvent")
.doesNotContain("UserEvent")
.doesNotContain("true ?");
assertThat(orderChain.getTriggerPoint().getConstraint())
.contains("ORDER")
.doesNotContain("DOCUMENT");
assertThat(documentChain.getMethodChain())
.contains("com.example.StateMachineDispatcher.fireDocument")
.doesNotContain("com.example.StateMachineDispatcher.fireOrder");
assertThat(documentChain.getTriggerPoint().getEvent())
.satisfiesAnyOf(
e -> assertThat(e).contains("DocumentEvent.valueOf"),
e -> assertThat(e).isEqualTo("event"));
assertThat(documentChain.getTriggerPoint().getConstraint())
.contains("DOCUMENT")
.doesNotContain("USER");
}
@Test
void bindingExpanderShouldProduceMachineTypeVariants(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class MachineController {
StateMachineDispatcher dispatcher;
public void transition(String machineType, String event) {
dispatcher.dispatch(machineType, event);
}
}
class StateMachineDispatcher {
void dispatch(String machineType, String eventString) {
if ("ORDER".equalsIgnoreCase(machineType)) {
order(eventString);
} else if ("DOCUMENT".equalsIgnoreCase(machineType)) {
document(eventString);
}
}
void order(String eventString) {}
void document(String eventString) {}
}
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.MachineController")
.methodName("transition")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("machineType")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants = EntryPointBindingExpander.expandPathVariableBindings(
entryPoint, context, engine.buildCallGraph());
assertThat(variants).extracting(map -> map.get("machineType"))
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
}
}

View File

@@ -1,12 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.service; package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
import click.kamil.springstatemachineexporter.exporter.JsonExporter; import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.service.ExportService; import click.kamil.springstatemachineexporter.service.ExportService;
import click.kamil.springstatemachineexporter.service.JsonImportService;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -15,96 +10,18 @@ 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 java.util.List;
import java.util.stream.StreamSupport;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/** /**
* Regression for REST dispatcher chains that lost {@code polymorphicEvents} during JSON round-trip. * Regression for REST dispatcher chains: expanded concrete endpoints must not re-infer all
* transition events; unresolved {@code {event}} templates stay external.
*/ */
class EnterpriseDispatcherMatchedTransitionsRegressionTest { class EnterpriseDispatcherMatchedTransitionsRegressionTest {
@Test @Test
void jsonReExportWithSourceShouldRestoreDispatcherMatchedTransitions(@TempDir Path tempDir) throws Exception { void astExportExpandedTransitionEndpointsShouldNotOverLink(@TempDir Path tempDir) throws Exception {
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
Path astOut = tempDir.resolve("ast");
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runExporter(enterprise, astOut, List.of("json"), true, List.of(), null, null,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
Path astJson = astOut.resolve(
"click.kamil.enterprise.machines.order.OrderStateMachineConfiguration/click.kamil.enterprise.machines.order.OrderStateMachineConfiguration.json");
AnalysisResult imported = new JsonImportService().importAnalysisResult(astJson);
List<CallChain> corruptedChains = imported.getMetadata().getCallChains().stream()
.map(chain -> {
if (chain.getTriggerPoint() == null
|| chain.getMethodChain() == null
|| !chain.getMethodChain().stream()
.anyMatch(m -> m.contains("StateMachineDispatcher.dispatch"))) {
return chain;
}
return chain.toBuilder()
.triggerPoint(chain.getTriggerPoint().toBuilder()
.event("event")
.external(false)
.polymorphicEvents(null)
.build())
.matchedTransitions(null)
.build();
})
.toList();
imported.setMetadata(CodebaseMetadata.builder()
.triggers(imported.getMetadata().getTriggers())
.entryPoints(imported.getMetadata().getEntryPoints())
.callChains(corruptedChains)
.properties(imported.getMetadata().getProperties())
.build());
Path brokenJson = tempDir.resolve("broken-order-machine.json");
Files.writeString(brokenJson, new JsonExporter().export(imported, ExportOptions.builder().build()));
Path outputDir = tempDir.resolve("out");
exportService.runJsonExporter(
brokenJson, outputDir, List.of("json"), List.of(),
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
enterprise);
Path finalized = outputDir.resolve(
"click.kamil.enterprise.machines.order.OrderStateMachineConfiguration/click.kamil.enterprise.machines.order.OrderStateMachineConfiguration.json");
AnalysisResult roundTripped = new JsonImportService().importAnalysisResult(finalized);
assertThat(roundTripped.getMetadata().getCallChains())
.as("call chains after re-export")
.isNotEmpty();
CallChain chain = roundTripped.getMetadata().getCallChains().stream()
.filter(c -> c.getMethodChain() != null && c.getMethodChain().stream()
.anyMatch(m -> m.contains("StateMachineDispatcher.dispatch")))
.findFirst()
.orElseThrow(() -> new IllegalStateException(
"no dispatcher chain among: "
+ roundTripped.getMetadata().getCallChains().stream()
.map(c -> c.getEntryPoint() != null ? c.getEntryPoint().getName() : "null")
.toList()));
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.as("polymorphicEvents on %s", chain.getEntryPoint().getName())
.isNotNull()
.isNotEmpty();
assertThat(chain.getTriggerPoint().isExternal())
.as("external flag on %s", chain.getEntryPoint().getName())
.isTrue();
assertThat(chain.getMatchedTransitions())
.as("matchedTransitions on %s", chain.getEntryPoint().getName())
.isNotNull()
.isNotEmpty();
}
@Test
void astExportTransitionEndpointShouldLinkMatchedTransitions(@TempDir Path tempDir) throws Exception {
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise"); Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
ExportService exportService = new ExportService(List.of(new JsonExporter())); ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runExporter(enterprise, tempDir, List.of("json"), true, List.of(), null, null, exportService.runExporter(enterprise, tempDir, List.of("json"), true, List.of(), null, null,
@@ -112,23 +29,31 @@ class EnterpriseDispatcherMatchedTransitionsRegressionTest {
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn); click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
JsonNode json = readMachineJson(tempDir, "OrderStateMachineConfiguration", new ObjectMapper()); JsonNode json = readMachineJson(tempDir, "OrderStateMachineConfiguration", new ObjectMapper());
JsonNode chain = findTransitionEndpointChain(json); int transitionCount = json.path("transitions").size();
assertThat(chain.path("triggerPoint").path("external").asBoolean()).isTrue(); StreamSupport.stream(json.path("metadata").path("callChains").spliterator(), false)
assertThat(chain.path("triggerPoint").path("polymorphicEvents").isArray()).isTrue(); .filter(chain -> chain.path("entryPoint").path("name").asText().contains("/transition/{event}"))
assertThat(chain.path("triggerPoint").path("polymorphicEvents")).isNotEmpty(); .forEach(chain -> {
assertThat(chain.path("matchedTransitions").isArray()).isTrue(); JsonNode matched = chain.path("matchedTransitions");
assertThat(chain.path("matchedTransitions")).isNotEmpty(); assertThat(matched.isNull() || (matched.isArray() && matched.isEmpty())).isTrue();
assertThat(chain.path("linkResolution").asText()).isEqualTo("UNRESOLVED_EXTERNAL");
});
JsonNode orderPay = findChainByEndpoint(json, "POST /api/machine/ORDER/transition/PAY");
assertThat(orderPay.path("linkResolution").asText()).isEqualTo("RESOLVED");
JsonNode matched = orderPay.path("matchedTransitions");
assertThat(matched.isArray()).isTrue();
assertThat(matched.size()).isLessThanOrEqualTo(transitionCount);
assertThat(matched.size()).isGreaterThan(0);
assertThat(matched.get(0).path("event").asText())
.contains("OrderEvent.PAY");
} }
private static JsonNode findTransitionEndpointChain(JsonNode machineJson) { private static JsonNode findChainByEndpoint(JsonNode machineJson, String endpointName) {
for (JsonNode chain : machineJson.path("metadata").path("callChains")) { return StreamSupport.stream(machineJson.path("metadata").path("callChains").spliterator(), false)
if ("POST /api/machine/{machineType}/transition/{event}".equals( .filter(chain -> endpointName.equals(chain.path("entryPoint").path("name").asText()))
chain.path("entryPoint").path("name").asText())) { .findFirst()
return chain; .orElseThrow(() -> new IllegalStateException("chain not found for " + endpointName));
}
}
throw new IllegalStateException("transition endpoint chain not found");
} }
private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper) private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper)

View File

@@ -0,0 +1,68 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.service.ExportService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.stream.StreamSupport;
import static org.assertj.core.api.Assertions.assertThat;
/**
* When path bindings prove machine type and event, expanded REST endpoints should link;
* the unresolved generic template remains external.
*/
class EnterpriseExpandedGenericEndpointLinkingTest {
private static final String ORDER_PAY = "POST /api/machine/ORDER/transition/PAY";
@Test
void astExportExpandedOrderPayEndpointShouldLink(@TempDir Path tempDir) throws Exception {
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runExporter(enterprise, tempDir, List.of("json"), true, List.of(), null, null,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
JsonNode json = readMachineJson(tempDir, "OrderStateMachineConfiguration", new ObjectMapper());
JsonNode expandedPayChain = findChainByEndpoint(json, ORDER_PAY);
assertThat(expandedPayChain.path("linkResolution").asText()).isEqualTo("RESOLVED");
assertThat(expandedPayChain.path("matchedTransitions").isArray()).isTrue();
assertThat(expandedPayChain.path("matchedTransitions")).isNotEmpty();
}
private static JsonNode findChainByEndpoint(JsonNode machineJson, String endpointName) {
return StreamSupport.stream(machineJson.path("metadata").path("callChains").spliterator(), false)
.filter(chain -> endpointName.equals(chain.path("entryPoint").path("name").asText()))
.findFirst()
.orElseThrow(() -> new IllegalStateException("chain not found for " + endpointName));
}
private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper)
throws Exception {
Path machineDir;
try (var stream = Files.list(outputDir)) {
machineDir = stream
.filter(Files::isDirectory)
.filter(path -> path.getFileName().toString().endsWith(configBaseName))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No output for " + configBaseName));
}
return mapper.readTree(machineDir.resolve(machineDir.getFileName() + ".json").toFile());
}
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
return current;
}
}

View File

@@ -0,0 +1,50 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.CompilationUnit;
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;
/**
* Export must work on standard Spring State Machine patterns without hints.json.
*/
class ExportWithoutHintsTest {
@Test
void shouldDetectSendEventTriggersWithoutHintsJson(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
import org.springframework.statemachine.StateMachine;
public class OrderService {
public void pay(StateMachine<OrderState, OrderEvent> sm) {
sm.sendEvent(OrderEvent.PAY);
}
}
enum OrderState { NEW, PAID }
enum OrderEvent { PAY }
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
assertThat(tempDir.resolve("hints.json")).doesNotExist();
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(context.getLibraryHints()).isEmpty();
GenericEventDetector detector = new GenericEventDetector(
context, context.getConstantResolver(), context.getLibraryHints());
List<TriggerPoint> triggers = detector.detect(
context.getCompilationUnits().iterator().next());
assertThat(triggers).isNotEmpty();
assertThat(triggers)
.extracting(TriggerPoint::getEvent)
.anyMatch(e -> e != null && e.contains("PAY"));
}
}

View File

@@ -0,0 +1,222 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
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 ExternalTriggerPolicyTest {
@Test
void pathVariableEventParameterShouldBeExternal() {
EntryPoint entryPoint = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/{event}")
.className("com.example.Api")
.methodName("trigger")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
TriggerPoint trigger = TriggerPoint.builder()
.event("eventString.toUpperCase()")
.build();
assertThat(ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, "com.example.Api.trigger", "event", null)).isTrue();
}
@Test
void pathVariableEnumParameterShouldBeInternal(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
import org.springframework.web.bind.annotation.*;
public class Api {
@PostMapping("/api/order/{event}")
public void transition(@PathVariable OrderEvent event) {
fire(event);
}
void fire(OrderEvent event) {}
}
enum OrderEvent { PAY, SHIP }
""";
Files.writeString(tempDir.resolve("Api.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
EntryPoint entryPoint = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/order/{event}")
.className("com.example.Api")
.methodName("transition")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("event")
.type("com.example.OrderEvent")
.annotations(List.of("PathVariable"))
.build()))
.build();
TriggerPoint trigger = TriggerPoint.builder()
.event("event")
.build();
assertThat(ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, "com.example.Api.transition", "event", context)).isFalse();
}
@Test
void concreteResolvedPathVariableEndpointShouldBeInternal() {
EntryPoint entryPoint = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/machine/ORDER/transition/PAY")
.className("com.example.Api")
.methodName("transition")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("machineType")
.type("String")
.annotations(List.of("PathVariable"))
.build(),
EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.valueOf(eventString.toUpperCase())")
.build();
assertThat(ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, "com.example.Api.transition", "event", null)).isFalse();
}
@Test
void requestBodyEnumShouldBeInternal(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
import org.springframework.web.bind.annotation.*;
public class Api {
@PostMapping("/pay")
public void pay(@RequestBody OrderEvent event) {
fire(event);
}
void fire(OrderEvent event) {}
}
enum OrderEvent { PAY }
""";
Files.writeString(tempDir.resolve("Api.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
EntryPoint entryPoint = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /pay")
.className("com.example.Api")
.methodName("pay")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.build();
assertThat(ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, "com.example.Api.pay", "event", context)).isFalse();
}
@Test
void concreteEnumLiteralFromDedicatedEndpointShouldBeInternal() {
EntryPoint entryPoint = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/order/pay")
.className("com.example.Api")
.methodName("pay")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.build();
assertThat(ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, "com.example.Api.pay", null, null)).isFalse();
}
@Test
void requestBodyRecordEnumComponentShouldBeInternal(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
import org.springframework.web.bind.annotation.*;
public class Api {
@PostMapping("/pay")
public void pay(@RequestBody OrderRequest request) {
fire(request.event());
}
void fire(OrderEvent event) {}
}
record OrderRequest(OrderEvent event) {}
enum OrderEvent { PAY }
""";
Files.writeString(tempDir.resolve("Api.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
EntryPoint entryPoint = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /pay")
.className("com.example.Api")
.methodName("pay")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.build();
assertThat(ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, "com.example.Api.pay", "event", context)).isFalse();
}
@Test
void requestBodyNestedDtoEnumFieldShouldBeInternal(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
import org.springframework.web.bind.annotation.*;
public class Api {
@PostMapping("/pay")
public void pay(@RequestBody OrderRequest request) {
fire(request.details().event());
}
void fire(OrderEvent event) {}
}
record OrderRequest(OrderDetails details) {}
record OrderDetails(OrderEvent event) {}
enum OrderEvent { PAY }
""";
Files.writeString(tempDir.resolve("Api.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
EntryPoint entryPoint = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /pay")
.className("com.example.Api")
.methodName("pay")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.build();
assertThat(ExternalTriggerPolicy.isExternalFromSource(
entryPoint, trigger, "com.example.Api.pay", "event", context)).isFalse();
}
}

View File

@@ -0,0 +1,28 @@
package click.kamil.springstatemachineexporter.analysis.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class FunctionalInterfaceTypesTest {
@Test
void shouldRecognizeSupplierParameter() {
assertThat(FunctionalInterfaceTypes.isFunctionalInterface("java.util.function.Supplier"))
.isTrue();
}
@Test
void shouldAcceptLambdaIntoSupplierParameter() {
assertThat(FunctionalInterfaceTypes.isProvablyResolvedCallSiteArgument(
"() -> OrderEvent.CANCEL", "java.util.function.Supplier"))
.isTrue();
}
@Test
void shouldRejectNonFunctionalParameter() {
assertThat(FunctionalInterfaceTypes.isProvablyResolvedCallSiteArgument(
"OrderEvent.CANCEL", "com.example.OrderEvent"))
.isFalse();
}
}

View File

@@ -130,6 +130,43 @@ class GenericEventDetectorControlFlowTest {
assertThat(shipTrigger.getSourceState()).isEqualTo("PAID"); assertThat(shipTrigger.getSourceState()).isEqualTo("PAID");
} }
@Test
void shouldDetectStateFromSwitchExpressionArrowSyntax(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderService {
private StateMachine sm;
public void processOrder(OrderState state) {
switch (state) {
case PENDING -> sm.sendEvent(OrderEvent.PAY);
case PAID -> sm.sendEvent(OrderEvent.SHIP);
}
}
}
class StateMachine {
public void sendEvent(OrderEvent e) {}
}
enum OrderState { PENDING, PAID }
enum OrderEvent { PAY, SHIP }
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), Collections.emptyList());
List<TriggerPoint> triggers = detector.detect(
(org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderService").getRoot());
assertThat(triggers).hasSize(2);
assertThat(triggers.stream().filter(t -> t.getEvent().equals("OrderEvent.PAY")).findFirst().orElseThrow()
.getSourceState()).isEqualTo("PENDING");
assertThat(triggers.stream().filter(t -> t.getEvent().equals("OrderEvent.SHIP")).findFirst().orElseThrow()
.getSourceState()).isEqualTo("PAID");
}
@Test @Test
void shouldNotTreatMachineTypeDiscriminatorAsSourceState(@TempDir Path tempDir) throws IOException { void shouldNotTreatMachineTypeDiscriminatorAsSourceState(@TempDir Path tempDir) throws IOException {
String source = """ String source = """
@@ -158,4 +195,66 @@ class GenericEventDetectorControlFlowTest {
assertThat(triggers).hasSize(1); assertThat(triggers).hasSize(1);
assertThat(triggers.get(0).getSourceState()).isNull(); assertThat(triggers.get(0).getSourceState()).isNull();
} }
@Test
void shouldDetectSourceStateFromLiteralSendEventArgument(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderService {
private StateMachine sm;
public void pay() {
sm.sendEvent(OrderEvent.PAY, OrderState.PENDING);
}
}
class StateMachine {
public void sendEvent(OrderEvent e, OrderState s) {}
}
enum OrderState { PENDING }
enum OrderEvent { PAY }
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), Collections.emptyList());
List<TriggerPoint> triggers = detector.detect(
(org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderService").getRoot());
assertThat(triggers).hasSize(1);
assertThat(triggers.get(0).getSourceState()).isEqualTo("PENDING");
}
@Test
void shouldNotGuessSourceStateFromVariableSendEventArgument(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderService {
private StateMachine sm;
public void pay(OrderState current) {
sm.sendEvent(OrderEvent.PAY, current);
}
}
class StateMachine {
public void sendEvent(OrderEvent e, OrderState s) {}
}
enum OrderState { PENDING }
enum OrderEvent { PAY }
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), Collections.emptyList());
List<TriggerPoint> triggers = detector.detect(
(org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderService").getRoot());
assertThat(triggers).hasSize(1);
assertThat(triggers.get(0).getSourceState()).isNull();
}
} }

View File

@@ -43,4 +43,136 @@ class GenericEventDetectorFireTest {
assertThat(triggers.get(0).getMethodName()).isEqualTo("pay"); assertThat(triggers.get(0).getMethodName()).isEqualTo("pay");
assertThat(triggers.get(0).getEvent()).contains("PAY"); assertThat(triggers.get(0).getEvent()).contains("PAY");
} }
@Test
void shouldInferSourceStateFromNonGuardIfLiteralBranch(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
enum OrderState { NEW, PAID }
enum OrderEvent { PAY }
class OrderService {
private final StateMachineFacade facade;
OrderService(StateMachineFacade facade) { this.facade = facade; }
public void payWhenPaid(OrderState state) {
if (state == OrderState.PAID) {
facade.sendEvent(OrderEvent.PAY);
}
}
}
class StateMachineFacade {
public void sendEvent(OrderEvent event) {}
}
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
GenericEventDetector detector = new GenericEventDetector(context, context.getConstantResolver(), List.of());
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration("com.example.OrderService");
List<TriggerPoint> triggers = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot());
assertThat(triggers).hasSize(1);
assertThat(triggers.get(0).getSourceState()).isEqualTo("PAID");
}
@Test
void shouldInferStateMachineIdFromQualifierOnSendEventReceiver(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
enum OrderEvent { PAY }
class OrderService {
@org.springframework.beans.factory.annotation.Qualifier("paymentStateMachine")
private final StateMachine stateMachine;
OrderService(StateMachine stateMachine) { this.stateMachine = stateMachine; }
public void pay() {
stateMachine.sendEvent(OrderEvent.PAY);
}
}
class StateMachine {
public void sendEvent(OrderEvent event) {}
}
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
GenericEventDetector detector = new GenericEventDetector(context, context.getConstantResolver(), List.of());
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration("com.example.OrderService");
List<TriggerPoint> triggers = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot());
assertThat(triggers).hasSize(1);
assertThat(triggers.get(0).getStateMachineId()).isEqualTo("paymentStateMachine");
}
@Test
void shouldInferStateMachineIdFromQualifierOnAutowiredSetter(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
enum OrderEvent { PAY }
class OrderService {
private StateMachine stateMachine;
@org.springframework.beans.factory.annotation.Autowired
public void setStateMachine(
@org.springframework.beans.factory.annotation.Qualifier("paymentStateMachine")
StateMachine stateMachine) {
this.stateMachine = stateMachine;
}
public void pay() {
stateMachine.sendEvent(OrderEvent.PAY);
}
}
class StateMachine {
public void sendEvent(OrderEvent event) {}
}
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
GenericEventDetector detector = new GenericEventDetector(context, context.getConstantResolver(), List.of());
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration("com.example.OrderService");
List<TriggerPoint> triggers = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot());
assertThat(triggers).hasSize(1);
assertThat(triggers.get(0).getStateMachineId()).isEqualTo("paymentStateMachine");
}
@Test
void shouldNotInferSourceStateFromVariableOnlyIfCondition(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
enum OrderState { NEW, PAID }
enum OrderEvent { PAY }
class OrderService {
private final StateMachineFacade facade;
OrderService(StateMachineFacade facade) { this.facade = facade; }
public void payWhenPaid(boolean isPaid) {
if (isPaid) {
facade.sendEvent(OrderEvent.PAY);
}
}
}
class StateMachineFacade {
public void sendEvent(OrderEvent event) {}
}
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
GenericEventDetector detector = new GenericEventDetector(context, context.getConstantResolver(), List.of());
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration("com.example.OrderService");
List<TriggerPoint> triggers = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot());
assertThat(triggers).hasSize(1);
assertThat(triggers.get(0).getSourceState()).isNull();
}
} }

View File

@@ -525,6 +525,18 @@ class HeuristicCallGraphEngineTypeTest {
EntryPoint entry = EntryPoint.builder() EntryPoint entry = EntryPoint.builder()
.className("click.kamil.enterprise.web.StateMachineController") .className("click.kamil.enterprise.web.StateMachineController")
.methodName("transition") .methodName("transition")
.name("POST /api/machine/{machineType}/transition/{event}")
.parameters(java.util.List.of(
EntryPoint.Parameter.builder()
.name("machineType")
.type("String")
.annotations(java.util.List.of("PathVariable"))
.build(),
EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(java.util.List.of("PathVariable"))
.build()))
.build(); .build();
TriggerPoint trigger = TriggerPoint.builder() TriggerPoint trigger = TriggerPoint.builder()
.className("click.kamil.enterprise.web.StateMachineDispatcher") .className("click.kamil.enterprise.web.StateMachineDispatcher")
@@ -534,7 +546,15 @@ class HeuristicCallGraphEngineTypeTest {
List<CallChain> chains = builder.findChains(List.of(entry), List.of(trigger)); List<CallChain> chains = builder.findChains(List.of(entry), List.of(trigger));
assertThat(chains).isNotEmpty(); assertThat(chains).isNotEmpty();
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) java.util.Set<String> polymorphicEvents = new java.util.LinkedHashSet<>();
.containsExactlyInAnyOrder("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: DocumentEvent.*>", "<SYMBOLIC: UserEvent.*>"); for (CallChain chain : chains) {
if (chain.getTriggerPoint().getPolymorphicEvents() != null) {
polymorphicEvents.addAll(chain.getTriggerPoint().getPolymorphicEvents());
}
assertThat(chain.getTriggerPoint().getEvent())
.doesNotContain("true ?");
}
assertThat(polymorphicEvents)
.contains("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: DocumentEvent.*>", "<SYMBOLIC: UserEvent.*>");
} }
} }

View File

@@ -0,0 +1,58 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class InheritanceCallTargetResolverTest {
@Test
void shouldResolveInheritedMethodOnDeclaringParentType(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class Child extends Parent {
void run() { dispatch(); }
}
class Parent {
protected void dispatch() {}
}
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration child = context.getTypeDeclaration("com.example.Child");
MethodDeclaration run = context.findMethodDeclaration(child, "run", false);
String target = InheritanceCallTargetResolver.resolveInstanceMethod(context, child, "dispatch");
assertThat(target).isEqualTo("com.example.Parent.dispatch");
assertThat(run).isNotNull();
}
@Test
void shouldResolveSuperCallToParentMethod(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class Child extends Parent {
void run() { super.dispatch(); }
}
class Parent {
protected void dispatch() {}
}
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration child = context.getTypeDeclaration("com.example.Child");
String target = InheritanceCallTargetResolver.resolveSuperMethod(context, child, "dispatch");
assertThat(target).isEqualTo("com.example.Parent.dispatch");
}
}

View File

@@ -0,0 +1,112 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Interface accessor widen must not union all implementation return values at ambiguous call sites.
*/
class InterfaceAccessorPolyPipelineTest {
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
private static final String EVENT_TYPE = "com.example.OrderEvent";
private static final String STATE_TYPE = "com.example.OrderState";
@Test
void linkerShouldNotMatchAllTransitionsForInterfaceTypedEventSource(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
EventHandler handler;
public void dispatch() { handler.fire(); }
}
class EventHandler {
StateMachine machine;
EventSource source;
void fire() { machine.sendEvent(source.getEvent()); }
}
interface EventSource { OrderEvent getEvent(); }
class PaySource implements EventSource {
public OrderEvent getEvent() { return OrderEvent.PAY; }
}
class ShipSource implements EventSource {
public OrderEvent getEvent() { return OrderEvent.SHIP; }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
CallChain raw = resolveChain(context, "com.example.ApiController", "dispatch",
"com.example.StateMachine", "sendEvent", EngineKind.JDT);
CallChain linked = linkChain(
context,
raw,
MACHINE_CONFIG,
EVENT_TYPE,
STATE_TYPE,
pay,
ship);
assertThat(linked.getTriggerPoint().isAmbiguous()).isTrue();
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
}
@Test
void linkerShouldResolveConcretePaySourceImplementation(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
EventHandler handler;
public void pay() { handler.fire(new PaySource()); }
}
class EventHandler {
StateMachine machine;
void fire(EventSource source) { machine.sendEvent(source.getEvent()); }
}
interface EventSource { OrderEvent getEvent(); }
class PaySource implements EventSource {
public OrderEvent getEvent() { return OrderEvent.PAY; }
}
class ShipSource implements EventSource {
public OrderEvent getEvent() { return OrderEvent.SHIP; }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
CallChain linked = resolveLinkAndAssertSingleMatch(
context,
"com.example.ApiController",
"pay",
"com.example.StateMachine",
"sendEvent",
MACHINE_CONFIG,
EVENT_TYPE,
STATE_TYPE,
EVENT_TYPE + ".PAY",
EngineKind.JDT,
pay,
ship);
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
}
}

View File

@@ -304,6 +304,18 @@ class JdtCallGraphEngineIntegrationTest {
EntryPoint entry = EntryPoint.builder() EntryPoint entry = EntryPoint.builder()
.className("click.kamil.enterprise.web.StateMachineController") .className("click.kamil.enterprise.web.StateMachineController")
.methodName("transition") .methodName("transition")
.name("POST /api/machine/{machineType}/transition/{event}")
.parameters(java.util.List.of(
EntryPoint.Parameter.builder()
.name("machineType")
.type("String")
.annotations(java.util.List.of("PathVariable"))
.build(),
EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(java.util.List.of("PathVariable"))
.build()))
.build(); .build();
TriggerPoint trigger = TriggerPoint.builder() TriggerPoint trigger = TriggerPoint.builder()
.className("click.kamil.enterprise.web.StateMachineDispatcher") .className("click.kamil.enterprise.web.StateMachineDispatcher")
@@ -313,7 +325,15 @@ class JdtCallGraphEngineIntegrationTest {
List<CallChain> chains = jdtEngine.findChains(List.of(entry), List.of(trigger)); List<CallChain> chains = jdtEngine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).isNotEmpty(); assertThat(chains).isNotEmpty();
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) java.util.Set<String> polymorphicEvents = new java.util.LinkedHashSet<>();
.containsExactlyInAnyOrder("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: DocumentEvent.*>", "<SYMBOLIC: UserEvent.*>"); for (CallChain chain : chains) {
if (chain.getTriggerPoint().getPolymorphicEvents() != null) {
polymorphicEvents.addAll(chain.getTriggerPoint().getPolymorphicEvents());
}
assertThat(chain.getTriggerPoint().getEvent())
.doesNotContain("true ?");
}
assertThat(polymorphicEvents)
.contains("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: DocumentEvent.*>", "<SYMBOLIC: UserEvent.*>");
} }
} }

View File

@@ -73,6 +73,106 @@ class JdtCentralDispatcherParityTest {
assertThat(jdtLink.getEvent()).isEqualTo(heuristicLink.getEvent()); assertThat(jdtLink.getEvent()).isEqualTo(heuristicLink.getEvent());
} }
@Test
void jdtShouldMatchHeuristicForStaticRoutingHelper(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CommandGateway gateway;
public void pay() { gateway.routeAndPay(); }
public void ship() { gateway.routeAndShip(); }
}
class CommandGateway {
CentralDispatcher dispatcher;
void routeAndPay() { dispatcher.dispatch("ORDER", OrderRoutingHelper.payAction()); }
void routeAndShip() { dispatcher.dispatch("ORDER", OrderRoutingHelper.shipAction()); }
}
class OrderRoutingHelper {
static String payAction() { return "PAY"; }
static String shipAction() { return "SHIP"; }
}
class CentralDispatcher {
void dispatch(String domain, String action) {
StateMachine sm = new StateMachine();
sm.sendEvent(OrderEvent.valueOf(action));
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain heuristicPay = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE, EngineKind.HEURISTIC);
CallChain jdtPay = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE, EngineKind.JDT);
CallChain heuristicShip = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE, EngineKind.HEURISTIC);
CallChain jdtShip = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE, EngineKind.JDT);
assertThat(jdtPay.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrderElementsOf(heuristicPay.getTriggerPoint().getPolymorphicEvents());
assertThat(jdtShip.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrderElementsOf(heuristicShip.getTriggerPoint().getPolymorphicEvents());
}
@Test
void jdtShouldMatchHeuristicForPublicDtoFieldAccess(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CommandGateway gateway;
public void submit() { gateway.submit(new OrderRequest(OrderEvent.SUBMIT)); }
}
class CommandGateway {
void submit(OrderRequest req) {
StateMachine sm = new StateMachine();
sm.sendEvent(req.type);
}
}
class OrderRequest {
public final OrderEvent type;
OrderRequest(OrderEvent type) { this.type = type; }
}
enum OrderEvent { SUBMIT, PAY }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain heuristic = resolveChain(context, CONTROLLER, "submit", STATE_MACHINE, EngineKind.HEURISTIC);
CallChain jdt = resolveChain(context, CONTROLLER, "submit", STATE_MACHINE, EngineKind.JDT);
assertThat(jdt.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrderElementsOf(heuristic.getTriggerPoint().getPolymorphicEvents());
}
@Test
void jdtShouldMatchHeuristicForRecordComponentAccess(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CommandGateway gateway;
public void submit() { gateway.submit(new OrderRequest(OrderEvent.SUBMIT)); }
}
class CommandGateway {
void submit(OrderRequest req) {
StateMachine sm = new StateMachine();
sm.sendEvent(req.type());
}
}
record OrderRequest(OrderEvent type) {}
enum OrderEvent { SUBMIT, PAY }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain heuristic = resolveChain(context, CONTROLLER, "submit", STATE_MACHINE, EngineKind.HEURISTIC);
CallChain jdt = resolveChain(context, CONTROLLER, "submit", STATE_MACHINE, EngineKind.JDT);
assertThat(jdt.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrderElementsOf(heuristic.getTriggerPoint().getPolymorphicEvents());
}
@Test @Test
void jdtShouldMatchHeuristicForDtoCrossHop(@TempDir Path tempDir) throws Exception { void jdtShouldMatchHeuristicForDtoCrossHop(@TempDir Path tempDir) throws Exception {
String source = """ String source = """

View File

@@ -0,0 +1,185 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Heuristic vs JDT parity for inheritance dispatcher patterns ({@code super}, protected, deep chains).
*/
class JdtInheritanceDispatcherParityTest {
private static final String API = "com.example.ApiEndpoint";
private static final String CONTROLLER = "com.example.ChildController";
private static final String STATE_MACHINE = "com.example.StateMachine";
@Test
void jdtShouldMatchHeuristicForSuperOverrideSwitchDelegation(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiEndpoint {
CustomHandler handler;
public void triggerCheckout() { handler.handleEvent(ActionType.BEGIN_CHECKOUT); }
}
abstract class BaseHandler {
StateMachine machine;
public void handleEvent(ActionType action) {
TransitionEnum transition = switch (action) {
case BEGIN_CHECKOUT -> TransitionEnum.CHECKOUT_STARTED;
case ABORT_CHECKOUT -> TransitionEnum.CHECKOUT_CANCELLED;
};
machine.fire(transition);
}
}
class CustomHandler extends BaseHandler {
@Override
public void handleEvent(ActionType action) {
if (action == null) return;
super.handleEvent(action);
}
}
class StateMachine { public void fire(TransitionEnum event) {} }
enum ActionType { BEGIN_CHECKOUT, ABORT_CHECKOUT }
enum TransitionEnum { CHECKOUT_STARTED, CHECKOUT_CANCELLED }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, API, "triggerCheckout", STATE_MACHINE, "fire");
CallChain chain = resolveChain(context, API, "triggerCheckout", STATE_MACHINE, "fire", EngineKind.HEURISTIC);
assertMethodChainContains(chain,
"com.example.ApiEndpoint.triggerCheckout",
"com.example.CustomHandler.handleEvent",
"com.example.BaseHandler.handleEvent");
assertPolyEvents(chain, "TransitionEnum.CHECKOUT_STARTED");
}
@Test
void jdtShouldMatchHeuristicForImplicitInheritedProtectedMethod(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ChildController extends ParentController {
StateMachine machine;
public void process() { machine.fire(getInheritedEvent()); }
}
abstract class ParentController {
protected TransitionEnum getInheritedEvent() { return TransitionEnum.STATE_P; }
}
class StateMachine { public void fire(TransitionEnum event) {} }
enum TransitionEnum { STATE_P, STATE_Q }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, CONTROLLER, "process", STATE_MACHINE, "fire");
assertPolyEvents(
resolveChain(context, CONTROLLER, "process", STATE_MACHINE, "fire", EngineKind.JDT),
"TransitionEnum.STATE_P");
}
@Test
void jdtShouldMatchHeuristicForThisCallToInheritedProtectedMethod(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ChildController extends ParentController {
StateMachine machine;
public void process() { machine.fire(this.getInheritedEvent()); }
}
abstract class ParentController {
protected TransitionEnum getInheritedEvent() { return TransitionEnum.STATE_P; }
}
class StateMachine { public void fire(TransitionEnum event) {} }
enum TransitionEnum { STATE_P, STATE_Q }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, CONTROLLER, "process", STATE_MACHINE, "fire");
assertPolyEvents(
resolveChain(context, CONTROLLER, "process", STATE_MACHINE, "fire", EngineKind.JDT),
"TransitionEnum.STATE_P");
}
@Test
void jdtShouldMatchHeuristicForSuperProtectedSendHelper(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ChildController extends BaseController {
public void trigger() { super.send("MY_EVENT"); }
}
abstract class BaseController {
StateMachine machine;
protected void send(String event) { machine.sendEvent(event); }
}
class StateMachine { public void sendEvent(String event) {} }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, "com.example.ChildController", "trigger", "com.example.StateMachine", "sendEvent");
CallChain chain = resolveChain(
context, "com.example.ChildController", "trigger", "com.example.StateMachine", "sendEvent", EngineKind.JDT);
assertMethodChainContains(chain,
"com.example.ChildController.trigger",
"com.example.BaseController.send");
assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("MY_EVENT");
}
@Test
void jdtShouldMatchHeuristicForSuperGetterArgument(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ChildController extends ParentController {
StateMachine machine;
@Override public TransitionEnum getEvent() { return TransitionEnum.STATE_X; }
public void process() { machine.fire(super.getEvent()); }
}
class ParentController {
public TransitionEnum getEvent() { return TransitionEnum.STATE_Y; }
}
class StateMachine { public void fire(TransitionEnum event) {} }
enum TransitionEnum { STATE_X, STATE_Y }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, CONTROLLER, "process", STATE_MACHINE, "fire");
assertPolyEvents(
resolveChain(context, CONTROLLER, "process", STATE_MACHINE, "fire", EngineKind.JDT),
"TransitionEnum.STATE_Y");
}
@Test
void jdtShouldMatchHeuristicForControllerServiceCentralDispatcher(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
OrderService orderService;
public void pay() { orderService.handlePay(); }
}
class OrderService {
CentralDispatcher dispatcher;
protected void handlePay() { dispatcher.route("PAY"); }
}
class CentralDispatcher {
StateMachine machine;
public void route(String action) {
machine.sendEvent(OrderEvent.valueOf(action));
}
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent");
CallChain chain = resolveChain(
context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent", EngineKind.JDT);
assertMethodChainContains(chain,
"com.example.ApiController.pay",
"com.example.OrderService.handlePay",
"com.example.CentralDispatcher.route");
}
}

View File

@@ -98,6 +98,55 @@ class JsonRoundTripCanonicalizationTest {
.isEqualTo("com.example.order.OrderEvent.PAY"); .isEqualTo("com.example.order.OrderEvent.PAY");
} }
@Test
void shouldResolvePropertyPlaceholdersOnJsonReExportWithSourceContext(@TempDir Path tempDir) throws Exception {
writeSampleProject(tempDir);
TriggerPoint trigger = TriggerPoint.builder()
.event("${app.event}")
.sourceState("${app.state}")
.className("com.example.web.OrderController")
.methodName("pay")
.sourceFile("OrderController.java")
.polymorphicEvents(List.of("${app.event}"))
.eventTypeFqn("com.example.order.OrderEvent")
.stateTypeFqn("com.example.order.OrderState")
.build();
AnalysisResult shortForm = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(shortTransition(
"OrderEvent.PAY",
"OrderState.NEW",
"OrderState.PAID")))
.metadata(CodebaseMetadata.builder()
.triggers(List.of(trigger))
.properties(Map.of("default", Map.of(
"app.event", "OrderEvent.PAY",
"app.state", "OrderState.NEW")))
.build())
.build();
Path jsonFile = tempDir.resolve("machine.json");
Files.writeString(jsonFile, new JsonExporter().export(shortForm, ExportOptions.builder().build()));
Path outputDir = tempDir.resolve("out");
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runJsonExporter(jsonFile, outputDir, List.of("json"));
AnalysisResult roundTripped = new JsonImportService().importAnalysisResult(
outputDir.resolve("com.example.config.OrderStateMachineConfiguration")
.resolve("com.example.config.OrderStateMachineConfiguration.json"));
TriggerPoint resolvedTrigger = roundTripped.getMetadata().getTriggers().get(0);
assertThat(resolvedTrigger.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(resolvedTrigger.getSourceState()).isEqualTo("com.example.order.OrderState.NEW");
assertThat(resolvedTrigger.getPolymorphicEvents())
.containsExactly("com.example.order.OrderEvent.PAY");
}
@Test @Test
void shouldRelinkMatchedTransitionsAfterPreCanonicalizingShortFormJson(@TempDir Path tempDir) throws Exception { void shouldRelinkMatchedTransitionsAfterPreCanonicalizingShortFormJson(@TempDir Path tempDir) throws Exception {
writeSampleProject(tempDir); writeSampleProject(tempDir);

View File

@@ -0,0 +1,886 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ProjectModuleGraph;
import click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver;
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 java.util.Map;
import java.util.Set;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Path binding expansion must resolve helpers declared in sibling modules without executing the build.
*/
class MultiModulePathBindingExpanderTest {
@Test
void shouldExpandEventUsingHelperFromSiblingModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/util"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/util/EventNormalizer.java"), """
package com.example.util;
public class EventNormalizer {
public static String normalize(String raw) {
return raw.toUpperCase();
}
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/OrderGateway.java"), """
package com.example;
import com.example.util.EventNormalizer;
public class OrderController {
OrderGateway gateway;
public void transition(String event) {
gateway.trigger(event);
}
}
class OrderGateway {
void trigger(String event) {
OrderEvent.valueOf(EventNormalizer.normalize(event));
}
}
enum OrderEvent { PAY, SHIP }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
assertThat(context.getTypeDeclaration("com.example.util.EventNormalizer")).isNotNull();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
.methodName("transition")
.name("POST /api/order/{event}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("event"))
.containsExactlyInAnyOrder("PAY", "SHIP");
assertThat(EntryPointBindingExpander.withResolvedPath(
entryPoint, Map.of("event", "PAY")).getName())
.isEqualTo("POST /api/order/PAY");
}
@Test
void shouldExpandEventUsingHelperFromSiblingMavenModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-maven");
Files.createDirectories(root.resolve(".git"));
Files.writeString(root.resolve("pom.xml"), """
<project><groupId>com.example</groupId><artifactId>order-maven</artifactId></project>
""");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/util"));
Files.writeString(apiModule.resolve("pom.xml"), """
<project><groupId>com.example</groupId><artifactId>api-module</artifactId></project>
""");
Files.writeString(apiModule.resolve("src/main/java/com/example/util/EventNormalizer.java"), """
package com.example.util;
public class EventNormalizer {
public static String normalize(String raw) {
return raw.toUpperCase();
}
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("pom.xml"), """
<project>
<groupId>com.example</groupId>
<artifactId>core-module</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>api-module</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>
""");
Files.writeString(coreModule.resolve("src/main/java/com/example/OrderGateway.java"), """
package com.example;
import com.example.util.EventNormalizer;
public class OrderController {
OrderGateway gateway;
public void transition(String event) {
gateway.trigger(event);
}
}
class OrderGateway {
void trigger(String event) {
OrderEvent.valueOf(EventNormalizer.normalize(event));
}
}
enum OrderEvent { PAY, SHIP }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
assertThat(context.getTypeDeclaration("com.example.util.EventNormalizer")).isNotNull();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
.methodName("transition")
.name("POST /api/order/{event}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("event"))
.containsExactlyInAnyOrder("PAY", "SHIP");
}
@Test
void shouldExpandMachineTypeFromIfEqualsOnSiblingModuleHelper(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("enterprise-style");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/util"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/util/MachineTypeNormalizer.java"), """
package com.example.util;
public class MachineTypeNormalizer {
public static String normalize(String raw) {
return raw.trim();
}
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/MachineController.java"), """
package com.example;
import com.example.util.MachineTypeNormalizer;
public class MachineController {
StateMachineDispatcher dispatcher;
public void transition(String machineType, String event) {
dispatcher.dispatch(machineType, event);
}
}
class StateMachineDispatcher {
void dispatch(String machineType, String eventString) {
if ("ORDER".equalsIgnoreCase(MachineTypeNormalizer.normalize(machineType))) {
OrderEvent.valueOf(eventString.toUpperCase());
} else if ("DOCUMENT".equalsIgnoreCase(MachineTypeNormalizer.normalize(machineType))) {
DocumentEvent.valueOf(eventString.toUpperCase());
}
}
}
enum OrderEvent { PAY, SHIP }
enum DocumentEvent { SUBMIT, APPROVE }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.MachineController")
.methodName("transition")
.name("POST /api/machine/{machineType}/transition/{event}")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("machineType")
.type("String")
.annotations(List.of("PathVariable"))
.build(),
EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants.stream().map(v -> v.get("machineType")).distinct())
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
assertThat(variants.stream()
.filter(v -> "ORDER".equals(v.get("machineType")))
.map(v -> v.get("event"))
.toList())
.containsExactlyInAnyOrder("PAY", "SHIP");
}
@Test
void shouldExpandMachineTypeFromBooleanPredicateInSiblingModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("enterprise-style");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/util"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/util/MachineTypeMatcher.java"), """
package com.example.util;
public class MachineTypeMatcher {
public static boolean matchesOrder(String raw) {
return "ORDER".equalsIgnoreCase(raw.trim());
}
public static boolean matchesDocument(String raw) {
return "DOCUMENT".equalsIgnoreCase(raw.trim());
}
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/MachineController.java"), """
package com.example;
import com.example.util.MachineTypeMatcher;
public class MachineController {
StateMachineDispatcher dispatcher;
public void transition(String machineType, String event) {
dispatcher.dispatch(machineType, event);
}
}
class StateMachineDispatcher {
void dispatch(String machineType, String eventString) {
if (MachineTypeMatcher.matchesOrder(machineType)) {
OrderEvent.valueOf(eventString.toUpperCase());
} else if (MachineTypeMatcher.matchesDocument(machineType)) {
DocumentEvent.valueOf(eventString.toUpperCase());
}
}
}
enum OrderEvent { PAY, SHIP }
enum DocumentEvent { SUBMIT, APPROVE }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.MachineController")
.methodName("transition")
.name("POST /api/machine/{machineType}/transition/{event}")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("machineType")
.type("String")
.annotations(List.of("PathVariable"))
.build(),
EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants.stream().map(v -> v.get("machineType")).distinct())
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
}
@Test
void shouldExpandCommandKeyFromCrossClassStaticMapInSiblingModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/CommandRoutes.java"), """
package com.example.routes;
import java.util.Map;
public class CommandRoutes {
public static final Map<String, String> ROUTES = Map.of(
"order.pay", "OrderEvent.PAY",
"order.ship", "OrderEvent.SHIP");
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
package com.example;
import com.example.routes.CommandRoutes;
public class GenericCommandController {
OrderGateway gateway;
public void execute(String commandKey) {
gateway.trigger(commandKey);
}
}
class OrderGateway {
void trigger(String commandKey) {
OrderEvent event = OrderEvent.valueOf(CommandRoutes.ROUTES.get(commandKey));
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.GenericCommandController")
.methodName("execute")
.name("POST /api/commands/{commandKey}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("commandKey")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
}
@Test
void shouldExpandMachineTypeFromCrossClassConstantInSiblingModuleIfEquals(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("enterprise-style");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/constants"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/constants/MachineTypes.java"), """
package com.example.constants;
public class MachineTypes {
public static final String ORDER = "ORDER";
public static final String DOCUMENT = "DOCUMENT";
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/MachineController.java"), """
package com.example;
import com.example.constants.MachineTypes;
public class MachineController {
StateMachineDispatcher dispatcher;
public void transition(String machineType, String event) {
dispatcher.dispatch(machineType, event);
}
}
class StateMachineDispatcher {
void dispatch(String machineType, String eventString) {
if (MachineTypes.ORDER.equalsIgnoreCase(machineType)) {
OrderEvent.valueOf(eventString.toUpperCase());
} else if (MachineTypes.DOCUMENT.equalsIgnoreCase(machineType)) {
DocumentEvent.valueOf(eventString.toUpperCase());
}
}
}
enum OrderEvent { PAY, SHIP }
enum DocumentEvent { SUBMIT, APPROVE }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.MachineController")
.methodName("transition")
.name("POST /api/machine/{machineType}/transition/{event}")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("machineType")
.type("String")
.annotations(List.of("PathVariable"))
.build(),
EntryPoint.Parameter.builder()
.name("event")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants.stream().map(v -> v.get("machineType")).distinct())
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
}
@Test
void shouldExpandCommandKeyFromSwitchExpressionWithSiblingModuleCaseConstants(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/constants"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/constants/CommandKeys.java"), """
package com.example.constants;
public class CommandKeys {
public static final String PAY_KEY = "order.pay";
public static final String SHIP_KEY = "order.ship";
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
package com.example;
import com.example.constants.CommandKeys;
public class GenericCommandController {
OrderGateway gateway;
public void execute(String commandKey) {
gateway.trigger(commandKey);
}
}
class OrderGateway {
void trigger(String commandKey) {
DomainCommand command = switch (commandKey) {
case CommandKeys.PAY_KEY -> DomainCommand.ORDER_PAY;
case CommandKeys.SHIP_KEY -> DomainCommand.ORDER_SHIP;
default -> throw new IllegalArgumentException("Unknown command: " + commandKey);
};
StateMachine sm = new StateMachine();
sm.sendEvent(command);
}
}
enum DomainCommand { ORDER_PAY, ORDER_SHIP }
class StateMachine { void sendEvent(DomainCommand command) {} }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.GenericCommandController")
.methodName("execute")
.name("POST /api/commands/{commandKey}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("commandKey")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
}
@Test
void shouldExpandCommandKeyFromSiblingModuleConstantMapKeys(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/constants"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/constants/CommandKeys.java"), """
package com.example.constants;
public class CommandKeys {
public static final String PAY_KEY = "order.pay";
public static final String SHIP_KEY = "order.ship";
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
package com.example;
import com.example.constants.CommandKeys;
import java.util.Map;
public class GenericCommandController {
OrderGateway gateway;
public void execute(String commandKey) {
gateway.trigger(commandKey);
}
}
class OrderGateway {
private static final Map<String, OrderEvent> ROUTES = Map.of(
CommandKeys.PAY_KEY, OrderEvent.PAY,
CommandKeys.SHIP_KEY, OrderEvent.SHIP);
void trigger(String commandKey) {
StateMachine sm = new StateMachine();
sm.sendEvent(ROUTES.get(commandKey));
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.GenericCommandController")
.methodName("execute")
.name("POST /api/commands/{commandKey}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("commandKey")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
}
@Test
void shouldExpandCommandKeyFromStaticBlockPutAllInSiblingModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/CommandRoutes.java"), """
package com.example.routes;
import java.util.Map;
public class CommandRoutes {
public static final Map<String, String> SEED = Map.of(
"order.pay", "OrderEvent.PAY",
"order.ship", "OrderEvent.SHIP");
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
package com.example;
import com.example.routes.CommandRoutes;
import java.util.HashMap;
import java.util.Map;
public class GenericCommandController {
OrderGateway gateway;
public void execute(String commandKey) {
gateway.trigger(commandKey);
}
}
class OrderGateway {
private static final Map<String, OrderEvent> ROUTES;
static {
ROUTES = new HashMap<>();
ROUTES.putAll(CommandRoutes.SEED);
}
void trigger(String commandKey) {
OrderEvent event = OrderEvent.valueOf(ROUTES.get(commandKey));
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.GenericCommandController")
.methodName("execute")
.name("POST /api/commands/{commandKey}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("commandKey")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
}
@Test
void shouldExpandCommandKeyFromCrossModuleStaticFactoryReturningMapOf(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/CommandRoutes.java"), """
package com.example.routes;
import java.util.Map;
public class CommandRoutes {
public static Map<String, String> seed() {
return Map.of(
"order.pay", "OrderEvent.PAY",
"order.ship", "OrderEvent.SHIP");
}
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
package com.example;
import com.example.routes.CommandRoutes;
import java.util.Map;
public class GenericCommandController {
OrderGateway gateway;
public void execute(String commandKey) {
gateway.trigger(commandKey);
}
}
class OrderGateway {
private static final Map<String, OrderEvent> ROUTES = CommandRoutes.seed();
void trigger(String commandKey) {
OrderEvent event = OrderEvent.valueOf(ROUTES.get(commandKey));
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.GenericCommandController")
.methodName("execute")
.name("POST /api/commands/{commandKey}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("commandKey")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
}
@Test
void shouldExpandCommandKeyFromInheritedStaticBlockMapInSiblingModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/BaseRoutes.java"), """
package com.example.routes;
import java.util.HashMap;
import java.util.Map;
public abstract class BaseRoutes {
protected static final Map<String, String> ROUTES;
static {
ROUTES = new HashMap<>();
ROUTES.put("order.pay", "OrderEvent.PAY");
ROUTES.put("order.ship", "OrderEvent.SHIP");
}
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
package com.example;
import com.example.routes.BaseRoutes;
public class GenericCommandController {
OrderGateway gateway;
public void execute(String commandKey) {
gateway.trigger(commandKey);
}
}
class OrderGateway extends BaseRoutes {
void trigger(String commandKey) {
OrderEvent event = OrderEvent.valueOf(ROUTES.get(commandKey));
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.GenericCommandController")
.methodName("execute")
.name("POST /api/commands/{commandKey}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("commandKey")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
}
}

View File

@@ -1,6 +1,10 @@
package click.kamil.springstatemachineexporter.analysis.service; package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
@@ -30,4 +34,72 @@ class TypeResolverTest {
assertThat(resolver.getParameterIndex("com.example.StateMachine.sendEvent", "e")).isEqualTo(-1); assertThat(resolver.getParameterIndex("com.example.StateMachine.sendEvent", "e")).isEqualTo(-1);
assertThat(resolver.getParameterIndex("com.example.StateMachine.sendEvent", "payload")).isEqualTo(-1); assertThat(resolver.getParameterIndex("com.example.StateMachine.sendEvent", "payload")).isEqualTo(-1);
} }
@Test
void shouldFailClosedOnAmbiguousSimpleTypeCompatibility(@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 { SHIP }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeResolver resolver = new TypeResolver(context);
assertThat(resolver.isTypeCompatible("a.OrderEvent", "OrderEvent")).isFalse();
assertThat(resolver.isTypeCompatible("a.OrderEvent", "b.OrderEvent")).isFalse();
assertThat(resolver.isTypeCompatible("a.OrderEvent", "a.OrderEvent")).isTrue();
}
@Test
void shouldUpgradeSimpleHeuristicTypeToBindingFqnForUserTypes(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("com/example/order"));
Files.writeString(tempDir.resolve("com/example/order/OrderEvent.java"), """
package com.example.order;
public enum OrderEvent { PAY }
""");
Files.writeString(tempDir.resolve("App.java"), """
package com.example;
import com.example.order.OrderEvent;
class StateMachine {
void sendEvent(OrderEvent e) {}
}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
TypeResolver resolver = new TypeResolver(context);
TypeDeclaration td = context.getTypeDeclaration("com.example.StateMachine");
MethodDeclaration md = context.findMethodDeclaration(td, "sendEvent", true);
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(0);
assertThat(resolver.resolveTypeToFqn(param.getType(), (CompilationUnit) td.getRoot()))
.isEqualTo("com.example.order.OrderEvent");
}
@Test
void shouldKeepHeuristicStringTypeWhenBindingsWouldReturnJavaLangString(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("App.java"), """
package com.example;
class Api {
void handle(String value) {}
}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
TypeResolver resolver = new TypeResolver(context);
TypeDeclaration td = context.getTypeDeclaration("com.example.Api");
MethodDeclaration md = context.findMethodDeclaration(td, "handle", true);
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(0);
assertThat(resolver.resolveTypeToFqn(param.getType(), (CompilationUnit) td.getRoot()))
.isEqualTo("String");
}
} }

View File

@@ -0,0 +1,42 @@
package click.kamil.springstatemachineexporter.analysis.service;
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.Map;
import static org.assertj.core.api.Assertions.assertThat;
class VariableTracerBranchFilterTest {
@Test
void shouldReturnBranchLocalInitializerWithoutCrossBranchTernary(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
class StateMachineDispatcher {
void fireDocument(String eventString) {
DocumentEvent event = DocumentEvent.valueOf(eventString.toUpperCase());
send(event);
}
void send(DocumentEvent event) {}
}
enum DocumentEvent { SUBMIT }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
String traced = engine.variableTracer.traceLocalVariable(
"com.example.StateMachineDispatcher.fireDocument",
"event",
Map.of("machineType", "DOCUMENT"));
assertThat(traced).contains("DocumentEvent.valueOf");
}
}

View File

@@ -322,6 +322,46 @@ class AnalysisCanonicalFormValidatorTest {
.contains("metadata.callChains[0].matchedTransitions"); .contains("metadata.callChains[0].matchedTransitions");
} }
@Test
void shouldFailWhenDedicatedEndpointHasConcreteLiteralEventButNoMatchedTransitions() {
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(transition(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderState.NEW",
"com.example.order.OrderState.PAID")))
.metadata(CodebaseMetadata.builder()
.callChains(List.of(CallChain.builder()
.entryPoint(click.kamil.springstatemachineexporter.analysis.model.EntryPoint.builder()
.type(click.kamil.springstatemachineexporter.analysis.model.EntryPoint.Type.REST)
.name("POST /api/machine/order/pay")
.className("com.example.web.StateMachineController")
.methodName("payOrder")
.build())
.triggerPoint(TriggerPoint.builder()
.event("com.example.order.OrderEvent.PAY")
.className("com.example.web.StateMachineDispatcher")
.methodName("payOrder")
.sourceFile("StateMachineDispatcher.java")
.eventTypeFqn("com.example.order.OrderEvent")
.build())
.build()))
.build())
.build();
List<AnalysisCanonicalFormValidator.Violation> violations =
AnalysisCanonicalFormValidator.validateWithMachineTypes(
result,
new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState", "com.example.order.OrderEvent"));
assertThat(violations)
.extracting(AnalysisCanonicalFormValidator.Violation::path)
.contains("metadata.callChains[0].matchedTransitions");
}
@Test @Test
void shouldAllowSymbolicPolymorphicEventsWithoutMatchedTransitions() { void shouldAllowSymbolicPolymorphicEventsWithoutMatchedTransitions() {
AnalysisResult result = AnalysisResult.builder() AnalysisResult result = AnalysisResult.builder()
@@ -352,6 +392,47 @@ class AnalysisCanonicalFormValidatorTest {
.noneMatch(v -> v.path().contains("matchedTransitions")); .noneMatch(v -> v.path().contains("matchedTransitions"));
} }
@Test
void shouldSkipImportStyleCanonicalCheckWhenSimpleNameIsAmbiguous(@TempDir Path tempDir) throws IOException {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.createDirectories(tempDir.resolve("com/example/config"));
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 }");
Files.writeString(tempDir.resolve("a/OrderState.java"), "package a; public enum OrderState { NEW, PAID }");
Files.writeString(tempDir.resolve("com/example/config/OrderStateMachineConfiguration.java"),
"""
package com.example.config;
import a.OrderEvent;
import a.OrderState;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
public class OrderStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
}
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.transitions(List.of(transition(
"a.OrderEvent.PAY",
"a.OrderState.NEW",
"a.OrderState.PAID")))
.metadata(CodebaseMetadata.builder()
.triggers(List.of(TriggerPoint.builder()
.event("OrderEvent.PAY")
.className("com.example.web.Controller")
.methodName("pay")
.sourceFile("Controller.java")
.build()))
.build())
.build();
assertThat(AnalysisCanonicalFormValidator.validate(result, context)).isEmpty();
}
@Test @Test
void shouldSkipValidationForStringStateMachines(@TempDir Path tempDir) throws IOException { void shouldSkipValidationForStringStateMachines(@TempDir Path tempDir) throws IOException {
Path configPkg = tempDir.resolve("com/example/config"); Path configPkg = tempDir.resolve("com/example/config");

View File

@@ -231,6 +231,66 @@ class CodebaseContextTest {
assertThat(context.findEntryPointClasses(List.of("EnableStateMachine"))).isEmpty(); assertThat(context.findEntryPointClasses(List.of("EnableStateMachine"))).isEmpty();
} }
@Test
void getEnumValuesShouldFailClosedForAmbiguousSimpleEnumName() throws IOException {
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 { SHIP }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(context.getEnumValues("OrderEvent"))
.as("ambiguous simple enum name must not resolve to arbitrary package")
.isNull();
assertThat(context.getEnumValues("a.OrderEvent")).containsExactly("a.OrderEvent.PAY");
assertThat(context.getEnumValues("b.OrderEvent")).containsExactly("b.OrderEvent.SHIP");
assertThat(context.isAmbiguousSimpleName("OrderEvent")).isTrue();
assertThat(context.isAmbiguousSimpleName("a.OrderEvent")).isFalse();
}
@Test
void getImplementationsShouldFailClosedForAmbiguousSimpleTypeName() throws IOException {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/Service.java"),
"package a; public interface Service {}");
Files.writeString(tempDir.resolve("a/AServiceImpl.java"),
"package a; public class AServiceImpl implements Service {}");
Files.writeString(tempDir.resolve("b/Service.java"),
"package b; public interface Service {}");
Files.writeString(tempDir.resolve("b/BServiceImpl.java"),
"package b; public class BServiceImpl implements Service {}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(context.getImplementations("Service"))
.as("ambiguous simple interface name must not widen to arbitrary impls")
.isEmpty();
assertThat(context.getImplementations("a.Service")).containsExactly("a.AServiceImpl");
assertThat(context.getImplementations("b.Service")).containsExactly("b.BServiceImpl");
}
@Test
void areSameTypeOrUnambiguousSimpleMatchShouldFailClosedForAmbiguousTypes() throws IOException {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/Dispatcher.java"),
"package a; public class Dispatcher { public void dispatch() {} }");
Files.writeString(tempDir.resolve("b/Dispatcher.java"),
"package b; public class Dispatcher { public void dispatch() {} }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(context.areSameTypeOrUnambiguousSimpleMatch("a.Dispatcher", "b.Dispatcher")).isFalse();
assertThat(context.areClassesPolymorphicallyCompatible("a.Dispatcher", "b.Dispatcher")).isFalse();
}
@Test @Test
void shouldFindClassWithFullyQualifiedAnnotation() throws IOException { void shouldFindClassWithFullyQualifiedAnnotation() throws IOException {
String source = """ String source = """

View File

@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.exporter;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow; import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
import click.kamil.springstatemachineexporter.analysis.model.FlowStep;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.model.Event; import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State; import click.kamil.springstatemachineexporter.model.State;
@@ -35,7 +36,7 @@ class JsonInterchangeContractTest {
.startStates(Set.of("com.example.order.OrderState.NEW")) .startStates(Set.of("com.example.order.OrderState.NEW"))
.endStates(Set.of("com.example.order.OrderState.PAID")) .endStates(Set.of("com.example.order.OrderState.PAID"))
.renderChoicesAsDiamonds(true) .renderChoicesAsDiamonds(true)
.flows(List.of(BusinessFlow.builder().name("Pay flow").steps(List.of("PAY")).build())) .flows(List.of(BusinessFlow.builder().name("Pay flow").steps(List.of(FlowStep.ofEvent("PAY"))).build()))
.metadata(CodebaseMetadata.empty()) .metadata(CodebaseMetadata.empty())
.build(); .build();

View File

@@ -0,0 +1,32 @@
package click.kamil.springstatemachineexporter.exporter;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TransitionLinkKeyTest {
@Test
void shouldBuildFnFormLinkKeyFromPackageCanonicalIdentifiers() {
assertThat(TransitionLinkKey.build(
"com.example.order.OrderState.NEW",
"com.example.order.OrderEvent.PAY"))
.isEqualTo("OrderState_NEW__OrderEvent_PAY");
}
@Test
void shouldBuildFqnFormLinkKeyWhenRequested() {
assertThat(TransitionLinkKey.build(
"com.example.order.OrderState.NEW",
"com.example.order.OrderEvent.PAY",
EnumFormat.fqn,
EnumFormat.fqn))
.isEqualTo("com_example_order_OrderState_NEW__com_example_order_OrderEvent_PAY");
}
@Test
void shouldBuildEventOnlyLinkKeyWhenSourceMissing() {
assertThat(TransitionLinkKey.build(null, "com.example.order.OrderEvent.PAY"))
.isEqualTo("__OrderEvent_PAY");
}
}

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