22 Commits

Author SHA1 Message Date
411b7a0e52 Perf: use CFG dataflow for traceLocalSetter and guard hot-path logging.
Replace per-call method-body AST scans with reaching-definitions lookup, guard remaining debug/trace calls in call-graph hot paths, and add branch edge-case tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:14:02 +02:00
82a70b5374 Improve export perf: guard debug logs, scope triggers, memoize path bindings.
Reduce multi-config call-chain cost by filtering triggers before pathfinding and cache path-binding walks per session, while avoiding debug string work on hot constant-resolution paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 09:59:57 +02:00
5d3333d494 Fix export metadata scoping and call-chain resolution gaps.
Correct machine-scope filtering for domain paths and Spring type erasure, skip phantom REST interfaces, resolve custom message wrapper events, scope JDT call-chain lookup per machine, and wire scan-time analysis indexes with JDT/heuristic parity tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 09:43:50 +02:00
7a5fc60ace Fix map-routed dispatcher events to respect path-bound keys.
Resolve static map lookups along the call path, guard enum widening when
keys are bound, and add isolation tests for warmed-cache map routing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 08:31:08 +02:00
9ede60049b Add CallGraphBuilder with tests and maven/multi-module golden fixtures.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 08:30:48 +02:00
cc35546d01 Fix CIC accessor resolution, concrete overrides, and reactive flatMap tracing.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 07:06:55 +02:00
c2eff6d7bb Fix abstract getter widening and nested reactive flatMap resolution.
Ensure abstract-type getter resolution merges subclass field values and trace indexed accessors on the requesting type, and extract terminal payloads from chained flatMap sendEvent calls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 06:02:48 +02:00
613dfffd53 Align enum rawName with fn-form labels during canonicalization.
Derive transition and state rawName from package-canonical fullIdentifier so JSON exports stay consistent when bare constants or mismatched short forms appear in source.

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 10:14:23 +02:00
12183693aa cursor 2026-07-07 21:15:12 +02:00
1be8d5e950 aaa 2026-07-07 21:11:32 +02:00
300 changed files with 27521 additions and 10676 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,10 +15,9 @@ include ':state_machines:inheritance_extra_functions3_state_machine'
include ':state_machines:simple_state_machine'
include ':state_machines:extended_analysis_sample'
include ':state_machines:inheritance_sample'
include ':state_machines:layered_dispatcher_sample'
include ':state_machines:ultimate_ecosystem_sm'
include ':state_machines:enterprise_order_system'
include ':state_machines:multi_module_sample:api-module'
include ':state_machines:multi_module_sample:core-module'
include ':state_machines:state_machine_enterprise'
include 'state_machine_exporter_kotlin'
include ':state_machines:kotlin_simple_state_machine'

View File

@@ -32,9 +32,6 @@ dependencies {
// deps for parsing java AST
implementation 'org.eclipse.jdt:org.eclipse.jdt.core:3.36.0'
// Kotlin plugin integration via ServiceLoader
runtimeOnly project(':state_machine_exporter_kotlin')
// Jackson for JSON support
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.17.1'
@@ -51,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

@@ -1,13 +1,18 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.enricher.MachineScopeFilter;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.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 {
@@ -16,10 +21,28 @@ public class CallChainEnricher implements AnalysisEnricher {
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<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
canonicalTriggers, result.getName(), context);
List<CallChain> chains = intelligence.findCallChains(
result.getMetadata().getEntryPoints(),
result.getMetadata().getTriggers()
scopedTriggers
);
chains = chains.stream()
.map(chain -> chain.getTriggerPoint() == null
? chain
: chain.toBuilder()
.triggerPoint(MachineEnumCanonicalizer.canonicalizeTriggerPoint(
chain.getTriggerPoint(), machineTypes))
.build())
.collect(Collectors.toList());
chains = MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context);
result.addMetadata(CodebaseMetadata.builder()
.callChains(chains)

View File

@@ -17,6 +17,7 @@ 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)

View File

@@ -0,0 +1,152 @@
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 && isKnownDomainScopedMachine(machineDomain)
&& domainsMatch(pathDomain, machineDomain)) {
return true;
}
if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain)
&& !domainsMatch(pathDomain, machineDomain)) {
return false;
}
if (pathDomain == null && path.contains("{")) {
return true;
}
}
CallChain probe = CallChain.builder()
.entryPoint(entryPoint)
.methodChain(List.of(entryPoint.getClassName() + "." + entryPoint.getMethodName()))
.build();
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context);
}
private static boolean isKnownDomainScopedMachine(String machineDomain) {
String normalized = normalizeDomainKey(machineDomain);
return "ORDER".equals(normalized) || "DOCUMENT".equals(normalized) || "USER".equals(normalized);
}
private static String inferDomainFromPath(String path) {
String lower = path.toLowerCase(Locale.ROOT);
if (lower.contains("/orders") || lower.contains("order.")) {
return "ORDER";
}
if (lower.contains("/documents") || lower.contains("document.")) {
return "DOCUMENT";
}
return null;
}
private static boolean domainsMatch(String pathDomain, String machineDomain) {
String normalizedPath = normalizeDomainKey(pathDomain);
String normalizedMachine = normalizeDomainKey(machineDomain);
return normalizedPath.equalsIgnoreCase(normalizedMachine)
|| normalizedMachine.startsWith(normalizedPath)
|| normalizedPath.startsWith(normalizedMachine);
}
private static String extractMachineDomainKey(String machineName) {
String simple = machineName.substring(machineName.lastIndexOf('.') + 1);
String upper = simple.toUpperCase(Locale.ROOT);
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) {
int idx = upper.indexOf(marker);
if (idx > 0) {
return normalizeDomainKey(upper.substring(0, idx));
}
}
int stateIdx = upper.indexOf("STATE");
if (stateIdx > 0) {
return normalizeDomainKey(upper.substring(0, stateIdx));
}
return normalizeDomainKey(upper);
}
private static String normalizeDomainKey(String domain) {
if (domain == null || domain.isEmpty()) {
return domain;
}
String upper = domain.toUpperCase(Locale.ROOT);
if (upper.startsWith("STANDARD") && upper.length() > "STANDARD".length()) {
return upper.substring("STANDARD".length());
}
return upper;
}
private static boolean isTriggerForMachine(TriggerPoint trigger, String machineName, CodebaseContext context) {
if (trigger == null) {
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,10 +16,11 @@ import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
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 {
@@ -40,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;
@@ -47,17 +55,16 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName();
// Event matches
String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) {
String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
String smSource = simplify(smSourceRaw);
String smSourceForLink = canonicalState(smSourceState);
String smSource = simplify(smSourceForLink);
if (triggerSource == null || triggerSource.equals(smSource)) {
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceRaw)
.targetState(smSourceRaw)
.event(smEventRaw)
.sourceState(smSourceForLink)
.targetState(smSourceForLink)
.event(smEventForLink)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
@@ -65,11 +72,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
} else {
for (State smTargetState : t.getTargetStates()) {
String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName();
String targetForLink = canonicalState(smTargetState);
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceRaw)
.targetState(targetRaw)
.event(smEventRaw)
.sourceState(smSourceForLink)
.targetState(targetForLink)
.event(smEventForLink)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
@@ -90,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();
@@ -107,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<>();
@@ -215,6 +165,20 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return simplified;
}
private String canonicalState(State state) {
if (state == null) {
return null;
}
return state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName();
}
private String canonicalEvent(click.kamil.springstatemachineexporter.model.Event event) {
if (event == null) {
return null;
}
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
}
private boolean isGuardedContains(String smEvent, String triggerEvent) {
if (smEvent == null || triggerEvent == null) return false;

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 {
@@ -15,10 +18,14 @@ public class TriggerEnricher implements AnalysisEnricher {
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with triggers", result.getName());
List<TriggerPoint> triggers = intelligence.findTriggerPoints();
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);
// 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.
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;
}
@@ -85,13 +80,13 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
if (triggerEvent == null || smEvent == null) return false;
if (triggerEvent.equals(smEvent)) return true;
String tConst = triggerEvent.contains(".") ? triggerEvent.substring(triggerEvent.lastIndexOf('.') + 1) : triggerEvent;
String smConst = smEvent.contains(".") ? smEvent.substring(smEvent.lastIndexOf('.') + 1) : smEvent;
String tConst = constantName(triggerEvent);
String smConst = constantName(smEvent);
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;
}
@@ -100,20 +95,46 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
return false;
}
if (eventTypeFqn != null && eventTypeFqn.contains(".") && smEvent.contains(".")) {
String fullTriggerFqn = eventTypeFqn + "." + tConst;
return fullTriggerFqn.equals(smEvent);
if (smEvent.contains(".")) {
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
if (eventTypeFqn != null) {
return enumTypesMatch(eventTypeFqn, smEnumType);
}
if (triggerEvent.contains(".")) {
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
return enumTypesMatch(triggerEnumType, smEnumType);
}
// Bare constant name (e.g. after Enum.name()) with no type context must not match every enum
return false;
}
if (triggerEvent.contains(".") && smEvent.contains(".")) {
String tClass = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
String smClass = smEvent.substring(0, smEvent.lastIndexOf('.'));
String tClassSimple = tClass.contains(".") ? tClass.substring(tClass.lastIndexOf('.') + 1) : tClass;
String smClassSimple = smClass.contains(".") ? smClass.substring(smClass.lastIndexOf('.') + 1) : smClass;
return tClassSimple.equals(smClassSimple);
}
// Both sides are unqualified identifiers (string/primitive events)
return !triggerEvent.contains(".");
}
return true;
private String constantName(String event) {
return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event;
}
private boolean enumTypesMatch(String type1, String type2) {
if (type1 == null || type2 == null) return false;
if (type1.equals(type2)) return true;
if (type1.endsWith("." + type2) || type2.endsWith("." + type1)) return true;
String simple1 = simpleName(type1);
String simple2 = simpleName(type2);
if (!simple1.equals(simple2)) return false;
// Same simple name only matches when at least one side is package-less (import-style reference).
// Prevents com.foo.OrderEvents from matching com.bar.OrderEvents.
return !type1.contains(".") || !type2.contains(".");
}
private String simpleName(String name) {
return name.contains(".") ? name.substring(name.lastIndexOf('.') + 1) : name;
}
private boolean isStringTypeOrPrimitive(String typeFqn) {
@@ -134,6 +155,10 @@ 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;

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,16 +17,20 @@ 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;
}
if (context != null && isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) {
return true;
}
}
if (triggerStateFqn != null && machineStateFqn != null) {
@@ -34,6 +40,10 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
return false; // Mismatch!
}
if (context != null && isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) {
return true;
}
}
}
}
@@ -110,16 +120,52 @@ 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 == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
return false;
}
if (smPackage.startsWith(chainPackage) || chainPackage.startsWith(smPackage)) {
if (smPackage.equals(chainPackage)) {
return true;
}
if (!chainPackage.startsWith(smPackage + ".") && !smPackage.startsWith(chainPackage + ".")) {
return false;
}
// Find deepest common package root
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
@@ -261,59 +307,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 +316,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);
@@ -370,11 +326,16 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return !type1.equals(type2);
}
private boolean isErasedOrOpaqueType(String typeFqn) {
if (typeFqn == null) {
return false;
}
String erased = eraseGenerics(typeFqn);
return "java.lang.Object".equals(erased) || "Object".equals(erased)
|| "java.io.Serializable".equals(erased) || "Serializable".equals(erased);
}
private boolean isSingleStateMachine(CodebaseContext context) {
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,93 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.function.BiConsumer;
/**
* Resolves constant values for fields referenced by indexed trivial accessors.
*/
public final class AccessorFieldResolver {
private AccessorFieldResolver() {
}
public static List<String> resolveFieldConstants(
AccessorSummary accessor,
String requestingTypeFqn,
CodebaseContext context,
ConstructorAnalyzer constructorAnalyzer,
CompilationUnit contextCu,
Set<String> visited,
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor) {
if (accessor == null || !accessor.isGetter() || context == null) {
return List.of();
}
if (accessor.kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER
|| accessor.fieldName() == null) {
return List.of();
}
String traceTypeFqn = requestingTypeFqn != null && !requestingTypeFqn.isBlank()
? requestingTypeFqn
: accessor.declaringFqn();
TypeDeclaration typeDeclaration = contextCu != null
? context.getTypeDeclaration(traceTypeFqn, contextCu)
: null;
if (typeDeclaration == null) {
typeDeclaration = context.getTypeDeclaration(traceTypeFqn);
}
if (typeDeclaration == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
if (constructorAnalyzer != null) {
constants.addAll(constructorAnalyzer.traceFieldInConstructors(
typeDeclaration, accessor.fieldName(), context, visited));
}
if (constants.isEmpty()) {
collectFieldInitializerConstants(
typeDeclaration, accessor.fieldName(), constantExtractor, constants, context);
}
return constants;
}
private static void collectFieldInitializerConstants(
TypeDeclaration typeDeclaration,
String fieldName,
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor,
List<String> constants,
CodebaseContext context) {
if (constantExtractor == null || typeDeclaration == null) {
return;
}
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
for (Object fragmentObj : fieldDeclaration.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(fieldName)
&& fragment.getInitializer() != null) {
constantExtractor.accept(fragment.getInitializer(), constants);
}
}
}
if (constants.isEmpty() && context != null) {
String superFqn = context.getSuperclassFqn(typeDeclaration);
if (superFqn != null && !superFqn.equals("java.lang.Object")) {
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
if (superType != null) {
collectFieldInitializerConstants(superType, fieldName, constantExtractor, constants, context);
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class FieldTypeIndex {
private static final FieldTypeIndex EMPTY = new FieldTypeIndex(Map.of(), 0);
private final Map<String, Map<String, String>> byOwnerAndField;
private final int fieldCount;
public FieldTypeIndex(Map<String, Map<String, String>> byOwnerAndField, int fieldCount) {
this.byOwnerAndField = Collections.unmodifiableMap(deepCopy(byOwnerAndField));
this.fieldCount = fieldCount;
}
public static FieldTypeIndex empty() {
return EMPTY;
}
public Optional<String> lookup(String ownerFqn, String fieldName) {
if (ownerFqn == null || fieldName == null) {
return Optional.empty();
}
Map<String, String> fields = byOwnerAndField.get(ownerFqn);
if (fields == null) {
return Optional.empty();
}
return Optional.ofNullable(fields.get(fieldName));
}
public int fieldCount() {
return fieldCount;
}
private static Map<String, Map<String, String>> deepCopy(Map<String, Map<String, String>> source) {
Map<String, Map<String, String>> copy = new HashMap<>();
for (Map.Entry<String, Map<String, String>> entry : source.entrySet()) {
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
}
return copy;
}
}

View File

@@ -0,0 +1,98 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
@Slf4j
public final class FieldTypeIndexBuilder {
private CodebaseContext context;
public FieldTypeIndex build(CodebaseContext context) {
this.context = context;
if (context == null) {
return FieldTypeIndex.empty();
}
Map<String, Map<String, String>> direct = new HashMap<>();
for (String ownerFqn : context.getIndexedTypeFqns()) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
if (typeDeclaration != null) {
indexDeclaredFields(ownerFqn, typeDeclaration, direct);
}
}
Map<String, Map<String, String>> effective = new HashMap<>();
int fieldCount = 0;
Map<String, Map<String, String>> cache = new HashMap<>();
for (String ownerFqn : direct.keySet()) {
Map<String, String> merged = mergeEffective(ownerFqn, direct, cache, new HashSet<>());
if (!merged.isEmpty()) {
effective.put(ownerFqn, merged);
fieldCount += merged.size();
}
}
log.info("Built field-type index: {} fields across {} types", fieldCount, effective.size());
this.context = null;
return new FieldTypeIndex(effective, fieldCount);
}
private void indexDeclaredFields(
String ownerFqn,
TypeDeclaration typeDeclaration,
Map<String, Map<String, String>> direct) {
Map<String, String> fields = direct.computeIfAbsent(ownerFqn, ignored -> new HashMap<>());
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
String fieldType = fieldDeclaration.getType().toString();
for (Object fragmentObj : fieldDeclaration.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
fields.put(fragment.getName().getIdentifier(), fieldType);
}
}
}
for (TypeDeclaration nested : typeDeclaration.getTypes()) {
indexDeclaredFields(context.getFqn(nested), nested, direct);
}
}
private Map<String, String> mergeEffective(
String ownerFqn,
Map<String, Map<String, String>> direct,
Map<String, Map<String, String>> cache,
Set<String> visiting) {
if (cache.containsKey(ownerFqn)) {
return cache.get(ownerFqn);
}
if (!visiting.add(ownerFqn)) {
return Map.of();
}
Map<String, String> merged = new HashMap<>();
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
if (ownerType != null) {
String superFqn = context.getSuperclassFqn(ownerType);
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
merged.putAll(mergeEffective(superFqn, direct, cache, visiting));
}
}
Map<String, String> own = direct.get(ownerFqn);
if (own != null) {
merged.putAll(own);
}
visiting.remove(ownerFqn);
cache.put(ownerFqn, Map.copyOf(merged));
return merged;
}
}

View File

@@ -0,0 +1,14 @@
package click.kamil.springstatemachineexporter.analysis.index;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
/**
* Scan-time result for {@code owner.getX()} getter-chain hops: the next receiver type
* and optional {@code new ...()} field initializer discovered on the backing field.
*/
public record GetterChainEdge(
String ownerFqn,
String getterName,
String nextTypeFqn,
ClassInstanceCreation fieldInitializerCic) {
}

View File

@@ -0,0 +1,47 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public final class GetterChainIndex {
private static final GetterChainIndex EMPTY = new GetterChainIndex(Map.of(), 0);
private final Map<String, Map<String, GetterChainEdge>> byOwnerAndGetter;
private final int edgeCount;
public GetterChainIndex(Map<String, Map<String, GetterChainEdge>> byOwnerAndGetter, int edgeCount) {
this.byOwnerAndGetter = Collections.unmodifiableMap(deepCopy(byOwnerAndGetter));
this.edgeCount = edgeCount;
}
public static GetterChainIndex empty() {
return EMPTY;
}
public Optional<GetterChainEdge> lookup(String ownerFqn, String getterName) {
if (ownerFqn == null || getterName == null) {
return Optional.empty();
}
Map<String, GetterChainEdge> getters = byOwnerAndGetter.get(ownerFqn);
if (getters == null) {
return Optional.empty();
}
return Optional.ofNullable(getters.get(getterName));
}
public int edgeCount() {
return edgeCount;
}
private static Map<String, Map<String, GetterChainEdge>> deepCopy(
Map<String, Map<String, GetterChainEdge>> source) {
Map<String, Map<String, GetterChainEdge>> copy = new HashMap<>();
for (Map.Entry<String, Map<String, GetterChainEdge>> entry : source.entrySet()) {
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
}
return copy;
}
}

View File

@@ -0,0 +1,77 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.HashMap;
import java.util.Map;
@Slf4j
public final class GetterChainIndexBuilder {
public GetterChainIndex build(CodebaseContext context, AccessorIndex accessorIndex) {
if (context == null || accessorIndex == null) {
return GetterChainIndex.empty();
}
FieldInitializerFinder fieldInitializerFinder = new FieldInitializerFinder(context);
Map<String, Map<String, GetterChainEdge>> edges = new HashMap<>();
int edgeCount = 0;
for (Map.Entry<String, Map<String, AccessorSummary>> ownerEntry : accessorIndex.asMap().entrySet()) {
String ownerFqn = ownerEntry.getKey();
for (AccessorSummary accessor : ownerEntry.getValue().values()) {
if (!accessor.isGetter()) {
continue;
}
GetterChainEdge edge = buildEdge(context, fieldInitializerFinder, ownerFqn, accessor);
if (edge == null) {
continue;
}
edges.computeIfAbsent(ownerFqn, ignored -> new HashMap<>())
.put(accessor.methodName(), edge);
edgeCount++;
}
}
log.info("Built getter-chain index: {} edges across {} types", edgeCount, edges.size());
return new GetterChainIndex(edges, edgeCount);
}
private static GetterChainEdge buildEdge(
CodebaseContext context,
FieldInitializerFinder fieldInitializerFinder,
String ownerFqn,
AccessorSummary accessor) {
String nextType = null;
ClassInstanceCreation fieldInitializerCic = null;
if (accessor.fieldName() != null) {
fieldInitializerCic = fieldInitializerFinder.findFieldInitializerCic(ownerFqn, accessor.fieldName());
nextType = fieldInitializerFinder.resolveFieldTypeName(ownerFqn, accessor.fieldName());
if (fieldInitializerCic != null) {
nextType = AstUtils.extractSimpleTypeName(fieldInitializerCic.getType());
}
}
if (nextType == null) {
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
MethodDeclaration getter = ownerType != null
? context.findMethodDeclaration(ownerType, accessor.methodName(), true)
: null;
if (getter != null && getter.getReturnType2() != null) {
nextType = getter.getReturnType2().toString();
}
}
if (nextType == null) {
return null;
}
return new GetterChainEdge(ownerFqn, accessor.methodName(), nextType, fieldInitializerCic);
}
}

View File

@@ -0,0 +1,54 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Scan-time constants for indexed getters backed by fields with literal or enum initializers.
*/
public final class IndexedFieldConstantsIndex {
private static final IndexedFieldConstantsIndex EMPTY = new IndexedFieldConstantsIndex(Map.of(), 0);
private final Map<String, Map<String, List<String>>> byOwnerAndMethod;
private final int entryCount;
public IndexedFieldConstantsIndex(Map<String, Map<String, List<String>>> byOwnerAndMethod, int entryCount) {
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
this.entryCount = entryCount;
}
public static IndexedFieldConstantsIndex empty() {
return EMPTY;
}
public Optional<List<String>> lookup(String ownerFqn, String methodName) {
if (ownerFqn == null || methodName == null) {
return Optional.empty();
}
Map<String, List<String>> methods = byOwnerAndMethod.get(ownerFqn);
if (methods == null) {
return Optional.empty();
}
List<String> constants = methods.get(methodName);
if (constants == null || constants.isEmpty()) {
return Optional.empty();
}
return Optional.of(constants);
}
public int entryCount() {
return entryCount;
}
private static Map<String, Map<String, List<String>>> deepCopy(Map<String, Map<String, List<String>>> source) {
Map<String, Map<String, List<String>>> copy = new HashMap<>();
for (Map.Entry<String, Map<String, List<String>>> entry : source.entrySet()) {
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
}
return copy;
}
}

View File

@@ -0,0 +1,41 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
public final class IndexedFieldConstantsIndexBuilder {
public IndexedFieldConstantsIndex build(CodebaseContext context, AccessorIndex accessorIndex) {
if (context == null || accessorIndex == null) {
return IndexedFieldConstantsIndex.empty();
}
Map<String, Map<String, List<String>>> entries = new HashMap<>();
int entryCount = 0;
for (Map.Entry<String, Map<String, AccessorSummary>> ownerEntry : accessorIndex.asMap().entrySet()) {
String ownerFqn = ownerEntry.getKey();
for (AccessorSummary accessor : ownerEntry.getValue().values()) {
if (!accessor.isGetter() || accessor.fieldName() == null) {
continue;
}
List<String> constants = ScanTimeAccessorConstants.resolve(
context, accessorIndex, ownerFqn, accessor.methodName());
if (constants.isEmpty()) {
continue;
}
entries.computeIfAbsent(ownerFqn, ignored -> new HashMap<>())
.put(accessor.methodName(), List.copyOf(constants));
entryCount++;
}
}
log.info("Built indexed field-constants index: {} entries across {} types", entryCount, entries.size());
return new IndexedFieldConstantsIndex(entries, entryCount);
}
}

View File

@@ -0,0 +1,16 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.List;
import java.util.Optional;
/**
* Scan-time summary of how a getter on an interface or abstract type resolves across
* its concrete implementations. Values are derived from static analysis only.
*/
public record PolymorphicAccessorEntry(
String ownerFqn,
String methodName,
Optional<AccessorSummary> representativeImplAccessor,
List<String> widenedReturnConstants,
boolean staticallyComplete) {
}

View File

@@ -0,0 +1,62 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class PolymorphicAccessorIndex {
private static final PolymorphicAccessorIndex EMPTY = new PolymorphicAccessorIndex(Map.of(), 0);
private final Map<String, Map<String, PolymorphicAccessorEntry>> byOwnerAndMethod;
private final int entryCount;
public PolymorphicAccessorIndex(
Map<String, Map<String, PolymorphicAccessorEntry>> byOwnerAndMethod,
int entryCount) {
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
this.entryCount = entryCount;
}
public static PolymorphicAccessorIndex empty() {
return EMPTY;
}
public Optional<PolymorphicAccessorEntry> lookup(String ownerFqn, String methodName) {
if (ownerFqn == null || methodName == null) {
return Optional.empty();
}
Map<String, PolymorphicAccessorEntry> methods = byOwnerAndMethod.get(ownerFqn);
if (methods == null) {
return Optional.empty();
}
return Optional.ofNullable(methods.get(methodName));
}
public Optional<AccessorSummary> representativeImplAccessor(String ownerFqn, String methodName) {
return lookup(ownerFqn, methodName)
.flatMap(PolymorphicAccessorEntry::representativeImplAccessor);
}
public Optional<List<String>> widenedReturnConstants(String ownerFqn, String methodName) {
return lookup(ownerFqn, methodName)
.filter(PolymorphicAccessorEntry::staticallyComplete)
.map(PolymorphicAccessorEntry::widenedReturnConstants)
.filter(values -> !values.isEmpty());
}
public int entryCount() {
return entryCount;
}
private static Map<String, Map<String, PolymorphicAccessorEntry>> deepCopy(
Map<String, Map<String, PolymorphicAccessorEntry>> source) {
Map<String, Map<String, PolymorphicAccessorEntry>> copy = new HashMap<>();
for (Map.Entry<String, Map<String, PolymorphicAccessorEntry>> entry : source.entrySet()) {
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
}
return copy;
}
}

View File

@@ -0,0 +1,156 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@Slf4j
public final class PolymorphicAccessorIndexBuilder {
public PolymorphicAccessorIndex build(CodebaseContext context, AccessorIndex accessorIndex) {
if (context == null || accessorIndex == null) {
return PolymorphicAccessorIndex.empty();
}
Map<String, Map<String, PolymorphicAccessorEntry>> entries = new HashMap<>();
int entryCount = 0;
for (String typeFqn : context.getIndexedTypeFqns()) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(typeFqn);
if (!isPolymorphicType(typeDeclaration)) {
continue;
}
List<String> implementations = context.getImplementations(typeFqn);
if (implementations.isEmpty()) {
continue;
}
Set<String> methodNames = collectGetterMethodNames(context, accessorIndex, typeFqn, implementations);
for (String methodName : methodNames) {
PolymorphicAccessorEntry entry = buildEntry(
context, accessorIndex, typeFqn, methodName, implementations);
if (entry == null) {
continue;
}
entries.computeIfAbsent(typeFqn, ignored -> new HashMap<>()).put(methodName, entry);
entryCount++;
}
}
log.info("Built polymorphic accessor index: {} entries across {} types", entryCount, entries.size());
return new PolymorphicAccessorIndex(entries, entryCount);
}
private static PolymorphicAccessorEntry buildEntry(
CodebaseContext context,
AccessorIndex accessorIndex,
String ownerFqn,
String methodName,
List<String> implementations) {
LinkedHashSet<String> widenedConstants = new LinkedHashSet<>();
widenedConstants.addAll(ScanTimeAccessorConstants.resolve(context, accessorIndex, ownerFqn, methodName));
Optional<AccessorSummary> representative = findRepresentativeImplAccessor(accessorIndex, implementations, methodName);
for (String implementationFqn : implementations) {
widenedConstants.addAll(
ScanTimeAccessorConstants.resolve(context, accessorIndex, implementationFqn, methodName));
}
if (representative.isEmpty() && widenedConstants.isEmpty()) {
return null;
}
boolean staticallyComplete = isStaticallyComplete(accessorIndex, methodName, implementations);
return new PolymorphicAccessorEntry(
ownerFqn,
methodName,
representative,
List.copyOf(widenedConstants),
staticallyComplete);
}
private static boolean isStaticallyComplete(
AccessorIndex accessorIndex,
String methodName,
List<String> implementations) {
for (String implementationFqn : implementations) {
if (!isConstantLikeAccessor(accessorIndex, implementationFqn, methodName)) {
return false;
}
}
return true;
}
private static boolean isConstantLikeAccessor(
AccessorIndex accessorIndex,
String typeFqn,
String methodName) {
Optional<AccessorSummary> accessor = accessorIndex.lookup(typeFqn, methodName);
if (accessor.isEmpty() || !accessor.get().isGetter()) {
return false;
}
return accessor.get().kind() == AccessorKind.CONSTANT_GETTER;
}
private static Optional<AccessorSummary> findRepresentativeImplAccessor(
AccessorIndex accessorIndex,
List<String> implementations,
String methodName) {
for (String implementationFqn : implementations) {
Optional<AccessorSummary> implAccessor = accessorIndex.lookup(implementationFqn, methodName);
if (implAccessor.isPresent() && implAccessor.get().isGetter()) {
return implAccessor;
}
}
return Optional.empty();
}
private static Set<String> collectGetterMethodNames(
CodebaseContext context,
AccessorIndex accessorIndex,
String ownerFqn,
List<String> implementations) {
Set<String> methodNames = new LinkedHashSet<>();
for (AccessorSummary accessor : accessorIndex.accessorsForType(ownerFqn)) {
if (accessor.isGetter()) {
methodNames.add(accessor.methodName());
}
}
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
if (ownerType != null) {
for (MethodDeclaration method : ownerType.getMethods()) {
String name = method.getName().getIdentifier();
if (looksLikeGetterName(name)) {
methodNames.add(name);
}
}
}
for (String implementationFqn : implementations) {
for (AccessorSummary accessor : accessorIndex.accessorsForType(implementationFqn)) {
if (accessor.isGetter()) {
methodNames.add(accessor.methodName());
}
}
}
return methodNames;
}
private static boolean isPolymorphicType(TypeDeclaration typeDeclaration) {
return typeDeclaration != null
&& (typeDeclaration.isInterface()
|| Modifier.isAbstract(typeDeclaration.getModifiers()));
}
private static boolean looksLikeGetterName(String methodName) {
return methodName.startsWith("get") || methodName.startsWith("is");
}
}

View File

@@ -0,0 +1,58 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Scan-time expansion of interface/abstract method calls to concrete implementation method FQNs.
*/
public final class PolymorphicMethodExpansionIndex {
private static final PolymorphicMethodExpansionIndex EMPTY =
new PolymorphicMethodExpansionIndex(Map.of(), 0);
private final Map<String, Map<String, List<String>>> byOwnerAndMethod;
private final int entryCount;
public PolymorphicMethodExpansionIndex(
Map<String, Map<String, List<String>>> byOwnerAndMethod,
int entryCount) {
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
this.entryCount = entryCount;
}
public static PolymorphicMethodExpansionIndex empty() {
return EMPTY;
}
public Optional<List<String>> lookup(String ownerFqn, String methodName) {
if (ownerFqn == null || methodName == null) {
return Optional.empty();
}
Map<String, List<String>> methods = byOwnerAndMethod.get(ownerFqn);
if (methods == null) {
return Optional.empty();
}
List<String> expanded = methods.get(methodName);
if (expanded == null || expanded.isEmpty()) {
return Optional.empty();
}
return Optional.of(expanded);
}
public int entryCount() {
return entryCount;
}
private static Map<String, Map<String, List<String>>> deepCopy(
Map<String, Map<String, List<String>>> source) {
Map<String, Map<String, List<String>>> copy = new HashMap<>();
for (Map.Entry<String, Map<String, List<String>>> entry : source.entrySet()) {
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
}
return copy;
}
}

View File

@@ -0,0 +1,81 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Slf4j
public final class PolymorphicMethodExpansionIndexBuilder {
public PolymorphicMethodExpansionIndex build(CodebaseContext context) {
if (context == null) {
return PolymorphicMethodExpansionIndex.empty();
}
Map<String, Map<String, List<String>>> entries = new HashMap<>();
int entryCount = 0;
for (String typeFqn : context.getIndexedTypeFqns()) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(typeFqn);
if (!isPolymorphicType(typeDeclaration)) {
continue;
}
List<String> implementations = context.getImplementations(typeFqn);
if (implementations.isEmpty()) {
continue;
}
Set<String> methodNames = collectMethodNames(context, typeDeclaration, implementations);
for (String methodName : methodNames) {
List<String> expanded = new ArrayList<>();
for (String implementationFqn : implementations) {
expanded.add(implementationFqn + "." + methodName);
}
entries.computeIfAbsent(typeFqn, ignored -> new HashMap<>())
.put(methodName, List.copyOf(expanded));
entryCount++;
}
}
log.info("Built polymorphic method expansion index: {} entries across {} types",
entryCount, entries.size());
return new PolymorphicMethodExpansionIndex(entries, entryCount);
}
private static Set<String> collectMethodNames(
CodebaseContext context,
TypeDeclaration ownerType,
List<String> implementations) {
Set<String> methodNames = new LinkedHashSet<>();
if (ownerType != null) {
for (var method : ownerType.getMethods()) {
methodNames.add(method.getName().getIdentifier());
}
}
for (String implementationFqn : implementations) {
TypeDeclaration implementationType = context.getTypeDeclaration(implementationFqn);
if (implementationType == null) {
continue;
}
for (var method : implementationType.getMethods()) {
methodNames.add(method.getName().getIdentifier());
}
}
return methodNames;
}
private static boolean isPolymorphicType(TypeDeclaration typeDeclaration) {
return typeDeclaration != null
&& (typeDeclaration.isInterface()
|| Modifier.isAbstract(typeDeclaration.getModifiers()));
}
}

View File

@@ -0,0 +1,162 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* Resolves accessor return constants using only scan-time information: indexed accessors,
* literal method bodies, and field initializers. No dataflow or constructor tracing.
*/
public final class ScanTimeAccessorConstants {
private ScanTimeAccessorConstants() {
}
public static List<String> resolve(
CodebaseContext context,
AccessorIndex accessorIndex,
String ownerFqn,
String methodName) {
if (context == null || accessorIndex == null || ownerFqn == null || methodName == null) {
return List.of();
}
Set<String> deduped = new LinkedHashSet<>();
Optional<AccessorSummary> indexed = accessorIndex.lookup(ownerFqn, methodName);
if (indexed.isPresent() && indexed.get().isGetter()) {
if (indexed.get().kind() == AccessorKind.CONSTANT_GETTER) {
deduped.addAll(extractReturnConstants(context, ownerFqn, methodName));
} else if (indexed.get().fieldName() != null) {
deduped.addAll(extractFieldInitializerConstants(
context, ownerFqn, indexed.get().fieldName()));
}
}
if (deduped.isEmpty()) {
deduped.addAll(extractReturnConstants(context, ownerFqn, methodName));
}
return List.copyOf(deduped);
}
private static List<String> extractReturnConstants(
CodebaseContext context,
String ownerFqn,
String methodName) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
if (typeDeclaration == null) {
return List.of();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
ConstantResolver constantResolver = context.getConstantResolver();
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
Expression expression = node.getExpression();
if (expression != null) {
addResolvedExpression(constantResolver, context, expression, constants);
}
return false;
}
});
return constants;
}
private static List<String> extractFieldInitializerConstants(
CodebaseContext context,
String ownerFqn,
String fieldName) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
if (typeDeclaration == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
collectFieldInitializerConstants(context, typeDeclaration, fieldName, constants);
return constants;
}
private static void collectFieldInitializerConstants(
CodebaseContext context,
TypeDeclaration typeDeclaration,
String fieldName,
List<String> constants) {
ConstantResolver constantResolver = context.getConstantResolver();
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
for (Object fragmentObj : fieldDeclaration.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(fieldName)
&& fragment.getInitializer() != null) {
addResolvedExpression(
constantResolver, context, fragment.getInitializer(), constants);
}
}
}
if (constants.isEmpty()) {
String superFqn = context.getSuperclassFqn(typeDeclaration);
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
if (superType != null) {
collectFieldInitializerConstants(context, superType, fieldName, constants);
}
}
}
}
private static void addResolvedExpression(
ConstantResolver constantResolver,
CodebaseContext context,
Expression expression,
List<String> constants) {
if (constantResolver == null || expression == null) {
return;
}
String resolved = constantResolver.resolve(expression, context);
if (resolved == null) {
return;
}
if (resolved.startsWith("ENUM_SET:")) {
for (String value : resolved.substring(9).split(",")) {
addUnique(constants, parseEnumSetElement(value));
}
} else if (resolved.startsWith("MAP:")) {
List<String> mapValues = ConstantResolver.decodeMapLiteralValues(resolved);
if (mapValues != null) {
for (String mapValue : mapValues) {
addUnique(constants, mapValue);
}
}
} else if (resolved.startsWith("ARRAY:")) {
for (String arrayValue : resolved.substring(6).split("\\|", -1)) {
addUnique(constants, arrayValue);
}
} else {
addUnique(constants, resolved);
}
}
private static String parseEnumSetElement(String value) {
int lastDot = value.lastIndexOf('.');
int secondLastDot = lastDot > 0 ? value.lastIndexOf('.', lastDot - 1) : -1;
return secondLastDot >= 0 ? value.substring(secondLastDot + 1) : value;
}
private static void addUnique(List<String> constants, String value) {
if (value != null && !constants.contains(value)) {
constants.add(value);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import java.util.AbstractMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Memoizes polymorphic class compatibility checks used during call-graph path matching.
*/
public final class ClassCompatibilityCache {
private final Map<Map.Entry<String, String>, Boolean> cache = new ConcurrentHashMap<>();
public boolean areClassesCompatible(String classNeighbor, String classTarget, ClassCompatibilityChecker checker) {
if (classNeighbor == null || classTarget == null) {
return false;
}
if (classNeighbor.equals(classTarget)) {
return true;
}
Map.Entry<String, String> key = cacheKey(classNeighbor, classTarget);
return cache.computeIfAbsent(key, ignored -> checker.check(classNeighbor, classTarget));
}
public void clear() {
cache.clear();
}
private static Map.Entry<String, String> cacheKey(String classNeighbor, String classTarget) {
if (classNeighbor.compareTo(classTarget) <= 0) {
return new AbstractMap.SimpleImmutableEntry<>(classNeighbor, classTarget);
}
return new AbstractMap.SimpleImmutableEntry<>(classTarget, classNeighbor);
}
@FunctionalInterface
public interface ClassCompatibilityChecker {
boolean check(String classNeighbor, String classTarget);
}
}

View File

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

View File

@@ -0,0 +1,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,313 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.ASTNode;
import java.util.Set;
/**
* Extracts terminal payload literals from reactive factory chains ({@code Mono.just}, nested {@code flatMap}).
*/
public final class ReactiveExpressionSupport {
private static final Set<String> REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success");
private ReactiveExpressionSupport() {
}
public static String extractPayload(Expression expression, ConstantResolver constantResolver, CodebaseContext context) {
return extractPayload(expression, null, constantResolver, context);
}
/**
* Rewrites {@code p.getEvent()} inside a {@code flatMap(p -> ...)} lambda to {@code payload.getEvent()}
* when {@code p} is fed by {@code Mono.just(payload)} (or a chained reactive source).
*/
public static String remapLambdaParameterGetter(
Expression eventExpr,
ConstantResolver constantResolver,
CodebaseContext context) {
if (!(eventExpr instanceof MethodInvocation getterCall)) {
return null;
}
if (!(getterCall.getExpression() instanceof SimpleName paramName)) {
return null;
}
LambdaExpression lambda = findEnclosingLambda(getterCall);
if (lambda == null) {
return null;
}
MethodInvocation flatMapCall = findEnclosingFlatMap(lambda);
if (flatMapCall == null) {
return null;
}
String mappedReceiver = mapLambdaParameterToSource(
lambda,
paramName.getIdentifier(),
flatMapCall.getExpression(),
constantResolver,
context);
if (mappedReceiver == null) {
return null;
}
return mappedReceiver + "." + getterCall.getName().getIdentifier() + "()";
}
/**
* Same as {@link #remapLambdaParameterGetter(Expression, ConstantResolver, CodebaseContext)} but locates
* the getter in a real method body when {@code expressionText} came from a detached parse tree.
*/
public static String remapLambdaParameterGetterInMethod(
String expressionText,
String methodFqn,
ConstantResolver constantResolver,
CodebaseContext context) {
MethodInvocation getter = findMethodInvocationInMethod(methodFqn, expressionText, context);
if (getter == null) {
return null;
}
String remapped = remapLambdaParameterGetter(getter, constantResolver, context);
if (remapped != null) {
return remapped;
}
return null;
}
private static MethodInvocation findMethodInvocationInMethod(
String methodFqn,
String expressionText,
CodebaseContext context) {
if (methodFqn == null || expressionText == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
org.eclipse.jdt.core.dom.MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
final MethodInvocation[] match = new MethodInvocation[1];
md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (expressionText.equals(node.toString())) {
match[0] = node;
return false;
}
return super.visit(node);
}
});
return match[0];
}
private static LambdaExpression findEnclosingLambda(ASTNode node) {
ASTNode current = node.getParent();
while (current != null) {
if (current instanceof LambdaExpression lambda) {
return lambda;
}
current = current.getParent();
}
return null;
}
private static MethodInvocation findEnclosingFlatMap(ASTNode node) {
ASTNode current = node.getParent();
while (current != null) {
if (current instanceof MethodInvocation mi && "flatMap".equals(mi.getName().getIdentifier())) {
return mi;
}
current = current.getParent();
}
return null;
}
private static String extractPayload(
Expression expression,
Expression flatMapReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (expression == null) {
return null;
}
if (expression instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier();
if ("flatMap".equals(methodName) && !mi.arguments().isEmpty()) {
String fromArgument = extractFlatMapArgumentPayload(
(Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context);
if (fromArgument != null) {
return fromArgument;
}
}
if (REACTIVE_FACTORY_METHODS.contains(methodName) && !mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0);
if (constantResolver != null && context != null) {
String resolved = constantResolver.resolve(arg, context);
if (resolved != null) {
return resolved;
}
}
return arg.toString();
}
if (mi.getExpression() != null) {
return extractPayload(mi.getExpression(), flatMapReceiver, constantResolver, context);
}
}
return null;
}
private static String extractFlatMapArgumentPayload(
Expression argument,
Expression flatMapReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (argument instanceof LambdaExpression lambda) {
Expression bodyExpression = lambdaBodyExpression(lambda);
if (bodyExpression != null) {
String lambdaPayload = extractLambdaBodyPayload(
bodyExpression, lambda, flatMapReceiver, constantResolver, context);
if (lambdaPayload != null) {
return lambdaPayload;
}
return extractPayload(bodyExpression, flatMapReceiver, constantResolver, context);
}
}
return extractPayload(argument, flatMapReceiver, constantResolver, context);
}
private static String extractLambdaBodyPayload(
Expression bodyExpression,
LambdaExpression lambda,
Expression flatMapReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (!(bodyExpression instanceof MethodInvocation factoryCall)) {
return null;
}
if (!REACTIVE_FACTORY_METHODS.contains(factoryCall.getName().getIdentifier())
|| factoryCall.arguments().isEmpty()) {
return null;
}
Expression factoryArg = (Expression) factoryCall.arguments().get(0);
if (!(factoryArg instanceof MethodInvocation getterCall)) {
return null;
}
String getterSuffix = "." + getterCall.getName().getIdentifier() + "()";
Expression getterReceiver = getterCall.getExpression();
if (getterReceiver instanceof SimpleName paramName) {
String mappedReceiver = mapLambdaParameterToSource(
lambda, paramName.getIdentifier(), flatMapReceiver, constantResolver, context);
if (mappedReceiver != null) {
return mappedReceiver + getterSuffix;
}
}
if (getterReceiver != null) {
return getterReceiver.toString() + getterSuffix;
}
return null;
}
private static String mapLambdaParameterToSource(
LambdaExpression lambda,
String paramName,
Expression flatMapReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (lambda.parameters().size() != 1) {
return null;
}
Object parameter = lambda.parameters().get(0);
if (!(parameter instanceof org.eclipse.jdt.core.dom.VariableDeclaration variableDeclaration)) {
return null;
}
if (!paramName.equals(variableDeclaration.getName().getIdentifier())) {
return null;
}
if (flatMapReceiver instanceof MethodInvocation receiverFlatMap
&& "flatMap".equals(receiverFlatMap.getName().getIdentifier())
&& !receiverFlatMap.arguments().isEmpty()
&& receiverFlatMap.arguments().get(0) instanceof LambdaExpression feederLambda
&& feederLambda != lambda) {
Expression feederBody = lambdaBodyExpression(feederLambda);
if (feederBody != null) {
String feederPayload = extractLambdaBodyPayload(
feederBody, feederLambda, receiverFlatMap.getExpression(), constantResolver, context);
if (feederPayload != null) {
return feederPayload;
}
String extracted = extractPayload(
feederBody, receiverFlatMap.getExpression(), constantResolver, context);
if (extracted != null) {
return extracted;
}
}
}
Expression source = peelJustArgument(flatMapReceiver);
if (source instanceof MethodInvocation getterMi
&& getterMi.getExpression() instanceof SimpleName innerParamName) {
LambdaExpression outerLambda = findEnclosingLambda(lambda);
if (outerLambda != null && outerLambda != lambda) {
MethodInvocation outerFlatMap = findEnclosingFlatMap(outerLambda);
if (outerFlatMap != null) {
String mappedBase = mapLambdaParameterToSource(
outerLambda,
innerParamName.getIdentifier(),
outerFlatMap.getExpression(),
constantResolver,
context);
if (mappedBase != null) {
return mappedBase + "." + getterMi.getName().getIdentifier() + "()";
}
}
}
}
if (source != null) {
if (constantResolver != null && context != null) {
String resolved = constantResolver.resolve(source, context);
if (resolved != null) {
return resolved;
}
}
return source.toString();
}
return extractPayload(flatMapReceiver, null, constantResolver, context);
}
private static Expression peelJustArgument(Expression expression) {
if (expression instanceof MethodInvocation mi
&& REACTIVE_FACTORY_METHODS.contains(mi.getName().getIdentifier())
&& !mi.arguments().isEmpty()) {
return (Expression) mi.arguments().get(0);
}
if (expression instanceof MethodInvocation mi && mi.getExpression() != null) {
return peelJustArgument(mi.getExpression());
}
return null;
}
private static Expression lambdaBodyExpression(LambdaExpression lambda) {
if (lambda.getBody() instanceof Expression bodyExpression) {
return bodyExpression;
}
if (lambda.getBody() instanceof Block block) {
for (Object statement : block.statements()) {
if (statement instanceof ReturnStatement returnStatement
&& returnStatement.getExpression() != null) {
return returnStatement.getExpression();
}
}
}
return null;
}
}

View File

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

View File

@@ -0,0 +1,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,14 +1,17 @@
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;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@Slf4j
public class ConstantResolver {
@@ -17,6 +20,40 @@ public class ConstantResolver {
return resolveInternal(expr, context, new HashSet<>());
}
/**
* Resolves {@code map.get(key)} when the map receiver and key are statically known.
*/
public String resolveKeyedMapLookup(MethodInvocation getCall, CodebaseContext context) {
if (getCall == null || getCall.getExpression() == null || getCall.arguments().isEmpty()) {
return null;
}
String methodName = getCall.getName().getIdentifier();
if (!"get".equals(methodName) && !"getOrDefault".equals(methodName)) {
return null;
}
return LiteralExpressionSupport.resolveMapGet(
getCall, context, new HashSet<>(), (e, v) -> resolveInternal(e, context, v));
}
/**
* Resolves a value from an encoded {@code MAP:...} literal or map-typed expression at {@code key}.
*/
public String resolveKeyedMapLookup(Expression mapExpr, String key, CodebaseContext context) {
if (mapExpr == null || key == null) {
return null;
}
String encoded = resolveInternal(mapExpr, context, new HashSet<>());
if (encoded == null || !encoded.startsWith("MAP:")) {
return null;
}
Map<String, String> entries = LiteralExpressionSupport.parseMapLiteral(encoded);
return entries != null ? entries.get(key) : null;
}
public static List<String> decodeMapLiteralValues(String encoded) {
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 +72,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 +132,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 +144,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 +750,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 +844,19 @@ 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;
}
}
for (String classFqn : classesFromCurrentCallPath()) {
TypeDeclaration pathTd = context.getTypeDeclaration(classFqn);
if (pathTd != null) {
String result = resolveFieldInType(pathTd, sn.getIdentifier(), classFqn, context, visited);
if (result != null) {
return result;
}
}
}
// Fallback: Static imports
@@ -742,6 +877,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 +889,81 @@ 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) {
List<String> classes = classesFromCurrentCallPath();
if (classes.isEmpty()) {
return null;
}
return context.getTypeDeclaration(classes.get(0));
}
private List<String> classesFromCurrentCallPath() {
List<String> path = JdtDataFlowModel.getCurrentPath();
if (path == null || path.isEmpty()) {
return List.of();
}
List<String> classes = new ArrayList<>();
for (int i = path.size() - 1; i >= 0; i--) {
String methodFqn = path.get(i);
int dot = methodFqn.lastIndexOf('.');
if (dot > 0) {
String className = methodFqn.substring(0, dot);
if (!classes.contains(className)) {
classes.add(className);
}
}
}
return classes;
}
private String getCurrentCallPathEntryClass() {
List<String> classes = classesFromCurrentCallPath();
return classes.isEmpty() ? null : classes.get(classes.size() - 1);
}
private String resolveFieldInType(TypeDeclaration td, String fieldName, String typeFqn, CodebaseContext context, Set<String> visited) {
String fieldId = typeFqn + "#" + fieldName;
if (visited.contains(fieldId)) {
@@ -870,9 +1083,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

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

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

View File

@@ -8,8 +8,4 @@ import java.util.List;
public interface CallGraphEngine {
List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers);
default java.util.Map<String, java.util.List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> getCallGraph() {
return java.util.Collections.emptyMap();
}
}

View File

@@ -18,6 +18,12 @@ public class CallGraphPathFinder {
this.context = context;
}
public void clearAnalysisCaches() {
heuristicCache.clear();
memoCache.clear();
unreachableCache.clear();
}
public List<String> findPath(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) {
if (start.equals(target)) return new ArrayList<>(List.of(start));
if (!visited.add(start)) {
@@ -56,6 +62,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 +196,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);
@@ -230,17 +366,8 @@ public class CallGraphPathFinder {
if (simpleClassNeighbor.equals(simpleClassTarget)) return true;
List<String> impls = context.getImplementations(classTarget);
if (impls != null) {
for (String impl : impls) {
if (impl.equals(classNeighbor) || impl.endsWith("." + classNeighbor)) return true;
}
}
List<String> implsN = context.getImplementations(classNeighbor);
if (implsN != null) {
for (String impl : implsN) {
if (impl.equals(classTarget) || impl.endsWith("." + classTarget)) return true;
}
if (context.areClassesPolymorphicallyCompatible(classNeighbor, classTarget)) {
return true;
}
return false;

View File

@@ -1,118 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import java.util.*;
public class CompositeCallGraphEngine implements CallGraphEngine {
private final List<CallGraphEngine> engines;
private final Map<String, List<CallEdge>> mergedGraph = new HashMap<>();
public CompositeCallGraphEngine(List<CallGraphEngine> engines) {
this.engines = engines;
mergeGraphs();
}
private void mergeGraphs() {
for (CallGraphEngine engine : engines) {
Map<String, List<CallEdge>> graph = engine.getCallGraph();
if (graph != null) {
for (Map.Entry<String, List<CallEdge>> entry : graph.entrySet()) {
String caller = normalizeCompanionFqn(entry.getKey());
List<CallEdge> edges = mergedGraph.computeIfAbsent(caller, k -> new ArrayList<>());
for (CallEdge edge : entry.getValue()) {
String target = normalizeCompanionFqn(edge.getTargetMethod());
edges.add(new CallEdge(target, edge.getArguments(), edge.getReceiver()));
}
}
}
}
}
private String normalizeCompanionFqn(String fqn) {
if (fqn == null) return null;
return fqn.replace(".Companion.", ".");
}
@Override
public Map<String, List<CallEdge>> getCallGraph() {
return mergedGraph;
}
@Override
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
List<CallChain> chains = new ArrayList<>();
for (EntryPoint ep : entryPoints) {
String startMethod = normalizeCompanionFqn(ep.getClassName() + "." + ep.getMethodName());
for (TriggerPoint tp : triggers) {
String targetMethod = normalizeCompanionFqn(tp.getClassName() + "." + tp.getMethodName());
List<List<String>> paths = findAllPaths(startMethod, targetMethod, new LinkedHashSet<>());
for (List<String> path : paths) {
chains.add(CallChain.builder()
.entryPoint(ep)
.methodChain(path)
.triggerPoint(tp)
.build());
}
}
}
return chains;
}
private List<List<String>> findAllPaths(String start, String target, Set<String> visited) {
List<List<String>> result = new ArrayList<>();
if (start.equals(target)) {
result.add(new ArrayList<>(List.of(start)));
return result;
}
if (!visited.add(start)) {
return result;
}
List<CallEdge> neighbors = mergedGraph.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;
}
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
List<String> path = new ArrayList<>(List.of(start, target));
result.add(path);
} else {
List<List<String>> subPaths = findAllPaths(neighbor, target, visited);
for (List<String> subPath : subPaths) {
List<String> path = new ArrayList<>();
path.add(start);
path.addAll(subPath);
result.add(path);
}
}
}
}
visited.remove(start);
return result;
}
private boolean isHeuristicMatch(String neighbor, String target) {
if (neighbor == null || target == null) return false;
if (neighbor.equals(target)) return true;
if (neighbor.contains(".") && target.contains(".")) {
String methodNeighbor = neighbor.substring(neighbor.lastIndexOf('.') + 1);
String methodTarget = target.substring(target.lastIndexOf('.') + 1);
if (methodNeighbor.equals(methodTarget)) {
String classNeighbor = neighbor.substring(0, neighbor.lastIndexOf('.'));
String classTarget = target.substring(0, target.lastIndexOf('.'));
String simpleClassNeighbor = classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
String simpleClassTarget = classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
return simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget);
}
}
return false;
}
}

View File

@@ -1,53 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import java.util.*;
public class CompositeIntelligenceProvider implements CodebaseIntelligenceProvider {
private final List<CodebaseIntelligenceProvider> providers;
private final List<CallGraphEngine> engines;
public CompositeIntelligenceProvider(List<CodebaseIntelligenceProvider> providers, List<CallGraphEngine> engines) {
this.providers = providers;
this.engines = engines;
}
@Override
public List<TriggerPoint> findTriggerPoints() {
List<TriggerPoint> result = new ArrayList<>();
for (CodebaseIntelligenceProvider provider : providers) {
result.addAll(provider.findTriggerPoints());
}
return result;
}
@Override
public List<EntryPoint> findEntryPoints() {
List<EntryPoint> result = new ArrayList<>();
for (CodebaseIntelligenceProvider provider : providers) {
result.addAll(provider.findEntryPoints());
}
return result;
}
@Override
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
CompositeCallGraphEngine compositeEngine = new CompositeCallGraphEngine(engines);
return compositeEngine.findChains(entryPoints, triggers);
}
@Override
public Map<String, Map<String, String>> resolveProperties() {
Map<String, Map<String, String>> result = new HashMap<>();
for (CodebaseIntelligenceProvider provider : providers) {
Map<String, Map<String, String>> props = provider.resolveProperties();
if (props != null) {
result.putAll(props);
}
}
return result;
}
}

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);
@@ -89,22 +95,20 @@ public class ConstantExtractor {
} else if (expr instanceof MethodInvocation mi) {
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);
}
}
return;
if (log.isTraceEnabled()) {
log.trace("Processing mi: {}", mi);
}
if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") ||
methodName.equals("just") || methodName.equals("withPayload") || methodName.equals("success")) {
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;
}
}
if (LibraryUnwrapRegistry.isUnwrapMethodName(methodName)) {
for (Object argObj : mi.arguments()) {
extractConstantsFromExpression((Expression) argObj, constants);
}
@@ -215,9 +219,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,24 +286,98 @@ public class ConstantExtractor {
}
public List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) {
log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName);
log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth);
if (depth > 20) {
log.debug("Exiting resolveMethodReturnConstant early (depth limit) for {}.{}", className, methodName);
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) {
return resolveMethodReturnConstant(className, methodName, depth, visited, contextCu, budget, true);
}
public List<String> resolveMethodReturnConstant(
String className,
String methodName,
int depth,
Set<String> visited,
CompilationUnit contextCu,
ResolutionBudget budget,
boolean allowWidenToImplementations) {
final boolean debug = log.isDebugEnabled();
if (debug) {
log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName);
log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth);
}
if (budget == null) {
budget = ResolutionBudget.defaults();
}
final ResolutionBudget activeBudget = budget;
if (activeBudget.isMethodReturnExhausted(depth)) {
if (debug) {
log.debug("Exiting resolveMethodReturnConstant early (depth limit) for {}.{}", className, methodName);
}
return Collections.emptyList();
}
String fqn = className + "." + methodName;
if (!visited.add(fqn)) {
log.debug("Exiting resolveMethodReturnConstant early (cycle detected) for fqn={}", fqn);
if (debug) {
log.debug("Exiting resolveMethodReturnConstant early (cycle detected) for fqn={}", fqn);
}
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 = allowWidenToImplementations
&& (td == null
|| td.isInterface()
|| Modifier.isAbstract(td.getModifiers()));
if (widenToImplementations && depth == 0) {
Optional<List<String>> precomputed = context.getPolymorphicAccessorIndex()
.widenedReturnConstants(className, methodName);
if (precomputed.isPresent()) {
visited.remove(fqn);
return new ArrayList<>(precomputed.get());
}
}
List<String> constants = new ArrayList<>();
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
boolean indexedOnRequestedType = className.equals(indexedAccessor.get().ownerFqn());
if (!widenToImplementations || indexedOnRequestedType) {
if (indexedAccessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER) {
List<String> constantGetterValues = extractConstantGetterReturn(
indexedAccessor.get().ownerFqn(), methodName, contextCu, visited);
if (!constantGetterValues.isEmpty()) {
if (!widenToImplementations) {
visited.remove(fqn);
return constantGetterValues;
}
constants.addAll(constantGetterValues);
}
}
if (constants.isEmpty()) {
List<String> indexedConstants = resolveIndexedGetterFieldConstants(
indexedAccessor.get(), className, contextCu, visited);
if (!indexedConstants.isEmpty()) {
if (!widenToImplementations) {
visited.remove(fqn);
return indexedConstants;
}
constants.addAll(indexedConstants);
}
}
}
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
@@ -322,7 +400,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, allowWidenToImplementations);
constants.addAll(delegationResult);
handled = true;
}
@@ -341,7 +420,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, allowWidenToImplementations);
constants.addAll(delegationResult);
handled = true;
}
@@ -382,26 +462,234 @@ public class ConstantExtractor {
return super.visit(node);
}
});
} else {
}
if (widenToImplementations) {
List<String> impls = context.getImplementations(className);
if (impls != null) {
for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant(implName, methodName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
List<String> delegationResult = resolveMethodReturnConstant(
implName, methodName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
for (String value : delegationResult) {
if (!constants.contains(value)) {
constants.add(value);
}
}
}
}
}
} else if (widenToImplementations) {
List<String> impls = context.getImplementations(className);
if (impls != null) {
for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant(
implName, methodName, depth + 1, visited, contextCu, activeBudget);
for (String value : delegationResult) {
if (!constants.contains(value)) {
constants.add(value);
}
}
}
}
}
visited.remove(fqn);
log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants);
log.debug("resolveMethodReturnConstant for class={}, method={} resolved constants: {}", className, methodName, constants);
if (debug) {
log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants);
}
return constants;
}
public List<String> resolveIndexedGetterFieldConstants(
AccessorSummary accessor,
String requestingTypeFqn,
CompilationUnit contextCu,
Set<String> visited) {
if (accessor != null && isSafeForPrecomputedFieldConstants(accessor)) {
Optional<List<String>> precomputed = context.getIndexedFieldConstantsIndex()
.lookup(accessor.ownerFqn(), accessor.methodName());
if (precomputed.isPresent()) {
return new ArrayList<>(precomputed.get());
}
}
return AccessorFieldResolver.resolveFieldConstants(
accessor,
requestingTypeFqn,
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 || implementations.isEmpty()) {
return Optional.empty();
}
Optional<AccessorSummary> precomputed = context.getPolymorphicAccessorIndex()
.representativeImplAccessor(className, methodName);
if (precomputed.isPresent()) {
return precomputed;
}
for (String implementation : implementations) {
Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName);
if (implAccessor.isPresent()) {
return implAccessor;
}
}
return Optional.empty();
}
private List<String> extractConstantGetterReturn(
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;
}
private boolean isSafeForPrecomputedFieldConstants(AccessorSummary accessor) {
if (accessor.fieldName() == null || accessor.declaringFqn() == null) {
return accessor.kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER;
}
if (!accessor.ownerFqn().equals(accessor.declaringFqn())) {
return false;
}
TypeDeclaration ownerType = context.getTypeDeclaration(accessor.ownerFqn());
return ownerType != null
&& !ownerType.isInterface()
&& !Modifier.isAbstract(ownerType.getModifiers());
}
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.*;
@@ -13,6 +14,8 @@ public class ConstructorAnalyzer {
private final ConstantResolver constantResolver;
private VariableTracer variableTracer;
private ConstantExtractor constantExtractor;
private final Map<String, ClassInstanceCreation> returnedCicCache = new HashMap<>();
private final Set<String> returnedCicAbsent = new HashSet<>();
@FunctionalInterface
public interface RhsResolver {
@@ -32,7 +35,21 @@ public class ConstructorAnalyzer {
this.constantExtractor = constantExtractor;
}
public void clearReturnedCicCache() {
returnedCicCache.clear();
returnedCicAbsent.clear();
}
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 +79,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 +124,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 +173,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 +215,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,
@@ -459,13 +487,26 @@ public class ConstructorAnalyzer {
public ClassInstanceCreation findReturnedCIC(
String className, String methodName, CodebaseContext context) {
return findReturnedCIC(className, methodName, context, new HashSet<>(), 0);
String cacheKey = className + "#" + methodName;
if (returnedCicAbsent.contains(cacheKey)) {
return null;
}
if (returnedCicCache.containsKey(cacheKey)) {
return returnedCicCache.get(cacheKey);
}
ClassInstanceCreation resolved = findReturnedCIC(className, methodName, context, new HashSet<>(), 0);
if (resolved != null) {
returnedCicCache.put(cacheKey, resolved);
} else {
returnedCicAbsent.add(cacheKey);
}
return resolved;
}
private ClassInstanceCreation findReturnedCIC(
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;
}
@@ -554,11 +595,15 @@ public class ConstructorAnalyzer {
try {
Map<String, String> fieldValues = buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor);
log.debug("RESOLVE_GETTER: td={}, fieldValues={}", context.getFqn(td), fieldValues);
if (log.isDebugEnabled()) {
log.debug("RESOLVE_GETTER: td={}, fieldValues={}", context.getFqn(td), fieldValues);
}
if (fieldValues.isEmpty()) return Collections.emptyList();
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, visited);
log.debug("RESOLVE_GETTER: result={}", result);
if (log.isDebugEnabled()) {
log.debug("RESOLVE_GETTER: result={}", result);
}
return result != null ? List.of(result) : Collections.emptyList();
} finally {
visited.remove(getterKey);

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,297 @@
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) {
String declared = lookupDeclaredType(scopeMethod, sn.getIdentifier());
if (declared != null) {
return declared;
}
return lookupFieldType(classNameFromScopeMethod(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

@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.RequiredArgsConstructor;
@@ -11,6 +12,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 +21,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 +49,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 +78,7 @@ public class GenericEventDetector {
}
MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration type = findEnclosingType(node);
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
if (type == null) return;
String sourceState = extractSourceState(node);
@@ -94,6 +108,10 @@ public class GenericEventDetector {
}
private String extractEventFromReceiver(Expression receiver) {
String reactivePayload = ReactiveExpressionSupport.extractPayload(receiver, constantResolver, context);
if (reactivePayload != null) {
return reactivePayload;
}
if (receiver == null) return null;
if (receiver instanceof MethodInvocation mi) {
String mName = mi.getName().getIdentifier();
@@ -191,18 +209,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;
@@ -227,10 +238,15 @@ public class GenericEventDetector {
} else {
eventValue = context.resolveExpression(eventExpr);
}
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetter(
eventExpr, constantResolver, context);
if (remapped != null) {
eventValue = remapped;
}
}
MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration type = findEnclosingType(node);
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
if (type == null) return Collections.emptyList();
@@ -303,8 +319,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;
@@ -362,6 +380,10 @@ public class GenericEventDetector {
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"`
@@ -382,6 +404,10 @@ public class GenericEventDetector {
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) {
return getSimpleNameString(arg);
@@ -402,6 +428,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 +549,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 +647,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 +664,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 +740,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

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

View File

@@ -24,11 +24,6 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
this.injectionAnalyzer = injectionAnalyzer;
}
@Override
public Map<String, List<CallEdge>> getCallGraph() {
return buildCallGraph();
}
@SuppressWarnings("unchecked")
protected Map<String, List<CallEdge>> buildCallGraph() {
Map<String, List<CallEdge>> cached = (Map<String, List<CallEdge>>) context.getCache().get("jdtCallGraph");
@@ -51,7 +46,8 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
for (String calledMethod : calledMethods) {
CallEdge edge = new CallEdge(calledMethod, args);
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(resolveConstraint(node, calledMethod, constraint));
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
@@ -69,7 +65,8 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
} else {
implicitArgs.addAll(args);
}
CallEdge edge = new CallEdge(refMethod, implicitArgs);
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(refMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
@@ -90,13 +87,14 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
} else {
implicitArgs.addAll(args);
}
CallEdge edge = new CallEdge(calledMethod, implicitArgs);
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args);
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args, receiver);
implEdge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(implEdge);
}
@@ -130,8 +128,9 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
String receiver = "super";
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
CallEdge edge = new CallEdge(calledMethod, args);
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
@@ -201,16 +200,24 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
nameBinding = fa.resolveFieldBinding();
}
if (nameBinding instanceof IVariableBinding varBinding) {
log.debug("CALLGRAPH RESOLVING BEAN FOR BINDING: {} calling {}", varBinding.getName(), methodName);
if (log.isDebugEnabled()) {
log.debug("CALLGRAPH RESOLVING BEAN FOR BINDING: {} calling {}", varBinding.getName(), methodName);
}
String concreteFqn = injectionAnalyzer.resolveInjectedBeanFqn(varBinding);
if (concreteFqn != null) {
log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn);
if (log.isDebugEnabled()) {
log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn);
}
return concreteFqn + "." + methodName;
} else {
log.debug(" -> RESOLVE FAILED, RETURNED NULL");
if (log.isDebugEnabled()) {
log.debug(" -> RESOLVE FAILED, RETURNED NULL");
}
}
} else {
log.debug("CALLGRAPH NAME BINDING IS NOT VARIABLE: {} calling {}", nameBinding, methodName);
if (log.isDebugEnabled()) {
log.debug("CALLGRAPH NAME BINDING IS NOT VARIABLE: {} calling {}", nameBinding, methodName);
}
}
}
@@ -265,88 +272,6 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
return null;
}
private String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier();
// 1. Check local variables in enclosing method
MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode);
if (enclosingMethod != null) {
// Check parameters
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(svd.getType(), receiverNameNode);
}
}
// Check method body (local variables)
if (enclosingMethod.getBody() != null) {
Type[] foundType = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType();
}
}
return super.visit(node);
}
});
if (foundType[0] != null) {
return resolveTypeToFqn(foundType[0], receiverNameNode);
}
}
}
// 2. Check fields in enclosing class and its superclasses
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
TypeDeclaration currentType = enclosingType;
while (currentType != null) {
for (FieldDeclaration field : currentType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(field.getType(), receiverNameNode);
}
}
}
String superclass = context.getSuperclassFqn(currentType);
currentType = superclass != null ? context.getTypeDeclaration(superclass) : null;
}
return null;
}
private String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode);
} else {
simpleName = type.toString();
}
CompilationUnit cu = (CompilationUnit) contextNode.getRoot();
TypeDeclaration td = context.getTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching if CodebaseContext doesn't know it (e.g., external library)
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
return simpleName;
}
private Expression unwrapMessageBuilder(Expression expr) {
if (expr instanceof MethodInvocation mi) {
MethodInvocation current = mi;

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;
@@ -35,6 +35,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
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 +93,15 @@ 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);
List<EntryPoint> scopedEntryPoints = entryPoints != null && !entryPoints.isEmpty()
? entryPoints
: findEntryPoints();
List<TriggerPoint> scopedTriggers = triggers != null && !triggers.isEmpty()
? triggers
: findTriggerPoints();
log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers",
scopedEntryPoints.size(), scopedTriggers.size());
return callGraphEngine.findChains(scopedEntryPoints, scopedTriggers);
}
@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,391 @@
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;
import java.util.StringJoiner;
/**
* 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;
private final Map<String, Map<String, String>> traceBindingsCache = new HashMap<>();
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) {
if (path == null || path.size() < 2) {
return Map.of();
}
String cacheKey = pathCacheKey(path);
Map<String, String> cached = traceBindingsCache.get(cacheKey);
if (cached != null) {
return new HashMap<>(cached);
}
Map<String, String> bindings = computeTraceBindings(path, callGraph, pathFinder);
traceBindingsCache.put(cacheKey, Map.copyOf(bindings));
return new HashMap<>(bindings);
}
public void clearAnalysisCaches() {
traceBindingsCache.clear();
}
private Map<String, String> computeTraceBindings(
List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
Map<String, String> bindings = new HashMap<>();
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;
}
private static String pathCacheKey(List<String> path) {
StringJoiner joiner = new StringJoiner("\0");
for (String hop : path) {
joiner.add(hop);
}
return joiner.toString();
}
public boolean isPathCompatible(
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

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

@@ -7,11 +7,21 @@ import java.util.*;
public class TypeResolver {
private final CodebaseContext context;
private static final Set<String> GENERIC_EVENT_PARAMETER_NAMES = Set.of("e", "ev");
public TypeResolver(CodebaseContext context) {
this.context = context;
}
public int getParameterIndex(String methodFqn, String paramName) {
return getParameterIndex(methodFqn, paramName, false);
}
/**
* When {@code allowSyntheticEventPlaceholder} is true, maps generic trigger placeholders
* such as {@code e} onto the sole parameter of single-arg methods (e.g. {@code sendEvent}).
*/
public int getParameterIndex(String methodFqn, String paramName, boolean allowSyntheticEventPlaceholder) {
if (methodFqn == null || !methodFqn.contains(".")) return -1;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
@@ -25,11 +35,42 @@ public class TypeResolver {
return i;
}
}
if (allowSyntheticEventPlaceholder
&& md.parameters().size() == 1
&& isGenericEventParameterName(paramName)) {
return 0;
}
}
}
return -1;
}
private static boolean isGenericEventParameterName(String paramName) {
return paramName != null && GENERIC_EVENT_PARAMETER_NAMES.contains(paramName);
}
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,8 +1,13 @@
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 click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@@ -12,6 +17,7 @@ public class VariableTracer {
private ConstantExtractor constantExtractor;
private final click.kamil.springstatemachineexporter.ast.common.DataFlowModel dataFlowModel;
private final Map<String, String> variableDeclaredTypeCache = new HashMap<>();
public VariableTracer(CodebaseContext context, ConstantResolver constantResolver) {
this.context = context;
@@ -23,6 +29,13 @@ public class VariableTracer {
this.constantExtractor = constantExtractor;
}
public void clearAnalysisCaches() {
variableDeclaredTypeCache.clear();
if (dataFlowModel instanceof JdtDataFlowModel jdtDataFlowModel) {
jdtDataFlowModel.clearAnalysisCaches();
}
}
public Expression traceVariable(Expression expr) {
List<Expression> all = traceVariableAll(expr);
return all.isEmpty() ? expr : all.get(all.size() - 1);
@@ -51,51 +64,91 @@ public class VariableTracer {
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
final Expression[] setterArg = new Expression[1];
String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName;
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.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) {
if (!node.arguments().isEmpty()) {
setterArg[0] = (Expression) node.arguments().get(0);
}
}
}
return super.visit(node);
if (td == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
String setterMethodName;
String indexedFieldName;
if (isBeanStyleAccessorName(getterName)) {
String 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();
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof FieldAccess fa) {
if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) {
if (fa.getName().getIdentifier().equalsIgnoreCase(propName)) {
setterArg[0] = node.getRightHandSide();
}
}
} else if (node.getLeftHandSide() instanceof QualifiedName qqn) {
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
if (qqn.getName().getIdentifier().equalsIgnoreCase(propName)) {
setterArg[0] = node.getRightHandSide();
}
}
}
return super.visit(node);
}
});
return setterArg[0];
}
}
} else {
String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName;
setterMethodName = "set" + propName;
indexedFieldName = propName;
}
if (dataFlowModel instanceof JdtDataFlowModel jdtDataFlowModel) {
return jdtDataFlowModel.findLocalSetterArgument(
md, varName, getterName, setterMethodName, indexedFieldName);
}
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;
if (td == null) {
return null;
}
String typeFqn = context.getFqn(td);
if (!visited.add(typeFqn)) return null;
if (!visited.add(typeFqn)) {
return null;
}
Optional<String> indexed = context.getFieldTypeIndex().lookup(typeFqn, fieldName);
if (indexed.isPresent()) {
return indexed.get();
}
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
@@ -149,7 +202,21 @@ public class VariableTracer {
}
public String getVariableDeclaredType(String methodFqn, String cleanFieldName) {
if (methodFqn == null) return null;
if (methodFqn == null) {
return null;
}
String cacheKey = methodFqn + "|" + cleanFieldName;
String cached = variableDeclaredTypeCache.get(cacheKey);
if (cached != null) {
return cached.isEmpty() ? null : cached;
}
String resolved = resolveVariableDeclaredTypeUncached(methodFqn, cleanFieldName);
variableDeclaredTypeCache.put(cacheKey, resolved == null ? "" : resolved);
return resolved;
}
private String resolveVariableDeclaredTypeUncached(String methodFqn, String cleanFieldName) {
int lastDot = methodFqn.lastIndexOf('.');
if (lastDot > 0) {
String fqn = methodFqn.substring(0, lastDot);
@@ -270,19 +337,6 @@ public class VariableTracer {
String fieldType = getFieldType(td, cleanFieldName, context, new java.util.HashSet<>());
if (fieldType != null) return fieldType;
}
}
// Final fallback: ask constantExtractor to resolve it via method return constant
if (constantExtractor != null && methodFqn.contains(".")) {
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
CompilationUnit cu = td != null && td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
List<String> values = constantExtractor.resolveMethodReturnConstant(cleanFieldName, methodName, 0, new HashSet<>(), cu);
if (values != null && !values.isEmpty()) {
// If it resolves to some class constant, we could infer type or return a fallback
return null;
}
}
return null;
}
@@ -656,62 +710,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 +732,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,18 @@
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.index.FieldTypeIndex;
import click.kamil.springstatemachineexporter.analysis.index.FieldTypeIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.GetterChainIndex;
import click.kamil.springstatemachineexporter.analysis.index.GetterChainIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.IndexedFieldConstantsIndex;
import click.kamil.springstatemachineexporter.analysis.index.IndexedFieldConstantsIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.PolymorphicAccessorIndex;
import click.kamil.springstatemachineexporter.analysis.index.PolymorphicAccessorIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.PolymorphicMethodExpansionIndex;
import click.kamil.springstatemachineexporter.analysis.index.PolymorphicMethodExpansionIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.pipeline.ClassCompatibilityCache;
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
@@ -18,15 +31,17 @@ import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import click.kamil.springstatemachineexporter.spi.SourceContext;
@Slf4j
public class CodebaseContext implements SourceContext {
public class CodebaseContext {
private final Map<String, CompilationUnit> classes = new HashMap<>();
private final Map<String, TypeDeclaration> typeDeclarations = new HashMap<>();
private final Map<String, org.eclipse.jdt.core.dom.RecordDeclaration> recordDeclarations = new HashMap<>();
private final Map<String, Path> classPaths = new HashMap<>();
private final Map<String, String> simpleNameToFqn = new HashMap<>();
private final Set<String> ambiguousSimpleNames = new HashSet<>();
@@ -43,18 +58,71 @@ public class CodebaseContext implements SourceContext {
private final ObjectMapper objectMapper = new ObjectMapper();
private Path projectRoot;
private final Map<String, Object> cache = new HashMap<>();
private AccessorIndex accessorIndex = AccessorIndex.empty();
private PolymorphicAccessorIndex polymorphicAccessorIndex = PolymorphicAccessorIndex.empty();
private GetterChainIndex getterChainIndex = GetterChainIndex.empty();
private IndexedFieldConstantsIndex indexedFieldConstantsIndex = IndexedFieldConstantsIndex.empty();
private FieldTypeIndex fieldTypeIndex = FieldTypeIndex.empty();
private PolymorphicMethodExpansionIndex polymorphicMethodExpansionIndex =
PolymorphicMethodExpansionIndex.empty();
private final ClassCompatibilityCache classCompatibilityCache = new ClassCompatibilityCache();
private final Map<String, MethodDeclaration> methodDeclarationCache = new HashMap<>();
private final Map<String, String> superclassFqnCache = new HashMap<>();
private final Map<String, List<String>> implementationsCache = new HashMap<>();
private boolean inlineAccessors = true;
public Map<String, Object> getCache() {
return cache;
}
public void setProjectRoot(Path root) {
this.projectRoot = root;
public AccessorIndex getAccessorIndex() {
return accessorIndex;
}
@Override
public Path getProjectRoot() {
return projectRoot;
public PolymorphicAccessorIndex getPolymorphicAccessorIndex() {
return polymorphicAccessorIndex;
}
public GetterChainIndex getGetterChainIndex() {
return getterChainIndex;
}
public IndexedFieldConstantsIndex getIndexedFieldConstantsIndex() {
return indexedFieldConstantsIndex;
}
public FieldTypeIndex getFieldTypeIndex() {
return fieldTypeIndex;
}
public PolymorphicMethodExpansionIndex getPolymorphicMethodExpansionIndex() {
return polymorphicMethodExpansionIndex;
}
public ClassCompatibilityCache getClassCompatibilityCache() {
return classCompatibilityCache;
}
public void clearAnalysisCaches() {
methodDeclarationCache.clear();
superclassFqnCache.clear();
classCompatibilityCache.clear();
}
public Set<String> getIndexedTypeFqns() {
return Collections.unmodifiableSet(classes.keySet());
}
public boolean isInlineAccessors() {
return inlineAccessors;
}
public void setInlineAccessors(boolean inlineAccessors) {
this.inlineAccessors = inlineAccessors;
}
public void setProjectRoot(Path root) {
this.projectRoot = root;
}
public String getRelativePath(Path fullPath) {
@@ -81,7 +149,31 @@ public class CodebaseContext implements SourceContext {
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;
@@ -94,10 +186,6 @@ public class CodebaseContext implements SourceContext {
this.classpath = classpath.toArray(new String[0]);
}
public List<String> getClasspath() {
return java.util.Arrays.asList(classpath);
}
public void setSourcepath(List<String> sourcepath) {
this.sourcepath = sourcepath.toArray(new String[0]);
}
@@ -209,6 +297,7 @@ public class CodebaseContext implements SourceContext {
List<Path> javaFiles = FileUtils.findFilesWithExtension(rootDirs, ".java", activeIgnore);
log.info("CodebaseContext found {} java files across {} root directories", javaFiles.size(), rootDirs.size());
typeDeclarations.clear();
for (Path javaFile : javaFiles) {
String source = Files.readString(javaFile);
CompilationUnit cu = parse(source, javaFile.toAbsolutePath().toString());
@@ -225,6 +314,29 @@ public class CodebaseContext implements SourceContext {
}
}
}
this.implementationsCache.clear();
this.accessorIndex = inlineAccessors
? new AccessorIndexBuilder().build(this)
: AccessorIndex.empty();
this.polymorphicAccessorIndex = inlineAccessors
? new PolymorphicAccessorIndexBuilder().build(this, accessorIndex)
: PolymorphicAccessorIndex.empty();
this.getterChainIndex = inlineAccessors
? new GetterChainIndexBuilder().build(this, accessorIndex)
: GetterChainIndex.empty();
this.indexedFieldConstantsIndex = inlineAccessors
? new IndexedFieldConstantsIndexBuilder().build(this, accessorIndex)
: IndexedFieldConstantsIndex.empty();
this.fieldTypeIndex = new FieldTypeIndexBuilder().build(this);
this.polymorphicMethodExpansionIndex = new PolymorphicMethodExpansionIndexBuilder().build(this);
clearAnalysisCaches();
if (inlineAccessors) {
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) {
@@ -233,6 +345,7 @@ public class CodebaseContext implements SourceContext {
classes.put(fqn, (CompilationUnit) td.getRoot());
classPaths.put(fqn, javaFile);
typeDeclarations.put(fqn, td);
if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName);
@@ -277,6 +390,7 @@ public class CodebaseContext implements SourceContext {
// 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);
@@ -300,13 +414,21 @@ public class CodebaseContext implements SourceContext {
}
public List<String> getImplementations(String interfaceName) {
String cleanName = interfaceName;
if (interfaceName != null && interfaceName.contains("<")) {
cleanName = interfaceName.substring(0, interfaceName.indexOf('<'));
if (interfaceName == null) {
return List.of();
}
Set<String> allImpls = new HashSet<>();
String cleanName = interfaceName.contains("<")
? interfaceName.substring(0, interfaceName.indexOf('<'))
: interfaceName;
return implementationsCache.computeIfAbsent(cleanName, this::computeImplementations);
}
private List<String> computeImplementations(String cleanName) {
Set<String> allImpls = new LinkedHashSet<>();
collectImplementations(cleanName, allImpls, new HashSet<>());
return new ArrayList<>(allImpls);
List<String> sorted = new ArrayList<>(allImpls);
Collections.sort(sorted);
return Collections.unmodifiableList(sorted);
}
private void collectImplementations(String typeName, Set<String> results, Set<String> visited) {
@@ -420,10 +542,20 @@ public class CodebaseContext implements SourceContext {
}
public TypeDeclaration getTypeDeclaration(String name) {
TypeDeclaration cached = typeDeclarations.get(name);
if (cached != null) {
return cached;
}
// Try exact FQN match first
CompilationUnit cu = classes.get(name);
if (cu != null)
return findTypeInCu(cu, name);
if (cu != null) {
TypeDeclaration resolved = findTypeInCu(cu, name);
if (resolved != null) {
typeDeclarations.put(name, resolved);
}
return resolved;
}
// If it's a simple name, check if it's ambiguous
if (ambiguousSimpleNames.contains(name)) {
@@ -434,9 +566,18 @@ public class CodebaseContext implements SourceContext {
// If not ambiguous, return the one we found during scan
String fqn = simpleNameToFqn.get(name);
if (fqn != null) {
cached = typeDeclarations.get(fqn);
if (cached != null) {
return cached;
}
cu = classes.get(fqn);
if (cu != null)
return findTypeInCu(cu, fqn);
if (cu != null) {
TypeDeclaration resolved = findTypeInCu(cu, fqn);
if (resolved != null) {
typeDeclarations.put(fqn, resolved);
}
return resolved;
}
}
return null;
@@ -496,7 +637,21 @@ public class CodebaseContext implements SourceContext {
}
public String getSuperclassFqn(TypeDeclaration td) {
if (td == null) return null;
if (td == null) {
return null;
}
String typeFqn = getFqn(td);
if (typeFqn != null && superclassFqnCache.containsKey(typeFqn)) {
return superclassFqnCache.get(typeFqn);
}
String resolved = resolveSuperclassFqn(td);
if (typeFqn != null) {
superclassFqnCache.put(typeFqn, resolved);
}
return resolved;
}
private String resolveSuperclassFqn(TypeDeclaration td) {
ITypeBinding binding = td.resolveBinding();
if (binding != null) {
ITypeBinding superBinding = binding.getSuperclass();
@@ -526,7 +681,23 @@ public class CodebaseContext implements SourceContext {
}
public MethodDeclaration findMethodDeclaration(TypeDeclaration td, String methodName, boolean includeSuper) {
if (td == null) return null;
if (td == null) {
return null;
}
String typeFqn = getFqn(td);
if (typeFqn != null) {
String cacheKey = typeFqn + "#" + methodName + "#" + includeSuper;
if (methodDeclarationCache.containsKey(cacheKey)) {
return methodDeclarationCache.get(cacheKey);
}
MethodDeclaration resolved = findMethodDeclarationUncached(td, methodName, includeSuper);
methodDeclarationCache.put(cacheKey, resolved);
return resolved;
}
return findMethodDeclarationUncached(td, methodName, includeSuper);
}
private MethodDeclaration findMethodDeclarationUncached(TypeDeclaration td, String methodName, boolean includeSuper) {
ITypeBinding binding = td.resolveBinding();
if (binding != null && includeSuper) {
IMethodBinding methodBinding = findMethodBinding(binding, methodName);
@@ -605,6 +776,59 @@ public class CodebaseContext implements SourceContext {
return null;
}
public boolean areClassesPolymorphicallyCompatible(String classNeighbor, String classTarget) {
if (classNeighbor == null || classTarget == null) {
return false;
}
if (classNeighbor.equals(classTarget)) {
return true;
}
String simpleClassNeighbor = simpleTypeName(classNeighbor);
String simpleClassTarget = simpleTypeName(classTarget);
if (simpleClassNeighbor.equals(simpleClassTarget)) {
return true;
}
return classCompatibilityCache.areClassesCompatible(
classNeighbor,
classTarget,
this::computePolymorphicClassCompatibility);
}
private boolean computePolymorphicClassCompatibility(String classNeighbor, String classTarget) {
List<String> targetImplementations = getImplementations(classTarget);
for (String implementation : targetImplementations) {
if (implementation.equals(classNeighbor) || implementation.endsWith("." + classNeighbor)) {
return true;
}
}
List<String> neighborImplementations = getImplementations(classNeighbor);
for (String implementation : neighborImplementations) {
if (implementation.equals(classTarget) || implementation.endsWith("." + classTarget)) {
return true;
}
}
return false;
}
private static String simpleTypeName(String typeFqn) {
return typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn;
}
public org.eclipse.jdt.core.dom.RecordDeclaration getRecordDeclaration(String fqn) {
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);
@@ -805,8 +1029,11 @@ public class CodebaseContext implements SourceContext {
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.*;
@@ -11,6 +14,7 @@ public class JdtDataFlowModel implements DataFlowModel {
private final Map<MethodDeclaration, ControlFlowGraph> cfgCache = new HashMap<>();
private final Map<MethodDeclaration, ReachingDefinitions> rdCache = new HashMap<>();
private final Map<ASTNode, Map<IVariableBinding, Expression>> objectFieldBindings = new HashMap<>();
private final Map<String, Set<String>> compatibleContextTypesCache = new HashMap<>();
private static final ThreadLocal<List<String>> CURRENT_PATH = new ThreadLocal<>();
@@ -18,6 +22,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();
}
@@ -26,6 +34,10 @@ public class JdtDataFlowModel implements DataFlowModel {
this.context = context;
}
public void clearAnalysisCaches() {
compatibleContextTypesCache.clear();
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode current = node;
while (current != null) {
@@ -56,7 +68,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 +207,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 +275,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 +322,498 @@ 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)) {
List<Expression> concreteOverride = inlineConcreteMethodOverride(
methodName, receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
if (!concreteOverride.isEmpty()) {
return concreteOverride;
}
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 List<Expression> inlineConcreteMethodOverride(
String methodName,
List<Expression> receiverDefs,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
List<Expression> results = new ArrayList<>();
for (Expression receiverDef : receiverDefs) {
String concreteFqn = concreteReceiverTypeFqn(receiverDef);
if (concreteFqn == null) {
continue;
}
TypeDeclaration concreteType = context.getTypeDeclaration(concreteFqn);
if (concreteType == null) {
continue;
}
MethodDeclaration method = context.findMethodDeclaration(concreteType, methodName, true);
if (method == null || method.getBody() == null) {
continue;
}
for (Object statement : method.getBody().statements()) {
if (statement instanceof ReturnStatement returnStatement
&& returnStatement.getExpression() != null) {
results.addAll(getReachingDefinitions(
returnStatement.getExpression(),
visited,
paramBindings,
instanceFieldBindings,
depth + 1));
}
}
}
return results;
}
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 +948,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 +1225,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 +1327,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);
@@ -946,6 +1495,272 @@ public class JdtDataFlowModel implements DataFlowModel {
return returns;
}
public Expression findLocalSetterArgument(
MethodDeclaration md,
String varName,
String getterName,
String resolvedSetterName,
String resolvedFieldName) {
if (md == null || md.getBody() == null) {
return null;
}
ControlFlowGraph cfg = cfgCache.computeIfAbsent(md, CfgBuilder::build);
MethodInvocation getterUse = findGetterUseOnVariable(cfg, varName, getterName);
List<ControlFlowGraph.CfgNode> nodesToScan;
ControlFlowGraph.CfgNode getterCfgNode = null;
if (getterUse != null && getterUse.getExpression() instanceof SimpleName receiver) {
getterCfgNode = findCfgNode(cfg, getterUse);
nodesToScan = collectCfgNodesBetweenDefAndUse(md, receiver, getterUse, cfg);
} else {
nodesToScan = orderedStatementNodes(cfg);
}
Expression lastSetterArg = null;
for (ControlFlowGraph.CfgNode node : nodesToScan) {
if (getterCfgNode != null && node == getterCfgNode) {
continue;
}
Expression arg = extractSetterArgument(node.getAstNode(), varName, resolvedSetterName, resolvedFieldName);
if (arg != null) {
lastSetterArg = arg;
}
}
return lastSetterArg;
}
private List<ControlFlowGraph.CfgNode> orderedStatementNodes(ControlFlowGraph cfg) {
List<ControlFlowGraph.CfgNode> result = new ArrayList<>();
for (ControlFlowGraph.CfgNode node : cfg.getNodes()) {
if (node.getType() == ControlFlowGraph.CfgNodeType.STATEMENT && node.getAstNode() != null) {
result.add(node);
}
}
result.sort(Comparator.comparingInt(cfg.getNodes()::indexOf));
return result;
}
private MethodInvocation findGetterUseOnVariable(ControlFlowGraph cfg, String varName, String getterName) {
if (getterName == null || getterName.isEmpty()) {
return null;
}
MethodInvocation lastMatch = null;
for (ControlFlowGraph.CfgNode node : orderedStatementNodes(cfg)) {
MethodInvocation match = findGetterInvocationInNode(node.getAstNode(), varName, getterName);
if (match != null) {
lastMatch = match;
}
}
return lastMatch;
}
private MethodInvocation findGetterInvocationInNode(ASTNode node, String varName, String getterName) {
Expression expr = unwrapCfgStatementExpression(node);
if (expr == null) {
return null;
}
final MethodInvocation[] found = new MethodInvocation[1];
if (expr instanceof MethodInvocation mi && matchesGetterInvocation(mi, varName, getterName)) {
found[0] = mi;
}
expr.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation nested) {
if (nested != expr && matchesGetterInvocation(nested, varName, getterName)) {
found[0] = nested;
}
return super.visit(nested);
}
});
return found[0];
}
private boolean matchesGetterInvocation(MethodInvocation mi, String varName, String getterName) {
if (!(mi.getExpression() instanceof SimpleName sn) || !sn.getIdentifier().equals(varName)) {
return false;
}
String methodId = mi.getName().getIdentifier();
if (methodId.equals(getterName) || methodId.equalsIgnoreCase(getterName)) {
return true;
}
if (!getterName.startsWith("get") && !getterName.startsWith("is")) {
return methodId.equals("get" + getterName) || methodId.equalsIgnoreCase("get" + getterName);
}
return false;
}
private List<ControlFlowGraph.CfgNode> collectCfgNodesBetweenDefAndUse(
MethodDeclaration md,
SimpleName receiver,
MethodInvocation useSite,
ControlFlowGraph cfg) {
ReachingDefinitions rd = getReachingDefinitionsForMethod(md);
if (rd == null) {
return orderedStatementNodes(cfg);
}
ControlFlowGraph.CfgNode useCfgNode = findCfgNode(cfg, useSite);
if (useCfgNode == null) {
return orderedStatementNodes(cfg);
}
Set<ControlFlowGraph.CfgNode> pathNodes = new HashSet<>();
Set<ASTNode> defs = rd.getReachingDefinitions(receiver, receiver.getIdentifier());
for (ASTNode def : defs) {
ControlFlowGraph.CfgNode defCfgNode = findCfgNode(cfg, def);
if (defCfgNode == null) {
continue;
}
Set<ControlFlowGraph.CfgNode> reachFromDef = forwardReach(defCfgNode);
Set<ControlFlowGraph.CfgNode> reachToUse = backwardReach(useCfgNode);
Set<ControlFlowGraph.CfgNode> between = new HashSet<>(reachFromDef);
between.retainAll(reachToUse);
pathNodes.addAll(between);
}
if (pathNodes.isEmpty()) {
return orderedStatementNodes(cfg);
}
List<ControlFlowGraph.CfgNode> ordered = new ArrayList<>();
for (ControlFlowGraph.CfgNode node : cfg.getNodes()) {
if (pathNodes.contains(node)) {
ordered.add(node);
}
}
return ordered;
}
private Set<ControlFlowGraph.CfgNode> forwardReach(ControlFlowGraph.CfgNode start) {
Set<ControlFlowGraph.CfgNode> reached = new HashSet<>();
Queue<ControlFlowGraph.CfgNode> queue = new LinkedList<>();
queue.add(start);
reached.add(start);
while (!queue.isEmpty()) {
ControlFlowGraph.CfgNode current = queue.poll();
for (ControlFlowGraph.CfgNode succ : current.getSuccessors()) {
if (reached.add(succ)) {
queue.add(succ);
}
}
}
return reached;
}
private Set<ControlFlowGraph.CfgNode> backwardReach(ControlFlowGraph.CfgNode end) {
Set<ControlFlowGraph.CfgNode> reached = new HashSet<>();
Queue<ControlFlowGraph.CfgNode> queue = new LinkedList<>();
queue.add(end);
reached.add(end);
while (!queue.isEmpty()) {
ControlFlowGraph.CfgNode current = queue.poll();
for (ControlFlowGraph.CfgNode pred : current.getPredecessors()) {
if (reached.add(pred)) {
queue.add(pred);
}
}
}
return reached;
}
private Expression extractSetterArgument(
ASTNode node,
String varName,
String resolvedSetterName,
String resolvedFieldName) {
Expression expr = unwrapCfgStatementExpression(node);
if (expr == null) {
return null;
}
Expression direct = matchSetterExpression(expr, varName, resolvedSetterName, resolvedFieldName);
if (direct != null) {
return direct;
}
if (expr instanceof Assignment assignment) {
return matchAssignmentSetter(assignment, varName, resolvedFieldName);
}
final Expression[] found = new Expression[1];
expr.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation nested) {
Expression arg = matchSetterExpression(nested, varName, resolvedSetterName, resolvedFieldName);
if (arg != null) {
found[0] = arg;
}
return super.visit(nested);
}
@Override
public boolean visit(Assignment assignment) {
Expression arg = matchAssignmentSetter(assignment, varName, resolvedFieldName);
if (arg != null) {
found[0] = arg;
}
return super.visit(assignment);
}
});
return found[0];
}
private Expression unwrapCfgStatementExpression(ASTNode node) {
if (node instanceof ExpressionStatement es) {
return es.getExpression();
}
if (node instanceof VariableDeclarationStatement vds && !vds.fragments().isEmpty()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) vds.fragments().get(0);
return frag.getInitializer();
}
if (node instanceof Assignment assignment) {
return assignment;
}
if (node instanceof Expression expr) {
return expr;
}
return null;
}
private Expression matchSetterExpression(
Expression expr,
String varName,
String resolvedSetterName,
String resolvedFieldName) {
if (!(expr instanceof MethodInvocation mi)) {
return null;
}
String methodId = mi.getName().getIdentifier();
if (!methodId.equals(resolvedSetterName)
&& !methodId.equalsIgnoreCase("set" + resolvedFieldName)
&& !methodId.equalsIgnoreCase(resolvedFieldName)) {
return null;
}
if (mi.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)
&& !mi.arguments().isEmpty()) {
return (Expression) mi.arguments().get(0);
}
return null;
}
private Expression matchAssignmentSetter(Assignment assignment, String varName, String resolvedFieldName) {
Expression lhs = assignment.getLeftHandSide();
if (lhs instanceof FieldAccess fa) {
if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) {
if (fa.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) {
return assignment.getRightHandSide();
}
}
} else if (lhs instanceof QualifiedName qqn) {
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
if (qqn.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) {
return assignment.getRightHandSide();
}
}
}
return null;
}
@Override
public String resolveValue(Expression expr, CodebaseContext context) {
if (expr == null) return null;
@@ -970,6 +1785,12 @@ public class JdtDataFlowModel implements DataFlowModel {
}
private Set<String> getCompatibleContextTypes(List<String> currentPath, String declaringClassFqn) {
String cacheKey = String.valueOf(currentPath) + "#" + declaringClassFqn;
return compatibleContextTypesCache.computeIfAbsent(
cacheKey, ignored -> computeCompatibleContextTypes(currentPath, declaringClassFqn));
}
private Set<String> computeCompatibleContextTypes(List<String> currentPath, String declaringClassFqn) {
Set<String> contextTypes = new HashSet<>();
List<String> implementations = context.getImplementations(declaringClassFqn);

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,147 +0,0 @@
package click.kamil.springstatemachineexporter.plugin.java;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.JdtCallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.app.AstTransitionParser;
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
import click.kamil.springstatemachineexporter.ast.app.TransitionStateUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.spi.LanguageAnalyzerPlugin;
import click.kamil.springstatemachineexporter.spi.SourceContext;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.nio.file.Path;
import java.util.*;
public class JavaLanguageAnalyzerPlugin implements LanguageAnalyzerPlugin {
@Override
public boolean supports(Path projectRoot) {
return true;
}
@Override
public SourceContext parseProject(Path projectRoot, List<String> classpath, Set<Path> sourcePaths) {
CodebaseContext context = new CodebaseContext();
context.setProjectRoot(projectRoot);
if (classpath != null && !classpath.isEmpty()) {
context.setClasspath(classpath);
}
Path hintsFile = projectRoot.resolve("hints.json");
if (!java.nio.file.Files.exists(hintsFile)) {
hintsFile = projectRoot.resolve("src/main/resources/hints.json");
}
if (java.nio.file.Files.exists(hintsFile)) {
try {
context.loadLibraryHints(hintsFile);
} catch (java.io.IOException e) {
throw new RuntimeException(e);
}
}
List<String> sp = sourcePaths.stream()
.map(p -> p.resolve("src/main/java"))
.filter(p -> java.nio.file.Files.exists(p))
.map(Path::toString)
.toList();
if (sp.isEmpty()) {
sp = sourcePaths.stream().map(Path::toString).toList();
}
context.setSourcepath(sp);
context.setResolveBindings(true);
try {
context.scan(sourcePaths, Collections.emptySet());
} catch (java.io.IOException e) {
throw new RuntimeException(e);
}
return context;
}
@Override
public CallGraphEngine createCallGraphEngine(SourceContext context) {
if (context instanceof CodebaseContext jdtContext) {
return new JdtCallGraphEngine(jdtContext, null);
}
throw new IllegalArgumentException("Expected CodebaseContext");
}
@Override
public CodebaseIntelligenceProvider createIntelligenceProvider(SourceContext context) {
if (context instanceof CodebaseContext jdtContext) {
return new JdtIntelligenceProvider(jdtContext, jdtContext.getProjectRoot());
}
throw new IllegalArgumentException("Expected CodebaseContext");
}
@Override
public List<AnalysisResult> extractStateMachines(SourceContext context, boolean renderChoicesAsDiamonds) {
if (context instanceof CodebaseContext jdtContext) {
List<AnalysisResult> results = new ArrayList<>();
// 1. Find entry point classes (annotated or extending adapter)
List<TypeDeclaration> entryPoints = jdtContext.findEntryPointClasses(List.of("EnableStateMachineFactory", "EnableStateMachine"));
for (TypeDeclaration td : entryPoints) {
String className = jdtContext.getFqn(td);
StateMachineAggregator aggregator = new StateMachineAggregator(jdtContext);
List<Transition> transitions = aggregator.aggregateTransitions(td);
aggregator.aggregateStates(td);
Set<String> initialStatesAst = aggregator.getInitialStates();
Set<String> endStatesAst = aggregator.getEndStates();
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);
if (allStates.isEmpty() && transitions.isEmpty()) {
continue;
}
results.add(AnalysisResult.builder()
.name(className)
.states(allStates)
.transitions(transitions)
.startStates(startStates)
.endStates(endStates)
.renderChoicesAsDiamonds(renderChoicesAsDiamonds)
.build());
}
// 2. Find methods returning StateMachine beans
List<MethodDeclaration> beanMethods = jdtContext.findBeanMethodsReturning(Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory"));
for (MethodDeclaration m : beanMethods) {
TypeDeclaration parentClass = (TypeDeclaration) m.getParent();
String parentFqn = jdtContext.getFqn(parentClass);
String methodName = m.getName().getIdentifier();
String uniqueName = parentFqn + "#" + methodName;
List<Transition> transitions = AstTransitionParser.parseTransitions(m, jdtContext);
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);
if (allStates.isEmpty() && transitions.isEmpty()) {
continue;
}
results.add(AnalysisResult.builder()
.name(uniqueName)
.states(allStates)
.transitions(transitions)
.startStates(startStates)
.endStates(endStates)
.renderChoicesAsDiamonds(renderChoicesAsDiamonds)
.build());
}
return results;
}
return Collections.emptyList();
}
}

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;
@@ -20,11 +20,6 @@ import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import click.kamil.springstatemachineexporter.spi.LanguageAnalyzerPlugin;
import click.kamil.springstatemachineexporter.spi.SourceContext;
import click.kamil.springstatemachineexporter.analysis.service.CompositeIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.JdtCallGraphEngine;
import java.io.IOException;
import java.io.PrintWriter;
@@ -45,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();
@@ -95,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");
@@ -142,57 +147,8 @@ public class ExportService {
context.scan(allPaths, Collections.emptySet());
// Load SPI plugins dynamically
ServiceLoader<LanguageAnalyzerPlugin> loader = ServiceLoader.load(LanguageAnalyzerPlugin.class);
List<LanguageAnalyzerPlugin> plugins = new ArrayList<>();
for (LanguageAnalyzerPlugin plugin : loader) {
if (plugin.supports(inputDir)) {
plugins.add(plugin);
}
}
if (plugins.isEmpty()) {
plugins.add(new click.kamil.springstatemachineexporter.plugin.java.JavaLanguageAnalyzerPlugin());
}
List<SourceContext> sourceContexts = new ArrayList<>();
List<CodebaseIntelligenceProvider> providers = new ArrayList<>();
List<CallGraphEngine> engines = new ArrayList<>();
List<AnalysisResult> extractedResults = new ArrayList<>();
for (LanguageAnalyzerPlugin plugin : plugins) {
SourceContext sc = plugin.parseProject(projectRoot, context.getClasspath(), allPaths);
sourceContexts.add(sc);
providers.add(plugin.createIntelligenceProvider(sc));
engines.add(plugin.createCallGraphEngine(sc));
extractedResults.addAll(plugin.extractStateMachines(sc, renderChoicesAsDiamonds));
}
CodebaseContext codebaseContext = context;
CodebaseIntelligenceProvider unifiedIntelligence;
if (plugins.size() == 1) {
unifiedIntelligence = providers.get(0);
if (sourceContexts.get(0) instanceof CodebaseContext jdtCtx) {
codebaseContext = jdtCtx;
}
} else {
unifiedIntelligence = new CompositeIntelligenceProvider(providers, engines);
}
// Process all extracted state machines
for (AnalysisResult result : extractedResults) {
String name = result.getName();
if (machineFilter != null && !name.contains(machineFilter)) {
continue;
}
if (result.getFlows() == null || result.getFlows().isEmpty()) {
result.setFlows(flows);
}
enrichmentService.enrich(result, codebaseContext, unifiedIntelligence);
resolveProperties(result, activeProfiles);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
}
CodebaseIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, inputDir);
exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds, flows, machineFilter, activeProfiles != null ? activeProfiles : Collections.emptyList(), eventFormat, stateFormat);
}
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats) throws IOException {
@@ -204,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);
}
@@ -269,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);
@@ -295,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);
}
@@ -312,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);
@@ -331,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

@@ -1,43 +0,0 @@
package click.kamil.springstatemachineexporter.spi;
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
/**
* Service Provider Interface (SPI) for introducing support for different languages (Java, Kotlin, etc.).
*/
public interface LanguageAnalyzerPlugin {
/**
* Determines if this plugin is capable of handling the provided project root.
* Often checks for file extensions (.kt, .java) within the root.
*/
boolean supports(Path projectRoot);
/**
* Parses the codebase and sets up a SourceContext loaded with the language's AST structures.
*/
SourceContext parseProject(Path projectRoot, List<String> classpath, Set<Path> sourcePaths);
/**
* Creates a CallGraphEngine specialized for this language's syntax and paradigms.
*/
CallGraphEngine createCallGraphEngine(SourceContext context);
/**
* Creates a CodebaseIntelligenceProvider specialized for this language to discover
* Spring State Machine configurations, entry points, and transitions.
*/
CodebaseIntelligenceProvider createIntelligenceProvider(SourceContext context);
/**
* Finds and extracts all Spring State Machine models (configurations and beans) from the project.
*/
default List<click.kamil.springstatemachineexporter.analysis.model.AnalysisResult> extractStateMachines(SourceContext context, boolean renderChoicesAsDiamonds) {
return java.util.Collections.emptyList();
}
}

View File

@@ -1,43 +0,0 @@
package click.kamil.springstatemachineexporter.spi;
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
/**
* An abstraction over a parsed source codebase (Java, Kotlin, etc.).
* Provides access to common metadata required by analysis engines.
*/
public interface SourceContext {
/**
* Gets the root directory of the scanned project.
*/
Path getProjectRoot();
/**
* Relativizes an absolute path against the project root.
*/
String getRelativePath(Path fullPath);
/**
* Returns a relative file path for a given fully qualified class/type name.
*/
String getRelativePath(String fqn);
/**
* Retrieves any loaded library hints (e.g. from hints.json).
*/
List<LibraryHint> getLibraryHints();
/**
* Retrieves application properties (e.g. from application.yml).
*/
Map<String, Map<String, String>> getProperties();
/**
* Gets an unstructured cache map that plugins can use to share data across processing phases.
*/
Map<String, Object> getCache();
}

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,88 @@
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");
}
@Test
void shouldKeepDedicatedOrderEndpointOnStandardOrderMachineInMultiModuleCodebase() {
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();
CodebaseContext context = new CodebaseContext();
List<EntryPoint> scoped = MachineScopeFilter.filterEntryPointsForMachine(
List.of(orderPay),
"click.kamil.examples.statemachine.layered.order.config.StandardOrderStateMachineConfiguration",
context);
assertThat(scoped).extracting(EntryPoint::getName)
.containsExactly("POST /api/orders/pay");
}
@Test
void shouldKeepOrdersPathOnNonDomainNamedMachineConfig() {
EntryPoint ordersSubmit = EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/v2/orders/submit")
.className("click.kamil.examples.statemachine.inheritance.api.OrderControllerImpl")
.methodName("submitOrder")
.metadata(Map.of("path", "/api/v2/orders/submit"))
.build();
CodebaseContext context = new CodebaseContext();
List<EntryPoint> scoped = MachineScopeFilter.filterEntryPointsForMachine(
List.of(ordersSubmit),
"click.kamil.examples.statemachine.inheritance.config.InheritanceStateMachineConfig",
context);
assertThat(scoped).extracting(EntryPoint::getName)
.containsExactly("POST /api/v2/orders/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();
@@ -143,7 +194,38 @@ class TransitionLinkerEnricherTest {
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
}
@Test
void shouldNotLinkAllEnumTransitionsWhenConstantNamesCollide() {
Transition orderPay = new Transition();
orderPay.setSourceStates(List.of(State.of("NEW", "NEW")));
orderPay.setTargetStates(List.of(State.of("PAID", "PAID")));
orderPay.setEvent(Event.of("PAY", "com.example.OrderEvents.PAY"));
Transition invoicePay = new Transition();
invoicePay.setSourceStates(List.of(State.of("DRAFT", "DRAFT")));
invoicePay.setTargetStates(List.of(State.of("SENT", "SENT")));
invoicePay.setEvent(Event.of("PAY", "com.example.InvoiceEvents.PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("OrderEvents.PAY")
.eventTypeFqn("com.example.OrderEvents")
.build())
.contextMachineId("testMachine")
.build();
AnalysisResult result = AnalysisResult.builder()
.name("testMachine")
.transitions(List.of(orderPay, invoicePay))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
assertThat(updatedChain.getMatchedTransitions().get(0).getEvent()).isEqualTo("com.example.OrderEvents.PAY");
}
@Test
void shouldMatchBaseEventDespiteScrapedPolyEvents() {
@@ -512,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
@@ -196,6 +219,38 @@ class StrictFqnMatchingEngineTest {
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldNotMatchBareStrippedConstantToAnyEnumWithoutTypeContext() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("PAY")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldNotMatchSameEnumSimpleNameFromDifferentPackages() {
Event smEvent = Event.of("PAY", "com.example.other.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvents.PAY")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldNotMatchPolymorphicStrippedConstantWithoutTypeContext() {
Event smEvent = Event.of("PAY", "com.example.InvoiceEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("getType()")
.polymorphicEvents(List.of("PAY"))
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchDynamicMethodCallAsVariable() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");

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,10 +1,16 @@
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.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 java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@@ -127,4 +133,71 @@ 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");
}
@Test
void shouldAcceptTriggerWhenSpringErasureDiffersFromStringMachineTypes(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("InheritanceStateMachineConfig.java"), """
package com.example;
@org.springframework.statemachine.config.EnableStateMachine
public class InheritanceStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
Files.writeString(tempDir.resolve("ProcessingServiceImpl.java"), """
package com.example;
public class ProcessingServiceImpl {
public void doProcess() {}
}
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallChain triggerProbe = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.className("com.example.ProcessingServiceImpl")
.methodName("doProcess")
.event("INHERITED_SUBMIT")
.stateTypeFqn("java.lang.Object")
.eventTypeFqn("java.lang.Object")
.build())
.methodChain(List.of())
.build();
assertTrue(engine.isRoutedToCorrectMachine(
triggerProbe, "com.example.InheritanceStateMachineConfig", context));
}
}

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);
}
}

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