13 Commits

Author SHA1 Message Date
613dfffd53 Align enum rawName with fn-form labels during canonicalization.
Derive transition and state rawName from package-canonical fullIdentifier so JSON exports stay consistent when bare constants or mismatched short forms appear in source.

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 10:14:23 +02:00
250 changed files with 20186 additions and 7995 deletions

View File

@@ -8,16 +8,46 @@ Static analysis tool to extract and visualize Spring State Machines with deep co
## Usage
Analysis is **source-first**: the exporter reads project source and build metadata (`pom.xml`, `settings.gradle`) statically. It does **not** run Maven or Gradle against the project being analyzed.
### 1. Extract Metadata (JSON)
```bash
./gradlew :state_machine_exporter:run --args="-i ./my-project -o ./out -f json"
```
Common options:
| Flag | Description |
|------|-------------|
| `-i, --input` | Project directory to scan (defaults to `.`) |
| `-j, --json` | Re-render from an existing JSON export instead of scanning source |
| `-o, --output` | Output directory (default `./out`) |
| `-f, --format` | Comma-separated formats: `json`, `dot`, `puml`, `scxml` |
| `-p, --profiles` | Spring profiles for property placeholder resolution |
| `--event`, `--state` | Enum formatting: `fn` (default), `fqn`, `sn` |
| `--debug` | Verbose analysis logging and unresolved chain diagnostics |
| `--no-diamonds` | Render choice/junction states as regular nodes (default: diamonds) |
| `--include-generated-sources` | Also scan `target/` / `build/` generated `.java` trees (default: on) |
| `--no-inline-accessors` | Disable accessor indexing/inlining (enabled by default) |
### 2. Generate Interactive Portal (HTML)
```bash
./gradlew :state_machine_exporter_html:run --args="-i ./my-project -o ./out_html"
```
*Supports `--profiles prod` to resolve Spring property placeholders.*
HTML-specific options:
| Flag | Description |
|------|-------------|
| `-f, --flows` | Custom `flows.json` path |
| `-m, --machine` | Export only machines whose name contains this filter |
| `--open` | Open the generated portal in the default browser |
| `--no-metadata-pane` | Hide the left metadata pane (entry points, flows) |
| `--no-diamonds` | Render choice pseudostates as normal nodes |
| `--debug` | Same diagnostic mode as the core exporter |
| `--include-generated-sources` | Same generated-source scanning as the core exporter |
| `--no-inline-accessors` | Disable accessor indexing/inlining (enabled by default) |
| `-p, --profiles` | Spring profiles for property resolution |
## Business Flows
Define sequence of events in `src/main/resources/flows.json`:
@@ -36,3 +66,16 @@ Define sequence of events in `src/main/resources/flows.json`:
- `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`).
- `transitions.actions[].internalLogic`: Extracted source code/lambdas.
- `metadata.properties`: Dictionary of all detected Spring profiles.
## Static analysis: accessor inlining
During scan, the exporter builds an **accessor index** of trivial getters, setters, record components, and constant-returning methods. At analysis time, `AccessorResolver` uses this index first (O(1) lookup), then falls back to constructor/field tracing and method-body evaluation.
This improves resolution of DTO/event chains such as `request.getPayload().getType()` without running Maven or Gradle on the target project.
```bash
# Enabled by default; disable for debugging or A/B comparison
./gradlew :state_machine_exporter:run --args="-i ./my-project -o ./out --no-inline-accessors"
```
Pipeline components live under `analysis/pipeline/` (`AccessorResolver`, `ResolutionBudget`, `MethodInvocationUnwrapper`, `FieldInitializerFinder`, `LibraryUnwrapRegistry`).

View File

@@ -15,6 +15,7 @@ include ':state_machines:inheritance_extra_functions3_state_machine'
include ':state_machines:simple_state_machine'
include ':state_machines:extended_analysis_sample'
include ':state_machines:inheritance_sample'
include ':state_machines:layered_dispatcher_sample'
include ':state_machines:ultimate_ecosystem_sm'
include ':state_machines:enterprise_order_system'
include ':state_machines:multi_module_sample:api-module'

View File

@@ -48,10 +48,15 @@ dependencies {
testImplementation 'org.assertj:assertj-core:3.27.7'
implementation 'net.sourceforge.plantuml:plantuml:1.2024.3'
// Library types for JDT binding when analyzing cloned source (never runs the target project's build).
implementation 'org.springframework.boot:spring-boot-starter:3.2.0'
implementation 'org.springframework.statemachine:spring-statemachine-starter:3.2.0'
}
tasks.named('test') {
useJUnitPlatform()
maxParallelForks = Math.max(1, Runtime.runtime.availableProcessors().intdiv(2) ?: 1)
systemProperty "updateGolden", System.getProperty("updateGolden")
}

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Deep audit of golden JSON analysis exports."""
import json
import re
from collections import defaultdict
from pathlib import Path
GOLDEN = Path(__file__).resolve().parent.parent / "src/test/resources/golden"
PH = re.compile(r"\$\{")
def fn_form(identifier):
if not identifier:
return identifier
last = identifier.rfind(".")
if last <= 0:
return identifier
prev = identifier.rfind(".", 0, last)
return identifier[prev + 1 :] if prev > 0 else identifier
def sid(st):
if isinstance(st, dict):
return st.get("fullIdentifier") or st.get("rawName")
return st
def eid(ev):
if isinstance(ev, dict):
return ev.get("fullIdentifier") or ev.get("rawName")
return ev
def audit_file(path, data):
name = data.get("name", path.stem)
issues = []
st_fqn = data.get("stateTypeFqn")
ev_fqn = data.get("eventTypeFqn")
transitions = data.get("transitions") or []
starts = list(data.get("startStates") or [])
ends = list(data.get("endStates") or [])
states = data.get("states") or []
meta = data.get("metadata") or {}
is_pkg_enum = st_fqn and "." in st_fqn and st_fqn not in ("String", "java.lang.String")
is_string = st_fqn in (None, "String", "java.lang.String") or (
not st_fqn and not ev_fqn
)
if PH.search(json.dumps(data)):
issues.append("BUG: unresolved ${placeholder} in export")
# Duplicate fullIdentifiers in states[]
by_full = defaultdict(list)
for s in states:
by_full[sid(s)].append(s.get("rawName"))
for fid, raws in by_full.items():
if fid and len(raws) > 1 and len(set(raws)) > 1:
issues.append(f"NOISE: duplicate state entry {fid!r} with rawNames {raws}")
# Collect transition state/event ids
t_events, t_sources, t_targets = set(), set(), set()
for t in transitions:
if eid(t.get("event")):
t_events.add(eid(t.get("event")))
for s in t.get("sourceStates") or []:
t_sources.add(sid(s))
for s in t.get("targetStates") or []:
t_targets.add(sid(s))
graph = t_sources | t_targets
# startStates canonical consistency
for ss in starts:
if is_string and ss and not ss.startswith("String."):
if any(x.startswith("String.") for x in graph):
issues.append(f"BUG: startStates {ss!r} bare but transitions use String.* form")
if ss not in graph and fn_form(ss) not in {fn_form(x) for x in graph}:
issues.append(f"WARN: startStates {ss!r} not in transition graph")
# endStates
for es in ends:
if es not in graph and fn_form(es) not in {fn_form(x) for x in graph}:
issues.append(f"WARN: endStates {es!r} not in transition graph")
# Enum canonical fullIdentifiers
if is_pkg_enum:
for i, t in enumerate(transitions):
for side in ("sourceStates", "targetStates"):
for s in t.get(side) or []:
fid = sid(s)
if fid and not fid.startswith(st_fqn + ".") and not fid.startswith('"'):
if "." in fid and not fid.startswith("<"):
issues.append(f"BUG: transitions[{i}].{side} {fid!r} not under {st_fqn}")
ev = eid(t.get("event"))
if ev and ev_fqn and "." in ev_fqn and not ev.startswith("<") and not ev.startswith(ev_fqn + "."):
if ev not in ("event",) and "." in ev and not ev.startswith(ev_fqn):
issues.append(f"BUG: transitions[{i}].event {ev!r} not under {ev_fqn}")
for i, tr in enumerate(meta.get("triggers") or []):
for field in ("event", "sourceState"):
val = tr.get(field)
if not val or val in ("event", "payload", "customMessage", "eventProvider"):
continue
type_fqn = ev_fqn if field == "event" else st_fqn
if type_fqn and "." in val and not val.startswith("<") and not val.startswith(type_fqn + "."):
if field == "sourceState" and val not in graph and fn_form(val) not in {fn_form(x) for x in graph}:
issues.append(f"BUG: triggers[{i}].sourceState {val!r} not a machine state")
elif field == "event" and type_fqn and val.isupper() is False and "." not in val and not val.startswith("<"):
if val not in ("event", "payload", "customMessage", "eventProvider", "true", "false"):
issues.append(f"INFO: triggers[{i}].event dynamic {val!r}")
# matchedTransitions
for i, chain in enumerate(meta.get("callChains") or []):
for j, mt in enumerate(chain.get("matchedTransitions") or []):
me, ms, mtgt = mt.get("event"), mt.get("sourceState"), mt.get("targetState")
ok = False
for t in transitions:
if fn_form(eid(t.get("event"))) != fn_form(me):
continue
srcs = [sid(s) for s in t.get("sourceStates") or []]
tgts = [sid(s) for s in t.get("targetStates") or []]
if (not ms or any(fn_form(x) == fn_form(ms) for x in srcs)) and (
not mtgt or any(fn_form(x) == fn_form(mtgt) for x in tgts)
):
ok = True
break
if not ok:
issues.append(
f"BUG: callChains[{i}].matchedTransitions[{j}] ({ms!r},{me!r},{mtgt!r}) no transition"
)
# String machine: mixed NEW vs String.NEW
if is_string or st_fqn == "String":
all_ids = set(graph) | set(starts) | set(ends) | {sid(s) for s in states}
for x in list(all_ids):
if not x:
continue
bare = x.strip('"')
if bare and "." not in bare and f"String.{bare}" in all_ids:
issues.append(f"BUG: mixed bare {x!r} and String.{bare}")
# stateTypeFqn missing on enum machine
if is_pkg_enum and not st_fqn:
issues.append("BUG: enum machine missing stateTypeFqn")
return name, issues
def main():
total = 0
for path in sorted(GOLDEN.rglob("*.json")):
data = json.loads(path.read_text())
machine, issues = audit_file(path, data)
rel = path.relative_to(GOLDEN)
bugs = [i for i in issues if i.startswith("BUG")]
warns = [i for i in issues if i.startswith("WARN")]
noise = [i for i in issues if i.startswith("NOISE")]
info = [i for i in issues if i.startswith("INFO")]
if bugs or warns:
print(f"\n{'='*70}\n{rel}\n machine: {machine}")
for i in bugs + warns + noise + info:
print(f" [{i.split(':')[0]}] {i.split(': ', 1)[-1]}")
total += len(bugs) + len(warns)
print(f"\nTotal BUG+WARN: {total}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,31 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.validation.AnalysisCanonicalFormValidator;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
/**
* Final enrichment step: fail fast when enum identifiers in the analysis model are not
* package-canonical. Disable with {@code -Danalyzer.canonical-form-validation.enabled=false}.
*/
@Slf4j
public class AnalysisCanonicalFormEnricher implements AnalysisEnricher {
private final boolean enabled;
public AnalysisCanonicalFormEnricher() {
this.enabled = Boolean.parseBoolean(
System.getProperty("analyzer.canonical-form-validation.enabled", "true"));
}
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (!enabled || result == null || context == null) {
return;
}
log.debug("Validating canonical enum identifiers for {}", result.getName());
AnalysisCanonicalFormValidator.enforce(result, context);
}
}

View File

@@ -3,11 +3,15 @@ package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
public class CallChainEnricher implements AnalysisEnricher {
@@ -15,12 +19,28 @@ public class CallChainEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with call chains", result.getName());
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
List<TriggerPoint> canonicalTriggers = intelligence.findTriggerPoints().stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
.collect(Collectors.toList());
List<CallChain> chains = intelligence.findCallChains(
result.getMetadata().getEntryPoints(),
result.getMetadata().getTriggers()
canonicalTriggers
);
chains = chains.stream()
.map(chain -> chain.getTriggerPoint() == null
? chain
: chain.toBuilder()
.triggerPoint(MachineEnumCanonicalizer.canonicalizeTriggerPoint(
chain.getTriggerPoint(), machineTypes))
.build())
.collect(Collectors.toList());
chains = MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context);
result.addMetadata(CodebaseMetadata.builder()
.callChains(chains)
.build());

View File

@@ -17,7 +17,8 @@ public class EntryPointEnricher implements AnalysisEnricher {
log.info("Enriching {} with entry points", result.getName());
List<EntryPoint> entryPoints = intelligence.findEntryPoints();
entryPoints = MachineScopeFilter.filterEntryPointsForMachine(entryPoints, result.getName(), context);
result.addMetadata(CodebaseMetadata.builder()
.entryPoints(entryPoints)
.build());

View File

@@ -0,0 +1,142 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Filters codebase-wide analysis artifacts to those relevant for a single state machine config.
*/
public final class MachineScopeFilter {
private static final BeanResolutionEngine ROUTING = new HeuristicBeanResolutionEngine();
private MachineScopeFilter() {
}
public static List<TriggerPoint> filterTriggersForMachine(
List<TriggerPoint> triggers, String machineName, CodebaseContext context) {
if (triggers == null || machineName == null) {
return triggers == null ? List.of() : triggers;
}
List<TriggerPoint> filtered = new ArrayList<>();
for (TriggerPoint trigger : triggers) {
if (isTriggerForMachine(trigger, machineName, context)) {
filtered.add(trigger);
}
}
return filtered;
}
public static List<CallChain> filterCallChainsForMachine(
List<CallChain> chains, String machineName, CodebaseContext context) {
if (chains == null || machineName == null) {
return chains == null ? List.of() : chains;
}
List<CallChain> filtered = new ArrayList<>();
for (CallChain chain : chains) {
if (ROUTING.isRoutedToCorrectMachine(chain, machineName, context)) {
filtered.add(chain);
}
}
return filtered;
}
public static List<EntryPoint> filterEntryPointsForMachine(
List<EntryPoint> entryPoints, String machineName, CodebaseContext context) {
if (entryPoints == null || machineName == null) {
return entryPoints == null ? List.of() : entryPoints;
}
String machineDomain = extractMachineDomainKey(machineName);
List<EntryPoint> filtered = new ArrayList<>();
for (EntryPoint entryPoint : entryPoints) {
if (isEntryPointForMachine(entryPoint, machineDomain, machineName, context)) {
filtered.add(entryPoint);
}
}
return filtered;
}
private static boolean isEntryPointForMachine(
EntryPoint entryPoint, String machineDomain, String machineName, CodebaseContext context) {
if (entryPoint == null) {
return false;
}
String path = entryPoint.getMetadata() != null ? entryPoint.getMetadata().get("path") : null;
if (path != null) {
String pathDomain = inferDomainFromPath(path);
if (pathDomain != null && machineDomain != null && !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 String inferDomainFromPath(String path) {
String lower = path.toLowerCase(Locale.ROOT);
if (lower.contains("/orders") || lower.contains("order.")) {
return "ORDER";
}
if (lower.contains("/documents") || lower.contains("document.")) {
return "DOCUMENT";
}
return null;
}
private static boolean domainsMatch(String pathDomain, String machineDomain) {
String normalizedPath = normalizeDomainKey(pathDomain);
String normalizedMachine = normalizeDomainKey(machineDomain);
return normalizedPath.equalsIgnoreCase(normalizedMachine)
|| normalizedMachine.startsWith(normalizedPath)
|| normalizedPath.startsWith(normalizedMachine);
}
private static String extractMachineDomainKey(String machineName) {
String simple = machineName.substring(machineName.lastIndexOf('.') + 1);
String upper = simple.toUpperCase(Locale.ROOT);
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) {
int idx = upper.indexOf(marker);
if (idx > 0) {
return normalizeDomainKey(upper.substring(0, idx));
}
}
int stateIdx = upper.indexOf("STATE");
if (stateIdx > 0) {
return normalizeDomainKey(upper.substring(0, stateIdx));
}
return normalizeDomainKey(upper);
}
private static String normalizeDomainKey(String domain) {
if (domain == null || domain.isEmpty()) {
return domain;
}
String upper = domain.toUpperCase(Locale.ROOT);
if (upper.startsWith("STANDARD") && upper.length() > "STANDARD".length()) {
return upper.substring("STANDARD".length());
}
return upper;
}
private static boolean isTriggerForMachine(TriggerPoint trigger, String machineName, CodebaseContext context) {
if (trigger == null) {
return false;
}
CallChain probe = CallChain.builder().triggerPoint(trigger).methodChain(List.of()).build();
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context);
}
}

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.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
@@ -14,16 +16,14 @@ import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
public class TransitionLinkerEnricher implements AnalysisEnricher {
private static final ExportOptions LINK_FORMAT_OPTIONS = ExportOptions.builder().build();
private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
private final EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine();
@@ -43,6 +43,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
continue;
}
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
updatedChains.add(chain);
continue;
}
String triggerEvent = simplify(tp.getEvent());
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null;
@@ -50,9 +55,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
String smEventForLink = formatEventForLink(t.getEvent());
String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) {
String smSourceForLink = formatSourceForLink(smSourceState);
String smSourceForLink = canonicalState(smSourceState);
String smSource = simplify(smSourceForLink);
if (triggerSource == null || triggerSource.equals(smSource)) {
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
@@ -67,7 +72,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
} else {
for (State smTargetState : t.getTargetStates()) {
String targetForLink = formatSourceForLink(smTargetState);
String targetForLink = canonicalState(smTargetState);
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceForLink)
.targetState(targetForLink)
@@ -92,10 +97,14 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
}
// Update the metadata with the new call chains
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
result.getMetadata().getTriggers(), result.getName(), context);
List<EntryPoint> scopedEntryPoints = MachineScopeFilter.filterEntryPointsForMachine(
result.getMetadata().getEntryPoints(), result.getName(), context);
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
.triggers(result.getMetadata().getTriggers())
.entryPoints(result.getMetadata().getEntryPoints())
.triggers(scopedTriggers)
.entryPoints(scopedEntryPoints)
.callChains(updatedChains)
.properties(result.getMetadata().getProperties())
.build();
@@ -109,91 +118,30 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
private boolean isConstraintCompatible(String constraint, String machineName) {
if (constraint == null || machineName == null) return true;
String cleanMachine = machineName.substring(machineName.lastIndexOf('.') + 1).toUpperCase();
if (cleanMachine.endsWith("STATEMACHINECONFIGURATION")) {
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "STATEMACHINECONFIGURATION".length());
}
if (cleanMachine.endsWith("STATEMACHINE")) {
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "STATEMACHINE".length());
}
if (cleanMachine.endsWith("CONFIG")) {
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "CONFIG".length());
}
String expr = constraint;
java.util.regex.Pattern p = java.util.regex.Pattern.compile("[\"']([a-zA-Z0-9_-]+)[\"']");
java.util.regex.Matcher matcher = p.matcher(constraint);
java.util.Set<String> literals = new java.util.HashSet<>();
while (matcher.find()) {
literals.add(matcher.group(1));
}
for (String m : literals) {
boolean isCurrent = m.equalsIgnoreCase(cleanMachine);
String replacement = isCurrent ? "true" : "false";
String regex1 = "(?i)[\"']?" + m + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\([^)]*\\)";
String regex2 = "(?i)[a-zA-Z0-9._]+\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?" + m + "[\"']?\\s*\\)";
String regex3 = "(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + m + "[\"']?";
String regex4 = "(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + m + "[\"']?";
expr = expr.replaceAll(regex1, replacement);
expr = expr.replaceAll(regex2, replacement);
expr = expr.replaceAll(regex3, replacement);
expr = expr.replaceAll(regex4, isCurrent ? "false" : "true");
}
try {
return BooleanEvaluator.evaluate(expr);
} catch (Exception e) {
return !expr.contains("false");
}
}
private static class BooleanEvaluator {
public static boolean evaluate(String expr) {
expr = expr.replaceAll("\\s+", "");
return parseExpr(expr);
}
private static boolean parseExpr(String s) {
if (s.isEmpty()) return true;
int parenDepth = 0;
for (int i = s.length() - 1; i >= 0; i--) {
char c = s.charAt(i);
if (c == ')') parenDepth++;
else if (c == '(') parenDepth--;
else if (parenDepth == 0 && i > 0 && s.charAt(i) == '|' && s.charAt(i - 1) == '|') {
return parseExpr(s.substring(0, i - 1)) || parseExpr(s.substring(i + 1));
}
}
parenDepth = 0;
for (int i = s.length() - 1; i >= 0; i--) {
char c = s.charAt(i);
if (c == ')') parenDepth++;
else if (c == '(') parenDepth--;
else if (parenDepth == 0 && i > 0 && s.charAt(i) == '&' && s.charAt(i - 1) == '&') {
return parseExpr(s.substring(0, i - 1)) && parseExpr(s.substring(i + 1));
}
}
if (s.startsWith("!")) {
return !parseExpr(s.substring(1));
}
if (s.startsWith("(") && s.endsWith(")")) {
return parseExpr(s.substring(1, s.length() - 1));
}
if (s.equalsIgnoreCase("true")) return true;
if (s.equalsIgnoreCase("false")) return false;
if (constraint == null || machineName == null) {
return true;
}
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
constraint, extractMachineDomainKey(machineName));
}
private String extractMachineDomainKey(String machineName) {
String simple = machineName.substring(machineName.lastIndexOf('.') + 1);
String upper = simple.toUpperCase();
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) {
int idx = upper.indexOf(marker);
if (idx > 0) {
return upper.substring(0, idx);
}
}
int stateIdx = upper.indexOf("STATE");
if (stateIdx > 0) {
return upper.substring(0, stateIdx);
}
return upper;
}
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>();
@@ -217,20 +165,18 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return simplified;
}
private String formatSourceForLink(State state) {
if (state == null) return null;
return simplifyForLink(LINK_FORMAT_OPTIONS.formatState(state));
private String canonicalState(State state) {
if (state == null) {
return null;
}
return state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName();
}
private String formatEventForLink(click.kamil.springstatemachineexporter.model.Event event) {
if (event == null) return null;
return LINK_FORMAT_OPTIONS.formatEvent(event);
}
private String simplifyForLink(String name) {
if (name == null) return "";
if (name.contains("\"")) return name;
return name.replaceAll("[^a-zA-Z0-9_.]", "");
private String canonicalEvent(click.kamil.springstatemachineexporter.model.Event event) {
if (event == null) {
return null;
}
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
}
private boolean isGuardedContains(String smEvent, String triggerEvent) {

View File

@@ -0,0 +1,63 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.stream.Collectors;
/**
* Canonicalizes trigger-side enum identifiers ({@code event}, {@code polymorphicEvents},
* {@code sourceState}) using the owning machine config's {@code <State, Event>} types.
*/
@Slf4j
public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || context == null) {
return;
}
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
List<TriggerPoint> triggers = result.getMetadata().getTriggers();
List<TriggerPoint> canonicalTriggers = triggers == null ? null : triggers.stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
.collect(Collectors.toList());
List<CallChain> callChains = result.getMetadata().getCallChains();
List<CallChain> canonicalChains = callChains == null ? null : callChains.stream()
.map(chain -> {
if (chain.getTriggerPoint() == null) {
return chain;
}
return chain.toBuilder()
.triggerPoint(MachineEnumCanonicalizer.canonicalizeTriggerPoint(
chain.getTriggerPoint(), machineTypes))
.build();
})
.collect(Collectors.toList());
if (canonicalTriggers == null && canonicalChains == null) {
return;
}
log.debug("Canonicalized triggers for {}", result.getName());
result.setMetadata(CodebaseMetadata.builder()
.triggers(canonicalTriggers != null ? canonicalTriggers : triggers)
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(canonicalChains != null ? canonicalChains : callChains)
.properties(result.getMetadata().getProperties())
.build());
}
}

View File

@@ -2,11 +2,14 @@ package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
public class TriggerEnricher implements AnalysisEnricher {
@@ -14,11 +17,15 @@ public class TriggerEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with triggers", result.getName());
List<TriggerPoint> triggers = intelligence.findTriggerPoints();
// Initially, we just add all triggers found in the codebase to the metadata.
// In later steps of Phase 2, we will filter them by State Machine ID.
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
List<TriggerPoint> triggers = intelligence.findTriggerPoints().stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
.collect(Collectors.toList());
triggers = MachineScopeFilter.filterTriggersForMachine(triggers, result.getName(), context);
result.addMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.triggers(triggers)
.build());

View File

@@ -12,56 +12,51 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
return false;
}
if (triggerPoint.isExternal()) {
return true;
if (click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers
.isLifecycle(triggerPoint.getEvent())) {
return false;
}
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
String rawTriggerEvent = triggerPoint.getEvent();
if (triggerPoint.isExternal()) {
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null
? triggerPoint.getPolymorphicEvents()
: java.util.Collections.emptyList();
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
return false;
}
}
if (rawTriggerEvent != null && rawTriggerEvent.startsWith("<SYMBOLIC: ") && rawTriggerEvent.endsWith(".*>")) {
String targetType = rawTriggerEvent.substring(11, rawTriggerEvent.length() - 3);
if (smEventRaw.startsWith(targetType + ".")) {
return true;
}
if (smEventRaw.contains(".")) {
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
return true;
}
}
return false;
}
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
for (String pe : polyEvents) {
if (matchEventNames(pe, smEventRaw, triggerPoint.getEventTypeFqn())) {
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
continue;
}
String eventTypeFqn = triggerPoint.getEventTypeFqn();
if (pe.contains(".") && isStringTypeOrPrimitive(eventTypeFqn)) {
eventTypeFqn = null;
}
if (matchEventNames(pe, smEventRaw, eventTypeFqn)) {
return true;
}
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
String targetType = pe.substring(11, pe.length() - 3);
if (smEventRaw.startsWith(targetType + ".")) {
return true;
}
if (smEventRaw.contains(".")) {
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
return true;
}
if (!pe.contains(".") && smEventRaw.contains(".")) {
String smConst = constantName(smEventRaw);
if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null
&& !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
return enumTypesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
}
}
}
if (rawTriggerEvent != null && rawTriggerEvent.contains(".valueOf(")) {
String enumClass = rawTriggerEvent.substring(0, rawTriggerEvent.indexOf(".valueOf("));
if (enumClass.contains(" ")) {
enumClass = enumClass.substring(enumClass.lastIndexOf(" ") + 1);
}
String smClass = smEventRaw.contains(".") ? smEventRaw.substring(0, smEventRaw.lastIndexOf('.')) : smEventRaw;
if (enumClass.equals(smClass) || enumClass.endsWith("." + smClass) || smClass.endsWith("." + enumClass)) {
return true;
}
return false;
}
@@ -91,7 +86,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
if (!tConst.equals(smConst)) return false;
// Prevent matching string/primitive triggers to enum transitions
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".")) {
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
return false;
}
@@ -160,9 +155,13 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
}
if (eventStr.contains(".")) {
String constantPart = eventStr.substring(eventStr.lastIndexOf('.') + 1);
if (!constantPart.isEmpty() && Character.isUpperCase(constantPart.charAt(0))) {
return false;
}
String firstPart = eventStr.substring(0, eventStr.indexOf('.'));
if (!firstPart.isEmpty() && Character.isLowerCase(firstPart.charAt(0))) {
return true;
return true;
}
return false;
}

View File

@@ -1,7 +1,9 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.HashSet;
import java.util.Set;
@@ -15,17 +17,18 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn();
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn();
if (triggerEventFqn != null || triggerStateFqn != null) {
String[] machineTypes = getStateMachineTypeArguments(currentMachineName, context);
String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context);
String machineStateFqn = machineTypes[0];
String machineEventFqn = machineTypes[1];
if (triggerEventFqn != null && machineEventFqn != null) {
if (eraseGenerics(triggerEventFqn).equals(eraseGenerics(machineEventFqn))) {
return true; // Match!
return true;
}
if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
return false; // Mismatch!
return false;
}
return false;
}
if (triggerStateFqn != null && machineStateFqn != null) {
if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) {
@@ -110,15 +113,51 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return false;
}
}
return true;
return context == null || isSingleStateMachine(context);
}
private int countStateMachines(CodebaseContext context) {
if (context == null) {
return 0;
}
Set<String> machines = new HashSet<>();
for (TypeDeclaration td : context.findEntryPointClasses(
java.util.List.of("EnableStateMachineFactory", "EnableStateMachine"))) {
String fqn = context.getFqn(td);
if (fqn != null) {
machines.add(fqn);
}
}
for (TypeDeclaration td : context.getTypeDeclarations()) {
if (td == null || td.isInterface()) {
continue;
}
if (context.extendsStateMachineConfigurerAdapter(td)) {
String fqn = context.getFqn(td);
if (fqn != null) {
machines.add(fqn);
}
}
}
if (!machines.isEmpty()) {
return machines.size();
}
return context.findBeanMethodsReturning(
java.util.Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory")).size();
}
private boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
if (smPackage.startsWith(chainPackage) || chainPackage.startsWith(smPackage)) {
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("\\.");
@@ -261,59 +300,6 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return null;
}
private String[] getStateMachineTypeArguments(String machineName, CodebaseContext context) {
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration(machineName);
if (td != null) {
org.eclipse.jdt.core.dom.Type superclass = td.getSuperclassType();
Set<String> visited = new HashSet<>();
visited.add(machineName);
return findTypeArgumentsRecursively(superclass, context, (org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot(), visited);
}
return new String[]{null, null};
}
private String[] findTypeArgumentsRecursively(org.eclipse.jdt.core.dom.Type type, CodebaseContext context, org.eclipse.jdt.core.dom.CompilationUnit cu, Set<String> visited) {
if (type == null) return new String[]{null, null};
if (type instanceof org.eclipse.jdt.core.dom.ParameterizedType pt) {
String rawTypeName = pt.getType().toString();
if (rawTypeName.equals("StateMachineConfigurerAdapter") || rawTypeName.equals("EnumStateMachineConfigurerAdapter") ||
rawTypeName.endsWith(".StateMachineConfigurerAdapter") || rawTypeName.endsWith(".EnumStateMachineConfigurerAdapter")) {
java.util.List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
String stateType = resolveTypeToFqn((org.eclipse.jdt.core.dom.Type) typeArgs.get(0), cu, context);
String eventType = resolveTypeToFqn((org.eclipse.jdt.core.dom.Type) typeArgs.get(1), cu, context);
return new String[]{stateType, eventType};
}
}
// Recurse into the raw type declaration's superclass
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(rawTypeName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
org.eclipse.jdt.core.dom.Type superclass = (td instanceof org.eclipse.jdt.core.dom.TypeDeclaration) ? ((org.eclipse.jdt.core.dom.TypeDeclaration) td).getSuperclassType() : null;
String[] res = findTypeArgumentsRecursively(superclass, context, (org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot(), visited);
if (res[0] != null || res[1] != null) return res;
}
}
} else {
// It's a simple type (e.g. MyBaseConfig)
String simpleName = type.toString();
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
org.eclipse.jdt.core.dom.Type superclass = (td instanceof org.eclipse.jdt.core.dom.TypeDeclaration) ? ((org.eclipse.jdt.core.dom.TypeDeclaration) td).getSuperclassType() : null;
String[] res = findTypeArgumentsRecursively(superclass, context, (org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot(), visited);
if (res[0] != null || res[1] != null) return res;
}
}
}
return new String[]{null, null};
}
private String eraseGenerics(String type) {
if (type == null) return null;
int idx = type.indexOf('<');
@@ -323,43 +309,6 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return type;
}
private String resolveTypeToFqn(org.eclipse.jdt.core.dom.Type type, org.eclipse.jdt.core.dom.CompilationUnit cu, CodebaseContext context) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((org.eclipse.jdt.core.dom.SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((org.eclipse.jdt.core.dom.ParameterizedType) type).getType(), cu, context);
} else {
simpleName = type.toString();
}
if (simpleName.contains("<")) {
simpleName = simpleName.substring(0, simpleName.indexOf('<'));
}
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
for (Object impObj : cu.imports()) {
org.eclipse.jdt.core.dom.ImportDeclaration imp = (org.eclipse.jdt.core.dom.ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
org.eclipse.jdt.core.dom.AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) return context.getFqn(localTd);
}
return simpleName;
}
private boolean isTypeMismatched(String type1, String type2) {
if (type1 == null || type2 == null) return false;
type1 = eraseGenerics(type1);
@@ -371,10 +320,6 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
}
private boolean isSingleStateMachine(CodebaseContext context) {
if (context == null) return false;
int count = 0;
count += context.findEntryPointClasses(java.util.List.of("EnableStateMachineFactory", "EnableStateMachine")).size();
count += context.findBeanMethodsReturning(java.util.Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory")).size();
return count <= 1;
return countStateMachines(context) <= 1;
}
}

View File

@@ -0,0 +1,79 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.function.BiConsumer;
/**
* Resolves constant values for fields referenced by indexed trivial accessors.
*/
public final class AccessorFieldResolver {
private AccessorFieldResolver() {
}
public static List<String> resolveFieldConstants(
AccessorSummary accessor,
CodebaseContext context,
ConstructorAnalyzer constructorAnalyzer,
CompilationUnit contextCu,
Set<String> visited,
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor) {
if (accessor == null || !accessor.isGetter() || context == null) {
return List.of();
}
if (accessor.kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER
|| accessor.fieldName() == null) {
return List.of();
}
TypeDeclaration typeDeclaration = contextCu != null
? context.getTypeDeclaration(accessor.declaringFqn(), contextCu)
: null;
if (typeDeclaration == null) {
typeDeclaration = context.getTypeDeclaration(accessor.declaringFqn());
}
if (typeDeclaration == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
if (constructorAnalyzer != null) {
constants.addAll(constructorAnalyzer.traceFieldInConstructors(
typeDeclaration, accessor.fieldName(), context, visited));
}
if (constants.isEmpty()) {
collectFieldInitializerConstants(
typeDeclaration, accessor.fieldName(), constantExtractor, constants);
}
return constants;
}
private static void collectFieldInitializerConstants(
TypeDeclaration typeDeclaration,
String fieldName,
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor,
List<String> constants) {
if (constantExtractor == null) {
return;
}
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
for (Object fragmentObj : fieldDeclaration.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(fieldName)
&& fragment.getInitializer() != null) {
constantExtractor.accept(fragment.getInitializer(), constants);
}
}
}
}
}

View File

@@ -0,0 +1,75 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public class AccessorIndex {
private static final AccessorIndex EMPTY = new AccessorIndex(Map.of(), 0, 0, 0);
private final Map<String, Map<String, AccessorSummary>> byOwnerAndMethod;
private final int typeCount;
private final int trivialCount;
private final int blockedCount;
public AccessorIndex(
Map<String, Map<String, AccessorSummary>> byOwnerAndMethod,
int typeCount,
int trivialCount,
int blockedCount) {
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
this.typeCount = typeCount;
this.trivialCount = trivialCount;
this.blockedCount = blockedCount;
}
public static AccessorIndex empty() {
return EMPTY;
}
public Optional<AccessorSummary> lookup(String ownerFqn, String methodName) {
if (ownerFqn == null || methodName == null) {
return Optional.empty();
}
Map<String, AccessorSummary> methods = byOwnerAndMethod.get(ownerFqn);
if (methods == null) {
return Optional.empty();
}
return Optional.ofNullable(methods.get(methodName));
}
public List<AccessorSummary> accessorsForType(String ownerFqn) {
Map<String, AccessorSummary> methods = byOwnerAndMethod.get(ownerFqn);
if (methods == null) {
return List.of();
}
return List.copyOf(methods.values());
}
public Map<String, Map<String, AccessorSummary>> asMap() {
return byOwnerAndMethod;
}
public int typeCount() {
return typeCount;
}
public int trivialCount() {
return trivialCount;
}
public int blockedCount() {
return blockedCount;
}
private static Map<String, Map<String, AccessorSummary>> deepCopy(Map<String, Map<String, AccessorSummary>> source) {
Map<String, Map<String, AccessorSummary>> copy = new HashMap<>();
for (Map.Entry<String, Map<String, AccessorSummary>> entry : source.entrySet()) {
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
}
return copy;
}
}

View File

@@ -0,0 +1,286 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@Slf4j
public class AccessorIndexBuilder {
private final TrivialAccessorDetector detector = new TrivialAccessorDetector();
private CodebaseContext context;
public AccessorIndex build(CodebaseContext context) {
this.context = context;
Map<String, DirectTypeAccessors> direct = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
for (Object typeObj : cu.types()) {
if (typeObj instanceof TypeDeclaration td) {
indexTypeDeclaration(td, direct);
} else if (typeObj instanceof RecordDeclaration rd) {
indexRecord(rd, packageName, direct);
} else if (typeObj instanceof EnumDeclaration ed) {
indexEnum(ed, packageName, direct);
}
}
}
int blocked = direct.values().stream().mapToInt(state -> state.blockedMethods.size()).sum();
Map<String, Map<String, AccessorSummary>> effective = new HashMap<>();
int trivial = 0;
for (String ownerFqn : direct.keySet()) {
Map<String, AccessorSummary> merged = mergeEffective(ownerFqn, direct, effective, new HashSet<>());
if (!merged.isEmpty()) {
effective.put(ownerFqn, merged);
trivial += merged.size();
}
}
log.info(
"Built accessor index: {} trivial accessors across {} types ({} blocked overrides)",
trivial,
effective.size(),
blocked);
this.context = null;
return new AccessorIndex(effective, effective.size(), trivial, blocked);
}
private void indexTypeDeclaration(TypeDeclaration td, Map<String, DirectTypeAccessors> direct) {
String ownerFqn = context.getFqn(td);
DirectTypeAccessors state = direct.computeIfAbsent(ownerFqn, k -> new DirectTypeAccessors());
for (MethodDeclaration md : td.getMethods()) {
classifyMethod(ownerFqn, md, fieldName -> findFieldDeclaringFqn(td, fieldName), state);
}
for (TypeDeclaration nested : td.getTypes()) {
indexTypeDeclaration(nested, direct);
}
}
private void indexEnum(EnumDeclaration ed, String parentFqn, Map<String, DirectTypeAccessors> direct) {
String ownerFqn = parentFqn.isEmpty() ? ed.getName().getIdentifier() : parentFqn + "." + ed.getName().getIdentifier();
DirectTypeAccessors state = direct.computeIfAbsent(ownerFqn, k -> new DirectTypeAccessors());
for (Object decl : ed.bodyDeclarations()) {
if (decl instanceof MethodDeclaration md) {
classifyMethod(ownerFqn, md, fieldName -> findEnumFieldDeclaringFqn(ed, ownerFqn, fieldName), state);
} else if (decl instanceof TypeDeclaration nestedTd) {
indexTypeDeclaration(nestedTd, direct);
} else if (decl instanceof RecordDeclaration nestedRd) {
indexRecord(nestedRd, ownerFqn, direct);
}
}
}
private void indexRecord(RecordDeclaration rd, String parentFqn, Map<String, DirectTypeAccessors> direct) {
String ownerFqn = parentFqn.isEmpty() ? rd.getName().getIdentifier() : parentFqn + "." + rd.getName().getIdentifier();
DirectTypeAccessors state = direct.computeIfAbsent(ownerFqn, k -> new DirectTypeAccessors());
for (Object compObj : rd.recordComponents()) {
String componentName = recordComponentName(compObj);
if (componentName == null) {
continue;
}
detector.detectRecordComponent(ownerFqn, componentName).ifPresent(summary ->
state.trivial.put(summary.methodName(), summary));
}
TrivialAccessorDetector.FieldLookup recordFieldLookup =
fieldName -> findRecordFieldDeclaringFqn(rd, ownerFqn, fieldName);
for (Object declObj : rd.bodyDeclarations()) {
if (declObj instanceof MethodDeclaration md) {
String methodName = md.getName().getIdentifier();
AccessorSummary existing = state.trivial.get(methodName);
if (existing != null && existing.kind() == AccessorKind.RECORD_COMPONENT) {
continue;
}
classifyMethod(ownerFqn, md, recordFieldLookup, state);
} else if (declObj instanceof TypeDeclaration nestedTd) {
indexTypeDeclaration(nestedTd, direct);
} else if (declObj instanceof RecordDeclaration nestedRd) {
indexRecord(nestedRd, ownerFqn, direct);
}
}
}
private void classifyMethod(
String ownerFqn,
MethodDeclaration md,
TrivialAccessorDetector.FieldLookup fieldLookup,
DirectTypeAccessors state) {
if (md.getBody() == null) {
return;
}
String methodName = md.getName().getIdentifier();
Optional<AccessorSummary> summary = detector.detect(ownerFqn, md, fieldLookup);
applyClassification(state, methodName, summary);
}
private void applyClassification(DirectTypeAccessors state, String methodName, Optional<AccessorSummary> summary) {
if (summary.isPresent()) {
state.trivial.put(methodName, summary.get());
state.blockedMethods.remove(methodName);
return;
}
if (looksLikeAccessorMethod(methodName)) {
state.blockedMethods.add(methodName);
state.trivial.remove(methodName);
}
}
private boolean looksLikeAccessorMethod(String methodName) {
return methodName.startsWith("get")
|| methodName.startsWith("set")
|| methodName.startsWith("is");
}
private Map<String, AccessorSummary> mergeEffective(
String ownerFqn,
Map<String, DirectTypeAccessors> direct,
Map<String, Map<String, AccessorSummary>> cache,
Set<String> visiting) {
ownerFqn = resolveTypeFqn(ownerFqn);
if (ownerFqn == null) {
return Map.of();
}
if (cache.containsKey(ownerFqn)) {
return cache.get(ownerFqn);
}
if (!visiting.add(ownerFqn)) {
return Map.of();
}
Map<String, AccessorSummary> merged = new HashMap<>();
TypeDeclaration td = context.getTypeDeclaration(ownerFqn);
if (td != null) {
String superFqn = resolveTypeFqn(context.getSuperclassFqn(td));
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
for (Map.Entry<String, AccessorSummary> inherited : mergeEffective(superFqn, direct, cache, visiting).entrySet()) {
merged.put(inherited.getKey(), rebindOwner(inherited.getValue(), ownerFqn));
}
}
}
DirectTypeAccessors own = direct.get(ownerFqn);
if (own != null) {
own.blockedMethods.forEach(merged::remove);
merged.putAll(own.trivial);
}
visiting.remove(ownerFqn);
cache.put(ownerFqn, Map.copyOf(merged));
return merged;
}
private AccessorSummary rebindOwner(AccessorSummary summary, String ownerFqn) {
if (summary.ownerFqn().equals(ownerFqn)) {
return summary;
}
return new AccessorSummary(
ownerFqn,
summary.methodName(),
summary.kind(),
summary.fieldName(),
summary.paramName(),
summary.declaringFqn(),
summary.returnsThis()
);
}
private String resolveTypeFqn(String typeName) {
if (typeName == null || typeName.isEmpty()) {
return null;
}
if (typeName.contains(".")) {
return typeName;
}
TypeDeclaration td = context.getTypeDeclaration(typeName);
return td != null ? context.getFqn(td) : typeName;
}
private static final class DirectTypeAccessors {
private final Map<String, AccessorSummary> trivial = new HashMap<>();
private final Set<String> blockedMethods = new HashSet<>();
}
private Optional<String> findFieldDeclaringFqn(TypeDeclaration td, String fieldName) {
return findFieldDeclaringFqn(td, fieldName, new HashSet<>());
}
private Optional<String> findFieldDeclaringFqn(TypeDeclaration td, String fieldName, Set<String> visited) {
if (td == null) {
return Optional.empty();
}
String ownerFqn = context.getFqn(td);
if (!visited.add(ownerFqn)) {
return Optional.empty();
}
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName)) {
return Optional.of(ownerFqn);
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
return findFieldDeclaringFqn(superTd, fieldName, visited);
}
}
return Optional.empty();
}
private Optional<String> findEnumFieldDeclaringFqn(EnumDeclaration ed, String ownerFqn, String fieldName) {
for (Object decl : ed.bodyDeclarations()) {
if (decl instanceof FieldDeclaration fd) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName)) {
return Optional.of(ownerFqn);
}
}
}
}
return Optional.empty();
}
private Optional<String> findRecordFieldDeclaringFqn(RecordDeclaration rd, String ownerFqn, String fieldName) {
for (Object compObj : rd.recordComponents()) {
String componentName = recordComponentName(compObj);
if (fieldName.equals(componentName)) {
return Optional.of(ownerFqn);
}
}
return Optional.empty();
}
private String recordComponentName(Object compObj) {
if (compObj instanceof SingleVariableDeclaration svd) {
return svd.getName().getIdentifier();
}
try {
java.lang.reflect.Method getNameMethod = compObj.getClass().getMethod("getName");
Object nameObj = getNameMethod.invoke(compObj);
if (nameObj instanceof SimpleName sn) {
return sn.getIdentifier();
}
} catch (ReflectiveOperationException ignored) {
// fall through
}
return null;
}
}

View File

@@ -0,0 +1,93 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.MethodInvocation;
import java.util.List;
import java.util.Optional;
public final class AccessorInlining {
private AccessorInlining() {
}
public static Optional<AccessorSummary> lookupAccessor(
CodebaseContext context,
MethodInvocation mi,
List<Expression> receiverDefinitions) {
if (context == null || mi == null) {
return Optional.empty();
}
String methodName = mi.getName().getIdentifier();
if (isLikelyNonBeanGet(methodName, mi)) {
return Optional.empty();
}
if (receiverDefinitions != null) {
for (Expression receiverDef : receiverDefinitions) {
if (receiverDef instanceof ClassInstanceCreation cic) {
Optional<AccessorSummary> concrete = lookupOnType(context, resolveTypeFqn(cic), methodName);
if (concrete.isPresent()) {
return concrete;
}
}
}
}
IMethodBinding methodBinding = mi.resolveMethodBinding();
if (methodBinding != null && methodBinding.getDeclaringClass() != null) {
ITypeBinding declaringClass = methodBinding.getDeclaringClass().getErasure();
Optional<AccessorSummary> declared = lookupOnType(context, declaringClass.getQualifiedName(), methodName);
if (declared.isPresent()) {
return declared;
}
}
return Optional.empty();
}
public static Optional<AccessorSummary> lookupAccessor(CodebaseContext context, MethodInvocation mi) {
return lookupAccessor(context, mi, List.of());
}
private static Optional<AccessorSummary> lookupOnType(CodebaseContext context, String ownerFqn, String methodName) {
if (ownerFqn == null || ownerFqn.isBlank() || ownerFqn.startsWith("java.")) {
return Optional.empty();
}
return context.getAccessorIndex().lookup(ownerFqn, methodName);
}
public static String resolveTypeFqn(ClassInstanceCreation cic) {
if (cic == null) {
return null;
}
if (cic.resolveConstructorBinding() != null && cic.resolveConstructorBinding().getDeclaringClass() != null) {
return cic.resolveConstructorBinding().getDeclaringClass().getErasure().getQualifiedName();
}
if (cic.resolveTypeBinding() != null) {
return cic.resolveTypeBinding().getErasure().getQualifiedName();
}
return AstUtils.extractSimpleTypeName(cic.getType());
}
private static boolean isLikelyNonBeanGet(String methodName, MethodInvocation mi) {
if (!"get".equals(methodName) && !"getOrDefault".equals(methodName)) {
return false;
}
if (mi.arguments().size() != 1) {
return false;
}
IMethodBinding methodBinding = mi.resolveMethodBinding();
if (methodBinding == null || methodBinding.getDeclaringClass() == null) {
return false;
}
String ownerFqn = methodBinding.getDeclaringClass().getErasure().getQualifiedName();
return ownerFqn != null && ownerFqn.startsWith("java.util");
}
}

View File

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

View File

@@ -0,0 +1,22 @@
package click.kamil.springstatemachineexporter.analysis.index;
public record AccessorSummary(
String ownerFqn,
String methodName,
AccessorKind kind,
String fieldName,
String paramName,
String declaringFqn,
boolean returnsThis
) {
public boolean isGetter() {
return kind == AccessorKind.GETTER
|| kind == AccessorKind.BOOLEAN_GETTER
|| kind == AccessorKind.CONSTANT_GETTER
|| kind == AccessorKind.RECORD_COMPONENT;
}
public boolean isSetter() {
return kind == AccessorKind.SETTER || kind == AccessorKind.FLUENT_SETTER;
}
}

View File

@@ -0,0 +1,305 @@
package click.kamil.springstatemachineexporter.analysis.index;
import org.eclipse.jdt.core.dom.*;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
/**
* Detects trivial JavaBean-style getters and setters whose bodies only read or write a single field.
*/
public class TrivialAccessorDetector {
@FunctionalInterface
public interface FieldLookup extends Function<String, Optional<String>> {
}
public Optional<AccessorSummary> detect(String ownerFqn, MethodDeclaration method, FieldLookup fieldLookup) {
if (ownerFqn == null || method == null || method.getBody() == null || fieldLookup == null) {
return Optional.empty();
}
String methodName = method.getName().getIdentifier();
if (method.isConstructor()) {
return Optional.empty();
}
Optional<String> getterField = propertyNameForGetter(methodName, method);
if (getterField.isPresent()) {
Optional<AccessorSummary> fieldGetter = detectGetter(ownerFqn, method, methodName, getterField.get(), fieldLookup);
if (fieldGetter.isPresent()) {
return fieldGetter;
}
return detectConstantGetter(ownerFqn, method, methodName);
}
Optional<String> setterField = propertyNameForSetter(methodName);
if (setterField.isPresent() && !method.parameters().isEmpty()) {
return detectSetter(ownerFqn, method, methodName, setterField.get(), fieldLookup);
}
return Optional.empty();
}
public Optional<AccessorSummary> detectRecordComponent(String ownerFqn, String componentName) {
if (ownerFqn == null || componentName == null || componentName.isEmpty()) {
return Optional.empty();
}
return Optional.of(new AccessorSummary(
ownerFqn,
componentName,
AccessorKind.RECORD_COMPONENT,
componentName,
null,
ownerFqn,
false
));
}
private Optional<AccessorSummary> detectConstantGetter(
String ownerFqn,
MethodDeclaration method,
String methodName) {
List<?> statements = method.getBody().statements();
if (statements.size() != 1 || !(statements.get(0) instanceof ReturnStatement rs)) {
return Optional.empty();
}
Expression expr = rs.getExpression();
if (expr == null || !isConstantReturnExpression(expr)) {
return Optional.empty();
}
return Optional.of(new AccessorSummary(
ownerFqn,
methodName,
AccessorKind.CONSTANT_GETTER,
null,
null,
ownerFqn,
false
));
}
private boolean isConstantReturnExpression(Expression expr) {
Expression current = unwrap(expr);
if (current instanceof StringLiteral
|| current instanceof NumberLiteral
|| current instanceof BooleanLiteral
|| current instanceof CharacterLiteral) {
return true;
}
if (current instanceof QualifiedName) {
return !containsMethodCall(current);
}
if (current instanceof SimpleName) {
return false;
}
if (current instanceof FieldAccess fa) {
Expression receiver = fa.getExpression();
return (receiver instanceof SimpleName || receiver instanceof QualifiedName)
&& !containsMethodCall(current);
}
return false;
}
private Optional<AccessorSummary> detectGetter(
String ownerFqn,
MethodDeclaration method,
String methodName,
String fieldName,
FieldLookup fieldLookup) {
if (!isTrivialGetterBody(method.getBody(), fieldName)) {
return Optional.empty();
}
Optional<String> declaringFqn = fieldLookup.apply(fieldName);
if (declaringFqn.isEmpty()) {
return Optional.empty();
}
AccessorKind kind = methodName.startsWith("is") ? AccessorKind.BOOLEAN_GETTER : AccessorKind.GETTER;
return Optional.of(new AccessorSummary(
ownerFqn,
methodName,
kind,
fieldName,
null,
declaringFqn.get(),
false
));
}
private Optional<AccessorSummary> detectSetter(
String ownerFqn,
MethodDeclaration method,
String methodName,
String fieldName,
FieldLookup fieldLookup) {
SingleVariableDeclaration param = (SingleVariableDeclaration) method.parameters().get(0);
String paramName = param.getName().getIdentifier();
boolean fluent = isTrivialFluentSetterBody(method.getBody(), fieldName, paramName);
boolean voidSetter = isVoidReturn(method) && isTrivialVoidSetterBody(method.getBody(), fieldName, paramName);
if (!fluent && !voidSetter) {
return Optional.empty();
}
Optional<String> declaringFqn = fieldLookup.apply(fieldName);
if (declaringFqn.isEmpty()) {
return Optional.empty();
}
AccessorKind kind = fluent ? AccessorKind.FLUENT_SETTER : AccessorKind.SETTER;
return Optional.of(new AccessorSummary(
ownerFqn,
methodName,
kind,
fieldName,
paramName,
declaringFqn.get(),
fluent
));
}
private Optional<String> propertyNameForGetter(String methodName, MethodDeclaration method) {
if (methodName.startsWith("get") && methodName.length() > 3) {
return Optional.of(decapitalize(methodName.substring(3)));
}
if (methodName.startsWith("is") && methodName.length() > 2 && isBooleanReturn(method)) {
return Optional.of(decapitalize(methodName.substring(2)));
}
return Optional.empty();
}
private Optional<String> propertyNameForSetter(String methodName) {
if (methodName.startsWith("set") && methodName.length() > 3) {
return Optional.of(decapitalize(methodName.substring(3)));
}
return Optional.empty();
}
private boolean isBooleanReturn(MethodDeclaration method) {
return "boolean".equals(method.getReturnType2().toString())
|| "Boolean".equals(method.getReturnType2().toString());
}
private boolean isVoidReturn(MethodDeclaration method) {
return method.getReturnType2() instanceof PrimitiveType pt && pt.getPrimitiveTypeCode() == PrimitiveType.VOID;
}
public static String decapitalize(String name) {
if (name == null || name.isEmpty()) {
return name;
}
if (name.length() > 1 && Character.isUpperCase(name.charAt(1))) {
return name;
}
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
}
private boolean isTrivialGetterBody(Block body, String fieldName) {
List<?> statements = body.statements();
if (statements.size() != 1 || !(statements.get(0) instanceof ReturnStatement rs)) {
return false;
}
Expression expr = rs.getExpression();
if (expr == null) {
return false;
}
return isDirectFieldRead(unwrap(expr), fieldName);
}
private boolean isTrivialVoidSetterBody(Block body, String fieldName, String paramName) {
List<?> statements = body.statements();
if (statements.size() != 1) {
return false;
}
return isAssignmentStatement(statements.get(0), fieldName, paramName);
}
private boolean isTrivialFluentSetterBody(Block body, String fieldName, String paramName) {
List<?> statements = body.statements();
if (statements.size() != 2) {
return false;
}
if (!isAssignmentStatement(statements.get(0), fieldName, paramName)) {
return false;
}
if (!(statements.get(1) instanceof ReturnStatement rs)) {
return false;
}
Expression ret = unwrap(rs.getExpression());
return ret instanceof ThisExpression;
}
private boolean isAssignmentStatement(Object statement, String fieldName, String paramName) {
if (!(statement instanceof ExpressionStatement es)) {
return false;
}
if (!(es.getExpression() instanceof Assignment assignment)) {
return false;
}
if (assignment.getOperator() != Assignment.Operator.ASSIGN) {
return false;
}
if (!isDirectFieldWrite(assignment.getLeftHandSide(), fieldName)) {
return false;
}
Expression rhs = unwrap(assignment.getRightHandSide());
return rhs instanceof SimpleName sn && sn.getIdentifier().equals(paramName);
}
private boolean isDirectFieldRead(Expression expr, String fieldName) {
if (containsMethodCall(expr)) {
return false;
}
if (expr instanceof SimpleName sn) {
return sn.getIdentifier().equals(fieldName);
}
if (expr instanceof FieldAccess fa) {
Expression receiver = fa.getExpression();
if (receiver == null || receiver instanceof ThisExpression) {
return fa.getName().getIdentifier().equals(fieldName);
}
}
return false;
}
private boolean isDirectFieldWrite(Expression expr, String fieldName) {
if (expr instanceof SimpleName sn) {
return sn.getIdentifier().equals(fieldName);
}
if (expr instanceof FieldAccess fa) {
Expression receiver = fa.getExpression();
if (receiver == null || receiver instanceof ThisExpression) {
return fa.getName().getIdentifier().equals(fieldName);
}
}
return false;
}
private boolean containsMethodCall(Expression expr) {
if (expr == null) {
return false;
}
final boolean[] found = {false};
expr.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
found[0] = true;
return false;
}
});
return found[0];
}
private Expression unwrap(Expression expr) {
Expression current = expr;
while (current instanceof ParenthesizedExpression pe) {
current = pe.getExpression();
}
while (current instanceof CastExpression ce) {
current = ce.getExpression();
}
return current;
}
}

View File

@@ -3,6 +3,10 @@ package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Transition;
import lombok.Builder;
import lombok.Getter;
@@ -31,6 +35,11 @@ public class AnalysisResult {
@Builder.Default
private List<BusinessFlow> flows = java.util.Collections.emptyList();
/** Resolved {@code StateMachineConfigurerAdapter<S,E>} state type; persisted for JSON round-trip. */
private String stateTypeFqn;
/** Resolved {@code StateMachineConfigurerAdapter<S,E>} event type; persisted for JSON round-trip. */
private String eventTypeFqn;
@Builder.Default
private CodebaseMetadata metadata = CodebaseMetadata.empty();
@@ -52,7 +61,9 @@ public class AnalysisResult {
// 2. Resolve states (State record is immutable, so recreate)
if (states != null) {
this.states = states.stream()
.map(s -> click.kamil.springstatemachineexporter.model.State.of(s.rawName(), resolver.resolveValue(s.fullIdentifier(), properties)))
.map(s -> click.kamil.springstatemachineexporter.model.State.of(
resolver.resolveValue(s.rawName(), properties),
resolver.resolveValue(s.fullIdentifier(), properties)))
.collect(java.util.stream.Collectors.toSet());
}
@@ -72,15 +83,18 @@ public class AnalysisResult {
}
}
// 4. Resolve metadata (triggers, entry points)
// 4. Resolve metadata (triggers, entry points, call chains)
if (metadata != null) {
if (metadata.getTriggers() != null) {
for (var trigger : metadata.getTriggers()) {
if (trigger.getEvent() != null) {
trigger.setEvent(resolver.resolveValue(trigger.getEvent(), properties));
}
}
}
List<TriggerPoint> resolvedTriggers = metadata.getTriggers() == null ? null
: metadata.getTriggers().stream()
.map(trigger -> resolveTrigger(trigger, resolver, properties))
.collect(java.util.stream.Collectors.toList());
List<CallChain> resolvedCallChains = metadata.getCallChains() == null ? null
: metadata.getCallChains().stream()
.map(chain -> resolveCallChain(chain, resolver, properties))
.collect(java.util.stream.Collectors.toList());
if (metadata.getEntryPoints() != null) {
for (var ep : metadata.getEntryPoints()) {
if (ep.getName() != null) {
@@ -95,9 +109,54 @@ public class AnalysisResult {
}
}
}
this.metadata = CodebaseMetadata.builder()
.triggers(resolvedTriggers != null ? resolvedTriggers : metadata.getTriggers())
.entryPoints(metadata.getEntryPoints())
.callChains(resolvedCallChains != null ? resolvedCallChains : metadata.getCallChains())
.properties(metadata.getProperties())
.build();
}
}
private static TriggerPoint resolveTrigger(
TriggerPoint trigger,
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
Map<String, String> properties) {
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream()
.map(value -> resolver.resolveValue(value, properties))
.collect(java.util.stream.Collectors.toList());
return trigger.toBuilder()
.event(trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null)
.sourceState(trigger.getSourceState() != null
? resolver.resolveValue(trigger.getSourceState(), properties)
: null)
.polymorphicEvents(polymorphicEvents)
.build();
}
private static CallChain resolveCallChain(
CallChain chain,
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
Map<String, String> properties) {
TriggerPoint trigger = chain.getTriggerPoint() == null
? null
: resolveTrigger(chain.getTriggerPoint(), resolver, properties);
List<MatchedTransition> matchedTransitions = chain.getMatchedTransitions() == null ? null
: chain.getMatchedTransitions().stream()
.map(matched -> MatchedTransition.builder()
.event(resolver.resolveValue(matched.getEvent(), properties))
.sourceState(resolver.resolveValue(matched.getSourceState(), properties))
.targetState(resolver.resolveValue(matched.getTargetState(), properties))
.build())
.collect(java.util.stream.Collectors.toList());
return chain.toBuilder()
.triggerPoint(trigger)
.matchedTransitions(matchedTransitions)
.build();
}
public void addMetadata(CodebaseMetadata newMetadata) {
if (newMetadata == null) return;

View File

@@ -0,0 +1,36 @@
package click.kamil.springstatemachineexporter.analysis.model;
/**
* Synthetic trigger markers for persistence/lifecycle operations that do not fire
* {@code sendEvent} transitions (e.g. {@code StateMachinePersister.restore(...)}).
*/
public final class LifecycleTriggerMarkers {
public static final String PREFIX = "[LIFECYCLE:";
private LifecycleTriggerMarkers() {
}
public static boolean isLifecycle(String event) {
return event != null && event.startsWith(PREFIX) && event.endsWith("]");
}
public static String lifecycleType(String event) {
if (!isLifecycle(event)) {
return null;
}
return event.substring(PREFIX.length(), event.length() - 1);
}
public static String displayLabel(String event) {
String type = lifecycleType(event);
if (type == null) {
return event;
}
return switch (type.toUpperCase()) {
case "RESTORE" -> "Restore persisted state";
case "RESET" -> "Reset state machine";
default -> "Lifecycle: " + type.toLowerCase();
};
}
}

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.model;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
@@ -49,14 +50,14 @@ public class TriggerPoint {
this.sourceFile = sourceFile;
this.sourceModule = sourceModule;
this.stateMachineId = stateMachineId;
this.sourceState = sourceState;
this.sourceState = MachineEnumCanonicalizer.canonicalizeLabel(sourceState, stateTypeFqn);
this.lineNumber = lineNumber;
this.stateTypeFqn = stateTypeFqn;
this.eventTypeFqn = eventTypeFqn;
this.event = qualifyEvent(event, eventTypeFqn);
this.event = MachineEnumCanonicalizer.qualifyEventIdentifier(event, eventTypeFqn);
if (polymorphicEvents != null) {
this.polymorphicEvents = polymorphicEvents.stream()
.map(pe -> qualifyEvent(pe, eventTypeFqn))
.map(pe -> MachineEnumCanonicalizer.qualifyEventIdentifier(pe, eventTypeFqn))
.collect(java.util.stream.Collectors.toList());
} else {
this.polymorphicEvents = null;
@@ -65,66 +66,4 @@ public class TriggerPoint {
this.constraint = constraint;
this.ambiguous = ambiguous;
}
private static String qualifyEvent(String e, String eventTypeFqn) {
if (e == null || eventTypeFqn == null || e.isEmpty()) {
return e;
}
if (e.startsWith("<SYMBOLIC: ")) {
return e;
}
if (isWildcardVariable(e)) {
return e;
}
if (Character.isLowerCase(e.charAt(0))) {
return e;
}
if (!isEnumConstantCandidate(e)) {
return e;
}
String simpleEventType = eventTypeFqn.contains(".") ? eventTypeFqn.substring(eventTypeFqn.lastIndexOf('.') + 1) : eventTypeFqn;
if (e.contains(".")) {
String qualifier = e.substring(0, e.lastIndexOf('.'));
String lastSegment = e.substring(e.lastIndexOf('.') + 1);
String simpleQualifier = qualifier.contains(".") ? qualifier.substring(qualifier.lastIndexOf('.') + 1) : qualifier;
if (simpleQualifier.equals(simpleEventType)) {
return simpleEventType + "." + lastSegment;
}
return e;
}
if (eventTypeFqn.startsWith("java.lang.") || eventTypeFqn.equals("String") || eventTypeFqn.equals("int") || eventTypeFqn.equals("long") || eventTypeFqn.equals("char")) {
return e;
}
return simpleEventType + "." + e;
}
private static boolean isEnumConstantCandidate(String eventStr) {
if (eventStr == null) return false;
if (eventStr.startsWith("<SYMBOLIC: ")) {
return false;
}
if (eventStr.contains(" ") || eventStr.contains("(") || eventStr.contains(")") || eventStr.contains("?")) {
return false;
}
return true;
}
private static boolean isWildcardVariable(String eventStr) {
if (eventStr == null) return false;
if (eventStr.equals("event") || eventStr.equals("e") ||
eventStr.equals("msg") || eventStr.equals("message") ||
eventStr.equals("payload")) {
return true;
}
if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) {
return true;
}
if (eventStr.contains(" ? ") && eventStr.contains(" : ")) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,433 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.index.AccessorKind;
import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiConsumer;
/**
* Unified accessor resolution pipeline: index lookup first, then field/CIC/method-body fallbacks.
*/
public final class AccessorResolver {
public record GetterChainStep(String typeFqn, ClassInstanceCreation cic) {
}
public record ReceiverContext(
TypeDeclaration ownerType,
ClassInstanceCreation cic,
CompilationUnit contextCu,
boolean anonymousGetter,
Map<String, String> fieldValues) {
}
private final CodebaseContext context;
private final ConstantExtractor constantExtractor;
private final ConstructorAnalyzer constructorAnalyzer;
private final ConstantResolver constantResolver;
private final FieldInitializerFinder fieldInitializerFinder;
private final Map<String, List<String>> sessionCache = new HashMap<>();
public AccessorResolver(
CodebaseContext context,
ConstantExtractor constantExtractor,
ConstructorAnalyzer constructorAnalyzer,
ConstantResolver constantResolver) {
this.context = context;
this.constantExtractor = constantExtractor;
this.constructorAnalyzer = constructorAnalyzer;
this.constantResolver = constantResolver;
this.fieldInitializerFinder = new FieldInitializerFinder(context);
}
public Optional<AccessorSummary> lookupIndexedGetter(String className, String methodName) {
if (className == null || className.isBlank()) {
return Optional.empty();
}
Optional<AccessorSummary> direct = context.getAccessorIndex().lookup(className, methodName);
if (direct.isPresent()) {
return direct;
}
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration != null) {
direct = context.getAccessorIndex().lookup(context.getFqn(typeDeclaration), methodName);
if (direct.isPresent()) {
return direct;
}
if (!typeDeclaration.isInterface() && !org.eclipse.jdt.core.dom.Modifier.isAbstract(typeDeclaration.getModifiers())) {
return Optional.empty();
}
}
List<String> implementations = context.getImplementations(className);
if (implementations == null) {
return Optional.empty();
}
for (String implementation : implementations) {
Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName);
if (implAccessor.isPresent()) {
return implAccessor;
}
}
return Optional.empty();
}
public List<String> resolve(String ownerFqn, String methodName, ReceiverContext receiverContext, ResolutionBudget budget) {
return resolve(ownerFqn, methodName, receiverContext, budget, new HashSet<>());
}
public List<String> resolve(
String ownerFqn,
String methodName,
ReceiverContext receiverContext,
ResolutionBudget budget,
Set<String> visited) {
if (ownerFqn == null || methodName == null) {
return List.of();
}
if (budget == null) {
budget = ResolutionBudget.defaults();
}
String cacheKey = cacheKey(ownerFqn, methodName, receiverContext);
List<String> cached = sessionCache.get(cacheKey);
if (cached != null) {
return cached;
}
List<String> resolved = resolveInternal(ownerFqn, methodName, receiverContext, budget, visited);
if (!resolved.isEmpty()) {
List<String> cachedCopy = List.copyOf(resolved);
sessionCache.put(cacheKey, cachedCopy);
return cachedCopy;
}
return resolved;
}
public GetterChainStep resolveGetterChainStep(String ownerFqn, String getterName, ResolutionBudget budget) {
if (ownerFqn == null || getterName == null) {
return null;
}
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(ownerFqn, getterName);
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
AccessorSummary accessor = indexedAccessor.get();
ClassInstanceCreation nextCic = fieldInitializerFinder.findFieldInitializerCic(ownerFqn, accessor.fieldName());
String nextType = fieldInitializerFinder.resolveFieldTypeName(ownerFqn, accessor.fieldName());
if (nextCic != null) {
nextType = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(nextCic.getType());
}
if (nextType != null) {
return new GetterChainStep(nextType, nextCic);
}
}
String simpleType = simplifyTypeName(ownerFqn);
ClassInstanceCreation nextCic = constructorAnalyzer.findReturnedCIC(simpleType, getterName, context);
if (nextCic == null) {
String fieldName = indexedFieldName(ownerFqn, getterName);
nextCic = fieldInitializerFinder.findFieldInitializerCic(ownerFqn, fieldName);
}
if (nextCic != null) {
return new GetterChainStep(
click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(nextCic.getType()),
nextCic);
}
TypeDeclaration td = context.getTypeDeclaration(simpleType);
if (td == null) {
td = context.getTypeDeclaration(ownerFqn);
}
if (td != null) {
MethodDeclaration getter = context.findMethodDeclaration(td, getterName, true);
if (getter != null && getter.getReturnType2() != null) {
return new GetterChainStep(getter.getReturnType2().toString(), null);
}
}
return null;
}
public void clearSessionCache() {
sessionCache.clear();
}
private List<String> resolveInternal(
String ownerFqn,
String methodName,
ReceiverContext receiverContext,
ResolutionBudget budget,
Set<String> visited) {
TypeDeclaration ownerType = receiverContext != null ? receiverContext.ownerType() : null;
if (ownerType == null) {
ownerType = context.getTypeDeclaration(ownerFqn);
}
boolean widenToImplementations = ownerType == null
|| ownerType.isInterface()
|| org.eclipse.jdt.core.dom.Modifier.isAbstract(ownerType.getModifiers());
if (widenToImplementations) {
List<String> widened = new ArrayList<>();
List<String> impls = context.getImplementations(ownerFqn);
if (impls != null) {
for (String implFqn : impls) {
TypeDeclaration implType = context.getTypeDeclaration(implFqn);
widened.addAll(resolve(
implFqn,
methodName,
new ReceiverContext(implType, null,
receiverContext != null ? receiverContext.contextCu() : null,
false,
receiverContext != null ? receiverContext.fieldValues() : null),
budget,
visited));
}
}
if (!widened.isEmpty()) {
return deduplicatePreservingOrder(widened);
}
}
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(ownerFqn, methodName);
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
List<String> indexed = resolveIndexedAccessor(indexedAccessor.get(), receiverContext, visited);
if (!indexed.isEmpty()) {
return indexed;
}
}
if (receiverContext != null && receiverContext.ownerType() != null && receiverContext.cic() != null) {
List<String> cicValues = resolveWithCic(
ownerFqn, methodName, receiverContext, indexedAccessor.orElse(null), visited);
if (!cicValues.isEmpty()) {
return cicValues;
}
} else if (receiverContext != null && receiverContext.cic() != null) {
ReceiverContext enriched = new ReceiverContext(
context.getTypeDeclaration(ownerFqn),
receiverContext.cic(),
receiverContext.contextCu(),
receiverContext.anonymousGetter(),
receiverContext.fieldValues());
List<String> cicValues = resolveWithCic(
ownerFqn, methodName, enriched, indexedAccessor.orElse(null), visited);
if (!cicValues.isEmpty()) {
return cicValues;
}
}
if (constantExtractor != null && !budget.isMethodReturnExhausted(0)) {
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : null;
List<String> deep = constantExtractor.resolveMethodReturnConstant(
ownerFqn, methodName, 0, visited, contextCu, budget);
if (!deep.isEmpty()) {
return deep;
}
}
return List.of();
}
private List<String> resolveIndexedAccessor(
AccessorSummary accessor,
ReceiverContext receiverContext,
Set<String> visited) {
if (accessor.kind() == AccessorKind.CONSTANT_GETTER) {
List<String> constants = extractConstantGetterReturn(accessor.ownerFqn(), accessor.methodName());
if (!constants.isEmpty()) {
return constants;
}
if (constantExtractor != null) {
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : null;
return constantExtractor.resolveMethodReturnConstant(
accessor.ownerFqn(), accessor.methodName(), 0, new HashSet<>(visited), contextCu);
}
}
if (constantExtractor != null) {
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : null;
List<String> fieldConstants = constantExtractor.resolveIndexedGetterFieldConstants(
accessor, contextCu, visited);
if (!fieldConstants.isEmpty()) {
return fieldConstants;
}
}
if (receiverContext != null
&& receiverContext.fieldValues() != null
&& accessor.fieldName() != null) {
String fieldValue = receiverContext.fieldValues().get(accessor.fieldName());
if (fieldValue != null) {
return List.of(fieldValue);
}
}
return List.of();
}
private List<String> resolveWithCic(
String ownerFqn,
String methodName,
ReceiverContext receiverContext,
AccessorSummary indexedAccessor,
Set<String> visited) {
TypeDeclaration td = receiverContext.ownerType();
if (td == null) {
td = context.getTypeDeclaration(ownerFqn);
}
ClassInstanceCreation cic = receiverContext.cic();
Map<String, String> fieldValues = receiverContext.fieldValues() != null
? receiverContext.fieldValues()
: new HashMap<>();
MethodDeclaration getter = null;
if (cic.getAnonymousClassDeclaration() != null) {
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
if (declObj instanceof MethodDeclaration md && md.getName().getIdentifier().equals(methodName)) {
getter = md;
break;
}
}
}
if (getter == null) {
getter = context.findMethodDeclaration(td, methodName, true);
}
if (getter != null && getter.getBody() != null) {
if (fieldValues.isEmpty() && constructorAnalyzer != null) {
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, null));
}
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, visited);
if (result != null) {
return List.of(qualifyEnumLikeResult(getter, result));
}
}
if (indexedAccessor != null && indexedAccessor.isGetter() && !receiverContext.anonymousGetter()) {
if (indexedAccessor.kind() == AccessorKind.CONSTANT_GETTER) {
List<String> constantValues = extractConstantGetterReturn(ownerFqn, methodName);
if (!constantValues.isEmpty()) {
return constantValues;
}
}
if (fieldValues.isEmpty() && constructorAnalyzer != null) {
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, null));
}
if (indexedAccessor.fieldName() != null) {
String indexedFieldValue = fieldValues.get(indexedAccessor.fieldName());
if (indexedFieldValue != null) {
return List.of(indexedFieldValue);
}
}
if (constantExtractor != null) {
List<String> indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants(
indexedAccessor, receiverContext.contextCu(), visited);
if (!indexedConstants.isEmpty()) {
return indexedConstants;
}
}
}
return List.of();
}
private List<String> extractConstantGetterReturn(String ownerFqn, String methodName) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
if (typeDeclaration == null) {
return List.of();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> extractor =
constantExtractor != null
? constantExtractor::extractConstantsFromExpression
: (expr, out) -> {
String resolved = constantResolver.resolve(expr, context);
if (resolved != null) {
out.add(resolved);
}
};
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
extractor.accept(node.getExpression(), constants);
}
return false;
}
});
return constants;
}
private static String qualifyEnumLikeResult(MethodDeclaration getter, String result) {
if (result.contains(".")) {
return result;
}
String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null;
if (returnType == null) {
return result;
}
int lastDotRet = returnType.lastIndexOf('.');
String simpleReturnType = lastDotRet > 0 ? returnType.substring(lastDotRet + 1) : returnType;
if ("String".equals(simpleReturnType) || "Integer".equals(simpleReturnType)
|| "Long".equals(simpleReturnType) || "Boolean".equals(simpleReturnType)) {
return result;
}
return simpleReturnType + "." + result;
}
private String indexedFieldName(String ownerFqn, String getterName) {
Optional<AccessorSummary> accessorOpt = lookupIndexedGetter(ownerFqn, getterName);
if (accessorOpt.isEmpty() || accessorOpt.get().fieldName() == null) {
return null;
}
return accessorOpt.get().fieldName();
}
private static List<String> deduplicatePreservingOrder(List<String> values) {
List<String> deduped = new ArrayList<>();
for (String value : values) {
if (!deduped.contains(value)) {
deduped.add(value);
}
}
return deduped;
}
private static String cacheKey(String ownerFqn, String methodName, ReceiverContext receiverContext) {
String cicKey = receiverContext != null && receiverContext.cic() != null
? receiverContext.cic().toString()
: "";
String fieldValuesKey = receiverContext != null && receiverContext.fieldValues() != null
? receiverContext.fieldValues().toString()
: "";
return ownerFqn + "#" + methodName + "#" + cicKey + "#" + fieldValuesKey;
}
private static String simplifyTypeName(String typeFqn) {
if (typeFqn == null) {
return null;
}
String simple = typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn;
if (simple.contains("<")) {
simple = simple.substring(0, simple.indexOf('<'));
}
return simple;
}
}

View File

@@ -0,0 +1,94 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import java.util.HashSet;
import java.util.Set;
/**
* Finds {@code new ...()} field initializers, walking the inheritance chain once.
*/
public final class FieldInitializerFinder {
private final CodebaseContext context;
public FieldInitializerFinder(CodebaseContext context) {
this.context = context;
}
public ClassInstanceCreation findFieldInitializerCic(String ownerFqn, String fieldName) {
if (fieldName == null || ownerFqn == null) {
return null;
}
TypeDeclaration td = context.getTypeDeclaration(ownerFqn);
return findFieldInitializerCic(td, fieldName, new HashSet<>());
}
public ClassInstanceCreation findFieldInitializerCic(TypeDeclaration td, String fieldName, Set<String> visited) {
if (td == null || fieldName == null) {
return null;
}
String typeFqn = context.getFqn(td);
if (typeFqn == null || !visited.add(typeFqn)) {
return null;
}
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment frag
&& frag.getName().getIdentifier().equals(fieldName)
&& frag.getInitializer() instanceof ClassInstanceCreation cic) {
return cic;
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
ClassInstanceCreation inherited = findFieldInitializerCic(superTd, fieldName, visited);
if (inherited != null) {
return inherited;
}
}
return null;
}
public String resolveFieldTypeName(String ownerFqn, String fieldName) {
if (ownerFqn == null || fieldName == null) {
return null;
}
TypeDeclaration td = context.getTypeDeclaration(ownerFqn);
return resolveFieldTypeName(td, fieldName, new HashSet<>());
}
public String resolveFieldTypeName(TypeDeclaration td, String fieldName, Set<String> visited) {
if (td == null || fieldName == null) {
return null;
}
String typeFqn = context.getFqn(td);
if (typeFqn == null || !visited.add(typeFqn)) {
return null;
}
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment frag
&& frag.getName().getIdentifier().equals(fieldName)) {
return fd.getType().toString();
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
return resolveFieldTypeName(superTd, fieldName, visited);
}
return null;
}
}

View File

@@ -0,0 +1,59 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import java.util.Locale;
import java.util.Set;
/**
* Central registry for reactive/factory unwrap and passthrough method names used during constant extraction.
*/
public final class LibraryUnwrapRegistry {
private static final Set<String> UNWRAP_RECEIVER_TYPES = Set.of(
"Optional", "Stream", "List", "Set", "Arrays", "Objects", "Mono", "Flux");
private static final Set<String> UNWRAP_METHOD_NAMES = Set.of(
"of", "ofEntries", "asList", "entry", "just", "withPayload", "success");
private static final String[] STOP_UNWRAP_PREFIXES = {
"map", "parse", "convert", "to", "resolve", "build", "create", "from",
"transform", "translate", "derive", "determine", "calculate", "decode",
"extract", "get", "find", "validate"
};
private LibraryUnwrapRegistry() {
}
public static boolean isUnwrapReceiverType(String receiverExpressionText) {
return receiverExpressionText != null && UNWRAP_RECEIVER_TYPES.contains(receiverExpressionText);
}
public static boolean isUnwrapMethodName(String methodName) {
return methodName != null && UNWRAP_METHOD_NAMES.contains(methodName);
}
public static boolean shouldStopUnwrap(String methodName) {
if (methodName == null) {
return true;
}
String lowerName = methodName.toLowerCase(Locale.ROOT);
for (String prefix : STOP_UNWRAP_PREFIXES) {
if (lowerName.startsWith(prefix)) {
return true;
}
}
return false;
}
public static boolean isEventSetterMethodName(String methodName) {
if (methodName == null) {
return false;
}
return "event".equalsIgnoreCase(methodName)
|| "type".equalsIgnoreCase(methodName)
|| "eventType".equalsIgnoreCase(methodName);
}
public static boolean isHeaderSetterMethodName(String methodName) {
return "setHeader".equals(methodName);
}
}

View File

@@ -0,0 +1,136 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
/**
* Shared {@link MethodInvocation} peeling for factory/reactive wrappers and local passthrough methods.
*/
public final class MethodInvocationUnwrapper {
private MethodInvocationUnwrapper() {
}
public static Expression unwrap(
MethodInvocation mi,
int depth,
String methodFqn,
CodebaseContext context,
Function<MethodInvocation, String> targetMethodFqnResolver) {
if (ResolutionBudget.defaults().isUnwrapExhausted(depth)) {
return mi;
}
String targetMethodFqn = targetMethodFqnResolver != null
? targetMethodFqnResolver.apply(mi)
: null;
if (targetMethodFqn == null && methodFqn != null && methodFqn.contains(".") && mi.getExpression() == null) {
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
targetMethodFqn = className + "." + mi.getName().getIdentifier();
}
if (targetMethodFqn != null && targetMethodFqn.contains(".")) {
String className = targetMethodFqn.substring(0, targetMethodFqn.lastIndexOf('.'));
String methodName = targetMethodFqn.substring(targetMethodFqn.lastIndexOf('.') + 1);
TypeDeclaration currentTd = methodFqn != null && methodFqn.contains(".")
? context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')))
: null;
org.eclipse.jdt.core.dom.CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof org.eclipse.jdt.core.dom.CompilationUnit compilationUnit
? compilationUnit
: null;
TypeDeclaration td = context.getTypeDeclaration(className, cu);
if (td == null) {
td = context.getTypeDeclaration(className);
}
if (td != null) {
MethodDeclaration localMd = context.findMethodDeclaration(td, methodName, true);
if (localMd != null) {
int paramIdx = getReturnedParameterIndex(localMd);
if (paramIdx >= 0 && paramIdx < mi.arguments().size()) {
Expression arg = peelExpression((Expression) mi.arguments().get(paramIdx));
if (arg instanceof MethodInvocation innerMi) {
return unwrap(innerMi, depth + 1, methodFqn, context, targetMethodFqnResolver);
}
return arg;
}
}
}
}
if (mi.getExpression() != null) {
String exprStr = mi.getExpression().toString();
if (!LibraryUnwrapRegistry.isUnwrapReceiverType(exprStr)) {
return mi;
}
} else if (targetMethodFqn == null) {
return mi;
}
if (!mi.arguments().isEmpty()) {
String methodName = mi.getName().getIdentifier();
if (LibraryUnwrapRegistry.shouldStopUnwrap(methodName)) {
return mi;
}
Expression arg = peelExpression((Expression) mi.arguments().get(0));
if (arg instanceof MethodInvocation innerMi) {
return unwrap(innerMi, depth + 1, methodFqn, context, targetMethodFqnResolver);
}
return arg;
}
return mi;
}
public static Expression peelExpression(Expression expression) {
Expression current = expression;
while (current instanceof ParenthesizedExpression pe) {
current = pe.getExpression();
}
return current;
}
public static int getReturnedParameterIndex(MethodDeclaration md) {
if (md.getBody() == null) {
return -1;
}
List<?> parameters = md.parameters();
if (parameters.isEmpty()) {
return -1;
}
final List<String> returnedExprs = new ArrayList<>();
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
returnedExprs.add(node.getExpression().toString());
}
return super.visit(node);
}
});
if (returnedExprs.size() == 1) {
String retStr = returnedExprs.get(0);
for (int i = 0; i < parameters.size(); i++) {
if (parameters.get(i) instanceof SingleVariableDeclaration svd
&& svd.getName().getIdentifier().equals(retStr)) {
return i;
}
}
}
return -1;
}
}

View File

@@ -0,0 +1,115 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
/**
* Shared depth and fallback budget for analysis traversals. Replaces scattered magic-number limits.
*/
public final class ResolutionBudget {
public static final int DEFAULT_METHOD_RETURN = 20;
public static final int DEFAULT_ACCESSOR_INLINE = 25;
public static final int DEFAULT_DATAFLOW = 50;
public static final int DEFAULT_UNWRAP = 5;
public static final int DEFAULT_CONSTRUCTOR = 10;
public static final int DEFAULT_RETURNED_CIC = 50;
public static final int DEFAULT_GLOBAL_FIELD_SCAN = 10;
private final int methodReturnDepth;
private final int accessorInlineDepth;
private final int dataflowDepth;
private final int unwrapDepth;
private final int constructorDepth;
private final int returnedCicDepth;
private final int globalFieldScanLimit;
private final boolean allowGlobalFieldScan;
private ResolutionBudget(
int methodReturnDepth,
int accessorInlineDepth,
int dataflowDepth,
int unwrapDepth,
int constructorDepth,
int returnedCicDepth,
int globalFieldScanLimit,
boolean allowGlobalFieldScan) {
this.methodReturnDepth = methodReturnDepth;
this.accessorInlineDepth = accessorInlineDepth;
this.dataflowDepth = dataflowDepth;
this.unwrapDepth = unwrapDepth;
this.constructorDepth = constructorDepth;
this.returnedCicDepth = returnedCicDepth;
this.globalFieldScanLimit = globalFieldScanLimit;
this.allowGlobalFieldScan = allowGlobalFieldScan;
}
public static ResolutionBudget defaults() {
return new ResolutionBudget(
DEFAULT_METHOD_RETURN,
DEFAULT_ACCESSOR_INLINE,
DEFAULT_DATAFLOW,
DEFAULT_UNWRAP,
DEFAULT_CONSTRUCTOR,
DEFAULT_RETURNED_CIC,
DEFAULT_GLOBAL_FIELD_SCAN,
false);
}
public static ResolutionBudget withGlobalFieldScan() {
return defaults().enableGlobalFieldScan();
}
public ResolutionBudget enableGlobalFieldScan() {
return new ResolutionBudget(
methodReturnDepth,
accessorInlineDepth,
dataflowDepth,
unwrapDepth,
constructorDepth,
returnedCicDepth,
globalFieldScanLimit,
true);
}
public ResolutionBudget child() {
return new ResolutionBudget(
methodReturnDepth - 1,
accessorInlineDepth - 1,
dataflowDepth - 1,
unwrapDepth,
constructorDepth - 1,
returnedCicDepth - 1,
globalFieldScanLimit,
allowGlobalFieldScan);
}
public boolean isMethodReturnExhausted(int depth) {
return depth > methodReturnDepth;
}
public boolean isAccessorInlineExhausted(int depth) {
return depth > accessorInlineDepth;
}
public boolean isDataflowExhausted(int depth) {
return depth > dataflowDepth;
}
public boolean isUnwrapExhausted(int depth) {
return depth > unwrapDepth;
}
public boolean isConstructorExhausted(int depth) {
return depth > constructorDepth;
}
public boolean isReturnedCicExhausted(int depth) {
return depth > returnedCicDepth;
}
public int globalFieldScanLimit() {
return globalFieldScanLimit;
}
public boolean allowGlobalFieldScan() {
return allowGlobalFieldScan;
}
}

View File

@@ -0,0 +1,190 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Shared boolean simplification for transition guards and dispatcher domain checks.
*/
public final class BooleanConstraintEvaluator {
private static final Pattern STRING_LITERAL_PATTERN = Pattern.compile("[\"']([a-zA-Z0-9_-]+)[\"']");
private BooleanConstraintEvaluator() {
}
/**
* Simplifies constraints such as {@code "ORDER".equals(domain)} against a machine domain key.
*/
public static boolean isCompatibleWithMachineDomain(String constraint, String machineDomainKey) {
if (constraint == null || machineDomainKey == null) {
return true;
}
String cleanMachine = machineDomainKey.toUpperCase();
String expr = constraint;
Set<String> literals = extractStringLiterals(constraint);
for (String literal : literals) {
boolean matchesMachine = literal.equalsIgnoreCase(cleanMachine)
|| cleanMachine.equalsIgnoreCase(literal)
|| (cleanMachine.length() > literal.length() && cleanMachine.startsWith(literal.toUpperCase()));
String replacement = matchesMachine ? "true" : "false";
String regex1 = "(?i)[\"']?" + Pattern.quote(literal) + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\([^)]*\\)";
String regex2 = "(?i)[a-zA-Z0-9._]+\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
+ Pattern.quote(literal) + "[\"']?\\s*\\)";
String regex3 = "(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + Pattern.quote(literal) + "[\"']?";
String regex4 = "(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + Pattern.quote(literal) + "[\"']?";
expr = expr.replaceAll(regex1, replacement);
expr = expr.replaceAll(regex2, replacement);
expr = expr.replaceAll(regex3, replacement);
expr = expr.replaceAll(regex4, matchesMachine ? "false" : "true");
}
return evaluateBooleanExpression(expr);
}
public static boolean evaluateBooleanExpression(String expression) {
if (expression == null || expression.isBlank()) {
return true;
}
try {
return parseExpression(expression.replaceAll("\\s+", ""));
} catch (Exception e) {
return !expression.contains("false");
}
}
/**
* Evaluates a constraint against known variable bindings gathered while walking a call path forward.
* Supports {@code var == VALUE} where VALUE may be a quoted string or enum constant; suffix matching
* is used so {@code command == ORDER_PAY} matches {@code DomainCommand.ORDER_PAY}.
*/
public static boolean isCompatibleWithBindings(String constraint, Map<String, String> bindings) {
if (constraint == null || constraint.isBlank()) {
return true;
}
if (bindings == null || bindings.isEmpty()) {
return true;
}
Set<String> equalityVars = extractEqualityVariables(constraint);
for (String varName : equalityVars) {
if (!bindings.containsKey(varName)) {
return false;
}
}
String expr = constraint;
for (Map.Entry<String, String> entry : bindings.entrySet()) {
expr = substituteVariableBindings(expr, entry.getKey(), entry.getValue());
}
return evaluateBooleanExpression(expr);
}
private static Set<String> extractEqualityVariables(String constraint) {
Set<String> vars = new HashSet<>();
Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*==");
Matcher matcher = pattern.matcher(constraint);
while (matcher.find()) {
vars.add(matcher.group(1));
}
return vars;
}
private static String substituteVariableBindings(String expr, String varName, String boundValue) {
if (boundValue == null || boundValue.isBlank()) {
return expr;
}
String cleanValue = boundValue;
if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) {
cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
}
String suffix = cleanValue.contains(".") ? cleanValue.substring(cleanValue.lastIndexOf('.') + 1) : cleanValue;
Pattern eqPattern = Pattern.compile(
"(?i)" + Pattern.quote(varName) + "\\s*==\\s*([\\w.\"']+)");
Matcher matcher = eqPattern.matcher(expr);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String rhs = matcher.group(1).replace("\"", "").replace("'", "");
boolean matches = cleanValue.equals(rhs)
|| cleanValue.endsWith("." + rhs)
|| suffix.equalsIgnoreCase(rhs)
|| cleanValue.equalsIgnoreCase(rhs);
matcher.appendReplacement(sb, matches ? "true" : "false");
}
matcher.appendTail(sb);
return sb.toString();
}
/**
* Returns {@code true}/{@code false} when both sides are compile-time string literals; otherwise {@code null}.
*/
public static Boolean evaluateStringEquals(String leftLiteral, String rightLiteral, boolean ignoreCase) {
if (leftLiteral == null || rightLiteral == null) {
return null;
}
return ignoreCase
? leftLiteral.equalsIgnoreCase(rightLiteral)
: leftLiteral.equals(rightLiteral);
}
private static Set<String> extractStringLiterals(String constraint) {
Set<String> literals = new HashSet<>();
Matcher matcher = STRING_LITERAL_PATTERN.matcher(constraint);
while (matcher.find()) {
literals.add(matcher.group(1));
}
return literals;
}
private static boolean parseExpression(String expression) {
if (expression.isEmpty()) {
return true;
}
int parenDepth = 0;
for (int i = expression.length() - 1; i >= 0; i--) {
char c = expression.charAt(i);
if (c == ')') {
parenDepth++;
} else if (c == '(') {
parenDepth--;
} else if (parenDepth == 0 && i > 0 && expression.charAt(i) == '|' && expression.charAt(i - 1) == '|') {
return parseExpression(expression.substring(0, i - 1)) || parseExpression(expression.substring(i + 1));
}
}
parenDepth = 0;
for (int i = expression.length() - 1; i >= 0; i--) {
char c = expression.charAt(i);
if (c == ')') {
parenDepth++;
} else if (c == '(') {
parenDepth--;
} else if (parenDepth == 0 && i > 0 && expression.charAt(i) == '&' && expression.charAt(i - 1) == '&') {
return parseExpression(expression.substring(0, i - 1)) && parseExpression(expression.substring(i + 1));
}
}
if (expression.startsWith("!")) {
return !parseExpression(expression.substring(1));
}
if (expression.startsWith("(") && expression.endsWith(")")) {
return parseExpression(expression.substring(1, expression.length() - 1));
}
if ("true".equalsIgnoreCase(expression)) {
return true;
}
if ("false".equalsIgnoreCase(expression)) {
return false;
}
return true;
}
}

View File

@@ -1,9 +1,11 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;
import java.util.LinkedHashSet;
@@ -17,6 +19,10 @@ public class ConstantResolver {
return resolveInternal(expr, context, new HashSet<>());
}
public static List<String> decodeMapLiteralValues(String encoded) {
return LiteralExpressionSupport.mapLiteralValues(encoded);
}
public String evaluateSwitchWithParams(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context) {
return evaluateSwitchExpression(se, paramValues, context, new HashSet<>());
}
@@ -35,6 +41,25 @@ public class ConstantResolver {
if (expr instanceof ParenthesizedExpression pe) {
return resolveInternal(pe.getExpression(), context, visited);
}
if (expr instanceof ArrayAccess access) {
String indexed = LiteralExpressionSupport.resolveArrayAccess(
access, context, visited, (e, v) -> resolveInternal(e, context, v));
if (indexed != null) {
return indexed;
}
}
if (expr instanceof ArrayCreation creation && creation.getInitializer() != null) {
List<String> elements = extractArrayElementsFromCreation(creation, context, visited);
if (elements != null) {
return LiteralExpressionSupport.encodeArrayLiteral(elements);
}
}
if (expr instanceof ArrayInitializer initializer) {
List<String> elements = extractArrayElementsFromInitializer(initializer, context, visited);
if (elements != null) {
return LiteralExpressionSupport.encodeArrayLiteral(elements);
}
}
if (expr instanceof MethodInvocation mi && (mi.getName().getIdentifier().equals("requireNonNull") || mi.getName().getIdentifier().equals("notNull"))) {
if (!mi.arguments().isEmpty()) {
return resolveInternal((Expression) mi.arguments().get(0), context, visited);
@@ -76,6 +101,10 @@ public class ConstantResolver {
return val != null ? val : qn.toString();
}
if (expr instanceof FieldAccess fa) {
String staticField = resolveStaticFieldAccess(fa, context, visited);
if (staticField != null) {
return staticField;
}
return resolveManual(fa.getName(), context, visited);
}
if (expr instanceof SimpleName sn) {
@@ -84,7 +113,26 @@ public class ConstantResolver {
if (expr instanceof MethodInvocation mi) {
if ((mi.getName().getIdentifier().equals("name") || mi.getName().getIdentifier().equals("toString")) && mi.arguments().isEmpty()) {
String methodName = mi.getName().getIdentifier();
if ("get".equals(methodName) || "getOrDefault".equals(methodName)) {
String mapValue = LiteralExpressionSupport.resolveMapGet(
mi, context, visited, (e, v) -> resolveInternal(e, context, v));
if (mapValue != null) {
return mapValue;
}
}
String transformed = LiteralExpressionSupport.resolveLiteralStringTransform(
mi, visited, (e, v) -> resolveInternal(e, context, v));
if (transformed != null) {
return transformed;
}
java.util.Map<String, String> mapEntries = LiteralExpressionSupport.extractMapEntries(
mi, context, visited, (e, v) -> resolveInternal(e, context, v));
if (mapEntries != null && isMapFactoryInvocation(mi)) {
return LiteralExpressionSupport.encodeMapLiteral(mapEntries);
}
if ((methodName.equals("name") || methodName.equals("toString")) && mi.arguments().isEmpty()) {
String val = resolveInternal(mi.getExpression(), context, visited);
if (val != null) {
// If it was resolved as a fully qualified enum string like "MyEvents.ORDER_PAID", strip it
@@ -671,6 +719,50 @@ public class ConstantResolver {
return fields.isEmpty() ? null : fields;
}
private List<String> extractArrayElementsFromCreation(ArrayCreation creation, CodebaseContext context, Set<String> visited) {
if (creation.getInitializer() == null) {
return null;
}
List<String> values = new ArrayList<>();
for (Object expressionObj : creation.getInitializer().expressions()) {
String value = resolveInternal((Expression) expressionObj, context, visited);
if (value == null || value.startsWith("ARRAY:") || value.startsWith("MAP:")) {
return null;
}
values.add(value);
}
return values;
}
private List<String> extractArrayElementsFromInitializer(ArrayInitializer initializer, CodebaseContext context, Set<String> visited) {
List<String> values = new ArrayList<>();
for (Object expressionObj : initializer.expressions()) {
String value = resolveInternal((Expression) expressionObj, context, visited);
if (value == null || value.startsWith("ARRAY:") || value.startsWith("MAP:")) {
return null;
}
values.add(value);
}
return values;
}
private boolean isMapFactoryInvocation(MethodInvocation mi) {
if (!"of".equals(mi.getName().getIdentifier()) && !"ofEntries".equals(mi.getName().getIdentifier())) {
return false;
}
Expression receiver = mi.getExpression();
if (receiver == null) {
return false;
}
if (receiver instanceof SimpleName sn) {
return "Map".equals(sn.getIdentifier());
}
if (receiver instanceof QualifiedName qn) {
return "Map".equals(qn.getName().getIdentifier());
}
return false;
}
private String resolveInfix(InfixExpression expr, CodebaseContext context, Set<String> visited) {
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
@@ -721,7 +813,18 @@ public class ConstantResolver {
if (td != null) {
String fqn = context.getFqn(td);
String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited);
if (result != null) return result;
if (result != null) {
return result;
}
}
td = typeFromCurrentCallPath(context);
if (td != null) {
String fqn = context.getFqn(td);
String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited);
if (result != null) {
return result;
}
}
// Fallback: Static imports
@@ -742,6 +845,9 @@ public class ConstantResolver {
CompilationUnit contextCu = (CompilationUnit) qn.getRoot();
TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu);
if (td == null) {
td = resolveTypeInContext(qualifier, qn, context);
}
if (td != null) {
String fqn = context.getFqn(td);
@@ -751,6 +857,67 @@ public class ConstantResolver {
return null;
}
private String resolveStaticFieldAccess(FieldAccess fa, CodebaseContext context, Set<String> visited) {
Expression receiver = fa.getExpression();
if (receiver instanceof ThisExpression) {
return null;
}
String typeName = null;
if (receiver instanceof SimpleName sn) {
typeName = sn.getIdentifier();
} else if (receiver instanceof QualifiedName qn) {
typeName = qn.getFullyQualifiedName();
}
if (typeName == null) {
return null;
}
TypeDeclaration td = resolveTypeInContext(typeName, fa, context);
if (td == null) {
td = typeFromCurrentCallPath(context);
}
if (td == null) {
return null;
}
return resolveFieldInType(td, fa.getName().getIdentifier(), context.getFqn(td), context, visited);
}
private TypeDeclaration resolveTypeInContext(String typeName, ASTNode node, CodebaseContext context) {
CompilationUnit contextCu = node.getRoot() instanceof CompilationUnit cu ? cu : null;
TypeDeclaration td = contextCu != null
? context.getTypeDeclaration(typeName, contextCu)
: context.getTypeDeclaration(typeName);
if (td != null) {
return td;
}
String entryClass = getCurrentCallPathEntryClass();
if (entryClass == null) {
return null;
}
TypeDeclaration entryTd = context.getTypeDeclaration(entryClass);
if (entryTd == null || !(entryTd.getRoot() instanceof CompilationUnit entryCu)) {
return null;
}
return context.getTypeDeclaration(typeName, entryCu);
}
private TypeDeclaration typeFromCurrentCallPath(CodebaseContext context) {
String entryClass = getCurrentCallPathEntryClass();
if (entryClass == null) {
return null;
}
return context.getTypeDeclaration(entryClass);
}
private String getCurrentCallPathEntryClass() {
List<String> path = JdtDataFlowModel.getCurrentPath();
if (path == null || path.isEmpty()) {
return null;
}
String entryMethod = path.get(0);
int dot = entryMethod.lastIndexOf('.');
return dot > 0 ? entryMethod.substring(0, dot) : null;
}
private String resolveFieldInType(TypeDeclaration td, String fieldName, String typeFqn, CodebaseContext context, Set<String> visited) {
String fieldId = typeFqn + "#" + fieldName;
if (visited.contains(fieldId)) {
@@ -870,9 +1037,19 @@ public class ConstantResolver {
}
if (valueExpr instanceof StringLiteral sl) {
return sl.getLiteralValue();
String literal = sl.getLiteralValue();
if (literal != null && literal.contains("${")) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
literal, java.util.Collections.emptyMap());
}
return literal;
}
return valueExpr != null ? valueExpr.toString().replaceAll("^\"|\"$", "") : null;
String raw = valueExpr != null ? valueExpr.toString().replaceAll("^\"|\"$", "") : null;
if (raw != null && raw.contains("${")) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
raw, java.util.Collections.emptyMap());
}
return raw;
}
private TypeDeclaration findEnclosingType(ASTNode node) {

View File

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

View File

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

View File

@@ -0,0 +1,348 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
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 java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Canonicalizes enum-backed state/event identifiers for analysis using the owning machine config's
* {@code StateMachineConfigurerAdapter<State, Event>} type parameters.
*
* <p>Display formatting ({@code --event}/{@code --state}) is separate; this fixes internal
* {@code fullIdentifier} values used for matching and traceability.
*/
public final class MachineEnumCanonicalizer {
private MachineEnumCanonicalizer() {
}
public static void canonicalizeTransitions(
List<Transition> transitions,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (transitions == null || machineTypes == null) {
return;
}
for (Transition transition : transitions) {
if (transition.getEvent() != null) {
transition.setEvent(canonicalizeEvent(transition.getEvent(), machineTypes.eventTypeFqn()));
}
if (transition.getSourceStates() != null) {
transition.setSourceStates(canonicalizeStates(transition.getSourceStates(), machineTypes.stateTypeFqn()));
}
if (transition.getTargetStates() != null) {
transition.setTargetStates(canonicalizeStates(transition.getTargetStates(), machineTypes.stateTypeFqn()));
}
}
}
public static Set<String> canonicalizeStateLabels(Set<String> labels, String stateTypeFqn) {
if (labels == null || labels.isEmpty()) {
return labels;
}
return labels.stream()
.map(label -> canonicalizeLabel(label, stateTypeFqn))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
public static Set<State> canonicalizeStates(Set<State> states, String stateTypeFqn) {
if (states == null || states.isEmpty()) {
return states;
}
return states.stream()
.map(state -> canonicalizeState(state, stateTypeFqn))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
public static TriggerPoint canonicalizeTriggerPoint(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (trigger == null) {
return null;
}
String eventTypeFqn = preferFullTypeFqn(
trigger.getEventTypeFqn(),
machineTypes != null ? machineTypes.eventTypeFqn() : null);
String stateTypeFqn = preferFullTypeFqn(
trigger.getStateTypeFqn(),
machineTypes != null ? machineTypes.stateTypeFqn() : null);
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
: trigger.getPolymorphicEvents().stream()
.map(event -> canonicalizeLabel(event, eventTypeFqn))
.collect(Collectors.toList());
return trigger.toBuilder()
.eventTypeFqn(eventTypeFqn)
.stateTypeFqn(stateTypeFqn)
.event(canonicalizeLabel(trigger.getEvent(), eventTypeFqn))
.sourceState(canonicalizeLabel(trigger.getSourceState(), stateTypeFqn))
.polymorphicEvents(polymorphicEvents)
.build();
}
public static String qualifyEventIdentifier(String event, String eventTypeFqn) {
if (event == null || eventTypeFqn == null || event.isEmpty()) {
return event;
}
if (event.startsWith("<SYMBOLIC: ")) {
return event;
}
if (isWildcardVariable(event)) {
return event;
}
if (Character.isLowerCase(event.charAt(0))) {
return event;
}
if (!isEnumConstantCandidate(event)) {
return event;
}
if (isStringOrPrimitiveType(eventTypeFqn)) {
return event;
}
if (isMachineEnumReference(event, eventTypeFqn)) {
return canonicalizeLabel(event, stripGenerics(eventTypeFqn));
}
return event;
}
private static String preferFullTypeFqn(String primary, String fallback) {
if (primary != null && primary.contains(".")) {
return stripGenerics(primary);
}
if (fallback != null && fallback.contains(".")) {
return stripGenerics(fallback);
}
return primary != null ? primary : fallback;
}
private static String stripGenerics(String typeFqn) {
int idx = typeFqn.indexOf('<');
return idx >= 0 ? typeFqn.substring(0, idx) : typeFqn;
}
public static boolean isStringOrPrimitiveType(String typeFqn) {
if (typeFqn == null) {
return false;
}
return typeFqn.equals("String") || typeFqn.equals("java.lang.String")
|| typeFqn.equals("int") || typeFqn.equals("long") || typeFqn.equals("char")
|| typeFqn.equals("java.lang.Object") || typeFqn.equals("Object");
}
private static boolean isEnumConstantCandidate(String eventStr) {
if (eventStr == null) {
return false;
}
if (eventStr.startsWith("<SYMBOLIC: ")) {
return false;
}
return !eventStr.contains(" ") && !eventStr.contains("(") && !eventStr.contains(")")
&& !eventStr.contains("?");
}
private static boolean isWildcardVariable(String eventStr) {
if (eventStr == null) {
return false;
}
if (eventStr.equals("event") || eventStr.equals("e")
|| eventStr.equals("msg") || eventStr.equals("message")
|| eventStr.equals("payload")) {
return true;
}
if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) {
return true;
}
return eventStr.contains(" ? ") && eventStr.contains(" : ");
}
private static List<State> canonicalizeStates(List<State> states, String stateTypeFqn) {
if (states == null) {
return null;
}
List<State> canonical = new ArrayList<>(states.size());
for (State state : states) {
canonical.add(canonicalizeState(state, stateTypeFqn));
}
return canonical;
}
static Event canonicalizeEvent(Event event, String enumTypeFqn) {
if (event == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return event;
}
String canonical = canonicalizeLabel(
event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(),
enumTypeFqn);
String fnRaw = toFnForm(canonical, enumTypeFqn);
if (fnRaw == null) {
fnRaw = event.rawName();
}
if (canonical.equals(event.fullIdentifier()) && fnRaw.equals(event.rawName())) {
return event;
}
return Event.of(fnRaw, canonical);
}
static State canonicalizeState(State state, String enumTypeFqn) {
if (state == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return state;
}
String raw = resolvePlaceholder(state.rawName());
String full = resolvePlaceholder(
state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName());
String canonical = canonicalizeLabel(full, enumTypeFqn);
if (canonical.equals(state.fullIdentifier()) && raw.equals(state.rawName())) {
return state;
}
if (isStringOrPrimitiveType(enumTypeFqn) && canonical.startsWith(enumTypeFqn + ".")) {
String constant = canonical.substring(enumTypeFqn.length() + 1);
raw = "\"" + constant + "\"";
} else {
String fnRaw = toFnForm(canonical, enumTypeFqn);
if (fnRaw != null) {
raw = fnRaw;
}
}
return State.of(raw, canonical);
}
private static String resolvePlaceholder(String value) {
if (value == null || !value.contains("${")) {
return value;
}
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
value, java.util.Collections.emptyMap());
}
public static boolean isMachineEnumReference(String value, String enumTypeFqn) {
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return false;
}
if (isStringOrPrimitiveType(enumTypeFqn)) {
return false;
}
if (value.startsWith("<SYMBOLIC: ")) {
return false;
}
if (value.length() >= 2 && value.startsWith("\"")) {
return false;
}
if (!isEnumConstantCandidate(value)) {
return false;
}
if (Character.isLowerCase(value.charAt(0))) {
return false;
}
String typePart = enumTypeFromRef(value);
if (typePart == null) {
return true;
}
return enumTypesMatch(enumTypeFqn, typePart);
}
public static String canonicalizeLabel(String value, String enumTypeFqn) {
if (value == null || value.isBlank() || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return value;
}
value = resolvePlaceholder(value);
if (value == null || value.isBlank()) {
return value;
}
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
return stripQuotes(value);
}
String stripped = value;
if (stripped == null || stripped.isBlank()) {
return value;
}
if (stripped.startsWith(enumTypeFqn + ".")) {
return stripped;
}
String constant = constantName(stripped);
String typePart = enumTypeFromRef(stripped);
if (typePart != null && enumTypesMatch(enumTypeFqn, typePart)) {
return enumTypeFqn + "." + constant;
}
if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) {
return enumTypeFqn + "." + stripped;
}
return stripped;
}
private static String stripQuotes(String value) {
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
return value.substring(1, value.length() - 1);
}
return value;
}
private static String constantName(String ref) {
int dot = ref.lastIndexOf('.');
return dot >= 0 ? ref.substring(dot + 1) : ref;
}
private static String enumTypeFromRef(String ref) {
int dot = ref.lastIndexOf('.');
if (dot <= 0) {
return null;
}
return ref.substring(0, dot);
}
static boolean enumTypesMatch(String type1, String type2) {
if (type1 == null || type2 == null) {
return false;
}
if (type1.equals(type2)) {
return true;
}
if (type1.endsWith("." + type2) || type2.endsWith("." + type1)) {
return true;
}
String simple1 = simpleName(type1);
String simple2 = simpleName(type2);
if (!simple1.equals(simple2)) {
return false;
}
return !type1.contains(".") || !type2.contains(".");
}
private static String simpleName(String fqn) {
return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
}
/**
* Derives {@code EnumFormat.fn} display form ({@code OrderEvent.PAY}) from a package-canonical identifier.
*/
static String toFnForm(String canonicalFqn, String enumTypeFqn) {
if (canonicalFqn == null || canonicalFqn.isBlank() || isStringOrPrimitiveType(enumTypeFqn)) {
return null;
}
if (canonicalFqn.startsWith("<") || canonicalFqn.startsWith("\"")) {
return null;
}
if (!canonicalFqn.contains(".")) {
return canonicalFqn;
}
int lastDot = canonicalFqn.lastIndexOf('.');
int prevDot = canonicalFqn.lastIndexOf('.', lastDot - 1);
if (prevDot > 0) {
return canonicalFqn.substring(prevDot + 1);
}
return simpleName(enumTypeFqn) + "." + canonicalFqn.substring(lastDot + 1);
}
}

View File

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

View File

@@ -3,224 +3,41 @@ package click.kamil.springstatemachineexporter.analysis.resolver;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Discovers related local modules by statically parsing Maven/Gradle build files (never executes build tools).
*/
@Slf4j
public class SiblingDependencyResolver {
private static final Pattern GRADLE_PROJECT_DEP = Pattern.compile("project\\(['\"]:(.*?)['\"]\\)");
private static final Pattern MAVEN_DEPENDENCY = Pattern.compile("<dependency>\\s*<groupId>(.*?)</groupId>\\s*<artifactId>(.*?)</artifactId>\\s*<version>(.*?)</version>", Pattern.DOTALL);
private final StaticBuildFileAnalyzer analyzer = new StaticBuildFileAnalyzer();
public ProjectModuleGraph analyzeProject(Path inputDir) throws IOException {
return analyzer.analyze(inputDir);
}
public Set<Path> resolveSiblings(Path projectDir) {
Set<Path> allSiblings = new HashSet<>();
Queue<Path> queue = new LinkedList<>();
queue.add(projectDir);
while (!queue.isEmpty()) {
Path current = queue.poll();
Set<Path> currentSiblings = new HashSet<>();
try {
currentSiblings.addAll(resolveGradleSiblings(current));
currentSiblings.addAll(resolveMavenSiblings(current));
} catch (Exception e) {
log.warn("Error resolving sibling dependencies for {}", current, e);
}
for (Path sibling : currentSiblings) {
if (!allSiblings.contains(sibling) && !sibling.equals(projectDir)) {
allSiblings.add(sibling);
queue.add(sibling);
}
}
try {
ProjectModuleGraph graph = analyzer.analyze(projectDir);
return resolveSiblings(graph, projectDir);
} catch (IOException e) {
log.warn("Error resolving sibling dependencies for {}", projectDir, e);
return Set.of();
}
return allSiblings;
}
private Set<Path> resolveGradleSiblings(Path projectDir) throws IOException {
Set<Path> siblings = new HashSet<>();
Path buildGradle = projectDir.resolve("build.gradle");
if (!Files.exists(buildGradle)) {
buildGradle = projectDir.resolve("build.gradle.kts");
}
if (Files.exists(buildGradle)) {
log.info("Found build.gradle at {}", buildGradle);
String content = Files.readString(buildGradle);
Matcher matcher = GRADLE_PROJECT_DEP.matcher(content);
Set<String> gradlePaths = new HashSet<>();
while (matcher.find()) {
gradlePaths.add(matcher.group(1));
}
log.info("Found project dependencies: {}", gradlePaths);
if (!gradlePaths.isEmpty()) {
Path rootDir = findGradleRoot(projectDir);
log.info("Resolved Gradle root to: {}", rootDir);
if (rootDir != null) {
for (String gradlePath : gradlePaths) {
String relativePath = gradlePath.replace(':', '/');
Path resolvedSibling = rootDir.resolve(relativePath).normalize();
// Check for explicit projectDir mapping in settings.gradle or settings.gradle.kts
Path settingsFile = rootDir.resolve("settings.gradle");
if (!Files.exists(settingsFile)) {
settingsFile = rootDir.resolve("settings.gradle.kts");
}
if (Files.exists(settingsFile)) {
String settingsContent = Files.readString(settingsFile);
String regex = "project\\(['\"]:" + gradlePath + "['\"]\\)\\.projectDir\\s*=\\s*(?:file\\(['\"](.*?)['\"]\\)|new File\\(.*?,\\s*['\"](.*?)['\"]\\))";
Matcher settingsMatcher = Pattern.compile(regex).matcher(settingsContent);
if (settingsMatcher.find()) {
String customPath = settingsMatcher.group(1) != null ? settingsMatcher.group(1) : settingsMatcher.group(2);
resolvedSibling = rootDir.resolve(customPath).normalize();
log.info("Found custom projectDir in settings for {}: {}", gradlePath, resolvedSibling);
}
}
if (Files.exists(resolvedSibling)) {
log.info("Discovered local Gradle sibling dependency: {}", resolvedSibling);
siblings.add(resolvedSibling);
}
}
}
public Set<Path> resolveSiblings(ProjectModuleGraph graph, Path inputDir) {
Set<Path> scanPaths = graph.resolveScanPaths(inputDir);
Set<Path> siblings = new LinkedHashSet<>();
Path normalizedInput = inputDir.toAbsolutePath().normalize();
for (Path path : scanPaths) {
if (!path.equals(normalizedInput)) {
siblings.add(path);
}
}
return siblings;
}
private Path findGradleRoot(Path start) {
Path current = start.toAbsolutePath();
Path lastSettingsDir = null;
while (current != null) {
if (Files.exists(current.resolve("settings.gradle")) || Files.exists(current.resolve("settings.gradle.kts"))) {
lastSettingsDir = current;
}
if (Files.exists(current.resolve(".git"))) {
return lastSettingsDir != null ? lastSettingsDir : current;
}
current = current.getParent();
}
return lastSettingsDir;
}
private Set<Path> resolveMavenSiblings(Path projectDir) throws IOException {
Set<Path> siblings = new HashSet<>();
Path pom = projectDir.resolve("pom.xml");
if (Files.exists(pom)) {
log.info("Found pom.xml at {}", pom);
String content = Files.readString(pom);
Set<String> targetArtifacts = new HashSet<>();
Matcher matcher = Pattern.compile("<artifactId>(.*?)</artifactId>").matcher(content);
// We want artifacts from the <dependencies> section
int depsStart = content.indexOf("<dependencies>");
int depsEnd = content.indexOf("</dependencies>");
if (depsStart != -1 && depsEnd > depsStart) {
String depsContent = content.substring(depsStart, depsEnd);
Matcher depMatcher = Pattern.compile("<artifactId>(.*?)</artifactId>").matcher(depsContent);
while (depMatcher.find()) {
targetArtifacts.add(depMatcher.group(1));
}
}
log.info("Found maven target artifacts: {}", targetArtifacts);
if (!targetArtifacts.isEmpty()) {
Path rootDir = findMavenRoot(projectDir);
log.info("Resolved Maven root to: {}", rootDir);
if (rootDir != null) {
try (Stream<Path> paths = Files.walk(rootDir)) {
List<Path> allPoms = paths.filter(p -> p.getFileName().toString().equals("pom.xml"))
.filter(p -> !p.toString().contains("/target/"))
.collect(Collectors.toList());
log.info("Scanned {} total pom.xml files under {}", allPoms.size(), rootDir);
for (Path p : allPoms) {
try {
String pContent = Files.readString(p);
String artifactId = extractOwnArtifactId(pContent);
log.info("Checking artifactId {} in {}", artifactId, p);
if (artifactId != null && targetArtifacts.contains(artifactId)) {
Path siblingDir = p.getParent();
if (!siblingDir.equals(projectDir)) {
log.info("Discovered local Maven sibling dependency via artifact match: {}", siblingDir);
siblings.add(siblingDir);
}
}
} catch (IOException e) {
// ignore
}
}
}
}
}
// Also directly add aggregator <module> directories declared in this pom
Matcher moduleMatcher = Pattern.compile("<module>(.*?)</module>").matcher(content);
while (moduleMatcher.find()) {
String moduleName = moduleMatcher.group(1).trim();
Path moduleDir = projectDir.resolve(moduleName).normalize();
if (Files.exists(moduleDir) && Files.exists(moduleDir.resolve("pom.xml")) && !moduleDir.equals(projectDir)) {
log.info("Discovered local Maven sibling dependency via <module>: {}", moduleDir);
siblings.add(moduleDir);
}
}
// Also add parent dir if it has a pom.xml
Path parentDir = projectDir.getParent();
// Check for explicit <relativePath> in parent block
Matcher parentRelativePathMatcher = Pattern.compile("<parent>.*?<relativePath>(.*?)</relativePath>.*?</parent>", Pattern.DOTALL).matcher(content);
if (parentRelativePathMatcher.find()) {
String relPathStr = parentRelativePathMatcher.group(1).trim();
Path relPath = projectDir.resolve(relPathStr).normalize();
if (Files.isRegularFile(relPath) && relPath.getFileName().toString().equals("pom.xml")) {
parentDir = relPath.getParent();
} else if (Files.isDirectory(relPath)) {
parentDir = relPath;
}
}
if (parentDir != null && Files.exists(parentDir.resolve("pom.xml")) && !parentDir.equals(projectDir)) {
log.info("Discovered local Maven parent sibling: {}", parentDir);
siblings.add(parentDir);
}
}
return siblings;
}
private String extractOwnArtifactId(String pomContent) {
// Strip <parent> block if it exists
String content = pomContent;
int parentEnd = pomContent.indexOf("</parent>");
if (parentEnd != -1) {
content = pomContent.substring(parentEnd);
}
Matcher matcher = Pattern.compile("<artifactId>(.*?)</artifactId>").matcher(content);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
private Path findMavenRoot(Path start) {
Path current = start.toAbsolutePath();
Path lastPomDir = null;
while (current != null) {
if (Files.exists(current.resolve("pom.xml"))) {
lastPomDir = current;
}
if (Files.exists(current.resolve(".git"))) {
return lastPomDir != null ? lastPomDir : current;
}
current = current.getParent();
}
return lastPomDir;
}
}

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
@@ -21,6 +26,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
protected final VariableTracer variableTracer;
protected final ConstantExtractor constantExtractor;
protected final ConstructorAnalyzer constructorAnalyzer;
protected final ExpressionAccessClassifier expressionAccessClassifier;
protected final AccessorResolver accessorResolver;
protected final FieldInitializerFinder fieldInitializerFinder;
protected final PathBindingEvaluator pathBindingEvaluator;
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
private ASTNode parseExpressionString(String expr) {
@@ -63,7 +72,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
public AbstractCallGraphEngine(CodebaseContext context) {
this.context = context;
this.constantResolver = new ConstantResolver();
this.constantResolver = context.getConstantResolver();
this.typeResolver = new TypeResolver(context);
this.pathFinder = new CallGraphPathFinder(context);
@@ -77,6 +86,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
this.constantExtractor.setConstructorAnalyzer(this.constructorAnalyzer);
this.constructorAnalyzer.setVariableTracer(this.variableTracer);
this.constructorAnalyzer.setConstantExtractor(this.constantExtractor);
this.expressionAccessClassifier = new ExpressionAccessClassifier(
context, variableTracer, constantResolver, this::parseExpressionString);
this.constantExtractor.setExpressionAccessClassifier(this.expressionAccessClassifier);
this.accessorResolver = new AccessorResolver(context, constantExtractor, constructorAnalyzer, constantResolver);
this.constantExtractor.setAccessorResolver(this.accessorResolver);
this.fieldInitializerFinder = new FieldInitializerFinder(context);
this.pathBindingEvaluator = new PathBindingEvaluator(context, variableTracer, constantResolver, typeResolver);
}
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
@@ -88,30 +104,48 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
List<String> startMethods = new ArrayList<>();
startMethods.add(startMethod);
boolean foundAny = false;
for (TriggerPoint tp : triggers) {
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<List<String>> allPaths = new ArrayList<>();
for (String sMethod : startMethods) {
allPaths.addAll(pathFinder.findAllPaths(sMethod, targetMethod, callGraph, new HashSet<>()));
}
Set<List<String>> uniquePaths = new LinkedHashSet<>(allPaths);
for (List<String> path : uniquePaths) {
foundAny = true;
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph);
if (resolvedTp != null) {
String contextMachineId = pathFinder.extractContextMachineId(path, callGraph);
chains.add(CallChain.builder()
.entryPoint(ep)
.triggerPoint(resolvedTp)
.methodChain(path)
.contextMachineId(contextMachineId)
.build());
List<Map<String, String>> bindingVariants =
EntryPointBindingExpander.expandPathVariableBindings(ep, context, callGraph);
if (bindingVariants.isEmpty()) {
bindingVariants = List.of(Map.of());
}
for (Map<String, String> initialBindings : bindingVariants) {
EntryPoint chainEntryPoint = initialBindings.isEmpty()
? ep
: EntryPointBindingExpander.withResolvedPath(ep, initialBindings);
boolean foundAny = false;
for (TriggerPoint tp : triggers) {
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<List<String>> allPaths = new ArrayList<>();
for (String sMethod : startMethods) {
allPaths.addAll(pathFinder.findAllPaths(
sMethod, targetMethod, callGraph, new HashSet<>(),
pathBindingEvaluator, initialBindings));
}
Set<List<String>> uniquePaths = new LinkedHashSet<>(allPaths);
for (List<String> path : uniquePaths) {
if (!pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder, initialBindings)) {
continue;
}
foundAny = true;
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph);
if (resolvedTp != null) {
String contextMachineId = pathFinder.extractContextMachineId(path, callGraph);
chains.add(CallChain.builder()
.entryPoint(chainEntryPoint)
.triggerPoint(resolvedTp)
.methodChain(path)
.contextMachineId(contextMachineId)
.build());
}
}
}
}
if (!foundAny && log.isDebugEnabled()) {
log.debug("Entry point {} ({}) did not reach any trigger points. Graph nodes available: {}", ep.getName(), startMethod, callGraph.keySet());
if (!foundAny && log.isDebugEnabled()) {
log.debug("Entry point {} ({}) did not reach any trigger points. Graph nodes available: {}",
chainEntryPoint.getName(), startMethod, callGraph.keySet());
}
}
}
return chains;
@@ -156,7 +190,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String currentParamName = event;
String resolvedValue = event;
String methodSuffix = "";
String[] extractedEntry = extractMethodSuffix(currentParamName, methodSuffix);
String scopeMethod = path.isEmpty() ? null : path.get(0);
String[] extractedEntry = extractMethodSuffix(currentParamName, methodSuffix, scopeMethod);
currentParamName = extractedEntry[0];
methodSuffix = extractedEntry[1];
boolean isAmbiguous = false;
@@ -197,7 +232,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
Map<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i);
String tracedVar = variableTracer.traceLocalVariable(target, currentParamName, parameterValues);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix);
String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix, target);
tracedVar = extractedTraced[0];
methodSuffix = extractedTraced[1];
currentParamName = tracedVar;
@@ -259,7 +294,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
// Intentionally not replacing 'super' because super should remain statically bound to the superclass of where the code is defined.
}
String[] extractedArg = extractMethodSuffix(arg, methodSuffix);
String[] extractedArg = extractMethodSuffix(arg, methodSuffix, caller);
arg = extractedArg[0];
methodSuffix = extractedArg[1];
currentParamName = arg;
@@ -290,46 +325,43 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
String entryMethod = path.get(0);
if (methodSuffix != null && expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) {
List<String> chainedGetterEvents = evaluateGetterOnReceiver(resolvedValue, methodSuffix, entryMethod, path, callGraph);
if (chainedGetterEvents != null && !chainedGetterEvents.isEmpty()) {
log.debug("Early return (chained getter): getterEvents = {}", chainedGetterEvents);
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, chainedGetterEvents);
}
if (expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) {
List<String> setterEvents = resolveSetterEventsFromSuffix(currentParamName, methodSuffix, entryMethod, path);
if (!setterEvents.isEmpty()) {
log.debug("Early return (scoped setter): setterEvents = {}", setterEvents);
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, setterEvents);
}
}
}
int entryParamIndex = typeResolver.getParameterIndex(entryMethod, currentParamName);
if (entryParamIndex < 0) {
if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event") || methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) {
String localMethodName = methodSuffix.substring(1).replace("()", "");
Expression localSetterExpr = variableTracer.traceLocalSetter(entryMethod, currentParamName, localMethodName);
if (localSetterExpr != null) {
List<String> setterEvents = new ArrayList<>();
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
for (Expression tracedSetter : tracedSetters) {
constantExtractor.extractConstantsFromExpression(tracedSetter, setterEvents);
}
if (!setterEvents.isEmpty()) {
log.debug("Early return 1: setterEvents = {}", setterEvents);
return TriggerPoint.builder()
.event(tp.getEvent())
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.sourceModule(tp.getSourceModule())
.stateMachineId(tp.getStateMachineId())
.sourceState(tp.getSourceState())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(setterEvents)
.constraint(tp.getConstraint())
.stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal())
.build();
}
if (methodSuffix != null && expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) {
List<String> setterEvents = resolveSetterEventsFromSuffix(currentParamName, methodSuffix, entryMethod, path);
if (!setterEvents.isEmpty()) {
log.debug("Early return 1: setterEvents = {}", setterEvents);
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, setterEvents);
}
}
boolean hasGetterOnReceiver = (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event") || methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) && currentParamName != null
boolean hasGetterOnReceiver = methodSuffix != null
&& expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)
&& currentParamName != null
&& (currentParamName.contains("new ") || (!currentParamName.replaceFirst("^this\\.", "").contains(".") && !currentParamName.contains("(")));
if (hasGetterOnReceiver) {
List<String> getterEvents = evaluateGetterOnReceiver(resolvedValue, methodSuffix, entryMethod, path, callGraph);
if (getterEvents != null && !getterEvents.isEmpty()) {
log.debug("Early return 2: getterEvents = {}", getterEvents);
return TriggerPoint.builder()
.event(tp.getEvent())
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
@@ -348,7 +380,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix);
String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix, entryMethod);
tracedVar = extractedFinalTraced[0];
methodSuffix = extractedFinalTraced[1];
currentParamName = tracedVar;
@@ -366,7 +398,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (!polymorphicEvents.contains(parsed)) polymorphicEvents.add(parsed);
}
return TriggerPoint.builder()
.event(tp.getEvent())
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
@@ -394,17 +426,62 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (exprNode instanceof MethodInvocation mi) {
methodName = mi.getName().getIdentifier();
if ("get".equals(methodName) || "getOrDefault".equals(methodName)) {
boolean keyedLookup = expressionAccessClassifier.isKeyedLookup(mi, entryMethod);
boolean functionalGet = "get".equals(methodName) && mi.arguments().isEmpty() && !keyedLookup;
if (keyedLookup || functionalGet) {
constantExtractor.extractConstantsFromExpression((Expression) exprNode, polymorphicEvents);
if (!polymorphicEvents.isEmpty()) {
return TriggerPoint.builder()
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.sourceModule(tp.getSourceModule())
.stateMachineId(tp.getStateMachineId())
.sourceState(tp.getSourceState())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(polymorphicEvents)
.constraint(tp.getConstraint())
.stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal())
.ambiguous(isAmbiguous)
.build();
}
}
}
boolean handledStaticEnum = false;
if (methodName.equals("valueOf") && mi.arguments().size() == 1) {
Object arg = mi.arguments().get(0);
String enumTypeName = resolveValueOfEnumTypeName(mi);
if (arg instanceof StringLiteral sl) {
polymorphicEvents.add(sl.getLiteralValue());
if (enumTypeName != null) {
polymorphicEvents.add(enumTypeName + "." + sl.getLiteralValue());
} else {
polymorphicEvents.add(sl.getLiteralValue());
}
methodName = null;
handledStaticEnum = true;
} else if (arg instanceof QualifiedName qn) {
polymorphicEvents.add(qn.getName().getIdentifier());
if (enumTypeName != null) {
polymorphicEvents.add(enumTypeName + "." + qn.getName().getIdentifier());
} else {
polymorphicEvents.add(qn.getName().getIdentifier());
}
methodName = null;
handledStaticEnum = true;
} else if (arg instanceof SimpleName sn) {
String resolvedConstant = resolveEnumConstantFromArgument(sn.getIdentifier(), path, callGraph);
if (resolvedConstant != null) {
if (enumTypeName != null) {
polymorphicEvents.add(enumTypeName + "." + resolvedConstant);
} else {
polymorphicEvents.add(resolvedConstant);
}
methodName = null;
handledStaticEnum = true;
}
}
} else if (methodName.equals("name") && mi.arguments().isEmpty()) {
if (mi.getExpression() instanceof QualifiedName qn) {
@@ -444,7 +521,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
}
return TriggerPoint.builder()
.event(tp.getEvent())
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
@@ -579,7 +656,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (methodName != null) {
if (varName != null && !varName.isEmpty() && Character.isLowerCase(varName.charAt(0)) && !methodName.equals("VariableReference")) {
Expression localSetterExpr = variableTracer.traceLocalSetter(entryMethod, varName, methodName);
String scopeForVar = resolveScopeMethodForVariable(varName, entryMethod, path);
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, varName, methodName);
if (localSetterExpr != null) {
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
for (Expression tracedSetter : tracedSetters) {
@@ -724,10 +802,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (declaredType != null) {
List<String> enumValues = context.getEnumValues(declaredType);
if (enumValues != null && !enumValues.isEmpty()) {
for (String ev : enumValues) {
polymorphicEvents.add(constantExtractor.parseEnumSetElement(ev));
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
&& !tp.isExternal()
&& !isRuntimeEnumParameter(exprNode instanceof Expression expression ? expression : null)) {
for (String ev : enumValues) {
polymorphicEvents.add(constantExtractor.parseEnumSetElement(ev));
}
isAmbiguous = true;
}
isAmbiguous = true;
} else {
List<String> typesToInspect = new ArrayList<>();
if ("inline-instantiation".equals(sourceMethod)) {
@@ -849,6 +931,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
polymorphicEvents = unpacked;
polymorphicEvents = polymorphicEvents.stream().distinct().collect(java.util.stream.Collectors.toList());
Expression runtimeExpr = exprNode instanceof Expression expression ? expression : null;
if (isRuntimeEnumParameter(runtimeExpr) || tp.isExternal()) {
polymorphicEvents.removeIf(pe -> pe != null && pe.startsWith("<SYMBOLIC:"));
}
if (!resolvedValue.equals(event) || !polymorphicEvents.isEmpty()) {
return TriggerPoint.builder()
@@ -1030,20 +1117,202 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return constructorAnalyzer.findReturnedCIC(className, methodName, context);
}
protected record GetterChainReceiver(String typeFqn, ClassInstanceCreation cic) {}
/**
* Resolves a nested getter prefix such as {@code wrapper.getEvent()} to the declared return type
* and, when possible, a {@code new ...()} instance from the getter body.
*/
private TriggerPoint buildTriggerPointWithPolymorphicEvents(TriggerPoint tp, String resolvedValue, List<String> polymorphicEvents) {
return TriggerPoint.builder()
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.sourceModule(tp.getSourceModule())
.stateMachineId(tp.getStateMachineId())
.sourceState(tp.getSourceState())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(polymorphicEvents)
.constraint(tp.getConstraint())
.stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal())
.build();
}
private List<String> resolveSetterEventsFromSuffix(
String currentParamName,
String methodSuffix,
String entryMethod,
List<String> path) {
String localMethodName = methodSuffix.substring(1).replace("()", "");
String scopeForVar = resolveScopeMethodForVariable(currentParamName, entryMethod, path);
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, currentParamName, localMethodName);
if (localSetterExpr == null) {
return List.of();
}
List<String> setterEvents = new ArrayList<>();
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
for (Expression tracedSetter : tracedSetters) {
constantExtractor.extractConstantsFromExpression(tracedSetter, setterEvents);
}
return setterEvents;
}
protected GetterChainReceiver resolveGetterChainToReceiver(Expression expr, String entryMethod) {
if (expr instanceof SimpleName sn) {
String type = variableTracer.getVariableDeclaredType(entryMethod, sn.getIdentifier());
if (type == null) {
return null;
}
Expression init = findVariableInitializer(entryMethod, sn.getIdentifier());
ClassInstanceCreation cic = init instanceof ClassInstanceCreation c ? c : null;
return new GetterChainReceiver(type, cic);
}
if (expr instanceof ThisExpression) {
int lastDot = entryMethod.lastIndexOf('.');
if (lastDot <= 0) {
return null;
}
String className = entryMethod.substring(0, lastDot);
return new GetterChainReceiver(className, null);
}
if (expr instanceof ClassInstanceCreation cic) {
return new GetterChainReceiver(
click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType()),
cic);
}
if (!(expr instanceof MethodInvocation mi)) {
return null;
}
GetterChainReceiver base = resolveGetterChainToReceiver(mi.getExpression(), entryMethod);
if (base == null) {
return null;
}
String getterName = mi.getName().getIdentifier();
String simpleType = simplifyTypeName(base.typeFqn());
String ownerFqn = resolveOwnerFqn(simpleType, base.typeFqn());
AccessorResolver.GetterChainStep indexedStep =
accessorResolver.resolveGetterChainStep(ownerFqn, getterName, ResolutionBudget.defaults());
if (indexedStep != null) {
return new GetterChainReceiver(indexedStep.typeFqn(), indexedStep.cic());
}
ClassInstanceCreation nextCic = constructorAnalyzer.findReturnedCIC(simpleType, getterName, context);
if (nextCic != null) {
return new GetterChainReceiver(
click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(nextCic.getType()),
nextCic);
}
TypeDeclaration td = context.getTypeDeclaration(simpleType);
if (td == null) {
td = context.getTypeDeclaration(base.typeFqn());
}
if (td != null) {
MethodDeclaration getter = context.findMethodDeclaration(td, getterName, true);
if (getter != null && getter.getReturnType2() != null) {
return new GetterChainReceiver(getter.getReturnType2().toString(), null);
}
}
return null;
}
private String resolveOwnerFqn(String simpleType, String typeFqn) {
TypeDeclaration td = context.getTypeDeclaration(simpleType);
if (td == null && typeFqn != null) {
td = context.getTypeDeclaration(typeFqn);
}
return td != null ? context.getFqn(td) : typeFqn;
}
private static String simplifyTypeName(String typeFqn) {
if (typeFqn == null) {
return null;
}
String simple = typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn;
if (simple.contains("<")) {
simple = simple.substring(0, simple.indexOf('<'));
}
return simple;
}
private String resolveScopeMethodForVariable(String varName, String entryMethod, List<String> path) {
if (varName != null && path != null) {
for (String methodFqn : path) {
if (variableTracer.getVariableDeclaredType(methodFqn, varName) != null) {
return methodFqn;
}
}
}
return entryMethod;
}
private String resolveClassForSuperCall(String entryMethod, List<String> path) {
if (path != null) {
for (String methodFqn : path) {
if (methodFqn == null) {
continue;
}
int lastDot = methodFqn.lastIndexOf('.');
if (lastDot <= 0) {
continue;
}
String className = methodFqn.substring(0, lastDot);
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration == null) {
continue;
}
String superFqn = context.getSuperclassFqn(typeDeclaration);
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
return className;
}
}
}
int lastDot = entryMethod.lastIndexOf('.');
return lastDot > 0 ? entryMethod.substring(0, lastDot) : entryMethod;
}
protected List<String> evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List<String> path, Map<String, List<CallEdge>> callGraph) {
ASTNode exprNode = parseExpressionString(resolvedValue);
while (exprNode instanceof ParenthesizedExpression pe) {
exprNode = pe.getExpression();
}
String scopeMethod = entryMethod;
Expression rootExpr = exprNode instanceof Expression e ? e : null;
if (rootExpr != null) {
while (rootExpr instanceof MethodInvocation mi) {
rootExpr = mi.getExpression();
}
if (rootExpr instanceof SimpleName sn) {
scopeMethod = resolveScopeMethodForVariable(sn.getIdentifier(), entryMethod, path);
}
}
if (exprNode instanceof SuperMethodInvocation smi) {
String getterName = smi.getName().getIdentifier();
int lastDot = entryMethod.lastIndexOf('.');
String className = lastDot > 0 ? entryMethod.substring(0, lastDot) : entryMethod;
String className = resolveClassForSuperCall(entryMethod, path);
TypeDeclaration currentTd = context.getTypeDeclaration(className);
if (currentTd != null) {
String receiverType = context.getSuperclassFqn(currentTd);
return constantExtractor.resolveMethodReturnConstant(receiverType, getterName, 0, new HashSet<>(), null);
if (receiverType != null) {
CompilationUnit contextCu = currentTd.getRoot() instanceof CompilationUnit cu ? cu : null;
List<String> resolved = accessorResolver.resolve(
receiverType,
getterName,
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration(receiverType), null, contextCu, false, null),
ResolutionBudget.defaults());
if (!resolved.isEmpty()) {
return resolved;
}
return constantExtractor.resolveMethodReturnConstant(receiverType, getterName, 0, new HashSet<>(), contextCu);
}
}
} else if (exprNode instanceof MethodInvocation mi) {
String getterName = mi.getName().getIdentifier();
@@ -1055,25 +1324,31 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (receiver instanceof SimpleName sn) {
receiverName = sn.getIdentifier();
receiverType = variableTracer.getVariableDeclaredType(entryMethod, receiverName);
receiverType = variableTracer.getVariableDeclaredType(scopeMethod, receiverName);
} else if (receiver instanceof ClassInstanceCreation cic) {
directCic = cic;
receiverType = cic.getType().toString();
receiverType = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
} else if (receiver instanceof MethodInvocation receiverMi) {
Expression currentMi = receiverMi;
while (currentMi instanceof MethodInvocation chainMi) {
String mName = chainMi.getName().getIdentifier();
if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) {
if (!chainMi.arguments().isEmpty()) {
Expression arg = (Expression) chainMi.arguments().get(0);
List<String> builderConstants = new ArrayList<>();
constantExtractor.extractConstantsFromExpression(arg, builderConstants);
if (!builderConstants.isEmpty()) {
return builderConstants;
GetterChainReceiver chainedReceiver = resolveGetterChainToReceiver(receiverMi, scopeMethod);
if (chainedReceiver != null) {
receiverType = chainedReceiver.typeFqn();
directCic = chainedReceiver.cic();
} else {
Expression currentMi = receiverMi;
while (currentMi instanceof MethodInvocation chainMi) {
String mName = chainMi.getName().getIdentifier();
if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) {
if (!chainMi.arguments().isEmpty()) {
Expression arg = (Expression) chainMi.arguments().get(0);
List<String> builderConstants = new ArrayList<>();
constantExtractor.extractConstantsFromExpression(arg, builderConstants);
if (!builderConstants.isEmpty()) {
return builderConstants;
}
}
}
currentMi = chainMi.getExpression();
}
currentMi = chainMi.getExpression();
}
} else if (receiver instanceof SuperMethodInvocation) {
int lastDot = entryMethod.lastIndexOf('.');
@@ -1082,9 +1357,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (currentTd != null) {
receiverType = context.getSuperclassFqn(currentTd);
}
} else if (receiver instanceof FieldAccess fa) {
receiverName = fa.getName().getIdentifier();
if (fa.resolveFieldBinding() != null && fa.resolveFieldBinding().getType() != null) {
receiverType = fa.resolveFieldBinding().getType().getErasure().getQualifiedName();
}
}
if (receiverType == null && path.size() > 1) {
if (receiverType == null && receiverName != null && path.size() > 1) {
String callerMethod = path.get(1);
receiverType = variableTracer.getVariableDeclaredType(callerMethod, receiverName);
}
@@ -1123,7 +1403,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (directCic != null) {
cic = directCic;
} else {
Expression initExpr = findVariableInitializer(entryMethod, varName);
Expression initExpr = findVariableInitializer(scopeMethod, varName);
if (initExpr instanceof MethodInvocation initMi) {
initExpr = unwrapMethodInvocation(initMi, 0, entryMethod);
}
@@ -1131,46 +1411,55 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
cic = (ClassInstanceCreation) initExpr;
}
}
if (cic == null) {
return null;
}
MethodDeclaration getter = null;
if (cic.getAnonymousClassDeclaration() != null) {
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
if (declObj instanceof MethodDeclaration md && md.getName().getIdentifier().equals(getterName)) {
getter = md;
break;
}
boolean anonymousGetter = false;
if (cic == null) {
String ownerFqn = context.getFqn(td);
List<String> resolvedWithoutCic = accessorResolver.resolve(
ownerFqn,
getterName,
new AccessorResolver.ReceiverContext(td, null, contextCu, false, fieldValues),
ResolutionBudget.defaults());
if (!resolvedWithoutCic.isEmpty()) {
return resolvedWithoutCic;
}
}
if (getter == null) {
getter = context.findMethodDeclaration(td, getterName, true);
}
if (getter != null && getter.getBody() != null) {
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor));
int lastDotEntry = entryMethod.lastIndexOf('.');
if (lastDotEntry > 0) {
String className = entryMethod.substring(0, lastDotEntry);
String methodName = entryMethod.substring(lastDot + 1);
TypeDeclaration entryTd = context.getTypeDeclaration(className);
if (entryTd != null) {
MethodDeclaration entryMd = context.findMethodDeclaration(entryTd, methodName, false);
if (entryMd != null && entryMd.getBody() != null) {
// Skip trackSetterCallsBetween
} else {
if (cic.getAnonymousClassDeclaration() != null) {
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
if (declObj instanceof MethodDeclaration md && md.getName().getIdentifier().equals(getterName)) {
getter = md;
anonymousGetter = true;
break;
}
}
}
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>());
if (result != null) {
String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null;
if (returnType != null && !result.contains(".")) {
int lastDotRet = returnType.lastIndexOf('.');
String simpleReturnType = lastDotRet > 0 ? returnType.substring(lastDotRet + 1) : returnType;
if (!simpleReturnType.equals("String") && !simpleReturnType.equals("Integer") && !simpleReturnType.equals("Long") && !simpleReturnType.equals("Boolean")) {
result = simpleReturnType + "." + result;
if (getter == null) {
getter = context.findMethodDeclaration(td, getterName, true);
}
if (getter != null && getter.getBody() != null) {
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(
td, cic, context, this::evaluateMethodCallInConstructor));
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>());
if (result != null) {
String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null;
if (returnType != null && !result.contains(".")) {
int lastDotRet = returnType.lastIndexOf('.');
String simpleReturnType = lastDotRet > 0 ? returnType.substring(lastDotRet + 1) : returnType;
if (!simpleReturnType.equals("String") && !simpleReturnType.equals("Integer")
&& !simpleReturnType.equals("Long") && !simpleReturnType.equals("Boolean")) {
result = simpleReturnType + "." + result;
}
}
return Collections.singletonList(result);
}
return Collections.singletonList(result);
}
List<String> resolvedWithCic = accessorResolver.resolve(
context.getFqn(td),
getterName,
new AccessorResolver.ReceiverContext(td, cic, contextCu, anonymousGetter, fieldValues),
ResolutionBudget.defaults());
if (!resolvedWithCic.isEmpty()) {
return resolvedWithCic;
}
}
}
@@ -1208,7 +1497,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return initExpr[0];
}
/**
* Splits a traced argument into a receiver base and a trailing accessor suffix for getter-chain
* resolution, e.g. {@code wrapper.getEvent()} → {@code ["wrapper", ".getEvent()"]}.
* <p>
* Map/collection lookups ({@code ROUTES.get(key)}) stay intact and are resolved by
* {@link ConstantExtractor}; see {@link ExpressionAccessClassifier} for the type-based distinction
* from {@code Supplier.get()} and JavaBean accessors.
*/
protected String[] extractMethodSuffix(String paramName, String currentSuffix) {
return extractMethodSuffix(paramName, currentSuffix, null);
}
protected String[] extractMethodSuffix(String paramName, String currentSuffix, String scopeMethod) {
if (expressionAccessClassifier.isKeyedLookup(paramName, scopeMethod)) {
return new String[] { paramName, currentSuffix };
}
if (paramName != null && !paramName.contains(" ") && !paramName.startsWith("new ")) {
int bracketIndex = paramName.indexOf('[');
int dotIndex = -1;
@@ -1243,90 +1547,21 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return new String[] { paramName, currentSuffix };
}
private int getReturnedParameterIndex(MethodDeclaration md) {
if (md.getBody() == null) return -1;
List<?> parameters = md.parameters();
if (parameters.isEmpty()) return -1;
final List<String> returnedExprs = new ArrayList<>();
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
returnedExprs.add(node.getExpression().toString());
}
return super.visit(node);
}
});
if (returnedExprs.size() == 1) {
String retStr = returnedExprs.get(0);
for (int i = 0; i < parameters.size(); i++) {
if (parameters.get(i) instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(retStr)) {
return i;
}
}
}
}
return -1;
}
protected Expression unwrapMethodInvocation(MethodInvocation mi, int depth) {
return unwrapMethodInvocation(mi, depth, null);
}
protected Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) {
if (depth > 5) return mi;
if (mi.getExpression() != null) {
String exprStr = mi.getExpression().toString();
if (!exprStr.equals("Optional") && !exprStr.equals("Stream") && !exprStr.equals("List") && !exprStr.equals("Set") && !exprStr.equals("Arrays") && !exprStr.equals("Objects") && !exprStr.equals("Mono") && !exprStr.equals("Flux")) {
return mi;
return MethodInvocationUnwrapper.unwrap(mi, depth, methodFqn, context, miArg -> {
if (methodFqn == null || !methodFqn.contains(".")) {
return null;
}
} else {
if (methodFqn != null && methodFqn.contains(".")) {
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration localMd = context.findMethodDeclaration(td, mi.getName().getIdentifier(), true);
if (localMd != null) {
int paramIdx = getReturnedParameterIndex(localMd);
if (paramIdx >= 0 && paramIdx < mi.arguments().size()) {
Expression arg = (Expression) mi.arguments().get(paramIdx);
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
}
return arg;
}
}
}
String currentClass = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
if (miArg.getExpression() == null || miArg.getExpression().toString().equals("this")) {
return currentClass + "." + miArg.getName().getIdentifier();
}
return mi;
}
if (!mi.arguments().isEmpty()) {
String mName = mi.getName().getIdentifier();
String lowerName = mName.toLowerCase();
if (lowerName.startsWith("map") || lowerName.startsWith("parse") ||
lowerName.startsWith("convert") || lowerName.startsWith("to") ||
lowerName.startsWith("resolve") || lowerName.startsWith("build") ||
lowerName.startsWith("create") || lowerName.startsWith("from") ||
lowerName.startsWith("transform") || lowerName.startsWith("translate") ||
lowerName.startsWith("derive") || lowerName.startsWith("determine") ||
lowerName.startsWith("calculate") || lowerName.startsWith("decode") ||
lowerName.startsWith("extract") || lowerName.startsWith("get") ||
lowerName.startsWith("find")) {
return mi;
}
Expression arg = (Expression) mi.arguments().get(0);
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
}
return arg;
}
return mi;
return null;
});
}
public List<String> resolveClassConstantReturns(String className, CodebaseContext context, CompilationUnit contextCu) {
@@ -1591,12 +1826,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return allResolved;
}
/**
* Peels enum factory wrappers while tracing call-path arguments, e.g.
* {@code OrderEvent.valueOf(action)} → {@code action} so the next hop can resolve the parameter.
* <p>
* Map/collection lookups ({@code ROUTES.get(key)}) are intentionally <em>not</em> peeled — they are
* routing tables, not enum converters. See {@link ExpressionAccessClassifier}.
*/
private String unwrapConverterMethods(String arg) {
if (arg == null) return null;
String current = arg;
while (current.contains("(") && !current.startsWith("new ")) {
org.eclipse.jdt.core.dom.ASTNode argNode = parseExpressionString(current);
if (argNode instanceof org.eclipse.jdt.core.dom.MethodInvocation miArg && !miArg.arguments().isEmpty()) {
if (expressionAccessClassifier.isKeyedLookup(miArg, null)) {
break;
}
boolean shouldUnpack = false;
org.eclipse.jdt.core.dom.Expression recv = miArg.getExpression();
if (recv == null || recv instanceof org.eclipse.jdt.core.dom.ThisExpression) {
@@ -1619,4 +1864,118 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
return current;
}
private String resolveValueOfEnumTypeName(MethodInvocation mi) {
if (mi.getExpression() instanceof SimpleName sn) {
return sn.getIdentifier();
}
if (mi.getExpression() instanceof QualifiedName qn) {
String full = qn.getFullyQualifiedName();
return full.contains(".") ? full.substring(full.lastIndexOf('.') + 1) : full;
}
return null;
}
private String resolveEnumConstantFromArgument(String argName, List<String> path, Map<String, List<CallEdge>> callGraph) {
if (argName == null || path == null || path.size() < 2) {
return null;
}
for (int i = path.size() - 1; i > 0; i--) {
String target = path.get(i);
String caller = path.get(i - 1);
Map<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i);
String traced = variableTracer.traceLocalVariable(target, argName, parameterValues);
if (traced != null && !traced.equals(argName)) {
String normalized = normalizeEnumConstantName(traced);
if (normalized != null) {
return normalized;
}
}
int paramIndex = typeResolver.getParameterIndex(target, argName);
if (paramIndex >= 0) {
List<CallEdge> edges = callGraph.get(caller);
if (edges != null) {
for (CallEdge edge : edges) {
if (edge.getTargetMethod().equals(target) || pathFinder.isHeuristicMatch(edge.getTargetMethod(), target)) {
if (paramIndex < edge.getArguments().size()) {
String normalized = normalizeEnumConstantName(edge.getArguments().get(paramIndex));
if (normalized != null) {
return normalized;
}
}
}
}
}
}
}
return null;
}
private String normalizeEnumConstantName(String value) {
if (value == null || value.isBlank()) {
return null;
}
String trimmed = value.trim();
if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
trimmed = trimmed.substring(1, trimmed.length() - 1);
}
if (trimmed.contains("(") || trimmed.contains(" ")) {
return null;
}
if (trimmed.contains(".")) {
trimmed = trimmed.substring(trimmed.lastIndexOf('.') + 1);
}
if (!trimmed.matches("[A-Z_][A-Z0-9_]*")) {
return null;
}
return trimmed;
}
private boolean hasConcreteEnumConstants(List<String> polymorphicEvents, String declaredType, CodebaseContext context) {
if (polymorphicEvents == null || polymorphicEvents.isEmpty() || declaredType == null) {
return false;
}
String simpleDeclared = declaredType.contains(".")
? declaredType.substring(declaredType.lastIndexOf('.') + 1)
: declaredType;
List<String> enumValues = context.getEnumValues(declaredType);
for (String pe : polymorphicEvents) {
if (pe == null || pe.startsWith("<SYMBOLIC:")) {
continue;
}
if (pe.contains(".")) {
String typePart = pe.substring(0, pe.lastIndexOf('.'));
String simpleType = typePart.contains(".")
? typePart.substring(typePart.lastIndexOf('.') + 1)
: typePart;
if (simpleType.equals(simpleDeclared)) {
return true;
}
if (enumValues != null && enumValues.contains(pe)) {
return true;
}
} else if (enumValues != null) {
for (String ev : enumValues) {
if (ev.endsWith("." + pe)) {
return true;
}
}
}
}
return false;
}
private boolean isRuntimeEnumParameter(Expression exprNode) {
if (!(exprNode instanceof MethodInvocation mi)) {
return false;
}
if (!"valueOf".equals(mi.getName().getIdentifier()) || mi.arguments().size() != 1) {
return false;
}
return mi.arguments().get(0) instanceof SimpleName;
}
}

View File

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

View File

@@ -56,6 +56,21 @@ public class CallGraphPathFinder {
return findAllPaths(start, target, graph, visited, new boolean[]{false});
}
public List<List<String>> findAllPaths(
String start,
String target,
Map<String, List<CallEdge>> graph,
Set<String> visited,
PathBindingEvaluator bindingEvaluator,
Map<String, String> initialBindings) {
if (bindingEvaluator == null) {
return findAllPaths(start, target, graph, visited);
}
Map<String, String> bindings = initialBindings == null ? new HashMap<>() : new HashMap<>(initialBindings);
return findAllPathsConstrained(
start, target, graph, visited, new boolean[]{false}, bindingEvaluator, bindings);
}
public List<List<String>> findAllPaths(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited, boolean[] parentHitCycle) {
List<List<String>> result = new ArrayList<>();
java.util.Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(start, target);
@@ -175,6 +190,121 @@ public class CallGraphPathFinder {
return result;
}
private List<List<String>> findAllPathsConstrained(
String start,
String target,
Map<String, List<CallEdge>> graph,
Set<String> visited,
boolean[] parentHitCycle,
PathBindingEvaluator bindingEvaluator,
Map<String, String> bindings) {
List<List<String>> result = new ArrayList<>();
if (start.equals(target)) {
result.add(new ArrayList<>(List.of(start)));
return result;
}
if (!visited.add(start)) {
parentHitCycle[0] = true;
return result;
}
List<CallEdge> neighbors = graph.get(start);
if (neighbors != null) {
for (CallEdge edge : neighbors) {
String neighbor = edge.getTargetMethod();
if (neighbor.startsWith("java.") || neighbor.startsWith("javax.") || neighbor.startsWith("jakarta.") || neighbor.startsWith("org.springframework.")) {
continue;
}
String resolvedTarget = neighbor.equals(target) || isHeuristicMatch(neighbor, target) ? target : neighbor;
List<String> hopPath = new ArrayList<>(List.of(start, resolvedTarget));
Map<String, String> branchBindings = new HashMap<>(bindings);
if (!bindingEvaluator.tryTraverseEdge(
start, resolvedTarget, edge, hopPath, 1, graph, this, branchBindings)) {
continue;
}
if (resolvedTarget.equals(target)) {
result.add(hopPath);
} else {
List<List<String>> subPaths = findAllPathsConstrained(
neighbor, target, graph, visited, parentHitCycle, bindingEvaluator, branchBindings);
for (List<String> subPath : subPaths) {
if (subPath.isEmpty() || subPath.get(0).equals(start)) {
continue;
}
List<String> path = new ArrayList<>();
path.add(start);
path.addAll(subPath);
result.add(path);
}
}
}
}
if (result.isEmpty() && start.contains(".")) {
result.addAll(findInheritanceFallbackPaths(
start, target, graph, visited, parentHitCycle, bindingEvaluator, bindings));
}
visited.remove(start);
return result;
}
private List<List<String>> findInheritanceFallbackPaths(
String start,
String target,
Map<String, List<CallEdge>> graph,
Set<String> visited,
boolean[] parentHitCycle,
PathBindingEvaluator bindingEvaluator,
Map<String, String> bindings) {
List<List<String>> result = new ArrayList<>();
String className = start.substring(0, start.lastIndexOf('.'));
String methodName = start.substring(start.lastIndexOf('.') + 1);
if (className.contains("<")) {
className = className.substring(0, className.indexOf('<'));
}
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
String superclass = context.getSuperclassFqn(td);
if (superclass != null) {
String superMethod = superclass + "." + methodName;
if (graph.containsKey(superMethod)) {
List<List<String>> superPaths = findAllPathsConstrained(
superMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings);
for (List<String> superPath : superPaths) {
List<String> path = new ArrayList<>();
path.add(start);
path.addAll(superPath);
result.add(path);
}
}
}
}
if (result.isEmpty()) {
List<String> impls = context.getImplementations(className);
if (impls != null) {
for (String impl : impls) {
String implMethod = impl + "." + methodName;
if (graph.containsKey(implMethod)) {
List<List<String>> implPaths = findAllPathsConstrained(
implMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings);
for (List<String> implPath : implPaths) {
List<String> path = new ArrayList<>();
path.add(start);
path.addAll(implPath);
result.add(path);
}
}
}
}
}
return result;
}
public String extractContextMachineId(List<String> path, Map<String, List<CallEdge>> callGraph) {
for (String node : path) {
List<CallEdge> edges = callGraph.get(node);

View File

@@ -1,5 +1,10 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
@@ -14,6 +19,8 @@ public class ConstantExtractor {
private final Function<MethodInvocation, String> calledMethodResolver;
private VariableTracer variableTracer;
private ConstructorAnalyzer constructorAnalyzer;
private ExpressionAccessClassifier expressionAccessClassifier;
private AccessorResolver accessorResolver;
public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function<MethodInvocation, String> calledMethodResolver) {
this.context = context;
@@ -29,25 +36,24 @@ public class ConstantExtractor {
this.constructorAnalyzer = constructorAnalyzer;
}
public void setExpressionAccessClassifier(ExpressionAccessClassifier expressionAccessClassifier) {
this.expressionAccessClassifier = expressionAccessClassifier;
}
public void setAccessorResolver(AccessorResolver accessorResolver) {
this.accessorResolver = accessorResolver;
}
public void extractConstantsFromExpression(Expression expr, List<String> constants) {
if (constantResolver != null) {
String resolved = constantResolver.resolve(expr, context);
if (resolved != null) {
if (resolved.startsWith("ENUM_SET:")) {
for (String eVal : resolved.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal);
if (!constants.contains(parsed)) constants.add(parsed);
}
} else {
constants.add(resolved);
}
addResolvedConstant(resolved, constants);
return;
}
}
if (expr instanceof QualifiedName qn) {
constants.add(qn.toString());
} else if (expr instanceof StringLiteral sl) {
if (expr instanceof StringLiteral sl) {
constants.add(sl.getLiteralValue());
} else if (expr instanceof ConditionalExpression ce) {
extractConstantsFromExpression(ce.getThenExpression(), constants);
@@ -90,21 +96,17 @@ public class ConstantExtractor {
String methodName = mi.getName().getIdentifier();
log.trace("Processing mi: {}", mi);
if (methodName.equals("get") || methodName.equals("getOrDefault")) {
if (mi.getExpression() != null) {
if (variableTracer != null) {
List<Expression> traced = variableTracer.traceVariableAll(mi.getExpression());
for (Expression t : traced) {
extractConstantsFromExpression(t, constants);
}
} else {
extractConstantsFromExpression(mi.getExpression(), constants);
}
if (("get".equals(methodName) || "getOrDefault".equals(methodName)) && mi.getExpression() != null) {
if (isKeyedLookupMethod(mi)) {
extractMapReceiverConstants(mi.getExpression(), constants);
return;
}
if ("get".equals(methodName) && mi.arguments().isEmpty()) {
extractMapReceiverConstants(mi.getExpression(), constants);
return;
}
return;
}
if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") ||
methodName.equals("just") || methodName.equals("withPayload") || methodName.equals("success")) {
if (LibraryUnwrapRegistry.isUnwrapMethodName(methodName)) {
for (Object argObj : mi.arguments()) {
extractConstantsFromExpression((Expression) argObj, constants);
}
@@ -215,9 +217,9 @@ public class ConstantExtractor {
Expression currentMi = mi;
while (currentMi instanceof MethodInvocation chainMi) {
String chainName = chainMi.getName().getIdentifier();
if (chainName.equals("setHeader") && chainMi.arguments().size() == 2) {
if (LibraryUnwrapRegistry.isHeaderSetterMethodName(chainName) && chainMi.arguments().size() == 2) {
extractConstantsFromExpression((Expression) chainMi.arguments().get(1), constants);
} else if ((chainName.equalsIgnoreCase("event") || chainName.equalsIgnoreCase("type") || chainName.equalsIgnoreCase("eventType")) && chainMi.arguments().size() == 1) {
} else if (LibraryUnwrapRegistry.isEventSetterMethodName(chainName) && chainMi.arguments().size() == 1) {
extractConstantsFromExpression((Expression) chainMi.arguments().get(0), constants);
}
currentMi = chainMi.getExpression();
@@ -282,9 +284,23 @@ public class ConstantExtractor {
}
public List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) {
return resolveMethodReturnConstant(className, methodName, depth, visited, contextCu, ResolutionBudget.defaults());
}
public List<String> resolveMethodReturnConstant(
String className,
String methodName,
int depth,
Set<String> visited,
CompilationUnit contextCu,
ResolutionBudget budget) {
log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName);
log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth);
if (depth > 20) {
if (budget == null) {
budget = ResolutionBudget.defaults();
}
final ResolutionBudget activeBudget = budget;
if (activeBudget.isMethodReturnExhausted(depth)) {
log.debug("Exiting resolveMethodReturnConstant early (depth limit) for {}.{}", className, methodName);
return Collections.emptyList();
}
@@ -294,12 +310,34 @@ public class ConstantExtractor {
return Collections.emptyList();
}
List<String> constants = new ArrayList<>();
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(className, methodName, activeBudget);
TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null;
if (tempTd == null) {
tempTd = context.getTypeDeclaration(className);
}
final TypeDeclaration td = tempTd;
boolean widenToImplementations = td == null
|| td.isInterface()
|| Modifier.isAbstract(td.getModifiers());
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter() && !widenToImplementations) {
if (indexedAccessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER) {
List<String> constantGetterValues = extractConstantGetterReturn(
indexedAccessor.get().ownerFqn(), methodName, contextCu, visited);
if (!constantGetterValues.isEmpty()) {
visited.remove(fqn);
return constantGetterValues;
}
}
List<String> indexedConstants = resolveIndexedGetterFieldConstants(
indexedAccessor.get(), contextCu, visited);
if (!indexedConstants.isEmpty()) {
visited.remove(fqn);
return indexedConstants;
}
}
List<String> constants = new ArrayList<>();
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
@@ -322,7 +360,8 @@ public class ConstantExtractor {
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
List<String> delegationResult = resolveMethodReturnConstant(
cName, mName, depth + 1, visited, contextCu, activeBudget);
constants.addAll(delegationResult);
handled = true;
}
@@ -341,7 +380,8 @@ public class ConstantExtractor {
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
List<String> delegationResult = resolveMethodReturnConstant(
cName, mName, depth + 1, visited, contextCu, activeBudget);
constants.addAll(delegationResult);
handled = true;
}
@@ -382,11 +422,13 @@ public class ConstantExtractor {
return super.visit(node);
}
});
} else {
}
if (constants.isEmpty() && widenToImplementations) {
List<String> impls = context.getImplementations(className);
if (impls != null) {
for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant(implName, methodName, depth + 1, visited, contextCu);
List<String> delegationResult = resolveMethodReturnConstant(
implName, methodName, depth + 1, visited, contextCu, activeBudget);
constants.addAll(delegationResult);
}
}
@@ -398,10 +440,170 @@ public class ConstantExtractor {
return constants;
}
public List<String> resolveIndexedGetterFieldConstants(
AccessorSummary accessor,
CompilationUnit contextCu,
Set<String> visited) {
return AccessorFieldResolver.resolveFieldConstants(
accessor,
context,
constructorAnalyzer,
contextCu,
visited,
this::extractConstantsFromExpression);
}
public Optional<AccessorSummary> lookupIndexedGetter(String className, String methodName) {
return lookupIndexedGetter(className, methodName, ResolutionBudget.defaults());
}
private Optional<AccessorSummary> lookupIndexedGetter(String className, String methodName, ResolutionBudget budget) {
if (accessorResolver != null) {
return accessorResolver.lookupIndexedGetter(className, methodName);
}
if (className == null || className.isBlank()) {
return Optional.empty();
}
Optional<AccessorSummary> direct = context.getAccessorIndex().lookup(className, methodName);
if (direct.isPresent()) {
return direct;
}
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration != null) {
direct = context.getAccessorIndex().lookup(context.getFqn(typeDeclaration), methodName);
if (direct.isPresent()) {
return direct;
}
if (!typeDeclaration.isInterface() && !Modifier.isAbstract(typeDeclaration.getModifiers())) {
return Optional.empty();
}
}
List<String> implementations = context.getImplementations(className);
if (implementations == null) {
return Optional.empty();
}
for (String implementation : implementations) {
Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName);
if (implAccessor.isPresent()) {
return implAccessor;
}
}
return Optional.empty();
}
private List<String> extractConstantGetterReturn(
String ownerFqn,
String methodName,
CompilationUnit contextCu,
Set<String> visited) {
TypeDeclaration typeDeclaration = contextCu != null
? context.getTypeDeclaration(ownerFqn, contextCu)
: null;
if (typeDeclaration == null) {
typeDeclaration = context.getTypeDeclaration(ownerFqn);
}
if (typeDeclaration == null) {
return List.of();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
extractConstantsFromExpression(node.getExpression(), constants);
}
return false;
}
});
return constants;
}
public String parseEnumSetElement(String eVal) {
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
}
private boolean isKeyedLookupMethod(MethodInvocation mi) {
if (expressionAccessClassifier != null) {
return expressionAccessClassifier.isKeyedLookup(mi, null);
}
return "getOrDefault".equals(mi.getName().getIdentifier())
|| ("get".equals(mi.getName().getIdentifier()) && !mi.arguments().isEmpty());
}
/**
* Resolves constants from the map/collection receiver of {@code .get(key)}.
* Uses {@link ConstantResolver} for static fields ({@code ROUTES = Map.of(...)}) first;
* falls back to dataflow tracing for locals ({@code Map map = ...; map.get(k)}).
*/
private void extractMapReceiverConstants(Expression receiver, List<String> constants) {
if (tryAddResolvedExpression(receiver, constants)) {
return;
}
if (receiver instanceof MethodInvocation || receiver instanceof ArrayCreation || receiver instanceof ArrayInitializer) {
extractConstantsFromExpression(receiver, constants);
if (!constants.isEmpty()) {
return;
}
}
if (variableTracer == null) {
return;
}
String receiverText = receiver.toString();
for (Expression traced : variableTracer.traceVariableAll(receiver)) {
if (traced == null || receiverText.equals(traced.toString())) {
continue;
}
if (tryAddResolvedExpression(traced, constants)) {
continue;
}
extractConstantsFromExpression(traced, constants);
}
}
private boolean tryAddResolvedExpression(Expression expr, List<String> constants) {
if (constantResolver == null) {
return false;
}
String resolved = constantResolver.resolve(expr, context);
if (resolved == null) {
return false;
}
addResolvedConstant(resolved, constants);
return true;
}
private void addResolvedConstant(String resolved, List<String> constants) {
if (resolved.startsWith("ENUM_SET:")) {
for (String eVal : resolved.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal);
if (!constants.contains(parsed)) {
constants.add(parsed);
}
}
} else if (resolved.startsWith("MAP:")) {
List<String> mapValues = ConstantResolver.decodeMapLiteralValues(resolved);
if (mapValues != null) {
for (String mapValue : mapValues) {
if (!constants.contains(mapValue)) {
constants.add(mapValue);
}
}
}
} else if (resolved.startsWith("ARRAY:")) {
for (String arrayValue : resolved.substring(6).split("\\|", -1)) {
if (!constants.contains(arrayValue)) {
constants.add(arrayValue);
}
}
} else if (!constants.contains(resolved)) {
constants.add(resolved);
}
}
private Block findEnclosingBlock(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof Block)) {

View File

@@ -1,6 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@@ -33,6 +34,15 @@ public class ConstructorAnalyzer {
}
public List<String> traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set<String> visited) {
return traceFieldInConstructors(td, fieldName, context, visited, ResolutionBudget.defaults());
}
public List<String> traceFieldInConstructors(
TypeDeclaration td,
String fieldName,
CodebaseContext context,
Set<String> visited,
ResolutionBudget budget) {
if (td == null || fieldName == null) return Collections.emptyList();
String fqn = context.getFqn(td);
if (fqn == null) return Collections.emptyList();
@@ -62,7 +72,8 @@ public class ConstructorAnalyzer {
targetClass = context.getFqn(targetTd);
}
}
results.addAll(constantExtractor.resolveMethodReturnConstant(targetClass, calledName, 0, visited, cu));
results.addAll(constantExtractor.resolveMethodReturnConstant(
targetClass, calledName, 0, visited, cu, budget));
} else {
String val = constantResolver.resolve(tracedRight, context);
if (val != null) results.add(val);
@@ -106,7 +117,8 @@ public class ConstructorAnalyzer {
}
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
results.addAll(constantExtractor.resolveMethodReturnConstant(targetClass, calledName, 0, visited, cu));
results.addAll(constantExtractor.resolveMethodReturnConstant(
targetClass, calledName, 0, visited, cu, budget));
} else {
String val = constantResolver.resolve(tracedRight, context);
if (val != null) results.add(val);
@@ -154,35 +166,37 @@ public class ConstructorAnalyzer {
if (superFqn != null && !superFqn.equals("java.lang.Object")) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
results.addAll(traceFieldInConstructors(superTd, fieldName, context, visited));
results.addAll(traceFieldInConstructors(superTd, fieldName, context, visited, budget.child()));
}
}
}
if (results.isEmpty()) {
// Context-Aware Fallback (Down): Check subclasses/implementations
List<String> impls = context.getImplementations(fqn);
if (impls != null) {
for (String implFqn : impls) {
TypeDeclaration implTd = context.getTypeDeclaration(implFqn);
if (implTd != null) {
results.addAll(traceFieldInConstructors(implTd, fieldName, context, visited));
// Context-Aware Fallback (Down): widen only for interface/abstract types
if (shouldWidenToImplementations(td)) {
List<String> impls = context.getImplementations(fqn);
if (impls != null) {
for (String implFqn : impls) {
TypeDeclaration implTd = context.getTypeDeclaration(implFqn);
if (implTd != null) {
results.addAll(traceFieldInConstructors(implTd, fieldName, context, visited, budget.child()));
}
}
}
}
}
if (results.isEmpty()) {
// Global Fallback: Search all parsed classes in the workspace
if (results.isEmpty() && budget != null && budget.allowGlobalFieldScan()) {
// Global Fallback: Search all parsed classes in the workspace (explicit opt-in only)
Collection<TypeDeclaration> allTypes = context.getTypeDeclarations();
if (allTypes != null) {
int matchedCount = 0;
for (TypeDeclaration typeDecl : allTypes) {
if (typeDecl == null) continue;
if (context.hasField(typeDecl, fieldName)) {
results.addAll(traceFieldInConstructors(typeDecl, fieldName, context, visited));
results.addAll(traceFieldInConstructors(typeDecl, fieldName, context, visited, budget.child()));
matchedCount++;
if (matchedCount >= 10) {
if (matchedCount >= budget.globalFieldScanLimit()) {
break;
}
}
@@ -194,6 +208,13 @@ public class ConstructorAnalyzer {
return results;
}
private static boolean shouldWidenToImplementations(TypeDeclaration td) {
if (td == null) {
return true;
}
return td.isInterface() || Modifier.isAbstract(td.getModifiers());
}
public Map<String, String> buildFieldValuesFromCIC(
TypeDeclaration td,
ClassInstanceCreation cic,
@@ -465,7 +486,7 @@ public class ConstructorAnalyzer {
private ClassInstanceCreation findReturnedCIC(
String className, String methodName, CodebaseContext context,
Set<String> visited, int depth) {
if (depth > 50 || !visited.add(className + "#" + methodName)) {
if (ResolutionBudget.defaults().isReturnedCicExhausted(depth) || !visited.add(className + "#" + methodName)) {
return null;
}

View File

@@ -2,172 +2,21 @@ package click.kamil.springstatemachineexporter.analysis.service;
import lombok.extern.slf4j.Slf4j;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* Placeholder kept for API stability. The exporter never executes Maven or Gradle against the
* analyzed project. Multi-module layout is discovered statically via
* {@link click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver}
* (parsed {@code settings.gradle}, {@code pom.xml}, {@code build.gradle}).
*/
@Slf4j
public class DynamicClasspathResolver {
public List<String> resolveClasspath(Path projectRoot) {
log.info("Attempting dynamic classpath resolution for project at {}", projectRoot);
if (projectRoot == null || !Files.exists(projectRoot)) {
return Collections.emptyList();
}
if (Files.exists(projectRoot.resolve("pom.xml"))) {
return resolveMaven(projectRoot);
} else if (Files.exists(projectRoot.resolve("build.gradle")) || Files.exists(projectRoot.resolve("build.gradle.kts"))) {
return resolveGradle(projectRoot);
}
log.info("No Maven or Gradle configuration found at {}. Proceeding without dynamic classpath.", projectRoot);
log.debug("Skipping external JAR classpath: analysis uses scanned source files only.");
return Collections.emptyList();
}
protected List<String> resolveMaven(Path projectRoot) {
log.info("Maven project detected. Resolving dependencies...");
try {
String mvnCmd = getCommand(projectRoot, "mvnw", "mvn");
if (mvnCmd == null) {
log.warn("Maven executable not found.");
return Collections.emptyList();
}
ProcessBuilder pb = new ProcessBuilder(
mvnCmd,
"-q",
"dependency:build-classpath",
"-DincludeScope=compile",
"-Dmdep.outputFile=sme_cp.txt"
);
pb.directory(projectRoot.toFile());
runProcess(pb);
java.util.Set<String> allPaths = new java.util.LinkedHashSet<>();
try (java.util.stream.Stream<Path> stream = Files.walk(projectRoot)) {
stream.filter(p -> p.getFileName().toString().equals("sme_cp.txt"))
.forEach(cpFile -> {
try {
String cpString = Files.readString(cpFile).trim();
if (!cpString.isEmpty()) {
allPaths.addAll(Arrays.asList(cpString.split(File.pathSeparator)));
}
Files.deleteIfExists(cpFile);
} catch (IOException e) {
log.warn("Failed to read or delete sme_cp.txt at {}", cpFile, e);
}
});
}
if (!allPaths.isEmpty()) {
return new ArrayList<>(allPaths);
}
} catch (Exception e) {
log.error("Failed to dynamically resolve Maven classpath", e);
}
return Collections.emptyList();
}
protected List<String> resolveGradle(Path projectRoot) {
log.info("Gradle project detected. Resolving dependencies...");
Path initScript = null;
try {
initScript = Files.createTempFile("state_machine_exporter_init", ".gradle");
String gradleCmd = getCommand(projectRoot, "gradlew", "gradle");
if (gradleCmd == null) {
log.warn("Gradle executable not found.");
return Collections.emptyList();
}
String scriptContent = """
allprojects {
task smePrintClasspath {
doLast {
def cp = []
if (configurations.findByName("compileClasspath")) {
cp = configurations.compileClasspath.files
}
println "SME_CLASSPATH_MARKER:" + cp.join(File.pathSeparator)
}
}
}
""";
Files.writeString(initScript, scriptContent);
ProcessBuilder pb = new ProcessBuilder(
gradleCmd,
"-q",
"-I", initScript.toAbsolutePath().toString(),
"smePrintClasspath"
);
pb.directory(projectRoot.toFile());
String output = runProcessAndGetOutput(pb);
java.util.Set<String> allPaths = new java.util.LinkedHashSet<>();
for (String line : output.split("\\r?\\n")) {
if (line.startsWith("SME_CLASSPATH_MARKER:")) {
String cpString = line.substring("SME_CLASSPATH_MARKER:".length()).trim();
if (!cpString.isEmpty()) {
allPaths.addAll(Arrays.asList(cpString.split(File.pathSeparator)));
}
}
}
if (!allPaths.isEmpty()) {
return new ArrayList<>(allPaths);
}
} catch (Exception e) {
log.error("Failed to dynamically resolve Gradle classpath", e);
} finally {
if (initScript != null) {
try {
Files.deleteIfExists(initScript);
} catch (IOException ignored) {}
}
}
return Collections.emptyList();
}
private String getCommand(Path projectRoot, String wrapper, String systemBinary) {
Path wrapperUnix = projectRoot.resolve(wrapper);
Path wrapperWin = projectRoot.resolve(wrapper + ".bat");
boolean isWin = System.getProperty("os.name").toLowerCase().contains("win");
if (isWin && Files.exists(wrapperWin)) return wrapperWin.toAbsolutePath().toString();
if (!isWin && Files.exists(wrapperUnix)) {
// Need absolute path like ./gradlew
return wrapperUnix.toAbsolutePath().toString();
}
return systemBinary;
}
protected void runProcess(ProcessBuilder pb) throws IOException, InterruptedException {
pb.redirectErrorStream(true);
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
Process p = pb.start();
p.waitFor();
}
protected String runProcessAndGetOutput(ProcessBuilder pb) throws IOException, InterruptedException {
pb.redirectErrorStream(true);
Process p = pb.start();
StringBuilder out = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
String line;
while ((line = br.readLine()) != null) {
out.append(line).append("\n");
}
}
p.waitFor();
return out.toString();
}
}

View File

@@ -1,17 +1,77 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.AnalysisEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.path.ForwardPathEstimationEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.RequiredArgsConstructor;
import java.util.List;
@RequiredArgsConstructor
public class EnrichmentService {
private final List<AnalysisEnricher> enrichers;
private final List<AnalysisEnricher> prePropertyEnrichers;
private final List<AnalysisEnricher> postPropertyEnrichers;
/** Single-phase pipeline (all enrichers run together; used by focused tests). */
public EnrichmentService(List<AnalysisEnricher> enrichers) {
this(enrichers, List.of());
}
public EnrichmentService(
List<AnalysisEnricher> prePropertyEnrichers,
List<AnalysisEnricher> postPropertyEnrichers) {
this.prePropertyEnrichers = prePropertyEnrichers;
this.postPropertyEnrichers = postPropertyEnrichers;
}
public static EnrichmentService createDefault() {
return new EnrichmentService(
List.of(
new TriggerEnricher(),
new EntryPointEnricher(),
new PropertyEnricher()),
List.of(
new CallChainEnricher(),
new ForwardPathEstimationEnricher(),
new TriggerCanonicalizationEnricher(),
new TransitionLinkerEnricher()));
}
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
enrichPreProperty(result, context, intelligence);
enrichPostProperty(result, context, intelligence);
}
public void enrichPreProperty(
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
runEnrichers(prePropertyEnrichers, result, context, intelligence);
}
public void enrichPostProperty(
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
runEnrichers(postPropertyEnrichers, result, context, intelligence);
}
/** Re-runs transition linking after property resolution without rebuilding call chains. */
public void relinkAfterPropertyResolution(
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
runEnrichers(
List.of(new TriggerCanonicalizationEnricher(), new TransitionLinkerEnricher()),
result,
context,
intelligence);
}
private static void runEnrichers(
List<AnalysisEnricher> enrichers,
AnalysisResult result,
CodebaseContext context,
CodebaseIntelligenceProvider intelligence) {
for (AnalysisEnricher enricher : enrichers) {
enricher.enrich(result, context, intelligence);
}

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
@Slf4j
@RequiredArgsConstructor
@@ -19,6 +20,20 @@ public class GenericEventDetector {
private final ConstantResolver constantResolver;
private final List<LibraryHint> hints;
/** Parameter names that route to a machine, not SM state enum constants. */
private static final Set<String> ROUTING_PARAMETER_NAMES = Set.of(
"machineType",
"machineId",
"machineName",
"domain",
"type",
"action",
"eventString",
"version");
private static final Set<String> TRIGGER_METHOD_NAMES = Set.of(
"sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger");
public List<TriggerPoint> detect(CompilationUnit cu) {
List<TriggerPoint> triggers = new ArrayList<>();
String fileName = "unknown";
@@ -33,15 +48,13 @@ public class GenericEventDetector {
public boolean visit(MethodInvocation node) {
String methodName = node.getName().getIdentifier();
if ("sendEvent".equals(methodName) || "sendEvents".equals(methodName) ||
"sendEventCollect".equals(methodName) || "sendEventMono".equals(methodName)) {
if (TRIGGER_METHOD_NAMES.contains(methodName)) {
processSendEvent(node, cu, triggers);
} else {
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String refMethod = emr.getName().getIdentifier();
if ("sendEvent".equals(refMethod) || "sendEvents".equals(refMethod) ||
"sendEventCollect".equals(refMethod) || "sendEventMono".equals(refMethod)) {
if (TRIGGER_METHOD_NAMES.contains(refMethod)) {
processSendEventMethodReference(node, emr, cu, triggers);
}
}
@@ -64,7 +77,7 @@ public class GenericEventDetector {
}
MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration type = findEnclosingType(node);
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
if (type == null) return;
String sourceState = extractSourceState(node);
@@ -191,18 +204,11 @@ public class GenericEventDetector {
if (receiver instanceof SimpleName sn) {
String name = sn.getIdentifier();
// Check if it's a field in the enclosing class
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(name)) {
String typeName = fd.getType().toString();
return typeName + "." + methodName;
}
}
}
AbstractTypeDeclaration enclosing = findEnclosingAbstractType(node);
if (enclosing != null) {
Type memberType = lookupMemberType(enclosing, name);
if (memberType != null) {
return memberType + "." + methodName;
}
}
return name + "." + methodName;
@@ -230,7 +236,7 @@ public class GenericEventDetector {
}
MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration type = findEnclosingType(node);
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
if (type == null) return Collections.emptyList();
@@ -303,8 +309,10 @@ public class GenericEventDetector {
String state = extractStateFromSiblings(current, block.statements());
if (state != null) return state;
} else if (parent instanceof SwitchStatement switchStmt) {
String state = extractStateFromSiblings(current, switchStmt.statements());
if (state != null) return state;
if (!isRoutingParameter(switchStmt.getExpression())) {
String state = extractStateFromSiblings(current, switchStmt.statements());
if (state != null) return state;
}
}
current = parent;
@@ -361,6 +369,10 @@ public class GenericEventDetector {
if (left instanceof NullLiteral || right instanceof NullLiteral) {
return null;
}
if (isRoutingParameter(left) || isRoutingParameter(right)) {
return null;
}
// Usually one is a method call like getState() or a variable like `state`
// and the other is the constant enum like `OrderState.PENDING` or `"PENDING"`
@@ -381,6 +393,10 @@ public class GenericEventDetector {
if (("equals".equals(methodName) || "equalsIgnoreCase".equals(methodName)) && !mi.arguments().isEmpty()) {
Expression receiver = mi.getExpression();
Expression arg = (Expression) mi.arguments().get(0);
if (isRoutingParameter(receiver) || isRoutingParameter(arg)) {
return null;
}
// If receiver is null (e.g., implicit this), fall back to arg
if (receiver == null) {
@@ -402,6 +418,13 @@ public class GenericEventDetector {
return null;
}
private boolean isRoutingParameter(Expression expr) {
if (expr instanceof SimpleName sn) {
return ROUTING_PARAMETER_NAMES.contains(sn.getIdentifier());
}
return false;
}
private String getSimpleNameString(Expression expr) {
if (expr instanceof QualifiedName qn) {
return qn.getName().getIdentifier();
@@ -516,11 +539,49 @@ public class GenericEventDetector {
}
private TypeDeclaration findEnclosingType(ASTNode node) {
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
return type instanceof TypeDeclaration td ? td : null;
}
private AbstractTypeDeclaration findEnclosingAbstractType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
while (parent != null && !(parent instanceof AbstractTypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
return (AbstractTypeDeclaration) parent;
}
private List<FieldDeclaration> getFieldDeclarations(AbstractTypeDeclaration type) {
if (type instanceof TypeDeclaration td) {
return List.of(td.getFields());
}
if (type instanceof RecordDeclaration rd) {
return List.of(rd.getFields());
}
return List.of();
}
private Type lookupMemberType(AbstractTypeDeclaration enclosingType, String memberName) {
if (enclosingType == null || memberName == null) {
return null;
}
for (FieldDeclaration fd : getFieldDeclarations(enclosingType)) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(memberName)) {
return fd.getType();
}
}
}
if (enclosingType instanceof RecordDeclaration rd) {
for (Object compObj : rd.recordComponents()) {
if (compObj instanceof SingleVariableDeclaration svd
&& svd.getName().getIdentifier().equals(memberName)) {
return svd.getType();
}
}
}
return null;
}
private String[] resolveStateMachineTypeArguments(MethodInvocation node) {
@@ -576,7 +637,7 @@ public class GenericEventDetector {
}
private String resolveGenericTypeVariable(String typeVarName, ASTNode node, CompilationUnit cu) {
TypeDeclaration enclosingClass = findEnclosingType(node);
AbstractTypeDeclaration enclosingClass = findEnclosingAbstractType(node);
if (enclosingClass == null) return typeVarName;
String className = context.getFqn(enclosingClass);
if (className == null) return typeVarName;
@@ -593,7 +654,11 @@ public class GenericEventDetector {
String baseName = resolveTypeToFqn(baseType, (CompilationUnit) implNode.getRoot());
if (className.equals(baseName) || className.endsWith("." + baseName)) {
int typeIndex = -1;
List<?> typeParams = enclosingClass.typeParameters();
List<?> typeParams = enclosingClass instanceof TypeDeclaration typeDecl
? typeDecl.typeParameters()
: enclosingClass instanceof RecordDeclaration recordDecl
? recordDecl.typeParameters()
: List.of();
for (int i = 0; i < typeParams.size(); i++) {
TypeParameter tp = (TypeParameter) typeParams.get(i);
if (tp.getName().getIdentifier().equals(typeVarName)) {
@@ -665,17 +730,12 @@ public class GenericEventDetector {
}
}
// Check fields
TypeDeclaration enclosingType = findEnclosingType(node);
// Check fields and record components
AbstractTypeDeclaration enclosingType = findEnclosingAbstractType(node);
if (enclosingType != null) {
for (FieldDeclaration fd : enclosingType.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(varName)) {
return fd.getType();
}
}
}
Type memberType = lookupMemberType(enclosingType, varName);
if (memberType != null) {
return memberType;
}
}

View File

@@ -21,7 +21,7 @@ import java.util.Map;
public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
private final CodebaseContext context;
private final Path rootDir;
private final ConstantResolver constantResolver = new ConstantResolver();
private final ConstantResolver constantResolver;
private final GenericEventDetector eventDetector;
private final SpringMvcDetector mvcDetector;
private final MessagingDetector messagingDetector;
@@ -31,10 +31,12 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
private final SpringComponentDetector componentDetector;
private List<TriggerPoint> cachedTriggers;
private List<EntryPoint> cachedEntryPoints;
private List<CallChain> cachedCallChains;
public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) {
this.context = context;
this.rootDir = rootDir;
this.constantResolver = context.getConstantResolver();
this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints());
this.mvcDetector = new SpringMvcDetector(context, constantResolver);
this.messagingDetector = new MessagingDetector(context);
@@ -92,8 +94,17 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
@Override
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers", entryPoints.size(), triggers.size());
return callGraphEngine.findChains(entryPoints, triggers);
if (cachedCallChains != null) {
return cachedCallChains;
}
List<EntryPoint> allEntryPoints = findEntryPoints();
List<TriggerPoint> allTriggers = triggers != null && !triggers.isEmpty()
? triggers
: findTriggerPoints();
log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers",
allEntryPoints.size(), allTriggers.size());
cachedCallChains = callGraphEngine.findChains(allEntryPoints, allTriggers);
return cachedCallChains;
}
@Override

View File

@@ -0,0 +1,73 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/** Builds a minimal {@link CodebaseContext} for JSON re-export when sources are available. */
@Slf4j
public final class JsonExportContextFactory {
private JsonExportContextFactory() {
}
public static Path findProjectRoot(Path start) {
Path current = start != null ? start.toAbsolutePath().normalize() : null;
while (current != null) {
if (Files.exists(current.resolve("settings.gradle"))
|| Files.exists(current.resolve("pom.xml"))
|| Files.exists(current.resolve("build.gradle"))) {
return current;
}
current = current.getParent();
}
return null;
}
public static CodebaseContext scanProjectRoot(Path projectRoot, List<String> activeProfiles) throws IOException {
CodebaseContext context = new CodebaseContext();
if (activeProfiles != null) {
context.setActiveProfiles(activeProfiles);
}
context.setProjectRoot(projectRoot);
context.setResolveBindings(true);
Path mainJava = projectRoot.resolve("src/main/java");
if (Files.isDirectory(mainJava)) {
context.setSourcepath(List.of(mainJava.toString()));
context.scan(projectRoot);
return context;
}
click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver siblingResolver =
new click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver();
var projectGraph = siblingResolver.analyzeProject(projectRoot);
var scanPaths = projectGraph.resolveScanPaths(projectRoot, false);
context.scan(scanPaths, java.util.Collections.emptySet());
return context;
}
public static StateMachineTypeResolver.MachineTypes resolveMachineTypes(
String machineName,
String embeddedStateTypeFqn,
String embeddedEventTypeFqn,
CodebaseContext context) {
if (context != null && machineName != null) {
StateMachineTypeResolver.MachineTypes resolved =
StateMachineTypeResolver.resolveTypes(machineName, context);
if (resolved.stateTypeFqn() != null || resolved.eventTypeFqn() != null) {
return resolved;
}
}
return new StateMachineTypeResolver.MachineTypes(embeddedStateTypeFqn, embeddedEventTypeFqn);
}
private static boolean hasPackageQualifiedType(String typeFqn) {
return typeFqn != null && typeFqn.contains(".");
}
}

View File

@@ -40,6 +40,12 @@ public class MessagingDetector {
} else if (typeName.endsWith("RabbitListener")) {
type = EntryPoint.Type.RABBIT;
destination = resolveValue(annotation, "queues");
if ("unknown".equals(destination)) {
destination = resolveValue(annotation, "queue");
}
if ("unknown".equals(destination)) {
destination = resolveValue(annotation, "bindings");
}
} else if (typeName.endsWith("SqsListener")) {
type = EntryPoint.Type.SQS;
destination = resolveValue(annotation, "value");
@@ -55,6 +61,9 @@ public class MessagingDetector {
} else if (typeName.endsWith("KafkaListener")) {
type = EntryPoint.Type.KAFKA;
destination = resolveValue(annotation, "topics");
if ("unknown".equals(destination)) {
destination = resolveValue(annotation, "topicPattern");
}
}
if (type != null) {

View File

@@ -0,0 +1,365 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Walks a call path forward, propagating known parameter / local bindings so switch-case
* edge constraints can prune impossible branches (e.g. {@code dispatch} arms).
*/
public class PathBindingEvaluator {
private final CodebaseContext context;
private final VariableTracer variableTracer;
private final ConstantResolver constantResolver;
private final TypeResolver typeResolver;
public PathBindingEvaluator(
CodebaseContext context,
VariableTracer variableTracer,
ConstantResolver constantResolver,
TypeResolver typeResolver) {
this.context = context;
this.variableTracer = variableTracer;
this.constantResolver = constantResolver;
this.typeResolver = typeResolver;
}
/** Visible for tests: bindings accumulated while walking a path forward (ignores constraint rejection). */
Map<String, String> traceBindings(List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
Map<String, String> bindings = new HashMap<>();
if (path == null || path.size() < 2) {
return bindings;
}
for (int i = 0; i < path.size() - 1; i++) {
String caller = path.get(i);
String target = path.get(i + 1);
CallEdge edge = findEdge(caller, target, callGraph, pathFinder);
if (edge == null) {
continue;
}
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings);
}
return bindings;
}
public boolean isPathCompatible(
List<String> path,
Map<String, List<CallEdge>> callGraph,
CallGraphPathFinder pathFinder,
Map<String, String> initialBindings) {
if (path == null || path.size() < 2) {
return true;
}
Map<String, String> bindings = new HashMap<>();
if (initialBindings != null) {
bindings.putAll(initialBindings);
}
for (int i = 0; i < path.size() - 1; i++) {
String caller = path.get(i);
String target = path.get(i + 1);
CallEdge edge = findEdge(caller, target, callGraph, pathFinder);
if (edge == null) {
continue;
}
if (edge.getConstraint() != null
&& !edge.getConstraint().isBlank()
&& !BooleanConstraintEvaluator.isCompatibleWithBindings(edge.getConstraint(), bindings)) {
return false;
}
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings);
}
return true;
}
public boolean isPathCompatible(List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
return isPathCompatible(path, callGraph, pathFinder, Map.of());
}
/**
* Returns {@code true} when {@code caller}→{@code callee} via {@code edge} satisfies the edge constraint
* under {@code bindings}; on success, {@code bindings} is updated with propagated parameter values.
*/
public boolean tryTraverseEdge(
String caller,
String callee,
CallEdge edge,
List<String> pathWithCallee,
int calleeIndex,
Map<String, List<CallEdge>> callGraph,
CallGraphPathFinder pathFinder,
Map<String, String> bindings) {
if (edge.getConstraint() != null
&& !edge.getConstraint().isBlank()
&& !BooleanConstraintEvaluator.isCompatibleWithBindings(edge.getConstraint(), bindings)) {
return false;
}
if (variableTracer == null || typeResolver == null) {
return true;
}
enrichBindings(caller, callee, edge, pathWithCallee, calleeIndex, callGraph, pathFinder, bindings);
return true;
}
private void enrichBindings(
String caller,
String target,
CallEdge edge,
List<String> path,
int pathIndex,
Map<String, List<CallEdge>> callGraph,
CallGraphPathFinder pathFinder,
Map<String, String> bindings) {
Map<String, String> paramValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, pathIndex);
mergeResolvedBindings(bindings, paramValues, caller);
List<String> targetParams = typeResolver.getParameterNames(target);
if (targetParams == null || edge.getArguments() == null) {
return;
}
for (int i = 0; i < edge.getArguments().size() && i < targetParams.size(); i++) {
String arg = edge.getArguments().get(i);
String paramName = targetParams.get(i);
String resolved = resolveBindingValue(caller, arg, bindings);
if (shouldStoreBinding(paramName, resolved)) {
bindings.put(paramName, resolved);
}
}
}
private boolean shouldStoreBinding(String paramName, String resolved) {
if (resolved == null || resolved.isBlank() || resolved.equals(paramName)) {
return false;
}
if (resolved.contains("(") && !isEnumLikeConstant(resolved)) {
return false;
}
return true;
}
private void mergeResolvedBindings(Map<String, String> bindings, Map<String, String> paramValues, String caller) {
if (paramValues == null || paramValues.isEmpty()) {
return;
}
for (Map.Entry<String, String> entry : paramValues.entrySet()) {
String resolved = resolveBindingValue(caller, entry.getValue(), bindings);
if (shouldStoreBinding(entry.getKey(), resolved)) {
bindings.put(entry.getKey(), resolved);
}
}
}
private String resolveBindingValue(String callerFqn, String rawValue, Map<String, String> bindings) {
if (rawValue == null || rawValue.isBlank()) {
return null;
}
if (rawValue.startsWith("\"") || isEnumLikeConstant(rawValue)) {
return normalizeLiteral(rawValue);
}
if (bindings.containsKey(rawValue)) {
return bindings.get(rawValue);
}
String traced = variableTracer.traceLocalVariable(callerFqn, rawValue, bindings);
if (traced != null && !traced.isBlank()) {
if (traced.startsWith("\"") || isEnumLikeConstant(traced)) {
return traced;
}
String fromCall = resolveMethodCallFromSource(callerFqn, rawValue, bindings);
if (fromCall != null) {
return fromCall;
}
if (!traced.equals(rawValue)) {
return traced;
}
}
return rawValue;
}
private String resolveMethodCallFromSource(String callerFqn, String varName, Map<String, String> bindings) {
MethodDeclaration md = findMethodDeclaration(callerFqn);
if (md == null || md.getBody() == null) {
return null;
}
final Expression[] initializer = new Expression[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializer[0] = node.getInitializer();
}
return super.visit(node);
}
});
if (initializer[0] == null) {
return null;
}
return evaluateExpressionValue(initializer[0], callerFqn, bindings);
}
private String evaluateExpressionValue(Expression expr, String callerFqn, Map<String, String> bindings) {
if (expr instanceof StringLiteral sl) {
return "\"" + sl.getLiteralValue() + "\"";
}
if (expr instanceof QualifiedName qn) {
return qn.getFullyQualifiedName();
}
if (expr instanceof SimpleName sn) {
return bindings.getOrDefault(sn.getIdentifier(), sn.getIdentifier());
}
if (expr instanceof MethodInvocation mi) {
return evaluateMethodInvocationReturn(mi, callerFqn, bindings);
}
if (expr instanceof SwitchExpression se) {
return constantResolver.evaluateSwitchWithParams(se, bindings, context);
}
String resolved = constantResolver.resolve(expr, context);
return resolved;
}
private String evaluateMethodInvocationReturn(MethodInvocation mi, String callerFqn, Map<String, String> bindings) {
String ownerFqn = resolveMethodOwnerFqn(mi, callerFqn);
if (ownerFqn == null) {
return null;
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration owner = context.getTypeDeclaration(ownerFqn);
if (owner == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
Map<String, String> callBindings = new HashMap<>(bindings);
for (int i = 0; i < md.parameters().size() && i < mi.arguments().size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i);
String paramName = param.getName().getIdentifier();
String argValue = evaluateExpressionValue((Expression) mi.arguments().get(i), callerFqn, callBindings);
if (argValue != null) {
callBindings.put(paramName, argValue);
}
}
final String[] result = new String[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() instanceof SwitchExpression se) {
result[0] = constantResolver.evaluateSwitchWithParams(se, callBindings, context);
} else if (node.getExpression() != null) {
result[0] = evaluateExpressionValue(node.getExpression(), ownerFqn + "." + methodName, callBindings);
}
return super.visit(node);
}
});
return result[0];
}
private String resolveFieldTypeFqn(String callerFqn, String fieldName) {
if (callerFqn == null || !callerFqn.contains(".")) {
return null;
}
String className = callerFqn.substring(0, callerFqn.lastIndexOf('.'));
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
for (FieldDeclaration field : td.getFields()) {
for (Object fragmentObj : field.fragments()) {
VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj;
if (fragment.getName().getIdentifier().equals(fieldName)) {
IVariableBinding binding = fragment.resolveBinding();
if (binding != null && binding.getType() != null) {
return binding.getType().getErasure().getQualifiedName();
}
String simpleType = field.getType().toString();
TypeDeclaration resolved = context.getTypeDeclaration(simpleType);
if (resolved != null) {
return context.getFqn(resolved);
}
if (td.getRoot() instanceof CompilationUnit cu && cu.getPackage() != null) {
String pkg = cu.getPackage().getName().getFullyQualifiedName();
resolved = context.getTypeDeclaration(pkg + "." + simpleType);
if (resolved != null) {
return context.getFqn(resolved);
}
}
return simpleType;
}
}
}
return null;
}
private String resolveMethodOwnerFqn(MethodInvocation mi, String callerFqn) {
IMethodBinding methodBinding = mi.resolveMethodBinding();
if (methodBinding != null && methodBinding.getDeclaringClass() != null) {
return methodBinding.getDeclaringClass().getErasure().getQualifiedName();
}
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
return resolveFieldTypeFqn(callerFqn, sn.getIdentifier());
}
if (receiver instanceof FieldAccess fa) {
IVariableBinding fieldBinding = fa.resolveFieldBinding();
if (fieldBinding != null && fieldBinding.getType() != null) {
return fieldBinding.getType().getErasure().getQualifiedName();
}
return resolveFieldTypeFqn(callerFqn, fa.getName().getIdentifier());
}
return null;
}
private MethodDeclaration findMethodDeclaration(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
return context.findMethodDeclaration(td, methodName, true);
}
private static String normalizeLiteral(String value) {
if (value != null && value.startsWith("\"") && value.endsWith("\"")) {
return value.substring(1, value.length() - 1);
}
return value;
}
private static boolean isEnumLikeConstant(String value) {
if (value == null || value.isEmpty()) {
return false;
}
int dot = value.lastIndexOf('.');
if (dot <= 0 || dot >= value.length() - 1) {
return false;
}
return Character.isUpperCase(value.charAt(dot + 1));
}
private static CallEdge findEdge(
String caller, String target, 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(target) || pathFinder.isHeuristicMatch(edge.getTargetMethod(), target)) {
return edge;
}
}
return null;
}
}

View File

@@ -182,11 +182,11 @@ public class SpringMvcDetector {
}
private String getHttpVerb(String annotationName) {
if (annotationName.endsWith("PostMapping") || annotationName.endsWith("POST")) return "POST";
if (annotationName.endsWith("GetMapping") || annotationName.endsWith("GET")) return "GET";
if (annotationName.endsWith("PutMapping") || annotationName.endsWith("PUT")) return "PUT";
if (annotationName.endsWith("DeleteMapping") || annotationName.endsWith("DELETE")) return "DELETE";
if (annotationName.endsWith("PatchMapping") || annotationName.endsWith("PATCH")) return "PATCH";
if (annotationName.endsWith("PostMapping") || annotationName.endsWith("POST") || "POST".equals(annotationName)) return "POST";
if (annotationName.endsWith("GetMapping") || annotationName.endsWith("GET") || "GET".equals(annotationName)) return "GET";
if (annotationName.endsWith("PutMapping") || annotationName.endsWith("PUT") || "PUT".equals(annotationName)) return "PUT";
if (annotationName.endsWith("DeleteMapping") || annotationName.endsWith("DELETE") || "DELETE".equals(annotationName)) return "DELETE";
if (annotationName.endsWith("PatchMapping") || annotationName.endsWith("PATCH") || "PATCH".equals(annotationName)) return "PATCH";
if (annotationName.endsWith("RequestMapping")) return "REQUEST";
return null;
}

View File

@@ -0,0 +1,25 @@
package click.kamil.springstatemachineexporter.analysis.service;
import java.io.File;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* Classpath entries from the exporter tool itself — not from running Maven/Gradle on the analyzed project.
*/
public final class ToolingClasspath {
private ToolingClasspath() {
}
public static List<String> currentJvmJarEntries() {
String raw = System.getProperty("java.class.path");
if (raw == null || raw.isBlank()) {
return List.of();
}
return Arrays.stream(raw.split(File.pathSeparator))
.filter(entry -> entry.endsWith(".jar"))
.collect(Collectors.toList());
}
}

View File

@@ -30,6 +30,28 @@ public class TypeResolver {
return -1;
}
public List<String> getParameterNames(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null) {
return null;
}
List<String> paramNames = new ArrayList<>();
for (Object paramObj : md.parameters()) {
SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj;
paramNames.add(param.getName().getIdentifier());
}
return paramNames;
}
public String getParameterName(String methodFqn, int index) {
if (methodFqn == null || !methodFqn.contains(".") || index < 0) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));

View File

@@ -1,7 +1,11 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@@ -55,11 +59,47 @@ public class VariableTracer {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
final Expression[] setterArg = new Expression[1];
String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName;
String propName;
String setterMethodName;
String indexedFieldName;
if (isBeanStyleAccessorName(getterName)) {
propName = propertyNameFromAccessor(getterName);
setterMethodName = "set" + capitalizeProperty(propName);
indexedFieldName = propName;
String varTypeName = getVariableDeclaredType(methodFqn, varName);
if (varTypeName != null) {
CompilationUnit contextCu = md.getRoot() instanceof CompilationUnit cu ? cu : null;
TypeDeclaration varType = contextCu != null
? context.getTypeDeclaration(varTypeName, contextCu)
: null;
if (varType == null) {
varType = context.getTypeDeclaration(varTypeName);
}
if (varType != null) {
Optional<AccessorSummary> indexedSetter = context.getAccessorIndex()
.lookup(context.getFqn(varType), setterMethodName);
if (indexedSetter.isPresent() && indexedSetter.get().isSetter()) {
setterMethodName = indexedSetter.get().methodName();
indexedFieldName = indexedSetter.get().fieldName();
}
}
}
} else {
propName = getterName.startsWith("get") ? getterName.substring(3) : getterName;
setterMethodName = "set" + propName;
indexedFieldName = propName;
}
final String resolvedSetterName = setterMethodName;
final String resolvedFieldName = indexedFieldName;
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (node.getName().getIdentifier().equalsIgnoreCase("set" + propName) || node.getName().getIdentifier().equalsIgnoreCase(propName)) {
if (node.getName().getIdentifier().equals(resolvedSetterName)
|| node.getName().getIdentifier().equalsIgnoreCase("set" + resolvedFieldName)
|| node.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) {
if (node.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) {
if (!node.arguments().isEmpty()) {
setterArg[0] = (Expression) node.arguments().get(0);
@@ -72,13 +112,13 @@ public class VariableTracer {
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof FieldAccess fa) {
if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) {
if (fa.getName().getIdentifier().equalsIgnoreCase(propName)) {
if (fa.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) {
setterArg[0] = node.getRightHandSide();
}
}
} else if (node.getLeftHandSide() instanceof QualifiedName qqn) {
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
if (qqn.getName().getIdentifier().equalsIgnoreCase(propName)) {
if (qqn.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) {
setterArg[0] = node.getRightHandSide();
}
}
@@ -92,6 +132,32 @@ public class VariableTracer {
return null;
}
private static boolean isBeanStyleAccessorName(String accessorName) {
return (accessorName.startsWith("get") && accessorName.length() > 3)
|| (accessorName.startsWith("is") && accessorName.length() > 2)
|| (accessorName.startsWith("set") && accessorName.length() > 3);
}
private static String propertyNameFromAccessor(String getterName) {
if (getterName.startsWith("get") && getterName.length() > 3) {
return TrivialAccessorDetector.decapitalize(getterName.substring(3));
}
if (getterName.startsWith("is") && getterName.length() > 2) {
return TrivialAccessorDetector.decapitalize(getterName.substring(2));
}
return getterName;
}
private static String capitalizeProperty(String propertyName) {
if (propertyName == null || propertyName.isEmpty()) {
return propertyName;
}
if (propertyName.length() > 1 && Character.isUpperCase(propertyName.charAt(1))) {
return propertyName;
}
return Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);
}
private String getFieldType(TypeDeclaration td, String fieldName, CodebaseContext context, java.util.Set<String> visited) {
if (td == null) return null;
String typeFqn = context.getFqn(td);
@@ -656,62 +722,7 @@ public class VariableTracer {
}
private Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) {
if (depth > 5) return mi;
String targetMethodFqn = resolveTargetMethodFqn(mi, methodFqn);
if (targetMethodFqn != null && targetMethodFqn.contains(".")) {
String className = targetMethodFqn.substring(0, targetMethodFqn.lastIndexOf('.'));
String methodName = targetMethodFqn.substring(targetMethodFqn.lastIndexOf('.') + 1);
TypeDeclaration currentTd = methodFqn != null && methodFqn.contains(".") ? context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.'))) : null;
CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
TypeDeclaration td = context.getTypeDeclaration(className, cu);
if (td != null) {
MethodDeclaration localMd = context.findMethodDeclaration(td, methodName, true);
if (localMd != null) {
int paramIdx = getReturnedParameterIndex(localMd);
if (paramIdx >= 0 && paramIdx < mi.arguments().size()) {
Expression arg = peelExpression((Expression) mi.arguments().get(paramIdx));
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
}
return arg;
}
}
}
}
if (mi.getExpression() != null) {
String exprStr = mi.getExpression().toString();
if (!exprStr.equals("Optional") && !exprStr.equals("Stream") && !exprStr.equals("List") && !exprStr.equals("Set") && !exprStr.equals("Arrays") && !exprStr.equals("Objects") && !exprStr.equals("Mono") && !exprStr.equals("Flux")) {
return mi;
}
}
if (!mi.arguments().isEmpty()) {
String mName = mi.getName().getIdentifier();
String lowerName = mName.toLowerCase();
if (lowerName.startsWith("map") || lowerName.startsWith("parse") ||
lowerName.startsWith("convert") || lowerName.startsWith("to") ||
lowerName.startsWith("resolve") || lowerName.startsWith("build") ||
lowerName.startsWith("create") || lowerName.startsWith("from") ||
lowerName.startsWith("transform") || lowerName.startsWith("translate") ||
lowerName.startsWith("derive") || lowerName.startsWith("determine") ||
lowerName.startsWith("calculate") || lowerName.startsWith("decode") ||
lowerName.startsWith("extract") || lowerName.startsWith("get") ||
lowerName.startsWith("find") || lowerName.startsWith("validate")) {
return mi;
}
Expression arg = peelExpression((Expression) mi.arguments().get(0));
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
}
return arg;
}
return mi;
return MethodInvocationUnwrapper.unwrap(mi, depth, methodFqn, context, miArg -> resolveTargetMethodFqn(miArg, methodFqn));
}
private String resolveTargetMethodFqn(MethodInvocation mi, String currentMethodFqn) {
@@ -733,33 +744,4 @@ public class VariableTracer {
}
return null;
}
private int getReturnedParameterIndex(MethodDeclaration md) {
if (md.getBody() == null) return -1;
List<?> parameters = md.parameters();
if (parameters.isEmpty()) return -1;
final List<String> returnedExprs = new ArrayList<>();
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
returnedExprs.add(node.getExpression().toString());
}
return super.visit(node);
}
});
if (returnedExprs.size() == 1) {
String retStr = returnedExprs.get(0);
for (int i = 0; i < parameters.size(); i++) {
if (parameters.get(i) instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(retStr)) {
return i;
}
}
}
}
return -1;
}
}

View File

@@ -0,0 +1,32 @@
package click.kamil.springstatemachineexporter.analysis.validation;
import java.util.ArrayList;
import java.util.List;
/**
* Thrown when {@link AnalysisCanonicalFormValidator} finds enum identifiers that are not
* package-canonical for the machine's resolved {@code <State, Event>} types.
*/
public class AnalysisCanonicalFormException extends IllegalStateException {
private final List<AnalysisCanonicalFormValidator.Violation> violations;
public AnalysisCanonicalFormException(List<AnalysisCanonicalFormValidator.Violation> violations) {
super(formatMessage(violations));
this.violations = List.copyOf(violations);
}
public List<AnalysisCanonicalFormValidator.Violation> getViolations() {
return violations;
}
private static String formatMessage(List<AnalysisCanonicalFormValidator.Violation> violations) {
StringBuilder sb = new StringBuilder("Analysis model contains non-canonical enum identifiers:");
for (AnalysisCanonicalFormValidator.Violation violation : violations) {
sb.append("\n - ").append(violation.path())
.append(": '").append(violation.actual())
.append("' (expected '").append(violation.expected()).append("')");
}
return sb.toString();
}
}

View File

@@ -0,0 +1,261 @@
package click.kamil.springstatemachineexporter.analysis.validation;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
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.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Validates that enum-backed identifiers across the analysis model share one canonical shape:
* {@code {package.EnumType.CONSTANT}} derived from the machine config's {@code <State, Event>} types.
*
* <p>String/primitive machines ({@code StateMachineConfigurerAdapter<String, String>}) are skipped.
*/
public final class AnalysisCanonicalFormValidator {
public record Violation(String path, String actual, String expected) {
}
private AnalysisCanonicalFormValidator() {
}
public static List<Violation> validate(AnalysisResult result, CodebaseContext context) {
List<Violation> violations = new ArrayList<>();
if (result == null || context == null) {
return violations;
}
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
if (!hasPackageQualifiedEnumType(machineTypes.stateTypeFqn())
&& !hasPackageQualifiedEnumType(machineTypes.eventTypeFqn())) {
if (!StateMachineTypeResolver.extendsEnumStateMachineConfigurer(result.getName(), context)) {
return violations;
}
violations.add(new Violation(
"machineTypes",
describeTypes(machineTypes),
"package-qualified state/event enum types"));
return violations;
}
validateFields(result, machineTypes, violations);
return violations;
}
public static List<Violation> validateWithMachineTypes(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes) {
List<Violation> violations = new ArrayList<>();
if (result == null || machineTypes == null) {
return violations;
}
if (!hasPackageQualifiedEnumType(machineTypes.stateTypeFqn())
&& !hasPackageQualifiedEnumType(machineTypes.eventTypeFqn())) {
return violations;
}
validateFields(result, machineTypes, violations);
return violations;
}
public static void enforce(AnalysisResult result, CodebaseContext context) {
List<Violation> violations = validate(result, context);
if (!violations.isEmpty()) {
throw new AnalysisCanonicalFormException(violations);
}
}
public static void enforceWithMachineTypes(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes) {
List<Violation> violations = validateWithMachineTypes(result, machineTypes);
if (!violations.isEmpty()) {
throw new AnalysisCanonicalFormException(violations);
}
}
private static void validateFields(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) {
validateTransitions(result.getTransitions(), machineTypes, violations);
validateStateCollection(result.getStates(), machineTypes.stateTypeFqn(), "states", violations);
validateStateLabels(result.getStartStates(), machineTypes.stateTypeFqn(), "startStates", violations);
validateStateLabels(result.getEndStates(), machineTypes.stateTypeFqn(), "endStates", violations);
if (result.getMetadata() != null) {
validateTriggers(result.getMetadata().getTriggers(), machineTypes, violations);
validateCallChains(result.getMetadata().getCallChains(), machineTypes, violations);
}
}
private static String describeTypes(StateMachineTypeResolver.MachineTypes machineTypes) {
return "stateTypeFqn=" + machineTypes.stateTypeFqn() + ", eventTypeFqn=" + machineTypes.eventTypeFqn();
}
private static boolean hasPackageQualifiedEnumType(String typeFqn) {
return typeFqn != null && typeFqn.contains(".") && !MachineEnumCanonicalizer.isStringOrPrimitiveType(typeFqn);
}
private static void validateTransitions(
List<Transition> transitions,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) {
if (transitions == null) {
return;
}
for (int i = 0; i < transitions.size(); i++) {
Transition transition = transitions.get(i);
String prefix = "transitions[" + i + "]";
if (transition.getEvent() != null) {
requireCanonical(
prefix + ".event.fullIdentifier",
transition.getEvent().fullIdentifier(),
machineTypes.eventTypeFqn(),
violations);
}
validateStateList(transition.getSourceStates(), machineTypes.stateTypeFqn(), prefix + ".sourceStates", violations);
validateStateList(transition.getTargetStates(), machineTypes.stateTypeFqn(), prefix + ".targetStates", violations);
}
}
private static void validateStateList(
List<State> states,
String stateTypeFqn,
String pathPrefix,
List<Violation> violations) {
if (states == null) {
return;
}
for (int i = 0; i < states.size(); i++) {
requireCanonical(pathPrefix + "[" + i + "].fullIdentifier", states.get(i).fullIdentifier(), stateTypeFqn, violations);
}
}
private static void validateStateCollection(
Set<State> states,
String stateTypeFqn,
String pathPrefix,
List<Violation> violations) {
if (states == null) {
return;
}
int i = 0;
for (State state : states) {
requireCanonical(pathPrefix + "[" + i++ + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations);
}
}
private static void validateStateLabels(
Set<String> labels,
String stateTypeFqn,
String pathPrefix,
List<Violation> violations) {
if (labels == null) {
return;
}
int i = 0;
for (String label : labels) {
requireCanonical(pathPrefix + "[" + i++ + "]", label, stateTypeFqn, violations);
}
}
private static void validateTriggers(
List<TriggerPoint> triggers,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) {
if (triggers == null) {
return;
}
for (int i = 0; i < triggers.size(); i++) {
TriggerPoint trigger = triggers.get(i);
String prefix = "metadata.triggers[" + i + "]";
String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn());
String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn());
requireCanonical(prefix + ".event", trigger.getEvent(), eventTypeFqn, violations);
requireCanonical(prefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations);
if (trigger.getPolymorphicEvents() != null) {
for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) {
requireCanonical(
prefix + ".polymorphicEvents[" + j + "]",
trigger.getPolymorphicEvents().get(j),
eventTypeFqn,
violations);
}
}
}
}
private static void validateCallChains(
List<CallChain> callChains,
StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) {
if (callChains == null) {
return;
}
for (int i = 0; i < callChains.size(); i++) {
CallChain chain = callChains.get(i);
String prefix = "metadata.callChains[" + i + "]";
TriggerPoint trigger = chain.getTriggerPoint();
if (trigger != null) {
String triggerPrefix = prefix + ".triggerPoint";
String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn());
String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn());
requireCanonical(triggerPrefix + ".event", trigger.getEvent(), eventTypeFqn, violations);
requireCanonical(triggerPrefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations);
if (trigger.getPolymorphicEvents() != null) {
for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) {
requireCanonical(
triggerPrefix + ".polymorphicEvents[" + j + "]",
trigger.getPolymorphicEvents().get(j),
eventTypeFqn,
violations);
}
}
}
if (chain.getMatchedTransitions() != null) {
for (int j = 0; j < chain.getMatchedTransitions().size(); j++) {
MatchedTransition matched = chain.getMatchedTransitions().get(j);
String matchedPrefix = prefix + ".matchedTransitions[" + j + "]";
requireCanonical(matchedPrefix + ".event", matched.getEvent(), machineTypes.eventTypeFqn(), violations);
requireCanonical(matchedPrefix + ".sourceState", matched.getSourceState(), machineTypes.stateTypeFqn(), violations);
requireCanonical(matchedPrefix + ".targetState", matched.getTargetState(), machineTypes.stateTypeFqn(), violations);
}
}
}
}
private static String preferTypeFqn(String primary, String fallback) {
if (hasPackageQualifiedEnumType(primary)) {
return primary;
}
return fallback;
}
private static void requireCanonical(
String path,
String value,
String enumTypeFqn,
List<Violation> violations) {
if (!MachineEnumCanonicalizer.isMachineEnumReference(value, enumTypeFqn)) {
return;
}
String expected = MachineEnumCanonicalizer.canonicalizeLabel(value, enumTypeFqn);
if (!expected.equals(value)) {
violations.add(new Violation(path, value, expected));
}
}
}

View File

@@ -45,20 +45,44 @@ public class TransitionStateUtils {
}
public static Set<State> findAllStates(Collection<Transition> transitions, Set<String> initialStates, Set<String> endStates) {
Set<State> allStates = new java.util.HashSet<>();
java.util.Map<String, State> byFullIdentifier = new java.util.LinkedHashMap<>();
if (transitions != null) {
transitions.forEach(t -> {
if (t.getSourceStates() != null) allStates.addAll(t.getSourceStates());
if (t.getTargetStates() != null) allStates.addAll(t.getTargetStates());
mergeState(byFullIdentifier, t.getSourceStates());
mergeState(byFullIdentifier, t.getTargetStates());
});
}
if (initialStates != null) {
initialStates.forEach(s -> allStates.add(State.of(s)));
initialStates.forEach(label -> mergeState(byFullIdentifier, State.of(label)));
}
if (endStates != null) {
endStates.forEach(s -> allStates.add(State.of(s)));
endStates.forEach(label -> mergeState(byFullIdentifier, State.of(label)));
}
return allStates;
return new java.util.LinkedHashSet<>(byFullIdentifier.values());
}
private static void mergeState(java.util.Map<String, State> map, State state) {
if (state == null) {
return;
}
String key = stateKey(state);
map.putIfAbsent(key, state);
}
private static void mergeState(java.util.Map<String, State> map, java.util.Collection<State> states) {
if (states == null) {
return;
}
for (State state : states) {
mergeState(map, state);
}
}
private static String stateKey(State state) {
if (state.fullIdentifier() != null && !state.fullIdentifier().isBlank()) {
return state.fullIdentifier();
}
return state.rawName();
}
private static Stream<String> normalizeStates(Collection<State> states) {

View File

@@ -113,8 +113,100 @@ public final class AstUtils {
}
current = parent;
}
String switchConstraint = buildSwitchCaseConstraint(findEnclosingSwitchCase(node));
if (switchConstraint != null) {
conditions.add(switchConstraint);
}
if (conditions.isEmpty()) return null;
java.util.Collections.reverse(conditions);
return String.join(" && ", conditions);
}
static org.eclipse.jdt.core.dom.SwitchCase findEnclosingSwitchCase(org.eclipse.jdt.core.dom.ASTNode node) {
org.eclipse.jdt.core.dom.ASTNode current = node;
while (current != null && !(current instanceof org.eclipse.jdt.core.dom.MethodDeclaration)) {
if (current instanceof org.eclipse.jdt.core.dom.SwitchCase switchCase) {
return switchCase;
}
current = current.getParent();
}
current = node;
while (current != null && !(current instanceof org.eclipse.jdt.core.dom.MethodDeclaration)) {
if (current instanceof org.eclipse.jdt.core.dom.YieldStatement yieldStatement) {
org.eclipse.jdt.core.dom.ASTNode parent = yieldStatement.getParent();
if (parent instanceof org.eclipse.jdt.core.dom.SwitchStatement switchStatement) {
org.eclipse.jdt.core.dom.SwitchCase switchCase =
findSwitchCaseForStatement(switchStatement.statements(), yieldStatement);
if (switchCase != null) {
return switchCase;
}
} else if (parent instanceof org.eclipse.jdt.core.dom.SwitchExpression switchExpression) {
org.eclipse.jdt.core.dom.SwitchCase switchCase =
findSwitchCaseForStatement(switchExpression.statements(), yieldStatement);
if (switchCase != null) {
return switchCase;
}
}
} else if (current instanceof org.eclipse.jdt.core.dom.ExpressionStatement expressionStatement) {
org.eclipse.jdt.core.dom.ASTNode parent = expressionStatement.getParent();
if (parent instanceof org.eclipse.jdt.core.dom.SwitchCase switchCase) {
return switchCase;
}
}
current = current.getParent();
}
return null;
}
private static org.eclipse.jdt.core.dom.SwitchCase findSwitchCaseForStatement(
java.util.List<?> statements, org.eclipse.jdt.core.dom.ASTNode bodyStatement) {
int idx = statements.indexOf(bodyStatement);
if (idx < 0) {
return null;
}
for (int i = idx - 1; i >= 0; i--) {
if (statements.get(i) instanceof org.eclipse.jdt.core.dom.SwitchCase switchCase) {
return switchCase;
}
}
return null;
}
private static String buildSwitchCaseConstraint(org.eclipse.jdt.core.dom.SwitchCase switchCase) {
if (switchCase == null || switchCase.isDefault()) {
return null;
}
org.eclipse.jdt.core.dom.Expression switchSelector = null;
org.eclipse.jdt.core.dom.ASTNode switchParent = switchCase.getParent();
if (switchParent instanceof org.eclipse.jdt.core.dom.SwitchStatement ss) {
switchSelector = ss.getExpression();
} else if (switchParent instanceof org.eclipse.jdt.core.dom.SwitchExpression se) {
switchSelector = se.getExpression();
}
if (switchSelector == null) {
return null;
}
java.util.List<String> caseLabels = new java.util.ArrayList<>();
for (Object exprObj : switchCase.expressions()) {
if (exprObj instanceof org.eclipse.jdt.core.dom.Expression caseExpr) {
caseLabels.add(caseExpr.toString());
}
}
if (caseLabels.isEmpty()) {
return null;
}
String selector = switchSelector.toString();
if (caseLabels.size() == 1) {
return selector + " == " + caseLabels.get(0);
}
StringBuilder combined = new StringBuilder("(");
for (int i = 0; i < caseLabels.size(); i++) {
if (i > 0) {
combined.append(" || ");
}
combined.append(selector).append(" == ").append(caseLabels.get(i));
}
combined.append(')');
return combined.toString();
}
}

View File

@@ -1,5 +1,7 @@
package click.kamil.springstatemachineexporter.ast.common;
import click.kamil.springstatemachineexporter.analysis.index.AccessorIndex;
import click.kamil.springstatemachineexporter.analysis.index.AccessorIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
@@ -26,6 +28,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CodebaseContext {
private final Map<String, CompilationUnit> classes = new HashMap<>();
private final Map<String, org.eclipse.jdt.core.dom.RecordDeclaration> recordDeclarations = new HashMap<>();
private final Map<String, Path> classPaths = new HashMap<>();
private final Map<String, String> simpleNameToFqn = new HashMap<>();
private final Set<String> ambiguousSimpleNames = new HashSet<>();
@@ -42,11 +45,25 @@ public class CodebaseContext {
private final ObjectMapper objectMapper = new ObjectMapper();
private Path projectRoot;
private final Map<String, Object> cache = new HashMap<>();
private AccessorIndex accessorIndex = AccessorIndex.empty();
private boolean inlineAccessors = true;
public Map<String, Object> getCache() {
return cache;
}
public AccessorIndex getAccessorIndex() {
return accessorIndex;
}
public boolean isInlineAccessors() {
return inlineAccessors;
}
public void setInlineAccessors(boolean inlineAccessors) {
this.inlineAccessors = inlineAccessors;
}
public void setProjectRoot(Path root) {
this.projectRoot = root;
}
@@ -75,7 +92,31 @@ public class CodebaseContext {
return Collections.unmodifiableList(libraryHints);
}
private final Set<String> ignorePatterns = new HashSet<>(Set.of("**/test/**", "**/build/classes/**", "**/build/libs/**", "**/build/tmp/**", "**/build/reports/**", "**/build/test-results/**", "**/node_modules/**", "**/out/**"));
private final Set<String> ignorePatterns = new HashSet<>(Set.of(
"**/src/test/**",
"**/src/test/java/**",
"**/src/testFixtures/**",
"**/src/test-fixtures/**",
"**/target/classes/**",
"**/target/test-classes/**",
"**/target/generated-test-sources/**",
"**/target/surefire/**",
"**/target/failsafe/**",
"**/target/surefire-reports/**",
"**/target/failsafe-reports/**",
"**/build/classes/**",
"**/build/test-classes/**",
"**/build/generated-test-sources/**",
"**/build/generated/sources/**/test/**",
"**/build/generated/sources/**/testFixtures/**",
"**/build/libs/**",
"**/build/tmp/**",
"**/build/reports/**",
"**/build/test-results/**",
"**/build/kotlin/**",
"**/build/intermediates/**",
"**/node_modules/**",
"**/out/**"));
private String[] classpath = new String[0];
private String[] sourcepath = new String[0];
private boolean resolveBindings = false;
@@ -215,6 +256,16 @@ public class CodebaseContext {
}
}
}
this.accessorIndex = inlineAccessors
? new AccessorIndexBuilder().build(this)
: AccessorIndex.empty();
if (inlineAccessors) {
log.info("Built accessor index: {} trivial accessors across {} types",
accessorIndex.trivialCount(), accessorIndex.typeCount());
} else {
log.info("Accessor inlining disabled; skipping accessor index build.");
}
}
private void indexType(TypeDeclaration td, String parentFqn, Path javaFile) {
@@ -267,6 +318,7 @@ public class CodebaseContext {
// Treat as a class since it is a type
classes.put(fqn, (CompilationUnit) rd.getRoot());
classPaths.put(fqn, javaFile);
recordDeclarations.put(fqn, rd);
if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName);
@@ -595,6 +647,21 @@ public class CodebaseContext {
return null;
}
public org.eclipse.jdt.core.dom.RecordDeclaration getRecordDeclaration(String fqn) {
if (fqn == null || fqn.isEmpty()) {
return null;
}
org.eclipse.jdt.core.dom.RecordDeclaration indexed = recordDeclarations.get(fqn);
if (indexed != null) {
return indexed;
}
AbstractTypeDeclaration abstractType = getAbstractTypeDeclaration(fqn);
if (abstractType instanceof org.eclipse.jdt.core.dom.RecordDeclaration recordDeclaration) {
return recordDeclaration;
}
return null;
}
public AbstractTypeDeclaration getAbstractTypeDeclaration(String name) {
// Try exact FQN match first
CompilationUnit cu = classes.get(name);
@@ -795,8 +862,11 @@ public class CodebaseContext {
Type superclass = td.getSuperclassType();
if (superclass != null) {
String superclassName = getSimpleName(superclass);
if (knownAdapters.contains(superclassName))
if (knownAdapters.contains(superclassName)
|| superclass.toString().contains("StateMachineConfigurerAdapter")
|| superclass.toString().contains("EnumStateMachineConfigurerAdapter")) {
return true;
}
TypeDeclaration superTd = getTypeDeclaration(superclassName, cu);
if (superTd != null && extendsStateMachineConfigurerAdapterLegacy(superTd, visited))
return true;

View File

@@ -1,5 +1,8 @@
package click.kamil.springstatemachineexporter.ast.common;
import click.kamil.springstatemachineexporter.analysis.index.AccessorInlining;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@@ -18,6 +21,10 @@ public class JdtDataFlowModel implements DataFlowModel {
CURRENT_PATH.set(path);
}
public static List<String> getCurrentPath() {
return CURRENT_PATH.get();
}
public static void clearCurrentPath() {
CURRENT_PATH.remove();
}
@@ -56,7 +63,7 @@ public class JdtDataFlowModel implements DataFlowModel {
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (expr == null || depth > 50 || !visited.add(expr)) {
if (expr == null || ResolutionBudget.defaults().isDataflowExhausted(depth) || !visited.add(expr)) {
return List.of();
}
@@ -195,6 +202,14 @@ public class JdtDataFlowModel implements DataFlowModel {
visited.remove(expr);
return resolved;
}
List<Expression> inlined = tryInlineAccessorInvocation(
mi, new HashSet<>(visited), paramBindings, instanceFieldBindings, depth);
if (!inlined.isEmpty()) {
visited.remove(expr);
return inlined;
}
IMethodBinding mb = mi.resolveMethodBinding();
if (mb != null) {
List<TargetMethod> targets = resolveTargets(mi, mb, visited, paramBindings, instanceFieldBindings, depth);
@@ -255,6 +270,19 @@ public class JdtDataFlowModel implements DataFlowModel {
// Handle Super Method Invocations
if (expr instanceof SuperMethodInvocation smi) {
IMethodBinding mb = smi.resolveMethodBinding();
if (mb != null && mb.getDeclaringClass() != null) {
String superFqn = mb.getDeclaringClass().getErasure().getQualifiedName();
String methodName = smi.getName().getIdentifier();
Optional<AccessorSummary> accessor = context.getAccessorIndex().lookup(superFqn, methodName);
if (accessor.isPresent() && accessor.get().isGetter()) {
Expression fieldValue = readFieldValue(accessor.get(), instanceFieldBindings, smi);
if (fieldValue != null) {
visited.remove(expr);
return getReachingDefinitions(
fieldValue, visited, paramBindings, instanceFieldBindings, depth + 1);
}
}
}
if (mb != null) {
MethodDeclaration md = findMethodDeclaration(mb);
if (md != null && md.getBody() != null) {
@@ -289,6 +317,457 @@ public class JdtDataFlowModel implements DataFlowModel {
return List.of(expr);
}
private List<Expression> tryInlineAccessorInvocation(
MethodInvocation mi,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (!context.isInlineAccessors()) {
return List.of();
}
if (ResolutionBudget.defaults().isAccessorInlineExhausted(depth)) {
return List.of();
}
String methodName = mi.getName().getIdentifier();
IMethodBinding methodBinding = mi.resolveMethodBinding();
if (methodBinding == null || methodBinding.getDeclaringClass() == null) {
return List.of();
}
String declaringFqn = methodBinding.getDeclaringClass().getErasure().getQualifiedName();
Optional<AccessorSummary> accessor = context.getAccessorIndex().lookup(declaringFqn, methodName);
if (accessor.isEmpty() && !looksLikeIndexedAccessorName(methodName)) {
return List.of();
}
List<Expression> receiverDefs = List.of();
if (accessor.isEmpty()) {
if (!methodBinding.getDeclaringClass().isInterface()) {
return List.of();
}
Expression receiver = mi.getExpression();
if (receiver == null) {
return List.of();
}
receiverDefs = getReachingDefinitions(receiver, visited, paramBindings, instanceFieldBindings, depth + 1);
accessor = AccessorInlining.lookupAccessor(context, mi, receiverDefs);
if (accessor.isEmpty()) {
return List.of();
}
}
if (receiverDefs.isEmpty() && mi.getExpression() != null) {
receiverDefs = getReachingDefinitions(
mi.getExpression(), visited, paramBindings, instanceFieldBindings, depth + 1);
}
if (accessor.isPresent()) {
String accessorOwner = accessor.get().ownerFqn();
if (accessorOwner.equals(declaringFqn)) {
Optional<AccessorSummary> concreteAccessor =
lookupConcreteAccessorOverride(declaringFqn, methodName, receiverDefs);
if (concreteAccessor.isPresent()) {
accessor = concreteAccessor;
} else if (shouldSkipAccessorDueToConcreteOverride(declaringFqn, methodName, receiverDefs)) {
return List.of();
}
}
}
if (accessor.isEmpty()) {
return List.of();
}
if (!accessor.get().isGetter()) {
return List.of();
}
if (accessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER) {
return inlineConstantGetter(accessor.get(), visited, paramBindings, instanceFieldBindings, depth);
}
if (accessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.RECORD_COMPONENT) {
return inlineRecordComponentRead(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
}
return inlineAccessorGetter(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
}
private List<Expression> inlineConstantGetter(
AccessorSummary accessor,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(accessor.ownerFqn());
if (typeDeclaration == null) {
return List.of();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, accessor.methodName(), true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
return List.of();
}
List<Expression> results = new ArrayList<>();
for (Object statementObj : methodDeclaration.getBody().statements()) {
if (statementObj instanceof ReturnStatement returnStatement && returnStatement.getExpression() != null) {
results.addAll(getReachingDefinitions(
returnStatement.getExpression(),
visited,
paramBindings,
instanceFieldBindings,
depth + 1));
}
}
return results;
}
private Optional<AccessorSummary> lookupConcreteAccessorOverride(
String declaringFqn,
String methodName,
List<Expression> receiverDefs) {
for (Expression receiverDef : receiverDefs) {
String concreteFqn = concreteReceiverTypeFqn(receiverDef);
if (concreteFqn == null || concreteFqn.equals(declaringFqn)) {
continue;
}
Optional<AccessorSummary> concreteAccessor = context.getAccessorIndex().lookup(concreteFqn, methodName);
if (concreteAccessor.isPresent() && concreteAccessor.get().isGetter()) {
return concreteAccessor;
}
}
return Optional.empty();
}
private boolean shouldSkipAccessorDueToConcreteOverride(
String declaringFqn,
String methodName,
List<Expression> receiverDefs) {
for (Expression receiverDef : receiverDefs) {
String concreteFqn = concreteReceiverTypeFqn(receiverDef);
if (concreteFqn == null || concreteFqn.equals(declaringFqn)) {
continue;
}
TypeDeclaration concreteType = context.getTypeDeclaration(concreteFqn);
if (concreteType != null && context.hasMethod(concreteType, methodName)) {
return true;
}
}
return false;
}
private String concreteReceiverTypeFqn(Expression receiverDef) {
if (receiverDef instanceof ClassInstanceCreation cic) {
return AccessorInlining.resolveTypeFqn(cic);
}
ITypeBinding typeBinding = receiverDef.resolveTypeBinding();
if (typeBinding != null) {
return typeBinding.getErasure().getQualifiedName();
}
return null;
}
private boolean looksLikeIndexedAccessorName(String methodName) {
return (methodName.startsWith("get") && methodName.length() > 3)
|| (methodName.startsWith("set") && methodName.length() > 3)
|| (methodName.startsWith("is") && methodName.length() > 2);
}
private List<Expression> inlineRecordComponentRead(
MethodInvocation mi,
AccessorSummary accessor,
List<Expression> receiverDefs,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
Expression receiver = mi.getExpression();
List<Expression> candidates = receiverDefs.isEmpty() && receiver != null ? List.of(receiver) : receiverDefs;
List<Expression> results = new ArrayList<>();
for (Expression candidate : candidates) {
if (candidate instanceof ClassInstanceCreation cic) {
Expression componentValue = readRecordComponentValue(cic, accessor.fieldName());
if (componentValue != null) {
results.addAll(getReachingDefinitions(componentValue, visited, paramBindings, instanceFieldBindings, depth + 1));
}
continue;
}
if (candidate instanceof MethodInvocation receiverCall) {
List<Expression> nested = tryInlineAccessorInvocation(
receiverCall, new HashSet<>(visited), paramBindings, instanceFieldBindings, depth + 1);
for (Expression nestedValue : nested) {
if (nestedValue instanceof ClassInstanceCreation cic) {
Expression componentValue = readRecordComponentValue(cic, accessor.fieldName());
if (componentValue != null) {
results.addAll(getReachingDefinitions(
componentValue, visited, paramBindings, instanceFieldBindings, depth + 1));
}
} else {
results.addAll(getReachingDefinitions(nestedValue, visited, paramBindings, instanceFieldBindings, depth + 1));
}
}
}
}
return results;
}
private Expression readRecordComponentValue(ClassInstanceCreation cic, String componentName) {
org.eclipse.jdt.core.dom.RecordDeclaration record = findRecordDeclaration(AccessorInlining.resolveTypeFqn(cic));
if (record == null) {
return null;
}
int componentIndex = -1;
List<?> components = record.recordComponents();
for (int i = 0; i < components.size(); i++) {
Object componentObj = components.get(i);
String name = recordComponentName(componentObj);
if (componentName.equals(name)) {
componentIndex = i;
break;
}
}
if (componentIndex < 0 || componentIndex >= cic.arguments().size()) {
return null;
}
return (Expression) cic.arguments().get(componentIndex);
}
private String recordComponentName(Object componentObj) {
if (componentObj instanceof SingleVariableDeclaration svd) {
return svd.getName().getIdentifier();
}
try {
java.lang.reflect.Method getNameMethod = componentObj.getClass().getMethod("getName");
Object nameObj = getNameMethod.invoke(componentObj);
if (nameObj instanceof SimpleName sn) {
return sn.getIdentifier();
}
} catch (ReflectiveOperationException ignored) {
// fall through
}
return null;
}
private List<Expression> inlineAccessorGetter(
MethodInvocation mi,
AccessorSummary accessor,
List<Expression> receiverDefs,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
Expression receiver = mi.getExpression();
List<Expression> results = new ArrayList<>();
if (receiver == null || receiver instanceof ThisExpression) {
Expression fieldValue = readFieldValue(accessor, instanceFieldBindings, mi);
if (fieldValue != null) {
results.addAll(getReachingDefinitions(fieldValue, visited, paramBindings, instanceFieldBindings, depth + 1));
}
return results;
}
List<Expression> candidates = receiverDefs.isEmpty() ? List.of(receiver) : receiverDefs;
for (Expression candidate : candidates) {
if (candidate instanceof ClassInstanceCreation cic) {
Map<IVariableBinding, Expression> fieldBindings =
getOrCreateFieldBindings(cic, paramBindings, instanceFieldBindings);
if (receiver instanceof SimpleName sn) {
applyFlowSensitiveMutationsBeforeUse(sn, mi, cic, fieldBindings, paramBindings, visited, depth);
} else if (receiver instanceof FieldAccess fa) {
applyFlowSensitiveFieldMutationsBeforeUse(fa, mi, fieldBindings, paramBindings, visited, depth);
}
Expression fieldValue = readFieldValue(accessor, fieldBindings, mi);
if (fieldValue != null) {
results.addAll(getReachingDefinitions(fieldValue, visited, paramBindings, fieldBindings, depth + 1));
}
}
}
return results;
}
private void applyFlowSensitiveMutationsBeforeUse(
SimpleName receiverName,
MethodInvocation useSite,
ClassInstanceCreation creationSite,
Map<IVariableBinding, Expression> fieldBindings,
Map<IVariableBinding, Expression> paramBindings,
Set<ASTNode> visited,
int depth) {
MethodDeclaration enclosingMethod = findEnclosingMethod(useSite);
if (enclosingMethod == null) {
return;
}
ReachingDefinitions rd = getReachingDefinitionsForMethod(enclosingMethod);
if (rd == null) {
return;
}
Set<ASTNode> defs = rd.getReachingDefinitions(receiverName, receiverName.getIdentifier());
for (ASTNode def : defs) {
Expression defExpr = ReachingDefinitions.getDefinitionExpression(def);
if (defExpr == creationSite || isExpressionWrapping(defExpr, creationSite)) {
applyIntermediateMutations(receiverName, def, useSite, enclosingMethod, fieldBindings, paramBindings, visited, depth);
}
}
}
private void applyFlowSensitiveFieldMutationsBeforeUse(
FieldAccess fieldReceiver,
MethodInvocation useSite,
Map<IVariableBinding, Expression> fieldBindings,
Map<IVariableBinding, Expression> paramBindings,
Set<ASTNode> visited,
int depth) {
Expression qualifier = fieldReceiver.getExpression();
if (qualifier != null && !(qualifier instanceof ThisExpression)) {
return;
}
MethodDeclaration enclosingMethod = findEnclosingMethod(useSite);
if (enclosingMethod == null || enclosingMethod.getBody() == null) {
return;
}
int useStart = useSite.getStartPosition();
String targetFieldName = fieldReceiver.getName().getIdentifier();
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (node.getStartPosition() >= useStart) {
return false;
}
if (!isThisFieldAccess(node.getExpression(), targetFieldName)) {
return super.visit(node);
}
List<Expression> receiverDefs = getReachingDefinitions(
node.getExpression(), visited, paramBindings, fieldBindings, depth + 1);
Optional<AccessorSummary> accessor = AccessorInlining.lookupAccessor(context, node, receiverDefs);
if (accessor.isPresent() && accessor.get().isSetter()) {
inlineAccessorSetter(node, accessor.get(), fieldBindings, paramBindings, visited, depth);
return super.visit(node);
}
IMethodBinding mb = node.resolveMethodBinding();
if (mb != null) {
List<TargetMethod> targets = resolveTargets(node, mb, visited, paramBindings, fieldBindings, depth + 1);
for (TargetMethod target : targets) {
if (target.declaration != null) {
evaluateMethodMutations(target.declaration, node.arguments(), fieldBindings);
}
}
}
return super.visit(node);
}
});
}
private boolean isThisFieldAccess(Expression expr, String fieldName) {
if (!(expr instanceof FieldAccess fa)) {
return false;
}
Expression receiver = fa.getExpression();
return fa.getName().getIdentifier().equals(fieldName)
&& (receiver == null || receiver instanceof ThisExpression);
}
private Expression readFieldValue(
AccessorSummary accessor,
Map<IVariableBinding, Expression> fieldBindings,
ASTNode contextNode) {
IVariableBinding fieldBinding = findFieldVariableBinding(accessor.declaringFqn(), accessor.fieldName(), contextNode);
if (fieldBinding != null && fieldBindings.containsKey(fieldBinding)) {
return fieldBindings.get(fieldBinding);
}
if (fieldBinding != null) {
return findFieldInitializer(fieldBinding, contextNode);
}
return null;
}
private IVariableBinding findFieldVariableBinding(String declaringFqn, String fieldName, ASTNode contextNode) {
if (declaringFqn == null || fieldName == null) {
return null;
}
TypeDeclaration typeDeclaration = context.getTypeDeclaration(declaringFqn);
if (typeDeclaration != null) {
IVariableBinding binding = findFieldOnType(typeDeclaration, fieldName, new HashSet<>());
if (binding != null) {
return binding;
}
}
org.eclipse.jdt.core.dom.RecordDeclaration recordDeclaration = findRecordDeclaration(declaringFqn);
if (recordDeclaration != null) {
for (Object componentObj : recordDeclaration.recordComponents()) {
if (componentObj instanceof SingleVariableDeclaration component
&& component.getName().getIdentifier().equals(fieldName)) {
return component.resolveBinding();
}
}
}
return null;
}
private IVariableBinding findFieldOnType(TypeDeclaration typeDeclaration, String fieldName, Set<String> visitedTypes) {
if (typeDeclaration == null) {
return null;
}
String typeFqn = context.getFqn(typeDeclaration);
if (!visitedTypes.add(typeFqn)) {
return null;
}
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
for (Object fragmentObj : fieldDeclaration.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(fieldName)) {
IVariableBinding binding = fragment.resolveBinding();
if (binding != null) {
return binding;
}
}
}
}
String superFqn = context.getSuperclassFqn(typeDeclaration);
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
if (superType != null) {
return findFieldOnType(superType, fieldName, visitedTypes);
}
}
return null;
}
private org.eclipse.jdt.core.dom.RecordDeclaration findRecordDeclaration(String fqn) {
return context.getRecordDeclaration(fqn);
}
private void inlineAccessorSetter(
MethodInvocation mi,
AccessorSummary accessor,
Map<IVariableBinding, Expression> fieldBindings,
Map<IVariableBinding, Expression> paramBindings,
Set<ASTNode> visited,
int depth) {
if (mi.arguments().isEmpty()) {
return;
}
Expression argument = (Expression) mi.arguments().get(0);
List<Expression> resolvedArgument = getReachingDefinitions(argument, visited, paramBindings, fieldBindings, depth + 1);
Expression value = resolvedArgument.isEmpty() ? argument : resolvedArgument.get(0);
IVariableBinding fieldBinding = findFieldVariableBinding(accessor.declaringFqn(), accessor.fieldName(), mi);
if (fieldBinding != null) {
fieldBindings.put(fieldBinding, value);
}
}
private IVariableBinding getVariableBinding(Expression expr) {
if (expr instanceof SimpleName sn) {
IBinding b = sn.resolveBinding();
@@ -423,7 +902,7 @@ public class JdtDataFlowModel implements DataFlowModel {
Map<IVariableBinding, Expression> callerParamValues,
Map<IVariableBinding, Expression> fieldBindings,
int depth) {
if (cb == null || depth > 10) return;
if (cb == null || ResolutionBudget.defaults().isConstructorExhausted(depth)) return;
MethodDeclaration cd = findMethodDeclaration(cb);
if (cd == null || cd.getBody() == null) return;
@@ -700,6 +1179,22 @@ public class JdtDataFlowModel implements DataFlowModel {
Map<IVariableBinding, Expression> fieldBindings) {
if (md == null || md.getBody() == null) return;
ASTNode parent = md.getParent();
if (parent instanceof AbstractTypeDeclaration typeDeclaration) {
String ownerFqn = context.getFqn(typeDeclaration);
Optional<AccessorSummary> accessor = context.getAccessorIndex().lookup(ownerFqn, md.getName().getIdentifier());
if (accessor.isPresent() && accessor.get().isSetter() && !arguments.isEmpty()) {
IVariableBinding fieldBinding = findFieldVariableBinding(
accessor.get().declaringFqn(),
accessor.get().fieldName(),
md);
if (fieldBinding != null) {
fieldBindings.put(fieldBinding, (Expression) arguments.get(0));
return;
}
}
}
Map<IVariableBinding, Expression> currentParamValues = new HashMap<>();
List<?> parameters = md.parameters();
int count = Math.min(parameters.size(), arguments.size());
@@ -786,6 +1281,14 @@ public class JdtDataFlowModel implements DataFlowModel {
public boolean visit(MethodInvocation mi) {
Expression receiver = mi.getExpression();
if (receiver != null && isTargetVariable(receiver, targetVar, targetName)) {
List<Expression> receiverDefs = getReachingDefinitions(
receiver, visited, paramBindings, fieldBindings, depth + 1);
Optional<AccessorSummary> accessor = AccessorInlining.lookupAccessor(context, mi, receiverDefs);
if (accessor.isPresent() && accessor.get().isSetter()) {
inlineAccessorSetter(mi, accessor.get(), fieldBindings, paramBindings, visited, depth);
return super.visit(mi);
}
IMethodBinding mb = mi.resolveMethodBinding();
if (mb != null) {
List<TargetMethod> targets = resolveTargets(mi, mb, visited, paramBindings, fieldBindings, depth + 1);

View File

@@ -69,17 +69,26 @@ public class StateResolver {
}
private String extractAnnotationValue(Annotation ann) {
String raw;
if (ann instanceof SingleMemberAnnotation sma) {
return stripQuotes(sma.getValue().toString());
raw = stripQuotes(sma.getValue().toString());
} else if (ann instanceof NormalAnnotation na) {
raw = null;
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if ("value".equals(pair.getName().getIdentifier())) {
return stripQuotes(pair.getValue().toString());
raw = stripQuotes(pair.getValue().toString());
break;
}
}
} else {
return null;
}
return null;
if (raw != null && raw.contains("${")) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
raw, java.util.Collections.emptyMap());
}
return raw;
}
private String stripQuotes(String s) {

View File

@@ -30,7 +30,7 @@ public class ExporterCommand implements Callable<Integer> {
@CommandLine.Spec
CommandLine.Model.CommandSpec spec;
@Option(names = {"-i", "--input"}, description = "Input directory containing the Spring Boot project source code.")
@Option(names = {"-i", "--input"}, description = "Input directory containing the Spring Boot project source code. With -j, optionally rescans sources to resolve machine enum types.")
private Path inputDir;
@Option(names = {"-j", "--json"}, description = "Input JSON file containing the state machine definition.")
@@ -57,8 +57,11 @@ public class ExporterCommand implements Callable<Integer> {
@Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false")
private boolean debug;
@Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "false")
private boolean resolveClasspath;
@Option(names = {"--include-generated-sources"}, description = "Scan existing Maven target/ and Gradle build/ trees for pre-generated .java sources (MapStruct, annotation processors, etc.). Does not run Maven or Gradle.", negatable = true, defaultValue = "true", fallbackValue = "true")
private boolean includeGeneratedSources = true;
@Option(names = {"--inline-accessors"}, description = "Index trivial JavaBean/record accessors at scan time and inline them during call-chain and constant resolution.", negatable = true, defaultValue = "true", fallbackValue = "true")
private boolean inlineAccessors = true;
@Override
public Integer call() throws Exception {
@@ -88,9 +91,9 @@ public class ExporterCommand implements Callable<Integer> {
try {
List<String> profiles = activeProfiles != null ? activeProfiles : java.util.Collections.emptyList();
if (jsonFile != null) {
exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat);
exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat, inputDir);
} else {
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, resolveClasspath);
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, includeGeneratedSources, inlineAccessors);
}
out.println(CommandLine.Help.Ansi.AUTO.string("%n@|bold,green SUCCESS:|@ All state machines have been exported to " + outputDir.toAbsolutePath()));
return 0;

View File

@@ -20,15 +20,9 @@ public class JsonExporter implements StateMachineExporter {
@Override
public String export(click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result, ExportOptions options) {
try {
java.util.Map<String, Object> output = new java.util.HashMap<>();
output.put("name", result.getName());
output.put("transitions", result.getTransitions());
output.put("startStates", result.getStartStates());
output.put("endStates", result.getEndStates());
output.put("renderChoicesAsDiamonds", options.isRenderChoicesAsDiamonds());
output.put("metadata", result.getMetadata());
return objectMapper.writeValueAsString(output);
// Full AnalysisResult serialization: self-contained interchange format for downstream
// exporters (HTML, etc.) without requiring source code re-analysis.
return objectMapper.writeValueAsString(result);
} catch (Exception e) {
throw new RuntimeException("Failed to export to JSON", e);
}

View File

@@ -1,14 +1,14 @@
package click.kamil.springstatemachineexporter.service;
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.BusinessFlow;
import click.kamil.springstatemachineexporter.analysis.service.AnalysisResultFinalizer;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.ToolingClasspath;
import click.kamil.springstatemachineexporter.ast.app.AstTransitionParser;
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
import click.kamil.springstatemachineexporter.ast.app.TransitionStateUtils;
@@ -40,43 +40,48 @@ public class ExportService {
}
public ExportService(List<StateMachineExporter> exporters) {
this(exporters, new EnrichmentService(List.of(
new TriggerEnricher(),
new EntryPointEnricher(),
new PropertyEnricher(),
new CallChainEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.path.ForwardPathEstimationEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher()
)));
this(exporters, EnrichmentService.createDefault());
}
private static final List<String> TARGET_ANNOTATIONS = List.of("EnableStateMachineFactory", "EnableStateMachine");
public static final Set<String> STATE_MACHINE_RETURN_TYPES = Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory");
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, eventFormat, stateFormat, false);
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, eventFormat, stateFormat, true);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, boolean resolveClasspath) throws IOException {
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, eventFormat, stateFormat, true);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, boolean includeGeneratedSources) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, eventFormat, stateFormat, includeGeneratedSources, true);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, boolean includeGeneratedSources, boolean inlineAccessors) throws IOException {
CodebaseContext context = new CodebaseContext();
context.setActiveProfiles(activeProfiles);
context.setInlineAccessors(inlineAccessors);
click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver siblingResolver = new click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver();
Set<Path> allPaths = new HashSet<>();
allPaths.add(inputDir);
allPaths.addAll(siblingResolver.resolveSiblings(inputDir));
click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver siblingResolver =
new click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver();
click.kamil.springstatemachineexporter.analysis.resolver.ProjectModuleGraph projectGraph =
siblingResolver.analyzeProject(inputDir);
Set<Path> allPaths = projectGraph.resolveScanPaths(inputDir, includeGeneratedSources);
log.info("Static module graph discovered {} scan roots from {} (includeGeneratedSources={})",
allPaths.size(), inputDir, includeGeneratedSources);
// Find project root (common ancestor)
Path projectRoot = inputDir.toAbsolutePath();
@@ -90,23 +95,28 @@ public class ExportService {
log.info("Project root resolved to: {}", projectRoot);
List<String> sourcepaths = allPaths.stream()
.map(p -> p.resolve("src/main/java").toString())
.filter(p -> Files.exists(Path.of(p)))
.flatMap(p -> {
List<Path> roots = new ArrayList<>();
Path mainJava = p.resolve("src/main/java");
if (Files.exists(mainJava)) {
roots.add(mainJava);
}
if (includeGeneratedSources) {
roots.addAll(new click.kamil.springstatemachineexporter.analysis.resolver.GeneratedSourceDiscovery()
.discoverSourceRoots(p));
}
return roots.stream();
})
.map(Path::toString)
.distinct()
.collect(Collectors.toList());
log.info("Setting JDT sourcepath: {}", sourcepaths);
context.setSourcepath(sourcepaths);
if (resolveClasspath) {
click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver classpathResolver = new click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver();
List<String> dynamicClasspath = classpathResolver.resolveClasspath(projectRoot);
if (!dynamicClasspath.isEmpty()) {
context.setClasspath(dynamicClasspath);
log.info("Injected {} external JARs into JDT ASTParser", dynamicClasspath.size());
}
} else {
log.info("Dynamic classpath resolution disabled. Use --resolve-classpath to enable.");
List<String> toolingClasspath = ToolingClasspath.currentJvmJarEntries();
if (!toolingClasspath.isEmpty()) {
context.setClasspath(toolingClasspath);
log.info("Using {} tooling JARs for library type bindings (target project build is not executed).", toolingClasspath.size());
}
context.setResolveBindings(true);
Path hintsFile = inputDir.resolve("hints.json");
@@ -150,12 +160,39 @@ public class ExportService {
}
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
runJsonExporter(jsonFile, outputDir, selectedFormats, activeProfiles, eventFormat, stateFormat, null);
}
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, Path optionalSourceDir) throws IOException {
JsonImportService jsonImportService = new JsonImportService();
AnalysisResult result = jsonImportService.importAnalysisResult(jsonFile);
// Apply property resolution pass if placeholders exist
resolveProperties(result, activeProfiles);
CodebaseContext context = null;
Path sourceRoot = optionalSourceDir;
if (sourceRoot == null) {
sourceRoot = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
.findProjectRoot(jsonFile.toAbsolutePath().getParent());
}
if (sourceRoot != null) {
log.info("JSON re-export: scanning source project at {}", sourceRoot);
context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
.scanProjectRoot(sourceRoot, activeProfiles);
} else {
log.info("JSON re-export: no source project found; using embedded machine types if present");
}
enrichmentService.relinkAfterPropertyResolution(result, context, null);
StateMachineTypeResolver.MachineTypes machineTypes =
click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory.resolveMachineTypes(
result.getName(),
result.getStateTypeFqn(),
result.getEventTypeFqn(),
context);
AnalysisResultFinalizer.finalizeResult(result, context, machineTypes);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
}
@@ -215,16 +252,23 @@ public class ExportService {
StateMachineAggregator aggregator = new StateMachineAggregator(context);
List<Transition> transitions = aggregator.aggregateTransitions(td);
StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.resolveTypes(className, context);
MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes);
aggregator.aggregateStates(td);
Set<String> initialStatesAst = aggregator.getInitialStates();
Set<String> endStatesAst = aggregator.getEndStates();
Set<String> initialStatesAst = MachineEnumCanonicalizer.canonicalizeStateLabels(
aggregator.getInitialStates(), machineTypes.stateTypeFqn());
Set<String> endStatesAst = MachineEnumCanonicalizer.canonicalizeStateLabels(
aggregator.getEndStates(), machineTypes.stateTypeFqn());
log.debug("Start States Ast: {}", initialStatesAst);
log.debug("End States Ast: {}", endStatesAst);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions, initialStatesAst);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst);
Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst);
Set<click.kamil.springstatemachineexporter.model.State> allStates = MachineEnumCanonicalizer.canonicalizeStates(
TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst),
machineTypes.stateTypeFqn());
if (allStates.isEmpty() && transitions.isEmpty()) {
log.info("Skipping empty state machine config: {}", className);
@@ -241,9 +285,10 @@ public class ExportService {
.flows(flows)
.build();
enrichmentService.enrich(result, context, intelligence);
enrichmentService.enrichPreProperty(result, context, intelligence);
resolveProperties(result, activeProfiles);
enrichmentService.enrichPostProperty(result, context, intelligence);
AnalysisResultFinalizer.finalizeResult(result, context);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
}
@@ -258,9 +303,14 @@ public class ExportService {
List<Transition> transitions = AstTransitionParser.parseTransitions(m, context);
StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.resolveTypes(parentFqn, context);
MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions, null);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, null);
Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, null, null);
Set<click.kamil.springstatemachineexporter.model.State> allStates = MachineEnumCanonicalizer.canonicalizeStates(
TransitionStateUtils.findAllStates(transitions, null, null),
machineTypes.stateTypeFqn());
if (allStates.isEmpty() && transitions.isEmpty()) {
log.info("Skipping empty state machine bean: {}", uniqueName);
@@ -277,9 +327,10 @@ public class ExportService {
.flows(flows)
.build();
enrichmentService.enrich(result, context, intelligence);
enrichmentService.enrichPreProperty(result, context, intelligence);
resolveProperties(result, activeProfiles);
enrichmentService.enrichPostProperty(result, context, intelligence);
AnalysisResultFinalizer.finalizeResult(result, context);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
}

View File

@@ -0,0 +1,36 @@
package click.kamil.springstatemachineexporter;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.service.ExportService;
import click.kamil.springstatemachineexporter.service.JsonImportService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class EnterpriseInitialStateExportTest {
@Test
void shouldExportCanonicalInitialStateForValuePlaceholder(@TempDir Path outputDir) throws Exception {
Path inputDir = Path.of("../state_machines/enterprise_order_system").toAbsolutePath().normalize();
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runExporter(inputDir, outputDir, List.of("json"), true, List.of());
Path jsonFile = outputDir.resolve("click.kamil.examples.enterprise.config.EnterpriseStateMachineConfig")
.resolve("click.kamil.examples.enterprise.config.EnterpriseStateMachineConfig.json");
var result = new JsonImportService().importAnalysisResult(jsonFile);
assertThat(result.getStateTypeFqn()).isEqualTo("String");
assertThat(result.getStartStates()).containsExactly("String.NEW");
assertThat(result.getStates())
.anyMatch(s -> "String.NEW".equals(s.fullIdentifier())
&& !s.rawName().contains("${"));
assertThat(result.getStates())
.noneMatch(s -> s.rawName().contains("${order.initial.state"));
}
}

View File

@@ -0,0 +1,50 @@
package click.kamil.springstatemachineexporter;
import org.junit.jupiter.api.Test;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Runs the golden JSON consistency audit script as a regression gate.
*/
class GoldenJsonAuditTest {
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
while (current != null && !current.resolve("settings.gradle").toFile().exists()) {
current = current.getParent();
}
return current;
}
@Test
void goldenJsonExportsShouldPassConsistencyAudit() throws Exception {
Path moduleRoot = findProjectRoot().resolve("state_machine_exporter");
Path script = moduleRoot.resolve("scripts/audit_golden_json.py");
assertThat(script).exists();
Process process = new ProcessBuilder("python3", script.toString())
.directory(moduleRoot.toFile())
.redirectErrorStream(true)
.start();
String output;
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
output = reader.lines().collect(Collectors.joining("\n"));
}
assertThat(process.waitFor(60, TimeUnit.SECONDS))
.as("audit script timed out\n%s", output)
.isTrue();
assertThat(process.exitValue())
.as("audit script failed\n%s", output)
.isZero();
assertThat(output).contains("Total BUG+WARN: 0");
}
}

View File

@@ -1,6 +1,5 @@
package click.kamil.springstatemachineexporter;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.exporter.Dot;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.exporter.PlantUml;
@@ -17,7 +16,6 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
public class GoldenUpdater {
@@ -33,7 +31,16 @@ public class GoldenUpdater {
System.out.println("Updating golden files for: " + scenario.name());
Path tempDir = Files.createTempDirectory("golden-update-");
try {
exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml", "dot", "scxml", "json"), true);
exportService.runExporter(
scenario.inputPath(),
tempDir,
List.of("puml", "dot", "scxml", "json"),
true,
scenario.activeProfiles(),
null,
null,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
List<Path> generatedDirs;
try (var stream = Files.list(tempDir)) {
@@ -159,12 +166,73 @@ public class GoldenUpdater {
Path.of("src/test/resources/golden/G2StateMachineConfiguration"),
"G2StateMachineConfiguration"
),
new TestScenario(
"Extended Analysis Sample",
Path.of("state_machines/extended_analysis_sample"),
Path.of("src/test/resources/golden/ExtendedStateMachineConfig"),
"ExtendedStateMachineConfig"
),
new TestScenario(
"Extended Analysis Sample (PROD)",
Path.of("state_machines/extended_analysis_sample"),
Path.of("src/test/resources/golden/ExtendedStateMachineConfig_PROD"),
"ExtendedStateMachineConfig",
List.of("prod")
),
new TestScenario(
"Inheritance Sample",
Path.of("state_machines/inheritance_sample"),
Path.of("src/test/resources/golden/InheritanceStateMachineConfig"),
"InheritanceStateMachineConfig"
),
new TestScenario(
"Enterprise Order System",
Path.of("state_machines/enterprise_order_system"),
Path.of("src/test/resources/golden/EnterpriseStateMachineConfig"),
"EnterpriseStateMachineConfig"
),
new TestScenario(
"Enterprise Order State Machine",
Path.of("state_machines/state_machine_enterprise"),
Path.of("src/test/resources/golden/OrderStateMachineConfiguration"),
"OrderStateMachineConfiguration"
),
new TestScenario(
"Multi-Module Sample",
Path.of("state_machines/multi_module_sample/core-module"),
Path.of("src/test/resources/golden/OrderStateMachineConfig"),
"OrderStateMachineConfig"
),
new TestScenario(
"Maven Multi-Module Sample",
Path.of("state_machines/maven_multi_module/core-module"),
Path.of("src/test/resources/golden/MavenOrderStateMachine"),
"MavenOrderStateMachine"
),
new TestScenario(
"Complex Multi-Module Sample",
Path.of("state_machines/complex_multi_module_sm"),
Path.of("src/test/resources/golden/StateMachineConfig"),
"StateMachineConfig"
),
new TestScenario(
"Polymorphic Events Sample",
Path.of("state_machines/polymorphic_events_sample"),
Path.of("src/test/resources/golden/PolymorphicStateMachineConfiguration"),
"PolymorphicStateMachineConfiguration"
),
new TestScenario(
"Layered Dispatcher (Order)",
Path.of("state_machines/layered_dispatcher_sample"),
Path.of("src/test/resources/golden/StandardOrderStateMachineConfiguration"),
"StandardOrderStateMachineConfiguration"
),
new TestScenario(
"Layered Dispatcher (Document)",
Path.of("state_machines/layered_dispatcher_sample"),
Path.of("src/test/resources/golden/StandardDocumentStateMachineConfiguration"),
"StandardDocumentStateMachineConfiguration"
),
new TestScenario(
"Enterprise Document State Machine",
Path.of("state_machines/state_machine_enterprise"),

View File

@@ -1,5 +1,7 @@
package click.kamil.springstatemachineexporter;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.exporter.Dot;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
@@ -21,8 +23,12 @@ import static org.assertj.core.api.Assertions.assertThat;
public class RegressionTest {
private final List<StateMachineExporter> exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
private final ExportService exportService = new ExportService(exporters);
private static final List<StateMachineExporter> EXPORTERS =
List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
private ExportService exportService() {
return new ExportService(EXPORTERS);
}
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
@@ -126,6 +132,12 @@ public class RegressionTest {
Path.of("src/test/resources/golden/EnterpriseStateMachineConfig"),
"EnterpriseStateMachineConfig"
),
new TestScenario(
"Enterprise Factory Order Machine",
root.resolve("state_machines/state_machine_enterprise"),
Path.of("src/test/resources/golden/OrderStateMachineConfiguration"),
"OrderStateMachineConfiguration"
),
new TestScenario(
"Multi-Module Sample",
root.resolve("state_machines/multi_module_sample/core-module"),
@@ -149,19 +161,44 @@ public class RegressionTest {
root.resolve("state_machines/polymorphic_events_sample"),
Path.of("src/test/resources/golden/PolymorphicStateMachineConfiguration"),
"PolymorphicStateMachineConfiguration"
),
new TestScenario(
"Layered Dispatcher (Order)",
root.resolve("state_machines/layered_dispatcher_sample"),
Path.of("src/test/resources/golden/StandardOrderStateMachineConfiguration"),
"StandardOrderStateMachineConfiguration"
),
new TestScenario(
"Layered Dispatcher (Document)",
root.resolve("state_machines/layered_dispatcher_sample"),
Path.of("src/test/resources/golden/StandardDocumentStateMachineConfiguration"),
"StandardDocumentStateMachineConfiguration"
),
new TestScenario(
"Enterprise Document State Machine",
root.resolve("state_machines/state_machine_enterprise"),
Path.of("src/test/resources/golden/DocumentStateMachineConfiguration"),
"DocumentStateMachineConfiguration"
),
new TestScenario(
"Enterprise User State Machine",
root.resolve("state_machines/state_machine_enterprise"),
Path.of("src/test/resources/golden/UserStateMachineConfiguration"),
"UserStateMachineConfiguration"
)
);
}
@ParameterizedTest(name = "{0}")
@MethodSource("provideTestScenarios")
@Execution(ExecutionMode.CONCURRENT)
void testOutputMatchesGolden(TestScenario scenario, @TempDir Path tempDir) throws Exception {
System.out.println("Running test for " + scenario.name());
if (exportService == null) System.out.println("exportService is NULL");
ExportService exportService = exportService();
if (scenario.inputPath() == null) System.out.println("inputPath is NULL");
if (tempDir == null) System.out.println("tempDir is NULL");
exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml", "dot", "scxml", "json"), true, scenario.activeProfiles(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml", "dot", "scxml", "json"), true, scenario.activeProfiles(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
// Find the generated directory (it might be named with FQN)
List<Path> generatedDirs;

View File

@@ -0,0 +1,48 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class MachineScopeFilterEntryPointTest {
@Test
void shouldKeepOrderEndpointsOnOrderMachineOnly() {
EntryPoint orderPay = EntryPoint.builder()
.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();
EntryPoint documentSubmit = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/documents/submit")
.className("click.kamil.examples.statemachine.layered.web.DocumentController")
.methodName("submit")
.metadata(Map.of("path", "/api/documents/submit"))
.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();
CodebaseContext context = new CodebaseContext();
List<EntryPoint> orderScoped = MachineScopeFilter.filterEntryPointsForMachine(
List.of(orderPay, documentSubmit, genericCommand),
"click.kamil.examples.statemachine.layered.order.config.StandardOrderStateMachineConfiguration",
context);
assertThat(orderScoped).extracting(EntryPoint::getName)
.contains("POST /api/orders/pay", "POST /api/commands/{commandKey}")
.doesNotContain("POST /api/documents/submit");
}
}

View File

@@ -17,6 +17,57 @@ class TransitionLinkerEnricherTest {
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
@Test
void shouldLinkWhenPolymorphicEventsContainConcreteEnumConstant() {
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()
.triggerPoint(TriggerPoint.builder()
.event("e")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.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);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
}
@Test
void shouldRespectDispatcherBranchConstraintForMachineDomain() {
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()
.triggerPoint(TriggerPoint.builder()
.event("OrderEvent.PAY")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.constraint("\"ORDER\".equalsIgnoreCase(type)")
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfig")
.transitions(List.of(payT))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
}
@Test
void shouldLinkTransitionByEventOnly() {
Transition t1 = new Transition();
@@ -173,7 +224,7 @@ class TransitionLinkerEnricherTest {
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
assertThat(updatedChain.getMatchedTransitions().get(0).getEvent()).isEqualTo("OrderEvents.PAY");
assertThat(updatedChain.getMatchedTransitions().get(0).getEvent()).isEqualTo("com.example.OrderEvents.PAY");
}
@Test
@@ -543,4 +594,30 @@ class TransitionLinkerEnricherTest {
enricher.enrich(resultUser, null, null);
assertThat(resultUser.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
}
@Test
void shouldRejectChainWhenDispatcherDomainConstraintDoesNotMatchMachine() {
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()
.triggerPoint(TriggerPoint.builder()
.event("OrderEvent.PAY")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.constraint("\"PAYMENT\".equalsIgnoreCase(type)")
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfig")
.transitions(List.of(payT))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
}
}

View File

@@ -0,0 +1,30 @@
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class StrictFqnMatchingEngineExternalTriggerTest {
private final StrictFqnMatchingEngine engine = new StrictFqnMatchingEngine();
@Test
void shouldMatchExternalTriggerByMainEventWhenPolymorphicEventsAreEmpty() {
Event machineEvent = Event.of(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderEvent.PAY");
TriggerPoint trigger = TriggerPoint.builder()
.event("com.example.order.OrderEvent.PAY")
.eventTypeFqn("com.example.order.OrderEvent")
.external(true)
.polymorphicEvents(List.of())
.build();
assertThat(engine.matches(machineEvent, trigger)).isTrue();
}
}

View File

@@ -142,14 +142,37 @@ class StrictFqnMatchingEngineTest {
}
@Test
void shouldMatchValueOfWithCorrectEnum() {
void shouldMatchValueOfWithCorrectEnumWhenPolymorphicEventIsResolved() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvents.valueOf(str)")
.eventTypeFqn("com.example.OrderEvents")
.polymorphicEvents(List.of("OrderEvents.PAY"))
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldNotMatchUnresolvedValueOfWithoutPolymorphicEvents() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvents.valueOf(str)")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldNotMatchExternalTriggerWithoutPolymorphicEvents() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("event")
.external(true)
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test

View File

@@ -0,0 +1,67 @@
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 static org.assertj.core.api.Assertions.assertThat;
class HeuristicBeanResolutionEngineRoutingTest {
private final HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
@Test
void shouldRejectChainWhenEventTypesDifferEvenIfStateTypesMatch(@TempDir Path tempDir) throws Exception {
writeTwoMachineSample(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("InvoiceEvent.PAY")
.eventTypeFqn("com.example.invoice.InvoiceEvent")
.stateTypeFqn("com.example.shared.SharedState")
.build())
.build();
assertThat(engine.isRoutedToCorrectMachine(
chain,
"com.example.config.OrderStateMachineConfiguration",
context)).isFalse();
}
private static void writeTwoMachineSample(Path tempDir) throws Exception {
Path orderPkg = tempDir.resolve("com/example/order");
Path invoicePkg = tempDir.resolve("com/example/invoice");
Path sharedPkg = tempDir.resolve("com/example/shared");
Path configPkg = tempDir.resolve("com/example/config");
Files.createDirectories(orderPkg);
Files.createDirectories(invoicePkg);
Files.createDirectories(sharedPkg);
Files.createDirectories(configPkg);
Files.writeString(sharedPkg.resolve("SharedState.java"),
"package com.example.shared; public enum SharedState { NEW }");
Files.writeString(orderPkg.resolve("OrderState.java"),
"package com.example.order; public enum OrderState { NEW }");
Files.writeString(orderPkg.resolve("OrderEvent.java"),
"package com.example.order; public enum OrderEvent { PAY }");
Files.writeString(invoicePkg.resolve("InvoiceEvent.java"),
"package com.example.invoice; public enum InvoiceEvent { PAY }");
Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"),
"""
package com.example.config;
import com.example.order.OrderEvent;
import com.example.order.OrderState;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
public class OrderStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
}
""");
}
}

View File

@@ -1,9 +1,13 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Tag;
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.Arrays;
import static org.junit.jupiter.api.Assertions.*;
@@ -127,4 +131,38 @@ public class HeuristicBeanResolutionEngineTest {
boolean result = engine.isRoutedToCorrectMachine(chain, machineName, null);
assertTrue(result, "MyStateMachineConfig should not trigger mismatch because it contains StateMachineConfig");
}
@Test
void shouldRejectAmbiguousChainWhenMultipleStateMachinesExist(@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<OrderStates, OrderEvents> {}
enum OrderStates { S1 }
enum OrderEvents { E1 }
""");
Files.writeString(tempDir.resolve("DocumentConfig.java"), """
package com.example;
@org.springframework.statemachine.config.EnableStateMachine
public class DocumentStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<DocumentStates, DocumentEvents> {}
enum DocumentStates { D1 }
enum DocumentEvents { D1E }
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.example.shared.GlobalGateway.fire()"))
.build();
assertFalse(
engine.isRoutedToCorrectMachine(chain, "com.example.OrderStateMachineConfig", context),
"Ambiguous shared gateway should not attach to every machine");
assertFalse(
engine.isRoutedToCorrectMachine(chain, "com.example.DocumentStateMachineConfig", context),
"Ambiguous shared gateway should not attach to every machine");
}
}

View File

@@ -0,0 +1,263 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
class AccessorIndexBuilderTest {
@Nested
class ClassAccessors {
@Test
void shouldIndexTrivialGetterAndSetter(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Order.java", """
package com.example;
public class Order {
private String event;
public String getEvent() { return event; }
public void setEvent(String event) { this.event = event; }
}
""");
AccessorIndex index = scan(tempDir);
assertThat(index.lookup("com.example.Order", "getEvent")).isPresent();
assertThat(index.lookup("com.example.Order", "setEvent")).isPresent();
assertThat(index.accessorsForType("com.example.Order")).hasSize(2);
}
@Test
void shouldIndexNestedClassAccessors(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Outer.java", """
package com.example;
public class Outer {
static class Inner {
private String code;
public String getCode() { return code; }
}
}
""");
AccessorIndex index = scan(tempDir);
assertThat(index.lookup("com.example.Outer.Inner", "getCode")).isPresent();
}
}
@Nested
class Inheritance {
@Test
void shouldInheritParentTrivialGetter(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event;
public String getEvent() { return event; }
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
public void run() {}
}
""");
AccessorIndex index = scan(tempDir);
assertThat(index.lookup("com.example.Child", "getEvent")).isPresent();
assertThat(index.lookup("com.example.Child", "getEvent").orElseThrow().ownerFqn())
.isEqualTo("com.example.Child");
}
@Test
void shouldNotInheritWhenChildOverridesWithLogic(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event;
public String getEvent() { return event; }
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
@Override
public String getEvent() {
return event == null ? "NONE" : event;
}
}
""");
AccessorIndex index = scan(tempDir);
assertThat(index.lookup("com.example.Base", "getEvent")).isPresent();
assertThat(index.lookup("com.example.Child", "getEvent")).isEmpty();
}
@Test
void shouldPreferChildTrivialOverride(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event;
public String getEvent() { return event; }
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
@Override
public String getEvent() { return this.event; }
}
""");
AccessorIndex index = scan(tempDir);
Optional<AccessorSummary> child = index.lookup("com.example.Child", "getEvent");
assertThat(child).isPresent();
assertThat(child.orElseThrow().ownerFqn()).isEqualTo("com.example.Child");
}
@Test
void shouldResolveFieldDeclaredInSuperclass(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event;
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
public String getEvent() { return event; }
}
""");
AccessorIndex index = scan(tempDir);
AccessorSummary summary = index.lookup("com.example.Child", "getEvent").orElseThrow();
assertThat(summary.declaringFqn()).isEqualTo("com.example.Base");
}
}
@Nested
class Records {
@Test
void shouldIndexRecordComponents(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Event.java", """
package com.example;
public record Event(String code, int version) {}
""");
AccessorIndex index = scan(tempDir);
assertThat(index.lookup("com.example.Event", "code")).isPresent();
assertThat(index.lookup("com.example.Event", "code").orElseThrow().kind())
.isEqualTo(AccessorKind.RECORD_COMPONENT);
assertThat(index.lookup("com.example.Event", "version")).isPresent();
}
}
@Nested
class Interfaces {
@Test
void shouldIndexDefaultInterfaceAccessor(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/HasEvent.java", """
package com.example;
public interface HasEvent {
default String getEvent() {
return null;
}
}
""");
AccessorIndex index = scan(tempDir);
// Default method body is not trivial (returns null literal, not field read)
assertThat(index.lookup("com.example.HasEvent", "getEvent")).isEmpty();
}
@Test
void shouldIndexTrivialDefaultInterfaceAccessor(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Holder.java", """
package com.example;
public interface Holder {
String getEvent();
}
""");
writeJava(tempDir, "com/example/Impl.java", """
package com.example;
public class Impl implements Holder {
private String event;
public String getEvent() { return event; }
}
""");
AccessorIndex index = scan(tempDir);
assertThat(index.lookup("com.example.Impl", "getEvent")).isPresent();
assertThat(index.lookup("com.example.Holder", "getEvent")).isEmpty();
}
}
@Nested
class Enums {
@Test
void shouldIndexEnumFieldGetter(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Status.java", """
package com.example;
public enum Status {
OK;
private final String code = "ok";
public String getCode() { return code; }
}
""");
AccessorIndex index = scan(tempDir);
assertThat(index.lookup("com.example.Status", "getCode")).isPresent();
}
}
@Test
void shouldBuildViaCodebaseContext(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Demo.java", """
package com.example;
public class Demo {
private String type;
public String getType() { return type; }
}
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(context.getAccessorIndex().lookup("com.example.Demo", "getType")).isPresent();
assertThat(context.getAccessorIndex().trivialCount()).isGreaterThan(0);
}
private static AccessorIndex scan(Path tempDir) throws IOException {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
return context.getAccessorIndex();
}
private static void writeJava(Path tempDir, String relativePath, String source) throws IOException {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, source);
}
}

View File

@@ -0,0 +1,678 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.analysis.index.AccessorKind;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.DataFlowModel;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class AccessorInliningDataFlowTest {
@Nested
class GetterInlining {
@Test
void shouldResolveTrivialGetterThroughFieldBinding(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public static class Payload {
private String event = "SHIP";
public String getEvent() { return event; }
}
public void run() {
Payload payload = new Payload();
String value = payload.getEvent();
}
}
""");
assertResolvedValue(tempDir, "value", "SHIP");
assertAccessorIndexed(tempDir, "com.example.Runner.Payload", "getEvent");
}
@Test
void shouldResolveChainedTrivialGetters(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public static class Event {
private String code = "PAY";
public String getCode() { return code; }
}
public static class Payload {
private Event event = new Event();
public Event getEvent() { return event; }
}
public static class Order {
private Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
public void run() {
Order order = new Order();
String code = order.getPayload().getEvent().getCode();
}
}
""");
assertResolvedValue(tempDir, "code", "PAY");
}
@Test
void shouldResolveInheritedFieldThroughChildGetter(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event = "BASE";
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
public String getEvent() { return event; }
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Child child = new Child();
String value = child.getEvent();
}
}
""");
assertResolvedValue(tempDir, "value", "BASE");
}
@Test
void shouldResolveSuperTrivialGetterWithoutOpeningSuperBody(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event = "SUPER";
public String getEvent() { return event; }
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
public String capture() {
return super.getEvent();
}
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Child child = new Child();
String value = child.capture();
}
}
""");
CodebaseContext context = scan(tempDir);
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", "value");
String resolved = model.resolveValue(initializer, context);
assertThat(resolved).isEqualTo("SUPER");
}
@Test
void shouldResolveRecordComponentAfterNonTrivialGetterWrapper(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Event.java", """
package com.example;
public record Event(String code) {}
""");
writeJava(tempDir, "com/example/Payload.java", """
package com.example;
public class Payload {
private Event event = new Event("WRAPPED");
public Event getEvent() { return event; }
public Event getEventViaWrapper() { return getEvent(); }
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Payload payload = new Payload();
String code = payload.getEventViaWrapper().code();
}
}
""");
assertResolvedValue(tempDir, "code", "WRAPPED");
}
@Test
void shouldResolveRecordComponentThroughAccessorChain(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Event.java", """
package com.example;
public record Event(String code) {}
""");
writeJava(tempDir, "com/example/Payload.java", """
package com.example;
public class Payload {
private Event event = new Event("RECORD");
public Event getEvent() { return event; }
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Payload payload = new Payload();
String code = payload.getEvent().code();
}
}
""");
assertResolvedValue(tempDir, "code", "RECORD");
}
}
@Nested
class SetterThenGetter {
@Test
void shouldApplySetterMutationBeforeGetterRead(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public static class Command {
private String event = "DEFAULT";
public void setEvent(String event) { this.event = event; }
public String getEvent() { return this.event; }
}
public void run() {
Command cmd = new Command();
cmd.setEvent("PAY");
String value = cmd.getEvent();
}
}
""");
assertResolvedValue(tempDir, "value", "PAY");
}
@Test
void shouldApplySetterThroughInterfaceTypedReceiver(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public interface Command {
void setEvent(String event);
String getEvent();
}
public static class Impl implements Command {
private String event = "DEFAULT";
@Override
public void setEvent(String event) { this.event = event; }
@Override
public String getEvent() { return event; }
}
public void run() {
Command cmd = new Impl();
cmd.setEvent("PAY");
String value = cmd.getEvent();
}
}
""");
assertResolvedValue(tempDir, "value", "PAY");
}
@Test
void shouldResolveSetterArgumentThroughInterfaceTypedReceiver(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public interface Command {
void setEvent(String event);
String getEvent();
}
public static class Impl implements Command {
private String event = "DEFAULT";
@Override
public void setEvent(String event) { this.event = event; }
@Override
public String getEvent() { return event; }
}
public static class Helper {
public String constant() { return "PAY"; }
}
public void run() {
Command cmd = new Impl();
Helper helper = new Helper();
cmd.setEvent(helper.constant());
String value = cmd.getEvent();
}
}
""");
assertResolvedValue(tempDir, "value", "PAY");
}
@Test
void shouldResolveFluentSetterChain(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public static class Builder {
private String event = "INIT";
public Builder setEvent(String event) {
this.event = event;
return this;
}
public String getEvent() { return event; }
}
public void run() {
Builder builder = new Builder();
builder.setEvent("FLUENT");
String value = builder.getEvent();
}
}
""");
assertResolvedValue(tempDir, "value", "FLUENT");
}
}
@Nested
class MustNotBreakExistingResolution {
@Test
void shouldStillResolveLiteralReturnWhenGetterIsNotTrivial(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public static class Service {
public String getEvent() { return "LITERAL"; }
}
public void run() {
Service service = new Service();
String value = service.getEvent();
}
}
""");
assertResolvedValue(tempDir, "value", "LITERAL");
assertThat(accessorLookup(tempDir, "com.example.Runner.Service", "getEvent"))
.isPresent()
.get()
.satisfies(summary -> assertThat(summary.kind()).isEqualTo(AccessorKind.CONSTANT_GETTER));
}
@Test
void shouldStillResolveDynamicDispatchWhenImplementationReturnsLiteral(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public interface Service {
String getEvent();
}
public static class PayService implements Service {
public String getEvent() { return "PAY"; }
}
public void run() {
Service service = new PayService();
String value = service.getEvent();
}
}
""");
assertResolvedValue(tempDir, "value", "PAY");
}
@Test
void shouldNotInlineParentAccessorWhenSubclassOverrideIsBlocked(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event = "base";
public String getEvent() { return event; }
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
@Override
public String getEvent() {
return "child";
}
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Base target = new Child();
String value = target.getEvent();
}
}
""");
assertResolvedValue(tempDir, "value", "child");
}
@Test
void shouldApplySetterMutationsOnInstanceFieldReceiver(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
private Command command = new Command();
public void run() {
this.command.setEvent("PAY");
String value = this.command.getEvent();
}
public static class Command {
private String event = "DEFAULT";
public void setEvent(String event) { this.event = event; }
public String getEvent() { return event; }
}
}
""");
assertResolvedValue(tempDir, "value", "PAY");
}
@Test
void shouldNotApplyParentAccessorIndexWhenChildOverrideIsBlocked(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event = "BASE";
public String getEvent() { return event; }
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
@Override
public String getEvent() {
return event == null ? "NONE" : event.toUpperCase();
}
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Child child = new Child();
child.event = "pay";
String value = child.getEvent();
}
}
""");
assertThat(accessorLookup(tempDir, "com.example.Child", "getEvent")).isEmpty();
assertThat(accessorLookup(tempDir, "com.example.Base", "getEvent")).isPresent();
CodebaseContext context = scan(tempDir);
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", "value");
String resolved = model.resolveValue(initializer, context);
assertThat(resolved).isNotEqualTo("BASE");
}
@Test
void shouldNotConfuseMapGetWithBeanGetter(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
import java.util.Map;
import java.util.HashMap;
public class Runner {
public void run() {
Map<String, String> map = new HashMap<>();
map.put("event", "MAP_VALUE");
String value = map.get("event");
}
}
""");
assertThat(accessorLookup(tempDir, "java.util.HashMap", "get")).isEmpty();
// Map resolution is separate; ensure we do not crash or return wrong constant.
CodebaseContext context = scan(tempDir);
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", "value");
List<Expression> defs = model.getReachingDefinitions(initializer);
assertThat(defs).isNotEmpty();
}
@Test
void shouldPreserveBranchSensitiveSetterVisibility(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public static class Command {
private String event = "DEFAULT";
public void setEvent(String event) { this.event = event; }
public String getEvent() { return this.event; }
}
public void run(boolean pay) {
Command cmd = new Command();
if (pay) {
cmd.setEvent("PAY");
} else {
cmd.setEvent("CANCEL");
}
String value = cmd.getEvent();
}
}
""");
CodebaseContext context = scan(tempDir);
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", "value");
String resolved = model.resolveValue(initializer, context);
assertThat(resolved).isIn("PAY", "CANCEL", null);
}
}
@Nested
class SchemaStressCases {
@Test
void shouldHandleDeepGetterChainWithoutStackOverflow(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public static class L4 { private String v = "DEEP"; public String getV() { return v; } }
public static class L3 { private L4 l4 = new L4(); public L4 getL4() { return l4; } }
public static class L2 { private L3 l3 = new L3(); public L3 getL3() { return l3; } }
public static class L1 { private L2 l2 = new L2(); public L2 getL2() { return l2; } }
public void run() {
L1 root = new L1();
String value = root.getL2().getL3().getL4().getV();
}
}
""");
assertResolvedValue(tempDir, "value", "DEEP");
}
@Test
void shouldHandleMixedTrivialAndLiteralGetterChain(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public static class Inner {
public String code() { return "INNER"; }
}
public static class Outer {
private Inner inner = new Inner();
public Inner getInner() { return inner; }
}
public void run() {
Outer outer = new Outer();
String value = outer.getInner().code();
}
}
""");
assertResolvedValue(tempDir, "value", "INNER");
}
@Test
void shouldResolveSuperclassFieldViaSubclassThisGetter(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public static class Base {
protected String event = "SUPER";
}
public static class Sub extends Base {
public String getEvent() { return event; }
}
public void run() {
Sub sub = new Sub();
String value = sub.getEvent();
}
}
""");
assertResolvedValue(tempDir, "value", "SUPER");
}
@Test
void shouldResolveInterfaceEnumGetterViaConcreteImplementation(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public interface RichEvent {
EventType getType();
}
public static class PayEvent implements RichEvent {
public EventType getType() { return EventType.PAY; }
}
public void run() {
RichEvent event = new PayEvent();
EventType value = event.getType();
}
}
enum EventType { PAY, SHIP }
""");
assertResolvedValue(tempDir, "value", "EventType.PAY");
}
@Test
void shouldReturnEmptyWhenAccessorIndexHasNoEntry(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public static class Worker {
public String compute() { return "WORK"; }
}
public void run() {
Worker worker = new Worker();
String value = worker.compute();
}
}
""");
assertResolvedValue(tempDir, "value", "WORK");
assertThat(accessorLookup(tempDir, "com.example.Runner.Worker", "compute")).isEmpty();
}
}
private static void assertResolvedValue(Path tempDir, String variableName, String expected) throws IOException {
CodebaseContext context = scan(tempDir);
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", variableName);
String resolved = model.resolveValue(initializer, context);
assertThat(resolved).isEqualTo(expected);
}
private static void assertAccessorIndexed(Path tempDir, String ownerFqn, String methodName) throws IOException {
assertThat(accessorLookup(tempDir, ownerFqn, methodName)).isPresent();
}
private static java.util.Optional<AccessorSummary> accessorLookup(Path tempDir, String ownerFqn, String methodName)
throws IOException {
CodebaseContext context = scan(tempDir);
return context.getAccessorIndex().lookup(ownerFqn, methodName);
}
private static CodebaseContext scan(Path tempDir) throws IOException {
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
context.scan(tempDir);
return context;
}
private static Expression findInitializer(CodebaseContext context, String methodName, String variableName) {
for (TypeDeclaration type : context.getTypeDeclarations()) {
for (MethodDeclaration method : type.getMethods()) {
if (!method.getName().getIdentifier().equals(methodName) || method.getBody() == null) {
continue;
}
for (Object statementObj : method.getBody().statements()) {
if (statementObj instanceof VariableDeclarationStatement statement) {
for (Object fragmentObj : statement.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(variableName)) {
return fragment.getInitializer();
}
}
}
}
}
for (TypeDeclaration nested : type.getTypes()) {
Expression nestedResult = findInitializerInType(nested, methodName, variableName);
if (nestedResult != null) {
return nestedResult;
}
}
}
throw new AssertionError("Initializer not found for " + variableName + " in " + methodName);
}
private static Expression findInitializerInType(TypeDeclaration type, String methodName, String variableName) {
for (MethodDeclaration method : type.getMethods()) {
if (!method.getName().getIdentifier().equals(methodName) || method.getBody() == null) {
continue;
}
for (Object statementObj : method.getBody().statements()) {
if (statementObj instanceof VariableDeclarationStatement statement) {
for (Object fragmentObj : statement.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(variableName)) {
return fragment.getInitializer();
}
}
}
}
}
for (TypeDeclaration nested : type.getTypes()) {
Expression result = findInitializerInType(nested, methodName, variableName);
if (result != null) {
return result;
}
}
return null;
}
private static void writeJava(Path tempDir, String relativePath, String source) throws IOException {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, source);
}
}

View File

@@ -0,0 +1,115 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.Expression;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ConstantExtractorAccessorInliningTest {
private CodebaseContext context;
private ConstantExtractor constantExtractor;
private VariableTracer variableTracer;
@BeforeEach
void setUp(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Payload.java", """
package com.example;
public class Payload {
private String event = "SHIP";
public String getEvent() { return event; }
public void setEvent(String event) { this.event = event; }
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Payload payload = new Payload();
payload.setEvent("PAY");
String value = payload.getEvent();
}
}
""");
context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
context.scan(tempDir);
constantExtractor = new ConstantExtractor(context, context.getConstantResolver(), mi -> null);
variableTracer = new VariableTracer(context, context.getConstantResolver());
constantExtractor.setVariableTracer(variableTracer);
constantExtractor.setConstructorAnalyzer(new ConstructorAnalyzer(context, context.getConstantResolver()));
}
@Test
void resolveMethodReturnConstantShouldReadIndexedGetterField() {
List<String> constants = constantExtractor.resolveMethodReturnConstant(
"com.example.Payload", "getEvent", 0, new HashSet<>(), null);
assertThat(constants).contains("SHIP");
}
@Test
void traceLocalSetterShouldUseAccessorIndexFieldName() {
Expression result = variableTracer.traceLocalSetter(
"com.example.Runner.run", "payload", "getEvent");
assertThat(result).isNotNull();
assertThat(result.toString()).contains("PAY");
}
@Test
void indexedGetterShouldExistForPayload() {
assertThat(context.getAccessorIndex().lookup("com.example.Payload", "getEvent")).isPresent();
assertThat(context.getAccessorIndex().lookup("com.example.Payload", "setEvent")).isPresent();
}
@Test
void resolveMethodReturnConstantShouldUseImplementationIndexedGetter(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/EventType.java", """
package com.example;
public interface EventType {
String getCode();
}
""");
writeJava(tempDir, "com/example/PayEvent.java", """
package com.example;
public class PayEvent implements EventType {
private String code = "PAY";
public String getCode() { return code; }
}
""");
context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
context.scan(tempDir);
constantExtractor = new ConstantExtractor(context, context.getConstantResolver(), mi -> null);
constantExtractor.setConstructorAnalyzer(new ConstructorAnalyzer(context, context.getConstantResolver()));
List<String> constants = constantExtractor.resolveMethodReturnConstant(
"com.example.EventType", "getCode", 0, new HashSet<>(), null);
assertThat(constants).contains("PAY");
}
private static void writeJava(Path tempDir, String relativePath, String source) throws IOException {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, source);
}
}

View File

@@ -0,0 +1,251 @@
package click.kamil.springstatemachineexporter.analysis.index;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
class TrivialAccessorDetectorTest {
private final TrivialAccessorDetector detector = new TrivialAccessorDetector();
private static final TrivialAccessorDetector.FieldLookup KNOWN_FIELDS =
fieldName -> Optional.of("com.example.Demo");
@Nested
class Getters {
@Test
void shouldDetectPlainGetter() {
MethodDeclaration md = method("""
public class Demo {
private String event;
public String getEvent() { return this.event; }
}
""", 0);
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
assertThat(summary).isPresent();
assertThat(summary.get().kind()).isEqualTo(AccessorKind.GETTER);
assertThat(summary.get().fieldName()).isEqualTo("event");
assertThat(summary.get().methodName()).isEqualTo("getEvent");
}
@Test
void shouldDetectGetterWithoutThisPrefix() {
MethodDeclaration md = method("""
public class Demo {
private String event;
public String getEvent() { return event; }
}
""", 0);
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isPresent();
}
@Test
void shouldDetectBooleanGetter() {
MethodDeclaration md = method("""
public class Demo {
private boolean active;
public boolean isActive() { return active; }
}
""", 0);
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
assertThat(summary).isPresent();
assertThat(summary.get().kind()).isEqualTo(AccessorKind.BOOLEAN_GETTER);
assertThat(summary.get().fieldName()).isEqualTo("active");
}
@Test
void shouldDetectGetterWithCast() {
MethodDeclaration md = method("""
public class Demo {
private Object event;
public Object getEvent() { return (Object) this.event; }
}
""", 0);
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isPresent();
}
@Test
void shouldDetectConstantGetter() {
MethodDeclaration md = method("""
public class Demo {
public String getEvent() { return "PAY"; }
}
""", 0);
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
assertThat(summary).isPresent();
assertThat(summary.get().kind()).isEqualTo(AccessorKind.CONSTANT_GETTER);
}
@Test
void shouldDetectEnumConstantGetter() {
MethodDeclaration md = method("""
public class Demo {
public OrderEvents getType() { return OrderEvents.PAY; }
}
enum OrderEvents { PAY }
""", 0);
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
assertThat(summary).isPresent();
assertThat(summary.get().kind()).isEqualTo(AccessorKind.CONSTANT_GETTER);
}
@Test
void shouldRejectComputedConstantGetter() {
MethodDeclaration md = method("""
public class Demo {
public String getEvent() { return build(); }
private String build() { return "X"; }
}
""", 0);
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isEmpty();
}
@Test
void shouldRejectLazyInitGetter() {
MethodDeclaration md = method("""
public class Demo {
private String event;
public String getEvent() {
if (event == null) {
event = "default";
}
return event;
}
}
""", 0);
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isEmpty();
}
@Test
void shouldRejectGetterWithMethodCall() {
MethodDeclaration md = method("""
public class Demo {
private String event;
public String getEvent() { return event.trim(); }
}
""", 0);
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isEmpty();
}
@Test
void shouldRejectUnknownField() {
MethodDeclaration md = method("""
public class Demo {
public String getMissing() { return missing; }
}
""", 0);
assertThat(detector.detect("com.example.Demo", md, fieldName -> Optional.empty())).isEmpty();
}
}
@Nested
class Setters {
@Test
void shouldDetectVoidSetter() {
MethodDeclaration md = method("""
public class Demo {
private String event;
public void setEvent(String event) { this.event = event; }
}
""", 0);
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
assertThat(summary).isPresent();
assertThat(summary.get().kind()).isEqualTo(AccessorKind.SETTER);
assertThat(summary.get().paramName()).isEqualTo("event");
assertThat(summary.get().returnsThis()).isFalse();
}
@Test
void shouldDetectFluentSetter() {
MethodDeclaration md = method("""
public class Demo {
private String event;
public Demo setEvent(String event) {
this.event = event;
return this;
}
}
""", 0);
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
assertThat(summary).isPresent();
assertThat(summary.get().kind()).isEqualTo(AccessorKind.FLUENT_SETTER);
assertThat(summary.get().returnsThis()).isTrue();
}
@Test
void shouldRejectSetterWithValidation() {
MethodDeclaration md = method("""
public class Demo {
private String event;
public void setEvent(String event) {
java.util.Objects.requireNonNull(event);
this.event = event;
}
}
""", 0);
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isEmpty();
}
}
@Nested
class Records {
@Test
void shouldDetectRecordComponentAccessor() {
Optional<AccessorSummary> summary = detector.detectRecordComponent("com.example.Event", "code");
assertThat(summary).isPresent();
assertThat(summary.get().kind()).isEqualTo(AccessorKind.RECORD_COMPONENT);
assertThat(summary.get().methodName()).isEqualTo("code");
assertThat(summary.get().fieldName()).isEqualTo("code");
}
}
@Nested
class Naming {
@Test
void shouldDecapitalizeJavaBeanProperty() {
assertThat(TrivialAccessorDetector.decapitalize("Event")).isEqualTo("event");
assertThat(TrivialAccessorDetector.decapitalize("URL")).isEqualTo("URL");
}
}
private static MethodDeclaration method(String classSource, int methodIndex) {
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
parser.setSource(classSource.toCharArray());
parser.setKind(ASTParser.K_COMPILATION_UNIT);
CompilationUnit cu = (CompilationUnit) parser.createAST(null);
TypeDeclaration td = (TypeDeclaration) cu.types().get(0);
return td.getMethods()[methodIndex];
}
}

View File

@@ -0,0 +1,23 @@
package click.kamil.springstatemachineexporter.analysis.model;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class LifecycleTriggerMarkersTest {
@Test
void shouldRecognizeLifecycleMarkers() {
assertThat(LifecycleTriggerMarkers.isLifecycle("[LIFECYCLE:RESTORE]")).isTrue();
assertThat(LifecycleTriggerMarkers.isLifecycle("[LIFECYCLE:RESET]")).isTrue();
assertThat(LifecycleTriggerMarkers.isLifecycle("OrderEvent.PAY")).isFalse();
}
@Test
void shouldFormatDisplayLabels() {
assertThat(LifecycleTriggerMarkers.displayLabel("[LIFECYCLE:RESTORE]"))
.isEqualTo("Restore persisted state");
assertThat(LifecycleTriggerMarkers.displayLabel("[LIFECYCLE:RESET]"))
.isEqualTo("Reset state machine");
}
}

View File

@@ -0,0 +1,176 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class AccessorPipelineBugFixTest {
@TempDir
Path tempDir;
CodebaseContext context;
ConstantExtractor constantExtractor;
ConstructorAnalyzer constructorAnalyzer;
AccessorResolver accessorResolver;
VariableTracer variableTracer;
@BeforeEach
void setUp() throws IOException {
context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
ConstantResolver constantResolver = context.getConstantResolver();
constantExtractor = new ConstantExtractor(context, constantResolver, mi -> null);
constructorAnalyzer = new ConstructorAnalyzer(context, constantResolver);
variableTracer = new VariableTracer(context, constantResolver);
constantExtractor.setVariableTracer(variableTracer);
constantExtractor.setConstructorAnalyzer(constructorAnalyzer);
accessorResolver = new AccessorResolver(context, constantExtractor, constructorAnalyzer, constantResolver);
constantExtractor.setAccessorResolver(accessorResolver);
}
private void writeJava(String relativePath, String source) throws IOException {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, source);
}
private void scanWorkspace() throws IOException {
context.scan(tempDir);
}
@Nested
class ConstructorAnalyzerSubclassPollution {
@Test
void shouldNotPullSubclassFieldValuesWhenTracingConcreteBaseType() throws IOException {
writeJava("com/example/Base.java", """
package com.example;
public class Base {
protected String token;
}
""");
writeJava("com/example/Sub.java", """
package com.example;
public class Sub extends Base {
public Sub() { this.token = "SUB_ONLY"; }
}
""");
scanWorkspace();
TypeDeclaration baseTd = context.getTypeDeclaration("com.example.Base");
List<String> traced = constructorAnalyzer.traceFieldInConstructors(
baseTd, "token", context, new HashSet<>(), ResolutionBudget.defaults());
assertThat(traced).doesNotContain("SUB_ONLY");
}
}
@Nested
class ConstantExtractorAbstractBodyWidening {
@Test
void shouldWidenToImplementationsWhenInterfaceMethodHasNoBody() throws IOException {
writeJava("com/example/Code.java", """
package com.example;
public enum Code { A, B }
""");
writeJava("com/example/RichEvent.java", """
package com.example;
public interface RichEvent {
Code getCode();
}
""");
writeJava("com/example/EventA.java", """
package com.example;
public class EventA implements RichEvent {
public Code getCode() { return Code.A; }
}
""");
writeJava("com/example/EventB.java", """
package com.example;
public class EventB implements RichEvent {
public Code getCode() { return Code.B; }
}
""");
scanWorkspace();
List<String> resolved = constantExtractor.resolveMethodReturnConstant(
"com.example.RichEvent", "getCode", 0, new HashSet<>(), null);
assertThat(resolved).containsExactlyInAnyOrder("Code.A", "Code.B");
}
}
@Nested
class AccessorResolverCacheKey {
@Test
void shouldNotReuseCachedResultWhenFieldValuesDiffer() throws IOException {
writeJava("com/example/Payload.java", """
package com.example;
public class Payload {
private String event;
public String getEvent() { return event; }
}
""");
scanWorkspace();
TypeDeclaration td = context.getTypeDeclaration("com.example.Payload");
Map<String, String> valuesA = new HashMap<>(Map.of("event", "PAY"));
Map<String, String> valuesB = new HashMap<>(Map.of("event", "CANCEL"));
List<String> pay = accessorResolver.resolve(
"com.example.Payload",
"getEvent",
new AccessorResolver.ReceiverContext(td, null, null, false, valuesA),
ResolutionBudget.defaults());
List<String> cancel = accessorResolver.resolve(
"com.example.Payload",
"getEvent",
new AccessorResolver.ReceiverContext(td, null, null, false, valuesB),
ResolutionBudget.defaults());
assertThat(pay).containsExactly("PAY");
assertThat(cancel).containsExactly("CANCEL");
}
}
@Nested
class InlineAccessorsDisabled {
@Test
void shouldSkipIndexedDataflowShortcutWhenDisabled() throws IOException {
writeJava("com/example/Payload.java", """
package com.example;
public class Payload {
private String event = "INLINE";
public String getEvent() { return event; }
}
""");
context.setInlineAccessors(false);
scanWorkspace();
assertThat(context.getAccessorIndex().trivialCount()).isZero();
assertThat(context.isInlineAccessors()).isFalse();
}
}
}

View File

@@ -0,0 +1,345 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
import click.kamil.springstatemachineexporter.analysis.service.ExpressionAccessClassifier;
import click.kamil.springstatemachineexporter.analysis.service.HeuristicCallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class PipelineRefactorTest {
@TempDir
Path tempDir;
CodebaseContext context;
ConstantExtractor constantExtractor;
ConstructorAnalyzer constructorAnalyzer;
AccessorResolver accessorResolver;
ExpressionAccessClassifier expressionAccessClassifier;
@BeforeEach
void setUp() throws IOException {
context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
ConstantResolver constantResolver = context.getConstantResolver();
constantExtractor = new ConstantExtractor(context, constantResolver, mi -> null);
constructorAnalyzer = new ConstructorAnalyzer(context, constantResolver);
VariableTracer variableTracer = new VariableTracer(context, constantResolver);
constantExtractor.setVariableTracer(variableTracer);
constantExtractor.setConstructorAnalyzer(constructorAnalyzer);
constructorAnalyzer.setVariableTracer(variableTracer);
constructorAnalyzer.setConstantExtractor(constantExtractor);
expressionAccessClassifier = new ExpressionAccessClassifier(context, variableTracer, constantResolver, expr -> null);
constantExtractor.setExpressionAccessClassifier(expressionAccessClassifier);
accessorResolver = new AccessorResolver(context, constantExtractor, constructorAnalyzer, constantResolver);
constantExtractor.setAccessorResolver(accessorResolver);
}
private void writeJava(String relativePath, String source) throws IOException {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, source);
}
private void scanWorkspace() throws IOException {
context.scan(tempDir);
}
@Nested
class ResolutionBudgetTests {
@Test
void shouldUseSharedDefaults() {
ResolutionBudget budget = ResolutionBudget.defaults();
assertThat(budget.isMethodReturnExhausted(20)).isFalse();
assertThat(budget.isMethodReturnExhausted(21)).isTrue();
assertThat(budget.isUnwrapExhausted(5)).isFalse();
assertThat(budget.isUnwrapExhausted(6)).isTrue();
assertThat(budget.allowGlobalFieldScan()).isFalse();
}
@Test
void shouldDecrementDepthLimitsViaChild() {
ResolutionBudget child = ResolutionBudget.defaults().child();
assertThat(child.isMethodReturnExhausted(19)).isFalse();
assertThat(child.isMethodReturnExhausted(20)).isTrue();
assertThat(child.isDataflowExhausted(49)).isFalse();
assertThat(child.isDataflowExhausted(50)).isTrue();
}
}
@Nested
class LibraryUnwrapRegistryTests {
@Test
void shouldRecognizeReactiveReceiversAndFactoryMethods() {
assertThat(LibraryUnwrapRegistry.isUnwrapReceiverType("Mono")).isTrue();
assertThat(LibraryUnwrapRegistry.isUnwrapMethodName("just")).isTrue();
assertThat(LibraryUnwrapRegistry.shouldStopUnwrap("buildPayload")).isTrue();
assertThat(LibraryUnwrapRegistry.isEventSetterMethodName("eventType")).isTrue();
}
}
@Nested
class MethodInvocationUnwrapperTests {
@Test
void shouldPeelOptionalOfFactoryCall() throws IOException {
writeJava("com/example/Factory.java", """
package com.example;
public class Factory {
public static String build() {
return Optional.of("PAY").get();
}
}
""");
scanWorkspace();
TypeDeclaration td = context.getTypeDeclaration("com.example.Factory");
assertThat(td).isNotNull();
}
}
@Nested
class AccessorResolverTests {
@Test
void shouldResolveConstantGetterWithoutCic() throws IOException {
writeJava("com/example/Events.java", """
package com.example;
public enum Events { PAY, CANCEL }
""");
writeJava("com/example/EventHolder.java", """
package com.example;
public class EventHolder {
public Events getEvent() { return Events.PAY; }
}
""");
scanWorkspace();
List<String> resolved = accessorResolver.resolve(
"com.example.EventHolder",
"getEvent",
new AccessorResolver.ReceiverContext(null, null, null, false, null),
ResolutionBudget.defaults());
assertThat(resolved).contains("Events.PAY");
}
@Test
void shouldCacheRepeatedLookups() throws IOException {
writeJava("com/example/Events.java", """
package com.example;
public enum Events { PAY }
""");
writeJava("com/example/EventHolder.java", """
package com.example;
public class EventHolder {
public Events getEvent() { return Events.PAY; }
}
""");
scanWorkspace();
AccessorResolver.ReceiverContext receiverContext =
new AccessorResolver.ReceiverContext(null, null, null, false, null);
List<String> first = accessorResolver.resolve(
"com.example.EventHolder", "getEvent", receiverContext, ResolutionBudget.defaults());
List<String> second = accessorResolver.resolve(
"com.example.EventHolder", "getEvent", receiverContext, ResolutionBudget.defaults());
assertThat(first).contains("Events.PAY");
assertThat(second).isSameAs(first);
}
@Test
void shouldResolveGetterChainStepViaIndexBeforeReturnedCic() throws IOException {
writeJava("com/example/Payload.java", """
package com.example;
public class Payload {
public String getType() { return "PAY"; }
}
""");
writeJava("com/example/Inner.java", """
package com.example;
public class Inner {
private final Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
""");
scanWorkspace();
AccessorResolver.GetterChainStep step = accessorResolver.resolveGetterChainStep(
"com.example.Inner", "getPayload", ResolutionBudget.defaults());
assertThat(step).isNotNull();
assertThat(step.typeFqn()).contains("Payload");
assertThat(step.cic()).isNotNull();
}
@Test
void shouldResolveFactoryBuildThenGetterWithoutCicDeadEnd() throws IOException {
writeJava("com/example/Events.java", """
package com.example;
public enum Events { PAY }
""");
writeJava("com/example/Order.java", """
package com.example;
public class Order {
public Events getEvent() { return Events.PAY; }
}
""");
writeJava("com/example/OrderFactory.java", """
package com.example;
public class OrderFactory {
public Order build() { return new Order(); }
}
""");
writeJava("com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
OrderFactory factory = new OrderFactory();
Order order = factory.build();
Events event = order.getEvent();
}
}
""");
scanWorkspace();
List<String> events = accessorResolver.resolve(
"com.example.Order",
"getEvent",
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration("com.example.Order"),
null,
null,
false,
null),
ResolutionBudget.defaults());
assertThat(events).contains("Events.PAY");
}
@Test
void shouldWidenInterfaceGettersToAllImplementations() throws IOException {
writeJava("com/example/Events.java", """
package com.example;
public enum Events { ALPHA, BETA }
""");
writeJava("com/example/AlphaPayload.java", """
package com.example;
public class AlphaPayload implements Payload {
public Events type() { return Events.ALPHA; }
}
""");
writeJava("com/example/BetaPayload.java", """
package com.example;
public class BetaPayload implements Payload {
public Events type() { return Events.BETA; }
}
""");
writeJava("com/example/Payload.java", """
package com.example;
public interface Payload {
Events type();
}
""");
scanWorkspace();
List<String> resolved = accessorResolver.resolve(
"com.example.Payload",
"type",
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration("com.example.Payload"),
null,
null,
false,
null),
ResolutionBudget.defaults());
assertThat(resolved).containsExactlyInAnyOrder("Events.ALPHA", "Events.BETA");
}
}
@Nested
class ExpressionAccessClassifierSuffixTests {
@Test
void shouldTreatRecordStyleSuffixesAsTraceable() {
assertThat(expressionAccessClassifier.isTraceableAccessorSuffix(".getEvent()")).isTrue();
assertThat(expressionAccessClassifier.isTraceableAccessorSuffix(".type()")).isTrue();
assertThat(expressionAccessClassifier.isTraceableAccessorSuffix(".event()")).isTrue();
assertThat(expressionAccessClassifier.isTraceableAccessorSuffix(".hashCode()")).isFalse();
}
}
@Nested
class ConstructorAnalyzerGlobalScanTests {
@Test
void shouldSkipGlobalScanByDefault() throws IOException {
writeJava("com/example/Unrelated.java", """
package com.example;
public class Unrelated {
private String sharedName = "SHOULD_NOT_MATCH";
}
""");
writeJava("com/example/Target.java", """
package com.example;
public class Target {
private String sharedName;
public Target() { this.sharedName = "LOCAL"; }
}
""");
scanWorkspace();
TypeDeclaration td = context.getTypeDeclaration("com.example.Target");
List<String> traced = constructorAnalyzer.traceFieldInConstructors(
td, "sharedName", context, new HashSet<>(), ResolutionBudget.defaults());
assertThat(traced).contains("LOCAL");
assertThat(traced).doesNotContain("SHOULD_NOT_MATCH");
}
@Test
void shouldAllowGlobalScanWhenExplicitlyEnabled() throws IOException {
writeJava("com/example/OnlyFieldOwner.java", """
package com.example;
public class OnlyFieldOwner {
private String orphanField = "GLOBAL";
}
""");
writeJava("com/example/Shell.java", """
package com.example;
public class Shell {
private String orphanField;
}
""");
scanWorkspace();
TypeDeclaration shell = context.getTypeDeclaration("com.example.Shell");
List<String> withoutGlobal = constructorAnalyzer.traceFieldInConstructors(
shell, "orphanField", context, new HashSet<>(), ResolutionBudget.defaults());
assertThat(withoutGlobal).isEmpty();
List<String> withGlobal = constructorAnalyzer.traceFieldInConstructors(
shell, "orphanField", context, new HashSet<>(), ResolutionBudget.withGlobalFieldScan());
assertThat(withGlobal).contains("GLOBAL");
}
}
@Nested
class ConstantResolverSharingTests {
@Test
void shouldUseSingleConstantResolverFromContext() {
assertThat(new HeuristicCallGraphEngine(context).getClass()).isNotNull();
ConstantResolver shared = context.getConstantResolver();
ConstantExtractor extractor = new ConstantExtractor(context, shared, mi -> null);
assertThat(extractor).isNotNull();
assertThat(shared).isSameAs(context.getConstantResolver());
}
}
}

View File

@@ -0,0 +1,38 @@
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 BooleanConstraintEvaluatorBindingsTest {
@Test
void shouldAcceptMatchingEnumSwitchConstraint() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"command == ORDER_PAY",
Map.of("command", "DomainCommand.ORDER_PAY"))).isTrue();
}
@Test
void shouldRejectMismatchedEnumSwitchConstraint() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"command == ORDER_SHIP",
Map.of("command", "DomainCommand.ORDER_PAY"))).isFalse();
}
@Test
void shouldAcceptMatchingStringSwitchConstraint() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"commandKey == \"order.pay\"",
Map.of("commandKey", "order.pay"))).isTrue();
}
@Test
void shouldRejectConstraintWhenBindingIsMissing() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
"command == ORDER_PAY",
Map.of("commandKey", "order.pay"))).isFalse();
}
}

View File

@@ -0,0 +1,66 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class BooleanConstraintEvaluatorTest {
@Test
void shouldEvaluateLiteralEqualsAgainstMachineDomain() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"ORDER\".equals(domain)", "OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"PAYMENT\".equals(domain)", "OrderStateMachineConfiguration")).isFalse();
}
@Test
void shouldEvaluateReverseEqualsAndInequality() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"domain.equals(\"ORDER\")", "OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"domain != \"ORDER\"", "OrderStateMachineConfiguration")).isFalse();
}
@Test
void shouldEvaluateStringEqualsForKnownLiterals() {
assertThat(BooleanConstraintEvaluator.evaluateStringEquals("ORDER", "order", true)).isTrue();
assertThat(BooleanConstraintEvaluator.evaluateStringEquals("ORDER", "order", false)).isFalse();
assertThat(BooleanConstraintEvaluator.evaluateStringEquals(null, "ORDER", true)).isNull();
}
@Test
void shouldEvaluateCombinedBooleanLogic() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"ORDER\".equals(type) && \"ORDER\".equals(domain)", "OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"ORDER\".equals(type) && \"PAYMENT\".equals(domain)", "OrderStateMachineConfiguration")).isFalse();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"PAYMENT\".equals(type) || \"ORDER\".equals(type)", "OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"PAYMENT\".equals(type) || \"DOCUMENT\".equals(type)", "OrderStateMachineConfiguration")).isFalse();
}
@Test
void shouldEvaluateEqualsIgnoreCaseAgainstMachineDomain() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"order\".equalsIgnoreCase(type)", "OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"payment\".equalsIgnoreCase(type)", "OrderStateMachineConfiguration")).isFalse();
}
@Test
void shouldEvaluateNegatedDomainChecks() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"!(\"PAYMENT\".equals(type))", "OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"!(\"ORDER\".equals(type))", "OrderStateMachineConfiguration")).isFalse();
}
@Test
void evaluateBooleanExpressionShouldHandleLogicalOperators() {
assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("true && false")).isFalse();
assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("true || false")).isTrue();
assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("!(false)")).isTrue();
}
}

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import org.junit.jupiter.api.Test;
@@ -8,6 +9,8 @@ import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -524,4 +527,166 @@ public class ConstantResolverTest {
String result2 = resolver.resolve(mi2, context);
assertThat(result2).isEqualTo("com.example.OrderEvent.SHIPPED");
}
@Test
void shouldResolveMapOfGetWithLiteralKey(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Routes.java"),
"package com.example;\n" +
"public class Routes {\n" +
" public static final String PAY = \"OrderEvents.PAY\";\n" +
" public static final String SHIP = \"OrderEvents.SHIP\";\n" +
" public String lookup() {\n" +
" return java.util.Map.of(\"order.pay\", PAY, \"order.ship\", SHIP).get(\"order.pay\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.Routes");
MethodDeclaration lookup = td.getMethods()[0];
ReturnStatement rs = (ReturnStatement) lookup.getBody().statements().get(0);
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(rs.getExpression(), context);
assertThat(result).isEqualTo("OrderEvents.PAY");
}
@Test
void shouldResolveStaticMapFieldGetWithLiteralKey(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Dispatcher.java"),
"package com.example;\n" +
"import java.util.Map;\n" +
"public class Dispatcher {\n" +
" private static final Map<String, String> ROUTES = Map.of(\n" +
" \"order.pay\", \"OrderEvents.PAY\",\n" +
" \"order.ship\", \"OrderEvents.SHIP\"\n" +
" );\n" +
" public String route() {\n" +
" return ROUTES.get(\"order.ship\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.Dispatcher");
MethodDeclaration route = td.getMethods()[0];
ReturnStatement rs = (ReturnStatement) route.getBody().statements().get(0);
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(rs.getExpression(), context);
assertThat(result).isEqualTo("OrderEvents.SHIP");
}
@Test
void shouldResolveLiteralStringTransforms(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("OrderEvent.java"),
"package com.example;\n" +
"public enum OrderEvent { PAY, SHIP }\n");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public OrderEvent fromLiteral() {\n" +
" return OrderEvent.valueOf(\"pay\".toUpperCase());\n" +
" }\n" +
" public String trimmed() {\n" +
" return \" pay \".trim().toUpperCase();\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration fromLiteral = td.getMethods()[0];
MethodDeclaration trimmed = td.getMethods()[1];
ReturnStatement rs1 = (ReturnStatement) fromLiteral.getBody().statements().get(0);
ReturnStatement rs2 = (ReturnStatement) trimmed.getBody().statements().get(0);
ConstantResolver resolver = new ConstantResolver();
assertThat(resolver.resolve(rs1.getExpression(), context)).isEqualTo("com.example.OrderEvent.PAY");
assertThat(resolver.resolve(rs2.getExpression(), context)).isEqualTo("PAY");
}
@Test
void shouldResolveStaticFieldUsingCallPathScope(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("ApiController.java"),
"package com.example;\n" +
"import java.util.Map;\n" +
"public class ApiController {\n" +
" private static final Map<String, OrderEvent> ROUTES = Map.of(\n" +
" \"order.pay\", OrderEvent.PAY,\n" +
" \"order.ship\", OrderEvent.SHIP\n" +
" );\n" +
" public void dispatch(String commandKey) {\n" +
" OrderEvent event = ROUTES.get(commandKey);\n" +
" }\n" +
"}\n" +
"enum OrderEvent { PAY, SHIP }\n");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
ASTParser parser = ASTParser.newParser(AST.JLS17);
parser.setKind(ASTParser.K_COMPILATION_UNIT);
parser.setSource(("class A { Object o = ROUTES.get(commandKey); }").toCharArray());
CompilationUnit cu = (CompilationUnit) parser.createAST(null);
FieldDeclaration fd = (FieldDeclaration) ((TypeDeclaration) cu.types().get(0)).bodyDeclarations().get(0);
MethodInvocation getCall = (MethodInvocation) ((VariableDeclarationFragment) fd.fragments().get(0)).getInitializer();
ConstantExtractor extractor = new ConstantExtractor(context, new ConstantResolver(), mi -> null);
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath(
List.of("com.example.ApiController.dispatch"));
try {
ConstantResolver resolver = new ConstantResolver();
SimpleName routesName = (SimpleName) getCall.getExpression();
assertThat(resolver.resolve(routesName, context))
.as("static ROUTES field should resolve to encoded map")
.startsWith("MAP:");
List<String> constants = new ArrayList<>();
extractor.extractConstantsFromExpression(getCall, constants);
assertThat(constants).contains("OrderEvent.PAY", "OrderEvent.SHIP");
} finally {
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath();
}
}
@Test
void shouldResolveArrayIndexWithLiteralMath(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Table.java"),
"package com.example;\n" +
"public class Table {\n" +
" private static final String[] EVENTS = {\"OrderEvents.PAY\", \"OrderEvents.SHIP\", \"OrderEvents.CANCEL\"};\n" +
" public String pick() {\n" +
" return EVENTS[1 + 1];\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.Table");
MethodDeclaration pick = td.getMethods()[0];
ReturnStatement rs = (ReturnStatement) pick.getBody().statements().get(0);
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(rs.getExpression(), context);
assertThat(result).isEqualTo("OrderEvents.CANCEL");
}
}

View File

@@ -0,0 +1,30 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class EnterpriseStringMachineTypeTest {
@Test
void shouldResolveStringTypesForEnterpriseConfig() throws Exception {
Path projectRoot = Path.of("../state_machines/enterprise_order_system").toAbsolutePath().normalize();
Path javaRoot = projectRoot.resolve("src/main/java");
CodebaseContext context = new CodebaseContext();
context.setSourcepath(List.of(javaRoot.toString()));
context.scan(javaRoot);
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
"click.kamil.examples.enterprise.config.EnterpriseStateMachineConfig", context);
assertThat(types.stateTypeFqn()).isEqualTo("String");
assertThat(types.eventTypeFqn()).isEqualTo("String");
assertThat(MachineEnumCanonicalizer.canonicalizeLabel("NEW", types.stateTypeFqn()))
.isEqualTo("String.NEW");
}
}

View File

@@ -0,0 +1,173 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class GeneratedSourceDiscoveryTest {
private final GeneratedSourceDiscovery discovery = new GeneratedSourceDiscovery();
@Nested
class MavenTarget {
@Test
void shouldDiscoverMapStructLikeSourcesUnderTarget(@TempDir Path tempDir) throws IOException {
Path module = tempDir.resolve("service");
Path generated = module.resolve("target/generated-sources/annotations/com/acme/mapper");
Files.createDirectories(generated);
Files.writeString(generated.resolve("OrderMapperImpl.java"), """
package com.acme.mapper;
public class OrderMapperImpl implements OrderMapper {}
""");
assertThat(discovery.discoverSourceRoots(module))
.containsExactly(module.resolve("target").normalize());
}
@Test
void shouldIgnoreCompiledClassesUnderTarget(@TempDir Path tempDir) throws IOException {
Path module = tempDir.resolve("service");
Path classes = module.resolve("target/classes/com/acme");
Files.createDirectories(classes);
Files.writeString(classes.resolve("Accidental.java"), "class Accidental {}");
assertThat(discovery.discoverSourceRoots(module)).isEmpty();
assertThat(GeneratedSourceDiscovery.isExcludedBuildPath(module, classes.resolve("Accidental.java")))
.isTrue();
}
}
@Nested
class GradleBuild {
@Test
void shouldDiscoverAnnotationProcessorOutput(@TempDir Path tempDir) throws IOException {
Path module = tempDir.resolve("app");
Path generated = module.resolve(
"build/generated/sources/annotationProcessor/java/main/com/acme/config");
Files.createDirectories(generated);
Files.writeString(generated.resolve("ApplicationConfigImpl.java"), """
package com.acme.config;
public class ApplicationConfigImpl {}
""");
assertThat(discovery.discoverSourceRoots(module))
.containsExactly(module.resolve("build").normalize());
}
@Test
void shouldIgnoreGradleBuildClasses(@TempDir Path tempDir) throws IOException {
Path module = tempDir.resolve("app");
Path classes = module.resolve("build/classes/java/main/com/acme");
Files.createDirectories(classes);
Files.writeString(classes.resolve("ShouldSkip.java"), "class ShouldSkip {}");
assertThat(discovery.discoverSourceRoots(module)).isEmpty();
}
@Test
void shouldIgnoreGradleTestAnnotationProcessorOutput(@TempDir Path tempDir) throws IOException {
Path module = tempDir.resolve("app");
Path generated = module.resolve(
"build/generated/sources/annotationProcessor/java/test/com/acme/testsupport");
Files.createDirectories(generated);
Files.writeString(generated.resolve("TestSupportImpl.java"), "class TestSupportImpl {}");
assertThat(discovery.discoverSourceRoots(module)).isEmpty();
}
}
@Nested
class TestOutputExclusion {
@Test
void shouldIgnoreMavenGeneratedTestSources(@TempDir Path tempDir) throws IOException {
Path module = tempDir.resolve("service");
Path generated = module.resolve("target/generated-test-sources/test-annotations/com/acme");
Files.createDirectories(generated);
Files.writeString(generated.resolve("TestMapperImpl.java"), "class TestMapperImpl {}");
assertThat(discovery.discoverSourceRoots(module)).isEmpty();
}
@Test
void shouldIgnoreTestClassesUnderTarget(@TempDir Path tempDir) throws IOException {
Path module = tempDir.resolve("service");
Path testClasses = module.resolve("target/test-classes/com/acme");
Files.createDirectories(testClasses);
Files.writeString(testClasses.resolve("OrderServiceTest.java"), "class OrderServiceTest {}");
assertThat(discovery.discoverSourceRoots(module)).isEmpty();
}
}
@Nested
class Integration {
@Test
void shouldAddGeneratedRootsToProjectScanPaths(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("monorepo");
Files.createDirectories(root.resolve(".git"));
Files.writeString(root.resolve("settings.gradle"), "include 'api'");
Path api = root.resolve("api");
Files.createDirectories(api);
Files.writeString(api.resolve("build.gradle"), "");
Path generated = api.resolve("target/generated-sources/annotations/com/example");
Files.createDirectories(generated);
Files.writeString(generated.resolve("GeneratedBean.java"), """
package com.example;
public class GeneratedBean {}
""");
StaticBuildFileAnalyzer analyzer = new StaticBuildFileAnalyzer();
ProjectModuleGraph graph = analyzer.analyze(root);
assertThat(graph.resolveScanPaths(root)).contains(api.resolve("target").normalize());
}
@Test
void shouldSkipGeneratedRootsWhenDisabled(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("monorepo");
Files.createDirectories(root.resolve(".git"));
Files.writeString(root.resolve("settings.gradle"), "include 'api'");
Path api = root.resolve("api");
Files.createDirectories(api);
Files.writeString(api.resolve("build.gradle"), "");
Path generated = api.resolve("target/generated-sources/annotations/com/example");
Files.createDirectories(generated);
Files.writeString(generated.resolve("GeneratedBean.java"), "package com.example; class GeneratedBean {}");
ProjectModuleGraph graph = new StaticBuildFileAnalyzer().analyze(root);
assertThat(graph.resolveScanPaths(root, false)).doesNotContain(api.resolve("target").normalize());
assertThat(graph.resolveScanPaths(root, true)).contains(api.resolve("target").normalize());
}
@Test
void shouldIndexGeneratedTypesWhenScanningModule() throws IOException {
Path module = Files.createTempDirectory("sme-gen-src").resolve("service");
Path generated = module.resolve("target/generated-sources/annotations/com/acme");
Files.createDirectories(generated);
Files.writeString(generated.resolve("GeneratedEvent.java"), """
package com.acme;
public enum GeneratedEvent { PAY, SHIP }
""");
CodebaseContext context = new CodebaseContext();
context.scan(Set.of(module), Set.of());
assertThat(context.getCompilationUnits()).hasSize(1);
assertThat(context.getEnumValues("GeneratedEvent"))
.contains("com.acme.GeneratedEvent.PAY", "com.acme.GeneratedEvent.SHIP");
}
}
}

View File

@@ -0,0 +1,26 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MachineEnumCanonicalizerCrossPackageTest {
@Test
void shouldNotRewriteCrossPackageEnumReferencesWhenSimpleNamesMatch() {
String machineEventType = "com.bar.OrderEvents";
String foreignEvent = "com.foo.OrderEvents.PAY";
assertThat(MachineEnumCanonicalizer.enumTypesMatch(machineEventType, "com.foo.OrderEvents"))
.isFalse();
assertThat(MachineEnumCanonicalizer.canonicalizeLabel(foreignEvent, machineEventType))
.isEqualTo(foreignEvent);
}
@Test
void shouldCanonicalizeImportStyleReferencesForMachineEnumType() {
assertThat(MachineEnumCanonicalizer.canonicalizeLabel(
"OrderEvents.PAY", "com.bar.OrderEvents"))
.isEqualTo("com.bar.OrderEvents.PAY");
}
}

View File

@@ -0,0 +1,179 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class MachineEnumCanonicalizerTest {
@Test
void shouldResolveMachineEnumTypesFromConfigurerAdapter(@TempDir Path tempDir) throws IOException {
writeSampleConfig(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
"com.example.config.OrderStateMachineConfiguration", context);
assertThat(types.stateTypeFqn()).isEqualTo("com.example.order.OrderState");
assertThat(types.eventTypeFqn()).isEqualTo("com.example.order.OrderEvent");
}
@Test
void shouldCanonicalizeTransitionIdentifiersUsingMachineTypes(@TempDir Path tempDir) throws IOException {
writeSampleConfig(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
"com.example.config.OrderStateMachineConfiguration", context);
Transition transition = new Transition();
transition.setEvent(Event.of("OrderEvent.PAY", "OrderEvent.PAY"));
transition.setSourceStates(List.of(State.of("OrderState.NEW", "OrderState.NEW")));
transition.setTargetStates(List.of(State.of("OrderState.PAID", "OrderState.PAID")));
MachineEnumCanonicalizer.canonicalizeTransitions(List.of(transition), types);
assertThat(transition.getEvent().fullIdentifier())
.isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(transition.getEvent().rawName()).isEqualTo("OrderEvent.PAY");
assertThat(transition.getSourceStates().get(0).fullIdentifier())
.isEqualTo("com.example.order.OrderState.NEW");
assertThat(transition.getSourceStates().get(0).rawName()).isEqualTo("OrderState.NEW");
assertThat(transition.getTargetStates().get(0).fullIdentifier())
.isEqualTo("com.example.order.OrderState.PAID");
assertThat(transition.getTargetStates().get(0).rawName()).isEqualTo("OrderState.PAID");
}
@Test
void shouldAlignBareRawNameToFnForm(@TempDir Path tempDir) throws IOException {
writeSampleConfig(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
"com.example.config.OrderStateMachineConfiguration", context);
Transition transition = new Transition();
transition.setEvent(Event.of("PAY", "PAY"));
transition.setSourceStates(List.of(State.of("NEW", "NEW")));
MachineEnumCanonicalizer.canonicalizeTransitions(List.of(transition), types);
assertThat(transition.getEvent().rawName()).isEqualTo("OrderEvent.PAY");
assertThat(transition.getSourceStates().get(0).rawName()).isEqualTo("OrderState.NEW");
}
@Test
void shouldCanonicalizeBareConstantNames(@TempDir Path tempDir) throws IOException {
writeSampleConfig(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
"com.example.config.OrderStateMachineConfiguration", context);
assertThat(MachineEnumCanonicalizer.canonicalizeLabel("PAY", types.eventTypeFqn()))
.isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(MachineEnumCanonicalizer.canonicalizeStateLabels(Set.of("NEW"), types.stateTypeFqn()))
.containsExactly("com.example.order.OrderState.NEW");
}
@Test
void shouldLeaveStringStatesUntouched(@TempDir Path tempDir) throws IOException {
writeSampleConfig(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
"com.example.config.OrderStateMachineConfiguration", context);
assertThat(MachineEnumCanonicalizer.canonicalizeLabel("\"DRAFT\"", types.stateTypeFqn()))
.isEqualTo("DRAFT");
}
@Test
void shouldCanonicalizePolymorphicEventsUsingMachineEventType(@TempDir Path tempDir) throws IOException {
writeSampleConfig(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
"com.example.config.OrderStateMachineConfiguration", context);
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.PAY")
.className("com.example.web.Controller")
.methodName("pay")
.sourceFile("Controller.java")
.polymorphicEvents(List.of("OrderEvent.PAY", "PAY"))
.eventTypeFqn("OrderEvent")
.build();
TriggerPoint canonical = MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, types);
assertThat(canonical.getPolymorphicEvents()).containsExactly(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderEvent.PAY");
assertThat(canonical.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY");
}
@Test
void shouldQualifyEventIdentifierToPackageFqn() {
String eventTypeFqn = "com.example.order.OrderEvent";
assertThat(MachineEnumCanonicalizer.qualifyEventIdentifier("PAY", eventTypeFqn))
.isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(MachineEnumCanonicalizer.qualifyEventIdentifier("OrderEvent.PAY", eventTypeFqn))
.isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(MachineEnumCanonicalizer.qualifyEventIdentifier("getType()", eventTypeFqn))
.isEqualTo("getType()");
}
private static void writeSampleConfig(Path tempDir) throws IOException {
Path orderPkg = tempDir.resolve("com/example/order");
Path configPkg = tempDir.resolve("com/example/config");
Files.createDirectories(orderPkg);
Files.createDirectories(configPkg);
Files.writeString(orderPkg.resolve("OrderState.java"),
"""
package com.example.order;
public enum OrderState { NEW, PAID }
""");
Files.writeString(orderPkg.resolve("OrderEvent.java"),
"""
package com.example.order;
public enum OrderEvent { PAY }
""");
Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"),
"""
package com.example.config;
import com.example.order.OrderEvent;
import com.example.order.OrderState;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
public class OrderStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
}
""");
}
}

View File

@@ -0,0 +1,39 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class StateMachineTypeResolverEnterpriseTest {
@Test
void shouldResolveConcreteTypesThroughGenericAbstractBase() throws Exception {
Path root = findProjectRoot();
Path enterprise = root.resolve("state_machines/state_machine_enterprise");
CodebaseContext context = new CodebaseContext();
context.scan(enterprise);
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
"click.kamil.enterprise.machines.order.OrderStateMachineConfiguration", context);
assertThat(types.stateTypeFqn())
.isEqualTo("click.kamil.enterprise.machines.order.OrderState");
assertThat(types.eventTypeFqn())
.isEqualTo("click.kamil.enterprise.machines.order.OrderEvent");
assertThat(StateMachineTypeResolver.extendsEnumStateMachineConfigurer(
"click.kamil.enterprise.machines.order.OrderStateMachineConfiguration", context))
.isTrue();
}
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,510 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
class StaticBuildFileAnalyzerTest {
private final StaticBuildFileAnalyzer analyzer = new StaticBuildFileAnalyzer();
@Nested
class GradleIncludes {
@Test
void shouldParseSettingsGradleIncludeList(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("monorepo");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), """
rootProject.name = 'monorepo'
include 'module-a', 'module-b', 'module-c'
""");
createGradleModule(root, "module-a");
createGradleModule(root, "module-b");
createGradleModule(root, "module-c");
Map<String, Path> includes = analyzer.parseGradleIncludes(root);
assertThat(includes).hasSize(3);
assertThat(includes.get("module-a")).isEqualTo(root.resolve("module-a").normalize());
assertThat(includes.get("module-b")).isEqualTo(root.resolve("module-b").normalize());
assertThat(includes.get("module-c")).isEqualTo(root.resolve("module-c").normalize());
}
@Test
void shouldParseSettingsGradleKtsIncludeCall(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("monorepo");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle.kts"), """
include(":api", ":core")
""");
createGradleModule(root, "api");
createGradleModule(root, "core");
Map<String, Path> includes = analyzer.parseGradleIncludes(root);
assertThat(includes).containsKeys("api", "core");
assertThat(includes.get("api")).isEqualTo(root.resolve("api").normalize());
}
@Test
void shouldResolveNestedIncludePaths(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("monorepo");
Path nested = root.resolve("state_machines/multi/api");
Files.createDirectories(nested);
Files.writeString(root.resolve("settings.gradle"), """
include ':state_machines:multi:api', ':state_machines:multi:core'
""");
createGradleModule(root, "state_machines/multi/api");
createGradleModule(root, "state_machines/multi/core");
Map<String, Path> includes = analyzer.parseGradleIncludes(root);
assertThat(includes.get("state_machines:multi:api"))
.isEqualTo(root.resolve("state_machines/multi/api").normalize());
assertThat(includes.get("state_machines:multi:core"))
.isEqualTo(root.resolve("state_machines/multi/core").normalize());
}
@Test
void shouldHonorCustomProjectDirMapping(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("monorepo");
Path custom = root.resolve("custom-location/service");
Files.createDirectories(custom);
Files.writeString(root.resolve("settings.gradle"), """
include ':service'
project(':service').projectDir = file('custom-location/service')
""");
createGradleModule(custom, ".");
Map<String, Path> includes = analyzer.parseGradleIncludes(root);
assertThat(includes.get("service")).isEqualTo(custom.normalize());
}
@Test
void shouldDiscoverAllIncludedModulesWhenScanningFromRoot(@TempDir Path tempDir) throws IOException {
Path root = writeGradleMonorepo(tempDir);
ProjectModuleGraph graph = analyzer.analyze(root.resolve("module-a"));
Set<Path> scanPaths = graph.resolveScanPaths(root);
assertThat(scanPaths).containsExactlyInAnyOrder(
root.normalize(),
root.resolve("module-a").normalize(),
root.resolve("module-b").normalize(),
root.resolve("module-c").normalize());
}
}
@Nested
class GradleDependencyGraph {
@Test
void shouldBuildTransitiveProjectDependencyGraph(@TempDir Path tempDir) throws IOException {
Path root = writeGradleMonorepo(tempDir);
ProjectModuleGraph graph = analyzer.analyze(root.resolve("module-a"));
List<ProjectModuleGraph.ModuleDependency> localDeps = graph.dependencies().stream()
.filter(dep -> dep.kind() == ProjectModuleGraph.DependencyKind.LOCAL_PROJECT)
.toList();
assertThat(localDeps).anyMatch(dep ->
dep.fromModuleId().contains("module-a") && dep.toModuleId().contains("module-b"));
assertThat(localDeps).anyMatch(dep ->
dep.fromModuleId().contains("module-b") && dep.toModuleId().contains("module-c"));
Set<Path> siblings = new SiblingDependencyResolver().resolveSiblings(graph, root.resolve("module-a"));
assertThat(siblings).containsExactlyInAnyOrder(
root.resolve("module-b").normalize(),
root.resolve("module-c").normalize());
}
@Test
void shouldParseBuildGradleKtsProjectDependencies(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("monorepo");
Files.createDirectories(root);
Files.createDirectories(root.resolve(".git"));
Files.writeString(root.resolve("settings.gradle.kts"), "include(\":app\", \":lib\")");
Files.createDirectories(root.resolve("app"));
Files.writeString(root.resolve("app/build.gradle.kts"), """
dependencies {
implementation(project(":lib"))
}
""");
Files.createDirectories(root.resolve("lib"));
Files.writeString(root.resolve("lib/build.gradle.kts"), "");
ProjectModuleGraph graph = analyzer.analyze(root.resolve("app"));
boolean hasLibDep = graph.dependencies().stream()
.anyMatch(dep -> dep.kind() == ProjectModuleGraph.DependencyKind.LOCAL_PROJECT
&& dep.fromModuleId().contains("app")
&& dep.toModuleId().contains("lib"));
assertThat(hasLibDep).isTrue();
}
@Test
void shouldIgnoreTestImplementationProjectDependencies(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("monorepo");
Files.createDirectories(root.resolve(".git"));
Files.writeString(root.resolve("settings.gradle"), "include 'app', 'lib', 'test-lib'");
createGradleModule(root, "app");
createGradleModule(root, "lib");
createGradleModule(root, "test-lib");
Files.writeString(root.resolve("app/build.gradle"), """
dependencies {
implementation project(':lib')
testImplementation project(':test-lib')
}
""");
ProjectModuleGraph graph = analyzer.analyze(root.resolve("app"));
List<ProjectModuleGraph.ModuleDependency> localDeps = graph.dependencies().stream()
.filter(dep -> dep.kind() == ProjectModuleGraph.DependencyKind.LOCAL_PROJECT)
.toList();
assertThat(localDeps).anyMatch(dep -> dep.toModuleId().contains("lib"));
assertThat(localDeps).noneMatch(dep -> dep.toModuleId().contains("test-lib"));
}
}
@Nested
class MavenCoordinates {
@Test
void shouldIndexModulesByGroupIdAndArtifactId(@TempDir Path tempDir) throws IOException {
Path root = writeMavenMultiModule(tempDir);
ProjectModuleGraph graph = analyzer.analyze(root.resolve("module-a"));
assertThat(graph.modulesByCoordinates()).containsKeys(
new ProjectModuleGraph.MavenCoordinates("com.acme", "module-a"),
new ProjectModuleGraph.MavenCoordinates("com.acme", "module-b"),
new ProjectModuleGraph.MavenCoordinates("com.acme", "module-c"));
}
@Test
void shouldDistinguishSameArtifactIdUnderDifferentGroups(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("repo");
Files.createDirectories(root.resolve(".git"));
writePom(root.resolve("pom.xml"), """
<project>
<groupId>com.repo</groupId>
<artifactId>parent</artifactId>
<packaging>pom</packaging>
<modules>
<module>team-a/api</module>
<module>team-b/api</module>
<module>consumer</module>
</modules>
</project>
""");
writePom(root.resolve("team-a/api/pom.xml"), """
<project>
<groupId>com.team.a</groupId>
<artifactId>api</artifactId>
</project>
""");
writePom(root.resolve("team-b/api/pom.xml"), """
<project>
<groupId>com.team.b</groupId>
<artifactId>api</artifactId>
</project>
""");
writePom(root.resolve("consumer/pom.xml"), """
<project>
<groupId>com.consumer</groupId>
<artifactId>consumer</artifactId>
<dependencies>
<dependency>
<groupId>com.team.a</groupId>
<artifactId>api</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>
""");
ProjectModuleGraph graph = analyzer.analyze(root.resolve("consumer"));
List<ProjectModuleGraph.ModuleDependency> deps = graph.localDependenciesOf(
graph.modulesByCoordinates()
.get(new ProjectModuleGraph.MavenCoordinates("com.consumer", "consumer"))
.id());
assertThat(deps).hasSize(1);
assertThat(deps.get(0).toModuleId()).isEqualTo(
graph.modulesByCoordinates().get(new ProjectModuleGraph.MavenCoordinates("com.team.a", "api")).id());
}
@Test
void shouldMarkExternalLibraryWhenCoordinatesAreNotInRepo(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("repo");
Files.createDirectories(root.resolve(".git"));
writePom(root.resolve("service/pom.xml"), """
<project>
<groupId>com.acme</groupId>
<artifactId>service</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>
</dependencies>
</project>
""");
ProjectModuleGraph graph = analyzer.analyze(root.resolve("service"));
String serviceId = graph.modulesByCoordinates()
.get(new ProjectModuleGraph.MavenCoordinates("com.acme", "service")).id();
assertThat(graph.dependencies()).anyMatch(dep ->
dep.fromModuleId().equals(serviceId)
&& dep.kind() == ProjectModuleGraph.DependencyKind.EXTERNAL_LIBRARY
&& dep.externalCoordinates().key().equals("org.springframework.boot:spring-boot-starter"));
}
@Test
void shouldIgnoreTestScopedMavenDependencies(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("repo");
Files.createDirectories(root.resolve(".git"));
writePom(root.resolve("pom.xml"), """
<project>
<groupId>com.repo</groupId>
<artifactId>parent</artifactId>
<packaging>pom</packaging>
<modules><module>app</module><module>test-support</module></modules>
</project>
""");
writePom(root.resolve("app/pom.xml"), """
<project>
<groupId>com.repo</groupId>
<artifactId>app</artifactId>
<dependencies>
<dependency>
<groupId>com.repo</groupId>
<artifactId>test-support</artifactId>
<version>1.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
""");
writePom(root.resolve("test-support/pom.xml"), """
<project><groupId>com.repo</groupId><artifactId>test-support</artifactId></project>
""");
ProjectModuleGraph graph = analyzer.analyze(root.resolve("app"));
String appId = graph.modulesByCoordinates()
.get(new ProjectModuleGraph.MavenCoordinates("com.repo", "app")).id();
assertThat(graph.localDependenciesOf(appId)).isEmpty();
}
}
@Nested
class MavenModules {
@Test
void shouldDiscoverAggregatorModules(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("aggregator");
Files.createDirectories(root.resolve(".git"));
writePom(root.resolve("pom.xml"), """
<project>
<groupId>com.acme</groupId>
<artifactId>root</artifactId>
<packaging>pom</packaging>
<modules>
<module>service-x</module>
<module>service-y</module>
</modules>
</project>
""");
writePom(root.resolve("service-x/pom.xml"), """
<project><groupId>com.acme</groupId><artifactId>service-x</artifactId></project>
""");
writePom(root.resolve("service-y/pom.xml"), """
<project><groupId>com.acme</groupId><artifactId>service-y</artifactId></project>
""");
ProjectModuleGraph graph = analyzer.analyze(root);
Set<Path> siblings = new SiblingDependencyResolver().resolveSiblings(graph, root);
assertThat(siblings).containsExactlyInAnyOrder(
root.resolve("service-x").normalize(),
root.resolve("service-y").normalize());
}
@Test
void shouldResolveTransitiveMavenModuleDependencies(@TempDir Path tempDir) throws IOException {
Path root = writeMavenMultiModule(tempDir);
Set<Path> siblings = new SiblingDependencyResolver().resolveSiblings(root.resolve("module-a"));
assertThat(siblings).containsExactlyInAnyOrder(
root.resolve("module-b").normalize(),
root.resolve("module-c").normalize(),
root.normalize());
}
@Test
void shouldResolveParentViaRelativePath(@TempDir Path tempDir) throws IOException {
Path root = tempDir.resolve("root");
Path child = tempDir.resolve("child");
Files.createDirectories(root);
Files.createDirectories(child);
writePom(root.resolve("pom.xml"), """
<project><groupId>com.acme</groupId><artifactId>root</artifactId></project>
""");
writePom(child.resolve("pom.xml"), """
<project>
<parent>
<relativePath>../root/pom.xml</relativePath>
</parent>
<artifactId>child</artifactId>
</project>
""");
Set<Path> scanPaths = analyzer.analyze(child).resolveScanPaths(child);
assertThat(scanPaths).contains(root.toAbsolutePath().normalize());
}
@Test
void shouldPreferNestedMavenAggregatorOverOuterGradleRoot(@TempDir Path tempDir) throws IOException {
Path outerGradle = tempDir.resolve("outer");
Path nestedMaven = outerGradle.resolve("nested-maven");
Files.createDirectories(outerGradle.resolve(".git"));
Files.writeString(outerGradle.resolve("settings.gradle"), "rootProject.name = 'outer'");
writePom(nestedMaven.resolve("pom.xml"), """
<project>
<groupId>com.nested</groupId>
<artifactId>parent</artifactId>
<packaging>pom</packaging>
<modules><module>api</module></modules>
</project>
""");
writePom(nestedMaven.resolve("api/pom.xml"), """
<project><groupId>com.nested</groupId><artifactId>api</artifactId></project>
""");
ProjectModuleGraph graph = analyzer.analyze(nestedMaven.resolve("api"));
assertThat(graph.workspaceRoot()).isEqualTo(nestedMaven.normalize());
assertThat(graph.allModuleDirectories()).containsExactlyInAnyOrder(
nestedMaven.resolve("api").normalize(),
nestedMaven.normalize());
}
}
@Nested
class ExtractHelpers {
@Test
void shouldExtractGradleIncludeTokensFromMixedSyntax() {
String settings = """
pluginManagement {}
include ':api'
include(":core", ":infra")
include 'web', 'worker'
""";
assertThat(StaticBuildFileAnalyzer.extractGradleIncludePaths(settings))
.containsExactlyInAnyOrder("api", "core", "infra", "web", "worker");
}
@Test
void shouldExtractOwnMavenCoordinatesSkippingParentSection() {
String pom = """
<project>
<parent>
<groupId>com.parent</groupId>
<artifactId>parent</artifactId>
</parent>
<artifactId>child</artifactId>
<groupId>com.child</groupId>
</project>
""";
assertThat(StaticBuildFileAnalyzer.extractOwnMavenCoordinates(pom))
.contains(new ProjectModuleGraph.MavenCoordinates("com.child", "child"));
}
}
private static Path writeGradleMonorepo(Path tempDir) throws IOException {
Path root = tempDir.resolve("my-root-project");
Files.createDirectories(root);
Files.createDirectories(root.resolve(".git"));
Files.writeString(root.resolve("settings.gradle"), "include 'module-a', 'module-b', 'module-c'");
createGradleModule(root, "module-a");
createGradleModule(root, "module-b");
createGradleModule(root, "module-c");
Files.writeString(root.resolve("module-a/build.gradle"), "dependencies { implementation project(':module-b') }");
Files.writeString(root.resolve("module-b/build.gradle"), "dependencies { implementation project(':module-c') }");
return root;
}
private static Path writeMavenMultiModule(Path tempDir) throws IOException {
Path root = tempDir.resolve("my-maven-root");
Files.createDirectories(root.resolve(".git"));
writePom(root.resolve("pom.xml"), """
<project><groupId>com.acme</groupId><artifactId>root</artifactId></project>
""");
writePom(root.resolve("module-a/pom.xml"), """
<project>
<groupId>com.acme</groupId>
<artifactId>module-a</artifactId>
<dependencies>
<dependency>
<groupId>com.acme</groupId>
<artifactId>module-b</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>
""");
writePom(root.resolve("module-b/pom.xml"), """
<project>
<groupId>com.acme</groupId>
<artifactId>module-b</artifactId>
<dependencies>
<dependency>
<groupId>com.acme</groupId>
<artifactId>module-c</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>
""");
writePom(root.resolve("module-c/pom.xml"), """
<project><groupId>com.acme</groupId><artifactId>module-c</artifactId></project>
""");
return root;
}
private static void createGradleModule(Path root, String modulePath) throws IOException {
Path moduleDir = ".".equals(modulePath) ? root : root.resolve(modulePath);
Files.createDirectories(moduleDir);
Path buildFile = moduleDir.resolve("build.gradle");
if (!Files.exists(buildFile)) {
Files.writeString(buildFile, "");
}
}
private static void writePom(Path pomFile, String content) throws IOException {
Files.createDirectories(pomFile.getParent());
Files.writeString(pomFile, content);
}
}

View File

@@ -0,0 +1,414 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.DataFlowModel;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Quirk-focused tests for accessor inlining edge cases: super dispatch, field-backed chains,
* inheritance, and look-alike patterns that must not be confused.
*/
class AccessorInliningEdgeCasesTest {
@Nested
class SuperMethodAccessorShortcut {
@Test
void shouldReadFieldDeclaredOnGrandparent(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Grand.java", """
package com.example;
public class Grand {
protected String token = "GRAND";
public String getToken() { return token; }
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Grand {
public String capture() {
return super.getToken();
}
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Child child = new Child();
String value = child.capture();
}
}
""");
assertResolved(tempDir, "value", "GRAND");
}
@Test
void shouldNotShortcutWhenSuperGetterIsNonTrivial(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event = "IGNORED";
public String getEvent() {
return event == null ? "NONE" : event;
}
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
public String capture() {
return super.getEvent();
}
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Child child = new Child();
String value = child.capture();
}
}
""");
assertThat(accessorLookup(tempDir, "com.example.Base", "getEvent")).isEmpty();
}
@Test
void callGraphShouldPreferSuperFieldOverChildOverride(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
ChildController controller;
public void run() {
controller.process();
}
}
class ChildController extends ParentController {
@Override
public OrderEvent getEvent() { return OrderEvent.SHIP; }
public void process() {
StateMachine sm = new StateMachine();
sm.sendEvent(super.getEvent());
}
}
class ParentController {
private OrderEvent event = OrderEvent.PAY;
public OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, "com.example.ApiController", "run", "com.example.StateMachine");
assertPolyEvents(chain, "OrderEvent.PAY");
}
}
@Nested
class FieldBackedGetterChains {
@Test
void shouldResolveDeepFieldBackedChainWithoutHopLimit(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(Order order) {
dispatcher.fire(order);
}
}
class CentralDispatcher {
public void fire(Order order) {
StateMachine sm = new StateMachine();
sm.sendEvent(order.getPayload().getEvent().getCode());
}
}
class Order {
private Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
class Payload {
private Event event = new Event();
public Event getEvent() { return event; }
}
class Event {
private OrderEvent code = OrderEvent.PAY;
public OrderEvent getCode() { return code; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(chain, "OrderEvent.PAY");
}
@Test
void shouldResolveFieldInitializerDeclaredOnSuperclass(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(BaseWrapper wrapper) {
dispatcher.fire(wrapper);
}
}
class CentralDispatcher {
public void fire(BaseWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getPayload().getType());
}
}
class BaseWrapper {
protected Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
class Payload {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() { return type; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(chain, "OrderEvent.PAY");
}
@Test
void shouldNotUseIndexedFieldWhenInnerGetterReturnsLiteral(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fire(wrapper);
}
}
class CentralDispatcher {
public void fire(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
private RichEvent event = new RichEvent();
public RichEvent getEvent() { return event; }
}
class RichEvent {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() {
return OrderEvent.SHIP;
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(chain, "OrderEvent.SHIP");
}
@Test
void shouldDistinguishFieldBackedChainFromLiteralReturnChain(@TempDir Path tempDir) throws Exception {
String literalSource = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fire(wrapper);
}
}
class CentralDispatcher {
public void fire(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
public RichEvent getEvent() { return new RichEvent(); }
}
class RichEvent {
public OrderEvent getType() { return OrderEvent.PAY; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
Path literalDir = tempDir.resolve("literal");
Files.createDirectories(literalDir);
CodebaseContext literalContext = scanSource(literalSource, literalDir);
CallChain literalChain = resolveChain(
literalContext, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(literalChain, "OrderEvent.PAY");
String fieldSource = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fire(wrapper);
}
}
class CentralDispatcher {
public void fire(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
private RichEvent event = new RichEvent();
public RichEvent getEvent() { return event; }
}
class RichEvent {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() { return type; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
Path fieldDir = tempDir.resolve("field");
Files.createDirectories(fieldDir);
CodebaseContext fieldContext = scanSource(fieldSource, fieldDir);
CallChain fieldChain = resolveChain(
fieldContext, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(fieldChain, "OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(fieldChain, "com.example.OrderStateMachineConfig",
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
}
}
@Nested
class LookAlikePatterns {
@Test
void shouldNotTreatListGetAsBeanAccessorInDataflow(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
import java.util.List;
public class Runner {
public void run() {
List<String> events = List.of("A", "B");
String value = events.get(0);
}
}
""");
CodebaseContext context = scan(tempDir);
assertThat(context.getAccessorIndex().lookup("java.util.List", "get")).isEmpty();
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", "value");
assertThat(model.getReachingDefinitions(initializer)).isNotEmpty();
}
@Test
void shouldNotIndexBareGetMethod(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
static class Box {
public String get() { return "X"; }
}
public void run() {
Box box = new Box();
String value = box.get();
}
}
""");
assertThat(accessorLookup(tempDir, "com.example.Runner.Box", "get")).isEmpty();
assertResolved(tempDir, "value", "X");
}
}
private static void assertResolved(Path tempDir, String variableName, String expected) throws IOException {
CodebaseContext context = scan(tempDir);
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", variableName);
String resolved = model.resolveValue(initializer, context);
assertThat(resolved).isEqualTo(expected);
}
private static Optional<AccessorSummary> accessorLookup(Path tempDir, String ownerFqn, String methodName)
throws IOException {
return scan(tempDir).getAccessorIndex().lookup(ownerFqn, methodName);
}
private static CodebaseContext scan(Path tempDir) throws IOException {
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
context.scan(tempDir);
return context;
}
private static Expression findInitializer(CodebaseContext context, String methodName, String variableName) {
for (TypeDeclaration type : context.getTypeDeclarations()) {
Expression result = findInitializerInType(type, methodName, variableName);
if (result != null) {
return result;
}
}
throw new AssertionError("Initializer not found for " + variableName + " in " + methodName);
}
private static Expression findInitializerInType(TypeDeclaration type, String methodName, String variableName) {
for (MethodDeclaration method : type.getMethods()) {
if (!method.getName().getIdentifier().equals(methodName) || method.getBody() == null) {
continue;
}
for (Object statementObj : method.getBody().statements()) {
if (statementObj instanceof VariableDeclarationStatement statement) {
for (Object fragmentObj : statement.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(variableName)) {
return fragment.getInitializer();
}
}
}
}
}
for (TypeDeclaration nested : type.getTypes()) {
Expression result = findInitializerInType(nested, methodName, variableName);
if (result != null) {
return result;
}
}
return null;
}
private static void writeJava(Path tempDir, String relativePath, String source) throws IOException {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, source);
}
}

View File

@@ -0,0 +1,367 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
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.app.StateMachineAggregator;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
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 java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Guards the shared analysis caches used when one codebase produces many inherited state machines.
*
* <p>Typical repo shape: one abstract config, several abstract branches, many concrete configs.
* The exporter scans once, caches the call graph, then enriches each machine from the same context.
*/
class AnalysisCacheCorrectnessTest {
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
return current;
}
/**
* The call graph is expensive to build; it must be stored on {@link CodebaseContext} and reused
* by subsequent engines pointing at the same scan result.
*/
@Test
void heuristicCallGraphShouldBeCachedOnContext(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
class A { void go() { new B().run("PAY"); } }
class B { void run(String s) { new C().fire(com.example.E.PAY); } }
enum E { PAY, SHIP }
class C { void fire(E e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine firstEngine = new HeuristicCallGraphEngine(context);
firstEngine.findChains(
List.of(EntryPoint.builder().className("com.example.A").methodName("go").build()),
List.of(TriggerPoint.builder().className("com.example.C").methodName("fire").event("e").build()));
assertThat(context.getCache()).containsKey("heuristicCallGraph");
Object cachedGraph = context.getCache().get("heuristicCallGraph");
HeuristicCallGraphEngine secondEngine = new HeuristicCallGraphEngine(context);
secondEngine.findChains(
List.of(EntryPoint.builder().className("com.example.A").methodName("go").build()),
List.of(TriggerPoint.builder().className("com.example.C").methodName("fire").event("e").build()));
assertThat(context.getCache().get("heuristicCallGraph")).isSameAs(cachedGraph);
}
@Test
void jdtIntelligenceProviderShouldCacheCallChains(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
class A { void go() { new B().run("PAY"); } }
class B { void run(String s) { new C().fire(com.example.E.PAY); } }
enum E { PAY, SHIP }
class C { void fire(E e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
JdtIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, tempDir);
EntryPoint entry = EntryPoint.builder().className("com.example.A").methodName("go").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.C").methodName("fire").event("e").build();
List<EntryPoint> entryPoints = List.of(entry);
List<TriggerPoint> triggers = List.of(trigger);
List<CallChain> first = intelligence.findCallChains(entryPoints, triggers);
List<CallChain> second = intelligence.findCallChains(entryPoints, triggers);
assertThat(second).isSameAs(first);
}
/**
* Two consecutive {@link CallGraphEngine#findChains} calls on a cached graph must agree.
*/
@Test
void repeatedCallChainResolutionShouldBeDeterministic(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
class Controller {
Dispatcher dispatcher;
public void pay() { dispatcher.dispatch("ORDER", "PAY"); }
}
class Dispatcher {
public void dispatch(String type, String action) {
StateMachine sm = new StateMachine();
if ("ORDER".equals(type)) {
OrderEvent ev = OrderEvent.valueOf(action);
sm.sendEvent(ev);
}
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
EntryPoint pay = EntryPoint.builder().className("com.example.Controller").methodName("pay").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.StateMachine").methodName("sendEvent").event("e").build();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
CallChain first = engine.findChains(List.of(pay), List.of(trigger)).get(0);
CallChain second = engine.findChains(List.of(pay), List.of(trigger)).get(0);
assertThat(second.getTriggerPoint().getPolymorphicEvents())
.isEqualTo(first.getTriggerPoint().getPolymorphicEvents());
assertThat(second.getMethodChain()).isEqualTo(first.getMethodChain());
}
/**
* A single {@link TransitionLinkerEnricher} instance is reused across machines during export.
* Its internal memoization must not let machine B overwrite machine A's matched transitions.
*/
@Test
void sharedTransitionLinkerShouldNotCrossContaminateMachineResults(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
class OrderController {
SharedBus bus;
void pay() { bus.orderPay(); }
}
class ShipmentController {
SharedBus bus;
void go() { bus.shipmentDispatch(); }
}
class SharedBus {
public void orderPay() {
OrderMachine sm = new OrderMachine();
sm.sendEvent(OrderEvent.PAY);
}
public void shipmentDispatch() {
ShipmentMachine sm = new ShipmentMachine();
sm.sendEvent(ShipmentEvent.DISPATCH);
}
}
enum OrderEvent { PAY }
enum ShipmentEvent { DISPATCH }
class OrderMachine { void sendEvent(OrderEvent e) {} }
class ShipmentMachine { void sendEvent(ShipmentEvent e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
CallChain orderChain = engine.findChains(
List.of(EntryPoint.builder().className("com.example.OrderController").methodName("pay").build()),
List.of(TriggerPoint.builder().className("com.example.OrderMachine").methodName("sendEvent").event("e").build())
).get(0);
CallChain shipmentChain = engine.findChains(
List.of(EntryPoint.builder().className("com.example.ShipmentController").methodName("go").build()),
List.of(TriggerPoint.builder().className("com.example.ShipmentMachine").methodName("sendEvent").event("e").build())
).get(0);
TransitionLinkerEnricher sharedLinker = new TransitionLinkerEnricher();
AnalysisResult orderMachine = analysisWithChain(
"com.example.OrderStateMachineConfig",
orderChain,
CentralDispatcherTestSupport.transition("NEW", "PAID", "OrderEvent.PAY"));
AnalysisResult shipmentMachine = analysisWithChain(
"com.example.ShipmentStateMachineConfig",
shipmentChain,
CentralDispatcherTestSupport.transition("READY", "IN_TRANSIT", "ShipmentEvent.DISPATCH"));
sharedLinker.enrich(orderMachine, null, null);
sharedLinker.enrich(shipmentMachine, null, null);
assertThat(orderMachine.getMetadata().getCallChains().get(0).getMatchedTransitions())
.extracting("event")
.containsExactly("OrderEvent.PAY");
assertThat(shipmentMachine.getMetadata().getCallChains().get(0).getMatchedTransitions())
.extracting("event")
.containsExactly("ShipmentEvent.DISPATCH");
// Re-enrich order machine after shipment machine was processed; result must stay stable.
sharedLinker.enrich(orderMachine, null, null);
assertThat(orderMachine.getMetadata().getCallChains().get(0).getMatchedTransitions())
.extracting("event")
.containsExactly("OrderEvent.PAY");
}
/**
* Exercises a real inherited hierarchy (F1/F2/G1/G2) from one scan + one intelligence provider,
* mirroring repos where many concrete configs extend shared abstract layers.
*/
@Test
void inheritedHierarchyShouldProduceStableDistinctMachineSnapshots() throws Exception {
Path sampleRoot = findProjectRoot().resolve("state_machines/inheritance_extra_functions3_state_machine");
CodebaseContext context = new CodebaseContext();
context.setProjectRoot(sampleRoot);
context.setSourcepath(List.of(sampleRoot.resolve("src/main/java").toString()));
context.setResolveBindings(true);
context.scan(sampleRoot);
JdtIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, sampleRoot);
EnrichmentService enrichmentService = new EnrichmentService(List.of(
new TriggerEnricher(),
new EntryPointEnricher(),
new CallChainEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher(),
new TransitionLinkerEnricher()));
List<String> configNames = List.of(
"F1StateMachineConfiguration",
"F2StateMachineConfiguration",
"G1StateMachineConfiguration",
"G2StateMachineConfiguration");
Map<String, Snapshot> firstPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames);
Map<String, Snapshot> secondPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames);
assertThat(secondPass).isEqualTo(firstPass);
assertThat(firstPass).hasSize(4);
List<Snapshot> snapshots = new ArrayList<>(firstPass.values());
assertThat(snapshots.get(0).transitionCount()).isNotEqualTo(snapshots.get(2).transitionCount());
assertThat(snapshots.get(0).eventNames()).isNotEqualTo(snapshots.get(2).eventNames());
}
/**
* Synthetic repo shape: 1 root abstract → 3 branch abstracts → 4 concrete configs each (12 machines).
* One scan and one intelligence provider must yield stable, distinct snapshots on repeat enrichment.
*
* <pre>
* RootAbstractSmConfig
* ├── BranchAAbstractSmConfig → BranchA1..A4SmConfig
* ├── BranchBAbstractSmConfig → BranchB1..B4SmConfig
* └── BranchCAbstractSmConfig → BranchC1..C4SmConfig
* </pre>
*/
@Test
void twelveConfigSyntheticHierarchyShouldProduceStableDistinctSnapshots(@TempDir Path tempDir) throws Exception {
TwelveConfigHierarchyTestSupport.writeSources(tempDir);
CodebaseContext context = new CodebaseContext();
context.setProjectRoot(tempDir);
context.setResolveBindings(true);
context.scan(tempDir);
JdtIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, tempDir);
EnrichmentService enrichmentService = new EnrichmentService(List.of(
new TriggerEnricher(),
new EntryPointEnricher(),
new CallChainEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher(),
new TransitionLinkerEnricher()));
List<String> configNames = TwelveConfigHierarchyTestSupport.concreteConfigNames();
Map<String, Snapshot> firstPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames);
Object cachedGraphAfterFirstPass = context.getCache().get("jdtCallGraph");
Map<String, Snapshot> secondPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames);
assertThat(firstPass).hasSize(12);
assertThat(secondPass).isEqualTo(firstPass);
assertThat(new ArrayList<>(firstPass.values())).doesNotHaveDuplicates();
// Each concrete config inherits root + branch + its own transition.
assertThat(firstPass.get("com.example.hierarchy.BranchA1SmConfig").transitionCount()).isEqualTo(3);
assertThat(firstPass.get("com.example.hierarchy.BranchA1SmConfig").eventNames())
.containsExactlyInAnyOrderElementsOf(TwelveConfigHierarchyTestSupport.expectedEventsForConcrete("A", 1));
assertThat(firstPass.get("com.example.hierarchy.BranchC4SmConfig").eventNames())
.containsExactlyInAnyOrderElementsOf(TwelveConfigHierarchyTestSupport.expectedEventsForConcrete("C", 4));
// Branch A and branch C machines must differ even when they share the same index.
assertThat(firstPass.get("com.example.hierarchy.BranchA2SmConfig"))
.isNotEqualTo(firstPass.get("com.example.hierarchy.BranchC2SmConfig"));
// Call-graph cache must survive enriching all twelve configs (same graph instance on second pass).
if (cachedGraphAfterFirstPass != null) {
assertThat(context.getCache().get("jdtCallGraph")).isSameAs(cachedGraphAfterFirstPass);
}
}
private static AnalysisResult analysisWithChain(String machineName, CallChain chain, Transition transition) {
return AnalysisResult.builder()
.name(machineName)
.transitions(List.of(transition))
.metadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.callChains(List.of(chain))
.build())
.build();
}
private static Map<String, Snapshot> enrichAllConfigs(
CodebaseContext context,
JdtIntelligenceProvider intelligence,
EnrichmentService enrichmentService,
List<String> configNames) {
Map<String, Snapshot> snapshots = new LinkedHashMap<>();
StateMachineAggregator aggregator = new StateMachineAggregator(context);
for (String configName : configNames) {
TypeDeclaration configType = context.getTypeDeclaration(configName);
if (configType == null) {
configType = context.getTypeDeclarations().stream()
.filter(td -> td.getName().getIdentifier().equals(configName))
.findFirst()
.orElse(null);
}
assertThat(configType).as("config type %s", configName).isNotNull();
java.util.List<Transition> transitions = aggregator.aggregateTransitions(configType);
AnalysisResult result = AnalysisResult.builder()
.name(configName)
.transitions(transitions)
.build();
enrichmentService.enrich(result, context, intelligence);
snapshots.put(context.getFqn(configType), Snapshot.from(result));
}
return snapshots;
}
private record Snapshot(int transitionCount, List<String> eventNames) {
static Snapshot from(AnalysisResult result) {
List<String> events = result.getTransitions().stream()
.map(t -> t.getEvent() != null ? t.getEvent().rawName() : null)
.filter(java.util.Objects::nonNull)
.map(Snapshot::stripLiteralQuotes)
.sorted()
.collect(Collectors.toList());
return new Snapshot(result.getTransitions().size(), events);
}
private static String stripLiteralQuotes(String name) {
if (name.length() >= 2 && name.startsWith("\"") && name.endsWith("\"")) {
return name.substring(1, name.length() - 1);
}
return name;
}
}
}

View File

@@ -0,0 +1,143 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.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 java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class AnalysisResultFinalizerTest {
@Test
void shouldReCanonicalizeAfterPropertyResolution(@TempDir Path tempDir) throws Exception {
writeSampleConfig(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
Transition transition = new Transition();
transition.setEvent(Event.of("OrderEvent.PAY", "OrderEvent.PAY"));
transition.setSourceStates(List.of(State.of("OrderState.NEW", "OrderState.NEW")));
transition.setTargetStates(List.of(State.of("OrderState.PAID", "OrderState.PAID")));
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.transitions(List.of(transition))
.startStates(Set.of("OrderState.NEW"))
.metadata(CodebaseMetadata.builder()
.triggers(List.of(TriggerPoint.builder()
.event("OrderEvent.PAY")
.className("com.example.web.OrderController")
.methodName("pay")
.sourceFile("OrderController.java")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.eventTypeFqn("com.example.order.OrderEvent")
.build()))
.properties(Map.of("default", Map.of()))
.build())
.build();
result.applyResolution(Map.of());
AnalysisResultFinalizer.finalizeResult(result, context);
assertThat(result.getTransitions().get(0).getEvent().fullIdentifier())
.isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(result.getStartStates())
.containsExactly("com.example.order.OrderState.NEW");
assertThat(result.getMetadata().getTriggers().get(0).getPolymorphicEvents())
.containsExactly("com.example.order.OrderEvent.PAY");
assertThat(result.getStateTypeFqn()).isEqualTo("com.example.order.OrderState");
assertThat(result.getEventTypeFqn()).isEqualTo("com.example.order.OrderEvent");
}
@Test
void shouldCanonicalizeUsingEmbeddedMachineTypesWithoutContext() {
Transition transition = new Transition();
transition.setEvent(Event.of("OrderEvent.PAY", "OrderEvent.PAY"));
transition.setSourceStates(List.of(State.of("OrderState.NEW", "OrderState.NEW")));
transition.setTargetStates(List.of(State.of("OrderState.PAID", "OrderState.PAID")));
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(transition))
.startStates(Set.of("OrderState.NEW"))
.build();
AnalysisResultFinalizer.finalizeResult(
result,
null,
new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState",
"com.example.order.OrderEvent"));
assertThat(result.getTransitions().get(0).getEvent().fullIdentifier())
.isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(result.getStartStates())
.containsExactly("com.example.order.OrderState.NEW");
}
@Test
void applyResolutionShouldRebuildTriggersWithAllFields(@TempDir Path tempDir) throws Exception {
writeSampleConfig(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 result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.metadata(CodebaseMetadata.builder().triggers(List.of(trigger)).build())
.build();
result.applyResolution(Map.of(
"app.event", "OrderEvent.PAY",
"app.state", "OrderState.NEW"));
TriggerPoint resolved = result.getMetadata().getTriggers().get(0);
assertThat(resolved.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(resolved.getSourceState()).isEqualTo("com.example.order.OrderState.NEW");
assertThat(resolved.getPolymorphicEvents()).containsExactly("com.example.order.OrderEvent.PAY");
}
private static void writeSampleConfig(Path tempDir) throws Exception {
Path orderPkg = tempDir.resolve("com/example/order");
Path configPkg = tempDir.resolve("com/example/config");
Files.createDirectories(orderPkg);
Files.createDirectories(configPkg);
Files.writeString(orderPkg.resolve("OrderState.java"),
"package com.example.order; public enum OrderState { NEW, PAID }");
Files.writeString(orderPkg.resolve("OrderEvent.java"),
"package com.example.order; public enum OrderEvent { PAY }");
Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"),
"""
package com.example.config;
import com.example.order.OrderEvent;
import com.example.order.OrderState;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
public class OrderStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
}
""");
}
}

View File

@@ -0,0 +1,94 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Documents how traced call-path expressions are classified: map lookups vs bean getter chains.
*/
class CallGraphExpressionShapeTest {
@TempDir
Path tempDir;
private HeuristicCallGraphEngine engine;
private static final String SCOPE = "com.example.Service.handle";
@BeforeEach
void setUp() throws IOException {
String source = """
package com.example;
import java.util.Map;
import java.util.function.Supplier;
public class Service {
private static final Map<String, String> ROUTES = Map.of("k", "v");
private EventWrapper eventWrapper;
private Supplier<String> eventSupplier;
public void handle(String commandKey) {
ROUTES.get(commandKey);
Routes.ROUTES.get(commandKey);
eventWrapper.getEvent();
eventSupplier.get();
}
}
class Routes {
static final Map<String, String> ROUTES = Map.of("k", "v");
}
class EventWrapper {
String getEvent() { return null; }
}
""";
Files.writeString(tempDir.resolve("Service.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
engine = new HeuristicCallGraphEngine(context);
}
@Test
void extractMethodSuffixShouldKeepMapLookupIntact() {
String[] parts = engine.extractMethodSuffix("ROUTES.get(commandKey)", "", SCOPE);
assertThat(parts[0]).isEqualTo("ROUTES.get(commandKey)");
assertThat(parts[1]).isEmpty();
}
@Test
void extractMethodSuffixShouldKeepQualifiedMapLookupIntact() {
String[] parts = engine.extractMethodSuffix("Routes.ROUTES.get(commandKey)", "", SCOPE);
assertThat(parts[0]).isEqualTo("Routes.ROUTES.get(commandKey)");
assertThat(parts[1]).isEmpty();
}
@Test
void extractMethodSuffixShouldSplitBeanGetterChains() {
String[] parts = engine.extractMethodSuffix("eventWrapper.getEvent()", "", SCOPE);
assertThat(parts[0]).isEqualTo("eventWrapper");
assertThat(parts[1]).isEqualTo(".getEvent()");
}
@Test
void extractMethodSuffixShouldSplitNoArgGetCalls() {
String[] parts = engine.extractMethodSuffix("eventSupplier.get()", "", SCOPE);
assertThat(parts[0]).isEqualTo("eventSupplier");
assertThat(parts[1]).isEqualTo(".get()");
}
@Test
void extractMethodSuffixShouldSplitChainedBeanGetters() {
String[] parts = engine.extractMethodSuffix("this.eventWrapper.getEvent().name()", ".name()", SCOPE);
assertThat(parts[0]).isEqualTo("this.eventWrapper");
assertThat(parts[1]).startsWith(".getEvent()");
}
}

View File

@@ -78,4 +78,40 @@ class CallGraphPathFinderTest {
"com.acme.End.finish"
);
}
@Test
void shouldPruneBranchesDuringSearchWhenConstraintsPresent() {
Map<String, List<CallEdge>> graph = new HashMap<>();
graph.put("com.acme.Start.run", List.of(
new CallEdge("com.acme.BranchA.step", List.of(), null, "command == \"PAY\""),
new CallEdge("com.acme.BranchB.step", List.of(), null, "command == \"SHIP\"")
));
graph.put("com.acme.BranchA.step", List.of(
new CallEdge("com.acme.End.finish", List.of())
));
graph.put("com.acme.BranchB.step", List.of(
new CallEdge("com.acme.End.finish", List.of())
));
CallGraphPathFinder pathFinder = new CallGraphPathFinder(new CodebaseContext());
PathBindingEvaluator evaluator = new PathBindingEvaluator(
new CodebaseContext(), null, null, null);
List<List<String>> unconstrained = pathFinder.findAllPaths(
"com.acme.Start.run", "com.acme.End.finish", graph, new HashSet<>());
assertThat(unconstrained).hasSize(2);
List<List<String>> payOnly = pathFinder.findAllPaths(
"com.acme.Start.run",
"com.acme.End.finish",
graph,
new HashSet<>(),
evaluator,
Map.of("command", "PAY"));
assertThat(payOnly).hasSize(1);
assertThat(payOnly.get(0)).containsExactly(
"com.acme.Start.run",
"com.acme.BranchA.step",
"com.acme.End.finish");
}
}

View File

@@ -0,0 +1,462 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
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 java.util.List;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Readable unit tests for central-dispatcher endpoint resolution.
*
* <p>Each test embeds a miniature codebase as a string so humans and agents can see the full
* REST → dispatcher → sendEvent pipeline without opening another module.
*
* <p>DTO setter/getter propagation is covered by {@code HeuristicCallGraphEngineTypeTest}.
* Cross-hop routing helpers that return computed strings are covered by literal-forwarding tests above.
*/
class CentralDispatcherResolutionTest {
private static final String CONTROLLER = "com.example.ApiController";
private static final String STATE_MACHINE = "com.example.StateMachine";
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
/**
* REST endpoint passes two string literals into a shared dispatcher.
* The dispatcher selects the machine branch, then resolves the enum constant.
*
* <pre>
* payOrder() → dispatch("ORDER", "PAY") → OrderEvent.PAY
* shipOrder() → dispatch("ORDER", "SHIP") → OrderEvent.SHIP
* </pre>
*/
@Test
void twoFieldCentralDispatcherShouldResolveDistinctEventsPerEndpoint(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void payOrder() { dispatcher.dispatch("ORDER", "PAY"); }
public void shipOrder() { dispatcher.dispatch("ORDER", "SHIP"); }
}
class CentralDispatcher {
public void dispatch(String domain, String action) {
StateMachine sm = new StateMachine();
if ("ORDER".equals(domain)) {
OrderEvent ev = OrderEvent.valueOf(action);
sm.sendEvent(ev);
}
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain payChain = resolveChain(context, CONTROLLER, "payOrder", STATE_MACHINE);
CallChain shipChain = resolveChain(context, CONTROLLER, "shipOrder", STATE_MACHINE);
assertPolyEvents(payChain, "OrderEvent.PAY");
assertPolyEvents(shipChain, "OrderEvent.SHIP");
MatchedTransition pay = linkEndpointToTransition(
payChain,
MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
MatchedTransition ship = linkEndpointToTransition(
shipChain,
MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
assertLinkedEvent(pay, "OrderEvent.PAY");
assertLinkedEvent(ship, "OrderEvent.SHIP");
}
/**
* Three request dimensions (domain, action, version) are passed as literals and folded
* together by a routing helper before the central dispatcher fires the SM event.
*
* <pre>
* payV2() → dispatch("ORDER", "PAY", "v2") → OrderEvent.valueOf(action)
* shipV2() → dispatch("ORDER", "SHIP", "v2") → OrderEvent.valueOf(action)
* </pre>
*
* <p>The third field is forwarded for API shape parity; routing uses domain + action literals.
*/
@Test
void threeFieldCentralDispatcherShouldResolveFromCombinedRoutingKey(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void payV2() { dispatcher.dispatch("ORDER", "PAY", "v2"); }
public void shipV2() { dispatcher.dispatch("ORDER", "SHIP", "v2"); }
}
class CentralDispatcher {
public void dispatch(String domain, String action, String version) {
StateMachine sm = new StateMachine();
if ("ORDER".equals(domain)) {
OrderEvent ev = OrderEvent.valueOf(action);
sm.sendEvent(ev);
}
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain payChain = resolveChain(context, CONTROLLER, "payV2", STATE_MACHINE);
CallChain shipChain = resolveChain(context, CONTROLLER, "shipV2", STATE_MACHINE);
assertPolyEvents(payChain, "OrderEvent.PAY");
assertPolyEvents(shipChain, "OrderEvent.SHIP");
MatchedTransition pay = linkEndpointToTransition(
payChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
MatchedTransition ship = linkEndpointToTransition(
shipChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
assertLinkedEvent(pay, "OrderEvent.PAY");
assertLinkedEvent(ship, "OrderEvent.SHIP");
}
/**
* Gateway receives three separate fields from the endpoint and forwards them to the
* central dispatcher (same routing core as the three-field test, without DTO indirection).
*/
@Test
void gatewayShouldForwardThreeRequestFieldsToCentralDispatcher(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
Gateway gateway;
public void submit() {
gateway.handle("ORDER", "SUBMIT", "v2");
}
public void approve() {
gateway.handle("ORDER", "APPROVE", "v2");
}
}
class Gateway {
CentralDispatcher dispatcher;
void handle(String domain, String action, String version) {
dispatcher.dispatch(domain, action, version);
}
}
class CentralDispatcher {
public void dispatch(String domain, String action, String version) {
StateMachine sm = new StateMachine();
if ("ORDER".equals(domain)) {
OrderEvent ev = OrderEvent.valueOf(action);
sm.sendEvent(ev);
}
}
}
enum OrderEvent { SUBMIT, APPROVE, REJECT }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain submitChain = resolveChain(context, CONTROLLER, "submit", STATE_MACHINE);
CallChain approveChain = resolveChain(context, CONTROLLER, "approve", STATE_MACHINE);
assertPolyEvents(submitChain, "OrderEvent.SUBMIT");
assertPolyEvents(approveChain, "OrderEvent.APPROVE");
assertLinkedEvent(
linkEndpointToTransition(
submitChain, MACHINE_CONFIG,
transition("DRAFT", "SUBMITTED", "OrderEvent.SUBMIT"),
transition("SUBMITTED", "APPROVED", "OrderEvent.APPROVE")),
"OrderEvent.SUBMIT");
assertLinkedEvent(
linkEndpointToTransition(
approveChain, MACHINE_CONFIG,
transition("DRAFT", "SUBMITTED", "OrderEvent.SUBMIT"),
transition("SUBMITTED", "APPROVED", "OrderEvent.APPROVE")),
"OrderEvent.APPROVE");
}
/**
* Two concrete state-machine configs share one central dispatcher class.
* Each endpoint must link only to transitions from its own config, not the sibling machine.
*/
@Test
void sharedCentralDispatcherShouldRouteEndpointsToCorrectMachineConfig(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class OrderApi {
SharedDispatcher dispatcher;
public void pay() { dispatcher.payOrder(); }
}
public class ShipmentApi {
SharedDispatcher dispatcher;
public void dispatchShipment() { dispatcher.dispatchShipment(); }
}
class SharedDispatcher {
public void payOrder() {
OrderMachine sm = new OrderMachine();
sm.sendEvent(OrderEvent.PAY);
}
public void dispatchShipment() {
ShipmentMachine sm = new ShipmentMachine();
sm.sendEvent(ShipmentEvent.DISPATCH);
}
}
enum OrderEvent { PAY, SHIP }
enum ShipmentEvent { DISPATCH, DELIVER }
class OrderMachine { public void sendEvent(OrderEvent e) {} }
class ShipmentMachine { public void sendEvent(ShipmentEvent e) {} }
class OrderStateMachineConfig {}
class ShipmentStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain orderChain = resolveChain(context, "com.example.OrderApi", "pay", "com.example.OrderMachine");
CallChain shipmentChain = resolveChain(context, "com.example.ShipmentApi", "dispatchShipment", "com.example.ShipmentMachine");
assertPolyEvents(orderChain, "OrderEvent.PAY");
assertPolyEvents(shipmentChain, "ShipmentEvent.DISPATCH");
MatchedTransition orderLink = linkEndpointToTransition(
orderChain,
"com.example.OrderStateMachineConfig",
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
MatchedTransition shipmentLink = linkEndpointToTransition(
shipmentChain,
"com.example.ShipmentStateMachineConfig",
transition("READY", "IN_TRANSIT", "ShipmentEvent.DISPATCH"),
transition("IN_TRANSIT", "DELIVERED", "ShipmentEvent.DELIVER"));
assertLinkedEvent(orderLink, "OrderEvent.PAY");
assertLinkedEvent(shipmentLink, "ShipmentEvent.DISPATCH");
// Negative check: order endpoint must not link against shipment transitions.
AnalysisResult wrongMachine = AnalysisResult.builder()
.name("com.example.ShipmentStateMachineConfig")
.transitions(List.of(transition("READY", "IN_TRANSIT", "ShipmentEvent.DISPATCH")))
.metadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.callChains(List.of(orderChain))
.build())
.build();
new TransitionLinkerEnricher().enrich(wrongMachine, null, null);
assertThat(wrongMachine.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNull();
}
/**
* Intermediate enum hop: endpoint literal → {@code DomainCommand} → concrete SM event.
* Uses named dispatcher methods so each endpoint resolves to a single transition.
*/
@Test
void intermediateEnumCentralDispatcherShouldResolveSingleEventPerEndpoint(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CommandGateway gateway;
public void pay() { gateway.payOrder(); }
public void ship() { gateway.shipOrder(); }
}
enum DomainCommand { ORDER_PAY, ORDER_SHIP }
enum OrderEvent { PAY, SHIP }
class CommandGateway {
CentralDispatcher central;
void payOrder() { central.orderPay(); }
void shipOrder() { central.orderShip(); }
}
class CentralDispatcher {
public void orderPay() {
StateMachine sm = new StateMachine();
sm.sendEvent(OrderEvent.PAY);
}
public void orderShip() {
StateMachine sm = new StateMachine();
sm.sendEvent(OrderEvent.SHIP);
}
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain payChain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
CallChain shipChain = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE);
assertPolyEvents(payChain, "OrderEvent.PAY");
assertPolyEvents(shipChain, "OrderEvent.SHIP");
assertLinkedEvent(
linkEndpointToTransition(payChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(shipChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.SHIP");
}
/**
* Tier 2 — REST → gateway → central dispatcher → {@code richEvent.getType()} → {@code sendEvent}.
*
* <pre>
* pay() → gateway.payWithRichEvent(new PayRichEvent()) → central.orderPay(e) → e.getType() → OrderEvent.PAY
* ship() → gateway.shipWithRichEvent(new ShipRichEvent()) → central.orderShip(e) → e.getType() → OrderEvent.SHIP
* </pre>
*/
@Test
void richEventCentralDispatcherShouldResolveDistinctEventsPerEndpoint(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CommandGateway gateway;
public void pay() { gateway.payWithRichEvent(new PayRichEvent()); }
public void ship() { gateway.shipWithRichEvent(new ShipRichEvent()); }
}
interface RichOrderEvent { OrderEvent getType(); }
class PayRichEvent implements RichOrderEvent {
public OrderEvent getType() { return OrderEvent.PAY; }
}
class ShipRichEvent implements RichOrderEvent {
public OrderEvent getType() { return OrderEvent.SHIP; }
}
class CommandGateway {
CentralDispatcher central;
void payWithRichEvent(RichOrderEvent event) { central.orderPay(event); }
void shipWithRichEvent(RichOrderEvent event) { central.orderShip(event); }
}
class CentralDispatcher {
public void orderPay(RichOrderEvent event) {
StateMachine sm = new StateMachine();
sm.sendEvent(event.getType());
}
public void orderShip(RichOrderEvent event) {
StateMachine sm = new StateMachine();
sm.sendEvent(event.getType());
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain payChain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
CallChain shipChain = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE);
assertPolyEvents(payChain, "OrderEvent.PAY");
assertPolyEvents(shipChain, "OrderEvent.SHIP");
assertLinkedEvent(
linkEndpointToTransition(payChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(shipChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.SHIP");
}
/**
* Tier 2 — chained accessor {@code wrapper.getEvent().getType()} through a central dispatcher.
*/
@Test
void chainedRichEventAccessorShouldResolveThroughCentralDispatcher(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fireOrderEvent(wrapper);
}
}
class CentralDispatcher {
public void fireOrderEvent(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
public RichEvent getEvent() { return new RichEvent(); }
}
class RichEvent {
public OrderEvent getType() { return OrderEvent.PAY; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
assertPolyEvents(chain, "OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(chain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
}
/**
* Chained field-backed trivial getters ({@code return this.field}) through a central dispatcher.
*/
@Test
void fieldBackedGetterChainShouldResolveThroughCentralDispatcher(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fireOrderEvent(wrapper);
}
}
class CentralDispatcher {
public void fireOrderEvent(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
private RichEvent event = new RichEvent();
public RichEvent getEvent() { return event; }
}
class RichEvent {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() { return type; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
assertPolyEvents(chain, "OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(chain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
}
}

View File

@@ -0,0 +1,91 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
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.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Small helpers so dispatcher tests read as documentation of expected endpoint → transition behaviour.
*/
final class CentralDispatcherTestSupport {
private CentralDispatcherTestSupport() {
}
static CodebaseContext scanSource(String source, Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
return context;
}
static CallChain resolveChain(
CodebaseContext context,
String controllerClass,
String controllerMethod,
String stateMachineClass) {
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className(controllerClass)
.methodName(controllerMethod)
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className(stateMachineClass)
.methodName("sendEvent")
.event("e")
.build();
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains)
.as("expected one call chain from %s.%s to %s.sendEvent", controllerClass, controllerMethod, stateMachineClass)
.hasSize(1);
return chains.get(0);
}
static void assertPolyEvents(CallChain chain, String... expectedEvents) {
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder(expectedEvents);
}
static Transition transition(String source, String target, String eventFqn) {
Transition transition = new Transition();
transition.setSourceStates(List.of(State.of(source, source)));
transition.setTargetStates(List.of(State.of(target, target)));
transition.setEvent(Event.of(eventFqn, eventFqn));
return transition;
}
static MatchedTransition linkEndpointToTransition(
CallChain chain,
String machineConfigName,
Transition... machineTransitions) {
AnalysisResult result = AnalysisResult.builder()
.name(machineConfigName)
.transitions(List.of(machineTransitions))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, null, null);
List<MatchedTransition> matched = result.getMetadata().getCallChains().get(0).getMatchedTransitions();
assertThat(matched)
.as("endpoint should resolve to exactly one SM transition")
.hasSize(1);
return matched.get(0);
}
static void assertLinkedEvent(MatchedTransition matched, String expectedEvent) {
assertThat(matched.getEvent()).isEqualTo(expectedEvent);
}
}

View File

@@ -0,0 +1,136 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.*;
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 DispatcherEndpointTest {
@Test
void shouldLinkOnlySpecificEventWhenEndpointPassesLiteralsThroughDispatcher(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class OrderController {
private Dispatcher dispatcher;
public void payOrder() { dispatcher.dispatch("ORDER", "PAY"); }
public void shipOrder() { dispatcher.dispatch("ORDER", "SHIP"); }
}
class Dispatcher {
public void dispatch(String type, String eventStr) {
StateMachine sm = new StateMachine();
if ("ORDER".equals(type)) {
OrderEvent ev = OrderEvent.valueOf(eventStr);
sm.sendEvent(ev);
}
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint payEp = EntryPoint.builder().className("com.example.OrderController").methodName("payOrder").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.StateMachine").methodName("sendEvent").event("e").build();
CallChain payChain = engine.findChains(List.of(payEp), List.of(trigger)).get(0);
TriggerPoint resolved = payChain.getTriggerPoint();
assertThat(resolved.getPolymorphicEvents())
.as("resolved trigger=%s eventTypeFqn=%s external=%s", resolved.getEvent(), resolved.getEventTypeFqn(), resolved.isExternal())
.contains("OrderEvent.PAY")
.doesNotContain("OrderEvent.SHIP", "OrderEvent.CANCEL");
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"));
Transition shipT = new Transition();
shipT.setSourceStates(List.of(State.of("PAID", "OrderState.PAID")));
shipT.setTargetStates(List.of(State.of("SHIPPED", "OrderState.SHIPPED")));
shipT.setEvent(Event.of("OrderEvent.SHIP", "com.example.OrderEvent.SHIP"));
assertThat(new click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine()
.matches(payT.getEvent(), resolved)).isTrue();
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.transitions(List.of(payT, shipT))
.metadata(CodebaseMetadata.builder().callChains(List.of(payChain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions())
.hasSize(1)
.first()
.extracting("event")
.isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void shouldNotLinkTransitionsForExternalGenericDispatchEndpoint(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class OrderController {
private Dispatcher dispatcher;
public void transition(String machineType, String event) {
dispatcher.dispatch(machineType, event);
}
}
class Dispatcher {
public void dispatch(String type, String eventStr) {
StateMachine sm = new StateMachine();
if ("ORDER".equals(type)) {
OrderEvent ev = OrderEvent.valueOf(eventStr);
sm.sendEvent(ev);
}
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
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);
assertThat(chain.getTriggerPoint().getPolymorphicEvents()).isEmpty();
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();
new TransitionLinkerEnricher().enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNull();
}
}

View File

@@ -1,78 +1,31 @@
package click.kamil.springstatemachineexporter.analysis.service;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
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;
@Tag("dynamic_classpath_resolver")
class DynamicClasspathResolverTest {
@Test
void shouldResolveMavenClasspathDynamically(@TempDir Path tempDir) throws IOException {
// Create dummy pom.xml
void shouldNeverResolveExternalJarsEvenWhenMavenProject(@TempDir Path tempDir) throws Exception {
Files.createFile(tempDir.resolve("pom.xml"));
// Mock the resolver so it doesn't actually run maven, but writes the expected output file
DynamicClasspathResolver resolver = new DynamicClasspathResolver() {
@Override
protected void runProcess(ProcessBuilder pb) throws IOException {
// Pretend maven executed and generated the cp.txt
String cp = "/fake/m2/repo/spring-core.jar" + File.pathSeparator + "/fake/m2/repo/spring-context.jar";
String outputFileArg = pb.command().stream()
.filter(arg -> arg.startsWith("-Dmdep.outputFile="))
.findFirst()
.orElseThrow();
Path outputFile = pb.directory().toPath().resolve(outputFileArg.substring("-Dmdep.outputFile=".length()));
Files.writeString(outputFile, cp);
}
};
List<String> classpath = resolver.resolveClasspath(tempDir);
assertThat(classpath).containsExactly(
"/fake/m2/repo/spring-core.jar",
"/fake/m2/repo/spring-context.jar"
);
assertThat(new DynamicClasspathResolver().resolveClasspath(tempDir)).isEmpty();
}
@Test
void shouldResolveGradleClasspathDynamically(@TempDir Path tempDir) throws IOException {
// Create dummy build.gradle
void shouldNeverResolveExternalJarsEvenWhenGradleProject(@TempDir Path tempDir) throws Exception {
Files.createFile(tempDir.resolve("build.gradle"));
DynamicClasspathResolver resolver = new DynamicClasspathResolver() {
@Override
protected String runProcessAndGetOutput(ProcessBuilder pb) {
// Pretend gradle executed and printed the marker
String cp = "/fake/gradle/caches/spring-core.jar" + File.pathSeparator + "/fake/gradle/caches/spring-context.jar";
return "Some noisy gradle output\n" +
"SME_CLASSPATH_MARKER:" + cp + "\n" +
"BUILD SUCCESSFUL\n";
}
};
List<String> classpath = resolver.resolveClasspath(tempDir);
assertThat(classpath).containsExactly(
"/fake/gradle/caches/spring-core.jar",
"/fake/gradle/caches/spring-context.jar"
);
assertThat(new DynamicClasspathResolver().resolveClasspath(tempDir)).isEmpty();
}
@Test
void shouldReturnEmptyListIfNoBuildFileFound(@TempDir Path tempDir) {
DynamicClasspathResolver resolver = new DynamicClasspathResolver();
List<String> classpath = resolver.resolveClasspath(tempDir);
List<String> classpath = new DynamicClasspathResolver().resolveClasspath(tempDir);
assertThat(classpath).isEmpty();
}
}

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