1 Commits

Author SHA1 Message Date
bb97285906 kotlin 2026-07-07 17:39:09 +02:00
309 changed files with 11401 additions and 26837 deletions

View File

@@ -8,46 +8,16 @@ 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"
```
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 |
*Supports `--profiles prod` to resolve Spring property placeholders.*
## Business Flows
Define sequence of events in `src/main/resources/flows.json`:
@@ -66,16 +36,3 @@ 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,9 +15,10 @@ 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,6 +32,9 @@ 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'
@@ -48,15 +51,10 @@ 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

@@ -1,168 +0,0 @@
#!/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

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

View File

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

View File

@@ -17,8 +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)
.build());

View File

@@ -1,152 +0,0 @@
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,8 +3,6 @@ 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;
@@ -16,11 +14,10 @@ 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 {
@@ -43,11 +40,6 @@ 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;
@@ -55,16 +47,17 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
String smEventForLink = canonicalEvent(t.getEvent());
String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName();
// Event matches
for (State smSourceState : t.getSourceStates()) {
String smSourceForLink = canonicalState(smSourceState);
String smSource = simplify(smSourceForLink);
String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
String smSource = simplify(smSourceRaw);
if (triggerSource == null || triggerSource.equals(smSource)) {
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceForLink)
.targetState(smSourceForLink)
.event(smEventForLink)
.sourceState(smSourceRaw)
.targetState(smSourceRaw)
.event(smEventRaw)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
@@ -72,11 +65,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
} else {
for (State smTargetState : t.getTargetStates()) {
String targetForLink = canonicalState(smTargetState);
String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName();
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceForLink)
.targetState(targetForLink)
.event(smEventForLink)
.sourceState(smSourceRaw)
.targetState(targetRaw)
.event(smEventRaw)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
@@ -97,14 +90,10 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
}
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
result.getMetadata().getTriggers(), result.getName(), context);
List<EntryPoint> scopedEntryPoints = MachineScopeFilter.filterEntryPointsForMachine(
result.getMetadata().getEntryPoints(), result.getName(), context);
// Update the metadata with the new call chains
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
.triggers(scopedTriggers)
.entryPoints(scopedEntryPoints)
.triggers(result.getMetadata().getTriggers())
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(updatedChains)
.properties(result.getMetadata().getProperties())
.build();
@@ -118,30 +107,91 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
private boolean isConstraintCompatible(String constraint, String machineName) {
if (constraint == null || machineName == null) {
return true;
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");
}
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
constraint, extractMachineDomainKey(machineName));
}
private String extractMachineDomainKey(String machineName) {
String simple = machineName.substring(machineName.lastIndexOf('.') + 1);
String upper = simple.toUpperCase();
private static class BooleanEvaluator {
public static boolean evaluate(String expr) {
expr = expr.replaceAll("\\s+", "");
return parseExpr(expr);
}
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) {
int idx = upper.indexOf(marker);
if (idx > 0) {
return upper.substring(0, idx);
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));
}
}
}
int stateIdx = upper.indexOf("STATE");
if (stateIdx > 0) {
return upper.substring(0, stateIdx);
}
if (s.startsWith("!")) {
return !parseExpr(s.substring(1));
}
return upper;
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;
return true;
}
}
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>();
@@ -165,20 +215,6 @@ 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

@@ -1,79 +0,0 @@
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) {
return;
}
StateMachineTypeResolver.MachineTypes machineTypes = resolveMachineTypes(result, context);
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) {
return;
}
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());
}
private static StateMachineTypeResolver.MachineTypes resolveMachineTypes(
AnalysisResult result, CodebaseContext context) {
StateMachineTypeResolver.MachineTypes resolved =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
if (resolved.stateTypeFqn() != null || resolved.eventTypeFqn() != null) {
return resolved;
}
if (result.getStateTypeFqn() != null || result.getEventTypeFqn() != null) {
return new StateMachineTypeResolver.MachineTypes(
result.getStateTypeFqn(), result.getEventTypeFqn());
}
return StateMachineTypeResolver.MachineTypes.empty();
}
}

View File

@@ -2,14 +2,11 @@ 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 {
@@ -17,15 +14,11 @@ public class TriggerEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with triggers", result.getName());
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);
List<TriggerPoint> triggers = intelligence.findTriggerPoints();
// Initially, we just add all triggers found in the codebase to the metadata.
// In later steps of Phase 2, we will filter them by State Machine ID.
result.addMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.triggers(triggers)
.build());

View File

@@ -12,51 +12,56 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
return false;
}
if (click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers
.isLifecycle(triggerPoint.getEvent())) {
return false;
if (triggerPoint.isExternal()) {
return true;
}
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 (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
continue;
}
String eventTypeFqn = triggerPoint.getEventTypeFqn();
if (pe.contains(".") && isStringTypeOrPrimitive(eventTypeFqn)) {
eventTypeFqn = null;
}
if (matchEventNames(pe, smEventRaw, eventTypeFqn)) {
if (matchEventNames(pe, smEventRaw, triggerPoint.getEventTypeFqn())) {
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 (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 (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;
}
@@ -80,13 +85,13 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
if (triggerEvent == null || smEvent == null) return false;
if (triggerEvent.equals(smEvent)) return true;
String tConst = constantName(triggerEvent);
String smConst = constantName(smEvent);
String tConst = triggerEvent.contains(".") ? triggerEvent.substring(triggerEvent.lastIndexOf('.') + 1) : triggerEvent;
String smConst = smEvent.contains(".") ? smEvent.substring(smEvent.lastIndexOf('.') + 1) : smEvent;
if (!tConst.equals(smConst)) return false;
// Prevent matching string/primitive triggers to enum transitions
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".")) {
return false;
}
@@ -95,46 +100,20 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
return false;
}
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 (eventTypeFqn != null && eventTypeFqn.contains(".") && smEvent.contains(".")) {
String fullTriggerFqn = eventTypeFqn + "." + tConst;
return fullTriggerFqn.equals(smEvent);
}
// Both sides are unqualified identifiers (string/primitive events)
return !triggerEvent.contains(".");
}
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);
}
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;
return true;
}
private boolean isStringTypeOrPrimitive(String typeFqn) {
@@ -155,13 +134,9 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
}
if (eventStr.contains(".")) {
String constantPart = eventStr.substring(eventStr.lastIndexOf('.') + 1);
if (!constantPart.isEmpty() && Character.isUpperCase(constantPart.charAt(0))) {
return false;
}
String firstPart = eventStr.substring(0, eventStr.indexOf('.'));
if (!firstPart.isEmpty() && Character.isLowerCase(firstPart.charAt(0))) {
return true;
return true;
}
return false;
}

View File

@@ -1,9 +1,7 @@
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;
@@ -17,20 +15,16 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn();
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn();
if (triggerEventFqn != null || triggerStateFqn != null) {
String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context);
String[] machineTypes = getStateMachineTypeArguments(currentMachineName, context);
String machineStateFqn = machineTypes[0];
String machineEventFqn = machineTypes[1];
if (triggerEventFqn != null && machineEventFqn != null) {
if (eraseGenerics(triggerEventFqn).equals(eraseGenerics(machineEventFqn))) {
return true;
return true; // Match!
}
if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
return false;
}
if (context != null && isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) {
return true;
return false; // Mismatch!
}
}
if (triggerStateFqn != null && machineStateFqn != null) {
@@ -40,10 +34,6 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
return false; // Mismatch!
}
if (context != null && isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) {
return true;
}
}
}
}
@@ -120,51 +110,15 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return false;
}
}
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();
return true;
}
private boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
return false;
}
if (smPackage.equals(chainPackage)) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
if (smPackage.startsWith(chainPackage) || chainPackage.startsWith(smPackage)) {
return true;
}
if (!chainPackage.startsWith(smPackage + ".") && !smPackage.startsWith(chainPackage + ".")) {
return false;
}
// Find deepest common package root
String[] p1 = smPackage.split("\\.");
@@ -307,6 +261,59 @@ 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('<');
@@ -316,6 +323,43 @@ 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);
@@ -326,16 +370,11 @@ 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) {
return countStateMachines(context) <= 1;
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;
}
}

View File

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

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

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

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

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

View File

@@ -1,47 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.index;
/**
* Shared JavaBean accessor naming helpers used by analysis and dataflow code.
*/
public final class AccessorNaming {
private AccessorNaming() {
}
public static boolean isBeanStyleAccessorName(String accessorName) {
return (accessorName.startsWith("get") && accessorName.length() > 3)
|| (accessorName.startsWith("is") && accessorName.length() > 2)
|| (accessorName.startsWith("set") && accessorName.length() > 3);
}
public static String propertyNameFromAccessor(String accessorName) {
if (accessorName.startsWith("get") && accessorName.length() > 3) {
return TrivialAccessorDetector.decapitalize(accessorName.substring(3));
}
if (accessorName.startsWith("is") && accessorName.length() > 2) {
return TrivialAccessorDetector.decapitalize(accessorName.substring(2));
}
if (accessorName.startsWith("set") && accessorName.length() > 3) {
return TrivialAccessorDetector.decapitalize(accessorName.substring(3));
}
return accessorName.startsWith("get") ? accessorName.substring(3) : accessorName;
}
public static String setterMethodNameForGetter(String getterName) {
return "set" + capitalizeProperty(propertyNameFromAccessor(getterName));
}
public static String capitalizeProperty(String propertyName) {
if (propertyName == null || propertyName.isEmpty()) {
return propertyName;
}
if (propertyName.length() > 1 && Character.isUpperCase(propertyName.charAt(1))) {
return propertyName;
}
return Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);
}
public static boolean looksLikeIndexedAccessorName(String methodName) {
return methodName.startsWith("get") || methodName.startsWith("is") || methodName.startsWith("set");
}
}

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -1,305 +0,0 @@
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,11 +3,6 @@ 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.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.model.Transition;
import lombok.Builder;
import lombok.Getter;
@@ -36,11 +31,6 @@ 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();
@@ -62,9 +52,7 @@ 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(
resolver.resolveValue(s.rawName(), properties),
resolver.resolveValue(s.fullIdentifier(), properties)))
.map(s -> click.kamil.springstatemachineexporter.model.State.of(s.rawName(), resolver.resolveValue(s.fullIdentifier(), properties)))
.collect(java.util.stream.Collectors.toSet());
}
@@ -84,18 +72,15 @@ public class AnalysisResult {
}
}
// 4. Resolve metadata (triggers, entry points, call chains)
// 4. Resolve metadata (triggers, entry points)
if (metadata != null) {
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.getTriggers() != null) {
for (var trigger : metadata.getTriggers()) {
if (trigger.getEvent() != null) {
trigger.setEvent(resolver.resolveValue(trigger.getEvent(), properties));
}
}
}
if (metadata.getEntryPoints() != null) {
for (var ep : metadata.getEntryPoints()) {
if (ep.getName() != null) {
@@ -110,63 +95,7 @@ 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();
}
canonicalizeResolvedIdentifiers();
}
private void canonicalizeResolvedIdentifiers() {
if (stateTypeFqn == null && eventTypeFqn == null) {
return;
}
click.kamil.springstatemachineexporter.analysis.service.AnalysisResultFinalizer.applyCanonicalization(
this,
new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn));
}
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) {

View File

@@ -1,36 +0,0 @@
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,6 +1,5 @@
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;
@@ -50,14 +49,14 @@ public class TriggerPoint {
this.sourceFile = sourceFile;
this.sourceModule = sourceModule;
this.stateMachineId = stateMachineId;
this.sourceState = MachineEnumCanonicalizer.canonicalizeLabel(sourceState, stateTypeFqn);
this.sourceState = sourceState;
this.lineNumber = lineNumber;
this.stateTypeFqn = stateTypeFqn;
this.eventTypeFqn = eventTypeFqn;
this.event = MachineEnumCanonicalizer.qualifyEventIdentifier(event, eventTypeFqn);
this.event = qualifyEvent(event, eventTypeFqn);
if (polymorphicEvents != null) {
this.polymorphicEvents = polymorphicEvents.stream()
.map(pe -> MachineEnumCanonicalizer.qualifyEventIdentifier(pe, eventTypeFqn))
.map(pe -> qualifyEvent(pe, eventTypeFqn))
.collect(java.util.stream.Collectors.toList());
} else {
this.polymorphicEvents = null;
@@ -66,4 +65,66 @@ 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

@@ -1,485 +0,0 @@
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.pipeline.GetterBodyConstantScanner;
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) {
return GetterBodyConstantScanner.extractConstantGetterReturn(
context, constantExtractor, constantResolver, ownerFqn, methodName);
}
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

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

View File

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

@@ -1,77 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.function.BiConsumer;
/**
* Scans trivial getter bodies for constant return values.
*/
public final class GetterBodyConstantScanner {
private GetterBodyConstantScanner() {
}
public static List<String> extractConstantGetterReturn(
CodebaseContext context,
ConstantExtractor constantExtractor,
ConstantResolver constantResolver,
String ownerFqn,
String methodName) {
return extractConstantGetterReturn(context, constantExtractor, constantResolver, ownerFqn, methodName, null, null);
}
public static List<String> extractConstantGetterReturn(
CodebaseContext context,
ConstantExtractor constantExtractor,
ConstantResolver constantResolver,
String ownerFqn,
String methodName,
CompilationUnit contextCu,
Set<String> ignoredVisited) {
TypeDeclaration typeDeclaration = contextCu != null
? context.getTypeDeclaration(ownerFqn, contextCu)
: null;
if (typeDeclaration == null) {
typeDeclaration = context.getTypeDeclaration(ownerFqn);
}
if (typeDeclaration == null) {
return List.of();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> extractor =
constantExtractor != null
? constantExtractor::extractConstantsFromExpression
: (expr, out) -> {
String resolved = constantResolver.resolve(expr, context);
if (resolved != null) {
out.add(resolved);
}
};
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
extractor.accept(node.getExpression(), constants);
}
return false;
}
});
return constants;
}
}

View File

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

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

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

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

@@ -1,190 +0,0 @@
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,17 +1,14 @@
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 {
@@ -20,40 +17,6 @@ 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<>());
}
@@ -72,25 +35,6 @@ 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);
@@ -132,10 +76,6 @@ 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) {
@@ -144,26 +84,7 @@ public class ConstantResolver {
if (expr instanceof MethodInvocation mi) {
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()) {
if ((mi.getName().getIdentifier().equals("name") || mi.getName().getIdentifier().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
@@ -750,50 +671,6 @@ 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;
@@ -844,19 +721,7 @@ 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;
}
}
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;
}
}
if (result != null) return result;
}
// Fallback: Static imports
@@ -877,9 +742,6 @@ 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);
@@ -889,81 +751,6 @@ 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)) {
@@ -1073,38 +860,27 @@ public class ConstantResolver {
if (ann instanceof SingleMemberAnnotation sma) {
valueExpr = sma.getValue();
} else if (ann instanceof NormalAnnotation na) {
String raw = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(ann, "value");
if (!raw.isEmpty()) {
if (raw.startsWith("\"") && raw.endsWith("\"")) {
raw = raw.substring(1, raw.length() - 1);
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if ("value".equals(pair.getName().getIdentifier())) {
valueExpr = pair.getValue();
break;
}
if (raw.contains("${")) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
raw, java.util.Collections.emptyMap());
}
return raw;
}
return null;
}
if (valueExpr instanceof StringLiteral sl) {
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 sl.getLiteralValue();
}
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;
return valueExpr != null ? valueExpr.toString().replaceAll("^\"|\"$", "") : null;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
ASTNode current = node;
while (current != null && !(current instanceof TypeDeclaration)) {
current = current.getParent();
}
return (TypeDeclaration) current;
}
private MethodDeclaration findInvokedMethod(org.eclipse.jdt.core.dom.MethodInvocation mi, CodebaseContext context) {
@@ -1126,7 +902,14 @@ public class ConstantResolver {
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
ASTNode parent = node.getParent();
while (parent != null) {
if (parent instanceof MethodDeclaration md) {
return md;
}
parent = parent.getParent();
}
return null;
}
private String resolveLocalType(SimpleName sn) {

View File

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

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

@@ -1,353 +0,0 @@
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.LinkedHashMap;
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;
}
LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>();
for (State state : states) {
State canonical = canonicalizeState(state, stateTypeFqn);
byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical);
}
return new LinkedHashSet<>(byFullIdentifier.values());
}
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;
}
LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>();
for (State state : states) {
State canonical = canonicalizeState(state, stateTypeFqn);
byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical);
}
return new ArrayList<>(byFullIdentifier.values());
}
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 full = resolvePlaceholder(
state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName());
String canonical = canonicalizeLabel(full, enumTypeFqn);
String raw;
if (isStringOrPrimitiveType(enumTypeFqn) && canonical.startsWith(enumTypeFqn + ".")) {
String constant = canonical.substring(enumTypeFqn.length() + 1);
raw = "\"" + constant + "\"";
} else {
String fnRaw = toFnForm(canonical, enumTypeFqn);
raw = fnRaw != null ? fnRaw : resolvePlaceholder(state.rawName());
}
if (canonical.equals(state.fullIdentifier()) && raw.equals(state.rawName())) {
return state;
}
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

@@ -1,259 +0,0 @@
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,41 +3,224 @@ 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.LinkedHashSet;
import java.util.Set;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Discovers related local modules by statically parsing Maven/Gradle build files (never executes build tools).
*/
@Slf4j
public class SiblingDependencyResolver {
private final StaticBuildFileAnalyzer analyzer = new StaticBuildFileAnalyzer();
public ProjectModuleGraph analyzeProject(Path inputDir) throws IOException {
return analyzer.analyze(inputDir);
}
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);
public Set<Path> resolveSiblings(Path projectDir) {
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();
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);
}
}
}
return allSiblings;
}
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);
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);
}
}
}
}
}
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

@@ -1,210 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.service.TypeResolver;
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) {
return new TypeResolver(context).resolveTypeToFqn(type, cu, typeVarBindings);
}
}

View File

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

View File

@@ -1,154 +0,0 @@
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());
}
/**
* Canonicalizes transitions, states, triggers, and matched transitions without validation.
* Safe to call before transition relinking when machine types are known.
*/
public static void applyCanonicalization(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (result == null || machineTypes == null) {
return;
}
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) {
return;
}
applyCanonicalizationFields(result, machineTypes);
}
private static void applyCanonicalizationFields(
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

@@ -8,4 +8,8 @@ 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,12 +18,6 @@ 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)) {
@@ -62,21 +56,6 @@ 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);
@@ -196,121 +175,6 @@ 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);
@@ -366,10 +230,19 @@ public class CallGraphPathFinder {
if (simpleClassNeighbor.equals(simpleClassTarget)) return true;
if (context.areClassesPolymorphicallyCompatible(classNeighbor, classTarget)) {
return true;
List<String> impls = context.getImplementations(classTarget);
if (impls != null) {
for (String impl : impls) {
if (impl.equals(classNeighbor) || impl.endsWith("." + classNeighbor)) return true;
}
}
List<String> implsN = context.getImplementations(classNeighbor);
if (implsN != null) {
for (String impl : implsN) {
if (impl.equals(classTarget) || impl.endsWith("." + classTarget)) return true;
}
}
return false;
}

View File

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

@@ -0,0 +1,53 @@
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,11 +1,5 @@
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.GetterBodyConstantScanner;
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;
@@ -20,8 +14,6 @@ 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;
@@ -37,24 +29,25 @@ 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) {
addResolvedConstant(resolved, 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 {
constants.add(resolved);
}
return;
}
}
if (expr instanceof StringLiteral sl) {
if (expr instanceof QualifiedName qn) {
constants.add(qn.toString());
} else if (expr instanceof StringLiteral sl) {
constants.add(sl.getLiteralValue());
} else if (expr instanceof ConditionalExpression ce) {
extractConstantsFromExpression(ce.getThenExpression(), constants);
@@ -96,20 +89,22 @@ public class ConstantExtractor {
} else if (expr instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier();
if (log.isTraceEnabled()) {
log.trace("Processing mi: {}", mi);
}
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;
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 (LibraryUnwrapRegistry.isUnwrapMethodName(methodName)) {
if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") ||
methodName.equals("just") || methodName.equals("withPayload") || methodName.equals("success")) {
for (Object argObj : mi.arguments()) {
extractConstantsFromExpression((Expression) argObj, constants);
}
@@ -220,9 +215,9 @@ public class ConstantExtractor {
Expression currentMi = mi;
while (currentMi instanceof MethodInvocation chainMi) {
String chainName = chainMi.getName().getIdentifier();
if (LibraryUnwrapRegistry.isHeaderSetterMethodName(chainName) && chainMi.arguments().size() == 2) {
if (chainName.equals("setHeader") && chainMi.arguments().size() == 2) {
extractConstantsFromExpression((Expression) chainMi.arguments().get(1), constants);
} else if (LibraryUnwrapRegistry.isEventSetterMethodName(chainName) && chainMi.arguments().size() == 1) {
} else if ((chainName.equalsIgnoreCase("event") || chainName.equalsIgnoreCase("type") || chainName.equalsIgnoreCase("eventType")) && chainMi.arguments().size() == 1) {
extractConstantsFromExpression((Expression) chainMi.arguments().get(0), constants);
}
currentMi = chainMi.getExpression();
@@ -287,98 +282,24 @@ public class ConstantExtractor {
}
public List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) {
return resolveMethodReturnConstant(className, methodName, depth, visited, contextCu, ResolutionBudget.defaults());
}
public List<String> resolveMethodReturnConstant(
String className,
String methodName,
int depth,
Set<String> visited,
CompilationUnit contextCu,
ResolutionBudget budget) {
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);
}
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 Collections.emptyList();
}
String fqn = className + "." + methodName;
if (!visited.add(fqn)) {
if (debug) {
log.debug("Exiting resolveMethodReturnConstant early (cycle detected) for fqn={}", fqn);
}
log.debug("Exiting resolveMethodReturnConstant early (cycle detected) for fqn={}", fqn);
return Collections.emptyList();
}
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(className, methodName, activeBudget);
List<String> constants = new ArrayList<>();
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) {
@@ -401,8 +322,7 @@ 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, activeBudget, allowWidenToImplementations);
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
handled = true;
}
@@ -421,8 +341,7 @@ 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, activeBudget, allowWidenToImplementations);
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
handled = true;
}
@@ -463,183 +382,26 @@ public class ConstantExtractor {
return super.visit(node);
}
});
}
if (widenToImplementations) {
} else {
List<String> impls = context.getImplementations(className);
if (impls != null) {
for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant(
implName, methodName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
for (String value : delegationResult) {
if (!constants.contains(value)) {
constants.add(value);
}
}
}
}
}
} else if (widenToImplementations) {
List<String> impls = context.getImplementations(className);
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);
}
List<String> delegationResult = resolveMethodReturnConstant(implName, methodName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
}
}
}
}
visited.remove(fqn);
if (debug) {
log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants);
}
log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants);
log.debug("resolveMethodReturnConstant for class={}, method={} resolved constants: {}", className, methodName, constants);
return constants;
}
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();
}
return context.getAccessorIndex().lookup(className, methodName);
}
private List<String> extractConstantGetterReturn(
String ownerFqn,
String methodName,
CompilationUnit contextCu,
Set<String> visited) {
return GetterBodyConstantScanner.extractConstantGetterReturn(
context, this, constantResolver, ownerFqn, methodName, contextCu, visited);
}
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)) {
@@ -649,6 +411,10 @@ public class ConstantExtractor {
}
private TypeDeclaration findEnclosingType(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
}

View File

@@ -1,7 +1,6 @@
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.*;
@@ -14,8 +13,6 @@ 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 {
@@ -35,21 +32,7 @@ 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();
@@ -79,8 +62,7 @@ public class ConstructorAnalyzer {
targetClass = context.getFqn(targetTd);
}
}
results.addAll(constantExtractor.resolveMethodReturnConstant(
targetClass, calledName, 0, visited, cu, budget));
results.addAll(constantExtractor.resolveMethodReturnConstant(targetClass, calledName, 0, visited, cu));
} else {
String val = constantResolver.resolve(tracedRight, context);
if (val != null) results.add(val);
@@ -124,8 +106,7 @@ public class ConstructorAnalyzer {
}
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
results.addAll(constantExtractor.resolveMethodReturnConstant(
targetClass, calledName, 0, visited, cu, budget));
results.addAll(constantExtractor.resolveMethodReturnConstant(targetClass, calledName, 0, visited, cu));
} else {
String val = constantResolver.resolve(tracedRight, context);
if (val != null) results.add(val);
@@ -173,37 +154,35 @@ 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, budget.child()));
results.addAll(traceFieldInConstructors(superTd, fieldName, context, visited));
}
}
}
if (results.isEmpty()) {
// 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()));
}
// 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));
}
}
}
}
if (results.isEmpty() && budget != null && budget.allowGlobalFieldScan()) {
// Global Fallback: Search all parsed classes in the workspace (explicit opt-in only)
if (results.isEmpty()) {
// Global Fallback: Search all parsed classes in the workspace
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, budget.child()));
results.addAll(traceFieldInConstructors(typeDecl, fieldName, context, visited));
matchedCount++;
if (matchedCount >= budget.globalFieldScanLimit()) {
if (matchedCount >= 10) {
break;
}
}
@@ -215,13 +194,6 @@ 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,
@@ -487,26 +459,13 @@ public class ConstructorAnalyzer {
public ClassInstanceCreation findReturnedCIC(
String className, String methodName, CodebaseContext context) {
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;
return findReturnedCIC(className, methodName, context, new HashSet<>(), 0);
}
private ClassInstanceCreation findReturnedCIC(
String className, String methodName, CodebaseContext context,
Set<String> visited, int depth) {
if (ResolutionBudget.defaults().isReturnedCicExhausted(depth) || !visited.add(className + "#" + methodName)) {
if (depth > 50 || !visited.add(className + "#" + methodName)) {
return null;
}
@@ -595,15 +554,11 @@ public class ConstructorAnalyzer {
try {
Map<String, String> fieldValues = buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor);
if (log.isDebugEnabled()) {
log.debug("RESOLVE_GETTER: td={}, fieldValues={}", context.getFqn(td), fieldValues);
}
log.debug("RESOLVE_GETTER: td={}, fieldValues={}", context.getFqn(td), fieldValues);
if (fieldValues.isEmpty()) return Collections.emptyList();
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, visited);
if (log.isDebugEnabled()) {
log.debug("RESOLVE_GETTER: result={}", result);
}
log.debug("RESOLVE_GETTER: result={}", result);
return result != null ? List.of(result) : Collections.emptyList();
} finally {
visited.remove(getterKey);
@@ -821,10 +776,18 @@ public class ConstructorAnalyzer {
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
}

View File

@@ -2,21 +2,172 @@ 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.debug("Skipping external JAR classpath: analysis uses scanned source files only.");
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);
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,77 +1,17 @@
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> 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()));
}
private final List<AnalysisEnricher> enrichers;
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

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

@@ -1,297 +0,0 @@
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,44 +2,22 @@ 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;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
@Slf4j
@RequiredArgsConstructor
public class GenericEventDetector {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private final List<LibraryHint> hints;
private final TypeResolver typeResolver;
public GenericEventDetector(CodebaseContext context, ConstantResolver constantResolver, List<LibraryHint> hints) {
this.context = context;
this.constantResolver = constantResolver;
this.hints = hints;
this.typeResolver = new TypeResolver(context);
}
/** Parameter names that route to a machine, not SM state enum constants. */
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<>();
@@ -55,13 +33,15 @@ public class GenericEventDetector {
public boolean visit(MethodInvocation node) {
String methodName = node.getName().getIdentifier();
if (TRIGGER_METHOD_NAMES.contains(methodName)) {
if ("sendEvent".equals(methodName) || "sendEvents".equals(methodName) ||
"sendEventCollect".equals(methodName) || "sendEventMono".equals(methodName)) {
processSendEvent(node, cu, triggers);
} else {
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String refMethod = emr.getName().getIdentifier();
if (TRIGGER_METHOD_NAMES.contains(refMethod)) {
if ("sendEvent".equals(refMethod) || "sendEvents".equals(refMethod) ||
"sendEventCollect".equals(refMethod) || "sendEventMono".equals(refMethod)) {
processSendEventMethodReference(node, emr, cu, triggers);
}
}
@@ -83,8 +63,8 @@ public class GenericEventDetector {
eventValue = receiver != null ? receiver.toString() : null;
}
MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration type = findEnclosingType(node);
if (type == null) return;
String sourceState = extractSourceState(node);
@@ -114,10 +94,6 @@ 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();
@@ -155,8 +131,8 @@ public class GenericEventDetector {
List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
CompilationUnit compilationUnit = (CompilationUnit) node.getRoot();
String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit);
String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit);
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit);
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit);
return new String[]{stateType, eventType};
}
}
@@ -168,9 +144,7 @@ public class GenericEventDetector {
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, null);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
if (log.isDebugEnabled()) {
log.debug("Successfully built trigger point: {}", trigger.getEvent());
}
log.debug("Successfully built trigger point: {}", trigger.getEvent());
triggers.add(trigger);
}
}
@@ -198,9 +172,7 @@ public class GenericEventDetector {
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
if (log.isDebugEnabled()) {
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
}
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
triggers.add(trigger);
}
}
@@ -219,11 +191,18 @@ public class GenericEventDetector {
if (receiver instanceof SimpleName sn) {
String name = sn.getIdentifier();
AbstractTypeDeclaration enclosing = findEnclosingAbstractType(node);
if (enclosing != null) {
Type memberType = lookupMemberType(enclosing, name);
if (memberType != null) {
return memberType + "." + methodName;
// 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;
}
}
}
}
}
return name + "." + methodName;
@@ -248,15 +227,10 @@ public class GenericEventDetector {
} else {
eventValue = context.resolveExpression(eventExpr);
}
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetter(
eventExpr, constantResolver, context);
if (remapped != null) {
eventValue = remapped;
}
}
MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration type = findEnclosingType(node);
if (type == null) return Collections.emptyList();
@@ -329,10 +303,8 @@ public class GenericEventDetector {
String state = extractStateFromSiblings(current, block.statements());
if (state != null) return state;
} else if (parent instanceof SwitchStatement switchStmt) {
if (!isRoutingParameter(switchStmt.getExpression())) {
String state = extractStateFromSiblings(current, switchStmt.statements());
if (state != null) return state;
}
String state = extractStateFromSiblings(current, switchStmt.statements());
if (state != null) return state;
}
current = parent;
@@ -389,10 +361,6 @@ public class GenericEventDetector {
if (left instanceof NullLiteral || right instanceof NullLiteral) {
return null;
}
if (isRoutingParameter(left) || isRoutingParameter(right)) {
return null;
}
// Usually one is a method call like getState() or a variable like `state`
// and the other is the constant enum like `OrderState.PENDING` or `"PENDING"`
@@ -413,10 +381,6 @@ public class GenericEventDetector {
if (("equals".equals(methodName) || "equalsIgnoreCase".equals(methodName)) && !mi.arguments().isEmpty()) {
Expression receiver = mi.getExpression();
Expression arg = (Expression) mi.arguments().get(0);
if (isRoutingParameter(receiver) || isRoutingParameter(arg)) {
return null;
}
// If receiver is null (e.g., implicit this), fall back to arg
if (receiver == null) {
@@ -438,13 +402,6 @@ 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();
@@ -463,7 +420,7 @@ public class GenericEventDetector {
if (expr instanceof SimpleName sn) {
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(expr);
MethodDeclaration enclosingMethod = findEnclosingMethod(expr);
if (enclosingMethod != null) {
// Find variable declaration
final Expression[] initializer = new Expression[1];
@@ -535,7 +492,7 @@ public class GenericEventDetector {
return payloadExpr.toString();
} else if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) {
// If the event is obtained by calling a method on a provider/supplier parameter,
// return the provider's name so the call-graph engine can trace the lambda argument
// return the provider's name so CallGraphBuilder can trace the lambda argument
String traced = extractEventFromMessageBuilder(sn);
return traced != null ? traced : sn.getIdentifier();
}
@@ -550,45 +507,20 @@ public class GenericEventDetector {
return null;
}
private AbstractTypeDeclaration findEnclosingAbstractType(ASTNode node) {
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof AbstractTypeDeclaration)) {
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (AbstractTypeDeclaration) parent;
return (MethodDeclaration) parent;
}
private List<FieldDeclaration> getFieldDeclarations(AbstractTypeDeclaration type) {
if (type instanceof TypeDeclaration td) {
return List.of(td.getFields());
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
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;
return (TypeDeclaration) parent;
}
private String[] resolveStateMachineTypeArguments(MethodInvocation node) {
@@ -627,8 +559,8 @@ public class GenericEventDetector {
List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
CompilationUnit cu = (CompilationUnit) node.getRoot();
String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), cu);
String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), cu);
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), cu);
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), cu);
if (stateType != null && stateType.length() == 1) {
stateType = resolveGenericTypeVariable(stateType, node, cu);
@@ -644,7 +576,7 @@ public class GenericEventDetector {
}
private String resolveGenericTypeVariable(String typeVarName, ASTNode node, CompilationUnit cu) {
AbstractTypeDeclaration enclosingClass = findEnclosingAbstractType(node);
TypeDeclaration enclosingClass = findEnclosingType(node);
if (enclosingClass == null) return typeVarName;
String className = context.getFqn(enclosingClass);
if (className == null) return typeVarName;
@@ -658,14 +590,10 @@ public class GenericEventDetector {
Type superclassType = td.getSuperclassType();
if (superclassType instanceof ParameterizedType pt) {
Type baseType = pt.getType();
String baseName = typeResolver.resolveTypeToFqn(baseType, (CompilationUnit) implNode.getRoot());
String baseName = resolveTypeToFqn(baseType, (CompilationUnit) implNode.getRoot());
if (className.equals(baseName) || className.endsWith("." + baseName)) {
int typeIndex = -1;
List<?> typeParams = enclosingClass instanceof TypeDeclaration typeDecl
? typeDecl.typeParameters()
: enclosingClass instanceof RecordDeclaration recordDecl
? recordDecl.typeParameters()
: List.of();
List<?> typeParams = enclosingClass.typeParameters();
for (int i = 0; i < typeParams.size(); i++) {
TypeParameter tp = (TypeParameter) typeParams.get(i);
if (tp.getName().getIdentifier().equals(typeVarName)) {
@@ -675,7 +603,7 @@ public class GenericEventDetector {
}
if (typeIndex >= 0 && typeIndex < pt.typeArguments().size()) {
Type concreteType = (Type) pt.typeArguments().get(typeIndex);
return typeResolver.resolveTypeToFqn(concreteType, (CompilationUnit) implNode.getRoot());
return resolveTypeToFqn(concreteType, (CompilationUnit) implNode.getRoot());
}
}
}
@@ -707,7 +635,7 @@ public class GenericEventDetector {
if (!(receiver instanceof SimpleName sn)) return null;
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
MethodDeclaration enclosingMethod = findEnclosingMethod(node);
if (enclosingMethod != null) {
// Check params
for (Object paramObj : enclosingMethod.parameters()) {
@@ -737,18 +665,58 @@ public class GenericEventDetector {
}
}
// Check fields and record components
AbstractTypeDeclaration enclosingType = findEnclosingAbstractType(node);
// Check fields
TypeDeclaration enclosingType = findEnclosingType(node);
if (enclosingType != null) {
Type memberType = lookupMemberType(enclosingType, varName);
if (memberType != null) {
return memberType;
for (FieldDeclaration fd : enclosingType.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(varName)) {
return fd.getType();
}
}
}
}
}
return null;
}
private String resolveTypeToFqn(Type type, CompilationUnit cu) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), cu);
} else {
simpleName = type.toString();
}
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
// Check package name
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) return context.getFqn(localTd);
}
return simpleName;
}
private static class AssignmentDag {
final java.util.Map<String, List<Expression>> assignments = new java.util.HashMap<>();
final java.util.Set<SingleVariableDeclaration> parameters = new java.util.HashSet<>();

View File

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

View File

@@ -1,13 +1,23 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
@Slf4j
public class JdtCallGraphEngine extends AbstractCallGraphEngine {
private final InjectionPointAnalyzer injectionAnalyzer;
private String currentMethodFqn;
private Map<String, List<CallEdge>> graph;
public JdtCallGraphEngine(CodebaseContext context, InjectionPointAnalyzer injectionAnalyzer) {
super(context);
@@ -15,28 +25,148 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
}
@Override
protected String callGraphCacheKey() {
return "jdtCallGraph";
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");
if (cached != null) {
this.graph = cached;
return cached;
}
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
for (String calledMethod : calledMethods) {
CallEdge edge = new CallEdge(calledMethod, args);
edge.setConstraint(resolveConstraint(node, calledMethod, constraint));
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration td2 = findEnclosingType(node);
if (td2 != null) {
String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) {
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
CallEdge edge = new CallEdge(refMethod, implicitArgs);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
CallEdge edge = new CallEdge(calledMethod, implicitArgs);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args);
implEdge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(implEdge);
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
CallEdge edge = new CallEdge(calledMethod, args);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
}
}
return super.visit(node);
}
});
}
context.getCache().put("jdtCallGraph", graph);
return graph;
}
@Override
protected String resolveArgument(Expression expr) {
protected String resolveArgument(org.eclipse.jdt.core.dom.Expression expr) {
if (expr == null) {
return "null";
}
Expression unwrappedBuilder = unwrapMessageBuilder(expr);
// Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...))
org.eclipse.jdt.core.dom.Expression unwrappedBuilder = unwrapMessageBuilder(expr);
if (unwrappedBuilder != expr) {
expr = unwrappedBuilder;
}
Expression tracedExpr = traceVariable(expr);
if (tracedExpr instanceof QualifiedName
|| tracedExpr instanceof ClassInstanceCreation
|| tracedExpr instanceof StringLiteral
|| tracedExpr instanceof NumberLiteral) {
expr = tracedExpr;
// Trace variable to resolve local variable references
org.eclipse.jdt.core.dom.Expression tracedExpr = traceVariable(expr);
if (tracedExpr instanceof org.eclipse.jdt.core.dom.QualifiedName
|| tracedExpr instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation
|| tracedExpr instanceof org.eclipse.jdt.core.dom.StringLiteral
|| tracedExpr instanceof org.eclipse.jdt.core.dom.NumberLiteral) {
expr = tracedExpr; // Only accept trace if it resolves to a pure constant/primitive/constructor
}
// Delegate to base class for lambda, method reference, and constructor unwrapping
String result = super.resolveArgument(expr);
if (result != null) {
result = result.replaceAll("->\\s*yield\\s+", "-> ");
@@ -44,7 +174,6 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
return result;
}
@Override
protected String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
@@ -72,19 +201,15 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
nameBinding = fa.resolveFieldBinding();
}
if (nameBinding instanceof IVariableBinding varBinding) {
if (log.isDebugEnabled()) {
log.debug("CALLGRAPH RESOLVING BEAN FOR BINDING: {} calling {}", varBinding.getName(), methodName);
}
log.debug("CALLGRAPH RESOLVING BEAN FOR BINDING: {} calling {}", varBinding.getName(), methodName);
String concreteFqn = injectionAnalyzer.resolveInjectedBeanFqn(varBinding);
if (concreteFqn != null) {
if (log.isDebugEnabled()) {
log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn);
}
log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn);
return concreteFqn + "." + methodName;
} else if (log.isDebugEnabled()) {
} else {
log.debug(" -> RESOLVE FAILED, RETURNED NULL");
}
} else if (log.isDebugEnabled()) {
} else {
log.debug("CALLGRAPH NAME BINDING IS NOT VARIABLE: {} calling {}", nameBinding, methodName);
}
}
@@ -99,7 +224,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
typeName = binding.getErasure().getName();
}
}
} catch (Exception ignored) {
} catch (Exception e) {
// Ignore JDT internal exceptions
}
@@ -114,7 +239,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
}
if (typeName != null && !typeName.isEmpty()) {
TypeDeclaration resolvedTd = context.getTypeDeclaration(typeName);
org.eclipse.jdt.core.dom.TypeDeclaration resolvedTd = context.getTypeDeclaration(typeName);
if (resolvedTd != null && context.findMethodDeclaration(resolvedTd, methodName, true) != null) {
return typeName + "." + methodName;
}
@@ -133,12 +258,95 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
return sn.getIdentifier() + "." + 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 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;
@@ -157,4 +365,55 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
}
return expr;
}
protected String resolveMethodInvocationReturnType(MethodInvocation mi) {
Expression receiver = mi.getExpression();
String receiverType = null;
if (receiver == null) {
TypeDeclaration td = findEnclosingType(mi);
if (td != null) {
receiverType = context.getFqn(td);
}
} else if (receiver instanceof SimpleName sn) {
receiverType = resolveReceiverTypeFallback(sn);
} else if (receiver instanceof FieldAccess fa) {
receiverType = resolveReceiverTypeFallback(fa.getName());
} else if (receiver instanceof MethodInvocation innerMi) {
receiverType = resolveMethodInvocationReturnType(innerMi);
}
if (receiverType == null) {
ITypeBinding binding = receiver != null ? receiver.resolveTypeBinding() : null;
if (binding != null) {
receiverType = binding.getErasure().getQualifiedName();
}
}
if (receiverType != null) {
String rawType = receiverType;
if (rawType.contains("<")) {
rawType = rawType.substring(0, rawType.indexOf('<'));
}
if (rawType.equals("java.util.Map") || rawType.equals("Map")) {
String valType = resolveMapValueType(mi);
if (valType != null) return valType;
}
if (receiverType.contains("<")) {
receiverType = receiverType.substring(0, receiverType.indexOf('<'));
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration td = context.getTypeDeclaration(receiverType);
if (td == null && receiverType.contains(".")) {
String simpleClass = receiverType.substring(receiverType.lastIndexOf('.') + 1);
td = context.getTypeDeclaration(simpleClass);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getReturnType2() != null) {
return resolveTypeToFqn(md.getReturnType2(), mi);
}
}
}
return null;
}
}

View File

@@ -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;
private final ConstantResolver constantResolver = new ConstantResolver();
private final GenericEventDetector eventDetector;
private final SpringMvcDetector mvcDetector;
private final MessagingDetector messagingDetector;
@@ -35,7 +35,6 @@ 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);
@@ -93,15 +92,8 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
@Override
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> 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);
log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers", entryPoints.size(), triggers.size());
return callGraphEngine.findChains(entryPoints, triggers);
}
@Override

View File

@@ -1,77 +0,0 @@
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;
Path settingsRoot = null;
Path buildRoot = null;
while (current != null) {
if (settingsRoot == null && Files.exists(current.resolve("settings.gradle"))) {
settingsRoot = current;
}
if (buildRoot == null && (Files.exists(current.resolve("pom.xml"))
|| Files.exists(current.resolve("build.gradle")))) {
buildRoot = current;
}
current = current.getParent();
}
return settingsRoot != null ? settingsRoot : buildRoot;
}
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

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

View File

@@ -40,12 +40,6 @@ 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");
@@ -61,9 +55,6 @@ 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

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

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

View File

@@ -23,7 +23,7 @@ public class SpringMvcDetector {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
if (isRestController(node) && !node.isInterface()) {
if (isRestController(node)) {
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") || "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("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("RequestMapping")) return "REQUEST";
return null;
}

View File

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

@@ -4,93 +4,14 @@ import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import org.eclipse.jdt.core.dom.WildcardType;
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 String resolveTypeToFqn(Type type, ASTNode contextNode) {
CompilationUnit cu = contextNode != null && contextNode.getRoot() instanceof CompilationUnit rootCu
? rootCu
: null;
return resolveTypeToFqn(type, cu, Map.of());
}
public String resolveTypeToFqn(Type type, CompilationUnit cu) {
return resolveTypeToFqn(type, cu, Map.of());
}
public String resolveTypeToFqn(Type type, CompilationUnit cu, Map<String, String> typeVarBindings) {
if (type == null) {
return null;
}
if (type instanceof WildcardType wildcardType) {
Type bound = wildcardType.getBound();
return bound != null ? resolveTypeToFqn(bound, cu, typeVarBindings) : null;
}
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), cu, typeVarBindings);
} else {
simpleName = type.toString();
}
if (simpleName.contains("<")) {
simpleName = simpleName.substring(0, simpleName.indexOf('<'));
}
if (typeVarBindings.containsKey(simpleName)) {
return typeVarBindings.get(simpleName);
}
if (cu != null) {
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) {
return context.getFqn(localTd);
}
}
} else {
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName);
if (td != null) {
return context.getFqn(td);
}
}
return simpleName;
}
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);
@@ -104,42 +25,11 @@ 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,15 +1,8 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorNaming;
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.*;
@@ -19,27 +12,17 @@ public class VariableTracer {
private ConstantExtractor constantExtractor;
private final click.kamil.springstatemachineexporter.ast.common.DataFlowModel dataFlowModel;
private final Map<String, String> variableDeclaredTypeCache = new HashMap<>();
private final FieldInitializerFinder fieldInitializerFinder;
public VariableTracer(CodebaseContext context, ConstantResolver constantResolver) {
this.context = context;
this.constantResolver = constantResolver;
this.dataFlowModel = new click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel(context);
this.fieldInitializerFinder = new FieldInitializerFinder(context);
}
public void setConstantExtractor(ConstantExtractor constantExtractor) {
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);
@@ -68,93 +51,105 @@ 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) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
String setterMethodName;
String indexedFieldName;
if (AccessorNaming.isBeanStyleAccessorName(getterName)) {
String propName = AccessorNaming.propertyNameFromAccessor(getterName);
setterMethodName = "set" + AccessorNaming.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();
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);
}
}
@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 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;
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName)) {
return fd.getType().toString();
}
}
}
Optional<String> indexed = context.getFieldTypeIndex().lookup(typeFqn, fieldName);
if (indexed.isPresent()) {
return indexed.get();
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
String result = getFieldType(superTd, fieldName, context, visited);
if (result != null) return result;
}
}
return fieldInitializerFinder.resolveFieldTypeName(td, fieldName, visited);
for (Object intfObj : td.superInterfaceTypes()) {
Type intfType = (Type) intfObj;
String intfName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(intfType);
String resolvedIntf = resolveTypeFqn(intfName, context, td);
if (resolvedIntf != null) {
TypeDeclaration intfTd = context.getTypeDeclaration(resolvedIntf);
if (intfTd != null) {
String result = getFieldType(intfTd, fieldName, context, visited);
if (result != null) return result;
}
}
}
return null;
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
}
private TypeDeclaration findEnclosingType(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
private String resolveTypeFqn(String simpleName, CodebaseContext context, TypeDeclaration td) {
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
if (cu != null) {
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String name = imp.getName().getFullyQualifiedName();
if (name.endsWith("." + simpleName)) {
return name;
}
}
if (cu.getPackage() != null) {
return cu.getPackage().getName().getFullyQualifiedName() + "." + simpleName;
}
}
return simpleName;
}
public String getVariableDeclaredType(String methodFqn, String cleanFieldName) {
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) {
if (methodFqn == null) return null;
int lastDot = methodFqn.lastIndexOf('.');
if (lastDot > 0) {
String fqn = methodFqn.substring(0, lastDot);
@@ -275,6 +270,19 @@ 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;
}
@@ -631,8 +639,79 @@ public class VariableTracer {
return true;
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) {
return MethodInvocationUnwrapper.unwrap(mi, depth, methodFqn, context, miArg -> resolveTargetMethodFqn(miArg, 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;
}
private String resolveTargetMethodFqn(MethodInvocation mi, String currentMethodFqn) {
@@ -654,4 +733,33 @@ 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

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

View File

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

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

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

View File

@@ -45,44 +45,20 @@ public class TransitionStateUtils {
}
public static Set<State> findAllStates(Collection<Transition> transitions, Set<String> initialStates, Set<String> endStates) {
java.util.Map<String, State> byFullIdentifier = new java.util.LinkedHashMap<>();
Set<State> allStates = new java.util.HashSet<>();
if (transitions != null) {
transitions.forEach(t -> {
mergeState(byFullIdentifier, t.getSourceStates());
mergeState(byFullIdentifier, t.getTargetStates());
if (t.getSourceStates() != null) allStates.addAll(t.getSourceStates());
if (t.getTargetStates() != null) allStates.addAll(t.getTargetStates());
});
}
if (initialStates != null) {
initialStates.forEach(label -> mergeState(byFullIdentifier, State.of(label)));
initialStates.forEach(s -> allStates.add(State.of(s)));
}
if (endStates != null) {
endStates.forEach(label -> mergeState(byFullIdentifier, State.of(label)));
endStates.forEach(s -> allStates.add(State.of(s)));
}
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();
return allStates;
}
private static Stream<String> normalizeStates(Collection<State> states) {

View File

@@ -1,13 +1,7 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.ArrayType;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.NameQualifiedType;
import org.eclipse.jdt.core.dom.ParameterizedType;
import org.eclipse.jdt.core.dom.PrimitiveType;
@@ -15,59 +9,11 @@ import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.QualifiedType;
import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration;
public final class AstUtils {
private AstUtils() {
}
public static MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
public static TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
public static String simpleName(String fqn) {
if (fqn == null || fqn.isEmpty()) {
return fqn;
}
int dot = fqn.lastIndexOf('.');
return dot >= 0 ? fqn.substring(dot + 1) : fqn;
}
public static String qualifier(String fqn) {
if (fqn == null || !fqn.contains(".")) {
return null;
}
return fqn.substring(0, fqn.lastIndexOf('.'));
}
public static String extractAnnotationMember(Annotation annotation, String memberName) {
if (annotation instanceof SingleMemberAnnotation sma) {
if ("value".equals(memberName)) {
return sma.getValue().toString();
}
} else if (annotation instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if (pair.getName().getIdentifier().equals(memberName)) {
return pair.getValue().toString();
}
}
}
return "";
}
public static String extractSimpleTypeName(Type type) {
if (type.isSimpleType()) {
Name name = ((SimpleType) type).getName();
@@ -167,100 +113,8 @@ 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,18 +1,5 @@
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;
@@ -31,17 +18,15 @@ 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 {
public class CodebaseContext implements SourceContext {
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<>();
@@ -58,73 +43,20 @@ public class CodebaseContext {
private final ObjectMapper objectMapper = new ObjectMapper();
private Path projectRoot;
private final Map<String, Object> cache = new HashMap<>();
private AccessorIndex accessorIndex = AccessorIndex.empty();
private 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 AccessorIndex getAccessorIndex() {
return accessorIndex;
}
public PolymorphicAccessorIndex getPolymorphicAccessorIndex() {
return polymorphicAccessorIndex;
}
public GetterChainIndex getGetterChainIndex() {
return getterChainIndex;
}
public IndexedFieldConstantsIndex getIndexedFieldConstantsIndex() {
return indexedFieldConstantsIndex;
}
public FieldTypeIndex getFieldTypeIndex() {
return fieldTypeIndex;
}
public PolymorphicMethodExpansionIndex getPolymorphicMethodExpansionIndex() {
return polymorphicMethodExpansionIndex;
}
public ClassCompatibilityCache getClassCompatibilityCache() {
return classCompatibilityCache;
}
public void clearAnalysisCaches() {
methodDeclarationCache.clear();
superclassFqnCache.clear();
classCompatibilityCache.clear();
}
public Set<String> getIndexedTypeFqns() {
return Collections.unmodifiableSet(classes.keySet());
}
public boolean isInlineAccessors() {
return inlineAccessors;
}
public void setInlineAccessors(boolean inlineAccessors) {
this.inlineAccessors = inlineAccessors;
}
public void setProjectRoot(Path root) {
this.projectRoot = root;
}
@Override
public Path getProjectRoot() {
return projectRoot;
}
public String getRelativePath(Path fullPath) {
if (projectRoot != null && fullPath.isAbsolute() && fullPath.startsWith(projectRoot)) {
return projectRoot.relativize(fullPath).toString();
@@ -149,31 +81,7 @@ public class CodebaseContext {
return Collections.unmodifiableList(libraryHints);
}
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 final Set<String> ignorePatterns = new HashSet<>(Set.of("**/test/**", "**/build/classes/**", "**/build/libs/**", "**/build/tmp/**", "**/build/reports/**", "**/build/test-results/**", "**/node_modules/**", "**/out/**"));
private String[] classpath = new String[0];
private String[] sourcepath = new String[0];
private boolean resolveBindings = false;
@@ -186,6 +94,10 @@ public class CodebaseContext {
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]);
}
@@ -297,7 +209,6 @@ public class CodebaseContext {
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());
@@ -314,29 +225,6 @@ public class CodebaseContext {
}
}
}
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) {
@@ -345,7 +233,6 @@ public class CodebaseContext {
classes.put(fqn, (CompilationUnit) td.getRoot());
classPaths.put(fqn, javaFile);
typeDeclarations.put(fqn, td);
if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName);
@@ -390,7 +277,6 @@ public class CodebaseContext {
// Treat as a class since it is a type
classes.put(fqn, (CompilationUnit) rd.getRoot());
classPaths.put(fqn, javaFile);
recordDeclarations.put(fqn, rd);
if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName);
@@ -414,21 +300,13 @@ public class CodebaseContext {
}
public List<String> getImplementations(String interfaceName) {
if (interfaceName == null) {
return List.of();
String cleanName = interfaceName;
if (interfaceName != null && interfaceName.contains("<")) {
cleanName = interfaceName.substring(0, interfaceName.indexOf('<'));
}
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<>();
Set<String> allImpls = new HashSet<>();
collectImplementations(cleanName, allImpls, new HashSet<>());
List<String> sorted = new ArrayList<>(allImpls);
Collections.sort(sorted);
return Collections.unmodifiableList(sorted);
return new ArrayList<>(allImpls);
}
private void collectImplementations(String typeName, Set<String> results, Set<String> visited) {
@@ -542,20 +420,10 @@ public class CodebaseContext {
}
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) {
TypeDeclaration resolved = findTypeInCu(cu, name);
if (resolved != null) {
typeDeclarations.put(name, resolved);
}
return resolved;
}
if (cu != null)
return findTypeInCu(cu, name);
// If it's a simple name, check if it's ambiguous
if (ambiguousSimpleNames.contains(name)) {
@@ -566,18 +434,9 @@ public class CodebaseContext {
// 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) {
TypeDeclaration resolved = findTypeInCu(cu, fqn);
if (resolved != null) {
typeDeclarations.put(fqn, resolved);
}
return resolved;
}
if (cu != null)
return findTypeInCu(cu, fqn);
}
return null;
@@ -637,21 +496,7 @@ public class CodebaseContext {
}
public String getSuperclassFqn(TypeDeclaration td) {
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) {
if (td == null) return null;
ITypeBinding binding = td.resolveBinding();
if (binding != null) {
ITypeBinding superBinding = binding.getSuperclass();
@@ -681,23 +526,7 @@ public class CodebaseContext {
}
public MethodDeclaration findMethodDeclaration(TypeDeclaration td, String methodName, boolean includeSuper) {
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) {
if (td == null) return null;
ITypeBinding binding = td.resolveBinding();
if (binding != null && includeSuper) {
IMethodBinding methodBinding = findMethodBinding(binding, methodName);
@@ -776,59 +605,6 @@ public class CodebaseContext {
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);
@@ -1029,11 +805,8 @@ public class CodebaseContext {
Type superclass = td.getSuperclassType();
if (superclass != null) {
String superclassName = getSimpleName(superclass);
if (knownAdapters.contains(superclassName)
|| superclass.toString().contains("StateMachineConfigurerAdapter")
|| superclass.toString().contains("EnumStateMachineConfigurerAdapter")) {
if (knownAdapters.contains(superclassName))
return true;
}
TypeDeclaration superTd = getTypeDeclaration(superclassName, cu);
if (superTd != null && extendsStateMachineConfigurerAdapterLegacy(superTd, visited))
return true;

View File

@@ -1,8 +1,5 @@
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.*;
@@ -21,10 +18,6 @@ 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();
}
@@ -33,12 +26,15 @@ public class JdtDataFlowModel implements DataFlowModel {
this.context = context;
}
public void clearAnalysisCaches() {
context.getClassCompatibilityCache().clearMemoCache();
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
return AstUtils.findEnclosingMethod(node);
ASTNode current = node;
while (current != null) {
if (current instanceof MethodDeclaration) {
return (MethodDeclaration) current;
}
current = current.getParent();
}
return null;
}
private ReachingDefinitions getReachingDefinitionsForMethod(MethodDeclaration method) {
@@ -60,7 +56,7 @@ public class JdtDataFlowModel implements DataFlowModel {
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (expr == null || ResolutionBudget.defaults().isDataflowExhausted(depth) || !visited.add(expr)) {
if (expr == null || depth > 50 || !visited.add(expr)) {
return List.of();
}
@@ -199,14 +195,6 @@ 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);
@@ -267,19 +255,6 @@ 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) {
@@ -314,498 +289,6 @@ 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();
@@ -940,7 +423,7 @@ public class JdtDataFlowModel implements DataFlowModel {
Map<IVariableBinding, Expression> callerParamValues,
Map<IVariableBinding, Expression> fieldBindings,
int depth) {
if (cb == null || ResolutionBudget.defaults().isConstructorExhausted(depth)) return;
if (cb == null || depth > 10) return;
MethodDeclaration cd = findMethodDeclaration(cb);
if (cd == null || cd.getBody() == null) return;
@@ -1217,22 +700,6 @@ 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());
@@ -1319,14 +786,6 @@ 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);
@@ -1487,272 +946,6 @@ 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;
@@ -1777,12 +970,6 @@ public class JdtDataFlowModel implements DataFlowModel {
}
private Set<String> getCompatibleContextTypes(List<String> currentPath, String declaringClassFqn) {
String cacheKey = "contextTypes:" + currentPath + "#" + declaringClassFqn;
return context.getClassCompatibilityCache().memoize(
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

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

View File

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

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. With -j, optionally rescans sources to resolve machine enum types.")
@Option(names = {"-i", "--input"}, description = "Input directory containing the Spring Boot project source code.")
private Path inputDir;
@Option(names = {"-j", "--json"}, description = "Input JSON file containing the state machine definition.")
@@ -57,11 +57,8 @@ 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 = {"--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;
@Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "false")
private boolean resolveClasspath;
@Override
public Integer call() throws Exception {
@@ -91,9 +88,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, inputDir);
exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat);
} else {
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, includeGeneratedSources, inlineAccessors);
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, resolveClasspath);
}
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,9 +20,15 @@ public class JsonExporter implements StateMachineExporter {
@Override
public String export(click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result, ExportOptions options) {
try {
// Full AnalysisResult serialization: self-contained interchange format for downstream
// exporters (HTML, etc.) without requiring source code re-analysis.
return objectMapper.writeValueAsString(result);
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);
} catch (Exception e) {
throw new RuntimeException("Failed to export to JSON", e);
}

View File

@@ -0,0 +1,147 @@
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.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
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.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,6 +20,11 @@ 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;
@@ -40,48 +45,43 @@ public class ExportService {
}
public ExportService(List<StateMachineExporter> exporters) {
this(exporters, EnrichmentService.createDefault());
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()
)));
}
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, true);
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
}
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, true);
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
}
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, true);
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
}
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, true);
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, eventFormat, stateFormat, false);
}
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 {
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 {
CodebaseContext context = new CodebaseContext();
context.setActiveProfiles(activeProfiles);
context.setInlineAccessors(inlineAccessors);
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);
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));
// Find project root (common ancestor)
Path projectRoot = inputDir.toAbsolutePath();
@@ -95,28 +95,23 @@ public class ExportService {
log.info("Project root resolved to: {}", projectRoot);
List<String> sourcepaths = allPaths.stream()
.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()
.map(p -> p.resolve("src/main/java").toString())
.filter(p -> Files.exists(Path.of(p)))
.collect(Collectors.toList());
log.info("Setting JDT sourcepath: {}", sourcepaths);
context.setSourcepath(sourcepaths);
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());
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.");
}
context.setResolveBindings(true);
Path hintsFile = inputDir.resolve("hints.json");
@@ -147,8 +142,57 @@ public class ExportService {
context.scan(allPaths, Collections.emptySet());
CodebaseIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, inputDir);
exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds, flows, machineFilter, activeProfiles != null ? activeProfiles : Collections.emptyList(), eventFormat, stateFormat);
// 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);
}
}
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats) throws IOException {
@@ -160,43 +204,12 @@ 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);
try {
context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
.scanProjectRoot(sourceRoot, activeProfiles);
} catch (RuntimeException ex) {
log.warn("JSON re-export: source scan failed; using embedded machine types if present", ex);
}
} else {
log.info("JSON re-export: no source project found; using embedded machine types if present");
}
StateMachineTypeResolver.MachineTypes machineTypes =
click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory.resolveMachineTypes(
result.getName(),
result.getStateTypeFqn(),
result.getEventTypeFqn(),
context);
AnalysisResultFinalizer.applyCanonicalization(result, machineTypes);
enrichmentService.relinkAfterPropertyResolution(result, context, null);
AnalysisResultFinalizer.finalizeResult(result, context, machineTypes);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
}
@@ -256,23 +269,16 @@ 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 = MachineEnumCanonicalizer.canonicalizeStateLabels(
aggregator.getInitialStates(), machineTypes.stateTypeFqn());
Set<String> endStatesAst = MachineEnumCanonicalizer.canonicalizeStateLabels(
aggregator.getEndStates(), machineTypes.stateTypeFqn());
Set<String> initialStatesAst = aggregator.getInitialStates();
Set<String> endStatesAst = aggregator.getEndStates();
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 = MachineEnumCanonicalizer.canonicalizeStates(
TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst),
machineTypes.stateTypeFqn());
Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst);
if (allStates.isEmpty() && transitions.isEmpty()) {
log.info("Skipping empty state machine config: {}", className);
@@ -289,10 +295,9 @@ public class ExportService {
.flows(flows)
.build();
enrichmentService.enrichPreProperty(result, context, intelligence);
enrichmentService.enrich(result, context, intelligence);
resolveProperties(result, activeProfiles);
enrichmentService.enrichPostProperty(result, context, intelligence);
AnalysisResultFinalizer.finalizeResult(result, context);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
}
@@ -307,14 +312,9 @@ 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 = MachineEnumCanonicalizer.canonicalizeStates(
TransitionStateUtils.findAllStates(transitions, null, null),
machineTypes.stateTypeFqn());
Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, null, null);
if (allStates.isEmpty() && transitions.isEmpty()) {
log.info("Skipping empty state machine bean: {}", uniqueName);
@@ -331,10 +331,9 @@ public class ExportService {
.flows(flows)
.build();
enrichmentService.enrichPreProperty(result, context, intelligence);
enrichmentService.enrich(result, context, intelligence);
resolveProperties(result, activeProfiles);
enrichmentService.enrichPostProperty(result, context, intelligence);
AnalysisResultFinalizer.finalizeResult(result, context);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
}

View File

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

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

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

@@ -1,50 +0,0 @@
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,5 +1,6 @@
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;
@@ -16,6 +17,7 @@ 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 {
@@ -31,16 +33,7 @@ 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,
scenario.activeProfiles(),
null,
null,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml", "dot", "scxml", "json"), true);
List<Path> generatedDirs;
try (var stream = Files.list(tempDir)) {
@@ -166,73 +159,12 @@ 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,7 +1,5 @@
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;
@@ -23,12 +21,8 @@ import static org.assertj.core.api.Assertions.assertThat;
public class RegressionTest {
private static final List<StateMachineExporter> EXPORTERS =
List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
private ExportService exportService() {
return new ExportService(EXPORTERS);
}
private final List<StateMachineExporter> exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
private final ExportService exportService = new ExportService(exporters);
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
@@ -132,12 +126,6 @@ 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"),
@@ -161,44 +149,19 @@ 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());
ExportService exportService = exportService();
if (exportService == null) System.out.println("exportService is NULL");
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);
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);
// Find the generated directory (it might be named with FQN)
List<Path> generatedDirs;

View File

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

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