Compare commits
118 Commits
9a5f122bd2
...
ai-branch-
| Author | SHA1 | Date | |
|---|---|---|---|
| dbc22bf1af | |||
| 758d433726 | |||
| 1957266a98 | |||
| 33ccc4a6c5 | |||
| 6ca8e64750 | |||
| 5b0778301a | |||
| d19d4f20ef | |||
| 3e0f8f18b8 | |||
| 9455a2c8a9 | |||
| 13de69862b | |||
| bb28e72bbe | |||
| a675a773da | |||
| 01c8c58277 | |||
| dd25db0109 | |||
| 280c346761 | |||
| a9dbf17ced | |||
| 60cd6511d4 | |||
| 85af06c89d | |||
| 73f047e0aa | |||
| 24cd248a22 | |||
| b9db614cb1 | |||
| fba7a0c90c | |||
| 12db9dbef4 | |||
| 79bb4e2aba | |||
| 80b784da29 | |||
| 610c83a7ec | |||
| fd21cfa440 | |||
| 8d7e04cc33 | |||
| 2d478d5779 | |||
| 252255f8b0 | |||
| a70059331b | |||
| 8ef6e5ef8f | |||
| 5348dafb69 | |||
| a3789ceb3c | |||
| 2e7afd6d42 | |||
| e3f3350398 | |||
| 5d432e8893 | |||
| 24fe8dfe00 | |||
| 00d1f24709 | |||
| 32fe96041c | |||
| 7799f8ce5f | |||
| d11f8afd79 | |||
| dbeab64e1b | |||
| d589f65cc1 | |||
| 4a8b6ae9d9 | |||
| be1ac91beb | |||
| 8c4f15133e | |||
| 57686c575e | |||
| 5bc65be30d | |||
| 5c0d9c466d | |||
| bd9136a45c | |||
| cef9dd1f9a | |||
| 26560fee6d | |||
| 8869ebc706 | |||
| 6401a58c20 | |||
| 82f4e5c087 | |||
| 828f1260cd | |||
| fb3218e1a2 | |||
| 5972b9df50 | |||
| a94490712f | |||
| dcae599d21 | |||
| 4ca42ae3dc | |||
| f3c82d5d7f | |||
| bbfb54c51b | |||
| 44497afd32 | |||
| 69361e5a60 | |||
| c1085a2bd3 | |||
| 308dac557e | |||
| be5386b972 | |||
| 4dd64c6cdf | |||
| 73754cf6ea | |||
| 1146cdbae6 | |||
| 4f3d6c40e7 | |||
| 4a7b3ff124 | |||
| 60b5ceea8c | |||
| 423bb4e819 | |||
| c7e826c0fb | |||
| 024458d638 | |||
| c3c5d66031 | |||
| ad567ace0c | |||
| 169fae88ab | |||
| f8f64487d1 | |||
| 1bf75c8a34 | |||
| 2f853fb971 | |||
| e6effa3dcd | |||
| 83008a6898 | |||
| 77bf840465 | |||
| 873e2c91f3 | |||
| 58d0701df5 | |||
| 422ea94b28 | |||
| 7ffd157105 | |||
| 393c998b66 | |||
| dd0363cf64 | |||
| cc2cf1845b | |||
| 8d954804b2 | |||
| 66aaa9563f | |||
| 411b7a0e52 | |||
| 82a70b5374 | |||
| 5d3333d494 | |||
| 7a5fc60ace | |||
| 9ede60049b | |||
| cc35546d01 | |||
| c2eff6d7bb | |||
| 613dfffd53 | |||
| db158200dd | |||
| 23a14d8391 | |||
| b158179559 | |||
| 4f13c9449c | |||
| 0aca0aade9 | |||
| ea9b8d6cff | |||
| 8a97bceca4 | |||
| 8fa6a44e75 | |||
| a108d8cc6e | |||
| 7f12ed3f3a | |||
| 0628710106 | |||
| c447641800 | |||
| 12183693aa | |||
| 1be8d5e950 |
53
README.md
53
README.md
@@ -8,16 +8,46 @@ Static analysis tool to extract and visualize Spring State Machines with deep co
|
||||
|
||||
## Usage
|
||||
|
||||
Analysis is **source-first**: the exporter reads project source and build metadata (`pom.xml`, `settings.gradle`) statically. It does **not** run Maven or Gradle against the project being analyzed.
|
||||
|
||||
### 1. Extract Metadata (JSON)
|
||||
```bash
|
||||
./gradlew :state_machine_exporter:run --args="-i ./my-project -o ./out -f json"
|
||||
```
|
||||
|
||||
Common options:
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-i, --input` | Project directory to scan (defaults to `.`) |
|
||||
| `-j, --json` | Re-render from an existing JSON export instead of scanning source |
|
||||
| `-o, --output` | Output directory (default `./out`) |
|
||||
| `-f, --format` | Comma-separated formats: `json`, `dot`, `puml`, `scxml` |
|
||||
| `-p, --profiles` | Spring profiles for property placeholder resolution |
|
||||
| `--event`, `--state` | Enum formatting: `fn` (default), `fqn`, `sn` |
|
||||
| `--debug` | Verbose analysis logging and unresolved chain diagnostics |
|
||||
| `--no-diamonds` | Render choice/junction states as regular nodes (default: diamonds) |
|
||||
| `--include-generated-sources` | Also scan `target/` / `build/` generated `.java` trees (default: on) |
|
||||
| `--no-inline-accessors` | Disable accessor indexing/inlining (enabled by default) |
|
||||
|
||||
### 2. Generate Interactive Portal (HTML)
|
||||
```bash
|
||||
./gradlew :state_machine_exporter_html:run --args="-i ./my-project -o ./out_html"
|
||||
```
|
||||
*Supports `--profiles prod` to resolve Spring property placeholders.*
|
||||
|
||||
HTML-specific options:
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-f, --flows` | Custom `flows.json` path |
|
||||
| `-m, --machine` | Export only machines whose name contains this filter |
|
||||
| `--open` | Open the generated portal in the default browser |
|
||||
| `--no-metadata-pane` | Hide the left metadata pane (entry points, flows) |
|
||||
| `--no-diamonds` | Render choice pseudostates as normal nodes |
|
||||
| `--debug` | Same diagnostic mode as the core exporter |
|
||||
| `--include-generated-sources` | Same generated-source scanning as the core exporter |
|
||||
| `--no-inline-accessors` | Disable accessor indexing/inlining (enabled by default) |
|
||||
| `-p, --profiles` | Spring profiles for property resolution |
|
||||
|
||||
## Business Flows
|
||||
Define sequence of events in `src/main/resources/flows.json`:
|
||||
@@ -26,13 +56,32 @@ Define sequence of events in `src/main/resources/flows.json`:
|
||||
{
|
||||
"name": "Order Success",
|
||||
"description": "Happy path for order placement",
|
||||
"steps": ["PAY", "CHECK_AVAILABILITY->PENDING", "SHIP"]
|
||||
"steps": [
|
||||
"PAY",
|
||||
"CHECK_AVAILABILITY->PENDING",
|
||||
{ "source": "com.example.order.OrderState.PAID", "event": "com.example.order.OrderEvent.SHIP" }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Event-only strings remain supported but do not highlight in the HTML explorer unless paired with a source state. Use `{source, event}` objects for precise transition highlighting.
|
||||
|
||||
## JSON Structure
|
||||
- `metadata.entryPoints`: REST, WebFlux, and JMS entry points.
|
||||
- `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`).
|
||||
|
||||
@@ -15,6 +15,7 @@ include ':state_machines:inheritance_extra_functions3_state_machine'
|
||||
include ':state_machines:simple_state_machine'
|
||||
include ':state_machines:extended_analysis_sample'
|
||||
include ':state_machines:inheritance_sample'
|
||||
include ':state_machines:layered_dispatcher_sample'
|
||||
include ':state_machines:ultimate_ecosystem_sm'
|
||||
include ':state_machines:enterprise_order_system'
|
||||
include ':state_machines:multi_module_sample:api-module'
|
||||
|
||||
@@ -48,10 +48,15 @@ dependencies {
|
||||
testImplementation 'org.assertj:assertj-core:3.27.7'
|
||||
|
||||
implementation 'net.sourceforge.plantuml:plantuml:1.2024.3'
|
||||
|
||||
// Library types for JDT binding when analyzing cloned source (never runs the target project's build).
|
||||
implementation 'org.springframework.boot:spring-boot-starter:3.2.0'
|
||||
implementation 'org.springframework.statemachine:spring-statemachine-starter:3.2.0'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
maxParallelForks = Math.max(1, Runtime.runtime.availableProcessors().intdiv(2) ?: 1)
|
||||
systemProperty "updateGolden", System.getProperty("updateGolden")
|
||||
}
|
||||
|
||||
|
||||
168
state_machine_exporter/scripts/audit_golden_json.py
Normal file
168
state_machine_exporter/scripts/audit_golden_json.py
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deep audit of golden JSON analysis exports."""
|
||||
import json
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
GOLDEN = Path(__file__).resolve().parent.parent / "src/test/resources/golden"
|
||||
PH = re.compile(r"\$\{")
|
||||
|
||||
|
||||
def fn_form(identifier):
|
||||
if not identifier:
|
||||
return identifier
|
||||
last = identifier.rfind(".")
|
||||
if last <= 0:
|
||||
return identifier
|
||||
prev = identifier.rfind(".", 0, last)
|
||||
return identifier[prev + 1 :] if prev > 0 else identifier
|
||||
|
||||
|
||||
def sid(st):
|
||||
if isinstance(st, dict):
|
||||
return st.get("fullIdentifier") or st.get("rawName")
|
||||
return st
|
||||
|
||||
|
||||
def eid(ev):
|
||||
if isinstance(ev, dict):
|
||||
return ev.get("fullIdentifier") or ev.get("rawName")
|
||||
return ev
|
||||
|
||||
|
||||
def audit_file(path, data):
|
||||
name = data.get("name", path.stem)
|
||||
issues = []
|
||||
st_fqn = data.get("stateTypeFqn")
|
||||
ev_fqn = data.get("eventTypeFqn")
|
||||
transitions = data.get("transitions") or []
|
||||
starts = list(data.get("startStates") or [])
|
||||
ends = list(data.get("endStates") or [])
|
||||
states = data.get("states") or []
|
||||
meta = data.get("metadata") or {}
|
||||
|
||||
is_pkg_enum = st_fqn and "." in st_fqn and st_fqn not in ("String", "java.lang.String")
|
||||
is_string = st_fqn in (None, "String", "java.lang.String") or (
|
||||
not st_fqn and not ev_fqn
|
||||
)
|
||||
|
||||
if PH.search(json.dumps(data)):
|
||||
issues.append("BUG: unresolved ${placeholder} in export")
|
||||
|
||||
# Duplicate fullIdentifiers in states[]
|
||||
by_full = defaultdict(list)
|
||||
for s in states:
|
||||
by_full[sid(s)].append(s.get("rawName"))
|
||||
for fid, raws in by_full.items():
|
||||
if fid and len(raws) > 1 and len(set(raws)) > 1:
|
||||
issues.append(f"NOISE: duplicate state entry {fid!r} with rawNames {raws}")
|
||||
|
||||
# Collect transition state/event ids
|
||||
t_events, t_sources, t_targets = set(), set(), set()
|
||||
for t in transitions:
|
||||
if eid(t.get("event")):
|
||||
t_events.add(eid(t.get("event")))
|
||||
for s in t.get("sourceStates") or []:
|
||||
t_sources.add(sid(s))
|
||||
for s in t.get("targetStates") or []:
|
||||
t_targets.add(sid(s))
|
||||
|
||||
graph = t_sources | t_targets
|
||||
|
||||
# startStates canonical consistency
|
||||
for ss in starts:
|
||||
if is_string and ss and not ss.startswith("String."):
|
||||
if any(x.startswith("String.") for x in graph):
|
||||
issues.append(f"BUG: startStates {ss!r} bare but transitions use String.* form")
|
||||
if ss not in graph and fn_form(ss) not in {fn_form(x) for x in graph}:
|
||||
issues.append(f"WARN: startStates {ss!r} not in transition graph")
|
||||
|
||||
# endStates
|
||||
for es in ends:
|
||||
if es not in graph and fn_form(es) not in {fn_form(x) for x in graph}:
|
||||
issues.append(f"WARN: endStates {es!r} not in transition graph")
|
||||
|
||||
# Enum canonical fullIdentifiers
|
||||
if is_pkg_enum:
|
||||
for i, t in enumerate(transitions):
|
||||
for side in ("sourceStates", "targetStates"):
|
||||
for s in t.get(side) or []:
|
||||
fid = sid(s)
|
||||
if fid and not fid.startswith(st_fqn + ".") and not fid.startswith('"'):
|
||||
if "." in fid and not fid.startswith("<"):
|
||||
issues.append(f"BUG: transitions[{i}].{side} {fid!r} not under {st_fqn}")
|
||||
ev = eid(t.get("event"))
|
||||
if ev and ev_fqn and "." in ev_fqn and not ev.startswith("<") and not ev.startswith(ev_fqn + "."):
|
||||
if ev not in ("event",) and "." in ev and not ev.startswith(ev_fqn):
|
||||
issues.append(f"BUG: transitions[{i}].event {ev!r} not under {ev_fqn}")
|
||||
for i, tr in enumerate(meta.get("triggers") or []):
|
||||
for field in ("event", "sourceState"):
|
||||
val = tr.get(field)
|
||||
if not val or val in ("event", "payload", "customMessage", "eventProvider"):
|
||||
continue
|
||||
type_fqn = ev_fqn if field == "event" else st_fqn
|
||||
if type_fqn and "." in val and not val.startswith("<") and not val.startswith(type_fqn + "."):
|
||||
if field == "sourceState" and val not in graph and fn_form(val) not in {fn_form(x) for x in graph}:
|
||||
issues.append(f"BUG: triggers[{i}].sourceState {val!r} not a machine state")
|
||||
elif field == "event" and type_fqn and val.isupper() is False and "." not in val and not val.startswith("<"):
|
||||
if val not in ("event", "payload", "customMessage", "eventProvider", "true", "false"):
|
||||
issues.append(f"INFO: triggers[{i}].event dynamic {val!r}")
|
||||
|
||||
# matchedTransitions
|
||||
for i, chain in enumerate(meta.get("callChains") or []):
|
||||
for j, mt in enumerate(chain.get("matchedTransitions") or []):
|
||||
me, ms, mtgt = mt.get("event"), mt.get("sourceState"), mt.get("targetState")
|
||||
ok = False
|
||||
for t in transitions:
|
||||
if fn_form(eid(t.get("event"))) != fn_form(me):
|
||||
continue
|
||||
srcs = [sid(s) for s in t.get("sourceStates") or []]
|
||||
tgts = [sid(s) for s in t.get("targetStates") or []]
|
||||
if (not ms or any(fn_form(x) == fn_form(ms) for x in srcs)) and (
|
||||
not mtgt or any(fn_form(x) == fn_form(mtgt) for x in tgts)
|
||||
):
|
||||
ok = True
|
||||
break
|
||||
if not ok:
|
||||
issues.append(
|
||||
f"BUG: callChains[{i}].matchedTransitions[{j}] ({ms!r},{me!r},{mtgt!r}) no transition"
|
||||
)
|
||||
|
||||
# String machine: mixed NEW vs String.NEW
|
||||
if is_string or st_fqn == "String":
|
||||
all_ids = set(graph) | set(starts) | set(ends) | {sid(s) for s in states}
|
||||
for x in list(all_ids):
|
||||
if not x:
|
||||
continue
|
||||
bare = x.strip('"')
|
||||
if bare and "." not in bare and f"String.{bare}" in all_ids:
|
||||
issues.append(f"BUG: mixed bare {x!r} and String.{bare}")
|
||||
|
||||
# stateTypeFqn missing on enum machine
|
||||
if is_pkg_enum and not st_fqn:
|
||||
issues.append("BUG: enum machine missing stateTypeFqn")
|
||||
|
||||
return name, issues
|
||||
|
||||
|
||||
def main():
|
||||
total = 0
|
||||
for path in sorted(GOLDEN.rglob("*.json")):
|
||||
data = json.loads(path.read_text())
|
||||
machine, issues = audit_file(path, data)
|
||||
rel = path.relative_to(GOLDEN)
|
||||
bugs = [i for i in issues if i.startswith("BUG")]
|
||||
warns = [i for i in issues if i.startswith("WARN")]
|
||||
noise = [i for i in issues if i.startswith("NOISE")]
|
||||
info = [i for i in issues if i.startswith("INFO")]
|
||||
if bugs or warns:
|
||||
print(f"\n{'='*70}\n{rel}\n machine: {machine}")
|
||||
for i in bugs + warns + noise + info:
|
||||
print(f" [{i.split(':')[0]}] {i.split(': ', 1)[-1]}")
|
||||
total += len(bugs) + len(warns)
|
||||
print(f"\nTotal BUG+WARN: {total}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.analysis.validation.AnalysisCanonicalFormValidator;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Final enrichment step: fail fast when enum identifiers in the analysis model are not
|
||||
* package-canonical. Disable with {@code -Danalyzer.canonical-form-validation.enabled=false}.
|
||||
*/
|
||||
@Slf4j
|
||||
public class AnalysisCanonicalFormEnricher implements AnalysisEnricher {
|
||||
|
||||
private final boolean enabled;
|
||||
|
||||
public AnalysisCanonicalFormEnricher() {
|
||||
this.enabled = Boolean.parseBoolean(
|
||||
System.getProperty("analyzer.canonical-form-validation.enabled", "true"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
if (!enabled || result == null || context == null) {
|
||||
return;
|
||||
}
|
||||
log.debug("Validating canonical enum identifiers for {}", result.getName());
|
||||
AnalysisCanonicalFormValidator.enforce(result, context);
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,16 @@ 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 lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
public class CallChainEnricher implements AnalysisEnricher {
|
||||
@@ -15,14 +20,88 @@ public class CallChainEnricher implements AnalysisEnricher {
|
||||
@Override
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
log.info("Enriching {} with call chains", result.getName());
|
||||
|
||||
List<CallChain> chains = intelligence.findCallChains(
|
||||
result.getMetadata().getEntryPoints(),
|
||||
result.getMetadata().getTriggers()
|
||||
);
|
||||
|
||||
List<CallChain> chains = buildCallChainsForMachine(result, context, intelligence, true);
|
||||
if (chains == null) {
|
||||
return;
|
||||
}
|
||||
result.addMetadata(CodebaseMetadata.builder()
|
||||
.callChains(chains)
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolves call chains from the codebase call graph for the given machine.
|
||||
* Used on JSON re-export when source is available to refresh {@code polymorphicEvents},
|
||||
* {@code external}, and branch {@code constraint} on trigger points.
|
||||
*/
|
||||
public static List<CallChain> buildCallChainsForMachine(
|
||||
AnalysisResult result,
|
||||
CodebaseContext context,
|
||||
CodebaseIntelligenceProvider intelligence) {
|
||||
return buildCallChainsForMachine(result, context, intelligence, false);
|
||||
}
|
||||
|
||||
public static List<CallChain> buildCallChainsForMachine(
|
||||
AnalysisResult result,
|
||||
CodebaseContext context,
|
||||
CodebaseIntelligenceProvider intelligence,
|
||||
boolean preferMetadataTriggers) {
|
||||
if (result.getMetadata() == null || intelligence == null) {
|
||||
return null;
|
||||
}
|
||||
List<EntryPoint> entryPoints = result.getMetadata().getEntryPoints();
|
||||
if (entryPoints == null || entryPoints.isEmpty()) {
|
||||
entryPoints = synthesizeEntryPoints(result.getMetadata().getCallChains());
|
||||
}
|
||||
if (entryPoints == null || entryPoints.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<TriggerPoint> scopedTriggers = null;
|
||||
if (preferMetadataTriggers) {
|
||||
scopedTriggers = result.getMetadata().getTriggers();
|
||||
}
|
||||
if (scopedTriggers == null || scopedTriggers.isEmpty()) {
|
||||
scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
|
||||
intelligence.findTriggerPoints(),
|
||||
result.getName(),
|
||||
context);
|
||||
}
|
||||
|
||||
List<CallChain> chains = intelligence.findCallChains(entryPoints, scopedTriggers);
|
||||
return MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context);
|
||||
}
|
||||
|
||||
private static List<EntryPoint> synthesizeEntryPoints(List<CallChain> callChains) {
|
||||
if (callChains == null || callChains.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Map<String, EntryPoint> byMethod = new LinkedHashMap<>();
|
||||
for (CallChain chain : callChains) {
|
||||
EntryPoint entryPoint = chain.getEntryPoint();
|
||||
if (hasClassAndMethod(entryPoint)) {
|
||||
byMethod.putIfAbsent(entryPoint.getClassName() + "#" + entryPoint.getMethodName(), entryPoint);
|
||||
continue;
|
||||
}
|
||||
TriggerPoint triggerPoint = chain.getTriggerPoint();
|
||||
if (triggerPoint != null
|
||||
&& triggerPoint.getClassName() != null
|
||||
&& triggerPoint.getMethodName() != null) {
|
||||
EntryPoint fallback = EntryPoint.builder()
|
||||
.type(EntryPoint.Type.CUSTOM)
|
||||
.className(triggerPoint.getClassName())
|
||||
.methodName(triggerPoint.getMethodName())
|
||||
.sourceFile(triggerPoint.getSourceFile())
|
||||
.build();
|
||||
byMethod.putIfAbsent(fallback.getClassName() + "#" + fallback.getMethodName(), fallback);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(byMethod.values());
|
||||
}
|
||||
|
||||
private static boolean hasClassAndMethod(EntryPoint entryPoint) {
|
||||
return entryPoint != null
|
||||
&& entryPoint.getClassName() != null
|
||||
&& entryPoint.getMethodName() != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ExternalTriggerPolicy;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Central rules for transition linking outcomes derived from existing trigger evidence only.
|
||||
*/
|
||||
public final class CallChainLinkPolicy {
|
||||
|
||||
private CallChainLinkPolicy() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the machine event enum FQN from config AST, embedded analysis fields, or transition events.
|
||||
* Abstract/inherited configs often omit AST type args; transition {@code fullIdentifier} values still
|
||||
* carry the correct enum type for endpoint narrowing and linking.
|
||||
*/
|
||||
public static String resolveMachineEventTypeFqn(
|
||||
String machineConfigName,
|
||||
String embeddedEventTypeFqn,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
if (machineTypes != null && machineTypes.eventTypeFqn() != null && !machineTypes.eventTypeFqn().isBlank()) {
|
||||
return machineTypes.eventTypeFqn();
|
||||
}
|
||||
if (embeddedEventTypeFqn != null && !embeddedEventTypeFqn.isBlank()) {
|
||||
return embeddedEventTypeFqn;
|
||||
}
|
||||
if (context != null && machineConfigName != null) {
|
||||
String[] types = StateMachineTypeResolver.resolve(machineConfigName, context);
|
||||
if (types != null && types.length > 1 && types[1] != null && !types[1].isBlank()) {
|
||||
return types[1];
|
||||
}
|
||||
}
|
||||
return eventTypeFromTransitions(machineTransitions);
|
||||
}
|
||||
|
||||
public static StateMachineTypeResolver.MachineTypes resolveEffectiveMachineTypes(
|
||||
String machineConfigName,
|
||||
String embeddedStateTypeFqn,
|
||||
String embeddedEventTypeFqn,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
String eventTypeFqn = resolveMachineEventTypeFqn(
|
||||
machineConfigName, embeddedEventTypeFqn, machineTypes, machineTransitions, context);
|
||||
String stateTypeFqn = machineTypes != null && machineTypes.stateTypeFqn() != null
|
||||
? machineTypes.stateTypeFqn()
|
||||
: embeddedStateTypeFqn;
|
||||
if ((stateTypeFqn == null || stateTypeFqn.isBlank()) && context != null && machineConfigName != null) {
|
||||
String[] types = StateMachineTypeResolver.resolve(machineConfigName, context);
|
||||
if (types != null && types.length > 0 && types[0] != null && !types[0].isBlank()) {
|
||||
stateTypeFqn = types[0];
|
||||
}
|
||||
}
|
||||
if ((stateTypeFqn == null || stateTypeFqn.isBlank()) && machineTransitions != null) {
|
||||
stateTypeFqn = stateTypeFromTransitions(machineTransitions);
|
||||
}
|
||||
return new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn);
|
||||
}
|
||||
|
||||
private static String eventTypeFromTransitions(List<Transition> machineTransitions) {
|
||||
if (machineTransitions == null) {
|
||||
return null;
|
||||
}
|
||||
for (Transition transition : machineTransitions) {
|
||||
Event event = transition.getEvent();
|
||||
if (event == null) {
|
||||
continue;
|
||||
}
|
||||
String fullIdentifier = event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
|
||||
if (fullIdentifier != null && fullIdentifier.contains(".")) {
|
||||
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String stateTypeFromTransitions(List<Transition> machineTransitions) {
|
||||
if (machineTransitions == null) {
|
||||
return null;
|
||||
}
|
||||
for (Transition transition : machineTransitions) {
|
||||
if (transition.getSourceStates() == null) {
|
||||
continue;
|
||||
}
|
||||
for (var state : transition.getSourceStates()) {
|
||||
String fullIdentifier = state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName();
|
||||
if (fullIdentifier != null && fullIdentifier.contains(".")) {
|
||||
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint trigger) {
|
||||
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, null, null);
|
||||
}
|
||||
|
||||
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(
|
||||
TriggerPoint trigger,
|
||||
String machineEventTypeFqn) {
|
||||
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, null);
|
||||
}
|
||||
|
||||
public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(
|
||||
TriggerPoint trigger,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null || trigger.isExternal() || !trigger.isAmbiguous()) {
|
||||
return false;
|
||||
}
|
||||
String eventTypeFqn = trigger.getEventTypeFqn();
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||
eventTypeFqn = machineEventTypeFqn;
|
||||
}
|
||||
List<String> concrete = concretePolymorphicCandidates(
|
||||
trigger.getPolymorphicEvents(), eventTypeFqn, context);
|
||||
if (concrete.size() <= 1) {
|
||||
return false;
|
||||
}
|
||||
String event = trigger.getEvent();
|
||||
if (event != null && event.startsWith("ENUM_SET:")) {
|
||||
return true;
|
||||
}
|
||||
return MachineEnumCanonicalizer.isDynamicTriggerExpression(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when call-graph evidence resolves to a single concrete machine enum event for this chain.
|
||||
* Dispatcher/rich-event paths should narrow to one constant per endpoint before linking.
|
||||
*/
|
||||
public static boolean isEndpointNarrowPolyEvidence(
|
||||
TriggerPoint trigger,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null) {
|
||||
return false;
|
||||
}
|
||||
String eventTypeFqn = trigger.getEventTypeFqn();
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||
eventTypeFqn = machineEventTypeFqn;
|
||||
}
|
||||
List<String> concrete = concretePolymorphicCandidates(
|
||||
trigger.getPolymorphicEvents(), eventTypeFqn, context);
|
||||
return concrete.size() == 1;
|
||||
}
|
||||
|
||||
public static boolean isEndpointNarrowPolyEvidence(TriggerPoint trigger) {
|
||||
return isEndpointNarrowPolyEvidence(trigger, null, null);
|
||||
}
|
||||
|
||||
public static boolean isEndpointNarrowPolyEvidence(
|
||||
TriggerPoint trigger,
|
||||
String machineEventTypeFqn) {
|
||||
return isEndpointNarrowPolyEvidence(trigger, machineEventTypeFqn, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every concrete polymorphic candidate belongs to the trigger's declared event type.
|
||||
* Used to allow machine-scoped symbolic expansion for generic dispatchers while rejecting
|
||||
* cross-package or unqualified widens.
|
||||
*/
|
||||
static boolean isTrustedEnumPolymorphicWiden(TriggerPoint trigger) {
|
||||
return isTrustedEnumPolymorphicWiden(trigger, null, null);
|
||||
}
|
||||
|
||||
static boolean isTrustedEnumPolymorphicWiden(TriggerPoint trigger, String machineEventTypeFqn) {
|
||||
return isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, null);
|
||||
}
|
||||
|
||||
static boolean isTrustedEnumPolymorphicWiden(
|
||||
TriggerPoint trigger,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null) {
|
||||
return false;
|
||||
}
|
||||
String eventTypeFqn = trigger.getEventTypeFqn();
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||
eventTypeFqn = machineEventTypeFqn;
|
||||
}
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
List<String> poly = trigger.getPolymorphicEvents();
|
||||
List<String> concrete = concretePolymorphicCandidates(poly, eventTypeFqn, context);
|
||||
if (concrete.size() != 1) {
|
||||
return false;
|
||||
}
|
||||
return MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(
|
||||
concrete.get(0), eventTypeFqn, context);
|
||||
}
|
||||
|
||||
static List<String> concretePolymorphicCandidates(
|
||||
List<String> poly,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (poly == null || poly.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> concrete = new ArrayList<>();
|
||||
for (String pe : poly) {
|
||||
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:")) {
|
||||
continue;
|
||||
}
|
||||
String qualified = pe;
|
||||
if (!pe.contains(".")) {
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
qualified = eventTypeFqn + "." + pe;
|
||||
} else if (eventTypeFqn != null && !eventTypeFqn.isBlank()) {
|
||||
qualified = MachineEnumCanonicalizer.canonicalizeLabel(pe, eventTypeFqn, context);
|
||||
}
|
||||
if (qualified != null && qualified.contains(".")) {
|
||||
concrete.add(qualified);
|
||||
}
|
||||
}
|
||||
return concrete;
|
||||
}
|
||||
|
||||
public static LinkResolution resolveLinkResolution(
|
||||
TriggerPoint trigger,
|
||||
List<MatchedTransition> matched,
|
||||
boolean ambiguousSource) {
|
||||
return resolveLinkResolution(trigger, matched, ambiguousSource, null, null);
|
||||
}
|
||||
|
||||
public static LinkResolution resolveLinkResolution(
|
||||
TriggerPoint trigger,
|
||||
List<MatchedTransition> matched,
|
||||
boolean ambiguousSource,
|
||||
String machineEventTypeFqn) {
|
||||
return resolveLinkResolution(trigger, matched, ambiguousSource, machineEventTypeFqn, null);
|
||||
}
|
||||
|
||||
public static LinkResolution resolveLinkResolution(
|
||||
TriggerPoint trigger,
|
||||
List<MatchedTransition> matched,
|
||||
boolean ambiguousSource,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (trigger != null && trigger.isExternal()) {
|
||||
return LinkResolution.UNRESOLVED_EXTERNAL;
|
||||
}
|
||||
if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) {
|
||||
return LinkResolution.AMBIGUOUS_WIDEN;
|
||||
}
|
||||
if (ambiguousSource) {
|
||||
return LinkResolution.AMBIGUOUS_WIDEN;
|
||||
}
|
||||
if (matched != null && !matched.isEmpty()) {
|
||||
return LinkResolution.RESOLVED;
|
||||
}
|
||||
return LinkResolution.NO_MATCH;
|
||||
}
|
||||
|
||||
public static TriggerPoint applyRestExternalPolicy(TriggerPoint trigger, CallChain chain, CodebaseContext context) {
|
||||
if (trigger == null || chain.getEntryPoint() == null) {
|
||||
return trigger;
|
||||
}
|
||||
EntryPoint entryPoint = chain.getEntryPoint();
|
||||
String entryMethod = entryPoint.getClassName() + "." + entryPoint.getMethodName();
|
||||
boolean external = ExternalTriggerPolicy.isExternalFromSource(
|
||||
entryPoint, trigger, entryMethod, trigger.getEvent(), context);
|
||||
if (external == trigger.isExternal()) {
|
||||
return trigger;
|
||||
}
|
||||
return trigger.toBuilder().external(external).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.path.SymbolicPathValueEstimator;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||
import org.eclipse.jdt.core.dom.QualifiedName;
|
||||
import org.eclipse.jdt.core.dom.SimpleName;
|
||||
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Narrows wide call-graph polymorphic widens to endpoint-specific enum constants.
|
||||
*
|
||||
* <p>Uses multiple independent evidence sources (REST path, command keys, constraints, static trigger
|
||||
* events, method-chain literals, camelCase method names) and only narrows when evidence converges.
|
||||
*/
|
||||
public final class CallChainPolyNarrower {
|
||||
|
||||
private static final int STRONG = 3;
|
||||
private static final int MEDIUM = 2;
|
||||
private static final int WEAK = 1;
|
||||
|
||||
private static final Pattern QUOTED_EQUALS_PARAM = Pattern.compile(
|
||||
"\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)");
|
||||
private static final Pattern PARAM_EQUALS_LITERAL = Pattern.compile(
|
||||
"(\\w+)\\.equals(?:IgnoreCase)?\\(\"([^\"]+)\"\\)");
|
||||
private static final Pattern ENUM_EQ = Pattern.compile(
|
||||
"([A-Za-z_][\\w.]*)\\s*==\\s*([A-Za-z_][\\w.]*)");
|
||||
private static final Pattern ROUTE_LITERAL = Pattern.compile(
|
||||
"route\\(\\s*\"([^\"]+)\"\\s*,\\s*\"([^\"]+)\"\\s*\\)");
|
||||
|
||||
private static final Set<String> FIRE_METHOD_NAMES = Set.of(
|
||||
"sendEvent", "fire", "fireEvent", "dispatchEvent", "send", "trigger");
|
||||
|
||||
private CallChainPolyNarrower() {
|
||||
}
|
||||
|
||||
public static TriggerPoint narrowForCallChain(
|
||||
TriggerPoint trigger,
|
||||
CallChain chain,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
return narrowForCallChain(trigger, chain, machineTypes, null, machineTransitions, context);
|
||||
}
|
||||
|
||||
public static TriggerPoint narrowForCallChain(
|
||||
TriggerPoint trigger,
|
||||
CallChain chain,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
String embeddedEventTypeFqn,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null) {
|
||||
return trigger;
|
||||
}
|
||||
List<String> poly = trigger.getPolymorphicEvents();
|
||||
if (poly == null || poly.size() <= 1) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
String eventTypeFqn = CallChainLinkPolicy.resolveMachineEventTypeFqn(
|
||||
null,
|
||||
embeddedEventTypeFqn != null ? embeddedEventTypeFqn : trigger.getEventTypeFqn(),
|
||||
machineTypes,
|
||||
machineTransitions,
|
||||
context);
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||
return trigger;
|
||||
}
|
||||
Set<String> configuredConstants = configuredEventConstants(machineTransitions, eventTypeFqn, context);
|
||||
if (configuredConstants.isEmpty()) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
HintVotes votes = new HintVotes();
|
||||
collectHints(trigger, chain, configuredConstants, context, votes);
|
||||
|
||||
String chosen = votes.choose(configuredConstants);
|
||||
if (chosen == null) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
List<String> narrowed = filterPolyToConstant(poly, chosen, eventTypeFqn, context);
|
||||
if (narrowed.size() == 1) {
|
||||
return trigger.toBuilder()
|
||||
.polymorphicEvents(narrowed)
|
||||
.ambiguous(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
return trigger.toBuilder()
|
||||
.polymorphicEvents(List.of(eventTypeFqn + "." + chosen))
|
||||
.ambiguous(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static void collectHints(
|
||||
TriggerPoint trigger,
|
||||
CallChain chain,
|
||||
Set<String> configuredConstants,
|
||||
CodebaseContext context,
|
||||
HintVotes votes) {
|
||||
collectFromStaticTriggerEvent(trigger, configuredConstants, votes);
|
||||
collectFromEntryPoint(chain != null ? chain.getEntryPoint() : null, configuredConstants, votes);
|
||||
collectFromConstraint(trigger.getConstraint(), configuredConstants, votes);
|
||||
if (chain != null) {
|
||||
collectFromMethodChain(chain.getMethodChain(), configuredConstants, votes);
|
||||
collectFromSendEventLiterals(chain.getMethodChain(), configuredConstants, context, votes);
|
||||
collectFromPathEstimator(chain, configuredConstants, context, votes);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromStaticTriggerEvent(
|
||||
TriggerPoint trigger,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes) {
|
||||
String event = trigger.getEvent();
|
||||
if (event == null || event.isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (MachineEnumCanonicalizer.isDynamicTriggerExpression(event)) {
|
||||
return;
|
||||
}
|
||||
addEnumReference(event, configuredConstants, votes, STRONG);
|
||||
}
|
||||
|
||||
private static void collectFromEntryPoint(
|
||||
EntryPoint entryPoint,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes) {
|
||||
if (entryPoint == null) {
|
||||
return;
|
||||
}
|
||||
if (entryPoint.getName() != null) {
|
||||
collectFromPath(entryPoint.getName(), configuredConstants, votes);
|
||||
}
|
||||
if (entryPoint.getMetadata() != null) {
|
||||
Object path = entryPoint.getMetadata().get("path");
|
||||
if (path instanceof String pathValue) {
|
||||
collectFromPath(pathValue, configuredConstants, votes);
|
||||
}
|
||||
}
|
||||
if (entryPoint.getMethodName() != null) {
|
||||
addUniqueIdentifierMatch(entryPoint.getMethodName(), configuredConstants, votes, WEAK);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromPath(String rawPath, Set<String> configuredConstants, HintVotes votes) {
|
||||
if (rawPath == null || rawPath.isBlank()) {
|
||||
return;
|
||||
}
|
||||
int space = rawPath.indexOf(' ');
|
||||
String path = space >= 0 ? rawPath.substring(space + 1).trim() : rawPath.trim();
|
||||
if (path.isBlank()) {
|
||||
return;
|
||||
}
|
||||
for (String segment : path.split("/")) {
|
||||
if (segment.isBlank() || segment.contains("{")) {
|
||||
continue;
|
||||
}
|
||||
addPathToken(segment, configuredConstants, votes, STRONG);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromConstraint(
|
||||
String constraint,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes) {
|
||||
if (constraint == null || constraint.isBlank()) {
|
||||
return;
|
||||
}
|
||||
Matcher quoted = QUOTED_EQUALS_PARAM.matcher(constraint);
|
||||
while (quoted.find()) {
|
||||
String literal = quoted.group(1);
|
||||
String param = quoted.group(2);
|
||||
if ("event".equals(param) || "eventString".equals(param) || "actionKey".equals(param)
|
||||
|| "action".equals(param)) {
|
||||
addPathToken(literal, configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
if ("commandKey".equals(param) || literal.contains(".")) {
|
||||
addPathToken(literal, configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
}
|
||||
Matcher route = ROUTE_LITERAL.matcher(constraint);
|
||||
while (route.find()) {
|
||||
addPathToken(route.group(2), configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
Matcher enumEq = ENUM_EQ.matcher(constraint);
|
||||
while (enumEq.find()) {
|
||||
addEnumReference(enumEq.group(1), configuredConstants, votes, MEDIUM);
|
||||
addEnumReference(enumEq.group(2), configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
Matcher reverseEquals = PARAM_EQUALS_LITERAL.matcher(constraint);
|
||||
while (reverseEquals.find()) {
|
||||
String param = reverseEquals.group(1);
|
||||
String literal = reverseEquals.group(2);
|
||||
if ("event".equals(param) || "eventString".equals(param) || "actionKey".equals(param)
|
||||
|| "action".equals(param) || "commandKey".equals(param)) {
|
||||
addPathToken(literal, configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromMethodChain(
|
||||
List<String> methodChain,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes) {
|
||||
if (methodChain == null) {
|
||||
return;
|
||||
}
|
||||
for (String methodFqn : methodChain) {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
continue;
|
||||
}
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
addUniqueIdentifierMatch(methodName, configuredConstants, votes, WEAK);
|
||||
for (String token : splitCamelCase(methodName)) {
|
||||
addPathToken(token, configuredConstants, votes, WEAK);
|
||||
}
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
int nestedDot = className.lastIndexOf('.');
|
||||
if (nestedDot >= 0) {
|
||||
addUniqueIdentifierMatch(className.substring(nestedDot + 1), configuredConstants, votes, WEAK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromSendEventLiterals(
|
||||
List<String> methodChain,
|
||||
Set<String> configuredConstants,
|
||||
CodebaseContext context,
|
||||
HintVotes votes) {
|
||||
if (context == null || methodChain == null) {
|
||||
return;
|
||||
}
|
||||
ConstantResolver resolver = new ConstantResolver();
|
||||
for (String methodFqn : methodChain) {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
continue;
|
||||
}
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
|
||||
if (typeDeclaration == null) {
|
||||
continue;
|
||||
}
|
||||
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
|
||||
if (methodDeclaration == null) {
|
||||
continue;
|
||||
}
|
||||
for (Object parameter : methodDeclaration.parameters()) {
|
||||
if (parameter instanceof SingleVariableDeclaration variable) {
|
||||
addUniqueIdentifierMatch(variable.getType().toString(), configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
}
|
||||
if (methodDeclaration.getBody() == null) {
|
||||
continue;
|
||||
}
|
||||
methodDeclaration.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if (!FIRE_METHOD_NAMES.contains(node.getName().getIdentifier())) {
|
||||
return super.visit(node);
|
||||
}
|
||||
for (Object argument : node.arguments()) {
|
||||
if (!(argument instanceof Expression expression)) {
|
||||
continue;
|
||||
}
|
||||
if (expression instanceof QualifiedName qualifiedName) {
|
||||
addEnumReference(qualifiedName.getFullyQualifiedName(), configuredConstants, votes, STRONG);
|
||||
} else if (expression instanceof SimpleName simpleName) {
|
||||
addEnumReference(simpleName.getIdentifier(), configuredConstants, votes, STRONG);
|
||||
} else {
|
||||
String resolved = resolver.resolve(expression, context);
|
||||
if (resolved != null) {
|
||||
addEnumReference(resolved, configuredConstants, votes, STRONG);
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromPathEstimator(
|
||||
CallChain chain,
|
||||
Set<String> configuredConstants,
|
||||
CodebaseContext context,
|
||||
HintVotes votes) {
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
List<String> estimated = new SymbolicPathValueEstimator().estimatePossibleValues(chain, context);
|
||||
if (estimated == null || estimated.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int weight = estimated.size() == 1 ? MEDIUM : WEAK;
|
||||
for (String value : estimated) {
|
||||
addEnumReference(value, configuredConstants, votes, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addPathToken(
|
||||
String token,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes,
|
||||
int weight) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return;
|
||||
}
|
||||
addPathTokenVariants(token, configuredConstants, votes, weight);
|
||||
if (token.contains(".")) {
|
||||
addPathTokenVariants(token.substring(token.lastIndexOf('.') + 1), configuredConstants, votes, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addPathTokenVariants(
|
||||
String token,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes,
|
||||
int weight) {
|
||||
maybeVoteConstant(votes, normalizeToken(token), configuredConstants, weight);
|
||||
for (String part : token.split("[-_.]")) {
|
||||
if (!part.isBlank()) {
|
||||
maybeVoteConstant(votes, normalizeToken(part), configuredConstants, weight);
|
||||
}
|
||||
}
|
||||
if (token.toUpperCase(Locale.ROOT).contains("_")) {
|
||||
String tail = token.substring(token.lastIndexOf('_') + 1);
|
||||
maybeVoteConstant(votes, normalizeToken(tail), configuredConstants, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addEnumReference(
|
||||
String reference,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes,
|
||||
int weight) {
|
||||
if (reference == null || reference.isBlank()) {
|
||||
return;
|
||||
}
|
||||
maybeVoteConstant(votes, constantName(reference), configuredConstants, weight);
|
||||
if (reference.contains(".")) {
|
||||
maybeVoteConstant(votes, constantName(reference), configuredConstants, weight);
|
||||
}
|
||||
String upper = reference.toUpperCase(Locale.ROOT);
|
||||
if (upper.contains("_")) {
|
||||
maybeVoteConstant(votes, upper.substring(upper.lastIndexOf('_') + 1), configuredConstants, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addUniqueIdentifierMatch(
|
||||
String identifier,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes,
|
||||
int weight) {
|
||||
if (identifier == null || identifier.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String upper = identifier.toUpperCase(Locale.ROOT);
|
||||
List<String> matched = new ArrayList<>();
|
||||
for (String constant : configuredConstants) {
|
||||
if (upper.contains(constant)) {
|
||||
matched.add(constant);
|
||||
}
|
||||
}
|
||||
if (matched.size() == 1) {
|
||||
votes.add(matched.get(0), weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void maybeVoteConstant(
|
||||
HintVotes votes,
|
||||
String candidate,
|
||||
Set<String> configuredConstants,
|
||||
int weight) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String normalized = candidate.toUpperCase(Locale.ROOT);
|
||||
if (configuredConstants.contains(normalized)) {
|
||||
votes.add(normalized, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> filterPolyToConstant(
|
||||
List<String> poly,
|
||||
String constant,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
List<String> narrowed = new ArrayList<>();
|
||||
for (String candidate : poly) {
|
||||
if (candidate == null || candidate.startsWith("<SYMBOLIC:") || candidate.startsWith("ENUM_SET:")) {
|
||||
continue;
|
||||
}
|
||||
if (!constant.equalsIgnoreCase(constantName(candidate))) {
|
||||
continue;
|
||||
}
|
||||
String canonical = candidate.contains(".")
|
||||
? MachineEnumCanonicalizer.canonicalizeLabel(candidate, eventTypeFqn, context)
|
||||
: eventTypeFqn + "." + constant;
|
||||
if (!narrowed.contains(canonical)) {
|
||||
narrowed.add(canonical);
|
||||
}
|
||||
}
|
||||
return narrowed;
|
||||
}
|
||||
|
||||
private static String normalizeToken(String token) {
|
||||
return token.replace('-', '_').toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static List<String> splitCamelCase(String identifier) {
|
||||
if (identifier == null || identifier.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> tokens = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
for (int i = 0; i < identifier.length(); i++) {
|
||||
char ch = identifier.charAt(i);
|
||||
if (Character.isUpperCase(ch) && !current.isEmpty()) {
|
||||
tokens.add(current.toString());
|
||||
current.setLength(0);
|
||||
}
|
||||
current.append(ch);
|
||||
}
|
||||
if (!current.isEmpty()) {
|
||||
tokens.add(current.toString());
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static Set<String> configuredEventConstants(
|
||||
List<Transition> transitions,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
Set<String> constants = new LinkedHashSet<>();
|
||||
for (String event : MachineEnumCanonicalizer.polymorphicEventsFromTransitions(
|
||||
transitions, eventTypeFqn, context)) {
|
||||
constants.add(constantName(event).toUpperCase(Locale.ROOT));
|
||||
}
|
||||
return constants;
|
||||
}
|
||||
|
||||
private static String constantName(String ref) {
|
||||
int dot = ref.lastIndexOf('.');
|
||||
return (dot >= 0 ? ref.substring(dot + 1) : ref).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static final class HintVotes {
|
||||
private final Map<String, Integer> scores = new HashMap<>();
|
||||
private final Map<String, Integer> maxWeight = new HashMap<>();
|
||||
|
||||
void add(String constant, int weight) {
|
||||
if (constant == null || constant.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String key = constant.toUpperCase(Locale.ROOT);
|
||||
scores.merge(key, weight, Integer::sum);
|
||||
maxWeight.merge(key, weight, Math::max);
|
||||
}
|
||||
|
||||
String choose(Set<String> configured) {
|
||||
String strongUnique = uniqueAboveWeight(configured, STRONG);
|
||||
if (strongUnique != null) {
|
||||
return strongUnique;
|
||||
}
|
||||
String mediumUnique = uniqueAboveWeight(configured, MEDIUM);
|
||||
if (mediumUnique != null) {
|
||||
return mediumUnique;
|
||||
}
|
||||
String best = null;
|
||||
int bestScore = 0;
|
||||
for (String constant : configured) {
|
||||
int score = scores.getOrDefault(constant, 0);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = constant;
|
||||
}
|
||||
}
|
||||
if (best == null || bestScore < MEDIUM) {
|
||||
return null;
|
||||
}
|
||||
final int winningScore = bestScore;
|
||||
List<String> tied = new ArrayList<>();
|
||||
for (String constant : configured) {
|
||||
if (scores.getOrDefault(constant, 0) == winningScore) {
|
||||
tied.add(constant);
|
||||
}
|
||||
}
|
||||
if (tied.size() == 1) {
|
||||
return best;
|
||||
}
|
||||
String maxWeightWinner = null;
|
||||
int bestMaxWeight = 0;
|
||||
for (String constant : tied) {
|
||||
int weight = maxWeight.getOrDefault(constant, 0);
|
||||
if (weight > bestMaxWeight) {
|
||||
bestMaxWeight = weight;
|
||||
maxWeightWinner = constant;
|
||||
} else if (weight == bestMaxWeight && weight > 0) {
|
||||
maxWeightWinner = null;
|
||||
}
|
||||
}
|
||||
return maxWeightWinner;
|
||||
}
|
||||
|
||||
private String uniqueAboveWeight(Set<String> configured, int minWeight) {
|
||||
String chosen = null;
|
||||
for (String constant : configured) {
|
||||
if (maxWeight.getOrDefault(constant, 0) >= minWeight) {
|
||||
if (chosen != null) {
|
||||
return null;
|
||||
}
|
||||
chosen = constant;
|
||||
}
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,10 @@ public class EntryPointEnricher implements AnalysisEnricher {
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
log.info("Enriching {} with entry points", result.getName());
|
||||
|
||||
// Keep all entry points; machine scoping is applied after call-chain resolution
|
||||
// when source evidence (trigger type FQNs, literals, constraints) is available.
|
||||
List<EntryPoint> entryPoints = intelligence.findEntryPoints();
|
||||
|
||||
|
||||
result.addMetadata(CodebaseMetadata.builder()
|
||||
.entryPoints(entryPoints)
|
||||
.build());
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.GenericEventDetector;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.JdtCallGraphEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Scopes REST entry points to a machine using call-graph reachability and trigger type evidence,
|
||||
* not URL path segment guessing.
|
||||
*/
|
||||
public final class EntryPointScopeResolver {
|
||||
|
||||
private static final BeanResolutionEngine ROUTING = new HeuristicBeanResolutionEngine();
|
||||
private static final String TRIGGERS_CACHE = "entryPointScope.triggers";
|
||||
|
||||
public enum Affinity {
|
||||
FOR_MACHINE,
|
||||
AGAINST_MACHINE,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
private EntryPointScopeResolver() {
|
||||
}
|
||||
|
||||
public static List<EntryPoint> scopeFromCallChains(List<CallChain> chains, List<EntryPoint> allEntryPoints) {
|
||||
Map<String, EntryPoint> scoped = new LinkedHashMap<>();
|
||||
if (chains != null) {
|
||||
for (CallChain chain : chains) {
|
||||
EntryPoint entryPoint = chain.getEntryPoint();
|
||||
if (entryPoint != null) {
|
||||
scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allEntryPoints != null) {
|
||||
for (EntryPoint entryPoint : allEntryPoints) {
|
||||
if (hasPathVariablePlaceholder(entryPoint)) {
|
||||
scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(scoped.values());
|
||||
}
|
||||
|
||||
public static Affinity resolveAffinity(EntryPoint entryPoint, String machineName, CodebaseContext context) {
|
||||
if (entryPoint == null || machineName == null) {
|
||||
return Affinity.UNKNOWN;
|
||||
}
|
||||
if (entryPoint.getClassName() == null || entryPoint.getMethodName() == null) {
|
||||
return Affinity.UNKNOWN;
|
||||
}
|
||||
if (context == null || context.getCompilationUnits().isEmpty()) {
|
||||
return Affinity.UNKNOWN;
|
||||
}
|
||||
|
||||
CallGraphEngine engine = createCallGraphEngine(context);
|
||||
List<CallChain> chains = engine.findChains(List.of(entryPoint), getOrCollectTriggers(context));
|
||||
if (chains.isEmpty()) {
|
||||
return Affinity.UNKNOWN;
|
||||
}
|
||||
|
||||
boolean matched = false;
|
||||
boolean mismatched = false;
|
||||
for (CallChain chain : chains) {
|
||||
TriggerPoint trigger = chain.getTriggerPoint();
|
||||
if (trigger == null || !hasTypeEvidence(trigger)) {
|
||||
continue;
|
||||
}
|
||||
if (ROUTING.isRoutedToCorrectMachine(chain, machineName, context)) {
|
||||
matched = true;
|
||||
} else {
|
||||
mismatched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
return Affinity.FOR_MACHINE;
|
||||
}
|
||||
if (mismatched) {
|
||||
return Affinity.AGAINST_MACHINE;
|
||||
}
|
||||
return Affinity.UNKNOWN;
|
||||
}
|
||||
|
||||
public static boolean hasPathVariablePlaceholder(EntryPoint entryPoint) {
|
||||
if (entryPoint == null) {
|
||||
return false;
|
||||
}
|
||||
String name = entryPoint.getName();
|
||||
if (name != null && name.contains("{")) {
|
||||
return true;
|
||||
}
|
||||
Map<String, String> metadata = entryPoint.getMetadata();
|
||||
if (metadata != null) {
|
||||
String path = metadata.get("path");
|
||||
return path != null && path.contains("{");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasTypeEvidence(TriggerPoint trigger) {
|
||||
if (trigger.getEventTypeFqn() != null || trigger.getStateTypeFqn() != null) {
|
||||
return true;
|
||||
}
|
||||
return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
|
||||
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<TriggerPoint> getOrCollectTriggers(CodebaseContext context) {
|
||||
Object cached = context.getCache().get(TRIGGERS_CACHE);
|
||||
if (cached instanceof List<?> list) {
|
||||
return (List<TriggerPoint>) list;
|
||||
}
|
||||
List<TriggerPoint> triggers = new ArrayList<>();
|
||||
GenericEventDetector detector =
|
||||
new GenericEventDetector(context, context.getConstantResolver(), context.getLibraryHints());
|
||||
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||
triggers.addAll(detector.detect(cu));
|
||||
}
|
||||
context.getCache().put(TRIGGERS_CACHE, triggers);
|
||||
return triggers;
|
||||
}
|
||||
|
||||
private static CallGraphEngine createCallGraphEngine(CodebaseContext context) {
|
||||
SpringBeanRegistry registry = new SpringBeanRegistry();
|
||||
SpringContextScanner scanner = new SpringContextScanner(registry);
|
||||
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||
cu.accept(scanner);
|
||||
}
|
||||
SpringDependencyResolver dependencyResolver = new SpringDependencyResolver(registry);
|
||||
InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(dependencyResolver);
|
||||
return new JdtCallGraphEngine(context, injectionAnalyzer);
|
||||
}
|
||||
|
||||
private static String entryPointKey(EntryPoint entryPoint) {
|
||||
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
|
||||
return entryPoint.getName();
|
||||
}
|
||||
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Extracts normalized domain keys from state machine configuration class names
|
||||
* (e.g. {@code StandardOrderStateMachineConfiguration} → {@code ORDER}).
|
||||
*/
|
||||
public final class MachineDomainKeys {
|
||||
|
||||
private MachineDomainKeys() {
|
||||
}
|
||||
|
||||
public static String extractMachineDomainKey(String machineName) {
|
||||
String simple = machineName.substring(machineName.lastIndexOf('.') + 1);
|
||||
String upper = simple.toUpperCase(Locale.ROOT);
|
||||
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) {
|
||||
int idx = upper.indexOf(marker);
|
||||
if (idx > 0) {
|
||||
return normalizeDomainKey(upper.substring(0, idx));
|
||||
}
|
||||
}
|
||||
int stateIdx = upper.indexOf("STATE");
|
||||
if (stateIdx > 0) {
|
||||
return normalizeDomainKey(upper.substring(0, stateIdx));
|
||||
}
|
||||
return normalizeDomainKey(upper);
|
||||
}
|
||||
|
||||
private static String normalizeDomainKey(String domain) {
|
||||
if (domain == null || domain.isEmpty()) {
|
||||
return domain;
|
||||
}
|
||||
String upper = domain.toUpperCase(Locale.ROOT);
|
||||
if (upper.startsWith("STANDARD") && upper.length() > "STANDARD".length()) {
|
||||
return upper.substring("STANDARD".length());
|
||||
}
|
||||
return upper;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return filterTriggersForMachine(triggers, machineName, context, null);
|
||||
}
|
||||
|
||||
public static List<TriggerPoint> filterTriggersForMachine(
|
||||
List<TriggerPoint> triggers,
|
||||
String machineName,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
if (triggers == null || machineName == null) {
|
||||
return triggers == null ? List.of() : triggers;
|
||||
}
|
||||
List<TriggerPoint> filtered = new ArrayList<>();
|
||||
for (TriggerPoint trigger : triggers) {
|
||||
if (isTriggerForMachine(trigger, machineName, context, machineTransitions)) {
|
||||
filtered.add(trigger);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
public static List<CallChain> filterCallChainsForMachine(
|
||||
List<CallChain> chains, String machineName, CodebaseContext context) {
|
||||
return filterCallChainsForMachine(chains, machineName, context, null);
|
||||
}
|
||||
|
||||
public static List<CallChain> filterCallChainsForMachine(
|
||||
List<CallChain> chains,
|
||||
String machineName,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
if (chains == null || machineName == null) {
|
||||
return chains == null ? List.of() : chains;
|
||||
}
|
||||
List<CallChain> filtered = new ArrayList<>();
|
||||
String machineEventTypeFqn = resolveMachineEventTypeFqn(machineName, context, machineTransitions);
|
||||
for (CallChain chain : chains) {
|
||||
if (ROUTING.hasProvenMachineAffinity(chain, machineName, context, machineTransitions)) {
|
||||
filtered.add(chain);
|
||||
continue;
|
||||
}
|
||||
TriggerPoint trigger = chain.getTriggerPoint();
|
||||
if (trigger != null
|
||||
&& machineEventTypeFqn != null
|
||||
&& CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context)) {
|
||||
filtered.add(chain);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private static String resolveMachineEventTypeFqn(
|
||||
String machineName,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
if (context != null && machineName != null) {
|
||||
String[] types = StateMachineTypeResolver.resolve(machineName, context);
|
||||
if (types != null && types.length > 1 && types[1] != null && !types[1].isBlank()) {
|
||||
return types[1];
|
||||
}
|
||||
}
|
||||
if (machineTransitions == null) {
|
||||
return null;
|
||||
}
|
||||
for (Transition transition : machineTransitions) {
|
||||
Event event = transition.getEvent();
|
||||
if (event == null) {
|
||||
continue;
|
||||
}
|
||||
String fullIdentifier = event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
|
||||
if (fullIdentifier != null && fullIdentifier.contains(".")) {
|
||||
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<EntryPoint> filterEntryPointsForMachine(
|
||||
List<EntryPoint> entryPoints, String machineName, CodebaseContext context) {
|
||||
if (entryPoints == null || machineName == null) {
|
||||
return entryPoints == null ? List.of() : entryPoints;
|
||||
}
|
||||
List<EntryPoint> filtered = new ArrayList<>();
|
||||
for (EntryPoint entryPoint : entryPoints) {
|
||||
if (isEntryPointForMachine(entryPoint, machineName, context)) {
|
||||
filtered.add(entryPoint);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private static boolean isEntryPointForMachine(
|
||||
EntryPoint entryPoint, String machineName, CodebaseContext context) {
|
||||
if (entryPoint == null) {
|
||||
return false;
|
||||
}
|
||||
if (EntryPointScopeResolver.hasPathVariablePlaceholder(entryPoint)) {
|
||||
EntryPointScopeResolver.Affinity affinity =
|
||||
EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
|
||||
return affinity != EntryPointScopeResolver.Affinity.AGAINST_MACHINE;
|
||||
}
|
||||
|
||||
EntryPointScopeResolver.Affinity affinity =
|
||||
EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context);
|
||||
if (affinity == EntryPointScopeResolver.Affinity.FOR_MACHINE) {
|
||||
return true;
|
||||
}
|
||||
if (affinity == EntryPointScopeResolver.Affinity.AGAINST_MACHINE) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isTriggerForMachine(
|
||||
TriggerPoint trigger, String machineName, CodebaseContext context, List<Transition> machineTransitions) {
|
||||
if (trigger == null) {
|
||||
return false;
|
||||
}
|
||||
CallChain probe = CallChain.builder().triggerPoint(trigger).methodChain(List.of()).build();
|
||||
return ROUTING.hasProvenMachineAffinity(probe, machineName, context, machineTransitions);
|
||||
}
|
||||
}
|
||||
@@ -3,26 +3,35 @@ 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.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.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.stream.Collectors;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
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;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
|
||||
public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
|
||||
private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
|
||||
private final EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine();
|
||||
|
||||
@Override
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
@@ -30,8 +39,25 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
return;
|
||||
}
|
||||
|
||||
EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine(context);
|
||||
|
||||
List<CallChain> updatedChains = new ArrayList<>();
|
||||
List<Transition> stateMachineTransitions = result.getTransitions();
|
||||
StateMachineTypeResolver.MachineTypes machineTypes =
|
||||
JsonExportContextFactory.resolveMachineTypes(
|
||||
result.getName(),
|
||||
result.getStateTypeFqn(),
|
||||
result.getEventTypeFqn(),
|
||||
context);
|
||||
StateMachineTypeResolver.MachineTypes effectiveMachineTypes =
|
||||
CallChainLinkPolicy.resolveEffectiveMachineTypes(
|
||||
result.getName(),
|
||||
result.getStateTypeFqn(),
|
||||
result.getEventTypeFqn(),
|
||||
machineTypes,
|
||||
stateMachineTransitions,
|
||||
context);
|
||||
String effectiveEventTypeFqn = effectiveMachineTypes.eventTypeFqn();
|
||||
|
||||
for (CallChain chain : result.getMetadata().getCallChains()) {
|
||||
TriggerPoint tp = chain.getTriggerPoint();
|
||||
@@ -40,38 +66,128 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
continue;
|
||||
}
|
||||
|
||||
String triggerEvent = simplify(tp.getEvent());
|
||||
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null;
|
||||
tp = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints(
|
||||
tp, effectiveMachineTypes, context, chain.getEntryPoint());
|
||||
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
tp, effectiveMachineTypes, context, stateMachineTransitions, true);
|
||||
tp = CallChainPolyNarrower.narrowForCallChain(
|
||||
tp, chain, effectiveMachineTypes, result.getEventTypeFqn(), stateMachineTransitions, context);
|
||||
tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, effectiveMachineTypes, context);
|
||||
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
|
||||
tp = tp.toBuilder()
|
||||
.eventTypeFqn(tp.getEventTypeFqn() != null
|
||||
? tp.getEventTypeFqn()
|
||||
: effectiveEventTypeFqn)
|
||||
.stateTypeFqn(tp.getStateTypeFqn() != null
|
||||
? tp.getStateTypeFqn()
|
||||
: effectiveMachineTypes.stateTypeFqn())
|
||||
.build();
|
||||
|
||||
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
|
||||
updatedChains.add(chain);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tp.isExternal() && isUnresolvedGenericRestEndpoint(chain.getEntryPoint())) {
|
||||
updatedChains.add(chain.toBuilder()
|
||||
.triggerPoint(tp)
|
||||
.matchedTransitions(null)
|
||||
.linkResolution(LinkResolution.UNRESOLVED_EXTERNAL)
|
||||
.build());
|
||||
continue;
|
||||
}
|
||||
|
||||
String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null;
|
||||
if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
|
||||
tp,
|
||||
effectiveEventTypeFqn,
|
||||
context)) {
|
||||
updatedChains.add(chain.toBuilder()
|
||||
.triggerPoint(tp)
|
||||
.matchedTransitions(null)
|
||||
.linkResolution(LinkResolution.AMBIGUOUS_WIDEN)
|
||||
.build());
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean ambiguousSource = false;
|
||||
|
||||
if (triggerSource == null) {
|
||||
Map<String, Set<String>> sourcesByEvent = new LinkedHashMap<>();
|
||||
Map<String, Set<String>> stateTypesByEvent = new LinkedHashMap<>();
|
||||
Map<String, String> canonicalSourceBySimplified = new LinkedHashMap<>();
|
||||
for (Transition t : stateMachineTransitions) {
|
||||
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)
|
||||
&& isRoutedToCorrectMachine(
|
||||
chain, result.getName(), context, stateMachineTransitions, tp,
|
||||
effectiveEventTypeFqn)
|
||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||
String smEventForLink = canonicalEvent(t.getEvent());
|
||||
for (State smSourceState : t.getSourceStates()) {
|
||||
String smSourceForLink = canonicalState(smSourceState);
|
||||
String smSource = simplifySourceState(smSourceForLink);
|
||||
sourcesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
|
||||
.add(smSource);
|
||||
stateTypesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
|
||||
.add(extractStateEnumType(smSourceForLink));
|
||||
canonicalSourceBySimplified.putIfAbsent(smSource, smSourceForLink);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean crossStateTypeConflict = stateTypesByEvent.values().stream()
|
||||
.anyMatch(types -> types.size() > 1);
|
||||
if (crossStateTypeConflict) {
|
||||
ambiguousSource = true;
|
||||
} else if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) {
|
||||
ambiguousSource = !CallChainLinkPolicy.isEndpointNarrowPolyEvidence(
|
||||
tp,
|
||||
effectiveEventTypeFqn,
|
||||
context);
|
||||
} else {
|
||||
Set<String> allDistinctSources = new LinkedHashSet<>();
|
||||
sourcesByEvent.values().forEach(allDistinctSources::addAll);
|
||||
if (allDistinctSources.size() == 1) {
|
||||
String inferredSimplifiedSource = allDistinctSources.iterator().next();
|
||||
String inferredCanonicalSource = canonicalSourceBySimplified.get(inferredSimplifiedSource);
|
||||
tp = tp.toBuilder().sourceState(inferredCanonicalSource).build();
|
||||
triggerSource = inferredSimplifiedSource;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<MatchedTransition> matched = new ArrayList<>();
|
||||
|
||||
if (!ambiguousSource) {
|
||||
for (Transition t : stateMachineTransitions) {
|
||||
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
|
||||
String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName();
|
||||
// Event matches
|
||||
String smEventForLink = canonicalEvent(t.getEvent());
|
||||
for (State smSourceState : t.getSourceStates()) {
|
||||
String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
|
||||
String smSource = simplify(smSourceRaw);
|
||||
String smSourceForLink = canonicalState(smSourceState);
|
||||
String smSource = simplifySourceState(smSourceForLink);
|
||||
if (triggerSource == null || triggerSource.equals(smSource)) {
|
||||
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
|
||||
MatchedTransition mt = MatchedTransition.builder()
|
||||
.sourceState(smSourceRaw)
|
||||
.targetState(smSourceRaw)
|
||||
.event(smEventRaw)
|
||||
.sourceState(smSourceForLink)
|
||||
.targetState(smSourceForLink)
|
||||
.event(smEventForLink)
|
||||
.build();
|
||||
if (isRoutedToCorrectMachine(chain, result.getName(), context)
|
||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||
if (isRoutedToCorrectMachine(
|
||||
chain, result.getName(), context, stateMachineTransitions, tp,
|
||||
effectiveEventTypeFqn)
|
||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||
matched.add(mt);
|
||||
}
|
||||
} else {
|
||||
for (State smTargetState : t.getTargetStates()) {
|
||||
String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName();
|
||||
String targetForLink = canonicalState(smTargetState);
|
||||
MatchedTransition mt = MatchedTransition.builder()
|
||||
.sourceState(smSourceRaw)
|
||||
.targetState(targetRaw)
|
||||
.event(smEventRaw)
|
||||
.sourceState(smSourceForLink)
|
||||
.targetState(targetForLink)
|
||||
.event(smEventForLink)
|
||||
.build();
|
||||
if (isRoutedToCorrectMachine(chain, result.getName(), context)
|
||||
if (isRoutedToCorrectMachine(
|
||||
chain, result.getName(), context, stateMachineTransitions, tp,
|
||||
effectiveEventTypeFqn)
|
||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||
matched.add(mt);
|
||||
}
|
||||
@@ -81,20 +197,46 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ambiguousSource) {
|
||||
tp = tp.toBuilder().ambiguous(true).build();
|
||||
}
|
||||
|
||||
LinkResolution linkResolution = CallChainLinkPolicy.resolveLinkResolution(
|
||||
tp,
|
||||
matched,
|
||||
ambiguousSource,
|
||||
effectiveEventTypeFqn,
|
||||
context);
|
||||
|
||||
if (!matched.isEmpty()) {
|
||||
CallChain newChain = chain.toBuilder().matchedTransitions(matched).build();
|
||||
CallChain newChain = chain.toBuilder()
|
||||
.triggerPoint(tp)
|
||||
.matchedTransitions(matched)
|
||||
.linkResolution(linkResolution)
|
||||
.build();
|
||||
updatedChains.add(newChain);
|
||||
} else {
|
||||
updatedChains.add(chain);
|
||||
updatedChains.add(chain.toBuilder()
|
||||
.triggerPoint(tp)
|
||||
.matchedTransitions(null)
|
||||
.linkResolution(linkResolution)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
// Update the metadata with the new call chains
|
||||
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
|
||||
result.getMetadata().getTriggers(), result.getName(), context, stateMachineTransitions);
|
||||
List<CallChain> scopedChains = MachineScopeFilter.filterCallChainsForMachine(
|
||||
updatedChains, result.getName(), context, stateMachineTransitions);
|
||||
List<EntryPoint> scopedEntryPoints = EntryPointScopeResolver.scopeFromCallChains(
|
||||
scopedChains, result.getMetadata().getEntryPoints());
|
||||
|
||||
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
|
||||
.triggers(result.getMetadata().getTriggers())
|
||||
.entryPoints(result.getMetadata().getEntryPoints())
|
||||
.callChains(updatedChains)
|
||||
.triggers(scopedTriggers)
|
||||
.entryPoints(scopedEntryPoints)
|
||||
.callChains(scopedChains)
|
||||
.properties(result.getMetadata().getProperties())
|
||||
.build();
|
||||
|
||||
@@ -102,154 +244,107 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
}
|
||||
|
||||
|
||||
private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
|
||||
return routingEngine.isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||
private boolean isRoutedToCorrectMachine(
|
||||
CallChain chain,
|
||||
String currentMachineName,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions,
|
||||
TriggerPoint triggerPoint,
|
||||
String machineEventTypeFqn) {
|
||||
if (triggerPoint != null
|
||||
&& machineEventTypeFqn != null
|
||||
&& CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(triggerPoint, machineEventTypeFqn, context)) {
|
||||
return true;
|
||||
}
|
||||
return routingEngine.hasProvenMachineAffinity(chain, currentMachineName, context, machineTransitions);
|
||||
}
|
||||
|
||||
private boolean isRoutedToCorrectMachine(
|
||||
CallChain chain, String currentMachineName, CodebaseContext context, List<Transition> machineTransitions) {
|
||||
return isRoutedToCorrectMachine(
|
||||
chain, currentMachineName, context, machineTransitions, chain.getTriggerPoint(), null);
|
||||
}
|
||||
|
||||
private boolean isConstraintCompatible(String constraint, String machineName) {
|
||||
if (constraint == null || machineName == null) return true;
|
||||
|
||||
String cleanMachine = machineName.substring(machineName.lastIndexOf('.') + 1).toUpperCase();
|
||||
if (cleanMachine.endsWith("STATEMACHINECONFIGURATION")) {
|
||||
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "STATEMACHINECONFIGURATION".length());
|
||||
}
|
||||
if (cleanMachine.endsWith("STATEMACHINE")) {
|
||||
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "STATEMACHINE".length());
|
||||
}
|
||||
if (cleanMachine.endsWith("CONFIG")) {
|
||||
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "CONFIG".length());
|
||||
}
|
||||
|
||||
String expr = constraint;
|
||||
java.util.regex.Pattern p = java.util.regex.Pattern.compile("[\"']([a-zA-Z0-9_-]+)[\"']");
|
||||
java.util.regex.Matcher matcher = p.matcher(constraint);
|
||||
java.util.Set<String> literals = new java.util.HashSet<>();
|
||||
while (matcher.find()) {
|
||||
literals.add(matcher.group(1));
|
||||
}
|
||||
|
||||
for (String m : literals) {
|
||||
boolean isCurrent = m.equalsIgnoreCase(cleanMachine);
|
||||
String replacement = isCurrent ? "true" : "false";
|
||||
|
||||
String regex1 = "(?i)[\"']?" + m + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\([^)]*\\)";
|
||||
String regex2 = "(?i)[a-zA-Z0-9._]+\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?" + m + "[\"']?\\s*\\)";
|
||||
String regex3 = "(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + m + "[\"']?";
|
||||
String regex4 = "(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + m + "[\"']?";
|
||||
|
||||
expr = expr.replaceAll(regex1, replacement);
|
||||
expr = expr.replaceAll(regex2, replacement);
|
||||
expr = expr.replaceAll(regex3, replacement);
|
||||
expr = expr.replaceAll(regex4, isCurrent ? "false" : "true");
|
||||
}
|
||||
|
||||
try {
|
||||
return BooleanEvaluator.evaluate(expr);
|
||||
} catch (Exception e) {
|
||||
return !expr.contains("false");
|
||||
}
|
||||
}
|
||||
|
||||
private static class BooleanEvaluator {
|
||||
public static boolean evaluate(String expr) {
|
||||
expr = expr.replaceAll("\\s+", "");
|
||||
return parseExpr(expr);
|
||||
}
|
||||
|
||||
private static boolean parseExpr(String s) {
|
||||
if (s.isEmpty()) return true;
|
||||
|
||||
int parenDepth = 0;
|
||||
for (int i = s.length() - 1; i >= 0; i--) {
|
||||
char c = s.charAt(i);
|
||||
if (c == ')') parenDepth++;
|
||||
else if (c == '(') parenDepth--;
|
||||
else if (parenDepth == 0 && i > 0 && s.charAt(i) == '|' && s.charAt(i - 1) == '|') {
|
||||
return parseExpr(s.substring(0, i - 1)) || parseExpr(s.substring(i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
parenDepth = 0;
|
||||
for (int i = s.length() - 1; i >= 0; i--) {
|
||||
char c = s.charAt(i);
|
||||
if (c == ')') parenDepth++;
|
||||
else if (c == '(') parenDepth--;
|
||||
else if (parenDepth == 0 && i > 0 && s.charAt(i) == '&' && s.charAt(i - 1) == '&') {
|
||||
return parseExpr(s.substring(0, i - 1)) && parseExpr(s.substring(i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
if (s.startsWith("!")) {
|
||||
return !parseExpr(s.substring(1));
|
||||
}
|
||||
|
||||
if (s.startsWith("(") && s.endsWith(")")) {
|
||||
return parseExpr(s.substring(1, s.length() - 1));
|
||||
}
|
||||
|
||||
if (s.equalsIgnoreCase("true")) return true;
|
||||
if (s.equalsIgnoreCase("false")) return false;
|
||||
|
||||
if (constraint == null || machineName == null) {
|
||||
return true;
|
||||
}
|
||||
String machineConstraint = stripEventBindingClauses(constraint);
|
||||
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
machineConstraint, MachineDomainKeys.extractMachineDomainKey(machineName));
|
||||
}
|
||||
|
||||
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>();
|
||||
private final java.util.Map<java.util.Map.Entry<String, String>, Boolean> guardedCache = new java.util.HashMap<>();
|
||||
private static final java.util.regex.Pattern SUFFIX_PATTERN = java.util.regex.Pattern.compile("(?i)(.+)(Event|Action|Transition|Command)(s)?$");
|
||||
private static final java.util.regex.Pattern FQN_PATTERN = java.util.regex.Pattern.compile("^.*\\.([A-Z0-9_]+)$");
|
||||
|
||||
private String simplify(String name) {
|
||||
if (name == null) return null;
|
||||
if (simplifyCache.containsKey(name)) return simplifyCache.get(name);
|
||||
|
||||
// Strip common suffixes
|
||||
String simplified = SUFFIX_PATTERN.matcher(name).replaceAll("$1");
|
||||
if (simplified.isEmpty()) {
|
||||
simplified = name;
|
||||
private static String stripEventBindingClauses(String constraint) {
|
||||
if (constraint == null || constraint.isBlank()) {
|
||||
return constraint;
|
||||
}
|
||||
// Simplify full identifiers to just the last part (enum name)
|
||||
simplified = FQN_PATTERN.matcher(simplified).replaceAll("$1");
|
||||
|
||||
simplifyCache.put(name, simplified);
|
||||
String stripped = constraint.replaceAll("\"[^\"]+\"\\.equalsIgnoreCase\\(event\\)", "true");
|
||||
stripped = stripped.replaceAll("&&\\s*&&+", "&&");
|
||||
stripped = stripped.replaceAll("^\\s*&&\\s*", "");
|
||||
stripped = stripped.replaceAll("\\s*&&\\s*$", "");
|
||||
return stripped.trim();
|
||||
}
|
||||
|
||||
private String extractStateEnumType(String stateIdentifier) {
|
||||
if (stateIdentifier == null || stateIdentifier.isBlank()) {
|
||||
return stateIdentifier;
|
||||
}
|
||||
int lastDot = stateIdentifier.lastIndexOf('.');
|
||||
if (lastDot <= 0) {
|
||||
return stateIdentifier;
|
||||
}
|
||||
return stateIdentifier.substring(0, lastDot);
|
||||
}
|
||||
|
||||
private final java.util.Map<String, String> simplifySourceCache = new java.util.HashMap<>();
|
||||
|
||||
private String simplifySourceState(String name) {
|
||||
if (name == null) return null;
|
||||
if (simplifySourceCache.containsKey(name)) return simplifySourceCache.get(name);
|
||||
|
||||
// For source states we must preserve enum type context to avoid collisions between
|
||||
// different state enums that share a constant name (e.g. OrderState.NEW vs InvoiceState.NEW).
|
||||
// Use EnumFormat.fn-like suffix (Type.CONST) when possible.
|
||||
String simplified = name;
|
||||
int lastDot = name.lastIndexOf('.');
|
||||
if (lastDot > 0) {
|
||||
int prevDot = name.lastIndexOf('.', lastDot - 1);
|
||||
if (prevDot > 0) {
|
||||
simplified = name.substring(prevDot + 1);
|
||||
}
|
||||
}
|
||||
|
||||
simplifySourceCache.put(name, simplified);
|
||||
return simplified;
|
||||
}
|
||||
|
||||
private boolean isGuardedContains(String smEvent, String triggerEvent) {
|
||||
if (smEvent == null || triggerEvent == null) return false;
|
||||
|
||||
// Use a strictly immutable pair to guarantee 100% collision-free caching regardless of string content
|
||||
java.util.Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(smEvent, triggerEvent);
|
||||
if (guardedCache.containsKey(cacheKey)) return guardedCache.get(cacheKey);
|
||||
|
||||
boolean result = calculateGuardedContains(smEvent, triggerEvent);
|
||||
guardedCache.put(cacheKey, result);
|
||||
return result;
|
||||
private String canonicalState(State state) {
|
||||
if (state == null) {
|
||||
return null;
|
||||
}
|
||||
return state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName();
|
||||
}
|
||||
|
||||
private boolean calculateGuardedContains(String smEvent, String triggerEvent) {
|
||||
// Only fire if it looks like a simple identifier (no spaces, no parens)
|
||||
// Prevents unresolved AST nodes (e.g. "event.getPayload()") from accidentally matching "Event"
|
||||
if (smEvent.contains(" ") || smEvent.contains("(") || smEvent.contains(")") ||
|
||||
triggerEvent.contains(" ") || triggerEvent.contains("(") || triggerEvent.contains(")")) {
|
||||
private String canonicalEvent(click.kamil.springstatemachineexporter.model.Event event) {
|
||||
if (event == null) {
|
||||
return null;
|
||||
}
|
||||
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
|
||||
}
|
||||
|
||||
private static boolean isUnresolvedGenericRestEndpoint(EntryPoint entryPoint) {
|
||||
if (entryPoint == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String shorter = smEvent.length() < triggerEvent.length() ? smEvent : triggerEvent;
|
||||
String longer = smEvent.length() >= triggerEvent.length() ? smEvent : triggerEvent;
|
||||
|
||||
shorter = shorter.toLowerCase();
|
||||
longer = longer.toLowerCase();
|
||||
|
||||
if (longer.contains(shorter)) {
|
||||
// Guard: If the matching part is very short (< 4 chars), it must be bounded by word separators
|
||||
// to avoid matching completely unrelated words (e.g., "PAY" matching "PAYMENT" or "E" matching "UPDATE")
|
||||
if (shorter.length() < 4) {
|
||||
return longer.matches(".*(^|_|-)" + java.util.regex.Pattern.quote(shorter) + "(_|-|$).*");
|
||||
}
|
||||
// For longer strings, a straight contains is usually safe enough
|
||||
if (entryPoint.getName() != null && entryPoint.getName().contains("{")) {
|
||||
return true;
|
||||
}
|
||||
if (entryPoint.getMetadata() != null) {
|
||||
Object path = entryPoint.getMetadata().get("path");
|
||||
if (path instanceof String pathValue && pathValue.contains("{")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory;
|
||||
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 = JsonExportContextFactory.resolveMachineTypes(
|
||||
result.getName(), result.getStateTypeFqn(), result.getEventTypeFqn(), 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.canonicalizeAndExpandTriggerPoint(
|
||||
trigger, machineTypes, context))
|
||||
.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;
|
||||
}
|
||||
TriggerPoint expanded = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
chain.getTriggerPoint(), machineTypes, context, result.getTransitions(), true);
|
||||
TriggerPoint canonical = expanded.getEventTypeFqn() == null && expanded.getStateTypeFqn() == null
|
||||
? MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(
|
||||
expanded, machineTypes, context)
|
||||
: MachineEnumCanonicalizer.canonicalizeTriggerPoint(
|
||||
expanded, machineTypes, context);
|
||||
return chain.toBuilder().triggerPoint(canonical).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());
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@ package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
public class TriggerEnricher implements AnalysisEnricher {
|
||||
@@ -14,11 +17,15 @@ public class TriggerEnricher implements AnalysisEnricher {
|
||||
@Override
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
log.info("Enriching {} with triggers", result.getName());
|
||||
|
||||
List<TriggerPoint> triggers = intelligence.findTriggerPoints();
|
||||
|
||||
// Initially, we just add all triggers found in the codebase to the metadata.
|
||||
// In later steps of Phase 2, we will filter them by State Machine ID.
|
||||
|
||||
StateMachineTypeResolver.MachineTypes machineTypes =
|
||||
StateMachineTypeResolver.resolveTypes(result.getName(), context);
|
||||
|
||||
List<TriggerPoint> triggers = intelligence.findTriggerPoints().stream()
|
||||
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
|
||||
.collect(Collectors.toList());
|
||||
triggers = MachineScopeFilter.filterTriggersForMachine(triggers, result.getName(), context);
|
||||
|
||||
result.addMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
|
||||
.triggers(triggers)
|
||||
.build());
|
||||
|
||||
@@ -1,81 +1,90 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import java.util.List;
|
||||
|
||||
public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
|
||||
private final CodebaseContext context;
|
||||
|
||||
public StrictFqnMatchingEngine() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
public StrictFqnMatchingEngine(CodebaseContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint) {
|
||||
if (stateMachineEvent == null || triggerPoint == null || triggerPoint.getEvent() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (triggerPoint.isExternal()) {
|
||||
return true;
|
||||
if (click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers
|
||||
.isLifecycle(triggerPoint.getEvent())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
|
||||
String rawTriggerEvent = triggerPoint.getEvent();
|
||||
|
||||
if (triggerPoint.isExternal()) {
|
||||
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null
|
||||
? triggerPoint.getPolymorphicEvents()
|
||||
: java.util.Collections.emptyList();
|
||||
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (rawTriggerEvent != null && rawTriggerEvent.startsWith("<SYMBOLIC: ") && rawTriggerEvent.endsWith(".*>")) {
|
||||
String targetType = rawTriggerEvent.substring(11, rawTriggerEvent.length() - 3);
|
||||
if (smEventRaw.startsWith(targetType + ".")) {
|
||||
return true;
|
||||
}
|
||||
if (smEventRaw.contains(".")) {
|
||||
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
||||
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
|
||||
|
||||
for (String pe : polyEvents) {
|
||||
if (matchEventNames(pe, smEventRaw, triggerPoint.getEventTypeFqn())) {
|
||||
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
|
||||
continue;
|
||||
}
|
||||
String eventTypeFqn = triggerPoint.getEventTypeFqn();
|
||||
if (pe.contains(".") && isStringTypeOrPrimitive(eventTypeFqn)) {
|
||||
eventTypeFqn = null;
|
||||
}
|
||||
if (matchEventNames(pe, smEventRaw, eventTypeFqn)) {
|
||||
return true;
|
||||
}
|
||||
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
|
||||
String targetType = pe.substring(11, pe.length() - 3);
|
||||
if (smEventRaw.startsWith(targetType + ".")) {
|
||||
return true;
|
||||
}
|
||||
if (smEventRaw.contains(".")) {
|
||||
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
||||
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
|
||||
return true;
|
||||
}
|
||||
if (!pe.contains(".") && smEventRaw.contains(".")) {
|
||||
String smConst = constantName(smEventRaw);
|
||||
if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null
|
||||
&& !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
||||
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
||||
return typesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rawTriggerEvent != null && rawTriggerEvent.contains(".valueOf(")) {
|
||||
String enumClass = rawTriggerEvent.substring(0, rawTriggerEvent.indexOf(".valueOf("));
|
||||
if (enumClass.contains(" ")) {
|
||||
enumClass = enumClass.substring(enumClass.lastIndexOf(" ") + 1);
|
||||
}
|
||||
String smClass = smEventRaw.contains(".") ? smEventRaw.substring(0, smEventRaw.lastIndexOf('.')) : smEventRaw;
|
||||
if (enumClass.equals(smClass) || enumClass.endsWith("." + smClass) || smClass.endsWith("." + enumClass)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
|
||||
if (triggerPoint.getEventTypeFqn() != null) {
|
||||
if (smEventRaw.startsWith(triggerPoint.getEventTypeFqn() + ".")) {
|
||||
return true;
|
||||
}
|
||||
if (isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn()) && !smEventRaw.contains(".")) {
|
||||
return true;
|
||||
}
|
||||
if (triggerPoint.getEventTypeFqn() != null && !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// Getter/method-call expressions (e.g. richEvent.getId(), getType()) without resolved
|
||||
// polymorphic events must not wildcard-match bare or enum transition events.
|
||||
if (rawTriggerEvent.contains(".") || rawTriggerEvent.endsWith("()")) {
|
||||
return false;
|
||||
}
|
||||
if (triggerPoint.getEventTypeFqn() != null && isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
||||
return !smEventRaw.contains(".");
|
||||
}
|
||||
return !smEventRaw.contains(".");
|
||||
}
|
||||
|
||||
return matchEventNames(rawTriggerEvent, smEventRaw, triggerPoint.getEventTypeFqn());
|
||||
@@ -85,14 +94,15 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
if (triggerEvent == null || smEvent == null) return false;
|
||||
if (triggerEvent.equals(smEvent)) return true;
|
||||
|
||||
String tConst = triggerEvent.contains(".") ? triggerEvent.substring(triggerEvent.lastIndexOf('.') + 1) : triggerEvent;
|
||||
String smConst = smEvent.contains(".") ? smEvent.substring(smEvent.lastIndexOf('.') + 1) : smEvent;
|
||||
String tConst = constantName(triggerEvent);
|
||||
String smConst = constantName(smEvent);
|
||||
|
||||
if (!tConst.equals(smConst)) return false;
|
||||
|
||||
// Prevent matching string/primitive triggers to enum transitions
|
||||
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".")) {
|
||||
return false;
|
||||
// String-typed triggers may match configured string transition events by constant name.
|
||||
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
|
||||
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
|
||||
return isStringTypeOrPrimitive(smEnumType);
|
||||
}
|
||||
|
||||
// Prevent matching enum triggers to string transitions
|
||||
@@ -100,20 +110,40 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (eventTypeFqn != null && eventTypeFqn.contains(".") && smEvent.contains(".")) {
|
||||
String fullTriggerFqn = eventTypeFqn + "." + tConst;
|
||||
return fullTriggerFqn.equals(smEvent);
|
||||
if (smEvent.contains(".")) {
|
||||
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
|
||||
|
||||
if (eventTypeFqn != null) {
|
||||
return typesMatch(eventTypeFqn, smEnumType);
|
||||
}
|
||||
|
||||
if (triggerEvent.contains(".")) {
|
||||
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
||||
return typesMatch(triggerEnumType, smEnumType);
|
||||
}
|
||||
|
||||
// Bare constant name (e.g. after Enum.name()) with no type context must not match every enum
|
||||
return false;
|
||||
}
|
||||
|
||||
if (triggerEvent.contains(".") && smEvent.contains(".")) {
|
||||
String tClass = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
||||
String smClass = smEvent.substring(0, smEvent.lastIndexOf('.'));
|
||||
String tClassSimple = tClass.contains(".") ? tClass.substring(tClass.lastIndexOf('.') + 1) : tClass;
|
||||
String smClassSimple = smClass.contains(".") ? smClass.substring(smClass.lastIndexOf('.') + 1) : smClass;
|
||||
return tClassSimple.equals(smClassSimple);
|
||||
}
|
||||
// Both sides are unqualified identifiers (string/primitive events)
|
||||
return !triggerEvent.contains(".");
|
||||
}
|
||||
|
||||
return true;
|
||||
private boolean typesMatch(String type1, String type2) {
|
||||
if (MachineEnumCanonicalizer.enumTypesMatch(type1, type2, context)) {
|
||||
return true;
|
||||
}
|
||||
if (type1 != null && type2 != null) {
|
||||
String machineFqn = type1.contains(".") ? type1 : type2;
|
||||
String candidate = type1.contains(".") ? type2 : type1;
|
||||
return MachineEnumCanonicalizer.enumTypesMatchForMachine(machineFqn, candidate, context);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String constantName(String event) {
|
||||
return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event;
|
||||
}
|
||||
|
||||
private boolean isStringTypeOrPrimitive(String typeFqn) {
|
||||
@@ -134,9 +164,13 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
}
|
||||
|
||||
if (eventStr.contains(".")) {
|
||||
String constantPart = eventStr.substring(eventStr.lastIndexOf('.') + 1);
|
||||
if (!constantPart.isEmpty() && Character.isUpperCase(constantPart.charAt(0))) {
|
||||
return false;
|
||||
}
|
||||
String firstPart = eventStr.substring(0, eventStr.indexOf('.'));
|
||||
if (!firstPart.isEmpty() && Character.isLowerCase(firstPart.charAt(0))) {
|
||||
return true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,19 @@ package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface BeanResolutionEngine {
|
||||
boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context);
|
||||
|
||||
default boolean hasProvenMachineAffinity(
|
||||
CallChain chain,
|
||||
String currentMachineName,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
return isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.Annotation;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
/**
|
||||
* Maps {@code @EnableStateMachine(name=…)} bean names to configuration class FQNs.
|
||||
*/
|
||||
final class EnableStateMachineBeanRouting {
|
||||
|
||||
private EnableStateMachineBeanRouting() {
|
||||
}
|
||||
|
||||
static Boolean matchesNamedBean(String beanName, String currentMachineConfigFqn, CodebaseContext context) {
|
||||
String normalized = stripQuotes(beanName);
|
||||
if (normalized == null || normalized.isEmpty() || context == null || currentMachineConfigFqn == null) {
|
||||
return null;
|
||||
}
|
||||
String matchingConfig = findConfigFqnByBeanName(normalized, context);
|
||||
if (matchingConfig == null) {
|
||||
return null;
|
||||
}
|
||||
return currentMachineConfigFqn.equals(matchingConfig);
|
||||
}
|
||||
|
||||
static String findConfigFqnByBeanName(String beanName, CodebaseContext context) {
|
||||
for (TypeDeclaration configType : context.findEntryPointClasses(java.util.List.of("EnableStateMachine"))) {
|
||||
String configFqn = context.getFqn(configType);
|
||||
String configuredName = extractEnableStateMachineName(configType);
|
||||
if (configuredName == null || configuredName.isEmpty()) {
|
||||
configuredName = defaultBeanName(configType.getName().getIdentifier());
|
||||
}
|
||||
if (beanName.equals(configuredName)) {
|
||||
return configFqn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String extractEnableStateMachineName(TypeDeclaration configType) {
|
||||
for (Object modifierObj : configType.modifiers()) {
|
||||
if (!(modifierObj instanceof Annotation annotation)) {
|
||||
continue;
|
||||
}
|
||||
if (!annotation.getTypeName().toString().endsWith("EnableStateMachine")) {
|
||||
continue;
|
||||
}
|
||||
String name = AstUtils.extractAnnotationMember(annotation, "name");
|
||||
if (name != null && !name.isBlank()) {
|
||||
return stripQuotes(name);
|
||||
}
|
||||
String value = AstUtils.extractAnnotationMember(annotation, "value");
|
||||
if (value != null && !value.isBlank()) {
|
||||
return stripQuotes(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String defaultBeanNameFromFqn(String fqn) {
|
||||
if (fqn == null || !fqn.contains(".")) {
|
||||
return fqn;
|
||||
}
|
||||
String simple = fqn.substring(fqn.lastIndexOf('.') + 1);
|
||||
return defaultBeanName(simple);
|
||||
}
|
||||
|
||||
private static String defaultBeanName(String simpleClassName) {
|
||||
if (simpleClassName == null || simpleClassName.isEmpty()) {
|
||||
return simpleClassName;
|
||||
}
|
||||
return Character.toLowerCase(simpleClassName.charAt(0)) + simpleClassName.substring(1);
|
||||
}
|
||||
|
||||
private static String stripQuotes(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = raw.trim();
|
||||
if (trimmed.length() >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
|
||||
return trimmed.substring(1, trimmed.length() - 1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -1,317 +1,289 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
|
||||
|
||||
private enum DistinctTypeAffinity {
|
||||
FOR_MACHINE,
|
||||
AGAINST_MACHINE,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasProvenMachineAffinity(
|
||||
CallChain chain,
|
||||
String currentMachineName,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
if (chain == null) {
|
||||
return false;
|
||||
}
|
||||
if (currentMachineName == null) {
|
||||
return isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||
}
|
||||
|
||||
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
|
||||
|
||||
String explicitTarget = evidence.explicitBeanName();
|
||||
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null) {
|
||||
Boolean namedTarget = EnableStateMachineBeanRouting.matchesNamedBean(
|
||||
explicitTarget, currentMachineName, context);
|
||||
if (namedTarget != null) {
|
||||
return namedTarget;
|
||||
}
|
||||
}
|
||||
|
||||
if (chain.getTriggerPoint() != null && context != null) {
|
||||
DistinctTypeAffinity typeAffinity =
|
||||
resolveDistinctTypeAffinity(chain.getTriggerPoint(), currentMachineName, context);
|
||||
if (typeAffinity == DistinctTypeAffinity.FOR_MACHINE) {
|
||||
return true;
|
||||
}
|
||||
if (typeAffinity == DistinctTypeAffinity.AGAINST_MACHINE) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (context == null || isSingleStateMachine(context)) {
|
||||
return isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||
}
|
||||
|
||||
TriggerPoint trigger = chain.getTriggerPoint();
|
||||
if (trigger != null
|
||||
&& SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)
|
||||
&& matchesConfiguredTransitionEvent(trigger, machineTransitions, context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isRoutedToCorrectMachine(chain, currentMachineName, context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
|
||||
// Precise FQN Type argument match
|
||||
if (chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
|
||||
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
|
||||
|
||||
String explicitTarget = evidence.explicitBeanName();
|
||||
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null && currentMachineName != null) {
|
||||
Boolean namedTarget = EnableStateMachineBeanRouting.matchesNamedBean(
|
||||
explicitTarget, currentMachineName, context);
|
||||
if (namedTarget != null) {
|
||||
return namedTarget;
|
||||
}
|
||||
}
|
||||
|
||||
boolean hasExplicitBeanTarget = explicitTarget != null && !explicitTarget.isEmpty();
|
||||
|
||||
// Precise FQN Type argument match from JDT bindings at sendEvent site
|
||||
if (!hasExplicitBeanTarget && chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
|
||||
String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn();
|
||||
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn();
|
||||
if (triggerEventFqn != null || triggerStateFqn != null) {
|
||||
String[] machineTypes = getStateMachineTypeArguments(currentMachineName, context);
|
||||
String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context);
|
||||
String machineStateFqn = machineTypes[0];
|
||||
String machineEventFqn = machineTypes[1];
|
||||
boolean matched = false;
|
||||
boolean mismatched = false;
|
||||
|
||||
if (triggerEventFqn != null && machineEventFqn != null) {
|
||||
if (eraseGenerics(triggerEventFqn).equals(eraseGenerics(machineEventFqn))) {
|
||||
return true; // Match!
|
||||
}
|
||||
if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
|
||||
return false; // Mismatch!
|
||||
if (typesEquivalent(triggerEventFqn, machineEventFqn)) {
|
||||
matched = true;
|
||||
} else if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
|
||||
mismatched = true;
|
||||
} else if (isSingleStateMachine(context)
|
||||
&& (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) {
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
if (triggerStateFqn != null && machineStateFqn != null) {
|
||||
if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) {
|
||||
return true; // Match!
|
||||
}
|
||||
if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
|
||||
return false; // Mismatch!
|
||||
if (typesEquivalent(triggerStateFqn, machineStateFqn)) {
|
||||
matched = true;
|
||||
} else if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
|
||||
mismatched = true;
|
||||
} else if (isSingleStateMachine(context)
|
||||
&& (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) {
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String targetVar = chain.getContextMachineId();
|
||||
if (targetVar == null && chain.getTriggerPoint() != null) {
|
||||
targetVar = chain.getTriggerPoint().getStateMachineId();
|
||||
}
|
||||
|
||||
String simplifiedMachineName = currentMachineName != null ? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase() : "";
|
||||
|
||||
if (targetVar != null && !targetVar.isEmpty()) {
|
||||
targetVar = targetVar.toLowerCase();
|
||||
if (targetVar.endsWith("statemachine")) {
|
||||
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
|
||||
if (!prefix.isEmpty()) {
|
||||
if (simplifiedMachineName.contains(prefix)) {
|
||||
return true; // Explicit positive match
|
||||
} else {
|
||||
return false; // Explicit negative match
|
||||
if (matched) {
|
||||
if (!isAmbiguousSharedGenericMatch(
|
||||
triggerEventFqn, triggerStateFqn, machineStateFqn, machineEventFqn)
|
||||
|| isSingleStateMachine(context)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (simplifiedMachineName.contains(targetVar)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) {
|
||||
String smPackage = getPackageName(currentMachineName);
|
||||
String smSimple = getSimpleClassName(currentMachineName);
|
||||
String smPrefix = getFirstCamelCaseWord(smSimple);
|
||||
|
||||
boolean hasPositiveMatch = false;
|
||||
boolean hasStrongMismatch = false;
|
||||
|
||||
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
|
||||
String chainClass = chain.getTriggerPoint().getClassName();
|
||||
String chainPackage = getPackageName(chainClass);
|
||||
String chainSimple = getSimpleClassName(chainClass);
|
||||
String chainPrefix = getFirstCamelCaseWord(chainSimple);
|
||||
|
||||
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
if (mismatched) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (String method : chain.getMethodChain()) {
|
||||
String chainClass = getClassNameOnly(method);
|
||||
String chainPackage = getPackageName(chainClass);
|
||||
String chainSimple = getSimpleClassName(chainClass);
|
||||
String chainPrefix = getFirstCamelCaseWord(chainSimple);
|
||||
|
||||
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
|
||||
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
|
||||
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix, smSimple, chainSimple)) {
|
||||
hasStrongMismatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPositiveMatch) {
|
||||
return true;
|
||||
}
|
||||
if (hasStrongMismatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return 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.startsWith(chainPackage) || chainPackage.startsWith(smPackage)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Find deepest common package root
|
||||
String[] p1 = smPackage.split("\\.");
|
||||
String[] p2 = chainPackage.split("\\.");
|
||||
int matchIdx = -1;
|
||||
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
|
||||
if (p1[i].equals(p2[i])) {
|
||||
matchIdx = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the state machine's prefix is part of the shared common package,
|
||||
// then the state machine represents the parent domain of the caller.
|
||||
if (smPrefix != null && matchIdx >= 0) {
|
||||
String lowerPrefix = smPrefix.toLowerCase();
|
||||
for (int i = 0; i <= matchIdx; i++) {
|
||||
if (p1[i].toLowerCase().contains(lowerPrefix) || lowerPrefix.contains(p1[i].toLowerCase())) {
|
||||
return true;
|
||||
// Fail closed only when both trigger and machine generic types were resolved but
|
||||
// could not be reconciled. When machine config AST does not expose type args (stub
|
||||
// config classes in tests, generated configs), fall through to single-SM / package routing.
|
||||
boolean machineTypesKnown = machineEventFqn != null || machineStateFqn != null;
|
||||
if (machineTypesKnown) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDomainMismatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix, String smSimple, String chainSimple) {
|
||||
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
|
||||
if (smPrefix == null || chainPrefix == null) return false;
|
||||
|
||||
String smLower = smPrefix.toLowerCase();
|
||||
String chainLower = chainPrefix.toLowerCase();
|
||||
|
||||
// Ignore generic/technical class prefixes
|
||||
if (smLower.equals("state") || smLower.equals("statemachine") || smLower.equals("config") || smLower.equals("configuration") || smLower.equals("adapter") ||
|
||||
smLower.equals("enterprise") || smLower.equals("app") || smLower.equals("global") || smLower.equals("core") || smLower.equals("project") || smLower.equals("main") || smLower.equals("base") || smLower.equals("common") || smLower.equals("shared") ||
|
||||
chainLower.equals("state") || chainLower.equals("statemachine") || chainLower.equals("config") || chainLower.equals("configuration") || chainLower.equals("adapter") ||
|
||||
chainLower.equals("enterprise") || chainLower.equals("app") || chainLower.equals("global") || chainLower.equals("core") || chainLower.equals("project") || chainLower.equals("main") || chainLower.equals("base") || chainLower.equals("common") || chainLower.equals("shared")) {
|
||||
if (!isSingleStateMachine(context) && isAmbiguousSharedGenericTrigger(chain.getTriggerPoint())) {
|
||||
Boolean injectionMatch = SpringInjectionRouting.matchesMachineConfig(
|
||||
chain.getTriggerPoint(), currentMachineName, context);
|
||||
if (injectionMatch != null) {
|
||||
return injectionMatch;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// If class prefixes diverge, check if they are from different domains
|
||||
if (!smLower.equals(chainLower)) {
|
||||
// If one prefix is present as a domain/feature segment in the other package, they are related
|
||||
if (chainPackage.toLowerCase().contains(smLower) || smPackage.toLowerCase().contains(chainLower)) {
|
||||
return false;
|
||||
}
|
||||
Boolean packageMatch = PackageNameRoutingHeuristics.matches(chain, currentMachineName, explicitTarget);
|
||||
if (packageMatch != null) {
|
||||
return packageMatch;
|
||||
}
|
||||
return context == null || isSingleStateMachine(context);
|
||||
}
|
||||
|
||||
// Otherwise, if they diverge under a shared project package root, it is a mismatch
|
||||
String[] p1 = smPackage.split("\\.");
|
||||
String[] p2 = chainPackage.split("\\.");
|
||||
int matchIdx = -1;
|
||||
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
|
||||
if (p1[i].equals(p2[i])) {
|
||||
matchIdx = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
private DistinctTypeAffinity resolveDistinctTypeAffinity(
|
||||
TriggerPoint trigger, String machineName, CodebaseContext context) {
|
||||
String triggerEventFqn = trigger.getEventTypeFqn();
|
||||
String triggerStateFqn = trigger.getStateTypeFqn();
|
||||
if (triggerEventFqn == null && triggerStateFqn == null) {
|
||||
return DistinctTypeAffinity.UNKNOWN;
|
||||
}
|
||||
|
||||
String[] machineTypes = StateMachineTypeResolver.resolve(machineName, context);
|
||||
String machineStateFqn = machineTypes[0];
|
||||
String machineEventFqn = machineTypes[1];
|
||||
|
||||
boolean matched = false;
|
||||
boolean mismatched = false;
|
||||
|
||||
if (triggerEventFqn != null && machineEventFqn != null) {
|
||||
if (typesEquivalent(triggerEventFqn, machineEventFqn)
|
||||
&& !isAmbiguousSharedGenericType(triggerEventFqn)) {
|
||||
matched = true;
|
||||
} else if (isDistinctTypeMismatched(triggerEventFqn, machineEventFqn)) {
|
||||
mismatched = true;
|
||||
}
|
||||
if (matchIdx >= 1) {
|
||||
// If they diverge under a shared root, check if the divergence is only on generic package layers
|
||||
boolean onlyGenericDivergence = true;
|
||||
Set<String> genericLayers = Set.of(
|
||||
"config", "configuration", "service", "services", "impl", "api", "web",
|
||||
"controller", "controllers", "messaging", "listener", "listeners",
|
||||
"repository", "repositories", "db", "model", "models", "domain",
|
||||
"constants", "utils", "helper", "helpers", "event", "events",
|
||||
"state", "states", "transition", "transitions"
|
||||
);
|
||||
for (int i = matchIdx + 1; i < p1.length; i++) {
|
||||
if (!genericLayers.contains(p1[i].toLowerCase())) {
|
||||
onlyGenericDivergence = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (onlyGenericDivergence) {
|
||||
for (int i = matchIdx + 1; i < p2.length; i++) {
|
||||
if (!genericLayers.contains(p2[i].toLowerCase())) {
|
||||
onlyGenericDivergence = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!onlyGenericDivergence) {
|
||||
return true; // Mismatch under a shared root
|
||||
}
|
||||
}
|
||||
if (triggerStateFqn != null && machineStateFqn != null) {
|
||||
if (typesEquivalent(triggerStateFqn, machineStateFqn)
|
||||
&& !isAmbiguousSharedGenericType(triggerStateFqn)) {
|
||||
matched = true;
|
||||
} else if (isDistinctTypeMismatched(triggerStateFqn, machineStateFqn)) {
|
||||
mismatched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
return DistinctTypeAffinity.FOR_MACHINE;
|
||||
}
|
||||
if (mismatched) {
|
||||
return DistinctTypeAffinity.AGAINST_MACHINE;
|
||||
}
|
||||
return DistinctTypeAffinity.UNKNOWN;
|
||||
}
|
||||
|
||||
private boolean matchesConfiguredTransitionEvent(
|
||||
TriggerPoint trigger,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null || machineTransitions == null || machineTransitions.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine(context);
|
||||
for (Transition transition : machineTransitions) {
|
||||
if (transition.getEvent() != null && matchingEngine.matches(transition.getEvent(), trigger)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Set<String> extractDomainTerms(String pkg, String prefix) {
|
||||
Set<String> terms = new HashSet<>();
|
||||
if (pkg != null && !pkg.isEmpty()) {
|
||||
String[] segments = pkg.split("\\.");
|
||||
// Take segments from index 2 onwards if length > 2
|
||||
int start = segments.length > 2 ? 2 : 0;
|
||||
for (int i = start; i < segments.length; i++) {
|
||||
terms.add(segments[i].toLowerCase());
|
||||
private boolean isAmbiguousSharedGenericMatch(
|
||||
String triggerEventFqn,
|
||||
String triggerStateFqn,
|
||||
String machineStateFqn,
|
||||
String machineEventFqn) {
|
||||
boolean eventShared = triggerEventFqn != null
|
||||
&& machineEventFqn != null
|
||||
&& typesEquivalent(triggerEventFqn, machineEventFqn)
|
||||
&& isAmbiguousSharedGenericType(triggerEventFqn);
|
||||
boolean stateShared = triggerStateFqn != null
|
||||
&& machineStateFqn != null
|
||||
&& typesEquivalent(triggerStateFqn, machineStateFqn)
|
||||
&& isAmbiguousSharedGenericType(triggerStateFqn);
|
||||
return eventShared || stateShared;
|
||||
}
|
||||
|
||||
private boolean isAmbiguousSharedGenericType(String typeFqn) {
|
||||
return isErasedOrOpaqueType(typeFqn) || isStringType(typeFqn);
|
||||
}
|
||||
|
||||
private boolean isDistinctTypeMismatched(String type1, String type2) {
|
||||
if (isAmbiguousSharedGenericType(type1) || isAmbiguousSharedGenericType(type2)) {
|
||||
return false;
|
||||
}
|
||||
return isTypeMismatched(type1, type2);
|
||||
}
|
||||
|
||||
private boolean isStringType(String typeFqn) {
|
||||
if (typeFqn == null) {
|
||||
return false;
|
||||
}
|
||||
String erased = eraseGenerics(typeFqn);
|
||||
return "java.lang.String".equals(erased) || "String".equals(erased);
|
||||
}
|
||||
|
||||
private Boolean resolveNamedBeanTarget(String beanName, String currentMachineName, CodebaseContext context) {
|
||||
return EnableStateMachineBeanRouting.matchesNamedBean(beanName, currentMachineName, context);
|
||||
}
|
||||
|
||||
private boolean isAmbiguousSharedGenericTrigger(TriggerPoint trigger) {
|
||||
if (trigger == null) {
|
||||
return false;
|
||||
}
|
||||
return isAmbiguousSharedGenericType(trigger.getEventTypeFqn())
|
||||
|| isAmbiguousSharedGenericType(trigger.getStateTypeFqn());
|
||||
}
|
||||
|
||||
private int countStateMachines(CodebaseContext context) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (prefix != null && !prefix.isEmpty()) {
|
||||
terms.add(prefix.toLowerCase());
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
|
||||
private String getPackageName(String fqn) {
|
||||
if (fqn == null || !fqn.contains(".")) return "";
|
||||
return fqn.substring(0, fqn.lastIndexOf('.'));
|
||||
}
|
||||
|
||||
private String getSimpleClassName(String fqn) {
|
||||
if (fqn == null) return "";
|
||||
if (!fqn.contains(".")) return fqn;
|
||||
return fqn.substring(fqn.lastIndexOf('.') + 1);
|
||||
}
|
||||
|
||||
private String getClassNameOnly(String methodFqn) {
|
||||
if (methodFqn == null) return "";
|
||||
String clean = methodFqn;
|
||||
if (clean.contains("(")) {
|
||||
clean = clean.substring(0, clean.indexOf('('));
|
||||
}
|
||||
if (clean.contains(".")) {
|
||||
clean = clean.substring(0, clean.lastIndexOf('.'));
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private String getFirstCamelCaseWord(String simpleName) {
|
||||
if (simpleName == null || simpleName.isEmpty()) return null;
|
||||
String[] words = simpleName.split("(?<!^)(?=[A-Z])");
|
||||
if (words.length > 0) {
|
||||
return words[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String[] 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};
|
||||
}
|
||||
for (TypeDeclaration td : context.getTypeDeclarations()) {
|
||||
if (td == null || td.isInterface()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recurse into the raw type declaration's superclass
|
||||
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(rawTypeName, cu);
|
||||
if (td != null) {
|
||||
if (context.extendsStateMachineConfigurerAdapter(td)) {
|
||||
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;
|
||||
if (fqn != null) {
|
||||
machines.add(fqn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new String[]{null, null};
|
||||
if (!machines.isEmpty()) {
|
||||
return machines.size();
|
||||
}
|
||||
return context.findBeanMethodsReturning(
|
||||
java.util.Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory")).size();
|
||||
}
|
||||
|
||||
private String eraseGenerics(String type) {
|
||||
@@ -323,41 +295,25 @@ 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();
|
||||
private boolean typesEquivalent(String type1, String type2) {
|
||||
if (type1 == null || type2 == null) {
|
||||
return false;
|
||||
}
|
||||
String erased1 = normalizePrimitiveFqn(eraseGenerics(type1));
|
||||
String erased2 = normalizePrimitiveFqn(eraseGenerics(type2));
|
||||
return erased1.equals(erased2);
|
||||
}
|
||||
|
||||
if (simpleName.contains("<")) {
|
||||
simpleName = simpleName.substring(0, simpleName.indexOf('<'));
|
||||
private String normalizePrimitiveFqn(String type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
return switch (type) {
|
||||
case "java.lang.String" -> "String";
|
||||
case "java.lang.Object" -> "Object";
|
||||
case "java.io.Serializable" -> "Serializable";
|
||||
default -> type;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isTypeMismatched(String type1, String type2) {
|
||||
@@ -370,11 +326,16 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
|
||||
return !type1.equals(type2);
|
||||
}
|
||||
|
||||
private boolean isErasedOrOpaqueType(String typeFqn) {
|
||||
if (typeFqn == null) {
|
||||
return false;
|
||||
}
|
||||
String erased = eraseGenerics(typeFqn);
|
||||
return "java.lang.Object".equals(erased) || "Object".equals(erased)
|
||||
|| "java.io.Serializable".equals(erased) || "Serializable".equals(erased);
|
||||
}
|
||||
|
||||
private boolean isSingleStateMachine(CodebaseContext context) {
|
||||
if (context == null) return false;
|
||||
int count = 0;
|
||||
count += context.findEntryPointClasses(java.util.List.of("EnableStateMachineFactory", "EnableStateMachine")).size();
|
||||
count += context.findBeanMethodsReturning(java.util.Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory")).size();
|
||||
return count <= 1;
|
||||
return countStateMachines(context) <= 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
|
||||
/**
|
||||
* Source-derived evidence used to decide whether a call chain belongs to a state-machine export.
|
||||
*/
|
||||
public record MachineRoutingEvidence(
|
||||
String explicitBeanName,
|
||||
String stateMachineId,
|
||||
String eventTypeFqn,
|
||||
String stateTypeFqn,
|
||||
String triggerClassFqn,
|
||||
String triggerMethod,
|
||||
Integer triggerLine) {
|
||||
|
||||
public static MachineRoutingEvidence from(CallChain chain) {
|
||||
if (chain == null) {
|
||||
return new MachineRoutingEvidence(null, null, null, null, null, null, null);
|
||||
}
|
||||
TriggerPoint trigger = chain.getTriggerPoint();
|
||||
String explicit = chain.getContextMachineId();
|
||||
String stateMachineId = null;
|
||||
Integer line = null;
|
||||
if (trigger != null) {
|
||||
if (explicit == null) {
|
||||
explicit = trigger.getStateMachineId();
|
||||
}
|
||||
stateMachineId = trigger.getStateMachineId();
|
||||
if (trigger.getLineNumber() > 0) {
|
||||
line = trigger.getLineNumber();
|
||||
}
|
||||
}
|
||||
return new MachineRoutingEvidence(
|
||||
explicit,
|
||||
stateMachineId,
|
||||
trigger != null ? trigger.getEventTypeFqn() : null,
|
||||
trigger != null ? trigger.getStateTypeFqn() : null,
|
||||
trigger != null ? trigger.getClassName() : null,
|
||||
trigger != null ? trigger.getMethodName() : null,
|
||||
line);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Legacy package-prefix and domain-name matching for single-machine / null-context fallback routing.
|
||||
*/
|
||||
final class PackageNameRoutingHeuristics {
|
||||
|
||||
private static final Set<String> GENERIC_CLASS_PREFIXES = Set.of(
|
||||
"state", "statemachine", "config", "configuration", "adapter",
|
||||
"enterprise", "app", "global", "core", "project", "main",
|
||||
"base", "common", "shared");
|
||||
|
||||
private static final Set<String> GENERIC_PACKAGE_SEGMENTS = Set.of(
|
||||
"config", "configuration", "service", "services", "impl", "api", "web",
|
||||
"controller", "controllers", "messaging", "listener", "listeners",
|
||||
"repository", "repositories", "db", "model", "models", "domain",
|
||||
"constants", "utils", "helper", "helpers", "event", "events",
|
||||
"state", "states", "transition", "transitions", "shared", "common",
|
||||
"global", "core", "base", "main", "app", "enterprise");
|
||||
|
||||
private PackageNameRoutingHeuristics() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true when any class in the call chain carries a vertical domain prefix or package segment
|
||||
*/
|
||||
static boolean hasVerticalDomainOwnership(CallChain chain) {
|
||||
if (chain == null) {
|
||||
return false;
|
||||
}
|
||||
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
|
||||
if (classHasVerticalDomainOwnership(chain.getTriggerPoint().getClassName())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (chain.getMethodChain() != null) {
|
||||
for (String method : chain.getMethodChain()) {
|
||||
if (classHasVerticalDomainOwnership(getClassNameOnly(method))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true/false if package heuristics decide, null if inconclusive
|
||||
*/
|
||||
static Boolean matches(CallChain chain, String currentMachineName, String explicitBeanTarget) {
|
||||
if (chain == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean hasExplicitBeanTarget = explicitBeanTarget != null && !explicitBeanTarget.isEmpty();
|
||||
String targetVar = hasExplicitBeanTarget ? explicitBeanTarget : chain.getContextMachineId();
|
||||
if (targetVar == null && chain.getTriggerPoint() != null) {
|
||||
targetVar = chain.getTriggerPoint().getStateMachineId();
|
||||
}
|
||||
|
||||
String simplifiedMachineName = currentMachineName != null
|
||||
? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase()
|
||||
: "";
|
||||
|
||||
if (targetVar != null && !targetVar.isEmpty()) {
|
||||
targetVar = targetVar.toLowerCase();
|
||||
if (targetVar.endsWith("statemachine")) {
|
||||
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
|
||||
if (!prefix.isEmpty()) {
|
||||
if (simplifiedMachineName.contains(prefix)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if (simplifiedMachineName.contains(targetVar)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) {
|
||||
String smPackage = getPackageName(currentMachineName);
|
||||
String smSimple = getSimpleClassName(currentMachineName);
|
||||
String smPrefix = getFirstCamelCaseWord(smSimple);
|
||||
|
||||
boolean hasPositiveMatch = false;
|
||||
boolean hasStrongMismatch = false;
|
||||
|
||||
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
|
||||
String chainClass = chain.getTriggerPoint().getClassName();
|
||||
String chainPackage = getPackageName(chainClass);
|
||||
String chainSimple = getSimpleClassName(chainClass);
|
||||
String chainPrefix = getFirstCamelCaseWord(chainSimple);
|
||||
|
||||
if (hasMatchingDomainPrefix(smPrefix, chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
|
||||
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (String method : chain.getMethodChain()) {
|
||||
String chainClass = getClassNameOnly(method);
|
||||
String chainPackage = getPackageName(chainClass);
|
||||
String chainSimple = getSimpleClassName(chainClass);
|
||||
String chainPrefix = getFirstCamelCaseWord(chainSimple);
|
||||
|
||||
if (hasMatchingDomainPrefix(smPrefix, chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
|
||||
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
|
||||
hasPositiveMatch = true;
|
||||
}
|
||||
|
||||
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix, smSimple, chainSimple)) {
|
||||
hasStrongMismatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPositiveMatch) {
|
||||
return true;
|
||||
}
|
||||
if (hasStrongMismatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
|
||||
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (smPackage.equals(chainPackage)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!chainPackage.startsWith(smPackage + ".") && !smPackage.startsWith(chainPackage + ".")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] p1 = smPackage.split("\\.");
|
||||
String[] p2 = chainPackage.split("\\.");
|
||||
int matchIdx = -1;
|
||||
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
|
||||
if (p1[i].equals(p2[i])) {
|
||||
matchIdx = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (smPrefix != null && matchIdx >= 0) {
|
||||
String lowerPrefix = smPrefix.toLowerCase();
|
||||
for (int i = 0; i <= matchIdx; i++) {
|
||||
if (p1[i].toLowerCase().contains(lowerPrefix) || lowerPrefix.contains(p1[i].toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isDomainMismatch(
|
||||
String smPackage,
|
||||
String chainPackage,
|
||||
String smPrefix,
|
||||
String chainPrefix,
|
||||
String smSimple,
|
||||
String chainSimple) {
|
||||
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (smPrefix == null || chainPrefix == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String smLower = smPrefix.toLowerCase();
|
||||
String chainLower = chainPrefix.toLowerCase();
|
||||
|
||||
if (isGenericClassPrefix(smLower) || isGenericClassPrefix(chainLower)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!smLower.equals(chainLower)) {
|
||||
if (chainPackage.toLowerCase().contains(smLower) || smPackage.toLowerCase().contains(chainLower)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] p1 = smPackage.split("\\.");
|
||||
String[] p2 = chainPackage.split("\\.");
|
||||
int matchIdx = -1;
|
||||
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
|
||||
if (p1[i].equals(p2[i])) {
|
||||
matchIdx = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchIdx >= 1) {
|
||||
boolean onlyGenericDivergence = true;
|
||||
for (int i = matchIdx + 1; i < p1.length; i++) {
|
||||
if (!isGenericPackageSegment(p1[i])) {
|
||||
onlyGenericDivergence = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (onlyGenericDivergence) {
|
||||
for (int i = matchIdx + 1; i < p2.length; i++) {
|
||||
if (!isGenericPackageSegment(p2[i])) {
|
||||
onlyGenericDivergence = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!onlyGenericDivergence) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String getPackageName(String fqn) {
|
||||
if (fqn == null || !fqn.contains(".")) {
|
||||
return "";
|
||||
}
|
||||
return fqn.substring(0, fqn.lastIndexOf('.'));
|
||||
}
|
||||
|
||||
private static String getSimpleClassName(String fqn) {
|
||||
if (fqn == null) {
|
||||
return "";
|
||||
}
|
||||
if (!fqn.contains(".")) {
|
||||
return fqn;
|
||||
}
|
||||
return fqn.substring(fqn.lastIndexOf('.') + 1);
|
||||
}
|
||||
|
||||
private static String getClassNameOnly(String methodFqn) {
|
||||
if (methodFqn == null) {
|
||||
return "";
|
||||
}
|
||||
String clean = methodFqn;
|
||||
if (clean.contains("(")) {
|
||||
clean = clean.substring(0, clean.indexOf('('));
|
||||
}
|
||||
if (clean.contains(".")) {
|
||||
clean = clean.substring(0, clean.lastIndexOf('.'));
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private static String getFirstCamelCaseWord(String simpleName) {
|
||||
if (simpleName == null || simpleName.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String[] words = simpleName.split("(?<!^)(?=[A-Z])");
|
||||
if (words.length > 0) {
|
||||
return words[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean classHasVerticalDomainOwnership(String classFqn) {
|
||||
if (classFqn == null || classFqn.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String prefix = getFirstCamelCaseWord(getSimpleClassName(classFqn));
|
||||
if (prefix != null && !isGenericClassPrefix(prefix)) {
|
||||
return true;
|
||||
}
|
||||
return hasVerticalPackageSegment(getPackageName(classFqn));
|
||||
}
|
||||
|
||||
private static boolean hasVerticalPackageSegment(String packageName) {
|
||||
if (packageName == null || packageName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String[] segments = packageName.split("\\.");
|
||||
for (int i = 2; i < segments.length; i++) {
|
||||
if (!isGenericPackageSegment(segments[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasMatchingDomainPrefix(String smPrefix, String chainPrefix) {
|
||||
return smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix);
|
||||
}
|
||||
|
||||
private static boolean isGenericClassPrefix(String prefix) {
|
||||
return prefix != null && GENERIC_CLASS_PREFIXES.contains(prefix.toLowerCase());
|
||||
}
|
||||
|
||||
private static boolean isGenericPackageSegment(String segment) {
|
||||
return segment != null && GENERIC_PACKAGE_SEGMENTS.contains(segment.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
|
||||
/**
|
||||
* Decides whether a call chain is provably shared infrastructure that may intentionally
|
||||
* attach to multiple state machines when they share the same transition event.
|
||||
* <p>
|
||||
* Shared infrastructure requires source-derived evidence only: no distinct machine enum types
|
||||
* at the sendEvent site and no vertical domain ownership in the call chain classes/packages.
|
||||
* Event-name matching alone is never sufficient outside this policy.
|
||||
*/
|
||||
final class SharedServiceRoutingPolicy {
|
||||
|
||||
private SharedServiceRoutingPolicy() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true when the chain may multi-attach to every machine that shares its transition event
|
||||
*/
|
||||
static boolean isProvablySharedInfrastructure(CallChain chain) {
|
||||
if (chain == null) {
|
||||
return false;
|
||||
}
|
||||
MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
|
||||
if (evidence.eventTypeFqn() != null || evidence.stateTypeFqn() != null) {
|
||||
return false;
|
||||
}
|
||||
return !PackageNameRoutingHeuristics.hasVerticalDomainOwnership(chain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
|
||||
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Resolves which {@code @EnableStateMachine} configuration owns a trigger's {@code StateMachine} receiver
|
||||
* using Spring injection analysis (JDT bindings), not package-name guessing.
|
||||
*/
|
||||
final class SpringInjectionRouting {
|
||||
|
||||
private static final Set<String> TRIGGER_METHODS = Set.of(
|
||||
"sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger");
|
||||
|
||||
private SpringInjectionRouting() {
|
||||
}
|
||||
|
||||
static Boolean matchesMachineConfig(TriggerPoint trigger, String machineConfigFqn, CodebaseContext context) {
|
||||
if (trigger == null || machineConfigFqn == null || context == null || !context.isResolveBindings()) {
|
||||
return null;
|
||||
}
|
||||
String configFqn = resolveConfigurationFqn(trigger, context);
|
||||
if (configFqn == null) {
|
||||
return null;
|
||||
}
|
||||
return machineConfigFqn.equals(configFqn);
|
||||
}
|
||||
|
||||
static String resolveConfigurationFqn(TriggerPoint trigger, CodebaseContext context) {
|
||||
if (trigger == null || trigger.getClassName() == null || trigger.getMethodName() == null) {
|
||||
return null;
|
||||
}
|
||||
if (!context.isResolveBindings()) {
|
||||
return null;
|
||||
}
|
||||
InjectionPointAnalyzer analyzer = createAnalyzer(context);
|
||||
TypeDeclaration type = context.getTypeDeclaration(trigger.getClassName());
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration method = context.findMethodDeclaration(type, trigger.getMethodName(), true);
|
||||
if (method == null || method.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
CompilationUnit cu = (CompilationUnit) type.getRoot();
|
||||
final IVariableBinding[] receiverBinding = new IVariableBinding[1];
|
||||
method.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if (trigger.getLineNumber() > 0 && cu != null
|
||||
&& cu.getLineNumber(node.getStartPosition()) != trigger.getLineNumber()) {
|
||||
return true;
|
||||
}
|
||||
if (!TRIGGER_METHODS.contains(node.getName().getIdentifier())) {
|
||||
return true;
|
||||
}
|
||||
Expression receiver = node.getExpression();
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
IBinding binding = sn.resolveBinding();
|
||||
if (binding instanceof IVariableBinding vb) {
|
||||
receiverBinding[0] = vb;
|
||||
}
|
||||
} else if (receiver instanceof FieldAccess fa) {
|
||||
receiverBinding[0] = fa.resolveFieldBinding();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (receiverBinding[0] == null) {
|
||||
return null;
|
||||
}
|
||||
String injectedFqn = analyzer.resolveInjectedBeanFqn(receiverBinding[0]);
|
||||
if (injectedFqn == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration injectedType = context.getTypeDeclaration(injectedFqn);
|
||||
if (injectedType != null && context.extendsStateMachineConfigurerAdapter(injectedType)) {
|
||||
return injectedFqn;
|
||||
}
|
||||
return EnableStateMachineBeanRouting.findConfigFqnByBeanName(
|
||||
EnableStateMachineBeanRouting.defaultBeanNameFromFqn(injectedFqn), context);
|
||||
}
|
||||
|
||||
private static InjectionPointAnalyzer createAnalyzer(CodebaseContext context) {
|
||||
SpringBeanRegistry registry = new SpringBeanRegistry();
|
||||
SpringContextScanner scanner = new SpringContextScanner(registry);
|
||||
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||
cu.accept(scanner);
|
||||
}
|
||||
return new InjectionPointAnalyzer(new SpringDependencyResolver(registry));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||
import org.eclipse.jdt.core.dom.FieldDeclaration;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
/**
|
||||
* Resolves constant values for fields referenced by indexed trivial accessors.
|
||||
*/
|
||||
public final class AccessorFieldResolver {
|
||||
|
||||
private AccessorFieldResolver() {
|
||||
}
|
||||
|
||||
public static List<String> resolveFieldConstants(
|
||||
AccessorSummary accessor,
|
||||
String requestingTypeFqn,
|
||||
CodebaseContext context,
|
||||
ConstructorAnalyzer constructorAnalyzer,
|
||||
CompilationUnit contextCu,
|
||||
Set<String> visited,
|
||||
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor) {
|
||||
if (accessor == null || !accessor.isGetter() || context == null) {
|
||||
return List.of();
|
||||
}
|
||||
if (accessor.kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER
|
||||
|| accessor.fieldName() == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
String traceTypeFqn = requestingTypeFqn != null && !requestingTypeFqn.isBlank()
|
||||
? requestingTypeFqn
|
||||
: accessor.declaringFqn();
|
||||
TypeDeclaration typeDeclaration = contextCu != null
|
||||
? context.getTypeDeclaration(traceTypeFqn, contextCu)
|
||||
: null;
|
||||
if (typeDeclaration == null) {
|
||||
typeDeclaration = context.getTypeDeclaration(traceTypeFqn);
|
||||
}
|
||||
if (typeDeclaration == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<String> constants = new ArrayList<>();
|
||||
if (constructorAnalyzer != null) {
|
||||
constants.addAll(constructorAnalyzer.traceFieldInConstructors(
|
||||
typeDeclaration, accessor.fieldName(), context, visited));
|
||||
}
|
||||
|
||||
if (constants.isEmpty()) {
|
||||
collectFieldInitializerConstants(
|
||||
typeDeclaration, accessor.fieldName(), constantExtractor, constants, context);
|
||||
}
|
||||
return constants;
|
||||
}
|
||||
|
||||
private static void collectFieldInitializerConstants(
|
||||
TypeDeclaration typeDeclaration,
|
||||
String fieldName,
|
||||
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor,
|
||||
List<String> constants,
|
||||
CodebaseContext context) {
|
||||
if (constantExtractor == null || typeDeclaration == null) {
|
||||
return;
|
||||
}
|
||||
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
|
||||
for (Object fragmentObj : fieldDeclaration.fragments()) {
|
||||
if (fragmentObj instanceof VariableDeclarationFragment fragment
|
||||
&& fragment.getName().getIdentifier().equals(fieldName)
|
||||
&& fragment.getInitializer() != null) {
|
||||
constantExtractor.accept(fragment.getInitializer(), constants);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (constants.isEmpty() && context != null) {
|
||||
String superFqn = context.getSuperclassFqn(typeDeclaration);
|
||||
if (superFqn != null && !superFqn.equals("java.lang.Object")) {
|
||||
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
|
||||
if (superType != null) {
|
||||
collectFieldInitializerConstants(superType, fieldName, constantExtractor, constants, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public class AccessorIndex {
|
||||
|
||||
private static final AccessorIndex EMPTY = new AccessorIndex(Map.of(), 0, 0, 0);
|
||||
|
||||
private final Map<String, Map<String, AccessorSummary>> byOwnerAndMethod;
|
||||
private final int typeCount;
|
||||
private final int trivialCount;
|
||||
private final int blockedCount;
|
||||
|
||||
public AccessorIndex(
|
||||
Map<String, Map<String, AccessorSummary>> byOwnerAndMethod,
|
||||
int typeCount,
|
||||
int trivialCount,
|
||||
int blockedCount) {
|
||||
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
|
||||
this.typeCount = typeCount;
|
||||
this.trivialCount = trivialCount;
|
||||
this.blockedCount = blockedCount;
|
||||
}
|
||||
|
||||
public static AccessorIndex empty() {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
public Optional<AccessorSummary> lookup(String ownerFqn, String methodName) {
|
||||
if (ownerFqn == null || methodName == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Map<String, AccessorSummary> methods = byOwnerAndMethod.get(ownerFqn);
|
||||
if (methods == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(methods.get(methodName));
|
||||
}
|
||||
|
||||
public List<AccessorSummary> accessorsForType(String ownerFqn) {
|
||||
Map<String, AccessorSummary> methods = byOwnerAndMethod.get(ownerFqn);
|
||||
if (methods == null) {
|
||||
return List.of();
|
||||
}
|
||||
return List.copyOf(methods.values());
|
||||
}
|
||||
|
||||
public Map<String, Map<String, AccessorSummary>> asMap() {
|
||||
return byOwnerAndMethod;
|
||||
}
|
||||
|
||||
public int typeCount() {
|
||||
return typeCount;
|
||||
}
|
||||
|
||||
public int trivialCount() {
|
||||
return trivialCount;
|
||||
}
|
||||
|
||||
public int blockedCount() {
|
||||
return blockedCount;
|
||||
}
|
||||
|
||||
private static Map<String, Map<String, AccessorSummary>> deepCopy(Map<String, Map<String, AccessorSummary>> source) {
|
||||
Map<String, Map<String, AccessorSummary>> copy = new HashMap<>();
|
||||
for (Map.Entry<String, Map<String, AccessorSummary>> entry : source.entrySet()) {
|
||||
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
public class AccessorIndexBuilder {
|
||||
|
||||
private final TrivialAccessorDetector detector = new TrivialAccessorDetector();
|
||||
private CodebaseContext context;
|
||||
|
||||
public AccessorIndex build(CodebaseContext context) {
|
||||
this.context = context;
|
||||
Map<String, DirectTypeAccessors> direct = new HashMap<>();
|
||||
|
||||
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
|
||||
for (Object typeObj : cu.types()) {
|
||||
if (typeObj instanceof TypeDeclaration td) {
|
||||
indexTypeDeclaration(td, direct);
|
||||
} else if (typeObj instanceof RecordDeclaration rd) {
|
||||
indexRecord(rd, packageName, direct);
|
||||
} else if (typeObj instanceof EnumDeclaration ed) {
|
||||
indexEnum(ed, packageName, direct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int blocked = direct.values().stream().mapToInt(state -> state.blockedMethods.size()).sum();
|
||||
|
||||
Map<String, Map<String, AccessorSummary>> effective = new HashMap<>();
|
||||
int trivial = 0;
|
||||
for (String ownerFqn : direct.keySet()) {
|
||||
Map<String, AccessorSummary> merged = mergeEffective(ownerFqn, direct, effective, new HashSet<>());
|
||||
if (!merged.isEmpty()) {
|
||||
effective.put(ownerFqn, merged);
|
||||
trivial += merged.size();
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Built accessor index: {} trivial accessors across {} types ({} blocked overrides)",
|
||||
trivial,
|
||||
effective.size(),
|
||||
blocked);
|
||||
|
||||
this.context = null;
|
||||
return new AccessorIndex(effective, effective.size(), trivial, blocked);
|
||||
}
|
||||
|
||||
private void indexTypeDeclaration(TypeDeclaration td, Map<String, DirectTypeAccessors> direct) {
|
||||
String ownerFqn = context.getFqn(td);
|
||||
DirectTypeAccessors state = direct.computeIfAbsent(ownerFqn, k -> new DirectTypeAccessors());
|
||||
for (MethodDeclaration md : td.getMethods()) {
|
||||
classifyMethod(ownerFqn, md, fieldName -> findFieldDeclaringFqn(td, fieldName), state);
|
||||
}
|
||||
|
||||
for (TypeDeclaration nested : td.getTypes()) {
|
||||
indexTypeDeclaration(nested, direct);
|
||||
}
|
||||
}
|
||||
|
||||
private void indexEnum(EnumDeclaration ed, String parentFqn, Map<String, DirectTypeAccessors> direct) {
|
||||
String ownerFqn = parentFqn.isEmpty() ? ed.getName().getIdentifier() : parentFqn + "." + ed.getName().getIdentifier();
|
||||
DirectTypeAccessors state = direct.computeIfAbsent(ownerFqn, k -> new DirectTypeAccessors());
|
||||
|
||||
for (Object decl : ed.bodyDeclarations()) {
|
||||
if (decl instanceof MethodDeclaration md) {
|
||||
classifyMethod(ownerFqn, md, fieldName -> findEnumFieldDeclaringFqn(ed, ownerFqn, fieldName), state);
|
||||
} else if (decl instanceof TypeDeclaration nestedTd) {
|
||||
indexTypeDeclaration(nestedTd, direct);
|
||||
} else if (decl instanceof RecordDeclaration nestedRd) {
|
||||
indexRecord(nestedRd, ownerFqn, direct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void indexRecord(RecordDeclaration rd, String parentFqn, Map<String, DirectTypeAccessors> direct) {
|
||||
String ownerFqn = parentFqn.isEmpty() ? rd.getName().getIdentifier() : parentFqn + "." + rd.getName().getIdentifier();
|
||||
DirectTypeAccessors state = direct.computeIfAbsent(ownerFqn, k -> new DirectTypeAccessors());
|
||||
|
||||
for (Object compObj : rd.recordComponents()) {
|
||||
String componentName = recordComponentName(compObj);
|
||||
if (componentName == null) {
|
||||
continue;
|
||||
}
|
||||
detector.detectRecordComponent(ownerFqn, componentName).ifPresent(summary ->
|
||||
state.trivial.put(summary.methodName(), summary));
|
||||
}
|
||||
|
||||
TrivialAccessorDetector.FieldLookup recordFieldLookup =
|
||||
fieldName -> findRecordFieldDeclaringFqn(rd, ownerFqn, fieldName);
|
||||
|
||||
for (Object declObj : rd.bodyDeclarations()) {
|
||||
if (declObj instanceof MethodDeclaration md) {
|
||||
String methodName = md.getName().getIdentifier();
|
||||
AccessorSummary existing = state.trivial.get(methodName);
|
||||
if (existing != null && existing.kind() == AccessorKind.RECORD_COMPONENT) {
|
||||
continue;
|
||||
}
|
||||
classifyMethod(ownerFqn, md, recordFieldLookup, state);
|
||||
} else if (declObj instanceof TypeDeclaration nestedTd) {
|
||||
indexTypeDeclaration(nestedTd, direct);
|
||||
} else if (declObj instanceof RecordDeclaration nestedRd) {
|
||||
indexRecord(nestedRd, ownerFqn, direct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void classifyMethod(
|
||||
String ownerFqn,
|
||||
MethodDeclaration md,
|
||||
TrivialAccessorDetector.FieldLookup fieldLookup,
|
||||
DirectTypeAccessors state) {
|
||||
if (md.getBody() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String methodName = md.getName().getIdentifier();
|
||||
Optional<AccessorSummary> summary = detector.detect(ownerFqn, md, fieldLookup);
|
||||
applyClassification(state, methodName, summary);
|
||||
}
|
||||
|
||||
private void applyClassification(DirectTypeAccessors state, String methodName, Optional<AccessorSummary> summary) {
|
||||
if (summary.isPresent()) {
|
||||
state.trivial.put(methodName, summary.get());
|
||||
state.blockedMethods.remove(methodName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (looksLikeAccessorMethod(methodName)) {
|
||||
state.blockedMethods.add(methodName);
|
||||
state.trivial.remove(methodName);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean looksLikeAccessorMethod(String methodName) {
|
||||
return methodName.startsWith("get")
|
||||
|| methodName.startsWith("set")
|
||||
|| methodName.startsWith("is");
|
||||
}
|
||||
|
||||
private Map<String, AccessorSummary> mergeEffective(
|
||||
String ownerFqn,
|
||||
Map<String, DirectTypeAccessors> direct,
|
||||
Map<String, Map<String, AccessorSummary>> cache,
|
||||
Set<String> visiting) {
|
||||
ownerFqn = resolveTypeFqn(ownerFqn);
|
||||
if (ownerFqn == null) {
|
||||
return Map.of();
|
||||
}
|
||||
if (cache.containsKey(ownerFqn)) {
|
||||
return cache.get(ownerFqn);
|
||||
}
|
||||
if (!visiting.add(ownerFqn)) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
Map<String, AccessorSummary> merged = new HashMap<>();
|
||||
TypeDeclaration td = context.getTypeDeclaration(ownerFqn);
|
||||
if (td != null) {
|
||||
String superFqn = resolveTypeFqn(context.getSuperclassFqn(td));
|
||||
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
|
||||
for (Map.Entry<String, AccessorSummary> inherited : mergeEffective(superFqn, direct, cache, visiting).entrySet()) {
|
||||
merged.put(inherited.getKey(), rebindOwner(inherited.getValue(), ownerFqn));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DirectTypeAccessors own = direct.get(ownerFqn);
|
||||
if (own != null) {
|
||||
own.blockedMethods.forEach(merged::remove);
|
||||
merged.putAll(own.trivial);
|
||||
}
|
||||
|
||||
visiting.remove(ownerFqn);
|
||||
cache.put(ownerFqn, Map.copyOf(merged));
|
||||
return merged;
|
||||
}
|
||||
|
||||
private AccessorSummary rebindOwner(AccessorSummary summary, String ownerFqn) {
|
||||
if (summary.ownerFqn().equals(ownerFqn)) {
|
||||
return summary;
|
||||
}
|
||||
return new AccessorSummary(
|
||||
ownerFqn,
|
||||
summary.methodName(),
|
||||
summary.kind(),
|
||||
summary.fieldName(),
|
||||
summary.paramName(),
|
||||
summary.declaringFqn(),
|
||||
summary.returnsThis()
|
||||
);
|
||||
}
|
||||
|
||||
private String resolveTypeFqn(String typeName) {
|
||||
if (typeName == null || typeName.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (typeName.contains(".")) {
|
||||
return typeName;
|
||||
}
|
||||
TypeDeclaration td = context.getTypeDeclaration(typeName);
|
||||
return td != null ? context.getFqn(td) : typeName;
|
||||
}
|
||||
|
||||
private static final class DirectTypeAccessors {
|
||||
private final Map<String, AccessorSummary> trivial = new HashMap<>();
|
||||
private final Set<String> blockedMethods = new HashSet<>();
|
||||
}
|
||||
|
||||
private Optional<String> findFieldDeclaringFqn(TypeDeclaration td, String fieldName) {
|
||||
return findFieldDeclaringFqn(td, fieldName, new HashSet<>());
|
||||
}
|
||||
|
||||
private Optional<String> findFieldDeclaringFqn(TypeDeclaration td, String fieldName, Set<String> visited) {
|
||||
if (td == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String ownerFqn = context.getFqn(td);
|
||||
if (!visited.add(ownerFqn)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
for (FieldDeclaration fd : td.getFields()) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
|
||||
if (frag.getName().getIdentifier().equals(fieldName)) {
|
||||
return Optional.of(ownerFqn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String superFqn = context.getSuperclassFqn(td);
|
||||
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
|
||||
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
||||
if (superTd != null) {
|
||||
return findFieldDeclaringFqn(superTd, fieldName, visited);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Optional<String> findEnumFieldDeclaringFqn(EnumDeclaration ed, String ownerFqn, String fieldName) {
|
||||
for (Object decl : ed.bodyDeclarations()) {
|
||||
if (decl instanceof FieldDeclaration fd) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
|
||||
if (frag.getName().getIdentifier().equals(fieldName)) {
|
||||
return Optional.of(ownerFqn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Optional<String> findRecordFieldDeclaringFqn(RecordDeclaration rd, String ownerFqn, String fieldName) {
|
||||
for (Object compObj : rd.recordComponents()) {
|
||||
String componentName = recordComponentName(compObj);
|
||||
if (fieldName.equals(componentName)) {
|
||||
return Optional.of(ownerFqn);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String recordComponentName(Object compObj) {
|
||||
if (compObj instanceof SingleVariableDeclaration svd) {
|
||||
return svd.getName().getIdentifier();
|
||||
}
|
||||
try {
|
||||
java.lang.reflect.Method getNameMethod = compObj.getClass().getMethod("getName");
|
||||
Object nameObj = getNameMethod.invoke(compObj);
|
||||
if (nameObj instanceof SimpleName sn) {
|
||||
return sn.getIdentifier();
|
||||
}
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
// fall through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
import org.eclipse.jdt.core.dom.IMethodBinding;
|
||||
import org.eclipse.jdt.core.dom.ITypeBinding;
|
||||
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class AccessorInlining {
|
||||
|
||||
private AccessorInlining() {
|
||||
}
|
||||
|
||||
public static Optional<AccessorSummary> lookupAccessor(
|
||||
CodebaseContext context,
|
||||
MethodInvocation mi,
|
||||
List<Expression> receiverDefinitions) {
|
||||
if (context == null || mi == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
if (isLikelyNonBeanGet(methodName, mi)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (receiverDefinitions != null) {
|
||||
for (Expression receiverDef : receiverDefinitions) {
|
||||
if (receiverDef instanceof ClassInstanceCreation cic) {
|
||||
Optional<AccessorSummary> concrete = lookupOnType(context, resolveTypeFqn(cic), methodName);
|
||||
if (concrete.isPresent()) {
|
||||
return concrete;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IMethodBinding methodBinding = mi.resolveMethodBinding();
|
||||
if (methodBinding != null && methodBinding.getDeclaringClass() != null) {
|
||||
ITypeBinding declaringClass = methodBinding.getDeclaringClass().getErasure();
|
||||
Optional<AccessorSummary> declared = lookupOnType(context, declaringClass.getQualifiedName(), methodName);
|
||||
if (declared.isPresent()) {
|
||||
return declared;
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static Optional<AccessorSummary> lookupAccessor(CodebaseContext context, MethodInvocation mi) {
|
||||
return lookupAccessor(context, mi, List.of());
|
||||
}
|
||||
|
||||
private static Optional<AccessorSummary> lookupOnType(CodebaseContext context, String ownerFqn, String methodName) {
|
||||
if (ownerFqn == null || ownerFqn.isBlank() || ownerFqn.startsWith("java.")) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return context.getAccessorIndex().lookup(ownerFqn, methodName);
|
||||
}
|
||||
|
||||
public static String resolveTypeFqn(ClassInstanceCreation cic) {
|
||||
if (cic == null) {
|
||||
return null;
|
||||
}
|
||||
if (cic.resolveConstructorBinding() != null && cic.resolveConstructorBinding().getDeclaringClass() != null) {
|
||||
return cic.resolveConstructorBinding().getDeclaringClass().getErasure().getQualifiedName();
|
||||
}
|
||||
if (cic.resolveTypeBinding() != null) {
|
||||
return cic.resolveTypeBinding().getErasure().getQualifiedName();
|
||||
}
|
||||
return AstUtils.extractSimpleTypeName(cic.getType());
|
||||
}
|
||||
|
||||
private static boolean isLikelyNonBeanGet(String methodName, MethodInvocation mi) {
|
||||
if (!"get".equals(methodName) && !"getOrDefault".equals(methodName)) {
|
||||
return false;
|
||||
}
|
||||
if (mi.arguments().size() != 1) {
|
||||
return false;
|
||||
}
|
||||
IMethodBinding methodBinding = mi.resolveMethodBinding();
|
||||
if (methodBinding == null || methodBinding.getDeclaringClass() == null) {
|
||||
return false;
|
||||
}
|
||||
String ownerFqn = methodBinding.getDeclaringClass().getErasure().getQualifiedName();
|
||||
return ownerFqn != null && ownerFqn.startsWith("java.util");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
public enum AccessorKind {
|
||||
GETTER,
|
||||
BOOLEAN_GETTER,
|
||||
CONSTANT_GETTER,
|
||||
RECORD_COMPONENT,
|
||||
SETTER,
|
||||
FLUENT_SETTER
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
/**
|
||||
* Shared JavaBean accessor naming helpers used by analysis and dataflow code.
|
||||
*/
|
||||
public final class AccessorNaming {
|
||||
|
||||
private AccessorNaming() {
|
||||
}
|
||||
|
||||
public static boolean isBeanStyleAccessorName(String accessorName) {
|
||||
return (accessorName.startsWith("get") && accessorName.length() > 3)
|
||||
|| (accessorName.startsWith("is") && accessorName.length() > 2)
|
||||
|| (accessorName.startsWith("set") && accessorName.length() > 3);
|
||||
}
|
||||
|
||||
public static String propertyNameFromAccessor(String accessorName) {
|
||||
if (accessorName.startsWith("get") && accessorName.length() > 3) {
|
||||
return TrivialAccessorDetector.decapitalize(accessorName.substring(3));
|
||||
}
|
||||
if (accessorName.startsWith("is") && accessorName.length() > 2) {
|
||||
return TrivialAccessorDetector.decapitalize(accessorName.substring(2));
|
||||
}
|
||||
if (accessorName.startsWith("set") && accessorName.length() > 3) {
|
||||
return TrivialAccessorDetector.decapitalize(accessorName.substring(3));
|
||||
}
|
||||
return accessorName.startsWith("get") ? accessorName.substring(3) : accessorName;
|
||||
}
|
||||
|
||||
public static String setterMethodNameForGetter(String getterName) {
|
||||
return "set" + capitalizeProperty(propertyNameFromAccessor(getterName));
|
||||
}
|
||||
|
||||
public static String capitalizeProperty(String propertyName) {
|
||||
if (propertyName == null || propertyName.isEmpty()) {
|
||||
return propertyName;
|
||||
}
|
||||
if (propertyName.length() > 1 && Character.isUpperCase(propertyName.charAt(1))) {
|
||||
return propertyName;
|
||||
}
|
||||
return Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);
|
||||
}
|
||||
|
||||
public static boolean looksLikeIndexedAccessorName(String methodName) {
|
||||
return methodName.startsWith("get") || methodName.startsWith("is") || methodName.startsWith("set");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
public record AccessorSummary(
|
||||
String ownerFqn,
|
||||
String methodName,
|
||||
AccessorKind kind,
|
||||
String fieldName,
|
||||
String paramName,
|
||||
String declaringFqn,
|
||||
boolean returnsThis
|
||||
) {
|
||||
public boolean isGetter() {
|
||||
return kind == AccessorKind.GETTER
|
||||
|| kind == AccessorKind.BOOLEAN_GETTER
|
||||
|| kind == AccessorKind.CONSTANT_GETTER
|
||||
|| kind == AccessorKind.RECORD_COMPONENT;
|
||||
}
|
||||
|
||||
public boolean isSetter() {
|
||||
return kind == AccessorKind.SETTER || kind == AccessorKind.FLUENT_SETTER;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class FieldTypeIndex {
|
||||
|
||||
private static final FieldTypeIndex EMPTY = new FieldTypeIndex(Map.of(), 0);
|
||||
|
||||
private final Map<String, Map<String, String>> byOwnerAndField;
|
||||
private final int fieldCount;
|
||||
|
||||
public FieldTypeIndex(Map<String, Map<String, String>> byOwnerAndField, int fieldCount) {
|
||||
this.byOwnerAndField = Collections.unmodifiableMap(deepCopy(byOwnerAndField));
|
||||
this.fieldCount = fieldCount;
|
||||
}
|
||||
|
||||
public static FieldTypeIndex empty() {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
public Optional<String> lookup(String ownerFqn, String fieldName) {
|
||||
if (ownerFqn == null || fieldName == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Map<String, String> fields = byOwnerAndField.get(ownerFqn);
|
||||
if (fields == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(fields.get(fieldName));
|
||||
}
|
||||
|
||||
public int fieldCount() {
|
||||
return fieldCount;
|
||||
}
|
||||
|
||||
private static Map<String, Map<String, String>> deepCopy(Map<String, Map<String, String>> source) {
|
||||
Map<String, Map<String, String>> copy = new HashMap<>();
|
||||
for (Map.Entry<String, Map<String, String>> entry : source.entrySet()) {
|
||||
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.FieldDeclaration;
|
||||
import org.eclipse.jdt.core.dom.Modifier;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Slf4j
|
||||
public final class FieldTypeIndexBuilder {
|
||||
|
||||
private CodebaseContext context;
|
||||
|
||||
public FieldTypeIndex build(CodebaseContext context) {
|
||||
this.context = context;
|
||||
if (context == null) {
|
||||
return FieldTypeIndex.empty();
|
||||
}
|
||||
|
||||
Map<String, Map<String, String>> direct = new HashMap<>();
|
||||
for (String ownerFqn : context.getIndexedTypeFqns()) {
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
|
||||
if (typeDeclaration != null) {
|
||||
indexDeclaredFields(ownerFqn, typeDeclaration, direct);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Map<String, String>> effective = new HashMap<>();
|
||||
int fieldCount = 0;
|
||||
Map<String, Map<String, String>> cache = new HashMap<>();
|
||||
for (String ownerFqn : direct.keySet()) {
|
||||
Map<String, String> merged = mergeEffective(ownerFqn, direct, cache, new HashSet<>());
|
||||
if (!merged.isEmpty()) {
|
||||
effective.put(ownerFqn, merged);
|
||||
fieldCount += merged.size();
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Built field-type index: {} fields across {} types", fieldCount, effective.size());
|
||||
this.context = null;
|
||||
return new FieldTypeIndex(effective, fieldCount);
|
||||
}
|
||||
|
||||
private void indexDeclaredFields(
|
||||
String ownerFqn,
|
||||
TypeDeclaration typeDeclaration,
|
||||
Map<String, Map<String, String>> direct) {
|
||||
Map<String, String> fields = direct.computeIfAbsent(ownerFqn, ignored -> new HashMap<>());
|
||||
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
|
||||
String fieldType = fieldDeclaration.getType().toString();
|
||||
for (Object fragmentObj : fieldDeclaration.fragments()) {
|
||||
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
|
||||
fields.put(fragment.getName().getIdentifier(), fieldType);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (TypeDeclaration nested : typeDeclaration.getTypes()) {
|
||||
indexDeclaredFields(context.getFqn(nested), nested, direct);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> mergeEffective(
|
||||
String ownerFqn,
|
||||
Map<String, Map<String, String>> direct,
|
||||
Map<String, Map<String, String>> cache,
|
||||
Set<String> visiting) {
|
||||
if (cache.containsKey(ownerFqn)) {
|
||||
return cache.get(ownerFqn);
|
||||
}
|
||||
if (!visiting.add(ownerFqn)) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
Map<String, String> merged = new HashMap<>();
|
||||
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
|
||||
if (ownerType != null) {
|
||||
String superFqn = context.getSuperclassFqn(ownerType);
|
||||
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
|
||||
merged.putAll(mergeEffective(superFqn, direct, cache, visiting));
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> own = direct.get(ownerFqn);
|
||||
if (own != null) {
|
||||
merged.putAll(own);
|
||||
}
|
||||
|
||||
visiting.remove(ownerFqn);
|
||||
cache.put(ownerFqn, Map.copyOf(merged));
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||
|
||||
/**
|
||||
* Scan-time result for {@code owner.getX()} getter-chain hops: the next receiver type
|
||||
* and optional {@code new ...()} field initializer discovered on the backing field.
|
||||
*/
|
||||
public record GetterChainEdge(
|
||||
String ownerFqn,
|
||||
String getterName,
|
||||
String nextTypeFqn,
|
||||
ClassInstanceCreation fieldInitializerCic) {
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class GetterChainIndex {
|
||||
|
||||
private static final GetterChainIndex EMPTY = new GetterChainIndex(Map.of(), 0);
|
||||
|
||||
private final Map<String, Map<String, GetterChainEdge>> byOwnerAndGetter;
|
||||
private final int edgeCount;
|
||||
|
||||
public GetterChainIndex(Map<String, Map<String, GetterChainEdge>> byOwnerAndGetter, int edgeCount) {
|
||||
this.byOwnerAndGetter = Collections.unmodifiableMap(deepCopy(byOwnerAndGetter));
|
||||
this.edgeCount = edgeCount;
|
||||
}
|
||||
|
||||
public static GetterChainIndex empty() {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
public Optional<GetterChainEdge> lookup(String ownerFqn, String getterName) {
|
||||
if (ownerFqn == null || getterName == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Map<String, GetterChainEdge> getters = byOwnerAndGetter.get(ownerFqn);
|
||||
if (getters == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(getters.get(getterName));
|
||||
}
|
||||
|
||||
public int edgeCount() {
|
||||
return edgeCount;
|
||||
}
|
||||
|
||||
private static Map<String, Map<String, GetterChainEdge>> deepCopy(
|
||||
Map<String, Map<String, GetterChainEdge>> source) {
|
||||
Map<String, Map<String, GetterChainEdge>> copy = new HashMap<>();
|
||||
for (Map.Entry<String, Map<String, GetterChainEdge>> entry : source.entrySet()) {
|
||||
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
|
||||
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
public final class GetterChainIndexBuilder {
|
||||
|
||||
public GetterChainIndex build(CodebaseContext context, AccessorIndex accessorIndex) {
|
||||
if (context == null || accessorIndex == null) {
|
||||
return GetterChainIndex.empty();
|
||||
}
|
||||
|
||||
FieldInitializerFinder fieldInitializerFinder = new FieldInitializerFinder(context);
|
||||
Map<String, Map<String, GetterChainEdge>> edges = new HashMap<>();
|
||||
int edgeCount = 0;
|
||||
|
||||
for (Map.Entry<String, Map<String, AccessorSummary>> ownerEntry : accessorIndex.asMap().entrySet()) {
|
||||
String ownerFqn = ownerEntry.getKey();
|
||||
for (AccessorSummary accessor : ownerEntry.getValue().values()) {
|
||||
if (!accessor.isGetter()) {
|
||||
continue;
|
||||
}
|
||||
GetterChainEdge edge = buildEdge(context, fieldInitializerFinder, ownerFqn, accessor);
|
||||
if (edge == null) {
|
||||
continue;
|
||||
}
|
||||
edges.computeIfAbsent(ownerFqn, ignored -> new HashMap<>())
|
||||
.put(accessor.methodName(), edge);
|
||||
edgeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Built getter-chain index: {} edges across {} types", edgeCount, edges.size());
|
||||
return new GetterChainIndex(edges, edgeCount);
|
||||
}
|
||||
|
||||
private static GetterChainEdge buildEdge(
|
||||
CodebaseContext context,
|
||||
FieldInitializerFinder fieldInitializerFinder,
|
||||
String ownerFqn,
|
||||
AccessorSummary accessor) {
|
||||
String nextType = null;
|
||||
ClassInstanceCreation fieldInitializerCic = null;
|
||||
|
||||
if (accessor.fieldName() != null) {
|
||||
fieldInitializerCic = fieldInitializerFinder.findFieldInitializerCic(ownerFqn, accessor.fieldName());
|
||||
nextType = fieldInitializerFinder.resolveFieldTypeName(ownerFqn, accessor.fieldName());
|
||||
if (fieldInitializerCic != null) {
|
||||
nextType = AstUtils.extractSimpleTypeName(fieldInitializerCic.getType());
|
||||
}
|
||||
}
|
||||
|
||||
if (nextType == null) {
|
||||
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
|
||||
MethodDeclaration getter = ownerType != null
|
||||
? context.findMethodDeclaration(ownerType, accessor.methodName(), true)
|
||||
: null;
|
||||
if (getter != null && getter.getReturnType2() != null) {
|
||||
nextType = getter.getReturnType2().toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (nextType == null) {
|
||||
return null;
|
||||
}
|
||||
return new GetterChainEdge(ownerFqn, accessor.methodName(), nextType, fieldInitializerCic);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Scan-time constants for indexed getters backed by fields with literal or enum initializers.
|
||||
*/
|
||||
public final class IndexedFieldConstantsIndex {
|
||||
|
||||
private static final IndexedFieldConstantsIndex EMPTY = new IndexedFieldConstantsIndex(Map.of(), 0);
|
||||
|
||||
private final Map<String, Map<String, List<String>>> byOwnerAndMethod;
|
||||
private final int entryCount;
|
||||
|
||||
public IndexedFieldConstantsIndex(Map<String, Map<String, List<String>>> byOwnerAndMethod, int entryCount) {
|
||||
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
|
||||
this.entryCount = entryCount;
|
||||
}
|
||||
|
||||
public static IndexedFieldConstantsIndex empty() {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
public Optional<List<String>> lookup(String ownerFqn, String methodName) {
|
||||
if (ownerFqn == null || methodName == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Map<String, List<String>> methods = byOwnerAndMethod.get(ownerFqn);
|
||||
if (methods == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<String> constants = methods.get(methodName);
|
||||
if (constants == null || constants.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(constants);
|
||||
}
|
||||
|
||||
public int entryCount() {
|
||||
return entryCount;
|
||||
}
|
||||
|
||||
private static Map<String, Map<String, List<String>>> deepCopy(Map<String, Map<String, List<String>>> source) {
|
||||
Map<String, Map<String, List<String>>> copy = new HashMap<>();
|
||||
for (Map.Entry<String, Map<String, List<String>>> entry : source.entrySet()) {
|
||||
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
public final class IndexedFieldConstantsIndexBuilder {
|
||||
|
||||
public IndexedFieldConstantsIndex build(CodebaseContext context, AccessorIndex accessorIndex) {
|
||||
if (context == null || accessorIndex == null) {
|
||||
return IndexedFieldConstantsIndex.empty();
|
||||
}
|
||||
|
||||
Map<String, Map<String, List<String>>> entries = new HashMap<>();
|
||||
int entryCount = 0;
|
||||
|
||||
for (Map.Entry<String, Map<String, AccessorSummary>> ownerEntry : accessorIndex.asMap().entrySet()) {
|
||||
String ownerFqn = ownerEntry.getKey();
|
||||
for (AccessorSummary accessor : ownerEntry.getValue().values()) {
|
||||
if (!accessor.isGetter() || accessor.fieldName() == null) {
|
||||
continue;
|
||||
}
|
||||
List<String> constants = ScanTimeAccessorConstants.resolve(
|
||||
context, accessorIndex, ownerFqn, accessor.methodName());
|
||||
if (constants.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
entries.computeIfAbsent(ownerFqn, ignored -> new HashMap<>())
|
||||
.put(accessor.methodName(), List.copyOf(constants));
|
||||
entryCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Built indexed field-constants index: {} entries across {} types", entryCount, entries.size());
|
||||
return new IndexedFieldConstantsIndex(entries, entryCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Scan-time summary of how a getter on an interface or abstract type resolves across
|
||||
* its concrete implementations. Values are derived from static analysis only.
|
||||
*/
|
||||
public record PolymorphicAccessorEntry(
|
||||
String ownerFqn,
|
||||
String methodName,
|
||||
Optional<AccessorSummary> representativeImplAccessor,
|
||||
List<String> widenedReturnConstants,
|
||||
boolean staticallyComplete) {
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class PolymorphicAccessorIndex {
|
||||
|
||||
private static final PolymorphicAccessorIndex EMPTY = new PolymorphicAccessorIndex(Map.of(), 0);
|
||||
|
||||
private final Map<String, Map<String, PolymorphicAccessorEntry>> byOwnerAndMethod;
|
||||
private final int entryCount;
|
||||
|
||||
public PolymorphicAccessorIndex(
|
||||
Map<String, Map<String, PolymorphicAccessorEntry>> byOwnerAndMethod,
|
||||
int entryCount) {
|
||||
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
|
||||
this.entryCount = entryCount;
|
||||
}
|
||||
|
||||
public static PolymorphicAccessorIndex empty() {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
public Optional<PolymorphicAccessorEntry> lookup(String ownerFqn, String methodName) {
|
||||
if (ownerFqn == null || methodName == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Map<String, PolymorphicAccessorEntry> methods = byOwnerAndMethod.get(ownerFqn);
|
||||
if (methods == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(methods.get(methodName));
|
||||
}
|
||||
|
||||
public Optional<AccessorSummary> representativeImplAccessor(String ownerFqn, String methodName) {
|
||||
return lookup(ownerFqn, methodName)
|
||||
.flatMap(PolymorphicAccessorEntry::representativeImplAccessor);
|
||||
}
|
||||
|
||||
public Optional<List<String>> widenedReturnConstants(String ownerFqn, String methodName) {
|
||||
return lookup(ownerFqn, methodName)
|
||||
.filter(PolymorphicAccessorEntry::staticallyComplete)
|
||||
.map(PolymorphicAccessorEntry::widenedReturnConstants)
|
||||
.filter(values -> !values.isEmpty());
|
||||
}
|
||||
|
||||
public int entryCount() {
|
||||
return entryCount;
|
||||
}
|
||||
|
||||
private static Map<String, Map<String, PolymorphicAccessorEntry>> deepCopy(
|
||||
Map<String, Map<String, PolymorphicAccessorEntry>> source) {
|
||||
Map<String, Map<String, PolymorphicAccessorEntry>> copy = new HashMap<>();
|
||||
for (Map.Entry<String, Map<String, PolymorphicAccessorEntry>> entry : source.entrySet()) {
|
||||
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.Modifier;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
@Slf4j
|
||||
public final class PolymorphicAccessorIndexBuilder {
|
||||
|
||||
public PolymorphicAccessorIndex build(CodebaseContext context, AccessorIndex accessorIndex) {
|
||||
if (context == null || accessorIndex == null) {
|
||||
return PolymorphicAccessorIndex.empty();
|
||||
}
|
||||
|
||||
Map<String, Map<String, PolymorphicAccessorEntry>> entries = new HashMap<>();
|
||||
int entryCount = 0;
|
||||
|
||||
for (String typeFqn : context.getIndexedTypeFqns()) {
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(typeFqn);
|
||||
if (!isPolymorphicType(typeDeclaration)) {
|
||||
continue;
|
||||
}
|
||||
List<String> implementations = context.getImplementations(typeFqn);
|
||||
if (implementations.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Set<String> methodNames = collectGetterMethodNames(context, accessorIndex, typeFqn, implementations);
|
||||
for (String methodName : methodNames) {
|
||||
PolymorphicAccessorEntry entry = buildEntry(
|
||||
context, accessorIndex, typeFqn, methodName, implementations);
|
||||
if (entry == null) {
|
||||
continue;
|
||||
}
|
||||
entries.computeIfAbsent(typeFqn, ignored -> new HashMap<>()).put(methodName, entry);
|
||||
entryCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Built polymorphic accessor index: {} entries across {} types", entryCount, entries.size());
|
||||
return new PolymorphicAccessorIndex(entries, entryCount);
|
||||
}
|
||||
|
||||
private static PolymorphicAccessorEntry buildEntry(
|
||||
CodebaseContext context,
|
||||
AccessorIndex accessorIndex,
|
||||
String ownerFqn,
|
||||
String methodName,
|
||||
List<String> implementations) {
|
||||
LinkedHashSet<String> widenedConstants = new LinkedHashSet<>();
|
||||
widenedConstants.addAll(ScanTimeAccessorConstants.resolve(context, accessorIndex, ownerFqn, methodName));
|
||||
|
||||
Optional<AccessorSummary> representative = findRepresentativeImplAccessor(accessorIndex, implementations, methodName);
|
||||
for (String implementationFqn : implementations) {
|
||||
widenedConstants.addAll(
|
||||
ScanTimeAccessorConstants.resolve(context, accessorIndex, implementationFqn, methodName));
|
||||
}
|
||||
|
||||
if (representative.isEmpty() && widenedConstants.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
boolean staticallyComplete = isStaticallyComplete(accessorIndex, methodName, implementations);
|
||||
return new PolymorphicAccessorEntry(
|
||||
ownerFqn,
|
||||
methodName,
|
||||
representative,
|
||||
List.copyOf(widenedConstants),
|
||||
staticallyComplete);
|
||||
}
|
||||
|
||||
private static boolean isStaticallyComplete(
|
||||
AccessorIndex accessorIndex,
|
||||
String methodName,
|
||||
List<String> implementations) {
|
||||
for (String implementationFqn : implementations) {
|
||||
if (!isConstantLikeAccessor(accessorIndex, implementationFqn, methodName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isConstantLikeAccessor(
|
||||
AccessorIndex accessorIndex,
|
||||
String typeFqn,
|
||||
String methodName) {
|
||||
Optional<AccessorSummary> accessor = accessorIndex.lookup(typeFqn, methodName);
|
||||
if (accessor.isEmpty() || !accessor.get().isGetter()) {
|
||||
return false;
|
||||
}
|
||||
return accessor.get().kind() == AccessorKind.CONSTANT_GETTER;
|
||||
}
|
||||
|
||||
private static Optional<AccessorSummary> findRepresentativeImplAccessor(
|
||||
AccessorIndex accessorIndex,
|
||||
List<String> implementations,
|
||||
String methodName) {
|
||||
for (String implementationFqn : implementations) {
|
||||
Optional<AccessorSummary> implAccessor = accessorIndex.lookup(implementationFqn, methodName);
|
||||
if (implAccessor.isPresent() && implAccessor.get().isGetter()) {
|
||||
return implAccessor;
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static Set<String> collectGetterMethodNames(
|
||||
CodebaseContext context,
|
||||
AccessorIndex accessorIndex,
|
||||
String ownerFqn,
|
||||
List<String> implementations) {
|
||||
Set<String> methodNames = new LinkedHashSet<>();
|
||||
for (AccessorSummary accessor : accessorIndex.accessorsForType(ownerFqn)) {
|
||||
if (accessor.isGetter()) {
|
||||
methodNames.add(accessor.methodName());
|
||||
}
|
||||
}
|
||||
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
|
||||
if (ownerType != null) {
|
||||
for (MethodDeclaration method : ownerType.getMethods()) {
|
||||
String name = method.getName().getIdentifier();
|
||||
if (looksLikeGetterName(name)) {
|
||||
methodNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String implementationFqn : implementations) {
|
||||
for (AccessorSummary accessor : accessorIndex.accessorsForType(implementationFqn)) {
|
||||
if (accessor.isGetter()) {
|
||||
methodNames.add(accessor.methodName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return methodNames;
|
||||
}
|
||||
|
||||
private static boolean isPolymorphicType(TypeDeclaration typeDeclaration) {
|
||||
return typeDeclaration != null
|
||||
&& (typeDeclaration.isInterface()
|
||||
|| Modifier.isAbstract(typeDeclaration.getModifiers()));
|
||||
}
|
||||
|
||||
private static boolean looksLikeGetterName(String methodName) {
|
||||
return methodName.startsWith("get") || methodName.startsWith("is");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Scan-time expansion of interface/abstract method calls to concrete implementation method FQNs.
|
||||
*/
|
||||
public final class PolymorphicMethodExpansionIndex {
|
||||
|
||||
private static final PolymorphicMethodExpansionIndex EMPTY =
|
||||
new PolymorphicMethodExpansionIndex(Map.of(), 0);
|
||||
|
||||
private final Map<String, Map<String, List<String>>> byOwnerAndMethod;
|
||||
private final int entryCount;
|
||||
|
||||
public PolymorphicMethodExpansionIndex(
|
||||
Map<String, Map<String, List<String>>> byOwnerAndMethod,
|
||||
int entryCount) {
|
||||
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
|
||||
this.entryCount = entryCount;
|
||||
}
|
||||
|
||||
public static PolymorphicMethodExpansionIndex empty() {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
public Optional<List<String>> lookup(String ownerFqn, String methodName) {
|
||||
if (ownerFqn == null || methodName == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Map<String, List<String>> methods = byOwnerAndMethod.get(ownerFqn);
|
||||
if (methods == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<String> expanded = methods.get(methodName);
|
||||
if (expanded == null || expanded.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(expanded);
|
||||
}
|
||||
|
||||
public int entryCount() {
|
||||
return entryCount;
|
||||
}
|
||||
|
||||
private static Map<String, Map<String, List<String>>> deepCopy(
|
||||
Map<String, Map<String, List<String>>> source) {
|
||||
Map<String, Map<String, List<String>>> copy = new HashMap<>();
|
||||
for (Map.Entry<String, Map<String, List<String>>> entry : source.entrySet()) {
|
||||
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.Modifier;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Slf4j
|
||||
public final class PolymorphicMethodExpansionIndexBuilder {
|
||||
|
||||
public PolymorphicMethodExpansionIndex build(CodebaseContext context) {
|
||||
if (context == null) {
|
||||
return PolymorphicMethodExpansionIndex.empty();
|
||||
}
|
||||
|
||||
Map<String, Map<String, List<String>>> entries = new HashMap<>();
|
||||
int entryCount = 0;
|
||||
|
||||
for (String typeFqn : context.getIndexedTypeFqns()) {
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(typeFqn);
|
||||
if (!isPolymorphicType(typeDeclaration)) {
|
||||
continue;
|
||||
}
|
||||
List<String> implementations = context.getImplementations(typeFqn);
|
||||
if (implementations.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Set<String> methodNames = collectMethodNames(context, typeDeclaration, implementations);
|
||||
for (String methodName : methodNames) {
|
||||
List<String> expanded = new ArrayList<>();
|
||||
for (String implementationFqn : implementations) {
|
||||
expanded.add(implementationFqn + "." + methodName);
|
||||
}
|
||||
entries.computeIfAbsent(typeFqn, ignored -> new HashMap<>())
|
||||
.put(methodName, List.copyOf(expanded));
|
||||
entryCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Built polymorphic method expansion index: {} entries across {} types",
|
||||
entryCount, entries.size());
|
||||
return new PolymorphicMethodExpansionIndex(entries, entryCount);
|
||||
}
|
||||
|
||||
private static Set<String> collectMethodNames(
|
||||
CodebaseContext context,
|
||||
TypeDeclaration ownerType,
|
||||
List<String> implementations) {
|
||||
Set<String> methodNames = new LinkedHashSet<>();
|
||||
if (ownerType != null) {
|
||||
for (var method : ownerType.getMethods()) {
|
||||
methodNames.add(method.getName().getIdentifier());
|
||||
}
|
||||
}
|
||||
for (String implementationFqn : implementations) {
|
||||
TypeDeclaration implementationType = context.getTypeDeclaration(implementationFqn);
|
||||
if (implementationType == null) {
|
||||
continue;
|
||||
}
|
||||
for (var method : implementationType.getMethods()) {
|
||||
methodNames.add(method.getName().getIdentifier());
|
||||
}
|
||||
}
|
||||
return methodNames;
|
||||
}
|
||||
|
||||
private static boolean isPolymorphicType(TypeDeclaration typeDeclaration) {
|
||||
return typeDeclaration != null
|
||||
&& (typeDeclaration.isInterface()
|
||||
|| Modifier.isAbstract(typeDeclaration.getModifiers()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
import org.eclipse.jdt.core.dom.FieldDeclaration;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Resolves accessor return constants using only scan-time information: indexed accessors,
|
||||
* literal method bodies, and field initializers. No dataflow or constructor tracing.
|
||||
*/
|
||||
public final class ScanTimeAccessorConstants {
|
||||
|
||||
private ScanTimeAccessorConstants() {
|
||||
}
|
||||
|
||||
public static List<String> resolve(
|
||||
CodebaseContext context,
|
||||
AccessorIndex accessorIndex,
|
||||
String ownerFqn,
|
||||
String methodName) {
|
||||
if (context == null || accessorIndex == null || ownerFqn == null || methodName == null) {
|
||||
return List.of();
|
||||
}
|
||||
Set<String> deduped = new LinkedHashSet<>();
|
||||
Optional<AccessorSummary> indexed = accessorIndex.lookup(ownerFqn, methodName);
|
||||
if (indexed.isPresent() && indexed.get().isGetter()) {
|
||||
if (indexed.get().kind() == AccessorKind.CONSTANT_GETTER) {
|
||||
deduped.addAll(extractReturnConstants(context, ownerFqn, methodName));
|
||||
} else if (indexed.get().fieldName() != null) {
|
||||
deduped.addAll(extractFieldInitializerConstants(
|
||||
context, ownerFqn, indexed.get().fieldName()));
|
||||
}
|
||||
}
|
||||
if (deduped.isEmpty()) {
|
||||
deduped.addAll(extractReturnConstants(context, ownerFqn, methodName));
|
||||
}
|
||||
return List.copyOf(deduped);
|
||||
}
|
||||
|
||||
private static List<String> extractReturnConstants(
|
||||
CodebaseContext context,
|
||||
String ownerFqn,
|
||||
String methodName) {
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
|
||||
if (typeDeclaration == null) {
|
||||
return List.of();
|
||||
}
|
||||
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
|
||||
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> constants = new ArrayList<>();
|
||||
ConstantResolver constantResolver = context.getConstantResolver();
|
||||
methodDeclaration.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(ReturnStatement node) {
|
||||
Expression expression = node.getExpression();
|
||||
if (expression != null) {
|
||||
addResolvedExpression(constantResolver, context, expression, constants);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return constants;
|
||||
}
|
||||
|
||||
private static List<String> extractFieldInitializerConstants(
|
||||
CodebaseContext context,
|
||||
String ownerFqn,
|
||||
String fieldName) {
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
|
||||
if (typeDeclaration == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> constants = new ArrayList<>();
|
||||
collectFieldInitializerConstants(context, typeDeclaration, fieldName, constants);
|
||||
return constants;
|
||||
}
|
||||
|
||||
private static void collectFieldInitializerConstants(
|
||||
CodebaseContext context,
|
||||
TypeDeclaration typeDeclaration,
|
||||
String fieldName,
|
||||
List<String> constants) {
|
||||
ConstantResolver constantResolver = context.getConstantResolver();
|
||||
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
|
||||
for (Object fragmentObj : fieldDeclaration.fragments()) {
|
||||
if (fragmentObj instanceof VariableDeclarationFragment fragment
|
||||
&& fragment.getName().getIdentifier().equals(fieldName)
|
||||
&& fragment.getInitializer() != null) {
|
||||
addResolvedExpression(
|
||||
constantResolver, context, fragment.getInitializer(), constants);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (constants.isEmpty()) {
|
||||
String superFqn = context.getSuperclassFqn(typeDeclaration);
|
||||
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
|
||||
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
|
||||
if (superType != null) {
|
||||
collectFieldInitializerConstants(context, superType, fieldName, constants);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addResolvedExpression(
|
||||
ConstantResolver constantResolver,
|
||||
CodebaseContext context,
|
||||
Expression expression,
|
||||
List<String> constants) {
|
||||
if (constantResolver == null || expression == null) {
|
||||
return;
|
||||
}
|
||||
String resolved = constantResolver.resolve(expression, context);
|
||||
if (resolved == null) {
|
||||
return;
|
||||
}
|
||||
if (resolved.startsWith("ENUM_SET:")) {
|
||||
for (String value : resolved.substring(9).split(",")) {
|
||||
addUnique(constants, parseEnumSetElement(value));
|
||||
}
|
||||
} else if (resolved.startsWith("MAP:")) {
|
||||
List<String> mapValues = ConstantResolver.decodeMapLiteralValues(resolved);
|
||||
if (mapValues != null) {
|
||||
for (String mapValue : mapValues) {
|
||||
addUnique(constants, mapValue);
|
||||
}
|
||||
}
|
||||
} else if (resolved.startsWith("ARRAY:")) {
|
||||
for (String arrayValue : resolved.substring(6).split("\\|", -1)) {
|
||||
addUnique(constants, arrayValue);
|
||||
}
|
||||
} else {
|
||||
addUnique(constants, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
private static String parseEnumSetElement(String value) {
|
||||
int lastDot = value.lastIndexOf('.');
|
||||
int secondLastDot = lastDot > 0 ? value.lastIndexOf('.', lastDot - 1) : -1;
|
||||
return secondLastDot >= 0 ? value.substring(secondLastDot + 1) : value;
|
||||
}
|
||||
|
||||
private static void addUnique(List<String> constants, String value) {
|
||||
if (value != null && !constants.contains(value)) {
|
||||
constants.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.index;
|
||||
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Detects trivial JavaBean-style getters and setters whose bodies only read or write a single field.
|
||||
*/
|
||||
public class TrivialAccessorDetector {
|
||||
|
||||
@FunctionalInterface
|
||||
public interface FieldLookup extends Function<String, Optional<String>> {
|
||||
}
|
||||
|
||||
public Optional<AccessorSummary> detect(String ownerFqn, MethodDeclaration method, FieldLookup fieldLookup) {
|
||||
if (ownerFqn == null || method == null || method.getBody() == null || fieldLookup == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String methodName = method.getName().getIdentifier();
|
||||
if (method.isConstructor()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<String> getterField = propertyNameForGetter(methodName, method);
|
||||
if (getterField.isPresent()) {
|
||||
Optional<AccessorSummary> fieldGetter = detectGetter(ownerFqn, method, methodName, getterField.get(), fieldLookup);
|
||||
if (fieldGetter.isPresent()) {
|
||||
return fieldGetter;
|
||||
}
|
||||
return detectConstantGetter(ownerFqn, method, methodName);
|
||||
}
|
||||
|
||||
Optional<String> setterField = propertyNameForSetter(methodName);
|
||||
if (setterField.isPresent() && !method.parameters().isEmpty()) {
|
||||
return detectSetter(ownerFqn, method, methodName, setterField.get(), fieldLookup);
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public Optional<AccessorSummary> detectRecordComponent(String ownerFqn, String componentName) {
|
||||
if (ownerFqn == null || componentName == null || componentName.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new AccessorSummary(
|
||||
ownerFqn,
|
||||
componentName,
|
||||
AccessorKind.RECORD_COMPONENT,
|
||||
componentName,
|
||||
null,
|
||||
ownerFqn,
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
private Optional<AccessorSummary> detectConstantGetter(
|
||||
String ownerFqn,
|
||||
MethodDeclaration method,
|
||||
String methodName) {
|
||||
List<?> statements = method.getBody().statements();
|
||||
if (statements.size() != 1 || !(statements.get(0) instanceof ReturnStatement rs)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Expression expr = rs.getExpression();
|
||||
if (expr == null || !isConstantReturnExpression(expr)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new AccessorSummary(
|
||||
ownerFqn,
|
||||
methodName,
|
||||
AccessorKind.CONSTANT_GETTER,
|
||||
null,
|
||||
null,
|
||||
ownerFqn,
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
private boolean isConstantReturnExpression(Expression expr) {
|
||||
Expression current = unwrap(expr);
|
||||
if (current instanceof StringLiteral
|
||||
|| current instanceof NumberLiteral
|
||||
|| current instanceof BooleanLiteral
|
||||
|| current instanceof CharacterLiteral) {
|
||||
return true;
|
||||
}
|
||||
if (current instanceof QualifiedName) {
|
||||
return !containsMethodCall(current);
|
||||
}
|
||||
if (current instanceof SimpleName) {
|
||||
return false;
|
||||
}
|
||||
if (current instanceof FieldAccess fa) {
|
||||
Expression receiver = fa.getExpression();
|
||||
return (receiver instanceof SimpleName || receiver instanceof QualifiedName)
|
||||
&& !containsMethodCall(current);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Optional<AccessorSummary> detectGetter(
|
||||
String ownerFqn,
|
||||
MethodDeclaration method,
|
||||
String methodName,
|
||||
String fieldName,
|
||||
FieldLookup fieldLookup) {
|
||||
if (!isTrivialGetterBody(method.getBody(), fieldName)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Optional<String> declaringFqn = fieldLookup.apply(fieldName);
|
||||
if (declaringFqn.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
AccessorKind kind = methodName.startsWith("is") ? AccessorKind.BOOLEAN_GETTER : AccessorKind.GETTER;
|
||||
return Optional.of(new AccessorSummary(
|
||||
ownerFqn,
|
||||
methodName,
|
||||
kind,
|
||||
fieldName,
|
||||
null,
|
||||
declaringFqn.get(),
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
private Optional<AccessorSummary> detectSetter(
|
||||
String ownerFqn,
|
||||
MethodDeclaration method,
|
||||
String methodName,
|
||||
String fieldName,
|
||||
FieldLookup fieldLookup) {
|
||||
SingleVariableDeclaration param = (SingleVariableDeclaration) method.parameters().get(0);
|
||||
String paramName = param.getName().getIdentifier();
|
||||
|
||||
boolean fluent = isTrivialFluentSetterBody(method.getBody(), fieldName, paramName);
|
||||
boolean voidSetter = isVoidReturn(method) && isTrivialVoidSetterBody(method.getBody(), fieldName, paramName);
|
||||
if (!fluent && !voidSetter) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Optional<String> declaringFqn = fieldLookup.apply(fieldName);
|
||||
if (declaringFqn.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
AccessorKind kind = fluent ? AccessorKind.FLUENT_SETTER : AccessorKind.SETTER;
|
||||
return Optional.of(new AccessorSummary(
|
||||
ownerFqn,
|
||||
methodName,
|
||||
kind,
|
||||
fieldName,
|
||||
paramName,
|
||||
declaringFqn.get(),
|
||||
fluent
|
||||
));
|
||||
}
|
||||
|
||||
private Optional<String> propertyNameForGetter(String methodName, MethodDeclaration method) {
|
||||
if (methodName.startsWith("get") && methodName.length() > 3) {
|
||||
return Optional.of(decapitalize(methodName.substring(3)));
|
||||
}
|
||||
if (methodName.startsWith("is") && methodName.length() > 2 && isBooleanReturn(method)) {
|
||||
return Optional.of(decapitalize(methodName.substring(2)));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Optional<String> propertyNameForSetter(String methodName) {
|
||||
if (methodName.startsWith("set") && methodName.length() > 3) {
|
||||
return Optional.of(decapitalize(methodName.substring(3)));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private boolean isBooleanReturn(MethodDeclaration method) {
|
||||
return "boolean".equals(method.getReturnType2().toString())
|
||||
|| "Boolean".equals(method.getReturnType2().toString());
|
||||
}
|
||||
|
||||
private boolean isVoidReturn(MethodDeclaration method) {
|
||||
return method.getReturnType2() instanceof PrimitiveType pt && pt.getPrimitiveTypeCode() == PrimitiveType.VOID;
|
||||
}
|
||||
|
||||
public static String decapitalize(String name) {
|
||||
if (name == null || name.isEmpty()) {
|
||||
return name;
|
||||
}
|
||||
if (name.length() > 1 && Character.isUpperCase(name.charAt(1))) {
|
||||
return name;
|
||||
}
|
||||
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
|
||||
}
|
||||
|
||||
private boolean isTrivialGetterBody(Block body, String fieldName) {
|
||||
List<?> statements = body.statements();
|
||||
if (statements.size() != 1 || !(statements.get(0) instanceof ReturnStatement rs)) {
|
||||
return false;
|
||||
}
|
||||
Expression expr = rs.getExpression();
|
||||
if (expr == null) {
|
||||
return false;
|
||||
}
|
||||
return isDirectFieldRead(unwrap(expr), fieldName);
|
||||
}
|
||||
|
||||
private boolean isTrivialVoidSetterBody(Block body, String fieldName, String paramName) {
|
||||
List<?> statements = body.statements();
|
||||
if (statements.size() != 1) {
|
||||
return false;
|
||||
}
|
||||
return isAssignmentStatement(statements.get(0), fieldName, paramName);
|
||||
}
|
||||
|
||||
private boolean isTrivialFluentSetterBody(Block body, String fieldName, String paramName) {
|
||||
List<?> statements = body.statements();
|
||||
if (statements.size() != 2) {
|
||||
return false;
|
||||
}
|
||||
if (!isAssignmentStatement(statements.get(0), fieldName, paramName)) {
|
||||
return false;
|
||||
}
|
||||
if (!(statements.get(1) instanceof ReturnStatement rs)) {
|
||||
return false;
|
||||
}
|
||||
Expression ret = unwrap(rs.getExpression());
|
||||
return ret instanceof ThisExpression;
|
||||
}
|
||||
|
||||
private boolean isAssignmentStatement(Object statement, String fieldName, String paramName) {
|
||||
if (!(statement instanceof ExpressionStatement es)) {
|
||||
return false;
|
||||
}
|
||||
if (!(es.getExpression() instanceof Assignment assignment)) {
|
||||
return false;
|
||||
}
|
||||
if (assignment.getOperator() != Assignment.Operator.ASSIGN) {
|
||||
return false;
|
||||
}
|
||||
if (!isDirectFieldWrite(assignment.getLeftHandSide(), fieldName)) {
|
||||
return false;
|
||||
}
|
||||
Expression rhs = unwrap(assignment.getRightHandSide());
|
||||
return rhs instanceof SimpleName sn && sn.getIdentifier().equals(paramName);
|
||||
}
|
||||
|
||||
private boolean isDirectFieldRead(Expression expr, String fieldName) {
|
||||
if (containsMethodCall(expr)) {
|
||||
return false;
|
||||
}
|
||||
if (expr instanceof SimpleName sn) {
|
||||
return sn.getIdentifier().equals(fieldName);
|
||||
}
|
||||
if (expr instanceof FieldAccess fa) {
|
||||
Expression receiver = fa.getExpression();
|
||||
if (receiver == null || receiver instanceof ThisExpression) {
|
||||
return fa.getName().getIdentifier().equals(fieldName);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDirectFieldWrite(Expression expr, String fieldName) {
|
||||
if (expr instanceof SimpleName sn) {
|
||||
return sn.getIdentifier().equals(fieldName);
|
||||
}
|
||||
if (expr instanceof FieldAccess fa) {
|
||||
Expression receiver = fa.getExpression();
|
||||
if (receiver == null || receiver instanceof ThisExpression) {
|
||||
return fa.getName().getIdentifier().equals(fieldName);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean containsMethodCall(Expression expr) {
|
||||
if (expr == null) {
|
||||
return false;
|
||||
}
|
||||
final boolean[] found = {false};
|
||||
expr.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
found[0] = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return found[0];
|
||||
}
|
||||
|
||||
private Expression unwrap(Expression expr) {
|
||||
Expression current = expr;
|
||||
while (current instanceof ParenthesizedExpression pe) {
|
||||
current = pe.getExpression();
|
||||
}
|
||||
while (current instanceof CastExpression ce) {
|
||||
current = ce.getExpression();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@ 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.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
@@ -31,10 +38,19 @@ 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();
|
||||
|
||||
public void applyResolution(Map<String, String> properties) {
|
||||
applyResolution(properties, null);
|
||||
}
|
||||
|
||||
public void applyResolution(Map<String, String> properties, CodebaseContext context) {
|
||||
var resolver = new click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver();
|
||||
|
||||
// 1. Resolve start/end states strings
|
||||
@@ -52,7 +68,9 @@ public class AnalysisResult {
|
||||
// 2. Resolve states (State record is immutable, so recreate)
|
||||
if (states != null) {
|
||||
this.states = states.stream()
|
||||
.map(s -> click.kamil.springstatemachineexporter.model.State.of(s.rawName(), resolver.resolveValue(s.fullIdentifier(), properties)))
|
||||
.map(s -> click.kamil.springstatemachineexporter.model.State.of(
|
||||
resolver.resolveValue(s.rawName(), properties),
|
||||
resolver.resolveValue(s.fullIdentifier(), properties)))
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
|
||||
@@ -72,15 +90,18 @@ public class AnalysisResult {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve metadata (triggers, entry points)
|
||||
// 4. Resolve metadata (triggers, entry points, call chains)
|
||||
if (metadata != null) {
|
||||
if (metadata.getTriggers() != null) {
|
||||
for (var trigger : metadata.getTriggers()) {
|
||||
if (trigger.getEvent() != null) {
|
||||
trigger.setEvent(resolver.resolveValue(trigger.getEvent(), properties));
|
||||
}
|
||||
}
|
||||
}
|
||||
List<TriggerPoint> resolvedTriggers = metadata.getTriggers() == null ? null
|
||||
: metadata.getTriggers().stream()
|
||||
.map(trigger -> resolveTrigger(trigger, resolver, properties, context))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
List<CallChain> resolvedCallChains = metadata.getCallChains() == null ? null
|
||||
: metadata.getCallChains().stream()
|
||||
.map(chain -> resolveCallChain(chain, resolver, properties, context))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
if (metadata.getEntryPoints() != null) {
|
||||
for (var ep : metadata.getEntryPoints()) {
|
||||
if (ep.getName() != null) {
|
||||
@@ -95,7 +116,76 @@ 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,
|
||||
null,
|
||||
new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn));
|
||||
}
|
||||
|
||||
private static TriggerPoint resolveTrigger(
|
||||
TriggerPoint trigger,
|
||||
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
|
||||
Map<String, String> properties,
|
||||
CodebaseContext context) {
|
||||
List<String> polymorphicEvents = trigger.getPolymorphicEvents() == null ? null
|
||||
: trigger.getPolymorphicEvents().stream()
|
||||
.map(value -> resolver.resolveValue(value, properties))
|
||||
.map(value -> MachineEnumCanonicalizer.qualifyEventIdentifier(
|
||||
value, trigger.getEventTypeFqn(), context))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
String resolvedEvent = trigger.getEvent() != null ? resolver.resolveValue(trigger.getEvent(), properties) : null;
|
||||
String resolvedSource = trigger.getSourceState() != null
|
||||
? resolver.resolveValue(trigger.getSourceState(), properties)
|
||||
: null;
|
||||
return trigger.toBuilder()
|
||||
.event(resolvedEvent != null
|
||||
? MachineEnumCanonicalizer.qualifyEventIdentifier(
|
||||
resolvedEvent, trigger.getEventTypeFqn(), context)
|
||||
: null)
|
||||
.sourceState(resolvedSource != null
|
||||
? MachineEnumCanonicalizer.canonicalizeLabel(
|
||||
resolvedSource, trigger.getStateTypeFqn(), context)
|
||||
: null)
|
||||
.polymorphicEvents(polymorphicEvents)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static CallChain resolveCallChain(
|
||||
CallChain chain,
|
||||
click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver resolver,
|
||||
Map<String, String> properties,
|
||||
CodebaseContext context) {
|
||||
TriggerPoint trigger = chain.getTriggerPoint() == null
|
||||
? null
|
||||
: resolveTrigger(chain.getTriggerPoint(), resolver, properties, context);
|
||||
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) {
|
||||
|
||||
@@ -8,11 +8,11 @@ import lombok.extern.jackson.Jacksonized;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@Builder(toBuilder = true)
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class BusinessFlow {
|
||||
private final String name;
|
||||
private final String description;
|
||||
private final List<String> steps; // List of Event names in order
|
||||
private final List<FlowStep> steps;
|
||||
}
|
||||
|
||||
@@ -17,4 +17,6 @@ public class CallChain {
|
||||
private final TriggerPoint triggerPoint;
|
||||
private final String contextMachineId;
|
||||
private final List<MatchedTransition> matchedTransitions;
|
||||
/** How this chain's trigger was linked; derived from trigger flags and matchedTransitions. */
|
||||
private final LinkResolution linkResolution;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder(toBuilder = true)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonDeserialize(using = FlowStepDeserializer.class)
|
||||
@JsonSerialize(using = FlowStepSerializer.class)
|
||||
public class FlowStep {
|
||||
/** Source state identifier (package-canonical FQN or short form). */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private final String source;
|
||||
/** Event identifier or {@code Source->Target} for anonymous transitions. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private final String event;
|
||||
/** Precomputed {@code #link_*} suffix for HTML/SVG highlight. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private final String linkKey;
|
||||
|
||||
public static FlowStep ofEvent(String event) {
|
||||
return FlowStep.builder().event(event).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
class FlowStepDeserializer extends JsonDeserializer<FlowStep> {
|
||||
|
||||
@Override
|
||||
public FlowStep deserialize(JsonParser parser, DeserializationContext context) throws IOException {
|
||||
JsonNode node = parser.getCodec().readTree(parser);
|
||||
if (node.isTextual()) {
|
||||
return FlowStep.builder().event(node.asText()).build();
|
||||
}
|
||||
return FlowStep.builder()
|
||||
.source(textOrNull(node, "source"))
|
||||
.event(textOrNull(node, "event"))
|
||||
.linkKey(textOrNull(node, "linkKey"))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String textOrNull(JsonNode node, String field) {
|
||||
JsonNode value = node.get(field);
|
||||
if (value == null || value.isNull()) {
|
||||
return null;
|
||||
}
|
||||
String text = value.asText();
|
||||
return text.isBlank() ? null : text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
class FlowStepSerializer extends JsonSerializer<FlowStep> {
|
||||
|
||||
@Override
|
||||
public void serialize(FlowStep step, JsonGenerator generator, SerializerProvider serializers) throws IOException {
|
||||
if (step == null) {
|
||||
generator.writeNull();
|
||||
return;
|
||||
}
|
||||
boolean structured = step.getSource() != null && !step.getSource().isBlank()
|
||||
|| step.getLinkKey() != null && !step.getLinkKey().isBlank();
|
||||
if (!structured && step.getEvent() != null && !step.getEvent().isBlank()) {
|
||||
generator.writeString(step.getEvent());
|
||||
return;
|
||||
}
|
||||
generator.writeStartObject();
|
||||
if (step.getSource() != null && !step.getSource().isBlank()) {
|
||||
generator.writeStringField("source", step.getSource());
|
||||
}
|
||||
if (step.getEvent() != null && !step.getEvent().isBlank()) {
|
||||
generator.writeStringField("event", step.getEvent());
|
||||
}
|
||||
if (step.getLinkKey() != null && !step.getLinkKey().isBlank()) {
|
||||
generator.writeStringField("linkKey", step.getLinkKey());
|
||||
}
|
||||
generator.writeEndObject();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
/**
|
||||
* Synthetic trigger markers for persistence/lifecycle operations that do not fire
|
||||
* {@code sendEvent} transitions (e.g. {@code StateMachinePersister.restore(...)}).
|
||||
*/
|
||||
public final class LifecycleTriggerMarkers {
|
||||
|
||||
public static final String PREFIX = "[LIFECYCLE:";
|
||||
|
||||
private LifecycleTriggerMarkers() {
|
||||
}
|
||||
|
||||
public static boolean isLifecycle(String event) {
|
||||
return event != null && event.startsWith(PREFIX) && event.endsWith("]");
|
||||
}
|
||||
|
||||
public static String lifecycleType(String event) {
|
||||
if (!isLifecycle(event)) {
|
||||
return null;
|
||||
}
|
||||
return event.substring(PREFIX.length(), event.length() - 1);
|
||||
}
|
||||
|
||||
public static String displayLabel(String event) {
|
||||
String type = lifecycleType(event);
|
||||
if (type == null) {
|
||||
return event;
|
||||
}
|
||||
return switch (type.toUpperCase()) {
|
||||
case "RESTORE" -> "Restore persisted state";
|
||||
case "RESET" -> "Reset state machine";
|
||||
default -> "Lifecycle: " + type.toLowerCase();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
/**
|
||||
* Export metadata describing how a call-chain trigger was linked to state-machine transitions.
|
||||
* Derived from existing trigger flags only — no additional heuristics.
|
||||
*/
|
||||
public enum LinkResolution {
|
||||
/** Concrete trigger linked to one or more matched transitions. */
|
||||
RESOLVED,
|
||||
/** REST or other external trigger that cannot be statically linked. */
|
||||
UNRESOLVED_EXTERNAL,
|
||||
/** Call-graph widening or source-state ambiguity prevented linking. */
|
||||
AMBIGUOUS_WIDEN,
|
||||
/** Static trigger with no matching transition link. */
|
||||
NO_MATCH
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@Builder(toBuilder = true)
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class MatchedTransition {
|
||||
private final String sourceState;
|
||||
private final String targetState;
|
||||
private final String event;
|
||||
/** Precomputed {@code #link_*} suffix for HTML/SVG highlight; optional in JSON exports. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private final String linkKey;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -53,78 +54,10 @@ public class TriggerPoint {
|
||||
this.lineNumber = lineNumber;
|
||||
this.stateTypeFqn = stateTypeFqn;
|
||||
this.eventTypeFqn = eventTypeFqn;
|
||||
this.event = qualifyEvent(event, eventTypeFqn);
|
||||
if (polymorphicEvents != null) {
|
||||
this.polymorphicEvents = polymorphicEvents.stream()
|
||||
.map(pe -> qualifyEvent(pe, eventTypeFqn))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
} else {
|
||||
this.polymorphicEvents = null;
|
||||
}
|
||||
this.event = event;
|
||||
this.polymorphicEvents = polymorphicEvents;
|
||||
this.external = external;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||
import org.eclipse.jdt.core.dom.FieldDeclaration;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Finds {@code new ...()} field initializers, walking the inheritance chain once.
|
||||
*/
|
||||
public final class FieldInitializerFinder {
|
||||
|
||||
private final CodebaseContext context;
|
||||
|
||||
public FieldInitializerFinder(CodebaseContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public ClassInstanceCreation findFieldInitializerCic(String ownerFqn, String fieldName) {
|
||||
if (fieldName == null || ownerFqn == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration td = context.getTypeDeclaration(ownerFqn);
|
||||
return findFieldInitializerCic(td, fieldName, new HashSet<>());
|
||||
}
|
||||
|
||||
public ClassInstanceCreation findFieldInitializerCic(TypeDeclaration td, String fieldName, Set<String> visited) {
|
||||
if (td == null || fieldName == null) {
|
||||
return null;
|
||||
}
|
||||
String typeFqn = context.getFqn(td);
|
||||
if (typeFqn == null || !visited.add(typeFqn)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (FieldDeclaration fd : td.getFields()) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
if (fragObj instanceof VariableDeclarationFragment frag
|
||||
&& frag.getName().getIdentifier().equals(fieldName)
|
||||
&& frag.getInitializer() instanceof ClassInstanceCreation cic) {
|
||||
return cic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String superFqn = context.getSuperclassFqn(td);
|
||||
if (superFqn != null) {
|
||||
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
||||
ClassInstanceCreation inherited = findFieldInitializerCic(superTd, fieldName, visited);
|
||||
if (inherited != null) {
|
||||
return inherited;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String resolveFieldTypeName(String ownerFqn, String fieldName) {
|
||||
if (ownerFqn == null || fieldName == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration td = context.getTypeDeclaration(ownerFqn);
|
||||
return resolveFieldTypeName(td, fieldName, new HashSet<>());
|
||||
}
|
||||
|
||||
public String resolveFieldTypeName(TypeDeclaration td, String fieldName, Set<String> visited) {
|
||||
if (td == null || fieldName == null) {
|
||||
return null;
|
||||
}
|
||||
String typeFqn = context.getFqn(td);
|
||||
if (typeFqn == null || !visited.add(typeFqn)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (FieldDeclaration fd : td.getFields()) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
if (fragObj instanceof VariableDeclarationFragment frag
|
||||
&& frag.getName().getIdentifier().equals(fieldName)) {
|
||||
return fd.getType().toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String superFqn = context.getSuperclassFqn(td);
|
||||
if (superFqn != null) {
|
||||
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
||||
return resolveFieldTypeName(superTd, fieldName, visited);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
/**
|
||||
* Scans trivial getter bodies for constant return values.
|
||||
*/
|
||||
public final class GetterBodyConstantScanner {
|
||||
|
||||
private GetterBodyConstantScanner() {
|
||||
}
|
||||
|
||||
public static List<String> extractConstantGetterReturn(
|
||||
CodebaseContext context,
|
||||
ConstantExtractor constantExtractor,
|
||||
ConstantResolver constantResolver,
|
||||
String ownerFqn,
|
||||
String methodName) {
|
||||
return extractConstantGetterReturn(context, constantExtractor, constantResolver, ownerFqn, methodName, null, null);
|
||||
}
|
||||
|
||||
public static List<String> extractConstantGetterReturn(
|
||||
CodebaseContext context,
|
||||
ConstantExtractor constantExtractor,
|
||||
ConstantResolver constantResolver,
|
||||
String ownerFqn,
|
||||
String methodName,
|
||||
CompilationUnit contextCu,
|
||||
Set<String> ignoredVisited) {
|
||||
TypeDeclaration typeDeclaration = contextCu != null
|
||||
? context.getTypeDeclaration(ownerFqn, contextCu)
|
||||
: null;
|
||||
if (typeDeclaration == null) {
|
||||
typeDeclaration = context.getTypeDeclaration(ownerFqn);
|
||||
}
|
||||
if (typeDeclaration == null) {
|
||||
return List.of();
|
||||
}
|
||||
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
|
||||
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<String> constants = new ArrayList<>();
|
||||
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> extractor =
|
||||
constantExtractor != null
|
||||
? constantExtractor::extractConstantsFromExpression
|
||||
: (expr, out) -> {
|
||||
String resolved = constantResolver.resolve(expr, context);
|
||||
if (resolved != null) {
|
||||
out.add(resolved);
|
||||
}
|
||||
};
|
||||
methodDeclaration.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(ReturnStatement node) {
|
||||
if (node.getExpression() != null) {
|
||||
extractor.accept(node.getExpression(), constants);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return constants;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Central registry for reactive/factory unwrap and passthrough method names used during constant extraction.
|
||||
*/
|
||||
public final class LibraryUnwrapRegistry {
|
||||
|
||||
private static final Set<String> UNWRAP_RECEIVER_TYPES = Set.of(
|
||||
"Optional", "Stream", "List", "Set", "Arrays", "Objects", "Mono", "Flux");
|
||||
|
||||
private static final Set<String> UNWRAP_METHOD_NAMES = Set.of(
|
||||
"of", "ofEntries", "asList", "entry", "just", "withPayload", "success");
|
||||
|
||||
private static final String[] STOP_UNWRAP_PREFIXES = {
|
||||
"map", "parse", "convert", "to", "resolve", "build", "create", "from",
|
||||
"transform", "translate", "derive", "determine", "calculate", "decode",
|
||||
"extract", "get", "find", "validate"
|
||||
};
|
||||
|
||||
private LibraryUnwrapRegistry() {
|
||||
}
|
||||
|
||||
public static boolean isUnwrapReceiverType(String receiverExpressionText) {
|
||||
return receiverExpressionText != null && UNWRAP_RECEIVER_TYPES.contains(receiverExpressionText);
|
||||
}
|
||||
|
||||
public static boolean isUnwrapMethodName(String methodName) {
|
||||
return methodName != null && UNWRAP_METHOD_NAMES.contains(methodName);
|
||||
}
|
||||
|
||||
public static boolean shouldStopUnwrap(String methodName) {
|
||||
if (methodName == null) {
|
||||
return true;
|
||||
}
|
||||
String lowerName = methodName.toLowerCase(Locale.ROOT);
|
||||
for (String prefix : STOP_UNWRAP_PREFIXES) {
|
||||
if (lowerName.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean isEventSetterMethodName(String methodName) {
|
||||
if (methodName == null) {
|
||||
return false;
|
||||
}
|
||||
return "event".equalsIgnoreCase(methodName)
|
||||
|| "type".equalsIgnoreCase(methodName)
|
||||
|| "eventType".equalsIgnoreCase(methodName);
|
||||
}
|
||||
|
||||
public static boolean isHeaderSetterMethodName(String methodName) {
|
||||
return "setHeader".equals(methodName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
|
||||
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Shared {@link MethodInvocation} peeling for factory/reactive wrappers and local passthrough methods.
|
||||
*/
|
||||
public final class MethodInvocationUnwrapper {
|
||||
|
||||
private MethodInvocationUnwrapper() {
|
||||
}
|
||||
|
||||
public static Expression unwrap(
|
||||
MethodInvocation mi,
|
||||
int depth,
|
||||
String methodFqn,
|
||||
CodebaseContext context,
|
||||
Function<MethodInvocation, String> targetMethodFqnResolver) {
|
||||
if (ResolutionBudget.defaults().isUnwrapExhausted(depth)) {
|
||||
return mi;
|
||||
}
|
||||
|
||||
String targetMethodFqn = targetMethodFqnResolver != null
|
||||
? targetMethodFqnResolver.apply(mi)
|
||||
: null;
|
||||
if (targetMethodFqn == null && methodFqn != null && methodFqn.contains(".") && mi.getExpression() == null) {
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
targetMethodFqn = className + "." + mi.getName().getIdentifier();
|
||||
}
|
||||
|
||||
if (targetMethodFqn != null && targetMethodFqn.contains(".")) {
|
||||
String className = targetMethodFqn.substring(0, targetMethodFqn.lastIndexOf('.'));
|
||||
String methodName = targetMethodFqn.substring(targetMethodFqn.lastIndexOf('.') + 1);
|
||||
|
||||
TypeDeclaration currentTd = methodFqn != null && methodFqn.contains(".")
|
||||
? context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')))
|
||||
: null;
|
||||
org.eclipse.jdt.core.dom.CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof org.eclipse.jdt.core.dom.CompilationUnit compilationUnit
|
||||
? compilationUnit
|
||||
: null;
|
||||
|
||||
TypeDeclaration td = context.getTypeDeclaration(className, cu);
|
||||
if (td == null) {
|
||||
td = context.getTypeDeclaration(className);
|
||||
}
|
||||
if (td != null) {
|
||||
MethodDeclaration localMd = context.findMethodDeclaration(td, methodName, true);
|
||||
if (localMd != null) {
|
||||
int paramIdx = getReturnedParameterIndex(localMd);
|
||||
if (paramIdx >= 0 && paramIdx < mi.arguments().size()) {
|
||||
Expression arg = peelExpression((Expression) mi.arguments().get(paramIdx));
|
||||
if (arg instanceof MethodInvocation innerMi) {
|
||||
return unwrap(innerMi, depth + 1, methodFqn, context, targetMethodFqnResolver);
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mi.getExpression() != null) {
|
||||
String exprStr = mi.getExpression().toString();
|
||||
if (!LibraryUnwrapRegistry.isUnwrapReceiverType(exprStr)) {
|
||||
return mi;
|
||||
}
|
||||
} else if (targetMethodFqn == null) {
|
||||
return mi;
|
||||
}
|
||||
|
||||
if (!mi.arguments().isEmpty()) {
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
if (LibraryUnwrapRegistry.shouldStopUnwrap(methodName)) {
|
||||
return mi;
|
||||
}
|
||||
|
||||
Expression arg = peelExpression((Expression) mi.arguments().get(0));
|
||||
if (arg instanceof MethodInvocation innerMi) {
|
||||
return unwrap(innerMi, depth + 1, methodFqn, context, targetMethodFqnResolver);
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
return mi;
|
||||
}
|
||||
|
||||
public static Expression peelExpression(Expression expression) {
|
||||
Expression current = expression;
|
||||
while (current instanceof ParenthesizedExpression pe) {
|
||||
current = pe.getExpression();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
public static int getReturnedParameterIndex(MethodDeclaration md) {
|
||||
if (md.getBody() == null) {
|
||||
return -1;
|
||||
}
|
||||
List<?> parameters = md.parameters();
|
||||
if (parameters.isEmpty()) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
final List<String> returnedExprs = new ArrayList<>();
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(ReturnStatement node) {
|
||||
if (node.getExpression() != null) {
|
||||
returnedExprs.add(node.getExpression().toString());
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
|
||||
if (returnedExprs.size() == 1) {
|
||||
String retStr = returnedExprs.get(0);
|
||||
for (int i = 0; i < parameters.size(); i++) {
|
||||
if (parameters.get(i) instanceof SingleVariableDeclaration svd
|
||||
&& svd.getName().getIdentifier().equals(retStr)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
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 transforms).
|
||||
*/
|
||||
public final class ReactiveExpressionSupport {
|
||||
|
||||
private static final Set<String> REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success");
|
||||
private static final Set<String> REACTIVE_TRANSFORM_METHODS = Set.of("map", "flatMap", "switchMap", "concatMap");
|
||||
|
||||
private ReactiveExpressionSupport() {
|
||||
}
|
||||
|
||||
public static String extractPayload(Expression expression, ConstantResolver constantResolver, CodebaseContext context) {
|
||||
return extractPayload(expression, null, constantResolver, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites {@code p.getEvent()} inside a reactive transform lambda to {@code payload.getEvent()}
|
||||
* when {@code p} is fed by {@code Mono.just(payload)} (or a chained reactive source).
|
||||
*/
|
||||
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 transformCall = findEnclosingReactiveTransform(lambda);
|
||||
if (transformCall == null) {
|
||||
return null;
|
||||
}
|
||||
String mappedReceiver = mapLambdaParameterToSource(
|
||||
lambda,
|
||||
paramName.getIdentifier(),
|
||||
transformCall.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 findEnclosingReactiveTransform(ASTNode node) {
|
||||
ASTNode current = node.getParent();
|
||||
while (current != null) {
|
||||
if (current instanceof MethodInvocation mi
|
||||
&& REACTIVE_TRANSFORM_METHODS.contains(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 (REACTIVE_TRANSFORM_METHODS.contains(methodName) && !mi.arguments().isEmpty()) {
|
||||
String fromArgument = extractTransformArgumentPayload(
|
||||
(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 extractTransformArgumentPayload(
|
||||
Expression argument,
|
||||
Expression transformReceiver,
|
||||
ConstantResolver constantResolver,
|
||||
CodebaseContext context) {
|
||||
if (argument instanceof LambdaExpression lambda) {
|
||||
Expression bodyExpression = lambdaBodyExpression(lambda);
|
||||
if (bodyExpression != null) {
|
||||
String lambdaPayload = extractLambdaBodyPayload(
|
||||
bodyExpression, lambda, transformReceiver, constantResolver, context);
|
||||
if (lambdaPayload != null) {
|
||||
return lambdaPayload;
|
||||
}
|
||||
return extractPayload(bodyExpression, transformReceiver, constantResolver, context);
|
||||
}
|
||||
}
|
||||
return extractPayload(argument, transformReceiver, constantResolver, context);
|
||||
}
|
||||
|
||||
private static String extractLambdaBodyPayload(
|
||||
Expression bodyExpression,
|
||||
LambdaExpression lambda,
|
||||
Expression transformReceiver,
|
||||
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(), transformReceiver, 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 transformReceiver,
|
||||
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 (transformReceiver instanceof MethodInvocation receiverTransform
|
||||
&& REACTIVE_TRANSFORM_METHODS.contains(receiverTransform.getName().getIdentifier())
|
||||
&& !receiverTransform.arguments().isEmpty()
|
||||
&& receiverTransform.arguments().get(0) instanceof LambdaExpression feederLambda
|
||||
&& feederLambda != lambda) {
|
||||
Expression feederBody = lambdaBodyExpression(feederLambda);
|
||||
if (feederBody != null) {
|
||||
String feederPayload = extractLambdaBodyPayload(
|
||||
feederBody, feederLambda, receiverTransform.getExpression(), constantResolver, context);
|
||||
if (feederPayload != null) {
|
||||
return feederPayload;
|
||||
}
|
||||
String extracted = extractPayload(
|
||||
feederBody, receiverTransform.getExpression(), constantResolver, context);
|
||||
if (extracted != null) {
|
||||
return extracted;
|
||||
}
|
||||
}
|
||||
}
|
||||
Expression source = peelJustArgument(transformReceiver);
|
||||
if (source instanceof MethodInvocation getterMi
|
||||
&& getterMi.getExpression() instanceof SimpleName innerParamName) {
|
||||
LambdaExpression outerLambda = findEnclosingLambda(lambda);
|
||||
if (outerLambda != null && outerLambda != lambda) {
|
||||
MethodInvocation outerTransform = findEnclosingReactiveTransform(outerLambda);
|
||||
if (outerTransform != null) {
|
||||
String mappedBase = mapLambdaParameterToSource(
|
||||
outerLambda,
|
||||
innerParamName.getIdentifier(),
|
||||
outerTransform.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(transformReceiver, 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.pipeline;
|
||||
|
||||
/**
|
||||
* Shared depth and fallback budget for analysis traversals. Replaces scattered magic-number limits.
|
||||
*/
|
||||
public final class ResolutionBudget {
|
||||
|
||||
public static final int DEFAULT_METHOD_RETURN = 20;
|
||||
public static final int DEFAULT_ACCESSOR_INLINE = 25;
|
||||
public static final int DEFAULT_DATAFLOW = 50;
|
||||
public static final int DEFAULT_UNWRAP = 5;
|
||||
public static final int DEFAULT_CONSTRUCTOR = 10;
|
||||
public static final int DEFAULT_RETURNED_CIC = 50;
|
||||
public static final int DEFAULT_GLOBAL_FIELD_SCAN = 10;
|
||||
|
||||
private final int methodReturnDepth;
|
||||
private final int accessorInlineDepth;
|
||||
private final int dataflowDepth;
|
||||
private final int unwrapDepth;
|
||||
private final int constructorDepth;
|
||||
private final int returnedCicDepth;
|
||||
private final int globalFieldScanLimit;
|
||||
private final boolean allowGlobalFieldScan;
|
||||
|
||||
private ResolutionBudget(
|
||||
int methodReturnDepth,
|
||||
int accessorInlineDepth,
|
||||
int dataflowDepth,
|
||||
int unwrapDepth,
|
||||
int constructorDepth,
|
||||
int returnedCicDepth,
|
||||
int globalFieldScanLimit,
|
||||
boolean allowGlobalFieldScan) {
|
||||
this.methodReturnDepth = methodReturnDepth;
|
||||
this.accessorInlineDepth = accessorInlineDepth;
|
||||
this.dataflowDepth = dataflowDepth;
|
||||
this.unwrapDepth = unwrapDepth;
|
||||
this.constructorDepth = constructorDepth;
|
||||
this.returnedCicDepth = returnedCicDepth;
|
||||
this.globalFieldScanLimit = globalFieldScanLimit;
|
||||
this.allowGlobalFieldScan = allowGlobalFieldScan;
|
||||
}
|
||||
|
||||
public static ResolutionBudget defaults() {
|
||||
return new ResolutionBudget(
|
||||
DEFAULT_METHOD_RETURN,
|
||||
DEFAULT_ACCESSOR_INLINE,
|
||||
DEFAULT_DATAFLOW,
|
||||
DEFAULT_UNWRAP,
|
||||
DEFAULT_CONSTRUCTOR,
|
||||
DEFAULT_RETURNED_CIC,
|
||||
DEFAULT_GLOBAL_FIELD_SCAN,
|
||||
false);
|
||||
}
|
||||
|
||||
public static ResolutionBudget withGlobalFieldScan() {
|
||||
return defaults().enableGlobalFieldScan();
|
||||
}
|
||||
|
||||
public ResolutionBudget enableGlobalFieldScan() {
|
||||
return new ResolutionBudget(
|
||||
methodReturnDepth,
|
||||
accessorInlineDepth,
|
||||
dataflowDepth,
|
||||
unwrapDepth,
|
||||
constructorDepth,
|
||||
returnedCicDepth,
|
||||
globalFieldScanLimit,
|
||||
true);
|
||||
}
|
||||
|
||||
public ResolutionBudget child() {
|
||||
return new ResolutionBudget(
|
||||
methodReturnDepth - 1,
|
||||
accessorInlineDepth - 1,
|
||||
dataflowDepth - 1,
|
||||
unwrapDepth,
|
||||
constructorDepth - 1,
|
||||
returnedCicDepth - 1,
|
||||
globalFieldScanLimit,
|
||||
allowGlobalFieldScan);
|
||||
}
|
||||
|
||||
public boolean isMethodReturnExhausted(int depth) {
|
||||
return depth > methodReturnDepth;
|
||||
}
|
||||
|
||||
public boolean isAccessorInlineExhausted(int depth) {
|
||||
return depth > accessorInlineDepth;
|
||||
}
|
||||
|
||||
public boolean isDataflowExhausted(int depth) {
|
||||
return depth > dataflowDepth;
|
||||
}
|
||||
|
||||
public boolean isUnwrapExhausted(int depth) {
|
||||
return depth > unwrapDepth;
|
||||
}
|
||||
|
||||
public boolean isConstructorExhausted(int depth) {
|
||||
return depth > constructorDepth;
|
||||
}
|
||||
|
||||
public boolean isReturnedCicExhausted(int depth) {
|
||||
return depth > returnedCicDepth;
|
||||
}
|
||||
|
||||
public int globalFieldScanLimit() {
|
||||
return globalFieldScanLimit;
|
||||
}
|
||||
|
||||
public boolean allowGlobalFieldScan() {
|
||||
return allowGlobalFieldScan;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// Be conservative in pruning: if we can't parse the expression, do not discard paths.
|
||||
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()) {
|
||||
if (!isConcreteBindingValue(entry.getKey(), entry.getValue())) {
|
||||
continue;
|
||||
}
|
||||
expr = substituteVariableBindings(expr, entry.getKey(), entry.getValue());
|
||||
expr = substituteEqualsLiteralBindings(expr, entry.getKey(), entry.getValue());
|
||||
}
|
||||
return evaluateBooleanExpression(expr);
|
||||
}
|
||||
|
||||
private static boolean isConcreteBindingValue(String paramName, String boundValue) {
|
||||
if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) {
|
||||
return false;
|
||||
}
|
||||
if (boundValue.contains("(") && !boundValue.contains("valueOf")) {
|
||||
return false;
|
||||
}
|
||||
if (boundValue.startsWith("\"") && boundValue.endsWith("\"")) {
|
||||
return true;
|
||||
}
|
||||
if ("true".equalsIgnoreCase(boundValue) || "false".equalsIgnoreCase(boundValue)) {
|
||||
return true;
|
||||
}
|
||||
if (boundValue.contains(".") && Character.isUpperCase(boundValue.charAt(boundValue.lastIndexOf('.') + 1))) {
|
||||
return true;
|
||||
}
|
||||
// Treat simple routing keys (e.g. order.pay) as concrete string bindings, but avoid
|
||||
// mistaking variable names (e.g. machineType) for concrete values.
|
||||
if ((boundValue.contains(".") || boundValue.contains("/") || boundValue.contains("-"))
|
||||
&& boundValue.matches("^[a-zA-Z0-9._/-]+$")) {
|
||||
return true;
|
||||
}
|
||||
return boundValue.equals(boundValue.toUpperCase(Locale.ROOT)) && boundValue.chars().allMatch(ch ->
|
||||
Character.isUpperCase(ch) || ch == '_');
|
||||
}
|
||||
|
||||
private static String substituteEqualsLiteralBindings(String expr, String varName, String boundValue) {
|
||||
if (boundValue == null || boundValue.isBlank()) {
|
||||
return expr;
|
||||
}
|
||||
String cleanValue = boundValue;
|
||||
if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) {
|
||||
cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
|
||||
}
|
||||
|
||||
String literalPattern = Pattern.quote(cleanValue);
|
||||
String varToken = Pattern.quote(varName);
|
||||
String argumentEquals = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||
+ literalPattern + "[\"']?\\s*\\)";
|
||||
expr = replaceReceiverEqualsForVariable(expr, varName, cleanValue, "true");
|
||||
expr = replaceObjectsEqualsForVariable(expr, varName, cleanValue, "true");
|
||||
expr = expr.replaceAll(argumentEquals, "true");
|
||||
|
||||
Set<String> allLiterals = extractStringLiterals(expr);
|
||||
for (String literal : allLiterals) {
|
||||
if (literal.equalsIgnoreCase(cleanValue)) {
|
||||
continue;
|
||||
}
|
||||
String otherLiteral = Pattern.quote(literal);
|
||||
String otherArgument = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||
+ otherLiteral + "[\"']?\\s*\\)";
|
||||
expr = replaceReceiverEqualsForVariable(expr, varName, literal, "false");
|
||||
expr = replaceObjectsEqualsForVariable(expr, varName, literal, "false");
|
||||
expr = expr.replaceAll(otherArgument, "false");
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
private static String replaceObjectsEqualsForVariable(
|
||||
String expr, String varName, String literalValue, String replacement) {
|
||||
Pattern head = Pattern.compile("(?is)(?:java\\.util\\.)?Objects\\.equals\\s*\\(");
|
||||
Matcher matcher = head.matcher(expr);
|
||||
StringBuilder result = new StringBuilder();
|
||||
int cursor = 0;
|
||||
while (matcher.find()) {
|
||||
int matchStart = matcher.start();
|
||||
int openParen = matcher.end() - 1;
|
||||
int closeParen = findMatchingCloseParen(expr, openParen);
|
||||
if (closeParen < 0) {
|
||||
break;
|
||||
}
|
||||
String[] args = splitTopLevelComma(expr.substring(openParen + 1, closeParen));
|
||||
result.append(expr, cursor, matchStart);
|
||||
if (args != null
|
||||
&& args.length == 2
|
||||
&& objectsEqualsArgumentMatches(args[0], args[1], varName, literalValue)) {
|
||||
result.append(replacement);
|
||||
} else {
|
||||
result.append(expr, matchStart, closeParen + 1);
|
||||
}
|
||||
cursor = closeParen + 1;
|
||||
}
|
||||
result.append(expr.substring(cursor));
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static boolean objectsEqualsArgumentMatches(
|
||||
String left, String right, String varName, String literalValue) {
|
||||
String leftLiteral = stripConstraintLiteral(left);
|
||||
String rightLiteral = stripConstraintLiteral(right);
|
||||
if (leftLiteral != null
|
||||
&& leftLiteral.equalsIgnoreCase(literalValue)
|
||||
&& constraintArgumentReferencesVariable(right, varName)) {
|
||||
return true;
|
||||
}
|
||||
return rightLiteral != null
|
||||
&& rightLiteral.equalsIgnoreCase(literalValue)
|
||||
&& constraintArgumentReferencesVariable(left, varName);
|
||||
}
|
||||
|
||||
private static String stripConstraintLiteral(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.length() >= 2
|
||||
&& ((trimmed.startsWith("\"") && trimmed.endsWith("\""))
|
||||
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
|
||||
return trimmed.substring(1, trimmed.length() - 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean constraintArgumentReferencesVariable(String argument, String varName) {
|
||||
return argument != null && argument.matches("(?s).*\\b" + Pattern.quote(varName) + "\\b.*");
|
||||
}
|
||||
|
||||
private static String[] splitTopLevelComma(String args) {
|
||||
int depth = 0;
|
||||
for (int i = 0; i < args.length(); i++) {
|
||||
char c = args.charAt(i);
|
||||
if (c == '(') {
|
||||
depth++;
|
||||
} else if (c == ')') {
|
||||
depth--;
|
||||
} else if (c == ',' && depth == 0) {
|
||||
return new String[] {args.substring(0, i), args.substring(i + 1)};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String replaceReceiverEqualsForVariable(
|
||||
String expr, String varName, String literalValue, String replacement) {
|
||||
Pattern head = Pattern.compile(
|
||||
"(?is)[\"']?" + Pattern.quote(literalValue) + "[\"']?\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(");
|
||||
Matcher matcher = head.matcher(expr);
|
||||
StringBuilder result = new StringBuilder();
|
||||
int cursor = 0;
|
||||
while (matcher.find()) {
|
||||
int matchStart = matcher.start();
|
||||
int openParen = matcher.end() - 1;
|
||||
int closeParen = findMatchingCloseParen(expr, openParen);
|
||||
if (closeParen < 0) {
|
||||
break;
|
||||
}
|
||||
String argument = expr.substring(openParen + 1, closeParen).trim();
|
||||
result.append(expr, cursor, matchStart);
|
||||
if (argument.equals(varName)
|
||||
|| argument.matches("(?s).*\\b" + Pattern.quote(varName) + "\\b.*")) {
|
||||
result.append(replacement);
|
||||
} else {
|
||||
result.append(expr, matchStart, closeParen + 1);
|
||||
}
|
||||
cursor = closeParen + 1;
|
||||
}
|
||||
result.append(expr.substring(cursor));
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static int findMatchingCloseParen(String expr, int openIdx) {
|
||||
int depth = 0;
|
||||
for (int i = openIdx; i < expr.length(); i++) {
|
||||
char c = expr.charAt(i);
|
||||
if (c == '(') {
|
||||
depth++;
|
||||
} else if (c == ')') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static Set<String> extractEqualityVariables(String constraint) {
|
||||
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);
|
||||
}
|
||||
|
||||
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 = constraintValuesMatch(cleanValue, rhs);
|
||||
matcher.appendReplacement(sb, matches ? "true" : "false");
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
if ("true".equalsIgnoreCase(cleanValue) || "false".equalsIgnoreCase(cleanValue)) {
|
||||
Pattern bareVar = Pattern.compile("(?<![\\w.])" + Pattern.quote(varName) + "(?![\\w])");
|
||||
return bareVar.matcher(expr).replaceAll(cleanValue.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static boolean constraintValuesMatch(String boundValue, String rhs) {
|
||||
if (boundValue == null || rhs == null) {
|
||||
return false;
|
||||
}
|
||||
String bound = stripOuterQuotes(boundValue);
|
||||
String rhsClean = stripOuterQuotes(rhs);
|
||||
if (bound.equals(rhsClean) || bound.equalsIgnoreCase(rhsClean)) {
|
||||
return true;
|
||||
}
|
||||
if (bound.endsWith("." + rhsClean) || rhsClean.endsWith("." + bound)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String boundConstant = enumConstantName(bound);
|
||||
String rhsConstant = enumConstantName(rhsClean);
|
||||
if (!boundConstant.equalsIgnoreCase(rhsConstant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String boundType = enumTypePart(bound);
|
||||
String rhsType = enumTypePart(rhsClean);
|
||||
if (boundType == null || rhsType == null) {
|
||||
return true;
|
||||
}
|
||||
if (boundType.equals(rhsType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String boundSimple = simpleTypeName(boundType);
|
||||
String rhsSimple = simpleTypeName(rhsType);
|
||||
if (!boundSimple.equals(rhsSimple)) {
|
||||
return false;
|
||||
}
|
||||
boolean boundImportStyle = !boundType.contains(".");
|
||||
boolean rhsImportStyle = !rhsType.contains(".");
|
||||
return boundImportStyle || rhsImportStyle;
|
||||
}
|
||||
|
||||
private static String stripOuterQuotes(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
|
||||
return value.substring(1, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String enumConstantName(String ref) {
|
||||
int dot = ref.lastIndexOf('.');
|
||||
return dot >= 0 ? ref.substring(dot + 1) : ref;
|
||||
}
|
||||
|
||||
private static String enumTypePart(String ref) {
|
||||
int dot = ref.lastIndexOf('.');
|
||||
if (dot <= 0) {
|
||||
return null;
|
||||
}
|
||||
return ref.substring(0, dot);
|
||||
}
|
||||
|
||||
private static String simpleTypeName(String typePart) {
|
||||
int dot = typePart.lastIndexOf('.');
|
||||
return dot >= 0 ? typePart.substring(dot + 1) : typePart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true}/{@code false} when both sides are compile-time string literals; otherwise {@code null}.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
public class ConstantResolver {
|
||||
@@ -17,6 +20,40 @@ public class ConstantResolver {
|
||||
return resolveInternal(expr, context, new HashSet<>());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves {@code map.get(key)} when the map receiver and key are statically known.
|
||||
*/
|
||||
public String resolveKeyedMapLookup(MethodInvocation getCall, CodebaseContext context) {
|
||||
if (getCall == null || getCall.getExpression() == null || getCall.arguments().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String methodName = getCall.getName().getIdentifier();
|
||||
if (!"get".equals(methodName) && !"getOrDefault".equals(methodName)) {
|
||||
return null;
|
||||
}
|
||||
return LiteralExpressionSupport.resolveMapGet(
|
||||
getCall, context, new HashSet<>(), (e, v) -> resolveInternal(e, context, v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a value from an encoded {@code MAP:...} literal or map-typed expression at {@code key}.
|
||||
*/
|
||||
public String resolveKeyedMapLookup(Expression mapExpr, String key, CodebaseContext context) {
|
||||
if (mapExpr == null || key == null) {
|
||||
return null;
|
||||
}
|
||||
String encoded = resolveInternal(mapExpr, context, new HashSet<>());
|
||||
if (encoded == null || !encoded.startsWith("MAP:")) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> entries = LiteralExpressionSupport.parseMapLiteral(encoded);
|
||||
return entries != null ? entries.get(key) : null;
|
||||
}
|
||||
|
||||
public static List<String> decodeMapLiteralValues(String encoded) {
|
||||
return LiteralExpressionSupport.mapLiteralValues(encoded);
|
||||
}
|
||||
|
||||
public String evaluateSwitchWithParams(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context) {
|
||||
return evaluateSwitchExpression(se, paramValues, context, new HashSet<>());
|
||||
}
|
||||
@@ -35,6 +72,25 @@ public class ConstantResolver {
|
||||
if (expr instanceof ParenthesizedExpression pe) {
|
||||
return resolveInternal(pe.getExpression(), context, visited);
|
||||
}
|
||||
if (expr instanceof ArrayAccess access) {
|
||||
String indexed = LiteralExpressionSupport.resolveArrayAccess(
|
||||
access, context, visited, (e, v) -> resolveInternal(e, context, v));
|
||||
if (indexed != null) {
|
||||
return indexed;
|
||||
}
|
||||
}
|
||||
if (expr instanceof ArrayCreation creation && creation.getInitializer() != null) {
|
||||
List<String> elements = extractArrayElementsFromCreation(creation, context, visited);
|
||||
if (elements != null) {
|
||||
return LiteralExpressionSupport.encodeArrayLiteral(elements);
|
||||
}
|
||||
}
|
||||
if (expr instanceof ArrayInitializer initializer) {
|
||||
List<String> elements = extractArrayElementsFromInitializer(initializer, context, visited);
|
||||
if (elements != null) {
|
||||
return LiteralExpressionSupport.encodeArrayLiteral(elements);
|
||||
}
|
||||
}
|
||||
if (expr instanceof MethodInvocation mi && (mi.getName().getIdentifier().equals("requireNonNull") || mi.getName().getIdentifier().equals("notNull"))) {
|
||||
if (!mi.arguments().isEmpty()) {
|
||||
return resolveInternal((Expression) mi.arguments().get(0), context, visited);
|
||||
@@ -76,6 +132,10 @@ public class ConstantResolver {
|
||||
return val != null ? val : qn.toString();
|
||||
}
|
||||
if (expr instanceof FieldAccess fa) {
|
||||
String staticField = resolveStaticFieldAccess(fa, context, visited);
|
||||
if (staticField != null) {
|
||||
return staticField;
|
||||
}
|
||||
return resolveManual(fa.getName(), context, visited);
|
||||
}
|
||||
if (expr instanceof SimpleName sn) {
|
||||
@@ -84,7 +144,26 @@ public class ConstantResolver {
|
||||
|
||||
|
||||
if (expr instanceof MethodInvocation mi) {
|
||||
if ((mi.getName().getIdentifier().equals("name") || mi.getName().getIdentifier().equals("toString")) && mi.arguments().isEmpty()) {
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
if ("get".equals(methodName) || "getOrDefault".equals(methodName)) {
|
||||
String mapValue = LiteralExpressionSupport.resolveMapGet(
|
||||
mi, context, visited, (e, v) -> resolveInternal(e, context, v));
|
||||
if (mapValue != null) {
|
||||
return mapValue;
|
||||
}
|
||||
}
|
||||
String transformed = LiteralExpressionSupport.resolveLiteralStringTransform(
|
||||
mi, visited, (e, v) -> resolveInternal(e, context, v));
|
||||
if (transformed != null) {
|
||||
return transformed;
|
||||
}
|
||||
java.util.Map<String, String> mapEntries = LiteralExpressionSupport.extractMapEntries(
|
||||
mi, context, visited, (e, v) -> resolveInternal(e, context, v));
|
||||
if (mapEntries != null && isMapFactoryInvocation(mi)) {
|
||||
return LiteralExpressionSupport.encodeMapLiteral(mapEntries);
|
||||
}
|
||||
|
||||
if ((methodName.equals("name") || methodName.equals("toString")) && mi.arguments().isEmpty()) {
|
||||
String val = resolveInternal(mi.getExpression(), context, visited);
|
||||
if (val != null) {
|
||||
// If it was resolved as a fully qualified enum string like "MyEvents.ORDER_PAID", strip it
|
||||
@@ -266,7 +345,7 @@ public class ConstantResolver {
|
||||
String varName = fragment.getName().getIdentifier();
|
||||
declaredLocals.add(varName);
|
||||
if (fragment.getInitializer() != null) {
|
||||
String rhsVal = resolveExpressionWithParams(fragment.getInitializer(), localVars, context);
|
||||
String rhsVal = resolveExpressionWithParams(fragment.getInitializer(), localVars, context, visited);
|
||||
if (rhsVal == null) rhsVal = resolveInternal(fragment.getInitializer(), context, visited);
|
||||
if (rhsVal != null) {
|
||||
localVars.put(varName, rhsVal);
|
||||
@@ -301,7 +380,7 @@ public class ConstantResolver {
|
||||
}
|
||||
for (int i = 0; i < node.arguments().size() && i < sideEffectMd.parameters().size(); i++) {
|
||||
Expression arg = (Expression) node.arguments().get(i);
|
||||
String val = resolveExpressionWithParams(arg, localVars, context);
|
||||
String val = resolveExpressionWithParams(arg, localVars, context, visited);
|
||||
if (val == null) val = resolveInternal(arg, context, visited);
|
||||
if (val != null) {
|
||||
org.eclipse.jdt.core.dom.SingleVariableDeclaration param = (org.eclipse.jdt.core.dom.SingleVariableDeclaration) sideEffectMd.parameters().get(i);
|
||||
@@ -318,7 +397,7 @@ public class ConstantResolver {
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -331,7 +410,7 @@ public class ConstantResolver {
|
||||
varName = "this." + fa.getName().getIdentifier();
|
||||
}
|
||||
if (varName != null) {
|
||||
String rhsVal = resolveExpressionWithParams(assignment.getRightHandSide(), localVars, context);
|
||||
String rhsVal = resolveExpressionWithParams(assignment.getRightHandSide(), localVars, context, visited);
|
||||
if (rhsVal == null) rhsVal = resolveInternal(assignment.getRightHandSide(), context, visited);
|
||||
if (rhsVal != null) {
|
||||
localVars.put(varName, rhsVal);
|
||||
@@ -371,7 +450,7 @@ public class ConstantResolver {
|
||||
String result = evaluateSwitchExpression(se, localVars, context, visited);
|
||||
if (result != null) finalResult[0] = result;
|
||||
} else if (rs.getExpression() != null) {
|
||||
String result = resolveExpressionWithParams(rs.getExpression(), localVars, context);
|
||||
String result = resolveExpressionWithParams(rs.getExpression(), localVars, context, visited);
|
||||
if (result == null) result = resolveInternal(rs.getExpression(), context, visited);
|
||||
if (result != null) finalResult[0] = result;
|
||||
}
|
||||
@@ -384,7 +463,7 @@ public class ConstantResolver {
|
||||
}
|
||||
|
||||
private String evaluateSwitchStatement(org.eclipse.jdt.core.dom.SwitchStatement ss, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
|
||||
String switchVar = resolveExpressionWithParams(ss.getExpression(), paramValues, context);
|
||||
String switchVar = resolveExpressionWithParams(ss.getExpression(), paramValues, context, visited);
|
||||
if (switchVar == null) {
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
for (Object stmtObj : ss.statements()) {
|
||||
@@ -436,7 +515,7 @@ public class ConstantResolver {
|
||||
matchingCase = true;
|
||||
} else {
|
||||
for (Object exprObj : sc.expressions()) {
|
||||
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context);
|
||||
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context, visited);
|
||||
// Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback
|
||||
if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) {
|
||||
caseVal = caseSn.getIdentifier();
|
||||
@@ -471,7 +550,7 @@ public class ConstantResolver {
|
||||
}
|
||||
|
||||
private String evaluateSwitchExpression(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
|
||||
String switchVar = resolveExpressionWithParams(se.getExpression(), paramValues, context);
|
||||
String switchVar = resolveExpressionWithParams(se.getExpression(), paramValues, context, visited);
|
||||
if (switchVar == null) {
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
for (Object stmtObj : se.statements()) {
|
||||
@@ -523,7 +602,7 @@ public class ConstantResolver {
|
||||
matchingCase = true;
|
||||
} else {
|
||||
for (Object exprObj : sc.expressions()) {
|
||||
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context);
|
||||
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context, visited);
|
||||
// Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback
|
||||
if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) {
|
||||
caseVal = caseSn.getIdentifier();
|
||||
@@ -557,7 +636,8 @@ public class ConstantResolver {
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveExpressionWithParams(Expression expr, java.util.Map<String, String> paramValues, CodebaseContext context) {
|
||||
private String resolveExpressionWithParams(
|
||||
Expression expr, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
|
||||
if (expr instanceof SimpleName sn) {
|
||||
String name = sn.getIdentifier();
|
||||
if (paramValues.containsKey(name)) return paramValues.get(name);
|
||||
@@ -579,7 +659,7 @@ public class ConstantResolver {
|
||||
arg = (Expression) mi.arguments().get(1);
|
||||
}
|
||||
if (arg != null) {
|
||||
String resolvedArg = resolveExpressionWithParams(arg, paramValues, context);
|
||||
String resolvedArg = resolveExpressionWithParams(arg, paramValues, context, visited);
|
||||
if (resolvedArg != null && resolvedArg.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
|
||||
String enumFqn = null;
|
||||
if (mi.arguments().size() == 2) {
|
||||
@@ -626,14 +706,14 @@ public class ConstantResolver {
|
||||
}
|
||||
for (int i = 0; i < mi.arguments().size() && i < sideEffectMd.parameters().size(); i++) {
|
||||
Expression arg = (Expression) mi.arguments().get(i);
|
||||
String val = resolveExpressionWithParams(arg, paramValues, context);
|
||||
if (val == null) val = resolveInternal(arg, context, new java.util.HashSet<>());
|
||||
String val = resolveExpressionWithParams(arg, paramValues, context, visited);
|
||||
if (val == null) val = resolveInternal(arg, context, visited);
|
||||
if (val != null) {
|
||||
org.eclipse.jdt.core.dom.SingleVariableDeclaration param = (org.eclipse.jdt.core.dom.SingleVariableDeclaration) sideEffectMd.parameters().get(i);
|
||||
inlineLocals.put(param.getName().getIdentifier(), val);
|
||||
}
|
||||
}
|
||||
String ret = evaluateMethodBodyWithLocals(sideEffectMd, inlineLocals, context, new java.util.HashSet<>());
|
||||
String ret = evaluateMethodBodyWithLocals(sideEffectMd, inlineLocals, context, visited);
|
||||
for (java.util.Map.Entry<String, String> entry : inlineLocals.entrySet()) {
|
||||
if (entry.getKey().startsWith("this.")) {
|
||||
paramValues.put(entry.getKey(), entry.getValue());
|
||||
@@ -657,7 +737,7 @@ public class ConstantResolver {
|
||||
if (!"build".equals(methodName) && !"builder".equals(methodName) && !"toBuilder".equals(methodName)) {
|
||||
if (current.arguments().size() == 1) {
|
||||
Expression arg = (Expression) current.arguments().get(0);
|
||||
String val = resolveExpressionWithParams(arg, localVars, context);
|
||||
String val = resolveExpressionWithParams(arg, localVars, context, visited);
|
||||
if (val == null) val = resolveInternal(arg, context, visited);
|
||||
if (val != null) fields.put(methodName, val);
|
||||
}
|
||||
@@ -671,6 +751,50 @@ public class ConstantResolver {
|
||||
return fields.isEmpty() ? null : fields;
|
||||
}
|
||||
|
||||
private List<String> extractArrayElementsFromCreation(ArrayCreation creation, CodebaseContext context, Set<String> visited) {
|
||||
if (creation.getInitializer() == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> values = new ArrayList<>();
|
||||
for (Object expressionObj : creation.getInitializer().expressions()) {
|
||||
String value = resolveInternal((Expression) expressionObj, context, visited);
|
||||
if (value == null || value.startsWith("ARRAY:") || value.startsWith("MAP:")) {
|
||||
return null;
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private List<String> extractArrayElementsFromInitializer(ArrayInitializer initializer, CodebaseContext context, Set<String> visited) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (Object expressionObj : initializer.expressions()) {
|
||||
String value = resolveInternal((Expression) expressionObj, context, visited);
|
||||
if (value == null || value.startsWith("ARRAY:") || value.startsWith("MAP:")) {
|
||||
return null;
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private boolean isMapFactoryInvocation(MethodInvocation mi) {
|
||||
if (!"of".equals(mi.getName().getIdentifier()) && !"ofEntries".equals(mi.getName().getIdentifier())) {
|
||||
return false;
|
||||
}
|
||||
Expression receiver = mi.getExpression();
|
||||
if (receiver == null) {
|
||||
return false;
|
||||
}
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
return "Map".equals(sn.getIdentifier());
|
||||
}
|
||||
if (receiver instanceof QualifiedName qn) {
|
||||
return "Map".equals(qn.getName().getIdentifier());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String resolveInfix(InfixExpression expr, CodebaseContext context, Set<String> visited) {
|
||||
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
|
||||
|
||||
@@ -721,7 +845,19 @@ public class ConstantResolver {
|
||||
if (td != null) {
|
||||
String fqn = context.getFqn(td);
|
||||
String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited);
|
||||
if (result != null) return result;
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
for (String classFqn : classesFromCurrentCallPath()) {
|
||||
TypeDeclaration pathTd = context.getTypeDeclaration(classFqn);
|
||||
if (pathTd != null) {
|
||||
String result = resolveFieldInType(pathTd, sn.getIdentifier(), classFqn, context, visited);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Static imports
|
||||
@@ -742,6 +878,9 @@ public class ConstantResolver {
|
||||
|
||||
CompilationUnit contextCu = (CompilationUnit) qn.getRoot();
|
||||
TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu);
|
||||
if (td == null) {
|
||||
td = resolveTypeInContext(qualifier, qn, context);
|
||||
}
|
||||
|
||||
if (td != null) {
|
||||
String fqn = context.getFqn(td);
|
||||
@@ -751,6 +890,81 @@ public class ConstantResolver {
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveStaticFieldAccess(FieldAccess fa, CodebaseContext context, Set<String> visited) {
|
||||
Expression receiver = fa.getExpression();
|
||||
if (receiver instanceof ThisExpression) {
|
||||
return null;
|
||||
}
|
||||
String typeName = null;
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
typeName = sn.getIdentifier();
|
||||
} else if (receiver instanceof QualifiedName qn) {
|
||||
typeName = qn.getFullyQualifiedName();
|
||||
}
|
||||
if (typeName == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration td = resolveTypeInContext(typeName, fa, context);
|
||||
if (td == null) {
|
||||
td = typeFromCurrentCallPath(context);
|
||||
}
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
return resolveFieldInType(td, fa.getName().getIdentifier(), context.getFqn(td), context, visited);
|
||||
}
|
||||
|
||||
private TypeDeclaration resolveTypeInContext(String typeName, ASTNode node, CodebaseContext context) {
|
||||
CompilationUnit contextCu = node.getRoot() instanceof CompilationUnit cu ? cu : null;
|
||||
TypeDeclaration td = contextCu != null
|
||||
? context.getTypeDeclaration(typeName, contextCu)
|
||||
: context.getTypeDeclaration(typeName);
|
||||
if (td != null) {
|
||||
return td;
|
||||
}
|
||||
String entryClass = getCurrentCallPathEntryClass();
|
||||
if (entryClass == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration entryTd = context.getTypeDeclaration(entryClass);
|
||||
if (entryTd == null || !(entryTd.getRoot() instanceof CompilationUnit entryCu)) {
|
||||
return null;
|
||||
}
|
||||
return context.getTypeDeclaration(typeName, entryCu);
|
||||
}
|
||||
|
||||
private TypeDeclaration typeFromCurrentCallPath(CodebaseContext context) {
|
||||
List<String> classes = classesFromCurrentCallPath();
|
||||
if (classes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return context.getTypeDeclaration(classes.get(0));
|
||||
}
|
||||
|
||||
private List<String> classesFromCurrentCallPath() {
|
||||
List<String> path = JdtDataFlowModel.getCurrentPath();
|
||||
if (path == null || path.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> classes = new ArrayList<>();
|
||||
for (int i = path.size() - 1; i >= 0; i--) {
|
||||
String methodFqn = path.get(i);
|
||||
int dot = methodFqn.lastIndexOf('.');
|
||||
if (dot > 0) {
|
||||
String className = methodFqn.substring(0, dot);
|
||||
if (!classes.contains(className)) {
|
||||
classes.add(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
private String getCurrentCallPathEntryClass() {
|
||||
List<String> classes = classesFromCurrentCallPath();
|
||||
return classes.isEmpty() ? null : classes.get(classes.size() - 1);
|
||||
}
|
||||
|
||||
private String resolveFieldInType(TypeDeclaration td, String fieldName, String typeFqn, CodebaseContext context, Set<String> visited) {
|
||||
String fieldId = typeFqn + "#" + fieldName;
|
||||
if (visited.contains(fieldId)) {
|
||||
@@ -860,27 +1074,38 @@ public class ConstantResolver {
|
||||
if (ann instanceof SingleMemberAnnotation sma) {
|
||||
valueExpr = sma.getValue();
|
||||
} else if (ann instanceof NormalAnnotation na) {
|
||||
for (Object pairObj : na.values()) {
|
||||
MemberValuePair pair = (MemberValuePair) pairObj;
|
||||
if ("value".equals(pair.getName().getIdentifier())) {
|
||||
valueExpr = pair.getValue();
|
||||
break;
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
return sl.getLiteralValue();
|
||||
String literal = sl.getLiteralValue();
|
||||
if (literal != null && literal.contains("${")) {
|
||||
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
|
||||
literal, java.util.Collections.emptyMap());
|
||||
}
|
||||
return literal;
|
||||
}
|
||||
return valueExpr != null ? valueExpr.toString().replaceAll("^\"|\"$", "") : null;
|
||||
String raw = valueExpr != null ? valueExpr.toString().replaceAll("^\"|\"$", "") : null;
|
||||
if (raw != null && raw.contains("${")) {
|
||||
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
|
||||
raw, java.util.Collections.emptyMap());
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
private TypeDeclaration findEnclosingType(ASTNode node) {
|
||||
ASTNode current = node;
|
||||
while (current != null && !(current instanceof TypeDeclaration)) {
|
||||
current = current.getParent();
|
||||
}
|
||||
return (TypeDeclaration) current;
|
||||
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
|
||||
}
|
||||
|
||||
private MethodDeclaration findInvokedMethod(org.eclipse.jdt.core.dom.MethodInvocation mi, CodebaseContext context) {
|
||||
@@ -902,14 +1127,7 @@ public class ConstantResolver {
|
||||
}
|
||||
|
||||
private MethodDeclaration findEnclosingMethod(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null) {
|
||||
if (parent instanceof MethodDeclaration md) {
|
||||
return md;
|
||||
}
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return null;
|
||||
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
|
||||
}
|
||||
|
||||
private String resolveLocalType(SimpleName sn) {
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||
import org.eclipse.jdt.core.dom.BooleanLiteral;
|
||||
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||
import org.eclipse.jdt.core.dom.ConditionalExpression;
|
||||
import org.eclipse.jdt.core.dom.EnumConstantDeclaration;
|
||||
import org.eclipse.jdt.core.dom.EnumDeclaration;
|
||||
import org.eclipse.jdt.core.dom.AbstractTypeDeclaration;
|
||||
import org.eclipse.jdt.core.dom.Block;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
import org.eclipse.jdt.core.dom.ExpressionStatement;
|
||||
import org.eclipse.jdt.core.dom.FieldAccess;
|
||||
import org.eclipse.jdt.core.dom.FieldDeclaration;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
|
||||
import org.eclipse.jdt.core.dom.QualifiedName;
|
||||
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||
import org.eclipse.jdt.core.dom.SimpleName;
|
||||
import org.eclipse.jdt.core.dom.SimpleType;
|
||||
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
|
||||
import org.eclipse.jdt.core.dom.SwitchCase;
|
||||
import org.eclipse.jdt.core.dom.SwitchExpression;
|
||||
import org.eclipse.jdt.core.dom.SwitchStatement;
|
||||
import org.eclipse.jdt.core.dom.ThisExpression;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
|
||||
import org.eclipse.jdt.core.dom.YieldStatement;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Evaluates no-arg boolean member calls in dispatcher constraints (e.g. {@code eventType.isEvent()})
|
||||
* against concrete enum constants by reading enum source — no hard-coded predicate names.
|
||||
*/
|
||||
public final class EnumMemberPredicateEvaluator {
|
||||
|
||||
private static final Pattern PREDICATE_PART =
|
||||
Pattern.compile("!?\\s*([a-zA-Z][\\w]*)\\s*\\.\\s*([a-zA-Z][\\w]*)\\s*\\(\\s*\\)");
|
||||
|
||||
private EnumMemberPredicateEvaluator() {
|
||||
}
|
||||
|
||||
public record PredicateCall(String receiverName, String methodName, boolean negated) {
|
||||
}
|
||||
|
||||
public static boolean hasEnumMemberPredicates(String constraint) {
|
||||
return !extractPredicateCalls(constraint).isEmpty();
|
||||
}
|
||||
|
||||
public static List<PredicateCall> extractPredicateCalls(String constraint) {
|
||||
if (constraint == null || constraint.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<PredicateCall> calls = new ArrayList<>();
|
||||
for (String part : splitTopLevelAndParts(constraint)) {
|
||||
Matcher matcher = PREDICATE_PART.matcher(part.trim());
|
||||
if (matcher.matches()) {
|
||||
boolean negated = part.trim().startsWith("!");
|
||||
calls.add(new PredicateCall(matcher.group(1), matcher.group(2), negated));
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
public static List<String> filterEnumConstants(
|
||||
List<String> candidates,
|
||||
String constraint,
|
||||
String enumTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (candidates == null || candidates.isEmpty()) {
|
||||
return candidates == null ? List.of() : candidates;
|
||||
}
|
||||
List<PredicateCall> predicates = extractPredicateCalls(constraint);
|
||||
if (predicates.isEmpty()) {
|
||||
return candidates;
|
||||
}
|
||||
EnumDeclaration enumDecl = findEnumDeclaration(enumTypeFqn, context);
|
||||
if (enumDecl == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> filtered = new ArrayList<>();
|
||||
for (String candidate : candidates) {
|
||||
if (satisfiesPredicates(candidate, predicates, enumDecl, context)) {
|
||||
filtered.add(candidate);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every predicate method in the constraint exists on the enum (or its interfaces).
|
||||
* Used to distinguish inconclusive filtering from an intentional empty result.
|
||||
*/
|
||||
public static boolean hasResolvablePredicateMethods(
|
||||
String constraint,
|
||||
String enumTypeFqn,
|
||||
CodebaseContext context) {
|
||||
List<PredicateCall> predicates = extractPredicateCalls(constraint);
|
||||
if (predicates.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
EnumDeclaration enumDecl = findEnumDeclaration(enumTypeFqn, context);
|
||||
if (enumDecl == null) {
|
||||
return false;
|
||||
}
|
||||
for (PredicateCall predicate : predicates) {
|
||||
MethodDeclaration method = findParameterlessMethod(enumDecl, predicate.methodName());
|
||||
if (method == null && context != null) {
|
||||
method = findInterfaceParameterlessMethod(enumDecl, predicate.methodName(), context);
|
||||
}
|
||||
if (method == null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean satisfiesPredicates(
|
||||
String canonicalConstantFqn,
|
||||
List<PredicateCall> predicates,
|
||||
EnumDeclaration enumDecl,
|
||||
CodebaseContext context) {
|
||||
String constantName = constantSimpleName(canonicalConstantFqn);
|
||||
if (constantName == null) {
|
||||
return false;
|
||||
}
|
||||
Map<String, Boolean> boolFields = resolveConstantBooleanFields(enumDecl, constantName);
|
||||
for (PredicateCall predicate : predicates) {
|
||||
Boolean methodResult = evaluateBooleanMethod(
|
||||
enumDecl, predicate.methodName(), constantName, boolFields, context);
|
||||
if (methodResult == null) {
|
||||
return false;
|
||||
}
|
||||
boolean expected = predicate.negated ? !methodResult : methodResult;
|
||||
if (!expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static Boolean evaluateBooleanMethod(
|
||||
EnumDeclaration enumDecl,
|
||||
String methodName,
|
||||
String currentConstantName,
|
||||
Map<String, Boolean> boolFields,
|
||||
CodebaseContext context) {
|
||||
MethodDeclaration method = findParameterlessMethod(enumDecl, methodName);
|
||||
if (method == null && context != null) {
|
||||
method = findInterfaceParameterlessMethod(enumDecl, methodName, context);
|
||||
}
|
||||
if (method == null || method.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
Boolean switchResult = evaluateSwitchDrivenMethod(
|
||||
method, currentConstantName, boolFields, enumDecl, context);
|
||||
if (switchResult != null) {
|
||||
return switchResult;
|
||||
}
|
||||
Boolean[] result = new Boolean[1];
|
||||
method.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(ReturnStatement node) {
|
||||
if (result[0] == null) {
|
||||
result[0] = evaluateBooleanExpression(
|
||||
node.getExpression(), currentConstantName, boolFields, enumDecl, context);
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return result[0];
|
||||
}
|
||||
|
||||
private static Boolean evaluateSwitchDrivenMethod(
|
||||
MethodDeclaration method,
|
||||
String currentConstantName,
|
||||
Map<String, Boolean> boolFields,
|
||||
EnumDeclaration enumDecl,
|
||||
CodebaseContext context) {
|
||||
Boolean[] result = new Boolean[1];
|
||||
method.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(SwitchStatement node) {
|
||||
if (result[0] == null && isEnumSelfSelector(node.getExpression())) {
|
||||
result[0] = evaluateSwitchLike(
|
||||
node.statements(), currentConstantName, boolFields, enumDecl, context);
|
||||
}
|
||||
return result[0] == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean visit(SwitchExpression node) {
|
||||
if (result[0] == null && isEnumSelfSelector(node.getExpression())) {
|
||||
result[0] = evaluateSwitchLike(
|
||||
node.statements(), currentConstantName, boolFields, enumDecl, context);
|
||||
}
|
||||
return result[0] == null;
|
||||
}
|
||||
});
|
||||
return result[0];
|
||||
}
|
||||
|
||||
static Map<String, Boolean> resolveConstantBooleanFields(EnumDeclaration enumDecl, String constantName) {
|
||||
Map<String, Boolean> fields = new HashMap<>();
|
||||
EnumConstantDeclaration constant = findEnumConstant(enumDecl, constantName);
|
||||
if (constant == null) {
|
||||
return fields;
|
||||
}
|
||||
MethodDeclaration constructor = findEnumConstructor(enumDecl);
|
||||
if (constructor != null && constant.arguments().size() == constructor.parameters().size()) {
|
||||
for (int i = 0; i < constructor.parameters().size(); i++) {
|
||||
SingleVariableDeclaration param =
|
||||
(SingleVariableDeclaration) constructor.parameters().get(i);
|
||||
Expression arg = (Expression) constant.arguments().get(i);
|
||||
Boolean boolVal = literalBoolean(arg);
|
||||
if (boolVal != null) {
|
||||
fields.put(param.getName().getIdentifier(), boolVal);
|
||||
mapFieldName(enumDecl, param.getName().getIdentifier(), boolVal, fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Object bodyObj : enumDecl.bodyDeclarations()) {
|
||||
if (bodyObj instanceof FieldDeclaration fd) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
if (fragObj instanceof VariableDeclarationFragment fragment) {
|
||||
Expression init = fragment.getInitializer();
|
||||
Boolean boolVal = literalBoolean(init);
|
||||
if (boolVal != null) {
|
||||
fields.put(fragment.getName().getIdentifier(), boolVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static void mapFieldName(
|
||||
EnumDeclaration enumDecl,
|
||||
String paramName,
|
||||
Boolean value,
|
||||
Map<String, Boolean> fields) {
|
||||
for (Object bodyObj : enumDecl.bodyDeclarations()) {
|
||||
if (!(bodyObj instanceof FieldDeclaration fd)) {
|
||||
continue;
|
||||
}
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
if (fragObj instanceof VariableDeclarationFragment fragment
|
||||
&& fragment.getName().getIdentifier().equals(paramName)) {
|
||||
fields.put(paramName, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Boolean evaluateBooleanExpression(
|
||||
Expression expr,
|
||||
String currentConstantName,
|
||||
Map<String, Boolean> boolFields,
|
||||
EnumDeclaration enumDecl,
|
||||
CodebaseContext context) {
|
||||
if (expr instanceof BooleanLiteral bl) {
|
||||
return bl.booleanValue();
|
||||
}
|
||||
if (expr instanceof SimpleName sn) {
|
||||
return boolFields.get(sn.getIdentifier());
|
||||
}
|
||||
if (expr instanceof FieldAccess fa) {
|
||||
if (fa.getExpression() == null || fa.getExpression() instanceof ThisExpression) {
|
||||
return boolFields.get(fa.getName().getIdentifier());
|
||||
}
|
||||
}
|
||||
if (expr instanceof ParenthesizedExpression pe) {
|
||||
return evaluateBooleanExpression(
|
||||
pe.getExpression(), currentConstantName, boolFields, enumDecl, context);
|
||||
}
|
||||
if (expr instanceof ConditionalExpression ce) {
|
||||
Boolean thenVal = evaluateBooleanExpression(
|
||||
ce.getThenExpression(), currentConstantName, boolFields, enumDecl, context);
|
||||
Boolean elseVal = evaluateBooleanExpression(
|
||||
ce.getElseExpression(), currentConstantName, boolFields, enumDecl, context);
|
||||
if (thenVal != null && elseVal != null && thenVal.equals(elseVal)) {
|
||||
return thenVal;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (expr instanceof SwitchExpression se && isEnumSelfSelector(se.getExpression())) {
|
||||
return evaluateSwitchLike(se.statements(), currentConstantName, boolFields, enumDecl, context);
|
||||
}
|
||||
if (expr instanceof MethodInvocation mi
|
||||
&& mi.arguments().isEmpty()
|
||||
&& enumDecl != null
|
||||
&& context != null) {
|
||||
return evaluateBooleanMethod(
|
||||
enumDecl, mi.getName().getIdentifier(), currentConstantName, boolFields, context);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Boolean evaluateSwitchLike(
|
||||
List<?> statements,
|
||||
String currentConstantName,
|
||||
Map<String, Boolean> boolFields,
|
||||
EnumDeclaration enumDecl,
|
||||
CodebaseContext context) {
|
||||
List<String> caseConstants = new ArrayList<>();
|
||||
boolean defaultCase = false;
|
||||
boolean sawArmBody = false;
|
||||
boolean pendingCase = false;
|
||||
for (Object statementObj : statements) {
|
||||
if (statementObj instanceof SwitchCase switchCase) {
|
||||
if (sawArmBody) {
|
||||
caseConstants.clear();
|
||||
defaultCase = false;
|
||||
sawArmBody = false;
|
||||
}
|
||||
pendingCase = true;
|
||||
if (switchCase.isDefault()) {
|
||||
defaultCase = true;
|
||||
} else {
|
||||
for (Object exprObj : switchCase.expressions()) {
|
||||
if (exprObj instanceof Expression caseExpr) {
|
||||
String caseConstant = extractCaseConstantName(caseExpr);
|
||||
if (caseConstant != null) {
|
||||
caseConstants.add(caseConstant);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!pendingCase) {
|
||||
continue;
|
||||
}
|
||||
sawArmBody = true;
|
||||
if (!defaultCase && !caseConstants.contains(currentConstantName)) {
|
||||
continue;
|
||||
}
|
||||
Boolean armValue = evaluateSwitchArmNode(
|
||||
statementObj, currentConstantName, boolFields, enumDecl, context);
|
||||
if (armValue != null) {
|
||||
return armValue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Boolean evaluateSwitchArmNode(
|
||||
Object node,
|
||||
String currentConstantName,
|
||||
Map<String, Boolean> boolFields,
|
||||
EnumDeclaration enumDecl,
|
||||
CodebaseContext context) {
|
||||
if (node instanceof ReturnStatement rs) {
|
||||
return evaluateBooleanExpression(
|
||||
rs.getExpression(), currentConstantName, boolFields, enumDecl, context);
|
||||
}
|
||||
if (node instanceof YieldStatement ys) {
|
||||
return evaluateBooleanExpression(
|
||||
ys.getExpression(), currentConstantName, boolFields, enumDecl, context);
|
||||
}
|
||||
if (node instanceof ExpressionStatement es) {
|
||||
return evaluateBooleanExpression(
|
||||
es.getExpression(), currentConstantName, boolFields, enumDecl, context);
|
||||
}
|
||||
if (node instanceof Block block) {
|
||||
return evaluateLinearBooleanStatements(
|
||||
block.statements(), currentConstantName, boolFields, enumDecl, context);
|
||||
}
|
||||
if (node instanceof Expression expression) {
|
||||
return evaluateBooleanExpression(
|
||||
expression, currentConstantName, boolFields, enumDecl, context);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Boolean evaluateLinearBooleanStatements(
|
||||
List<?> statements,
|
||||
String currentConstantName,
|
||||
Map<String, Boolean> boolFields,
|
||||
EnumDeclaration enumDecl,
|
||||
CodebaseContext context) {
|
||||
for (Object statementObj : statements) {
|
||||
Boolean value = evaluateSwitchArmNode(
|
||||
statementObj, currentConstantName, boolFields, enumDecl, context);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isEnumSelfSelector(Expression expr) {
|
||||
if (expr instanceof ThisExpression) {
|
||||
return true;
|
||||
}
|
||||
if (expr instanceof ParenthesizedExpression pe) {
|
||||
return isEnumSelfSelector(pe.getExpression());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String extractCaseConstantName(Expression expr) {
|
||||
if (expr instanceof SimpleName simpleName) {
|
||||
return simpleName.getIdentifier();
|
||||
}
|
||||
if (expr instanceof QualifiedName qualifiedName) {
|
||||
return qualifiedName.getName().getIdentifier();
|
||||
}
|
||||
if (expr instanceof FieldAccess fieldAccess) {
|
||||
return fieldAccess.getName().getIdentifier();
|
||||
}
|
||||
if (expr instanceof ParenthesizedExpression pe) {
|
||||
return extractCaseConstantName(pe.getExpression());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Boolean literalBoolean(Expression expr) {
|
||||
return expr instanceof BooleanLiteral bl ? bl.booleanValue() : null;
|
||||
}
|
||||
|
||||
private static MethodDeclaration findParameterlessMethod(AbstractTypeDeclaration typeDecl, String methodName) {
|
||||
if (typeDecl == null) {
|
||||
return null;
|
||||
}
|
||||
for (Object bodyObj : typeDecl.bodyDeclarations()) {
|
||||
if (bodyObj instanceof MethodDeclaration md
|
||||
&& !md.isConstructor()
|
||||
&& methodName.equals(md.getName().getIdentifier())
|
||||
&& md.parameters().isEmpty()) {
|
||||
return md;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MethodDeclaration findInterfaceParameterlessMethod(
|
||||
EnumDeclaration enumDecl,
|
||||
String methodName,
|
||||
CodebaseContext context) {
|
||||
CompilationUnit cu = enumDecl.getRoot() instanceof CompilationUnit root ? root : null;
|
||||
for (Object typeObj : enumDecl.superInterfaceTypes()) {
|
||||
if (!(typeObj instanceof SimpleType simpleType)) {
|
||||
continue;
|
||||
}
|
||||
String interfaceName = simpleType.getName().getFullyQualifiedName();
|
||||
TypeDeclaration interfaceDecl = context.getTypeDeclaration(interfaceName, cu);
|
||||
MethodDeclaration method = findParameterlessMethod(interfaceDecl, methodName);
|
||||
if (method != null) {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MethodDeclaration findEnumConstructor(EnumDeclaration enumDecl) {
|
||||
MethodDeclaration implicit = null;
|
||||
for (Object bodyObj : enumDecl.bodyDeclarations()) {
|
||||
if (bodyObj instanceof MethodDeclaration md && md.isConstructor()) {
|
||||
if (md.parameters().isEmpty()) {
|
||||
implicit = md;
|
||||
} else {
|
||||
return md;
|
||||
}
|
||||
}
|
||||
}
|
||||
return implicit;
|
||||
}
|
||||
|
||||
private static EnumConstantDeclaration findEnumConstant(EnumDeclaration enumDecl, String constantName) {
|
||||
for (Object constantObj : enumDecl.enumConstants()) {
|
||||
if (constantObj instanceof EnumConstantDeclaration ecd
|
||||
&& constantName.equals(ecd.getName().getIdentifier())) {
|
||||
return ecd;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static EnumDeclaration findEnumDeclaration(String enumTypeFqn, CodebaseContext context) {
|
||||
if (enumTypeFqn == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
String clean = enumTypeFqn.contains("<") ? enumTypeFqn.substring(0, enumTypeFqn.indexOf('<')) : enumTypeFqn;
|
||||
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||
String pkg = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
|
||||
for (Object typeObj : cu.types()) {
|
||||
EnumDeclaration found = findEnumNode(typeObj, clean, pkg, context);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static EnumDeclaration findEnumNode(Object typeObj, String targetFqn, String pkg, CodebaseContext context) {
|
||||
if (typeObj instanceof EnumDeclaration ed) {
|
||||
String fqn = pkg.isEmpty() ? ed.getName().getIdentifier() : pkg + "." + ed.getName().getIdentifier();
|
||||
if (targetFqn.equals(fqn)) {
|
||||
return ed;
|
||||
}
|
||||
} else if (typeObj instanceof TypeDeclaration td) {
|
||||
String parentFqn = pkg.isEmpty() ? td.getName().getIdentifier() : pkg + "." + td.getName().getIdentifier();
|
||||
for (Object decl : td.bodyDeclarations()) {
|
||||
if (decl instanceof EnumDeclaration nested) {
|
||||
String nestedFqn = parentFqn + "." + nested.getName().getIdentifier();
|
||||
if (targetFqn.equals(nestedFqn)) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String constantSimpleName(String canonicalConstantFqn) {
|
||||
if (canonicalConstantFqn == null || !canonicalConstantFqn.contains(".")) {
|
||||
return canonicalConstantFqn;
|
||||
}
|
||||
return canonicalConstantFqn.substring(canonicalConstantFqn.lastIndexOf('.') + 1);
|
||||
}
|
||||
|
||||
private static List<String> splitTopLevelAndParts(String constraint) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
int depth = 0;
|
||||
for (int i = 0; i < constraint.length(); i++) {
|
||||
char c = constraint.charAt(i);
|
||||
if (c == '(') {
|
||||
depth++;
|
||||
} else if (c == ')') {
|
||||
depth--;
|
||||
} else if (depth == 0 && i + 1 < constraint.length()
|
||||
&& c == '&' && constraint.charAt(i + 1) == '&') {
|
||||
parts.add(current.toString());
|
||||
current.setLength(0);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
current.append(c);
|
||||
}
|
||||
if (!current.isEmpty()) {
|
||||
parts.add(current.toString());
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Discovers generated {@code .java} sources under Maven {@code target/} and Gradle {@code build/} when
|
||||
* they already exist in the workspace. Does not run Maven or Gradle.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class GeneratedSourceDiscovery {
|
||||
|
||||
private static final List<String> BUILD_ROOTS = List.of("target", "build");
|
||||
|
||||
private static final List<String> EXCLUDED_RELATIVE_SEGMENTS = List.of(
|
||||
"classes",
|
||||
"test-classes",
|
||||
"generated-test-sources",
|
||||
"generated-test",
|
||||
"test",
|
||||
"testfixtures",
|
||||
"test-fixtures",
|
||||
"androidtest",
|
||||
"inttest",
|
||||
"functionaltest",
|
||||
"e2etest",
|
||||
"libs",
|
||||
"lib",
|
||||
"tmp",
|
||||
"reports",
|
||||
"test-results",
|
||||
"surefire",
|
||||
"failsafe",
|
||||
"surefire-reports",
|
||||
"failsafe-reports",
|
||||
"cache",
|
||||
"caches",
|
||||
"outputs",
|
||||
"intermediates",
|
||||
"kotlin",
|
||||
"native",
|
||||
"resources",
|
||||
"distributions",
|
||||
"docs",
|
||||
"javadoc",
|
||||
"testOutput");
|
||||
|
||||
public Set<Path> discoverSourceRoots(Collection<Path> moduleDirectories) {
|
||||
Set<Path> roots = new LinkedHashSet<>();
|
||||
if (moduleDirectories == null) {
|
||||
return roots;
|
||||
}
|
||||
for (Path moduleDir : moduleDirectories) {
|
||||
if (moduleDir == null || !Files.isDirectory(moduleDir)) {
|
||||
continue;
|
||||
}
|
||||
roots.addAll(discoverSourceRoots(moduleDir));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
public Set<Path> discoverSourceRoots(Path moduleDirectory) {
|
||||
Set<Path> roots = new LinkedHashSet<>();
|
||||
Path normalizedModule = moduleDirectory.toAbsolutePath().normalize();
|
||||
for (String buildRootName : BUILD_ROOTS) {
|
||||
Path buildRoot = normalizedModule.resolve(buildRootName);
|
||||
if (containsGeneratedJava(normalizedModule, buildRoot)) {
|
||||
roots.add(buildRoot);
|
||||
}
|
||||
}
|
||||
if (!roots.isEmpty()) {
|
||||
log.info("Discovered generated source roots under {}: {}", normalizedModule, roots);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
public boolean hasGeneratedSources(Path moduleDirectory) {
|
||||
return !discoverSourceRoots(moduleDirectory).isEmpty();
|
||||
}
|
||||
|
||||
private boolean containsGeneratedJava(Path moduleDirectory, Path buildRoot) {
|
||||
if (!Files.isDirectory(buildRoot)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> files = Files.walk(buildRoot)) {
|
||||
return files.filter(Files::isRegularFile)
|
||||
.anyMatch(path -> path.getFileName().toString().endsWith(".java")
|
||||
&& !isExcludedBuildPath(moduleDirectory, path));
|
||||
} catch (IOException e) {
|
||||
log.debug("Failed to scan generated sources under {}", buildRoot, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isExcludedBuildPath(Path moduleDirectory, Path candidate) {
|
||||
Path normalizedModule = moduleDirectory.toAbsolutePath().normalize();
|
||||
Path normalizedCandidate = candidate.toAbsolutePath().normalize();
|
||||
if (!normalizedCandidate.startsWith(normalizedModule)) {
|
||||
return true;
|
||||
}
|
||||
Path relative = normalizedModule.relativize(normalizedCandidate);
|
||||
if (relative.getNameCount() < 2) {
|
||||
return false;
|
||||
}
|
||||
String buildRoot = relative.getName(0).toString().toLowerCase(Locale.ROOT);
|
||||
if (!buildRoot.equals("target") && !buildRoot.equals("build")) {
|
||||
return true;
|
||||
}
|
||||
for (int i = 1; i < relative.getNameCount(); i++) {
|
||||
String segment = relative.getName(i).toString().toLowerCase(Locale.ROOT);
|
||||
if (isExcludedBuildSegment(segment)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean isExcludedBuildSegment(String segment) {
|
||||
if (segment == null || segment.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String lower = segment.toLowerCase(Locale.ROOT);
|
||||
if (EXCLUDED_RELATIVE_SEGMENTS.contains(lower)) {
|
||||
return true;
|
||||
}
|
||||
return lower.contains("test-sources")
|
||||
|| lower.startsWith("test-")
|
||||
|| lower.endsWith("-test")
|
||||
|| lower.contains("-test-");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
/**
|
||||
* Compile-time literal folding for map lookups, array indexing, and narrow string transforms.
|
||||
*/
|
||||
final class LiteralExpressionSupport {
|
||||
|
||||
private LiteralExpressionSupport() {
|
||||
}
|
||||
|
||||
static String resolveArrayAccess(
|
||||
ArrayAccess access,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
Integer index = resolveLiteralInt(access.getIndex(), context, visited, resolver);
|
||||
if (index == null || index < 0) {
|
||||
return null;
|
||||
}
|
||||
List<String> elements = extractArrayElements(access.getArray(), context, visited, resolver);
|
||||
if (elements == null || index >= elements.size()) {
|
||||
return null;
|
||||
}
|
||||
return elements.get(index);
|
||||
}
|
||||
|
||||
static String resolveMapGet(
|
||||
MethodInvocation getCall,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (getCall.arguments().isEmpty() || getCall.getExpression() == null) {
|
||||
return null;
|
||||
}
|
||||
String key = resolver.apply((Expression) getCall.arguments().get(0), visited);
|
||||
if (key == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> entries = extractMapEntries(getCall.getExpression(), context, visited, resolver);
|
||||
if (entries == null) {
|
||||
return null;
|
||||
}
|
||||
return entries.get(key);
|
||||
}
|
||||
|
||||
static String resolveLiteralStringTransform(
|
||||
MethodInvocation transformCall,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (!transformCall.arguments().isEmpty() || transformCall.getExpression() == null) {
|
||||
return null;
|
||||
}
|
||||
String receiver = resolver.apply(transformCall.getExpression(), visited);
|
||||
if (receiver == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (transformCall.getName().getIdentifier()) {
|
||||
case "toUpperCase" -> receiver.toUpperCase(Locale.ROOT);
|
||||
case "toLowerCase" -> receiver.toLowerCase(Locale.ROOT);
|
||||
case "trim" -> receiver.trim();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
static Integer resolveLiteralInt(
|
||||
Expression expr,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (expr instanceof NumberLiteral nl) {
|
||||
try {
|
||||
return Integer.parseInt(nl.getToken().replace("_", ""));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (expr instanceof ParenthesizedExpression pe) {
|
||||
return resolveLiteralInt(pe.getExpression(), context, visited, resolver);
|
||||
}
|
||||
if (expr instanceof InfixExpression infix) {
|
||||
Integer left = resolveLiteralInt(infix.getLeftOperand(), context, visited, resolver);
|
||||
Integer right = resolveLiteralInt(infix.getRightOperand(), context, visited, resolver);
|
||||
if (left == null || right == null) {
|
||||
return null;
|
||||
}
|
||||
InfixExpression.Operator op = infix.getOperator();
|
||||
if (op == InfixExpression.Operator.PLUS) {
|
||||
return left + right;
|
||||
}
|
||||
if (op == InfixExpression.Operator.MINUS) {
|
||||
return left - right;
|
||||
}
|
||||
}
|
||||
String resolved = resolver.apply(expr, visited);
|
||||
if (resolved != null) {
|
||||
try {
|
||||
return Integer.parseInt(resolved.replace("_", ""));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, String> extractMapEntries(
|
||||
Expression expr,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (expr instanceof MethodInvocation mi) {
|
||||
Map<String, String> fromFactory = extractMapFactoryEntries(mi, context, visited, resolver);
|
||||
if (fromFactory != null) {
|
||||
return fromFactory;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
String resolvedField = resolver.apply(expr, visited);
|
||||
if (resolvedField != null && resolvedField.startsWith("MAP:")) {
|
||||
return parseMapLiteral(resolvedField);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, String> extractMapFactoryEntries(
|
||||
MethodInvocation mi,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (!isMapFactory(mi.getExpression())) {
|
||||
return null;
|
||||
}
|
||||
String method = mi.getName().getIdentifier();
|
||||
if ("of".equals(method)) {
|
||||
return extractAlternatingPairs(mi.arguments(), context, visited, resolver);
|
||||
}
|
||||
if ("ofEntries".equals(method)) {
|
||||
Map<String, String> entries = new LinkedHashMap<>();
|
||||
for (Object argObj : mi.arguments()) {
|
||||
if (argObj instanceof Expression entryExpr) {
|
||||
Map<String, String> single = extractMapEntryCall(entryExpr, context, visited, resolver);
|
||||
if (single != null) {
|
||||
entries.putAll(single);
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries.isEmpty() ? null : entries;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, String> extractMapEntryCall(
|
||||
Expression expr,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (!(expr instanceof MethodInvocation entryCall) || !"entry".equals(entryCall.getName().getIdentifier())) {
|
||||
return null;
|
||||
}
|
||||
if (!isMapFactory(entryCall.getExpression()) || entryCall.arguments().size() != 2) {
|
||||
return null;
|
||||
}
|
||||
String key = resolver.apply((Expression) entryCall.arguments().get(0), visited);
|
||||
String value = resolver.apply((Expression) entryCall.arguments().get(1), visited);
|
||||
if (key == null || value == null) {
|
||||
return null;
|
||||
}
|
||||
return Map.of(key, value);
|
||||
}
|
||||
|
||||
private static Map<String, String> extractAlternatingPairs(
|
||||
List<?> arguments,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (arguments.size() < 2) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> entries = new LinkedHashMap<>();
|
||||
for (int i = 0; i + 1 < arguments.size(); i += 2) {
|
||||
String key = resolver.apply((Expression) arguments.get(i), visited);
|
||||
String value = resolver.apply((Expression) arguments.get(i + 1), visited);
|
||||
if (key == null || value == null) {
|
||||
return null;
|
||||
}
|
||||
entries.put(key, value);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
static String encodeMapLiteral(Map<String, String> entries) {
|
||||
StringBuilder sb = new StringBuilder("MAP:");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> entry : entries.entrySet()) {
|
||||
if (!first) {
|
||||
sb.append('|');
|
||||
}
|
||||
first = false;
|
||||
sb.append(escape(entry.getKey())).append('=').append(escape(entry.getValue()));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
static Map<String, String> parseMapLiteral(String encoded) {
|
||||
if (encoded == null || !encoded.startsWith("MAP:")) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> entries = new LinkedHashMap<>();
|
||||
for (String pair : encoded.substring(4).split("\\|")) {
|
||||
int eq = pair.indexOf('=');
|
||||
if (eq <= 0) {
|
||||
continue;
|
||||
}
|
||||
entries.put(unescape(pair.substring(0, eq)), unescape(pair.substring(eq + 1)));
|
||||
}
|
||||
return entries.isEmpty() ? null : entries;
|
||||
}
|
||||
|
||||
static List<String> mapLiteralValues(String encoded) {
|
||||
Map<String, String> entries = parseMapLiteral(encoded);
|
||||
if (entries == null) {
|
||||
return null;
|
||||
}
|
||||
return List.copyOf(entries.values());
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value.replace("\\", "\\\\").replace("|", "\\|").replace("=", "\\=");
|
||||
}
|
||||
|
||||
private static String unescape(String value) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (c == '\\' && i + 1 < value.length()) {
|
||||
sb.append(value.charAt(++i));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static List<String> extractArrayElements(
|
||||
Expression arrayExpr,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
if (arrayExpr instanceof ArrayCreation creation && creation.getInitializer() != null) {
|
||||
return extractExpressionList(creation.getInitializer().expressions(), context, visited, resolver);
|
||||
}
|
||||
if (arrayExpr instanceof ArrayInitializer initializer) {
|
||||
return extractExpressionList(initializer.expressions(), context, visited, resolver);
|
||||
}
|
||||
String resolved = resolver.apply(arrayExpr, visited);
|
||||
if (resolved != null && resolved.startsWith("ARRAY:")) {
|
||||
return List.of(resolved.substring(6).split("\\|", -1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String encodeArrayLiteral(List<String> values) {
|
||||
return "ARRAY:" + String.join("|", values.stream().map(LiteralExpressionSupport::escape).toList());
|
||||
}
|
||||
|
||||
private static List<String> extractExpressionList(
|
||||
List<?> expressions,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
BiFunction<Expression, Set<String>, String> resolver) {
|
||||
List<String> values = new ArrayList<>();
|
||||
for (Object expressionObj : expressions) {
|
||||
String value = resolver.apply((Expression) expressionObj, visited);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static boolean isMapFactory(Expression expression) {
|
||||
if (expression == null) {
|
||||
return false;
|
||||
}
|
||||
if (expression instanceof SimpleName sn) {
|
||||
return "Map".equals(sn.getIdentifier());
|
||||
}
|
||||
if (expression instanceof QualifiedName qn) {
|
||||
return "Map".equals(qn.getName().getIdentifier());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Queue;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Static wiring model built from parsed {@code settings.gradle}, {@code build.gradle}, and {@code pom.xml}
|
||||
* files — no Maven/Gradle execution.
|
||||
*/
|
||||
public final class ProjectModuleGraph {
|
||||
|
||||
public enum BuildSystem {
|
||||
GRADLE,
|
||||
MAVEN,
|
||||
MIXED
|
||||
}
|
||||
|
||||
public enum DependencyKind {
|
||||
LOCAL_PROJECT,
|
||||
EXTERNAL_LIBRARY
|
||||
}
|
||||
|
||||
public record MavenCoordinates(String groupId, String artifactId) {
|
||||
public MavenCoordinates {
|
||||
Objects.requireNonNull(groupId, "groupId");
|
||||
Objects.requireNonNull(artifactId, "artifactId");
|
||||
}
|
||||
|
||||
public String key() {
|
||||
return groupId + ":" + artifactId;
|
||||
}
|
||||
}
|
||||
|
||||
public record ProjectModule(
|
||||
String id,
|
||||
Path directory,
|
||||
BuildSystem buildSystem,
|
||||
MavenCoordinates mavenCoordinates) {
|
||||
|
||||
public Optional<MavenCoordinates> coordinates() {
|
||||
return Optional.ofNullable(mavenCoordinates);
|
||||
}
|
||||
}
|
||||
|
||||
public record ModuleDependency(
|
||||
String fromModuleId,
|
||||
String toModuleId,
|
||||
MavenCoordinates externalCoordinates,
|
||||
DependencyKind kind) {
|
||||
}
|
||||
|
||||
private final Path workspaceRoot;
|
||||
private final Map<String, ProjectModule> modulesById;
|
||||
private final Map<MavenCoordinates, ProjectModule> modulesByCoordinates;
|
||||
private final List<ModuleDependency> dependencies;
|
||||
|
||||
public ProjectModuleGraph(
|
||||
Path workspaceRoot,
|
||||
Map<String, ProjectModule> modulesById,
|
||||
Map<MavenCoordinates, ProjectModule> modulesByCoordinates,
|
||||
List<ModuleDependency> dependencies) {
|
||||
this.workspaceRoot = workspaceRoot != null ? workspaceRoot.normalize() : null;
|
||||
this.modulesById = Map.copyOf(modulesById);
|
||||
this.modulesByCoordinates = Map.copyOf(modulesByCoordinates);
|
||||
this.dependencies = List.copyOf(dependencies);
|
||||
}
|
||||
|
||||
public Path workspaceRoot() {
|
||||
return workspaceRoot;
|
||||
}
|
||||
|
||||
public Map<String, ProjectModule> modulesById() {
|
||||
return modulesById;
|
||||
}
|
||||
|
||||
public Map<MavenCoordinates, ProjectModule> modulesByCoordinates() {
|
||||
return modulesByCoordinates;
|
||||
}
|
||||
|
||||
public List<ModuleDependency> dependencies() {
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
public Optional<ProjectModule> findByDirectory(Path directory) {
|
||||
if (directory == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Path normalized = directory.toAbsolutePath().normalize();
|
||||
return modulesById.values().stream()
|
||||
.filter(module -> module.directory().toAbsolutePath().normalize().equals(normalized))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
public Optional<ProjectModule> findContainingModule(Path path) {
|
||||
if (path == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Path normalized = path.toAbsolutePath().normalize();
|
||||
ProjectModule best = null;
|
||||
int bestLength = -1;
|
||||
for (ProjectModule module : modulesById.values()) {
|
||||
Path moduleDir = module.directory().toAbsolutePath().normalize();
|
||||
if (normalized.startsWith(moduleDir) && moduleDir.getNameCount() > bestLength) {
|
||||
best = module;
|
||||
bestLength = moduleDir.getNameCount();
|
||||
}
|
||||
}
|
||||
return Optional.ofNullable(best);
|
||||
}
|
||||
|
||||
public Set<Path> allModuleDirectories() {
|
||||
Set<Path> paths = new LinkedHashSet<>();
|
||||
for (ProjectModule module : modulesById.values()) {
|
||||
if (Files.isDirectory(module.directory())) {
|
||||
paths.add(module.directory().toAbsolutePath().normalize());
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
public boolean isGradleAggregatorRoot(Path directory) {
|
||||
if (directory == null || workspaceRoot == null) {
|
||||
return false;
|
||||
}
|
||||
return directory.toAbsolutePath().normalize().equals(workspaceRoot)
|
||||
&& modulesById.values().stream().anyMatch(module -> module.buildSystem() == BuildSystem.GRADLE);
|
||||
}
|
||||
|
||||
public boolean isMavenAggregatorRoot(Path directory) {
|
||||
if (directory == null) {
|
||||
return false;
|
||||
}
|
||||
Path normalized = directory.toAbsolutePath().normalize();
|
||||
long childModules = modulesById.values().stream()
|
||||
.filter(module -> module.directory().toAbsolutePath().normalize().startsWith(normalized))
|
||||
.filter(module -> !module.directory().toAbsolutePath().normalize().equals(normalized))
|
||||
.count();
|
||||
return childModules > 0 && findByDirectory(normalized)
|
||||
.map(module -> module.buildSystem() == BuildSystem.MAVEN || module.buildSystem() == BuildSystem.MIXED)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves scan roots for analysis: the input directory, all Gradle includes when scanning from the
|
||||
* workspace root, and transitively reachable local modules from build-file wiring.
|
||||
*/
|
||||
public Set<Path> resolveScanPaths(Path inputDir) {
|
||||
return resolveScanPaths(inputDir, true);
|
||||
}
|
||||
|
||||
public Set<Path> resolveScanPaths(Path inputDir, boolean includeGeneratedSources) {
|
||||
if (inputDir == null) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<Path> scanPaths = new LinkedHashSet<>();
|
||||
Path normalizedInput = inputDir.toAbsolutePath().normalize();
|
||||
scanPaths.add(normalizedInput);
|
||||
|
||||
if (workspaceRoot != null && normalizedInput.equals(workspaceRoot)) {
|
||||
scanPaths.addAll(allModuleDirectories());
|
||||
}
|
||||
|
||||
Optional<ProjectModule> inputModule = findContainingModule(normalizedInput);
|
||||
if (inputModule.isPresent()) {
|
||||
scanPaths.addAll(transitiveLocalModuleDirectories(inputModule.get().id()));
|
||||
resolveMavenParentDirectory(inputModule.get().directory()).ifPresent(scanPaths::add);
|
||||
Path parentDir = inputModule.get().directory().getParent();
|
||||
if (parentDir != null && Files.exists(parentDir.resolve("pom.xml"))) {
|
||||
scanPaths.add(parentDir.toAbsolutePath().normalize());
|
||||
}
|
||||
}
|
||||
|
||||
if (includeGeneratedSources) {
|
||||
scanPaths.addAll(new GeneratedSourceDiscovery().discoverSourceRoots(scanPaths));
|
||||
}
|
||||
return scanPaths;
|
||||
}
|
||||
|
||||
static Optional<Path> resolveMavenParentDirectory(Path moduleDirectory) {
|
||||
if (moduleDirectory == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Path pom = moduleDirectory.resolve("pom.xml");
|
||||
if (!Files.exists(pom)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
String content = Files.readString(pom);
|
||||
Matcher parentMatcher = Pattern.compile(
|
||||
"<parent>.*?<relativePath>(.*?)</relativePath>.*?</parent>", Pattern.DOTALL).matcher(content);
|
||||
if (!parentMatcher.find()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String relPathStr = parentMatcher.group(1).trim();
|
||||
Path relPath = moduleDirectory.resolve(relPathStr).normalize();
|
||||
if (Files.isRegularFile(relPath) && "pom.xml".equals(relPath.getFileName().toString())) {
|
||||
return Optional.of(relPath.getParent().toAbsolutePath().normalize());
|
||||
}
|
||||
if (Files.isDirectory(relPath)) {
|
||||
return Optional.of(relPath.toAbsolutePath().normalize());
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public Set<Path> transitiveLocalModuleDirectories(String rootModuleId) {
|
||||
Set<Path> reachable = new LinkedHashSet<>();
|
||||
Queue<String> queue = new ArrayDeque<>();
|
||||
Set<String> visited = new HashSet<>();
|
||||
if (rootModuleId != null) {
|
||||
queue.add(rootModuleId);
|
||||
}
|
||||
while (!queue.isEmpty()) {
|
||||
String current = queue.poll();
|
||||
if (!visited.add(current)) {
|
||||
continue;
|
||||
}
|
||||
ProjectModule module = modulesById.get(current);
|
||||
if (module != null && Files.isDirectory(module.directory())) {
|
||||
reachable.add(module.directory().toAbsolutePath().normalize());
|
||||
}
|
||||
for (ModuleDependency dependency : dependencies) {
|
||||
if (dependency.kind() == DependencyKind.LOCAL_PROJECT
|
||||
&& current.equals(dependency.fromModuleId())
|
||||
&& dependency.toModuleId() != null) {
|
||||
queue.add(dependency.toModuleId());
|
||||
}
|
||||
}
|
||||
}
|
||||
return reachable;
|
||||
}
|
||||
|
||||
public List<ModuleDependency> localDependenciesOf(String moduleId) {
|
||||
List<ModuleDependency> result = new ArrayList<>();
|
||||
for (ModuleDependency dependency : dependencies) {
|
||||
if (moduleId.equals(dependency.fromModuleId())) {
|
||||
result.add(dependency);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -3,224 +3,41 @@ package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Discovers related local modules by statically parsing Maven/Gradle build files (never executes build tools).
|
||||
*/
|
||||
@Slf4j
|
||||
public class SiblingDependencyResolver {
|
||||
|
||||
private static final Pattern GRADLE_PROJECT_DEP = Pattern.compile("project\\(['\"]:(.*?)['\"]\\)");
|
||||
private static final Pattern MAVEN_DEPENDENCY = Pattern.compile("<dependency>\\s*<groupId>(.*?)</groupId>\\s*<artifactId>(.*?)</artifactId>\\s*<version>(.*?)</version>", Pattern.DOTALL);
|
||||
private final StaticBuildFileAnalyzer analyzer = new StaticBuildFileAnalyzer();
|
||||
|
||||
public ProjectModuleGraph analyzeProject(Path inputDir) throws IOException {
|
||||
return analyzer.analyze(inputDir);
|
||||
}
|
||||
|
||||
public Set<Path> resolveSiblings(Path projectDir) {
|
||||
Set<Path> allSiblings = new HashSet<>();
|
||||
Queue<Path> queue = new LinkedList<>();
|
||||
queue.add(projectDir);
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
Path current = queue.poll();
|
||||
Set<Path> currentSiblings = new HashSet<>();
|
||||
try {
|
||||
currentSiblings.addAll(resolveGradleSiblings(current));
|
||||
currentSiblings.addAll(resolveMavenSiblings(current));
|
||||
} catch (Exception e) {
|
||||
log.warn("Error resolving sibling dependencies for {}", current, e);
|
||||
}
|
||||
|
||||
for (Path sibling : currentSiblings) {
|
||||
if (!allSiblings.contains(sibling) && !sibling.equals(projectDir)) {
|
||||
allSiblings.add(sibling);
|
||||
queue.add(sibling);
|
||||
}
|
||||
}
|
||||
try {
|
||||
ProjectModuleGraph graph = analyzer.analyze(projectDir);
|
||||
return resolveSiblings(graph, projectDir);
|
||||
} catch (IOException e) {
|
||||
log.warn("Error resolving sibling dependencies for {}", projectDir, e);
|
||||
return Set.of();
|
||||
}
|
||||
return allSiblings;
|
||||
}
|
||||
|
||||
private Set<Path> resolveGradleSiblings(Path projectDir) throws IOException {
|
||||
Set<Path> siblings = new HashSet<>();
|
||||
Path buildGradle = projectDir.resolve("build.gradle");
|
||||
if (!Files.exists(buildGradle)) {
|
||||
buildGradle = projectDir.resolve("build.gradle.kts");
|
||||
}
|
||||
|
||||
if (Files.exists(buildGradle)) {
|
||||
log.info("Found build.gradle at {}", buildGradle);
|
||||
String content = Files.readString(buildGradle);
|
||||
Matcher matcher = GRADLE_PROJECT_DEP.matcher(content);
|
||||
Set<String> gradlePaths = new HashSet<>();
|
||||
while (matcher.find()) {
|
||||
gradlePaths.add(matcher.group(1));
|
||||
}
|
||||
log.info("Found project dependencies: {}", gradlePaths);
|
||||
|
||||
if (!gradlePaths.isEmpty()) {
|
||||
Path rootDir = findGradleRoot(projectDir);
|
||||
log.info("Resolved Gradle root to: {}", rootDir);
|
||||
if (rootDir != null) {
|
||||
for (String gradlePath : gradlePaths) {
|
||||
String relativePath = gradlePath.replace(':', '/');
|
||||
Path resolvedSibling = rootDir.resolve(relativePath).normalize();
|
||||
|
||||
// Check for explicit projectDir mapping in settings.gradle or settings.gradle.kts
|
||||
Path settingsFile = rootDir.resolve("settings.gradle");
|
||||
if (!Files.exists(settingsFile)) {
|
||||
settingsFile = rootDir.resolve("settings.gradle.kts");
|
||||
}
|
||||
if (Files.exists(settingsFile)) {
|
||||
String settingsContent = Files.readString(settingsFile);
|
||||
String regex = "project\\(['\"]:" + gradlePath + "['\"]\\)\\.projectDir\\s*=\\s*(?:file\\(['\"](.*?)['\"]\\)|new File\\(.*?,\\s*['\"](.*?)['\"]\\))";
|
||||
Matcher settingsMatcher = Pattern.compile(regex).matcher(settingsContent);
|
||||
if (settingsMatcher.find()) {
|
||||
String customPath = settingsMatcher.group(1) != null ? settingsMatcher.group(1) : settingsMatcher.group(2);
|
||||
resolvedSibling = rootDir.resolve(customPath).normalize();
|
||||
log.info("Found custom projectDir in settings for {}: {}", gradlePath, resolvedSibling);
|
||||
}
|
||||
}
|
||||
|
||||
if (Files.exists(resolvedSibling)) {
|
||||
log.info("Discovered local Gradle sibling dependency: {}", resolvedSibling);
|
||||
siblings.add(resolvedSibling);
|
||||
}
|
||||
}
|
||||
}
|
||||
public Set<Path> resolveSiblings(ProjectModuleGraph graph, Path inputDir) {
|
||||
Set<Path> scanPaths = graph.resolveScanPaths(inputDir);
|
||||
Set<Path> siblings = new LinkedHashSet<>();
|
||||
Path normalizedInput = inputDir.toAbsolutePath().normalize();
|
||||
for (Path path : scanPaths) {
|
||||
if (!path.equals(normalizedInput)) {
|
||||
siblings.add(path);
|
||||
}
|
||||
}
|
||||
return siblings;
|
||||
}
|
||||
|
||||
private Path findGradleRoot(Path start) {
|
||||
Path current = start.toAbsolutePath();
|
||||
Path lastSettingsDir = null;
|
||||
while (current != null) {
|
||||
if (Files.exists(current.resolve("settings.gradle")) || Files.exists(current.resolve("settings.gradle.kts"))) {
|
||||
lastSettingsDir = current;
|
||||
}
|
||||
if (Files.exists(current.resolve(".git"))) {
|
||||
return lastSettingsDir != null ? lastSettingsDir : current;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return lastSettingsDir;
|
||||
}
|
||||
|
||||
private Set<Path> resolveMavenSiblings(Path projectDir) throws IOException {
|
||||
Set<Path> siblings = new HashSet<>();
|
||||
Path pom = projectDir.resolve("pom.xml");
|
||||
if (Files.exists(pom)) {
|
||||
log.info("Found pom.xml at {}", pom);
|
||||
String content = Files.readString(pom);
|
||||
|
||||
Set<String> targetArtifacts = new HashSet<>();
|
||||
Matcher matcher = Pattern.compile("<artifactId>(.*?)</artifactId>").matcher(content);
|
||||
// We want artifacts from the <dependencies> section
|
||||
int depsStart = content.indexOf("<dependencies>");
|
||||
int depsEnd = content.indexOf("</dependencies>");
|
||||
if (depsStart != -1 && depsEnd > depsStart) {
|
||||
String depsContent = content.substring(depsStart, depsEnd);
|
||||
Matcher depMatcher = Pattern.compile("<artifactId>(.*?)</artifactId>").matcher(depsContent);
|
||||
while (depMatcher.find()) {
|
||||
targetArtifacts.add(depMatcher.group(1));
|
||||
}
|
||||
}
|
||||
log.info("Found maven target artifacts: {}", targetArtifacts);
|
||||
|
||||
if (!targetArtifacts.isEmpty()) {
|
||||
Path rootDir = findMavenRoot(projectDir);
|
||||
log.info("Resolved Maven root to: {}", rootDir);
|
||||
if (rootDir != null) {
|
||||
try (Stream<Path> paths = Files.walk(rootDir)) {
|
||||
List<Path> allPoms = paths.filter(p -> p.getFileName().toString().equals("pom.xml"))
|
||||
.filter(p -> !p.toString().contains("/target/"))
|
||||
.collect(Collectors.toList());
|
||||
log.info("Scanned {} total pom.xml files under {}", allPoms.size(), rootDir);
|
||||
|
||||
for (Path p : allPoms) {
|
||||
try {
|
||||
String pContent = Files.readString(p);
|
||||
String artifactId = extractOwnArtifactId(pContent);
|
||||
log.info("Checking artifactId {} in {}", artifactId, p);
|
||||
if (artifactId != null && targetArtifacts.contains(artifactId)) {
|
||||
Path siblingDir = p.getParent();
|
||||
if (!siblingDir.equals(projectDir)) {
|
||||
log.info("Discovered local Maven sibling dependency via artifact match: {}", siblingDir);
|
||||
siblings.add(siblingDir);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also directly add aggregator <module> directories declared in this pom
|
||||
Matcher moduleMatcher = Pattern.compile("<module>(.*?)</module>").matcher(content);
|
||||
while (moduleMatcher.find()) {
|
||||
String moduleName = moduleMatcher.group(1).trim();
|
||||
Path moduleDir = projectDir.resolve(moduleName).normalize();
|
||||
if (Files.exists(moduleDir) && Files.exists(moduleDir.resolve("pom.xml")) && !moduleDir.equals(projectDir)) {
|
||||
log.info("Discovered local Maven sibling dependency via <module>: {}", moduleDir);
|
||||
siblings.add(moduleDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Also add parent dir if it has a pom.xml
|
||||
Path parentDir = projectDir.getParent();
|
||||
|
||||
// Check for explicit <relativePath> in parent block
|
||||
Matcher parentRelativePathMatcher = Pattern.compile("<parent>.*?<relativePath>(.*?)</relativePath>.*?</parent>", Pattern.DOTALL).matcher(content);
|
||||
if (parentRelativePathMatcher.find()) {
|
||||
String relPathStr = parentRelativePathMatcher.group(1).trim();
|
||||
Path relPath = projectDir.resolve(relPathStr).normalize();
|
||||
if (Files.isRegularFile(relPath) && relPath.getFileName().toString().equals("pom.xml")) {
|
||||
parentDir = relPath.getParent();
|
||||
} else if (Files.isDirectory(relPath)) {
|
||||
parentDir = relPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (parentDir != null && Files.exists(parentDir.resolve("pom.xml")) && !parentDir.equals(projectDir)) {
|
||||
log.info("Discovered local Maven parent sibling: {}", parentDir);
|
||||
siblings.add(parentDir);
|
||||
}
|
||||
}
|
||||
return siblings;
|
||||
}
|
||||
|
||||
private String extractOwnArtifactId(String pomContent) {
|
||||
// Strip <parent> block if it exists
|
||||
String content = pomContent;
|
||||
int parentEnd = pomContent.indexOf("</parent>");
|
||||
if (parentEnd != -1) {
|
||||
content = pomContent.substring(parentEnd);
|
||||
}
|
||||
|
||||
Matcher matcher = Pattern.compile("<artifactId>(.*?)</artifactId>").matcher(content);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Path findMavenRoot(Path start) {
|
||||
Path current = start.toAbsolutePath();
|
||||
Path lastPomDir = null;
|
||||
while (current != null) {
|
||||
if (Files.exists(current.resolve("pom.xml"))) {
|
||||
lastPomDir = current;
|
||||
}
|
||||
if (Files.exists(current.resolve(".git"))) {
|
||||
return lastPomDir != null ? lastPomDir : current;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return lastPomDir;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Parses Maven/Gradle build files statically to discover modules and local dependency wiring.
|
||||
*/
|
||||
@Slf4j
|
||||
public class StaticBuildFileAnalyzer {
|
||||
|
||||
private static final Pattern GRADLE_PROJECT_DEP = Pattern.compile("project\\(['\"]:(.*?)['\"]\\)");
|
||||
private static final Pattern QUOTED_TOKEN = Pattern.compile("['\"]([^'\"]+)['\"]");
|
||||
private static final Pattern GRADLE_PROJECT_DIR = Pattern.compile(
|
||||
"project\\(['\"]:(.*?)['\"]\\)\\.projectDir\\s*=\\s*(?:file\\(['\"](.*?)['\"]\\)|new File\\(.*?,\\s*['\"](.*?)['\"]\\))");
|
||||
private static final Pattern MAVEN_MODULE = Pattern.compile("<module>(.*?)</module>");
|
||||
private static final Pattern MAVEN_DEPENDENCY_BLOCK = Pattern.compile("<dependency>(.*?)</dependency>", Pattern.DOTALL);
|
||||
private static final Pattern MAVEN_TAG = Pattern.compile("<(groupId|artifactId)>(.*?)</\\1>");
|
||||
|
||||
public ProjectModuleGraph analyze(Path inputDir) throws IOException {
|
||||
Path workspaceRoot = findWorkspaceRoot(inputDir);
|
||||
Path indexRoot = findIndexingRoot(inputDir, workspaceRoot);
|
||||
if (workspaceRoot == null) {
|
||||
workspaceRoot = inputDir.toAbsolutePath().normalize();
|
||||
}
|
||||
if (indexRoot == null) {
|
||||
indexRoot = workspaceRoot;
|
||||
}
|
||||
|
||||
Map<String, ProjectModuleGraph.ProjectModule> modulesById = new LinkedHashMap<>();
|
||||
Map<ProjectModuleGraph.MavenCoordinates, ProjectModuleGraph.ProjectModule> modulesByCoordinates = new LinkedHashMap<>();
|
||||
List<ProjectModuleGraph.ModuleDependency> dependencies = new ArrayList<>();
|
||||
|
||||
Path gradleSettingsRoot = Files.exists(workspaceRoot.resolve("settings.gradle"))
|
||||
|| Files.exists(workspaceRoot.resolve("settings.gradle.kts"))
|
||||
? workspaceRoot
|
||||
: indexRoot;
|
||||
Map<String, Path> gradleModulePaths = parseGradleIncludes(gradleSettingsRoot);
|
||||
for (Map.Entry<String, Path> entry : gradleModulePaths.entrySet()) {
|
||||
registerGradleModule(entry.getKey(), entry.getValue(), modulesById);
|
||||
}
|
||||
|
||||
List<Path> pomFiles = findPomFiles(indexRoot);
|
||||
for (Path pom : pomFiles) {
|
||||
registerMavenSubmodules(pom, modulesById, modulesByCoordinates);
|
||||
}
|
||||
for (Path pom : pomFiles) {
|
||||
registerMavenModule(pom, modulesById, modulesByCoordinates);
|
||||
}
|
||||
|
||||
ensureInputModule(inputDir, modulesById);
|
||||
|
||||
for (ProjectModuleGraph.ProjectModule module : List.copyOf(modulesById.values())) {
|
||||
if (module.buildSystem() == ProjectModuleGraph.BuildSystem.GRADLE
|
||||
|| module.buildSystem() == ProjectModuleGraph.BuildSystem.MIXED) {
|
||||
dependencies.addAll(parseGradleDependencies(module, modulesById, gradleModulePaths));
|
||||
}
|
||||
}
|
||||
for (Path pom : pomFiles) {
|
||||
dependencies.addAll(parseMavenDependencies(pom, modulesById, modulesByCoordinates));
|
||||
}
|
||||
|
||||
log.info("Static project graph: {} modules, {} dependencies under {}", modulesById.size(), dependencies.size(), workspaceRoot);
|
||||
return new ProjectModuleGraph(workspaceRoot, modulesById, modulesByCoordinates, dependencies);
|
||||
}
|
||||
|
||||
Map<String, Path> parseGradleIncludes(Path gradleRoot) throws IOException {
|
||||
Map<String, Path> includes = new LinkedHashMap<>();
|
||||
if (gradleRoot == null) {
|
||||
return includes;
|
||||
}
|
||||
Path settingsFile = gradleRoot.resolve("settings.gradle");
|
||||
if (!Files.exists(settingsFile)) {
|
||||
settingsFile = gradleRoot.resolve("settings.gradle.kts");
|
||||
}
|
||||
if (!Files.exists(settingsFile)) {
|
||||
return includes;
|
||||
}
|
||||
|
||||
String content = Files.readString(settingsFile);
|
||||
for (String includePath : extractGradleIncludePaths(content)) {
|
||||
String normalized = normalizeGradlePath(includePath);
|
||||
Path moduleDir = resolveGradleModuleDirectory(gradleRoot, normalized, content);
|
||||
if (Files.isDirectory(moduleDir)) {
|
||||
includes.put(normalized, moduleDir.normalize());
|
||||
}
|
||||
}
|
||||
return includes;
|
||||
}
|
||||
|
||||
static Set<String> extractGradleIncludePaths(String settingsContent) {
|
||||
Set<String> paths = new LinkedHashSet<>();
|
||||
for (String line : settingsContent.split("\\R")) {
|
||||
if (!line.contains("include")) {
|
||||
continue;
|
||||
}
|
||||
int includeIndex = line.indexOf("include");
|
||||
Matcher tokenMatcher = QUOTED_TOKEN.matcher(line);
|
||||
while (tokenMatcher.find()) {
|
||||
if (tokenMatcher.start() > includeIndex) {
|
||||
paths.add(normalizeGradlePath(tokenMatcher.group(1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
private static String normalizeGradlePath(String includePath) {
|
||||
String path = includePath.trim();
|
||||
if (path.startsWith(":")) {
|
||||
path = path.substring(1);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private Path resolveGradleModuleDirectory(Path gradleRoot, String gradlePath, String settingsContent) {
|
||||
String lookup = gradlePath.contains(":") ? gradlePath : gradlePath.replace('/', ':');
|
||||
Matcher matcher = GRADLE_PROJECT_DIR.matcher(settingsContent);
|
||||
while (matcher.find()) {
|
||||
String configuredPath = matcher.group(1);
|
||||
if (configuredPath != null && configuredPath.equals(lookup)) {
|
||||
String custom = matcher.group(2) != null ? matcher.group(2) : matcher.group(3);
|
||||
if (custom != null) {
|
||||
return gradleRoot.resolve(custom).normalize();
|
||||
}
|
||||
}
|
||||
}
|
||||
return gradleRoot.resolve(gradlePath.replace(':', '/')).normalize();
|
||||
}
|
||||
|
||||
private void registerGradleModule(String gradlePath, Path directory, Map<String, ProjectModuleGraph.ProjectModule> modulesById) {
|
||||
String id = "gradle:" + gradlePath;
|
||||
ProjectModuleGraph.ProjectModule existing = modulesById.get(id);
|
||||
if (existing != null) {
|
||||
modulesById.put(id, new ProjectModuleGraph.ProjectModule(
|
||||
id,
|
||||
directory,
|
||||
ProjectModuleGraph.BuildSystem.MIXED,
|
||||
existing.mavenCoordinates()));
|
||||
return;
|
||||
}
|
||||
modulesById.put(id, new ProjectModuleGraph.ProjectModule(
|
||||
id,
|
||||
directory,
|
||||
ProjectModuleGraph.BuildSystem.GRADLE,
|
||||
null));
|
||||
}
|
||||
|
||||
private void registerMavenSubmodules(
|
||||
Path pomFile,
|
||||
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
|
||||
Map<ProjectModuleGraph.MavenCoordinates, ProjectModuleGraph.ProjectModule> modulesByCoordinates) throws IOException {
|
||||
String content = Files.readString(pomFile);
|
||||
Path parentDir = pomFile.getParent();
|
||||
Matcher moduleMatcher = MAVEN_MODULE.matcher(content);
|
||||
while (moduleMatcher.find()) {
|
||||
Path moduleDir = parentDir.resolve(moduleMatcher.group(1).trim()).normalize();
|
||||
Path childPom = moduleDir.resolve("pom.xml");
|
||||
if (Files.exists(childPom)) {
|
||||
registerMavenModule(childPom, modulesById, modulesByCoordinates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void registerMavenModule(
|
||||
Path pomFile,
|
||||
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
|
||||
Map<ProjectModuleGraph.MavenCoordinates, ProjectModuleGraph.ProjectModule> modulesByCoordinates) throws IOException {
|
||||
String content = Files.readString(pomFile);
|
||||
Optional<ProjectModuleGraph.MavenCoordinates> coordinates = extractOwnMavenCoordinates(content);
|
||||
if (coordinates.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Path directory = pomFile.getParent().normalize();
|
||||
|
||||
for (Map.Entry<String, ProjectModuleGraph.ProjectModule> entry : modulesById.entrySet()) {
|
||||
if (entry.getValue().directory().toAbsolutePath().normalize().equals(directory.toAbsolutePath().normalize())) {
|
||||
ProjectModuleGraph.ProjectModule merged = new ProjectModuleGraph.ProjectModule(
|
||||
entry.getKey(),
|
||||
directory,
|
||||
ProjectModuleGraph.BuildSystem.MIXED,
|
||||
coordinates.get());
|
||||
modulesById.put(entry.getKey(), merged);
|
||||
modulesByCoordinates.put(coordinates.get(), merged);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
String id = "maven:" + coordinates.get().key();
|
||||
ProjectModuleGraph.ProjectModule module = new ProjectModuleGraph.ProjectModule(
|
||||
id,
|
||||
directory,
|
||||
ProjectModuleGraph.BuildSystem.MAVEN,
|
||||
coordinates.get());
|
||||
modulesById.put(id, module);
|
||||
modulesByCoordinates.put(coordinates.get(), module);
|
||||
}
|
||||
|
||||
static Optional<ProjectModuleGraph.MavenCoordinates> extractOwnMavenCoordinates(String pomContent) {
|
||||
String content = pomContent;
|
||||
int parentEnd = pomContent.indexOf("</parent>");
|
||||
if (parentEnd != -1) {
|
||||
content = pomContent.substring(parentEnd);
|
||||
}
|
||||
String groupId = extractFirstTag(content, "groupId").orElse(null);
|
||||
String artifactId = extractFirstTag(content, "artifactId").orElse(null);
|
||||
if (artifactId == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (groupId == null) {
|
||||
groupId = extractParentTag(pomContent, "groupId").orElse("unknown");
|
||||
}
|
||||
return Optional.of(new ProjectModuleGraph.MavenCoordinates(groupId, artifactId));
|
||||
}
|
||||
|
||||
private static Optional<String> extractParentTag(String pomContent, String tag) {
|
||||
Matcher parentMatcher = Pattern.compile("<parent>.*?</parent>", Pattern.DOTALL).matcher(pomContent);
|
||||
if (!parentMatcher.find()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return extractFirstTag(parentMatcher.group(), tag);
|
||||
}
|
||||
|
||||
private static Optional<String> extractFirstTag(String content, String tag) {
|
||||
Matcher matcher = Pattern.compile("<" + tag + ">(.*?)</" + tag + ">").matcher(content);
|
||||
if (matcher.find()) {
|
||||
return Optional.of(matcher.group(1).trim());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private List<ProjectModuleGraph.ModuleDependency> parseGradleDependencies(
|
||||
ProjectModuleGraph.ProjectModule module,
|
||||
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
|
||||
Map<String, Path> gradleModulePaths) throws IOException {
|
||||
List<ProjectModuleGraph.ModuleDependency> result = new ArrayList<>();
|
||||
Path buildFile = module.directory().resolve("build.gradle");
|
||||
if (!Files.exists(buildFile)) {
|
||||
buildFile = module.directory().resolve("build.gradle.kts");
|
||||
}
|
||||
if (!Files.exists(buildFile)) {
|
||||
return result;
|
||||
}
|
||||
String content = Files.readString(buildFile);
|
||||
for (String line : content.split("\\R")) {
|
||||
if (isTestGradleConfigurationLine(line)) {
|
||||
continue;
|
||||
}
|
||||
Matcher matcher = GRADLE_PROJECT_DEP.matcher(line);
|
||||
while (matcher.find()) {
|
||||
String gradlePath = normalizeGradlePath(matcher.group(1));
|
||||
String targetId = findGradleModuleId(gradlePath, modulesById, gradleModulePaths);
|
||||
if (targetId != null) {
|
||||
result.add(new ProjectModuleGraph.ModuleDependency(
|
||||
module.id(), targetId, null, ProjectModuleGraph.DependencyKind.LOCAL_PROJECT));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static boolean isTestScopedMavenDependency(String dependencyBlock) {
|
||||
return extractFirstTag(dependencyBlock, "scope")
|
||||
.map(scope -> scope.equalsIgnoreCase("test") || scope.equalsIgnoreCase("testCompile"))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
static boolean isTestGradleConfigurationLine(String line) {
|
||||
if (line == null || line.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = line.trim();
|
||||
return trimmed.matches("(?i).*(testImplementation|testCompileOnly|testRuntimeOnly|testFixturesImplementation|androidTestImplementation|androidTestCompileOnly|androidTestRuntimeOnly|testAnnotationProcessor)\\b.*");
|
||||
}
|
||||
|
||||
private static String findGradleModuleId(
|
||||
String gradlePath,
|
||||
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
|
||||
Map<String, Path> gradleModulePaths) {
|
||||
String exact = "gradle:" + gradlePath;
|
||||
if (modulesById.containsKey(exact)) {
|
||||
return exact;
|
||||
}
|
||||
Path expectedDir = gradleModulePaths.get(gradlePath);
|
||||
if (expectedDir != null) {
|
||||
for (ProjectModuleGraph.ProjectModule module : modulesById.values()) {
|
||||
if (module.directory().toAbsolutePath().normalize().equals(expectedDir.toAbsolutePath().normalize())) {
|
||||
return module.id();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<ProjectModuleGraph.ModuleDependency> parseMavenDependencies(
|
||||
Path pomFile,
|
||||
Map<String, ProjectModuleGraph.ProjectModule> modulesById,
|
||||
Map<ProjectModuleGraph.MavenCoordinates, ProjectModuleGraph.ProjectModule> modulesByCoordinates) throws IOException {
|
||||
List<ProjectModuleGraph.ModuleDependency> result = new ArrayList<>();
|
||||
String content = Files.readString(pomFile);
|
||||
Optional<ProjectModuleGraph.MavenCoordinates> fromCoordinates = extractOwnMavenCoordinates(content);
|
||||
if (fromCoordinates.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
ProjectModuleGraph.ProjectModule fromModule = modulesByCoordinates.get(fromCoordinates.get());
|
||||
if (fromModule == null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
int depsStart = content.indexOf("<dependencies>");
|
||||
int depsEnd = content.indexOf("</dependencies>");
|
||||
if (depsStart == -1 || depsEnd <= depsStart) {
|
||||
return result;
|
||||
}
|
||||
String depsSection = content.substring(depsStart, depsEnd);
|
||||
Matcher blockMatcher = MAVEN_DEPENDENCY_BLOCK.matcher(depsSection);
|
||||
while (blockMatcher.find()) {
|
||||
String block = blockMatcher.group(1);
|
||||
if (isTestScopedMavenDependency(block)) {
|
||||
continue;
|
||||
}
|
||||
String groupId = extractFirstTag(block, "groupId").orElse(null);
|
||||
String artifactId = extractFirstTag(block, "artifactId").orElse(null);
|
||||
if (artifactId == null) {
|
||||
continue;
|
||||
}
|
||||
if (groupId == null) {
|
||||
groupId = extractParentTag(content, "groupId").orElse("unknown");
|
||||
}
|
||||
ProjectModuleGraph.MavenCoordinates targetCoordinates = new ProjectModuleGraph.MavenCoordinates(groupId, artifactId);
|
||||
ProjectModuleGraph.ProjectModule targetModule = modulesByCoordinates.get(targetCoordinates);
|
||||
if (targetModule != null && !targetModule.id().equals(fromModule.id())) {
|
||||
result.add(new ProjectModuleGraph.ModuleDependency(
|
||||
fromModule.id(),
|
||||
targetModule.id(),
|
||||
null,
|
||||
ProjectModuleGraph.DependencyKind.LOCAL_PROJECT));
|
||||
} else {
|
||||
result.add(new ProjectModuleGraph.ModuleDependency(
|
||||
fromModule.id(),
|
||||
null,
|
||||
targetCoordinates,
|
||||
ProjectModuleGraph.DependencyKind.EXTERNAL_LIBRARY));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ensureInputModule(Path inputDir, Map<String, ProjectModuleGraph.ProjectModule> modulesById) {
|
||||
Path normalized = inputDir.toAbsolutePath().normalize();
|
||||
boolean known = modulesById.values().stream()
|
||||
.anyMatch(module -> module.directory().toAbsolutePath().normalize().equals(normalized));
|
||||
if (!known) {
|
||||
modulesById.put("input:" + normalized.getFileName(), new ProjectModuleGraph.ProjectModule(
|
||||
"input:" + normalized.getFileName(),
|
||||
normalized,
|
||||
ProjectModuleGraph.BuildSystem.MIXED,
|
||||
null));
|
||||
}
|
||||
}
|
||||
|
||||
private List<Path> findPomFiles(Path workspaceRoot) throws IOException {
|
||||
if (workspaceRoot == null || !Files.isDirectory(workspaceRoot)) {
|
||||
return List.of();
|
||||
}
|
||||
try (Stream<Path> stream = Files.walk(workspaceRoot)) {
|
||||
return stream.filter(path -> path.getFileName().toString().equals("pom.xml"))
|
||||
.filter(path -> !path.toString().contains("/target/"))
|
||||
.filter(path -> !path.toString().contains("\\target\\"))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
Path findWorkspaceRoot(Path start) {
|
||||
if (start == null) {
|
||||
return null;
|
||||
}
|
||||
Path normalized = start.toAbsolutePath().normalize();
|
||||
Path current = normalized;
|
||||
Path deepestMavenAggregator = null;
|
||||
Path deepestGradleSettings = null;
|
||||
Path deepestPom = null;
|
||||
|
||||
while (current != null) {
|
||||
if (Files.exists(current.resolve("settings.gradle")) || Files.exists(current.resolve("settings.gradle.kts"))) {
|
||||
deepestGradleSettings = current;
|
||||
}
|
||||
Path pom = current.resolve("pom.xml");
|
||||
if (Files.exists(pom)) {
|
||||
deepestPom = current;
|
||||
try {
|
||||
if (Files.readString(pom).contains("<modules>")) {
|
||||
deepestMavenAggregator = current;
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// continue walking
|
||||
}
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
|
||||
if (deepestMavenAggregator != null) {
|
||||
return deepestMavenAggregator;
|
||||
}
|
||||
if (deepestGradleSettings != null) {
|
||||
return deepestGradleSettings;
|
||||
}
|
||||
if (deepestPom != null) {
|
||||
return deepestPom;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
Path findIndexingRoot(Path start, Path workspaceRoot) {
|
||||
if (workspaceRoot == null) {
|
||||
return findIndexingRoot(start);
|
||||
}
|
||||
return workspaceRoot;
|
||||
}
|
||||
|
||||
Path findIndexingRoot(Path start) {
|
||||
if (start == null) {
|
||||
return null;
|
||||
}
|
||||
Path current = start.toAbsolutePath().normalize();
|
||||
Path gitRoot = null;
|
||||
while (current != null) {
|
||||
if (Files.exists(current.resolve(".git"))) {
|
||||
gitRoot = current;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return gitRoot != null ? gitRoot : findWorkspaceRoot(start);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
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);
|
||||
applyCanonicalizationFields(result, context, 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) {
|
||||
applyCanonicalization(result, null, machineTypes);
|
||||
}
|
||||
|
||||
public static void applyCanonicalization(
|
||||
AnalysisResult result,
|
||||
CodebaseContext context,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
if (result == null || machineTypes == null) {
|
||||
return;
|
||||
}
|
||||
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) {
|
||||
return;
|
||||
}
|
||||
applyCanonicalizationFields(result, context, machineTypes);
|
||||
}
|
||||
|
||||
private static void applyCanonicalizationFields(
|
||||
AnalysisResult result,
|
||||
CodebaseContext context,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
MachineEnumCanonicalizer.canonicalizeTransitions(result.getTransitions(), machineTypes, context);
|
||||
|
||||
if (result.getStates() != null) {
|
||||
result.setStates(MachineEnumCanonicalizer.canonicalizeStates(
|
||||
result.getStates(), machineTypes.stateTypeFqn(), context));
|
||||
}
|
||||
result.setStartStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
|
||||
result.getStartStates(), machineTypes.stateTypeFqn(), context));
|
||||
result.setEndStates(MachineEnumCanonicalizer.canonicalizeStateLabels(
|
||||
result.getEndStates(), machineTypes.stateTypeFqn(), context));
|
||||
|
||||
if (result.getMetadata() != null) {
|
||||
CodebaseMetadata metadata = result.getMetadata();
|
||||
List<TriggerPoint> triggers = canonicalizeTriggers(metadata.getTriggers(), context, machineTypes);
|
||||
List<CallChain> callChains = canonicalizeCallChains(metadata.getCallChains(), context, machineTypes);
|
||||
result.setMetadata(CodebaseMetadata.builder()
|
||||
.triggers(triggers)
|
||||
.entryPoints(metadata.getEntryPoints())
|
||||
.callChains(callChains)
|
||||
.properties(metadata.getProperties())
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
private static List<TriggerPoint> canonicalizeTriggers(
|
||||
List<TriggerPoint> triggers,
|
||||
CodebaseContext context,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
if (triggers == null) {
|
||||
return null;
|
||||
}
|
||||
return triggers.stream()
|
||||
.map(trigger -> MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
|
||||
trigger, machineTypes, context))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static List<CallChain> canonicalizeCallChains(
|
||||
List<CallChain> callChains,
|
||||
CodebaseContext context,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes) {
|
||||
if (callChains == null) {
|
||||
return null;
|
||||
}
|
||||
return callChains.stream()
|
||||
.map(chain -> {
|
||||
TriggerPoint trigger = chain.getTriggerPoint() == null
|
||||
? null
|
||||
: MachineEnumCanonicalizer.canonicalizeAndExpandTriggerPoint(
|
||||
chain.getTriggerPoint(), machineTypes, context);
|
||||
List<MatchedTransition> matched = canonicalizeMatchedTransitions(
|
||||
chain.getMatchedTransitions(), machineTypes, context);
|
||||
return chain.toBuilder()
|
||||
.triggerPoint(trigger)
|
||||
.matchedTransitions(matched)
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static List<MatchedTransition> canonicalizeMatchedTransitions(
|
||||
List<MatchedTransition> matchedTransitions,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context) {
|
||||
if (matchedTransitions == null) {
|
||||
return null;
|
||||
}
|
||||
return matchedTransitions.stream()
|
||||
.map(matched -> MatchedTransition.builder()
|
||||
.event(MachineEnumCanonicalizer.canonicalizeLabel(
|
||||
matched.getEvent(), machineTypes.eventTypeFqn(), context))
|
||||
.sourceState(MachineEnumCanonicalizer.canonicalizeLabel(
|
||||
matched.getSourceState(), machineTypes.stateTypeFqn(), context))
|
||||
.targetState(MachineEnumCanonicalizer.canonicalizeLabel(
|
||||
matched.getTargetState(), machineTypes.stateTypeFqn(), context))
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,12 @@ public class CallGraphPathFinder {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public void clearAnalysisCaches() {
|
||||
heuristicCache.clear();
|
||||
memoCache.clear();
|
||||
unreachableCache.clear();
|
||||
}
|
||||
|
||||
public List<String> findPath(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) {
|
||||
if (start.equals(target)) return new ArrayList<>(List.of(start));
|
||||
if (!visited.add(start)) {
|
||||
@@ -56,6 +62,21 @@ public class CallGraphPathFinder {
|
||||
return findAllPaths(start, target, graph, visited, new boolean[]{false});
|
||||
}
|
||||
|
||||
public List<List<String>> findAllPaths(
|
||||
String start,
|
||||
String target,
|
||||
Map<String, List<CallEdge>> graph,
|
||||
Set<String> visited,
|
||||
PathBindingEvaluator bindingEvaluator,
|
||||
Map<String, String> initialBindings) {
|
||||
if (bindingEvaluator == null) {
|
||||
return findAllPaths(start, target, graph, visited);
|
||||
}
|
||||
Map<String, String> bindings = initialBindings == null ? new HashMap<>() : new HashMap<>(initialBindings);
|
||||
return findAllPathsConstrained(
|
||||
start, target, graph, visited, new boolean[]{false}, bindingEvaluator, bindings);
|
||||
}
|
||||
|
||||
public List<List<String>> findAllPaths(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited, boolean[] parentHitCycle) {
|
||||
List<List<String>> result = new ArrayList<>();
|
||||
java.util.Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(start, target);
|
||||
@@ -175,6 +196,121 @@ public class CallGraphPathFinder {
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<List<String>> findAllPathsConstrained(
|
||||
String start,
|
||||
String target,
|
||||
Map<String, List<CallEdge>> graph,
|
||||
Set<String> visited,
|
||||
boolean[] parentHitCycle,
|
||||
PathBindingEvaluator bindingEvaluator,
|
||||
Map<String, String> bindings) {
|
||||
List<List<String>> result = new ArrayList<>();
|
||||
if (start.equals(target)) {
|
||||
result.add(new ArrayList<>(List.of(start)));
|
||||
return result;
|
||||
}
|
||||
if (!visited.add(start)) {
|
||||
parentHitCycle[0] = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
List<CallEdge> neighbors = graph.get(start);
|
||||
if (neighbors != null) {
|
||||
for (CallEdge edge : neighbors) {
|
||||
String neighbor = edge.getTargetMethod();
|
||||
if (neighbor.startsWith("java.") || neighbor.startsWith("javax.") || neighbor.startsWith("jakarta.") || neighbor.startsWith("org.springframework.")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String resolvedTarget = neighbor.equals(target) || isHeuristicMatch(neighbor, target) ? target : neighbor;
|
||||
List<String> hopPath = new ArrayList<>(List.of(start, resolvedTarget));
|
||||
Map<String, String> branchBindings = new HashMap<>(bindings);
|
||||
if (!bindingEvaluator.tryTraverseEdge(
|
||||
start, resolvedTarget, edge, hopPath, 1, graph, this, branchBindings)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolvedTarget.equals(target)) {
|
||||
result.add(hopPath);
|
||||
} else {
|
||||
List<List<String>> subPaths = findAllPathsConstrained(
|
||||
neighbor, target, graph, visited, parentHitCycle, bindingEvaluator, branchBindings);
|
||||
for (List<String> subPath : subPaths) {
|
||||
if (subPath.isEmpty() || subPath.get(0).equals(start)) {
|
||||
continue;
|
||||
}
|
||||
List<String> path = new ArrayList<>();
|
||||
path.add(start);
|
||||
path.addAll(subPath);
|
||||
result.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isEmpty() && start.contains(".")) {
|
||||
result.addAll(findInheritanceFallbackPaths(
|
||||
start, target, graph, visited, parentHitCycle, bindingEvaluator, bindings));
|
||||
}
|
||||
|
||||
visited.remove(start);
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<List<String>> findInheritanceFallbackPaths(
|
||||
String start,
|
||||
String target,
|
||||
Map<String, List<CallEdge>> graph,
|
||||
Set<String> visited,
|
||||
boolean[] parentHitCycle,
|
||||
PathBindingEvaluator bindingEvaluator,
|
||||
Map<String, String> bindings) {
|
||||
List<List<String>> result = new ArrayList<>();
|
||||
String className = start.substring(0, start.lastIndexOf('.'));
|
||||
String methodName = start.substring(start.lastIndexOf('.') + 1);
|
||||
if (className.contains("<")) {
|
||||
className = className.substring(0, className.indexOf('<'));
|
||||
}
|
||||
|
||||
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||
if (td != null) {
|
||||
String superclass = context.getSuperclassFqn(td);
|
||||
if (superclass != null) {
|
||||
String superMethod = superclass + "." + methodName;
|
||||
if (graph.containsKey(superMethod)) {
|
||||
List<List<String>> superPaths = findAllPathsConstrained(
|
||||
superMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings);
|
||||
for (List<String> superPath : superPaths) {
|
||||
List<String> path = new ArrayList<>();
|
||||
path.add(start);
|
||||
path.addAll(superPath);
|
||||
result.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isEmpty()) {
|
||||
List<String> impls = context.getImplementations(className);
|
||||
if (impls != null) {
|
||||
for (String impl : impls) {
|
||||
String implMethod = impl + "." + methodName;
|
||||
if (graph.containsKey(implMethod)) {
|
||||
List<List<String>> implPaths = findAllPathsConstrained(
|
||||
implMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings);
|
||||
for (List<String> implPath : implPaths) {
|
||||
List<String> path = new ArrayList<>();
|
||||
path.add(start);
|
||||
path.addAll(implPath);
|
||||
result.add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public String extractContextMachineId(List<String> path, Map<String, List<CallEdge>> callGraph) {
|
||||
for (String node : path) {
|
||||
List<CallEdge> edges = callGraph.get(node);
|
||||
@@ -225,24 +361,12 @@ public class CallGraphPathFinder {
|
||||
|
||||
if (classNeighbor.equals(classTarget)) return true;
|
||||
|
||||
String simpleClassNeighbor = classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
|
||||
String simpleClassTarget = classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
|
||||
if (context.areSameTypeOrUnambiguousSimpleMatch(classNeighbor, classTarget)) return true;
|
||||
|
||||
if (simpleClassNeighbor.equals(simpleClassTarget)) return true;
|
||||
|
||||
List<String> impls = context.getImplementations(classTarget);
|
||||
if (impls != null) {
|
||||
for (String impl : impls) {
|
||||
if (impl.equals(classNeighbor) || impl.endsWith("." + classNeighbor)) return true;
|
||||
}
|
||||
if (context.areClassesPolymorphicallyCompatible(classNeighbor, classTarget)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -256,13 +380,22 @@ public class CallGraphPathFinder {
|
||||
String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
|
||||
|
||||
if (simpleClassNeighbor != null && simpleClassTarget != null) {
|
||||
if (simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) return true;
|
||||
String leftClass = classNeighbor != null ? classNeighbor : simpleClassNeighbor;
|
||||
String rightClass = classTarget != null ? classTarget : simpleClassTarget;
|
||||
if (context.areSameTypeOrUnambiguousSimpleMatch(leftClass, rightClass)) return true;
|
||||
if (!context.isAmbiguousSimpleName(simpleClassNeighbor)
|
||||
&& !context.isAmbiguousSimpleName(simpleClassTarget)
|
||||
&& simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) {
|
||||
return true;
|
||||
}
|
||||
// e.g. "this" vs "com.example.Machine"
|
||||
if (simpleClassNeighbor.equals("this") || simpleClassNeighbor.equals("super")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
// If we have no class ownership information for either side, we can't prove a match beyond the
|
||||
// method name. Fail closed to avoid accidental cross-class linking.
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isFullyQualifiedMethod(String methodFqn) {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||
import org.eclipse.jdt.core.dom.ASTNode;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||
import org.eclipse.jdt.core.dom.SimpleName;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Resolves call-site arguments across stack frames (e.g. {@code Supplier.get()} → caller lambda).
|
||||
*/
|
||||
public final class CallSiteArgumentResolver {
|
||||
|
||||
public interface ExpressionHooks {
|
||||
/** Unwrap lambda / enum literal argument expressions to a stable string form. */
|
||||
String resolveArgument(Expression expr);
|
||||
|
||||
/** Parse a stringified expression back to AST for constant extraction. */
|
||||
Expression parseExpression(String expr);
|
||||
}
|
||||
|
||||
private CallSiteArgumentResolver() {
|
||||
}
|
||||
|
||||
/**
|
||||
* When {@code supplierParam.get()} is seen inside {@code scopeMethod}, walk to the caller frame
|
||||
* and extract enum/constants from the argument bound to {@code supplierParam}.
|
||||
*/
|
||||
public static List<String> resolveFunctionalGetFromCallerFrame(
|
||||
MethodInvocation getCall,
|
||||
List<String> path,
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
String scopeMethod,
|
||||
TypeResolver typeResolver,
|
||||
CallGraphPathFinder pathFinder,
|
||||
ConstantExtractor constantExtractor,
|
||||
ExpressionHooks hooks) {
|
||||
if (path == null || path.size() < 2 || scopeMethod == null || getCall.getExpression() == null) {
|
||||
return List.of();
|
||||
}
|
||||
if (!(getCall.getExpression() instanceof SimpleName supplierParam)) {
|
||||
return List.of();
|
||||
}
|
||||
int scopeIndex = indexOfMethod(path, scopeMethod);
|
||||
if (scopeIndex <= 0) {
|
||||
return List.of();
|
||||
}
|
||||
String callee = path.get(scopeIndex);
|
||||
String caller = path.get(scopeIndex - 1);
|
||||
int paramIndex = typeResolver.getParameterIndex(callee, supplierParam.getIdentifier());
|
||||
if (paramIndex < 0) {
|
||||
return List.of();
|
||||
}
|
||||
String argValue = findCallSiteArgument(caller, callee, paramIndex, callGraph, pathFinder);
|
||||
if (argValue == null) {
|
||||
return List.of();
|
||||
}
|
||||
return extractConstantsFromCallSiteArgument(argValue, constantExtractor, hooks);
|
||||
}
|
||||
|
||||
public static String findCallSiteArgument(
|
||||
String caller,
|
||||
String callee,
|
||||
int paramIndex,
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
CallGraphPathFinder pathFinder) {
|
||||
List<CallEdge> edges = callGraph.get(caller);
|
||||
if (edges == null) {
|
||||
return null;
|
||||
}
|
||||
for (CallEdge edge : edges) {
|
||||
if (!edge.getTargetMethod().equals(callee) && !pathFinder.isHeuristicMatch(edge.getTargetMethod(), callee)) {
|
||||
continue;
|
||||
}
|
||||
if (paramIndex < edge.getArguments().size()) {
|
||||
return edge.getArguments().get(paramIndex);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static int indexOfMethod(List<String> path, String methodFqn) {
|
||||
for (int i = path.size() - 1; i >= 0; i--) {
|
||||
if (methodFqn.equals(path.get(i))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public static List<String> extractConstantsFromCallSiteArgument(
|
||||
String argValue,
|
||||
ConstantExtractor constantExtractor,
|
||||
ExpressionHooks hooks) {
|
||||
if (argValue == null || argValue.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
Expression parsed = hooks.parseExpression(argValue);
|
||||
if (parsed == null) {
|
||||
if (FunctionalInterfaceTypes.looksLikeEnumConstant(argValue)) {
|
||||
return List.of(argValue);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
String resolved = hooks.resolveArgument(parsed);
|
||||
Expression resolvedExpr = hooks.parseExpression(resolved);
|
||||
List<String> constants = new ArrayList<>();
|
||||
if (resolvedExpr != null) {
|
||||
constantExtractor.extractConstantsFromExpression(resolvedExpr, constants);
|
||||
}
|
||||
if (constants.isEmpty() && FunctionalInterfaceTypes.looksLikeEnumConstant(resolved)) {
|
||||
constants.add(resolved);
|
||||
}
|
||||
return constants;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
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;
|
||||
@@ -14,6 +20,8 @@ public class ConstantExtractor {
|
||||
private final Function<MethodInvocation, String> calledMethodResolver;
|
||||
private VariableTracer variableTracer;
|
||||
private ConstructorAnalyzer constructorAnalyzer;
|
||||
private ExpressionAccessClassifier expressionAccessClassifier;
|
||||
private AccessorResolver accessorResolver;
|
||||
|
||||
public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function<MethodInvocation, String> calledMethodResolver) {
|
||||
this.context = context;
|
||||
@@ -29,25 +37,24 @@ public class ConstantExtractor {
|
||||
this.constructorAnalyzer = constructorAnalyzer;
|
||||
}
|
||||
|
||||
public void setExpressionAccessClassifier(ExpressionAccessClassifier expressionAccessClassifier) {
|
||||
this.expressionAccessClassifier = expressionAccessClassifier;
|
||||
}
|
||||
|
||||
public void setAccessorResolver(AccessorResolver accessorResolver) {
|
||||
this.accessorResolver = accessorResolver;
|
||||
}
|
||||
|
||||
public void extractConstantsFromExpression(Expression expr, List<String> constants) {
|
||||
if (constantResolver != null) {
|
||||
String resolved = constantResolver.resolve(expr, context);
|
||||
if (resolved != null) {
|
||||
if (resolved.startsWith("ENUM_SET:")) {
|
||||
for (String eVal : resolved.substring(9).split(",")) {
|
||||
String parsed = parseEnumSetElement(eVal);
|
||||
if (!constants.contains(parsed)) constants.add(parsed);
|
||||
}
|
||||
} else {
|
||||
constants.add(resolved);
|
||||
}
|
||||
if (resolved != null && !shouldInspectExpressionStructure(expr, resolved)) {
|
||||
addResolvedConstant(resolved, constants);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (expr instanceof QualifiedName qn) {
|
||||
constants.add(qn.toString());
|
||||
} else if (expr instanceof StringLiteral sl) {
|
||||
if (expr instanceof StringLiteral sl) {
|
||||
constants.add(sl.getLiteralValue());
|
||||
} else if (expr instanceof ConditionalExpression ce) {
|
||||
extractConstantsFromExpression(ce.getThenExpression(), constants);
|
||||
@@ -59,23 +66,7 @@ public class ConstantExtractor {
|
||||
} else if (expr instanceof Assignment assignment) {
|
||||
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
|
||||
} else if (expr instanceof SwitchExpression se) {
|
||||
se.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(YieldStatement ys) {
|
||||
extractConstantsFromExpression(ys.getExpression(), constants);
|
||||
return super.visit(ys);
|
||||
}
|
||||
@Override
|
||||
public boolean visit(ExpressionStatement es) {
|
||||
extractConstantsFromExpression(es.getExpression(), constants);
|
||||
return super.visit(es);
|
||||
}
|
||||
@Override
|
||||
public boolean visit(ReturnStatement rs) {
|
||||
extractConstantsFromExpression(rs.getExpression(), constants);
|
||||
return super.visit(rs);
|
||||
}
|
||||
});
|
||||
extractConstantsFromSwitchLike(se, constants);
|
||||
} else if (expr instanceof ArrayAccess aa) {
|
||||
extractConstantsFromExpression(aa.getArray(), constants);
|
||||
} else if (expr instanceof ArrayCreation ac && ac.getInitializer() != null) {
|
||||
@@ -89,22 +80,20 @@ public class ConstantExtractor {
|
||||
} else if (expr instanceof MethodInvocation mi) {
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
|
||||
log.trace("Processing mi: {}", mi);
|
||||
if (methodName.equals("get") || methodName.equals("getOrDefault")) {
|
||||
if (mi.getExpression() != null) {
|
||||
if (variableTracer != null) {
|
||||
List<Expression> traced = variableTracer.traceVariableAll(mi.getExpression());
|
||||
for (Expression t : traced) {
|
||||
extractConstantsFromExpression(t, constants);
|
||||
}
|
||||
} else {
|
||||
extractConstantsFromExpression(mi.getExpression(), constants);
|
||||
}
|
||||
}
|
||||
return;
|
||||
if (log.isTraceEnabled()) {
|
||||
log.trace("Processing mi: {}", mi);
|
||||
}
|
||||
if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") ||
|
||||
methodName.equals("just") || methodName.equals("withPayload") || methodName.equals("success")) {
|
||||
if (("get".equals(methodName) || "getOrDefault".equals(methodName)) && mi.getExpression() != null) {
|
||||
if (isKeyedLookupMethod(mi)) {
|
||||
extractMapReceiverConstants(mi.getExpression(), constants);
|
||||
return;
|
||||
}
|
||||
if ("get".equals(methodName) && mi.arguments().isEmpty()) {
|
||||
extractMapReceiverConstants(mi.getExpression(), constants);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (LibraryUnwrapRegistry.isUnwrapMethodName(methodName)) {
|
||||
for (Object argObj : mi.arguments()) {
|
||||
extractConstantsFromExpression((Expression) argObj, constants);
|
||||
}
|
||||
@@ -215,9 +204,9 @@ public class ConstantExtractor {
|
||||
Expression currentMi = mi;
|
||||
while (currentMi instanceof MethodInvocation chainMi) {
|
||||
String chainName = chainMi.getName().getIdentifier();
|
||||
if (chainName.equals("setHeader") && chainMi.arguments().size() == 2) {
|
||||
if (LibraryUnwrapRegistry.isHeaderSetterMethodName(chainName) && chainMi.arguments().size() == 2) {
|
||||
extractConstantsFromExpression((Expression) chainMi.arguments().get(1), constants);
|
||||
} else if ((chainName.equalsIgnoreCase("event") || chainName.equalsIgnoreCase("type") || chainName.equalsIgnoreCase("eventType")) && chainMi.arguments().size() == 1) {
|
||||
} else if (LibraryUnwrapRegistry.isEventSetterMethodName(chainName) && chainMi.arguments().size() == 1) {
|
||||
extractConstantsFromExpression((Expression) chainMi.arguments().get(0), constants);
|
||||
}
|
||||
currentMi = chainMi.getExpression();
|
||||
@@ -231,6 +220,17 @@ public class ConstantExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldInspectExpressionStructure(Expression expr, String resolved) {
|
||||
if (expr == null || resolved == null) {
|
||||
return false;
|
||||
}
|
||||
if (!(resolved.startsWith("<SYMBOLIC:") || resolved.contains("SYMBOLIC:") || resolved.endsWith(".*>"))) {
|
||||
return false;
|
||||
}
|
||||
return expr instanceof SwitchExpression
|
||||
|| expr instanceof ConditionalExpression;
|
||||
}
|
||||
|
||||
public void extractConstantsFromArgument(Expression argObj, List<String> constants) {
|
||||
if (argObj instanceof ClassInstanceCreation innerCic) {
|
||||
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(innerCic.getType());
|
||||
@@ -282,27 +282,106 @@ public class ConstantExtractor {
|
||||
}
|
||||
|
||||
public List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) {
|
||||
log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName);
|
||||
log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth);
|
||||
if (depth > 20) {
|
||||
log.debug("Exiting resolveMethodReturnConstant early (depth limit) for {}.{}", className, methodName);
|
||||
return resolveMethodReturnConstant(className, methodName, depth, visited, contextCu, ResolutionBudget.defaults());
|
||||
}
|
||||
|
||||
public List<String> resolveMethodReturnConstant(
|
||||
String className,
|
||||
String methodName,
|
||||
int depth,
|
||||
Set<String> visited,
|
||||
CompilationUnit contextCu,
|
||||
ResolutionBudget budget) {
|
||||
return resolveMethodReturnConstant(className, methodName, depth, visited, contextCu, budget, true);
|
||||
}
|
||||
|
||||
public List<String> resolveMethodReturnConstant(
|
||||
String className,
|
||||
String methodName,
|
||||
int depth,
|
||||
Set<String> visited,
|
||||
CompilationUnit contextCu,
|
||||
ResolutionBudget budget,
|
||||
boolean allowWidenToImplementations) {
|
||||
final boolean debug = log.isDebugEnabled();
|
||||
if (debug) {
|
||||
log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName);
|
||||
log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth);
|
||||
}
|
||||
if (budget == null) {
|
||||
budget = ResolutionBudget.defaults();
|
||||
}
|
||||
final ResolutionBudget activeBudget = budget;
|
||||
if (activeBudget.isMethodReturnExhausted(depth)) {
|
||||
if (debug) {
|
||||
log.debug("Exiting resolveMethodReturnConstant early (depth limit) for {}.{}", className, methodName);
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
String fqn = className + "." + methodName;
|
||||
if (!visited.add(fqn)) {
|
||||
log.debug("Exiting resolveMethodReturnConstant early (cycle detected) for fqn={}", fqn);
|
||||
if (debug) {
|
||||
log.debug("Exiting resolveMethodReturnConstant early (cycle detected) for fqn={}", fqn);
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<String> constants = new ArrayList<>();
|
||||
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(className, methodName, activeBudget);
|
||||
TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null;
|
||||
if (tempTd == null) {
|
||||
tempTd = context.getTypeDeclaration(className);
|
||||
}
|
||||
final TypeDeclaration td = tempTd;
|
||||
boolean widenToImplementations = allowWidenToImplementations
|
||||
&& (td == null
|
||||
|| td.isInterface()
|
||||
|| Modifier.isAbstract(td.getModifiers()));
|
||||
|
||||
if (widenToImplementations && depth == 0) {
|
||||
Optional<List<String>> precomputed = context.getPolymorphicAccessorIndex()
|
||||
.widenedReturnConstants(className, methodName);
|
||||
if (precomputed.isPresent()) {
|
||||
visited.remove(fqn);
|
||||
return new ArrayList<>(precomputed.get());
|
||||
}
|
||||
}
|
||||
|
||||
List<String> constants = new ArrayList<>();
|
||||
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
|
||||
boolean indexedOnRequestedType = className.equals(indexedAccessor.get().ownerFqn());
|
||||
if (!widenToImplementations || indexedOnRequestedType) {
|
||||
if (indexedAccessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER) {
|
||||
List<String> constantGetterValues = extractConstantGetterReturn(
|
||||
indexedAccessor.get().ownerFqn(), methodName, contextCu, visited);
|
||||
if (!constantGetterValues.isEmpty()) {
|
||||
if (!widenToImplementations) {
|
||||
visited.remove(fqn);
|
||||
return constantGetterValues;
|
||||
}
|
||||
constants.addAll(constantGetterValues);
|
||||
}
|
||||
}
|
||||
if (constants.isEmpty()) {
|
||||
List<String> indexedConstants = resolveIndexedGetterFieldConstants(
|
||||
indexedAccessor.get(), className, contextCu, visited);
|
||||
if (!indexedConstants.isEmpty()) {
|
||||
if (!widenToImplementations) {
|
||||
visited.remove(fqn);
|
||||
return indexedConstants;
|
||||
}
|
||||
constants.addAll(indexedConstants);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (td != null) {
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md != null && md.getBody() != null) {
|
||||
for (Object stmtObj : md.getBody().statements()) {
|
||||
if (stmtObj instanceof SwitchStatement switchStatement) {
|
||||
extractConstantsFromSwitchStatement(switchStatement, constants);
|
||||
}
|
||||
}
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(ReturnStatement node) {
|
||||
@@ -322,7 +401,8 @@ public class ConstantExtractor {
|
||||
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
|
||||
|
||||
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
|
||||
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
|
||||
List<String> delegationResult = resolveMethodReturnConstant(
|
||||
cName, mName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
|
||||
constants.addAll(delegationResult);
|
||||
handled = true;
|
||||
}
|
||||
@@ -341,7 +421,8 @@ public class ConstantExtractor {
|
||||
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
|
||||
|
||||
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
|
||||
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
|
||||
List<String> delegationResult = resolveMethodReturnConstant(
|
||||
cName, mName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
|
||||
constants.addAll(delegationResult);
|
||||
handled = true;
|
||||
}
|
||||
@@ -360,7 +441,13 @@ public class ConstantExtractor {
|
||||
if (val.startsWith("ENUM_SET:")) {
|
||||
for (String eVal : val.substring(9).split(",")) {
|
||||
String parsed = parseEnumSetElement(eVal);
|
||||
if (!constants.contains(parsed)) constants.add(parsed);
|
||||
if (!constants.contains(parsed)) {
|
||||
constants.add(parsed);
|
||||
}
|
||||
}
|
||||
} else if (val.startsWith("<SYMBOLIC:") || val.contains("SYMBOLIC:") || val.endsWith(".*>")) {
|
||||
if (!constants.contains(val)) {
|
||||
constants.add(val);
|
||||
}
|
||||
} else {
|
||||
if (!constants.contains(val)) constants.add(val);
|
||||
@@ -382,26 +469,215 @@ public class ConstantExtractor {
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
}
|
||||
if (widenToImplementations) {
|
||||
List<String> impls = context.getImplementations(className);
|
||||
if (impls != null) {
|
||||
for (String implName : impls) {
|
||||
List<String> delegationResult = resolveMethodReturnConstant(implName, methodName, depth + 1, visited, contextCu);
|
||||
constants.addAll(delegationResult);
|
||||
List<String> delegationResult = resolveMethodReturnConstant(
|
||||
implName, methodName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
|
||||
for (String value : delegationResult) {
|
||||
if (!constants.contains(value)) {
|
||||
constants.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (widenToImplementations) {
|
||||
List<String> impls = context.getImplementations(className);
|
||||
if (impls != null) {
|
||||
for (String implName : impls) {
|
||||
List<String> delegationResult = resolveMethodReturnConstant(
|
||||
implName, methodName, depth + 1, visited, contextCu, activeBudget);
|
||||
for (String value : delegationResult) {
|
||||
if (!constants.contains(value)) {
|
||||
constants.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
visited.remove(fqn);
|
||||
log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants);
|
||||
log.debug("resolveMethodReturnConstant for class={}, method={} resolved constants: {}", className, methodName, constants);
|
||||
if (debug) {
|
||||
log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants);
|
||||
}
|
||||
return constants;
|
||||
}
|
||||
|
||||
public List<String> resolveIndexedGetterFieldConstants(
|
||||
AccessorSummary accessor,
|
||||
String requestingTypeFqn,
|
||||
CompilationUnit contextCu,
|
||||
Set<String> visited) {
|
||||
if (accessor != null && isSafeForPrecomputedFieldConstants(accessor)) {
|
||||
Optional<List<String>> precomputed = context.getIndexedFieldConstantsIndex()
|
||||
.lookup(accessor.ownerFqn(), accessor.methodName());
|
||||
if (precomputed.isPresent()) {
|
||||
return new ArrayList<>(precomputed.get());
|
||||
}
|
||||
}
|
||||
return AccessorFieldResolver.resolveFieldConstants(
|
||||
accessor,
|
||||
requestingTypeFqn,
|
||||
context,
|
||||
constructorAnalyzer,
|
||||
contextCu,
|
||||
visited,
|
||||
this::extractConstantsFromExpression);
|
||||
}
|
||||
|
||||
public Optional<AccessorSummary> lookupIndexedGetter(String className, String methodName) {
|
||||
return lookupIndexedGetter(className, methodName, ResolutionBudget.defaults());
|
||||
}
|
||||
|
||||
private Optional<AccessorSummary> lookupIndexedGetter(String className, String methodName, ResolutionBudget budget) {
|
||||
if (accessorResolver != null) {
|
||||
return accessorResolver.lookupIndexedGetter(className, methodName);
|
||||
}
|
||||
if (className == null || className.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
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) {
|
||||
if (eVal != null && (eVal.startsWith("<SYMBOLIC:") || eVal.contains("SYMBOLIC:") || eVal.endsWith(".*>"))) {
|
||||
return eVal;
|
||||
}
|
||||
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
|
||||
}
|
||||
|
||||
public void extractConstantsFromSwitchStatement(SwitchStatement switchStatement, List<String> constants) {
|
||||
if (switchStatement == null) {
|
||||
return;
|
||||
}
|
||||
extractConstantsFromSwitchLike(switchStatement, constants);
|
||||
}
|
||||
|
||||
private void extractConstantsFromSwitchLike(ASTNode switchNode, List<String> constants) {
|
||||
switchNode.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(YieldStatement ys) {
|
||||
extractConstantsFromExpression(ys.getExpression(), constants);
|
||||
return super.visit(ys);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean visit(ExpressionStatement es) {
|
||||
extractConstantsFromExpression(es.getExpression(), constants);
|
||||
return super.visit(es);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean visit(ReturnStatement rs) {
|
||||
extractConstantsFromExpression(rs.getExpression(), constants);
|
||||
return super.visit(rs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isKeyedLookupMethod(MethodInvocation mi) {
|
||||
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)) {
|
||||
@@ -411,10 +687,6 @@ public class ConstantExtractor {
|
||||
}
|
||||
|
||||
private TypeDeclaration findEnclosingType(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null && !(parent instanceof TypeDeclaration)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return (TypeDeclaration) parent;
|
||||
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
import java.util.*;
|
||||
@@ -13,6 +14,8 @@ public class ConstructorAnalyzer {
|
||||
private final ConstantResolver constantResolver;
|
||||
private VariableTracer variableTracer;
|
||||
private ConstantExtractor constantExtractor;
|
||||
private final Map<String, ClassInstanceCreation> returnedCicCache = new HashMap<>();
|
||||
private final Set<String> returnedCicAbsent = new HashSet<>();
|
||||
|
||||
@FunctionalInterface
|
||||
public interface RhsResolver {
|
||||
@@ -32,7 +35,21 @@ public class ConstructorAnalyzer {
|
||||
this.constantExtractor = constantExtractor;
|
||||
}
|
||||
|
||||
public void clearReturnedCicCache() {
|
||||
returnedCicCache.clear();
|
||||
returnedCicAbsent.clear();
|
||||
}
|
||||
|
||||
public List<String> traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set<String> visited) {
|
||||
return traceFieldInConstructors(td, fieldName, context, visited, ResolutionBudget.defaults());
|
||||
}
|
||||
|
||||
public List<String> traceFieldInConstructors(
|
||||
TypeDeclaration td,
|
||||
String fieldName,
|
||||
CodebaseContext context,
|
||||
Set<String> visited,
|
||||
ResolutionBudget budget) {
|
||||
if (td == null || fieldName == null) return Collections.emptyList();
|
||||
String fqn = context.getFqn(td);
|
||||
if (fqn == null) return Collections.emptyList();
|
||||
@@ -62,7 +79,8 @@ public class ConstructorAnalyzer {
|
||||
targetClass = context.getFqn(targetTd);
|
||||
}
|
||||
}
|
||||
results.addAll(constantExtractor.resolveMethodReturnConstant(targetClass, calledName, 0, visited, cu));
|
||||
results.addAll(constantExtractor.resolveMethodReturnConstant(
|
||||
targetClass, calledName, 0, visited, cu, budget));
|
||||
} else {
|
||||
String val = constantResolver.resolve(tracedRight, context);
|
||||
if (val != null) results.add(val);
|
||||
@@ -106,7 +124,8 @@ public class ConstructorAnalyzer {
|
||||
}
|
||||
|
||||
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
|
||||
results.addAll(constantExtractor.resolveMethodReturnConstant(targetClass, calledName, 0, visited, cu));
|
||||
results.addAll(constantExtractor.resolveMethodReturnConstant(
|
||||
targetClass, calledName, 0, visited, cu, budget));
|
||||
} else {
|
||||
String val = constantResolver.resolve(tracedRight, context);
|
||||
if (val != null) results.add(val);
|
||||
@@ -154,35 +173,37 @@ public class ConstructorAnalyzer {
|
||||
if (superFqn != null && !superFqn.equals("java.lang.Object")) {
|
||||
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
||||
if (superTd != null) {
|
||||
results.addAll(traceFieldInConstructors(superTd, fieldName, context, visited));
|
||||
results.addAll(traceFieldInConstructors(superTd, fieldName, context, visited, budget.child()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.isEmpty()) {
|
||||
// Context-Aware Fallback (Down): Check subclasses/implementations
|
||||
List<String> impls = context.getImplementations(fqn);
|
||||
if (impls != null) {
|
||||
for (String implFqn : impls) {
|
||||
TypeDeclaration implTd = context.getTypeDeclaration(implFqn);
|
||||
if (implTd != null) {
|
||||
results.addAll(traceFieldInConstructors(implTd, fieldName, context, visited));
|
||||
// Context-Aware Fallback (Down): widen only for interface/abstract types
|
||||
if (shouldWidenToImplementations(td)) {
|
||||
List<String> impls = context.getImplementations(fqn);
|
||||
if (impls != null) {
|
||||
for (String implFqn : impls) {
|
||||
TypeDeclaration implTd = context.getTypeDeclaration(implFqn);
|
||||
if (implTd != null) {
|
||||
results.addAll(traceFieldInConstructors(implTd, fieldName, context, visited, budget.child()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.isEmpty()) {
|
||||
// Global Fallback: Search all parsed classes in the workspace
|
||||
if (results.isEmpty() && budget != null && budget.allowGlobalFieldScan()) {
|
||||
// Global Fallback: Search all parsed classes in the workspace (explicit opt-in only)
|
||||
Collection<TypeDeclaration> allTypes = context.getTypeDeclarations();
|
||||
if (allTypes != null) {
|
||||
int matchedCount = 0;
|
||||
for (TypeDeclaration typeDecl : allTypes) {
|
||||
if (typeDecl == null) continue;
|
||||
if (context.hasField(typeDecl, fieldName)) {
|
||||
results.addAll(traceFieldInConstructors(typeDecl, fieldName, context, visited));
|
||||
results.addAll(traceFieldInConstructors(typeDecl, fieldName, context, visited, budget.child()));
|
||||
matchedCount++;
|
||||
if (matchedCount >= 10) {
|
||||
if (matchedCount >= budget.globalFieldScanLimit()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -194,6 +215,13 @@ public class ConstructorAnalyzer {
|
||||
return results;
|
||||
}
|
||||
|
||||
private static boolean shouldWidenToImplementations(TypeDeclaration td) {
|
||||
if (td == null) {
|
||||
return true;
|
||||
}
|
||||
return td.isInterface() || Modifier.isAbstract(td.getModifiers());
|
||||
}
|
||||
|
||||
public Map<String, String> buildFieldValuesFromCIC(
|
||||
TypeDeclaration td,
|
||||
ClassInstanceCreation cic,
|
||||
@@ -459,13 +487,26 @@ public class ConstructorAnalyzer {
|
||||
|
||||
public ClassInstanceCreation findReturnedCIC(
|
||||
String className, String methodName, CodebaseContext context) {
|
||||
return findReturnedCIC(className, methodName, context, new HashSet<>(), 0);
|
||||
String cacheKey = className + "#" + methodName;
|
||||
if (returnedCicAbsent.contains(cacheKey)) {
|
||||
return null;
|
||||
}
|
||||
if (returnedCicCache.containsKey(cacheKey)) {
|
||||
return returnedCicCache.get(cacheKey);
|
||||
}
|
||||
ClassInstanceCreation resolved = findReturnedCIC(className, methodName, context, new HashSet<>(), 0);
|
||||
if (resolved != null) {
|
||||
returnedCicCache.put(cacheKey, resolved);
|
||||
} else {
|
||||
returnedCicAbsent.add(cacheKey);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private ClassInstanceCreation findReturnedCIC(
|
||||
String className, String methodName, CodebaseContext context,
|
||||
Set<String> visited, int depth) {
|
||||
if (depth > 50 || !visited.add(className + "#" + methodName)) {
|
||||
if (ResolutionBudget.defaults().isReturnedCicExhausted(depth) || !visited.add(className + "#" + methodName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -554,11 +595,15 @@ public class ConstructorAnalyzer {
|
||||
|
||||
try {
|
||||
Map<String, String> fieldValues = buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor);
|
||||
log.debug("RESOLVE_GETTER: td={}, fieldValues={}", context.getFqn(td), fieldValues);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("RESOLVE_GETTER: td={}, fieldValues={}", context.getFqn(td), fieldValues);
|
||||
}
|
||||
if (fieldValues.isEmpty()) return Collections.emptyList();
|
||||
|
||||
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, visited);
|
||||
log.debug("RESOLVE_GETTER: result={}", result);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("RESOLVE_GETTER: result={}", result);
|
||||
}
|
||||
return result != null ? List.of(result) : Collections.emptyList();
|
||||
} finally {
|
||||
visited.remove(getterKey);
|
||||
@@ -776,18 +821,10 @@ public class ConstructorAnalyzer {
|
||||
}
|
||||
|
||||
private MethodDeclaration findEnclosingMethod(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null && !(parent instanceof MethodDeclaration)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return (MethodDeclaration) parent;
|
||||
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
|
||||
}
|
||||
|
||||
private TypeDeclaration findEnclosingType(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null && !(parent instanceof TypeDeclaration)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return (TypeDeclaration) parent;
|
||||
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,172 +2,21 @@ package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Placeholder kept for API stability. The exporter never executes Maven or Gradle against the
|
||||
* analyzed project. Multi-module layout is discovered statically via
|
||||
* {@link click.kamil.springstatemachineexporter.analysis.resolver.SiblingDependencyResolver}
|
||||
* (parsed {@code settings.gradle}, {@code pom.xml}, {@code build.gradle}).
|
||||
*/
|
||||
@Slf4j
|
||||
public class DynamicClasspathResolver {
|
||||
|
||||
public List<String> resolveClasspath(Path projectRoot) {
|
||||
log.info("Attempting dynamic classpath resolution for project at {}", projectRoot);
|
||||
if (projectRoot == null || !Files.exists(projectRoot)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (Files.exists(projectRoot.resolve("pom.xml"))) {
|
||||
return resolveMaven(projectRoot);
|
||||
} else if (Files.exists(projectRoot.resolve("build.gradle")) || Files.exists(projectRoot.resolve("build.gradle.kts"))) {
|
||||
return resolveGradle(projectRoot);
|
||||
}
|
||||
|
||||
log.info("No Maven or Gradle configuration found at {}. Proceeding without dynamic classpath.", projectRoot);
|
||||
log.debug("Skipping external JAR classpath: analysis uses scanned source files only.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
protected List<String> resolveMaven(Path projectRoot) {
|
||||
log.info("Maven project detected. Resolving dependencies...");
|
||||
try {
|
||||
String mvnCmd = getCommand(projectRoot, "mvnw", "mvn");
|
||||
if (mvnCmd == null) {
|
||||
log.warn("Maven executable not found.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
mvnCmd,
|
||||
"-q",
|
||||
"dependency:build-classpath",
|
||||
"-DincludeScope=compile",
|
||||
"-Dmdep.outputFile=sme_cp.txt"
|
||||
);
|
||||
pb.directory(projectRoot.toFile());
|
||||
|
||||
runProcess(pb);
|
||||
|
||||
java.util.Set<String> allPaths = new java.util.LinkedHashSet<>();
|
||||
try (java.util.stream.Stream<Path> stream = Files.walk(projectRoot)) {
|
||||
stream.filter(p -> p.getFileName().toString().equals("sme_cp.txt"))
|
||||
.forEach(cpFile -> {
|
||||
try {
|
||||
String cpString = Files.readString(cpFile).trim();
|
||||
if (!cpString.isEmpty()) {
|
||||
allPaths.addAll(Arrays.asList(cpString.split(File.pathSeparator)));
|
||||
}
|
||||
Files.deleteIfExists(cpFile);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to read or delete sme_cp.txt at {}", cpFile, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!allPaths.isEmpty()) {
|
||||
return new ArrayList<>(allPaths);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to dynamically resolve Maven classpath", e);
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
protected List<String> resolveGradle(Path projectRoot) {
|
||||
log.info("Gradle project detected. Resolving dependencies...");
|
||||
Path initScript = null;
|
||||
try {
|
||||
initScript = Files.createTempFile("state_machine_exporter_init", ".gradle");
|
||||
String gradleCmd = getCommand(projectRoot, "gradlew", "gradle");
|
||||
if (gradleCmd == null) {
|
||||
log.warn("Gradle executable not found.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
String scriptContent = """
|
||||
allprojects {
|
||||
task smePrintClasspath {
|
||||
doLast {
|
||||
def cp = []
|
||||
if (configurations.findByName("compileClasspath")) {
|
||||
cp = configurations.compileClasspath.files
|
||||
}
|
||||
println "SME_CLASSPATH_MARKER:" + cp.join(File.pathSeparator)
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
Files.writeString(initScript, scriptContent);
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
gradleCmd,
|
||||
"-q",
|
||||
"-I", initScript.toAbsolutePath().toString(),
|
||||
"smePrintClasspath"
|
||||
);
|
||||
pb.directory(projectRoot.toFile());
|
||||
|
||||
String output = runProcessAndGetOutput(pb);
|
||||
java.util.Set<String> allPaths = new java.util.LinkedHashSet<>();
|
||||
for (String line : output.split("\\r?\\n")) {
|
||||
if (line.startsWith("SME_CLASSPATH_MARKER:")) {
|
||||
String cpString = line.substring("SME_CLASSPATH_MARKER:".length()).trim();
|
||||
if (!cpString.isEmpty()) {
|
||||
allPaths.addAll(Arrays.asList(cpString.split(File.pathSeparator)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!allPaths.isEmpty()) {
|
||||
return new ArrayList<>(allPaths);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to dynamically resolve Gradle classpath", e);
|
||||
} finally {
|
||||
if (initScript != null) {
|
||||
try {
|
||||
Files.deleteIfExists(initScript);
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private String getCommand(Path projectRoot, String wrapper, String systemBinary) {
|
||||
Path wrapperUnix = projectRoot.resolve(wrapper);
|
||||
Path wrapperWin = projectRoot.resolve(wrapper + ".bat");
|
||||
|
||||
boolean isWin = System.getProperty("os.name").toLowerCase().contains("win");
|
||||
|
||||
if (isWin && Files.exists(wrapperWin)) return wrapperWin.toAbsolutePath().toString();
|
||||
if (!isWin && Files.exists(wrapperUnix)) {
|
||||
// Need absolute path like ./gradlew
|
||||
return wrapperUnix.toAbsolutePath().toString();
|
||||
}
|
||||
|
||||
return systemBinary;
|
||||
}
|
||||
|
||||
protected void runProcess(ProcessBuilder pb) throws IOException, InterruptedException {
|
||||
pb.redirectErrorStream(true);
|
||||
pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);
|
||||
Process p = pb.start();
|
||||
p.waitFor();
|
||||
}
|
||||
|
||||
protected String runProcessAndGetOutput(ProcessBuilder pb) throws IOException, InterruptedException {
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
StringBuilder out = new StringBuilder();
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
out.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
p.waitFor();
|
||||
return out.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,146 @@
|
||||
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.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.path.ForwardPathEstimationEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class EnrichmentService {
|
||||
private final List<AnalysisEnricher> enrichers;
|
||||
private final List<AnalysisEnricher> prePropertyEnrichers;
|
||||
private final List<AnalysisEnricher> postPropertyEnrichers;
|
||||
|
||||
/** Single-phase pipeline (all enrichers run together; used by focused tests). */
|
||||
public EnrichmentService(List<AnalysisEnricher> enrichers) {
|
||||
this(enrichers, List.of());
|
||||
}
|
||||
|
||||
public EnrichmentService(
|
||||
List<AnalysisEnricher> prePropertyEnrichers,
|
||||
List<AnalysisEnricher> postPropertyEnrichers) {
|
||||
this.prePropertyEnrichers = prePropertyEnrichers;
|
||||
this.postPropertyEnrichers = postPropertyEnrichers;
|
||||
}
|
||||
|
||||
public static EnrichmentService createDefault() {
|
||||
return new EnrichmentService(
|
||||
List.of(
|
||||
new TriggerEnricher(),
|
||||
new EntryPointEnricher(),
|
||||
new PropertyEnricher()),
|
||||
List.of(
|
||||
new CallChainEnricher(),
|
||||
new ForwardPathEstimationEnricher(),
|
||||
new TriggerCanonicalizationEnricher(),
|
||||
new TransitionLinkerEnricher()));
|
||||
}
|
||||
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
enrichPreProperty(result, context, intelligence);
|
||||
enrichPostProperty(result, context, intelligence);
|
||||
}
|
||||
|
||||
public void enrichPreProperty(
|
||||
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
runEnrichers(prePropertyEnrichers, result, context, intelligence);
|
||||
}
|
||||
|
||||
public void enrichPostProperty(
|
||||
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
runEnrichers(postPropertyEnrichers, result, context, intelligence);
|
||||
}
|
||||
|
||||
/** Re-runs transition linking after property resolution without rebuilding call chains. */
|
||||
public void relinkAfterPropertyResolution(
|
||||
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
runEnrichers(
|
||||
List.of(new TriggerCanonicalizationEnricher(), new TransitionLinkerEnricher()),
|
||||
result,
|
||||
context,
|
||||
intelligence);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON re-export path: when source and entry points are available, rebuild call chains from the
|
||||
* call graph so dispatcher triggers regain {@code polymorphicEvents}/{@code external}, then relink.
|
||||
*/
|
||||
public void refreshCallChainsAndRelink(
|
||||
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
if (result.getMetadata() != null && context != null && intelligence != null) {
|
||||
List<CallChain> refreshed = CallChainEnricher.buildCallChainsForMachine(result, context, intelligence);
|
||||
if (refreshed != null) {
|
||||
List<CallChain> merged = mergeCallChainsByEntryPoint(result.getMetadata().getCallChains(), refreshed);
|
||||
result.setMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
|
||||
.triggers(result.getMetadata().getTriggers())
|
||||
.entryPoints(result.getMetadata().getEntryPoints())
|
||||
.callChains(merged)
|
||||
.properties(result.getMetadata().getProperties())
|
||||
.build());
|
||||
}
|
||||
}
|
||||
relinkAfterPropertyResolution(result, context, intelligence);
|
||||
}
|
||||
|
||||
private static List<CallChain> mergeCallChainsByEntryPoint(
|
||||
List<CallChain> existing,
|
||||
List<CallChain> refreshed) {
|
||||
Map<String, CallChain> byEntryPoint = new LinkedHashMap<>();
|
||||
if (existing != null) {
|
||||
for (CallChain chain : existing) {
|
||||
byEntryPoint.putIfAbsent(callChainKey(chain), chain);
|
||||
}
|
||||
}
|
||||
for (CallChain chain : refreshed) {
|
||||
byEntryPoint.put(callChainKey(chain), chain);
|
||||
}
|
||||
return new ArrayList<>(byEntryPoint.values());
|
||||
}
|
||||
|
||||
private static String callChainKey(CallChain chain) {
|
||||
if (chain == null) {
|
||||
return "";
|
||||
}
|
||||
String entryPointKey = entryPointKey(chain.getEntryPoint());
|
||||
if (!entryPointKey.isBlank()) {
|
||||
return entryPointKey;
|
||||
}
|
||||
if (chain.getTriggerPoint() != null
|
||||
&& chain.getTriggerPoint().getClassName() != null
|
||||
&& chain.getTriggerPoint().getMethodName() != null) {
|
||||
return chain.getTriggerPoint().getClassName() + "#" + chain.getTriggerPoint().getMethodName();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String entryPointKey(EntryPoint entryPoint) {
|
||||
if (entryPoint == null) {
|
||||
return "";
|
||||
}
|
||||
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
|
||||
return entryPoint.getName();
|
||||
}
|
||||
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
|
||||
}
|
||||
|
||||
private static void runEnrichers(
|
||||
List<AnalysisEnricher> enrichers,
|
||||
AnalysisResult result,
|
||||
CodebaseContext context,
|
||||
CodebaseIntelligenceProvider intelligence) {
|
||||
for (AnalysisEnricher enricher : enrichers) {
|
||||
enricher.enrich(result, context, intelligence);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Classifies trailing expression access (map lookup vs traceable accessor) using AST structure and
|
||||
* declared receiver types instead of string heuristics.
|
||||
*/
|
||||
public final class ExpressionAccessClassifier {
|
||||
|
||||
public enum AccessKind {
|
||||
/** {@code Map.get(key)}, {@code list.get(i)} — keep intact for {@link ConstantExtractor}. */
|
||||
KEYED_LOOKUP,
|
||||
/** {@code getEvent()}, {@code supplier.get()} — split receiver/suffix for call-path tracing. */
|
||||
TRACEABLE_ACCESSOR,
|
||||
NONE
|
||||
}
|
||||
|
||||
private final CodebaseContext context;
|
||||
private final VariableTracer variableTracer;
|
||||
private final ConstantResolver constantResolver;
|
||||
private final Function<String, ASTNode> expressionParser;
|
||||
|
||||
public ExpressionAccessClassifier(
|
||||
CodebaseContext context,
|
||||
VariableTracer variableTracer,
|
||||
ConstantResolver constantResolver,
|
||||
Function<String, ASTNode> expressionParser) {
|
||||
this.context = context;
|
||||
this.variableTracer = variableTracer;
|
||||
this.constantResolver = constantResolver;
|
||||
this.expressionParser = expressionParser;
|
||||
}
|
||||
|
||||
public AccessKind classifyTrailingAccess(String expression, String scopeMethod) {
|
||||
if (expression == null || expression.isBlank()) {
|
||||
return AccessKind.NONE;
|
||||
}
|
||||
ASTNode node = expressionParser.apply(expression);
|
||||
while (node instanceof ParenthesizedExpression pe) {
|
||||
node = pe.getExpression();
|
||||
}
|
||||
if (node instanceof ArrayAccess) {
|
||||
return AccessKind.NONE;
|
||||
}
|
||||
if (node instanceof MethodInvocation mi) {
|
||||
return classifyMethodInvocation(mi, scopeMethod);
|
||||
}
|
||||
return AccessKind.NONE;
|
||||
}
|
||||
|
||||
public AccessKind classifyMethodInvocation(MethodInvocation mi, String scopeMethod) {
|
||||
if (mi == null) {
|
||||
return AccessKind.NONE;
|
||||
}
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
int argCount = mi.arguments().size();
|
||||
|
||||
if ("getOrDefault".equals(methodName) && argCount >= 1) {
|
||||
return AccessKind.KEYED_LOOKUP;
|
||||
}
|
||||
|
||||
if ("get".equals(methodName)) {
|
||||
String receiverType = resolveReceiverType(mi.getExpression(), scopeMethod);
|
||||
if (argCount == 0) {
|
||||
if (isMapLikeType(receiverType)) {
|
||||
return AccessKind.KEYED_LOOKUP;
|
||||
}
|
||||
return AccessKind.TRACEABLE_ACCESSOR;
|
||||
}
|
||||
if (isMapLikeType(receiverType) || isIndexedCollectionType(receiverType)) {
|
||||
return AccessKind.KEYED_LOOKUP;
|
||||
}
|
||||
if (receiverExpressionIsMapLiteral(mi.getExpression())) {
|
||||
return AccessKind.KEYED_LOOKUP;
|
||||
}
|
||||
return AccessKind.TRACEABLE_ACCESSOR;
|
||||
}
|
||||
|
||||
if (argCount == 0 && (isBeanAccessorName(methodName) || isRecordStyleAccessor(methodName))) {
|
||||
return AccessKind.TRACEABLE_ACCESSOR;
|
||||
}
|
||||
|
||||
return AccessKind.TRACEABLE_ACCESSOR;
|
||||
}
|
||||
|
||||
public boolean isKeyedLookup(String expression, String scopeMethod) {
|
||||
return classifyTrailingAccess(expression, scopeMethod) == AccessKind.KEYED_LOOKUP;
|
||||
}
|
||||
|
||||
public boolean isKeyedLookup(MethodInvocation mi, String scopeMethod) {
|
||||
return classifyMethodInvocation(mi, scopeMethod) == AccessKind.KEYED_LOOKUP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a trailing suffix like {@code .getEvent()} or {@code .type()} should be resolved via accessor tracing.
|
||||
*/
|
||||
public boolean isTraceableAccessorSuffix(String suffix) {
|
||||
if (suffix == null || !suffix.startsWith(".")) {
|
||||
return false;
|
||||
}
|
||||
String methodPart = suffix.substring(1);
|
||||
if (methodPart.endsWith("()")) {
|
||||
methodPart = methodPart.substring(0, methodPart.length() - 2);
|
||||
}
|
||||
return isBeanAccessorName(methodPart) || isRecordStyleAccessor(methodPart);
|
||||
}
|
||||
|
||||
public boolean isTraceableAccessorExpression(String expression, String scopeMethod) {
|
||||
if (expression == null || expression.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
AccessKind kind = classifyTrailingAccess(expression, scopeMethod);
|
||||
return kind == AccessKind.TRACEABLE_ACCESSOR;
|
||||
}
|
||||
|
||||
private boolean receiverExpressionIsMapLiteral(Expression receiver) {
|
||||
if (constantResolver == null || context == null || receiver == null) {
|
||||
return false;
|
||||
}
|
||||
String resolved = constantResolver.resolve(receiver, context);
|
||||
return resolved != null && resolved.startsWith("MAP:");
|
||||
}
|
||||
|
||||
private String resolveReceiverType(Expression receiver, String scopeMethod) {
|
||||
if (receiver == null) {
|
||||
return null;
|
||||
}
|
||||
if (receiverExpressionIsMapLiteral(receiver)) {
|
||||
return "Map";
|
||||
}
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
String declared = lookupDeclaredType(scopeMethod, sn.getIdentifier());
|
||||
if (declared != null) {
|
||||
return declared;
|
||||
}
|
||||
return lookupFieldType(classNameFromScopeMethod(scopeMethod), sn.getIdentifier());
|
||||
}
|
||||
if (receiver instanceof ThisExpression) {
|
||||
return classNameFromScopeMethod(scopeMethod);
|
||||
}
|
||||
if (receiver instanceof FieldAccess fa) {
|
||||
String fieldName = fa.getName().getIdentifier();
|
||||
if (fa.getExpression() instanceof ThisExpression) {
|
||||
return lookupFieldType(classNameFromScopeMethod(scopeMethod), fieldName);
|
||||
}
|
||||
if (fa.getExpression() instanceof SimpleName outer) {
|
||||
String outerType = lookupDeclaredType(scopeMethod, outer.getIdentifier());
|
||||
return lookupFieldType(rawTypeName(outerType), fieldName);
|
||||
}
|
||||
if (fa.getExpression() instanceof QualifiedName qn) {
|
||||
String outerType = resolveQualifiedTypeName(qn);
|
||||
return lookupFieldType(outerType, fieldName);
|
||||
}
|
||||
}
|
||||
if (receiver instanceof QualifiedName qn) {
|
||||
String typeName = resolveQualifiedTypeName(qn);
|
||||
if (typeName != null) {
|
||||
String fieldName = qn.getName().getIdentifier();
|
||||
String fieldType = lookupFieldType(typeName, fieldName);
|
||||
if (fieldType != null) {
|
||||
return fieldType;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (receiver instanceof MethodInvocation chainMi) {
|
||||
AccessKind kind = classifyMethodInvocation(chainMi, scopeMethod);
|
||||
if (kind == AccessKind.TRACEABLE_ACCESSOR) {
|
||||
String getterName = chainMi.getName().getIdentifier();
|
||||
String baseType = resolveReceiverType(chainMi.getExpression(), scopeMethod);
|
||||
return lookupMethodReturnType(rawTypeName(baseType), getterName);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String lookupDeclaredType(String scopeMethod, String name) {
|
||||
if (variableTracer == null || scopeMethod == null || name == null) {
|
||||
return null;
|
||||
}
|
||||
return variableTracer.getVariableDeclaredType(scopeMethod, name);
|
||||
}
|
||||
|
||||
private String lookupFieldType(String typeName, String fieldName) {
|
||||
if (context == null || typeName == null || fieldName == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration td = context.getTypeDeclaration(typeName);
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
return findFieldType(td, fieldName, new HashSet<>());
|
||||
}
|
||||
|
||||
private String findFieldType(TypeDeclaration td, String fieldName, Set<String> visited) {
|
||||
String typeFqn = context.getFqn(td);
|
||||
if (typeFqn == null || !visited.add(typeFqn)) {
|
||||
return null;
|
||||
}
|
||||
for (FieldDeclaration fd : td.getFields()) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
|
||||
if (frag.getName().getIdentifier().equals(fieldName)) {
|
||||
return fd.getType().toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
String superFqn = context.getSuperclassFqn(td);
|
||||
if (superFqn != null) {
|
||||
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
||||
if (superTd != null) {
|
||||
String inherited = findFieldType(superTd, fieldName, visited);
|
||||
if (inherited != null) {
|
||||
return inherited;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String lookupMethodReturnType(String typeName, String methodName) {
|
||||
if (context == null || typeName == null || methodName == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration td = context.getTypeDeclaration(typeName);
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md != null && md.getReturnType2() != null) {
|
||||
return md.getReturnType2().toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveQualifiedTypeName(QualifiedName qn) {
|
||||
String full = qn.getFullyQualifiedName();
|
||||
if (full.contains(".")) {
|
||||
return full.substring(0, full.lastIndexOf('.'));
|
||||
}
|
||||
return classNameFromScopeMethod(null);
|
||||
}
|
||||
|
||||
private static String classNameFromScopeMethod(String scopeMethod) {
|
||||
if (scopeMethod == null || !scopeMethod.contains(".")) {
|
||||
return null;
|
||||
}
|
||||
return scopeMethod.substring(0, scopeMethod.lastIndexOf('.'));
|
||||
}
|
||||
|
||||
private static boolean isBeanAccessorName(String methodName) {
|
||||
return (methodName.startsWith("get") && methodName.length() > 3)
|
||||
|| (methodName.startsWith("is") && methodName.length() > 2);
|
||||
}
|
||||
|
||||
private static boolean isRecordStyleAccessor(String methodName) {
|
||||
return "type".equals(methodName) || "event".equals(methodName) || "name".equals(methodName);
|
||||
}
|
||||
|
||||
static boolean isMapLikeType(String type) {
|
||||
String raw = rawTypeName(type);
|
||||
if (raw == null) {
|
||||
return false;
|
||||
}
|
||||
return "Map".equals(raw) || raw.endsWith("Map");
|
||||
}
|
||||
|
||||
static boolean isIndexedCollectionType(String type) {
|
||||
String raw = rawTypeName(type);
|
||||
if (raw == null) {
|
||||
return false;
|
||||
}
|
||||
return "List".equals(raw) || raw.endsWith("List")
|
||||
|| "Set".equals(raw) || raw.endsWith("Set")
|
||||
|| raw.endsWith("Deque") || "Queue".equals(raw);
|
||||
}
|
||||
|
||||
static String rawTypeName(String type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
String raw = type.trim();
|
||||
if (raw.contains("<")) {
|
||||
raw = raw.substring(0, raw.indexOf('<'));
|
||||
}
|
||||
if (raw.contains(".")) {
|
||||
raw = raw.substring(raw.lastIndexOf('.') + 1);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Single source-derived policy for marking REST triggers as external vs internal.
|
||||
*/
|
||||
public final class ExternalTriggerPolicy {
|
||||
|
||||
private ExternalTriggerPolicy() {
|
||||
}
|
||||
|
||||
public static boolean isExternalFromSource(
|
||||
EntryPoint entryPoint,
|
||||
TriggerPoint trigger,
|
||||
String entryMethodFqn,
|
||||
String resolvedEventParamName,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null) {
|
||||
return false;
|
||||
}
|
||||
if (entryPoint != null && entryPointHasUnboundPlaceholders(entryPoint)
|
||||
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
|
||||
String paramName = resolveRestEventParameterName(entryPoint, resolvedEventParamName);
|
||||
if (paramName != null) {
|
||||
RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context);
|
||||
if (binding != null && binding.pathVariable() && binding.enumType()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
|
||||
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) {
|
||||
return false;
|
||||
}
|
||||
if (trigger.getPolymorphicEvents() != null
|
||||
&& !trigger.getPolymorphicEvents().isEmpty()
|
||||
&& MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) {
|
||||
return false;
|
||||
}
|
||||
if (entryPoint != null && entryPoint.getType() == EntryPoint.Type.REST) {
|
||||
String paramName = resolveRestEventParameterName(entryPoint, resolvedEventParamName);
|
||||
if (paramName != null) {
|
||||
RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context);
|
||||
if (binding != null) {
|
||||
if (binding.pathOrQueryVariable() && !binding.enumType()) {
|
||||
if (binding.pathVariable() && !entryPointHasUnboundPlaceholders(entryPoint)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (binding.requestBodyEnum()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) {
|
||||
RestParamBinding requestBodyField = findRequestBodyEnumFieldBinding(
|
||||
entryPoint.getClassName() + "." + entryPoint.getMethodName(), paramName, context);
|
||||
if (requestBodyField != null && requestBodyField.requestBodyEnum()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entryPoint.getName() != null && entryPoint.getName().contains("{")
|
||||
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
|
||||
if (paramName != null) {
|
||||
RestParamBinding pathBinding = findRestParameterBinding(entryPoint, paramName, context);
|
||||
if (pathBinding != null && pathBinding.pathVariable() && pathBinding.enumType()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (entryMethodFqn != null && resolvedEventParamName != null) {
|
||||
RestParamBinding binding = findMethodParameterBinding(entryMethodFqn, resolvedEventParamName, context);
|
||||
if (binding != null) {
|
||||
if (binding.pathOrQueryVariable() && !binding.enumType()) {
|
||||
return true;
|
||||
}
|
||||
if (binding.requestBodyEnum()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
RestParamBinding requestBodyField = findRequestBodyEnumFieldBinding(
|
||||
entryMethodFqn, resolvedEventParamName, context);
|
||||
if (requestBodyField != null && requestBodyField.requestBodyEnum()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (entryPoint != null && entryPoint.getType() == EntryPoint.Type.REST
|
||||
&& !entryPointHasUnboundPlaceholders(entryPoint)
|
||||
&& MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) {
|
||||
return false;
|
||||
}
|
||||
return trigger.isExternal();
|
||||
}
|
||||
|
||||
private static boolean entryPointHasUnboundPlaceholders(EntryPoint entryPoint) {
|
||||
if (entryPoint == null) {
|
||||
return false;
|
||||
}
|
||||
if (entryPoint.getName() != null && entryPoint.getName().contains("{")) {
|
||||
return true;
|
||||
}
|
||||
if (entryPoint.getMetadata() != null) {
|
||||
String path = entryPoint.getMetadata().get("path");
|
||||
if (path != null && path.contains("{")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String resolveRestEventParameterName(EntryPoint entryPoint, String resolvedEventParamName) {
|
||||
if (resolvedEventParamName != null && !resolvedEventParamName.isBlank()) {
|
||||
return resolvedEventParamName;
|
||||
}
|
||||
if (entryPoint.getParameters() == null) {
|
||||
return null;
|
||||
}
|
||||
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||
if (parameter.getAnnotations() == null) {
|
||||
continue;
|
||||
}
|
||||
for (String annotation : parameter.getAnnotations()) {
|
||||
if ("PathVariable".equals(annotation) || "RequestParam".equals(annotation)) {
|
||||
return parameter.getName();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RestParamBinding findRestParameterBinding(
|
||||
EntryPoint entryPoint, String paramName, CodebaseContext context) {
|
||||
if (entryPoint.getParameters() == null || paramName == null) {
|
||||
return null;
|
||||
}
|
||||
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||
if (!paramName.equals(parameter.getName())) {
|
||||
continue;
|
||||
}
|
||||
return RestParamBinding.fromEntryPointParameter(parameter);
|
||||
}
|
||||
if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) {
|
||||
return findMethodParameterBinding(
|
||||
entryPoint.getClassName() + "." + entryPoint.getMethodName(), paramName, context);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RestParamBinding findMethodParameterBinding(
|
||||
String methodFqn, String paramName, CodebaseContext context) {
|
||||
if (methodFqn == null || !methodFqn.contains(".") || paramName == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
|
||||
if (typeDeclaration == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration method = context.findMethodDeclaration(typeDeclaration, methodName, true);
|
||||
if (method == null) {
|
||||
return null;
|
||||
}
|
||||
for (Object paramObj : method.parameters()) {
|
||||
if (!(paramObj instanceof SingleVariableDeclaration param)) {
|
||||
continue;
|
||||
}
|
||||
if (!paramName.equals(param.getName().getIdentifier())) {
|
||||
continue;
|
||||
}
|
||||
return RestParamBinding.fromAstParameter(param);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RestParamBinding findRequestBodyEnumFieldBinding(
|
||||
String methodFqn, String fieldName, CodebaseContext context) {
|
||||
if (methodFqn == null || !methodFqn.contains(".") || fieldName == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
|
||||
if (typeDeclaration == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration method = context.findMethodDeclaration(typeDeclaration, methodName, true);
|
||||
if (method == null) {
|
||||
return null;
|
||||
}
|
||||
for (Object paramObj : method.parameters()) {
|
||||
if (!(paramObj instanceof SingleVariableDeclaration param)) {
|
||||
continue;
|
||||
}
|
||||
if (!hasParameterAstAnnotation(param, "RequestBody") || param.getType() == null) {
|
||||
continue;
|
||||
}
|
||||
ITypeBinding bodyBinding = param.getType().resolveBinding();
|
||||
if (bodyBinding == null || bodyBinding.isEnum()) {
|
||||
continue;
|
||||
}
|
||||
if (isEnumRecordComponent(bodyBinding, fieldName)) {
|
||||
return new RestParamBinding(false, false, true, true);
|
||||
}
|
||||
if (isEnumDtoField(bodyBinding, fieldName, context)) {
|
||||
return new RestParamBinding(false, false, true, true);
|
||||
}
|
||||
if (containsNestedEnumField(bodyBinding, fieldName, context)) {
|
||||
return new RestParamBinding(false, false, true, true);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isEnumRecordComponent(ITypeBinding bodyBinding, String fieldName) {
|
||||
if (bodyBinding == null || fieldName == null) {
|
||||
return false;
|
||||
}
|
||||
for (IVariableBinding component : bodyBinding.getDeclaredFields()) {
|
||||
if (!fieldName.equals(component.getName())) {
|
||||
continue;
|
||||
}
|
||||
ITypeBinding fieldType = component.getType();
|
||||
return fieldType != null && fieldType.isEnum();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isEnumDtoField(ITypeBinding bodyBinding, String fieldName, CodebaseContext context) {
|
||||
if (bodyBinding == null || fieldName == null || context == null) {
|
||||
return false;
|
||||
}
|
||||
String bodyFqn = bodyBinding.getQualifiedName();
|
||||
if (bodyFqn == null) {
|
||||
return false;
|
||||
}
|
||||
AbstractTypeDeclaration bodyType = context.getAbstractTypeDeclaration(bodyFqn);
|
||||
if (!(bodyType instanceof TypeDeclaration td)) {
|
||||
return false;
|
||||
}
|
||||
for (FieldDeclaration field : td.getFields()) {
|
||||
for (Object fragObj : field.fragments()) {
|
||||
if (!(fragObj instanceof VariableDeclarationFragment fragment)) {
|
||||
continue;
|
||||
}
|
||||
if (!fieldName.equals(fragment.getName().getIdentifier())) {
|
||||
continue;
|
||||
}
|
||||
ITypeBinding fieldBinding = field.getType().resolveBinding();
|
||||
return fieldBinding != null && fieldBinding.isEnum();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean containsNestedEnumField(
|
||||
ITypeBinding bodyBinding, String fieldName, CodebaseContext context) {
|
||||
return containsNestedEnumField(bodyBinding, fieldName, context, new HashSet<>());
|
||||
}
|
||||
|
||||
private static boolean containsNestedEnumField(
|
||||
ITypeBinding bodyBinding,
|
||||
String fieldName,
|
||||
CodebaseContext context,
|
||||
Set<String> visited) {
|
||||
if (bodyBinding == null || fieldName == null || context == null) {
|
||||
return false;
|
||||
}
|
||||
String bodyFqn = bodyBinding.getQualifiedName();
|
||||
if (bodyFqn == null || !visited.add(bodyFqn)) {
|
||||
return false;
|
||||
}
|
||||
for (IVariableBinding component : bodyBinding.getDeclaredFields()) {
|
||||
ITypeBinding nestedType = component.getType();
|
||||
if (nestedType == null || nestedType.isPrimitive() || nestedType.isEnum()) {
|
||||
continue;
|
||||
}
|
||||
if (isEnumRecordComponent(nestedType, fieldName) || isEnumDtoField(nestedType, fieldName, context)) {
|
||||
return true;
|
||||
}
|
||||
if (containsNestedEnumField(nestedType, fieldName, context, visited)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasParameterAstAnnotation(SingleVariableDeclaration param, String simpleName) {
|
||||
for (Object modifier : param.modifiers()) {
|
||||
if (modifier instanceof Annotation annotation
|
||||
&& annotation.getTypeName().getFullyQualifiedName().endsWith(simpleName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private record RestParamBinding(boolean pathVariable, boolean requestParam, boolean requestBody, boolean enumType) {
|
||||
boolean pathOrQueryVariable() {
|
||||
return pathVariable || requestParam;
|
||||
}
|
||||
|
||||
boolean requestBodyEnum() {
|
||||
return requestBody && enumType;
|
||||
}
|
||||
|
||||
static RestParamBinding fromEntryPointParameter(EntryPoint.Parameter parameter) {
|
||||
boolean pathVariable = hasAnnotation(parameter.getAnnotations(), "PathVariable");
|
||||
boolean requestParam = hasAnnotation(parameter.getAnnotations(), "RequestParam");
|
||||
boolean requestBody = hasAnnotation(parameter.getAnnotations(), "RequestBody");
|
||||
boolean enumType = isEnumTypeName(parameter.getType());
|
||||
return new RestParamBinding(pathVariable, requestParam, requestBody, enumType);
|
||||
}
|
||||
|
||||
static RestParamBinding fromAstParameter(SingleVariableDeclaration param) {
|
||||
boolean pathVariable = hasParameterAstAnnotation(param, "PathVariable");
|
||||
boolean requestParam = hasParameterAstAnnotation(param, "RequestParam");
|
||||
boolean requestBody = hasParameterAstAnnotation(param, "RequestBody");
|
||||
boolean enumType = param.getType() != null && param.getType().resolveBinding() != null
|
||||
&& param.getType().resolveBinding().isEnum();
|
||||
return new RestParamBinding(pathVariable, requestParam, requestBody, enumType);
|
||||
}
|
||||
|
||||
private static boolean hasAnnotation(java.util.List<String> annotations, String name) {
|
||||
return annotations != null && annotations.stream().anyMatch(name::equals);
|
||||
}
|
||||
|
||||
private static boolean isEnumTypeName(String typeName) {
|
||||
if (typeName == null || typeName.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
int lastDot = typeName.lastIndexOf('.');
|
||||
String simple = lastDot >= 0 ? typeName.substring(lastDot + 1) : typeName;
|
||||
return Character.isUpperCase(simple.charAt(0)) && !simple.endsWith("String");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
/**
|
||||
* Recognizes JDK functional interface parameters used as delayed event providers
|
||||
* ({@code Supplier.get()}, etc.) in call-graph argument tracing.
|
||||
*/
|
||||
public final class FunctionalInterfaceTypes {
|
||||
|
||||
private FunctionalInterfaceTypes() {
|
||||
}
|
||||
|
||||
public static boolean isFunctionalInterface(String typeName) {
|
||||
if (typeName == null || typeName.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String simple = typeName.contains(".") ? typeName.substring(typeName.lastIndexOf('.') + 1) : typeName;
|
||||
if (simple.contains("<")) {
|
||||
simple = simple.substring(0, simple.indexOf('<'));
|
||||
}
|
||||
return "Supplier".equals(simple) || "Function".equals(simple) || "Callable".equals(simple);
|
||||
}
|
||||
|
||||
public static boolean isLambdaArgument(String argValue) {
|
||||
if (argValue == null) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = argValue.trim();
|
||||
return trimmed.contains("->")
|
||||
&& (trimmed.startsWith("(") || trimmed.matches("^\\w+\\s*->.*"));
|
||||
}
|
||||
|
||||
/**
|
||||
* When the caller passes a lambda or enum literal into a functional parameter, skip
|
||||
* incompatible-type rejection between {@code Supplier<T>} and resolved {@code T}.
|
||||
*/
|
||||
public static boolean isProvablyResolvedCallSiteArgument(String argValue, String expectedType) {
|
||||
if (argValue == null || argValue.isBlank() || expectedType == null) {
|
||||
return false;
|
||||
}
|
||||
if (!isFunctionalInterface(expectedType)) {
|
||||
return false;
|
||||
}
|
||||
if (looksLikeEnumConstant(argValue)) {
|
||||
return true;
|
||||
}
|
||||
return isLambdaArgument(argValue);
|
||||
}
|
||||
|
||||
static boolean looksLikeEnumConstant(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String name = value.contains(".") ? value.substring(value.lastIndexOf('.') + 1) : value;
|
||||
return name.matches("[A-Z_][A-Z0-9_]*");
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,49 @@ 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");
|
||||
|
||||
/** Built-in source patterns for Spring State Machine and messaging APIs (no hints.json required). */
|
||||
private static final List<LibraryHint> BUILTIN_SOURCE_PATTERNS = List.of(
|
||||
LibraryHint.builder().methodFqn("org.springframework.messaging.support.MessageBuilder.build").eventArgumentIndex(0).build(),
|
||||
LibraryHint.builder().methodFqn("org.springframework.messaging.support.GenericMessage.getPayload").eventArgumentMethod("getPayload").build());
|
||||
|
||||
public List<TriggerPoint> detect(CompilationUnit cu) {
|
||||
List<TriggerPoint> triggers = new ArrayList<>();
|
||||
@@ -33,21 +60,20 @@ public class GenericEventDetector {
|
||||
public boolean visit(MethodInvocation node) {
|
||||
String methodName = node.getName().getIdentifier();
|
||||
|
||||
if ("sendEvent".equals(methodName) || "sendEvents".equals(methodName) ||
|
||||
"sendEventCollect".equals(methodName) || "sendEventMono".equals(methodName)) {
|
||||
if (TRIGGER_METHOD_NAMES.contains(methodName)) {
|
||||
processSendEvent(node, cu, triggers);
|
||||
} else {
|
||||
for (Object argObj : node.arguments()) {
|
||||
if (argObj instanceof ExpressionMethodReference emr) {
|
||||
String refMethod = emr.getName().getIdentifier();
|
||||
if ("sendEvent".equals(refMethod) || "sendEvents".equals(refMethod) ||
|
||||
"sendEventCollect".equals(refMethod) || "sendEventMono".equals(refMethod)) {
|
||||
if (TRIGGER_METHOD_NAMES.contains(refMethod)) {
|
||||
processSendEventMethodReference(node, emr, cu, triggers);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processBuiltInPatterns(node, cu, triggers);
|
||||
processHints(node, cu, triggers);
|
||||
|
||||
return super.visit(node);
|
||||
@@ -63,11 +89,15 @@ public class GenericEventDetector {
|
||||
eventValue = receiver != null ? receiver.toString() : null;
|
||||
}
|
||||
|
||||
MethodDeclaration method = findEnclosingMethod(node);
|
||||
TypeDeclaration type = findEnclosingType(node);
|
||||
MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
|
||||
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
|
||||
if (type == null) return;
|
||||
|
||||
String sourceState = extractSourceState(node);
|
||||
if (sourceState == null) {
|
||||
sourceState = extractSourceStateFromArguments(node);
|
||||
}
|
||||
String stateMachineId = extractStateMachineId(node);
|
||||
String[] smTypes = resolveStateMachineTypeArgumentsForExpression(emr.getExpression(), node);
|
||||
|
||||
boolean external = false;
|
||||
@@ -81,6 +111,7 @@ public class GenericEventDetector {
|
||||
triggers.add(TriggerPoint.builder()
|
||||
.event(eventValue)
|
||||
.sourceState(sourceState)
|
||||
.stateMachineId(stateMachineId)
|
||||
.className(context.getFqn(type))
|
||||
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
|
||||
.sourceFile(context.getRelativePath(context.getFqn(type)))
|
||||
@@ -94,6 +125,10 @@ public class GenericEventDetector {
|
||||
}
|
||||
|
||||
private String extractEventFromReceiver(Expression receiver) {
|
||||
String reactivePayload = ReactiveExpressionSupport.extractPayload(receiver, constantResolver, context);
|
||||
if (reactivePayload != null) {
|
||||
return reactivePayload;
|
||||
}
|
||||
if (receiver == null) return null;
|
||||
if (receiver instanceof MethodInvocation mi) {
|
||||
String mName = mi.getName().getIdentifier();
|
||||
@@ -131,8 +166,8 @@ public class GenericEventDetector {
|
||||
List<?> typeArgs = pt.typeArguments();
|
||||
if (typeArgs.size() >= 2) {
|
||||
CompilationUnit compilationUnit = (CompilationUnit) node.getRoot();
|
||||
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit);
|
||||
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit);
|
||||
String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit);
|
||||
String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit);
|
||||
return new String[]{stateType, eventType};
|
||||
}
|
||||
}
|
||||
@@ -144,44 +179,86 @@ public class GenericEventDetector {
|
||||
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, null);
|
||||
if (builtTriggers != null) {
|
||||
for (TriggerPoint trigger : builtTriggers) {
|
||||
log.debug("Successfully built trigger point: {}", trigger.getEvent());
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Successfully built trigger point: {}", trigger.getEvent());
|
||||
}
|
||||
triggers.add(trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processHints(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
|
||||
if (hints == null || hints.isEmpty()) return;
|
||||
|
||||
private void processBuiltInPatterns(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
|
||||
String methodName = node.getName().getIdentifier();
|
||||
if (TRIGGER_METHOD_NAMES.contains(methodName)) {
|
||||
return;
|
||||
}
|
||||
String calledMethod = resolveCalledMethodName(node);
|
||||
if (calledMethod == null) return;
|
||||
|
||||
for (LibraryHint hint : hints) {
|
||||
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) {
|
||||
String eventToUse = hint.getEvent();
|
||||
if (eventToUse == null && hint.getEventArgumentIndex() != null) {
|
||||
if (node.arguments().size() > hint.getEventArgumentIndex()) {
|
||||
Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex());
|
||||
eventToUse = argExpr.toString();
|
||||
if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) {
|
||||
eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
|
||||
if (builtTriggers != null) {
|
||||
for (TriggerPoint trigger : builtTriggers) {
|
||||
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
|
||||
triggers.add(trigger);
|
||||
if (calledMethod == null) {
|
||||
return;
|
||||
}
|
||||
for (LibraryHint pattern : BUILTIN_SOURCE_PATTERNS) {
|
||||
if (!matchesHint(calledMethod, pattern.getMethodFqn())) {
|
||||
continue;
|
||||
}
|
||||
String eventToUse = resolveHintEvent(node, pattern);
|
||||
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
|
||||
if (builtTriggers != null) {
|
||||
for (TriggerPoint trigger : builtTriggers) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Built trigger from built-in source pattern {}: {}", pattern.getMethodFqn(), trigger.getEvent());
|
||||
}
|
||||
triggers.add(trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isHintMatch(String called, String hintFqn) {
|
||||
return hintFqn.endsWith("." + called);
|
||||
private void processHints(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
|
||||
if (hints == null || hints.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String calledMethod = resolveCalledMethodName(node);
|
||||
if (calledMethod == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (LibraryHint hint : hints) {
|
||||
if (!matchesHint(calledMethod, hint.getMethodFqn())) {
|
||||
continue;
|
||||
}
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Applying optional hints.json override for {}", hint.getMethodFqn());
|
||||
}
|
||||
String eventToUse = resolveHintEvent(node, hint);
|
||||
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
|
||||
if (builtTriggers != null) {
|
||||
for (TriggerPoint trigger : builtTriggers) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
|
||||
}
|
||||
triggers.add(trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveHintEvent(MethodInvocation node, LibraryHint hint) {
|
||||
String eventToUse = hint.getEvent();
|
||||
if (eventToUse == null && hint.getEventArgumentIndex() != null) {
|
||||
if (node.arguments().size() > hint.getEventArgumentIndex()) {
|
||||
Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex());
|
||||
eventToUse = argExpr.toString();
|
||||
if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) {
|
||||
eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()";
|
||||
}
|
||||
}
|
||||
}
|
||||
return eventToUse;
|
||||
}
|
||||
|
||||
private static boolean matchesHint(String calledMethod, String hintFqn) {
|
||||
return calledMethod.equals(hintFqn) || hintFqn.endsWith("." + calledMethod);
|
||||
}
|
||||
|
||||
private String resolveCalledMethodName(MethodInvocation node) {
|
||||
@@ -191,18 +268,11 @@ public class GenericEventDetector {
|
||||
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
String name = sn.getIdentifier();
|
||||
// Check if it's a field in the enclosing class
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
if (td != null) {
|
||||
for (FieldDeclaration fd : td.getFields()) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
if (fragObj instanceof VariableDeclarationFragment fragment) {
|
||||
if (fragment.getName().getIdentifier().equals(name)) {
|
||||
String typeName = fd.getType().toString();
|
||||
return typeName + "." + methodName;
|
||||
}
|
||||
}
|
||||
}
|
||||
AbstractTypeDeclaration enclosing = findEnclosingAbstractType(node);
|
||||
if (enclosing != null) {
|
||||
Type memberType = lookupMemberType(enclosing, name);
|
||||
if (memberType != null) {
|
||||
return memberType + "." + methodName;
|
||||
}
|
||||
}
|
||||
return name + "." + methodName;
|
||||
@@ -227,14 +297,23 @@ public class GenericEventDetector {
|
||||
} else {
|
||||
eventValue = context.resolveExpression(eventExpr);
|
||||
}
|
||||
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetter(
|
||||
eventExpr, constantResolver, context);
|
||||
if (remapped != null) {
|
||||
eventValue = remapped;
|
||||
}
|
||||
}
|
||||
|
||||
MethodDeclaration method = findEnclosingMethod(node);
|
||||
TypeDeclaration type = findEnclosingType(node);
|
||||
MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
|
||||
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
|
||||
|
||||
if (type == null) return Collections.emptyList();
|
||||
|
||||
String sourceState = extractSourceState(node);
|
||||
if (sourceState == null && node instanceof MethodInvocation mi) {
|
||||
sourceState = extractSourceStateFromArguments(mi);
|
||||
}
|
||||
String stateMachineId = extractStateMachineId(node);
|
||||
String[] smTypes = resolveStateMachineTypeArguments(node);
|
||||
|
||||
boolean external = false;
|
||||
@@ -254,6 +333,7 @@ public class GenericEventDetector {
|
||||
results.add(TriggerPoint.builder()
|
||||
.event(part.trim())
|
||||
.sourceState(sourceState)
|
||||
.stateMachineId(stateMachineId)
|
||||
.className(context.getFqn(type))
|
||||
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
|
||||
.sourceFile(context.getRelativePath(context.getFqn(type)))
|
||||
@@ -269,6 +349,7 @@ public class GenericEventDetector {
|
||||
results.add(TriggerPoint.builder()
|
||||
.event(eventValue)
|
||||
.sourceState(sourceState)
|
||||
.stateMachineId(stateMachineId)
|
||||
.className(context.getFqn(type))
|
||||
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
|
||||
.sourceFile(context.getRelativePath(context.getFqn(type)))
|
||||
@@ -303,8 +384,25 @@ public class GenericEventDetector {
|
||||
String state = extractStateFromSiblings(current, block.statements());
|
||||
if (state != null) return state;
|
||||
} else if (parent instanceof SwitchStatement switchStmt) {
|
||||
String state = extractStateFromSiblings(current, switchStmt.statements());
|
||||
if (state != null) return state;
|
||||
if (!isRoutingParameter(switchStmt.getExpression())) {
|
||||
String state = extractStateFromSiblings(current, switchStmt.statements());
|
||||
if (state != null) return state;
|
||||
}
|
||||
} else if (parent instanceof SwitchExpression switchExpr) {
|
||||
if (!isRoutingParameter(switchExpr.getExpression())) {
|
||||
String state = extractStateFromSiblings(current, switchExpr.statements());
|
||||
if (state != null) return state;
|
||||
}
|
||||
} else if (parent instanceof SwitchCase switchCase && !switchCase.isDefault()) {
|
||||
Expression selector = resolveSwitchSelector(switchCase);
|
||||
if (selector != null && !isRoutingParameter(selector) && !switchCase.expressions().isEmpty()) {
|
||||
Expression caseExpr = (Expression) switchCase.expressions().get(0);
|
||||
String resolved = resolveProvableStateLiteral(caseExpr);
|
||||
if (resolved != null) {
|
||||
return resolved;
|
||||
}
|
||||
return getSimpleNameString(caseExpr);
|
||||
}
|
||||
}
|
||||
|
||||
current = parent;
|
||||
@@ -312,6 +410,251 @@ public class GenericEventDetector {
|
||||
return null;
|
||||
}
|
||||
|
||||
private Expression resolveSwitchSelector(SwitchCase switchCase) {
|
||||
ASTNode switchParent = switchCase.getParent();
|
||||
if (switchParent instanceof SwitchStatement switchStatement) {
|
||||
return switchStatement.getExpression();
|
||||
}
|
||||
if (switchParent instanceof SwitchExpression switchExpression) {
|
||||
return switchExpression.getExpression();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractStateMachineId(MethodInvocation node) {
|
||||
if (!TRIGGER_METHOD_NAMES.contains(node.getName().getIdentifier())) {
|
||||
return null;
|
||||
}
|
||||
Expression receiver = node.getExpression();
|
||||
while (receiver instanceof MethodInvocation methodInvocation) {
|
||||
receiver = methodInvocation.getExpression();
|
||||
}
|
||||
if (receiver == null) {
|
||||
return null;
|
||||
}
|
||||
AbstractTypeDeclaration enclosing = findEnclosingAbstractType(node);
|
||||
if (enclosing == null) {
|
||||
return null;
|
||||
}
|
||||
return resolveQualifierForReceiver(receiver, enclosing);
|
||||
}
|
||||
|
||||
private String resolveQualifierForReceiver(Expression receiver, AbstractTypeDeclaration enclosing) {
|
||||
String fieldName = null;
|
||||
if (receiver instanceof SimpleName simpleName) {
|
||||
fieldName = simpleName.getIdentifier();
|
||||
IBinding binding = simpleName.resolveBinding();
|
||||
if (binding instanceof IVariableBinding variableBinding) {
|
||||
String fromBinding = extractQualifierFromBinding(variableBinding);
|
||||
if (fromBinding != null) {
|
||||
return fromBinding;
|
||||
}
|
||||
}
|
||||
} else if (receiver instanceof FieldAccess fieldAccess) {
|
||||
fieldName = fieldAccess.getName().getIdentifier();
|
||||
}
|
||||
if (fieldName == null) {
|
||||
return null;
|
||||
}
|
||||
return findQualifierOnField(enclosing, fieldName);
|
||||
}
|
||||
|
||||
private String findQualifierOnField(AbstractTypeDeclaration enclosing, String fieldName) {
|
||||
for (FieldDeclaration fieldDeclaration : getFieldDeclarations(enclosing)) {
|
||||
for (Object fragmentObj : fieldDeclaration.fragments()) {
|
||||
if (fragmentObj instanceof VariableDeclarationFragment fragment
|
||||
&& fragment.getName().getIdentifier().equals(fieldName)) {
|
||||
String qualifier = extractQualifierAnnotation(fieldDeclaration.modifiers());
|
||||
if (qualifier != null) {
|
||||
return qualifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enclosing instanceof TypeDeclaration typeDeclaration) {
|
||||
for (MethodDeclaration method : typeDeclaration.getMethods()) {
|
||||
if (!hasAutowiredAnnotation(method) || method.parameters().size() != 1) {
|
||||
continue;
|
||||
}
|
||||
SingleVariableDeclaration param = (SingleVariableDeclaration) method.parameters().get(0);
|
||||
if (!matchesInjectionTarget(method, fieldName, param)) {
|
||||
continue;
|
||||
}
|
||||
String qualifier = extractQualifierAnnotation(param.modifiers());
|
||||
if (qualifier != null) {
|
||||
return qualifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean hasAutowiredAnnotation(MethodDeclaration method) {
|
||||
for (Object modifierObj : method.modifiers()) {
|
||||
if (modifierObj instanceof Annotation annotation
|
||||
&& annotation.getTypeName().toString().endsWith("Autowired")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean matchesInjectionTarget(
|
||||
MethodDeclaration method, String fieldName, SingleVariableDeclaration param) {
|
||||
if (fieldName.equals(param.getName().getIdentifier())) {
|
||||
return true;
|
||||
}
|
||||
String methodName = method.getName().getIdentifier();
|
||||
if (methodName.startsWith("set") && methodName.length() > 3) {
|
||||
String propertyName = methodName.substring(3);
|
||||
return propertyName.equalsIgnoreCase(fieldName)
|
||||
|| (Character.toLowerCase(propertyName.charAt(0)) + propertyName.substring(1)).equals(fieldName);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String extractQualifierFromBinding(IVariableBinding binding) {
|
||||
if (binding == null) {
|
||||
return null;
|
||||
}
|
||||
String qualifier = readQualifierFromAnnotations(binding.getAnnotations());
|
||||
if (qualifier != null) {
|
||||
return qualifier;
|
||||
}
|
||||
if (binding.isField() && binding.getDeclaringClass() != null) {
|
||||
for (IMethodBinding method : binding.getDeclaringClass().getDeclaredMethods()) {
|
||||
boolean isAutowiredMethod = false;
|
||||
for (IAnnotationBinding ann : method.getAnnotations()) {
|
||||
if (ann.getAnnotationType() != null
|
||||
&& "org.springframework.beans.factory.annotation.Autowired"
|
||||
.equals(ann.getAnnotationType().getQualifiedName())) {
|
||||
isAutowiredMethod = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!method.isConstructor() && !isAutowiredMethod) {
|
||||
continue;
|
||||
}
|
||||
for (int i = 0; i < method.getParameterTypes().length; i++) {
|
||||
if (!method.getParameterTypes()[i].isEqualTo(binding.getType().getErasure())) {
|
||||
continue;
|
||||
}
|
||||
if (method.isConstructor() && !binding.getName().equals(method.getParameterNames()[i])) {
|
||||
continue;
|
||||
}
|
||||
if (!method.isConstructor() && !matchesSetterParameterName(method.getName(), binding.getName())) {
|
||||
continue;
|
||||
}
|
||||
String paramQualifier = readQualifierFromAnnotations(method.getParameterAnnotations(i));
|
||||
if (paramQualifier != null) {
|
||||
return paramQualifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean matchesSetterParameterName(String methodName, String fieldName) {
|
||||
if (methodName == null || fieldName == null) {
|
||||
return false;
|
||||
}
|
||||
if (methodName.startsWith("set") && methodName.length() > 3) {
|
||||
String propertyName = methodName.substring(3);
|
||||
return propertyName.equalsIgnoreCase(fieldName)
|
||||
|| (Character.toLowerCase(propertyName.charAt(0)) + propertyName.substring(1)).equals(fieldName);
|
||||
}
|
||||
return fieldName.equals(methodName);
|
||||
}
|
||||
|
||||
private String readQualifierFromAnnotations(IAnnotationBinding[] annotations) {
|
||||
if (annotations == null) {
|
||||
return null;
|
||||
}
|
||||
for (IAnnotationBinding annotation : annotations) {
|
||||
if (annotation.getAnnotationType() == null
|
||||
|| !"org.springframework.beans.factory.annotation.Qualifier"
|
||||
.equals(annotation.getAnnotationType().getQualifiedName())) {
|
||||
continue;
|
||||
}
|
||||
for (IMemberValuePairBinding pair : annotation.getDeclaredMemberValuePairs()) {
|
||||
Object value = pair.getValue();
|
||||
if (value instanceof String stringValue) {
|
||||
return stringValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractQualifierAnnotation(List<?> modifiers) {
|
||||
if (modifiers == null) {
|
||||
return null;
|
||||
}
|
||||
for (Object modifierObj : modifiers) {
|
||||
if (!(modifierObj instanceof Annotation annotation)) {
|
||||
continue;
|
||||
}
|
||||
String typeName = annotation.getTypeName().toString();
|
||||
if (!typeName.endsWith("Qualifier")) {
|
||||
continue;
|
||||
}
|
||||
String value = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(
|
||||
annotation, "value");
|
||||
if (value == null || value.isBlank()) {
|
||||
value = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(
|
||||
annotation, "name");
|
||||
}
|
||||
return stripAnnotationQuotes(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String stripAnnotationQuotes(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = raw.trim();
|
||||
if (trimmed.length() >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
|
||||
return trimmed.substring(1, trimmed.length() - 1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers source state from a literal second argument to sendEvent(event, sourceState) when provable from AST.
|
||||
*/
|
||||
private String extractSourceStateFromArguments(MethodInvocation node) {
|
||||
if (node.arguments().size() < 2) {
|
||||
return null;
|
||||
}
|
||||
Expression stateExpr = (Expression) node.arguments().get(1);
|
||||
return resolveProvableStateLiteral(stateExpr);
|
||||
}
|
||||
|
||||
private String resolveProvableStateLiteral(Expression expr) {
|
||||
if (expr == null) {
|
||||
return null;
|
||||
}
|
||||
Expression peeled = expr;
|
||||
while (peeled instanceof ParenthesizedExpression pe) {
|
||||
peeled = pe.getExpression();
|
||||
}
|
||||
while (peeled instanceof CastExpression ce) {
|
||||
peeled = ce.getExpression();
|
||||
}
|
||||
if (peeled instanceof QualifiedName || peeled instanceof FieldAccess || peeled instanceof StringLiteral) {
|
||||
String resolved = constantResolver.resolve(peeled, context);
|
||||
if (resolved != null && !resolved.isBlank()) {
|
||||
return getSimpleNameString(peeled);
|
||||
}
|
||||
if (peeled instanceof StringLiteral sl) {
|
||||
return sl.getLiteralValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractStateFromSiblings(ASTNode currentNode, List<?> statements) {
|
||||
int index = statements.indexOf(currentNode);
|
||||
if (index <= 0) return null; // No previous siblings
|
||||
@@ -361,47 +704,47 @@ public class GenericEventDetector {
|
||||
if (left instanceof NullLiteral || right instanceof NullLiteral) {
|
||||
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"`
|
||||
|
||||
// If one is a QualifiedName (enum constant) or StringLiteral, it's likely the state
|
||||
if (left instanceof QualifiedName || left instanceof StringLiteral || left instanceof FieldAccess) {
|
||||
return getSimpleNameString(left);
|
||||
|
||||
if (isRoutingParameter(left) || isRoutingParameter(right)) {
|
||||
return null;
|
||||
}
|
||||
if (right instanceof QualifiedName || right instanceof StringLiteral || right instanceof FieldAccess) {
|
||||
return getSimpleNameString(right);
|
||||
|
||||
String leftState = resolveProvableStateLiteral(left);
|
||||
if (leftState != null) {
|
||||
return leftState;
|
||||
}
|
||||
String rightState = resolveProvableStateLiteral(right);
|
||||
if (rightState != null) {
|
||||
return rightState;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return getSimpleNameString(right);
|
||||
}
|
||||
} else if (expr instanceof MethodInvocation mi) {
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
if (("equals".equals(methodName) || "equalsIgnoreCase".equals(methodName)) && !mi.arguments().isEmpty()) {
|
||||
Expression receiver = mi.getExpression();
|
||||
Expression arg = (Expression) mi.arguments().get(0);
|
||||
|
||||
// If receiver is null (e.g., implicit this), fall back to arg
|
||||
if (receiver == null) {
|
||||
return getSimpleNameString(arg);
|
||||
|
||||
if (isRoutingParameter(receiver) || isRoutingParameter(arg)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prioritize the one that looks like a constant (QualifiedName or StringLiteral)
|
||||
if (receiver instanceof QualifiedName || receiver instanceof StringLiteral || receiver instanceof FieldAccess) {
|
||||
return getSimpleNameString(receiver);
|
||||
|
||||
String receiverState = resolveProvableStateLiteral(receiver);
|
||||
if (receiverState != null) {
|
||||
return receiverState;
|
||||
}
|
||||
if (arg instanceof QualifiedName || arg instanceof StringLiteral || arg instanceof FieldAccess) {
|
||||
return getSimpleNameString(arg);
|
||||
}
|
||||
|
||||
// Fallback to receiver
|
||||
return getSimpleNameString(receiver);
|
||||
return resolveProvableStateLiteral(arg);
|
||||
}
|
||||
}
|
||||
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();
|
||||
@@ -420,7 +763,7 @@ public class GenericEventDetector {
|
||||
|
||||
if (expr instanceof SimpleName sn) {
|
||||
String varName = sn.getIdentifier();
|
||||
MethodDeclaration enclosingMethod = findEnclosingMethod(expr);
|
||||
MethodDeclaration enclosingMethod = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(expr);
|
||||
if (enclosingMethod != null) {
|
||||
// Find variable declaration
|
||||
final Expression[] initializer = new Expression[1];
|
||||
@@ -492,7 +835,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 CallGraphBuilder can trace the lambda argument
|
||||
// return the provider's name so the call-graph engine can trace the lambda argument
|
||||
String traced = extractEventFromMessageBuilder(sn);
|
||||
return traced != null ? traced : sn.getIdentifier();
|
||||
}
|
||||
@@ -507,20 +850,45 @@ public class GenericEventDetector {
|
||||
return null;
|
||||
}
|
||||
|
||||
private MethodDeclaration findEnclosingMethod(ASTNode node) {
|
||||
private AbstractTypeDeclaration findEnclosingAbstractType(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null && !(parent instanceof MethodDeclaration)) {
|
||||
while (parent != null && !(parent instanceof AbstractTypeDeclaration)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return (MethodDeclaration) parent;
|
||||
return (AbstractTypeDeclaration) parent;
|
||||
}
|
||||
|
||||
private TypeDeclaration findEnclosingType(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null && !(parent instanceof TypeDeclaration)) {
|
||||
parent = parent.getParent();
|
||||
private List<FieldDeclaration> getFieldDeclarations(AbstractTypeDeclaration type) {
|
||||
if (type instanceof TypeDeclaration td) {
|
||||
return List.of(td.getFields());
|
||||
}
|
||||
return (TypeDeclaration) parent;
|
||||
if (type instanceof RecordDeclaration rd) {
|
||||
return List.of(rd.getFields());
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private Type lookupMemberType(AbstractTypeDeclaration enclosingType, String memberName) {
|
||||
if (enclosingType == null || memberName == null) {
|
||||
return null;
|
||||
}
|
||||
for (FieldDeclaration fd : getFieldDeclarations(enclosingType)) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
if (fragObj instanceof VariableDeclarationFragment fragment
|
||||
&& fragment.getName().getIdentifier().equals(memberName)) {
|
||||
return fd.getType();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enclosingType instanceof RecordDeclaration rd) {
|
||||
for (Object compObj : rd.recordComponents()) {
|
||||
if (compObj instanceof SingleVariableDeclaration svd
|
||||
&& svd.getName().getIdentifier().equals(memberName)) {
|
||||
return svd.getType();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String[] resolveStateMachineTypeArguments(MethodInvocation node) {
|
||||
@@ -559,8 +927,8 @@ public class GenericEventDetector {
|
||||
List<?> typeArgs = pt.typeArguments();
|
||||
if (typeArgs.size() >= 2) {
|
||||
CompilationUnit cu = (CompilationUnit) node.getRoot();
|
||||
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), cu);
|
||||
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), cu);
|
||||
String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), cu);
|
||||
String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), cu);
|
||||
|
||||
if (stateType != null && stateType.length() == 1) {
|
||||
stateType = resolveGenericTypeVariable(stateType, node, cu);
|
||||
@@ -576,7 +944,7 @@ public class GenericEventDetector {
|
||||
}
|
||||
|
||||
private String resolveGenericTypeVariable(String typeVarName, ASTNode node, CompilationUnit cu) {
|
||||
TypeDeclaration enclosingClass = findEnclosingType(node);
|
||||
AbstractTypeDeclaration enclosingClass = findEnclosingAbstractType(node);
|
||||
if (enclosingClass == null) return typeVarName;
|
||||
String className = context.getFqn(enclosingClass);
|
||||
if (className == null) return typeVarName;
|
||||
@@ -590,10 +958,14 @@ public class GenericEventDetector {
|
||||
Type superclassType = td.getSuperclassType();
|
||||
if (superclassType instanceof ParameterizedType pt) {
|
||||
Type baseType = pt.getType();
|
||||
String baseName = resolveTypeToFqn(baseType, (CompilationUnit) implNode.getRoot());
|
||||
String baseName = typeResolver.resolveTypeToFqn(baseType, (CompilationUnit) implNode.getRoot());
|
||||
if (className.equals(baseName) || className.endsWith("." + baseName)) {
|
||||
int typeIndex = -1;
|
||||
List<?> typeParams = enclosingClass.typeParameters();
|
||||
List<?> typeParams = enclosingClass instanceof TypeDeclaration typeDecl
|
||||
? typeDecl.typeParameters()
|
||||
: enclosingClass instanceof RecordDeclaration recordDecl
|
||||
? recordDecl.typeParameters()
|
||||
: List.of();
|
||||
for (int i = 0; i < typeParams.size(); i++) {
|
||||
TypeParameter tp = (TypeParameter) typeParams.get(i);
|
||||
if (tp.getName().getIdentifier().equals(typeVarName)) {
|
||||
@@ -603,7 +975,7 @@ public class GenericEventDetector {
|
||||
}
|
||||
if (typeIndex >= 0 && typeIndex < pt.typeArguments().size()) {
|
||||
Type concreteType = (Type) pt.typeArguments().get(typeIndex);
|
||||
return resolveTypeToFqn(concreteType, (CompilationUnit) implNode.getRoot());
|
||||
return typeResolver.resolveTypeToFqn(concreteType, (CompilationUnit) implNode.getRoot());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -635,7 +1007,7 @@ public class GenericEventDetector {
|
||||
if (!(receiver instanceof SimpleName sn)) return null;
|
||||
String varName = sn.getIdentifier();
|
||||
|
||||
MethodDeclaration enclosingMethod = findEnclosingMethod(node);
|
||||
MethodDeclaration enclosingMethod = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
|
||||
if (enclosingMethod != null) {
|
||||
// Check params
|
||||
for (Object paramObj : enclosingMethod.parameters()) {
|
||||
@@ -665,58 +1037,18 @@ public class GenericEventDetector {
|
||||
}
|
||||
}
|
||||
|
||||
// Check fields
|
||||
TypeDeclaration enclosingType = findEnclosingType(node);
|
||||
// Check fields and record components
|
||||
AbstractTypeDeclaration enclosingType = findEnclosingAbstractType(node);
|
||||
if (enclosingType != null) {
|
||||
for (FieldDeclaration fd : enclosingType.getFields()) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
if (fragObj instanceof VariableDeclarationFragment fragment) {
|
||||
if (fragment.getName().getIdentifier().equals(varName)) {
|
||||
return fd.getType();
|
||||
}
|
||||
}
|
||||
}
|
||||
Type memberType = lookupMemberType(enclosingType, varName);
|
||||
if (memberType != null) {
|
||||
return memberType;
|
||||
}
|
||||
}
|
||||
|
||||
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<>();
|
||||
|
||||
@@ -1,183 +1,22 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@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 callGraphCacheKey() {
|
||||
return "heuristicCallGraph";
|
||||
}
|
||||
|
||||
@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();
|
||||
@@ -189,12 +28,14 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
|
||||
}
|
||||
}
|
||||
|
||||
TypeDeclaration enclosingType = findEnclosingType(node);
|
||||
|
||||
if (receiver instanceof SuperMethodInvocation) {
|
||||
return InheritanceCallTargetResolver.resolveSuperMethod(context, enclosingType, methodName);
|
||||
}
|
||||
|
||||
if (receiver == null) {
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
if (td != null) {
|
||||
return resolveMethodInTypeHierarchy(td, methodName);
|
||||
}
|
||||
return null;
|
||||
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
|
||||
}
|
||||
|
||||
ITypeBinding binding = receiver.resolveTypeBinding();
|
||||
@@ -203,10 +44,7 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
|
||||
}
|
||||
|
||||
if (receiver instanceof ThisExpression) {
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
if (td != null) {
|
||||
return context.getFqn(td) + "." + methodName;
|
||||
}
|
||||
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
|
||||
}
|
||||
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
@@ -214,8 +52,7 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
|
||||
if (fallbackTypeFqn != null) {
|
||||
return fallbackTypeFqn + "." + methodName;
|
||||
}
|
||||
String receiverName = sn.getIdentifier();
|
||||
return receiverName + "." + methodName;
|
||||
return sn.getIdentifier() + "." + methodName;
|
||||
}
|
||||
|
||||
if (receiver instanceof QualifiedName qn) {
|
||||
@@ -224,411 +61,9 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.ASTNode;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
|
||||
/**
|
||||
* Resolves call-graph target FQNs for {@code this}, implicit, and {@code super} dispatch hops.
|
||||
* Uses the declaring type of the resolved method (parent class for inherited members).
|
||||
*/
|
||||
final class InheritanceCallTargetResolver {
|
||||
|
||||
private InheritanceCallTargetResolver() {
|
||||
}
|
||||
|
||||
static String resolveInstanceMethod(
|
||||
CodebaseContext context,
|
||||
TypeDeclaration enclosingType,
|
||||
String methodName) {
|
||||
if (context == null || enclosingType == null || methodName == null || methodName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration method = context.findMethodDeclaration(enclosingType, methodName, true);
|
||||
if (method == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration declaringType = findEnclosingTypeDeclaration(method);
|
||||
if (declaringType == null) {
|
||||
return null;
|
||||
}
|
||||
String fqn = context.getFqn(declaringType);
|
||||
return fqn == null ? null : fqn + "." + methodName;
|
||||
}
|
||||
|
||||
static String resolveSuperMethod(
|
||||
CodebaseContext context,
|
||||
TypeDeclaration enclosingType,
|
||||
String methodName) {
|
||||
if (context == null || enclosingType == null || methodName == null || methodName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String superFqn = context.getSuperclassFqn(enclosingType);
|
||||
if (superFqn == null) {
|
||||
return null;
|
||||
}
|
||||
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
|
||||
if (superType == null) {
|
||||
return superFqn + "." + methodName;
|
||||
}
|
||||
String resolved = resolveInstanceMethod(context, superType, methodName);
|
||||
return resolved != null ? resolved : superFqn + "." + methodName;
|
||||
}
|
||||
|
||||
private static TypeDeclaration findEnclosingTypeDeclaration(ASTNode node) {
|
||||
ASTNode current = node;
|
||||
while (current != null) {
|
||||
if (current instanceof TypeDeclaration typeDeclaration) {
|
||||
return typeDeclaration;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,167 +1,42 @@
|
||||
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.analysis.spring.InjectionPointAnalyzer;
|
||||
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);
|
||||
this.injectionAnalyzer = injectionAnalyzer;
|
||||
}
|
||||
|
||||
@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 callGraphCacheKey() {
|
||||
return "jdtCallGraph";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resolveArgument(org.eclipse.jdt.core.dom.Expression expr) {
|
||||
protected String resolveArgument(Expression expr) {
|
||||
if (expr == null) {
|
||||
return "null";
|
||||
}
|
||||
// Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...))
|
||||
org.eclipse.jdt.core.dom.Expression unwrappedBuilder = unwrapMessageBuilder(expr);
|
||||
Expression unwrappedBuilder = unwrapMessageBuilder(expr);
|
||||
if (unwrappedBuilder != expr) {
|
||||
expr = unwrappedBuilder;
|
||||
}
|
||||
|
||||
// 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
|
||||
Expression tracedExpr = traceVariable(expr);
|
||||
if (tracedExpr instanceof QualifiedName
|
||||
|| tracedExpr instanceof ClassInstanceCreation
|
||||
|| tracedExpr instanceof StringLiteral
|
||||
|| tracedExpr instanceof NumberLiteral) {
|
||||
expr = tracedExpr;
|
||||
}
|
||||
|
||||
// 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+", "-> ");
|
||||
@@ -169,6 +44,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String resolveCalledMethod(MethodInvocation node) {
|
||||
Expression receiver = node.getExpression();
|
||||
String methodName = node.getName().getIdentifier();
|
||||
@@ -180,12 +56,14 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
||||
}
|
||||
}
|
||||
|
||||
TypeDeclaration enclosingType = findEnclosingType(node);
|
||||
|
||||
if (receiver instanceof SuperMethodInvocation) {
|
||||
return InheritanceCallTargetResolver.resolveSuperMethod(context, enclosingType, methodName);
|
||||
}
|
||||
|
||||
if (receiver == null) {
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
if (td != null) {
|
||||
return resolveMethodInType(td, methodName);
|
||||
}
|
||||
return null;
|
||||
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
|
||||
}
|
||||
|
||||
if (injectionAnalyzer != null) {
|
||||
@@ -196,15 +74,19 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
||||
nameBinding = fa.resolveFieldBinding();
|
||||
}
|
||||
if (nameBinding instanceof IVariableBinding varBinding) {
|
||||
log.debug("CALLGRAPH RESOLVING BEAN FOR BINDING: {} calling {}", varBinding.getName(), methodName);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("CALLGRAPH RESOLVING BEAN FOR BINDING: {} calling {}", varBinding.getName(), methodName);
|
||||
}
|
||||
String concreteFqn = injectionAnalyzer.resolveInjectedBeanFqn(varBinding);
|
||||
if (concreteFqn != null) {
|
||||
log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn);
|
||||
}
|
||||
return concreteFqn + "." + methodName;
|
||||
} else {
|
||||
} else if (log.isDebugEnabled()) {
|
||||
log.debug(" -> RESOLVE FAILED, RETURNED NULL");
|
||||
}
|
||||
} else {
|
||||
} else if (log.isDebugEnabled()) {
|
||||
log.debug("CALLGRAPH NAME BINDING IS NOT VARIABLE: {} calling {}", nameBinding, methodName);
|
||||
}
|
||||
}
|
||||
@@ -219,7 +101,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
||||
typeName = binding.getErasure().getName();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
} catch (Exception ignored) {
|
||||
// Ignore JDT internal exceptions
|
||||
}
|
||||
|
||||
@@ -234,7 +116,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
||||
}
|
||||
|
||||
if (typeName != null && !typeName.isEmpty()) {
|
||||
org.eclipse.jdt.core.dom.TypeDeclaration resolvedTd = context.getTypeDeclaration(typeName);
|
||||
TypeDeclaration resolvedTd = context.getTypeDeclaration(typeName);
|
||||
if (resolvedTd != null && context.findMethodDeclaration(resolvedTd, methodName, true) != null) {
|
||||
return typeName + "." + methodName;
|
||||
}
|
||||
@@ -242,10 +124,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
||||
}
|
||||
|
||||
if (receiver instanceof ThisExpression) {
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
if (td != null) {
|
||||
return context.getFqn(td) + "." + methodName;
|
||||
}
|
||||
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
|
||||
}
|
||||
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
@@ -253,95 +132,12 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
||||
if (fallbackTypeFqn != null) {
|
||||
return fallbackTypeFqn + "." + methodName;
|
||||
}
|
||||
String receiverName = sn.getIdentifier();
|
||||
return receiverName + "." + methodName;
|
||||
return sn.getIdentifier() + "." + 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;
|
||||
@@ -360,55 +156,4 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import java.util.Map;
|
||||
public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
|
||||
private final CodebaseContext context;
|
||||
private final Path rootDir;
|
||||
private final ConstantResolver constantResolver = new ConstantResolver();
|
||||
private final ConstantResolver constantResolver;
|
||||
private final GenericEventDetector eventDetector;
|
||||
private final SpringMvcDetector mvcDetector;
|
||||
private final MessagingDetector messagingDetector;
|
||||
@@ -35,6 +35,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
|
||||
public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) {
|
||||
this.context = context;
|
||||
this.rootDir = rootDir;
|
||||
this.constantResolver = context.getConstantResolver();
|
||||
this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints());
|
||||
this.mvcDetector = new SpringMvcDetector(context, constantResolver);
|
||||
this.messagingDetector = new MessagingDetector(context);
|
||||
@@ -92,8 +93,15 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
|
||||
|
||||
@Override
|
||||
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
|
||||
log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers", entryPoints.size(), triggers.size());
|
||||
return callGraphEngine.findChains(entryPoints, triggers);
|
||||
List<EntryPoint> scopedEntryPoints = entryPoints != null && !entryPoints.isEmpty()
|
||||
? entryPoints
|
||||
: findEntryPoints();
|
||||
List<TriggerPoint> scopedTriggers = triggers != null && !triggers.isEmpty()
|
||||
? triggers
|
||||
: findTriggerPoints();
|
||||
log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers",
|
||||
scopedEntryPoints.size(), scopedTriggers.size());
|
||||
return callGraphEngine.findChains(scopedEntryPoints, scopedTriggers);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the nearest ancestor directory that looks like a Gradle/Maven module
|
||||
* ({@code build.gradle} or {@code pom.xml}), not the monorepo {@code settings.gradle} root.
|
||||
*/
|
||||
public static Path findProjectRoot(Path start) {
|
||||
Path current = start != null ? start.toAbsolutePath().normalize() : null;
|
||||
while (current != null) {
|
||||
if (Files.exists(current.resolve("build.gradle")) || Files.exists(current.resolve("pom.xml"))) {
|
||||
return current;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when the scanned context actually contains classes referenced by the imported JSON. */
|
||||
public static boolean contextContainsAnalysisSources(
|
||||
click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result,
|
||||
CodebaseContext context) {
|
||||
if (context == null || result == null) {
|
||||
return false;
|
||||
}
|
||||
if (result.getName() != null && context.getTypeDeclaration(result.getName()) != null) {
|
||||
return true;
|
||||
}
|
||||
if (result.getMetadata() == null || result.getMetadata().getEntryPoints() == null) {
|
||||
return false;
|
||||
}
|
||||
for (click.kamil.springstatemachineexporter.analysis.model.EntryPoint entryPoint
|
||||
: result.getMetadata().getEntryPoints()) {
|
||||
if (entryPoint.getClassName() != null
|
||||
&& context.getTypeDeclaration(entryPoint.getClassName()) != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static CodebaseContext scanProjectRoot(Path projectRoot, List<String> activeProfiles) throws IOException {
|
||||
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(".");
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,8 @@ public class LifecycleDetector {
|
||||
}
|
||||
|
||||
private TriggerPoint buildLifecyclePoint(MethodInvocation node, CompilationUnit cu, String type) {
|
||||
MethodDeclaration method = findEnclosingMethod(node);
|
||||
TypeDeclaration typeDecl = findEnclosingType(node);
|
||||
MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
|
||||
TypeDeclaration typeDecl = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
|
||||
|
||||
if (typeDecl == null) return null;
|
||||
|
||||
@@ -43,20 +43,4 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ public class MessagingDetector {
|
||||
} else if (typeName.endsWith("RabbitListener")) {
|
||||
type = EntryPoint.Type.RABBIT;
|
||||
destination = resolveValue(annotation, "queues");
|
||||
if ("unknown".equals(destination)) {
|
||||
destination = resolveValue(annotation, "queue");
|
||||
}
|
||||
if ("unknown".equals(destination)) {
|
||||
destination = resolveValue(annotation, "bindings");
|
||||
}
|
||||
} else if (typeName.endsWith("SqsListener")) {
|
||||
type = EntryPoint.Type.SQS;
|
||||
destination = resolveValue(annotation, "value");
|
||||
@@ -55,6 +61,9 @@ public class MessagingDetector {
|
||||
} else if (typeName.endsWith("KafkaListener")) {
|
||||
type = EntryPoint.Type.KAFKA;
|
||||
destination = resolveValue(annotation, "topics");
|
||||
if ("unknown".equals(destination)) {
|
||||
destination = resolveValue(annotation, "topicPattern");
|
||||
}
|
||||
}
|
||||
|
||||
if (type != null) {
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
/**
|
||||
* Walks a call path forward, propagating known parameter / local bindings so switch-case
|
||||
* edge constraints can prune impossible branches (e.g. {@code dispatch} arms).
|
||||
*/
|
||||
public class PathBindingEvaluator {
|
||||
|
||||
private final CodebaseContext context;
|
||||
private final VariableTracer variableTracer;
|
||||
private final ConstantResolver constantResolver;
|
||||
private final TypeResolver typeResolver;
|
||||
private final Map<String, Map<String, String>> traceBindingsCache = new HashMap<>();
|
||||
|
||||
public PathBindingEvaluator(
|
||||
CodebaseContext context,
|
||||
VariableTracer variableTracer,
|
||||
ConstantResolver constantResolver,
|
||||
TypeResolver typeResolver) {
|
||||
this.context = context;
|
||||
this.variableTracer = variableTracer;
|
||||
this.constantResolver = constantResolver;
|
||||
this.typeResolver = typeResolver;
|
||||
}
|
||||
|
||||
/** Visible for tests: bindings accumulated while walking a path forward (ignores constraint rejection). */
|
||||
Map<String, String> traceBindings(List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
|
||||
if (path == null || path.size() < 2) {
|
||||
return Map.of();
|
||||
}
|
||||
String cacheKey = pathCacheKey(path);
|
||||
Map<String, String> cached = traceBindingsCache.get(cacheKey);
|
||||
if (cached != null) {
|
||||
return new HashMap<>(cached);
|
||||
}
|
||||
Map<String, String> bindings = computeTraceBindings(path, callGraph, pathFinder);
|
||||
traceBindingsCache.put(cacheKey, Map.copyOf(bindings));
|
||||
return new HashMap<>(bindings);
|
||||
}
|
||||
|
||||
public void clearAnalysisCaches() {
|
||||
traceBindingsCache.clear();
|
||||
}
|
||||
|
||||
private Map<String, String> computeTraceBindings(
|
||||
List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
|
||||
Map<String, String> bindings = new HashMap<>();
|
||||
for (int i = 0; i < path.size() - 1; i++) {
|
||||
String caller = path.get(i);
|
||||
String target = path.get(i + 1);
|
||||
CallEdge edge = findEdge(caller, target, callGraph, pathFinder);
|
||||
if (edge == null) {
|
||||
continue;
|
||||
}
|
||||
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings);
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
private static String pathCacheKey(List<String> path) {
|
||||
StringJoiner joiner = new StringJoiner("\0");
|
||||
for (String hop : path) {
|
||||
joiner.add(hop);
|
||||
}
|
||||
return joiner.toString();
|
||||
}
|
||||
|
||||
public boolean isPathCompatible(
|
||||
List<String> path,
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
CallGraphPathFinder pathFinder,
|
||||
Map<String, String> initialBindings) {
|
||||
if (path == null || path.size() < 2) {
|
||||
return true;
|
||||
}
|
||||
Map<String, String> bindings = new HashMap<>();
|
||||
if (initialBindings != null) {
|
||||
bindings.putAll(initialBindings);
|
||||
}
|
||||
for (int i = 0; i < path.size() - 1; i++) {
|
||||
String caller = path.get(i);
|
||||
String target = path.get(i + 1);
|
||||
CallEdge edge = findEdge(caller, target, callGraph, pathFinder);
|
||||
if (edge == null) {
|
||||
continue;
|
||||
}
|
||||
if (edge.getConstraint() != null
|
||||
&& !edge.getConstraint().isBlank()
|
||||
&& !BooleanConstraintEvaluator.isCompatibleWithBindings(edge.getConstraint(), bindings)) {
|
||||
return false;
|
||||
}
|
||||
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean isPathCompatible(List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
|
||||
return isPathCompatible(path, callGraph, pathFinder, Map.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} when {@code caller}→{@code callee} via {@code edge} satisfies the edge constraint
|
||||
* under {@code bindings}; on success, {@code bindings} is updated with propagated parameter values.
|
||||
*/
|
||||
public boolean tryTraverseEdge(
|
||||
String caller,
|
||||
String callee,
|
||||
CallEdge edge,
|
||||
List<String> pathWithCallee,
|
||||
int calleeIndex,
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
CallGraphPathFinder pathFinder,
|
||||
Map<String, String> bindings) {
|
||||
if (edge.getConstraint() != null
|
||||
&& !edge.getConstraint().isBlank()
|
||||
&& !BooleanConstraintEvaluator.isCompatibleWithBindings(edge.getConstraint(), bindings)) {
|
||||
return false;
|
||||
}
|
||||
if (variableTracer == null || typeResolver == null) {
|
||||
return true;
|
||||
}
|
||||
enrichBindings(caller, callee, edge, pathWithCallee, calleeIndex, callGraph, pathFinder, bindings);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void enrichBindings(
|
||||
String caller,
|
||||
String target,
|
||||
CallEdge edge,
|
||||
List<String> path,
|
||||
int pathIndex,
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
CallGraphPathFinder pathFinder,
|
||||
Map<String, String> bindings) {
|
||||
Map<String, String> paramValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, pathIndex);
|
||||
mergeResolvedBindings(bindings, paramValues, caller);
|
||||
|
||||
List<String> targetParams = typeResolver.getParameterNames(target);
|
||||
if (targetParams == null || edge.getArguments() == null) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < edge.getArguments().size() && i < targetParams.size(); i++) {
|
||||
String arg = edge.getArguments().get(i);
|
||||
String paramName = targetParams.get(i);
|
||||
String resolved = resolveBindingValue(caller, arg, bindings);
|
||||
if (shouldStoreBinding(paramName, resolved)) {
|
||||
bindings.put(paramName, resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldStoreBinding(String paramName, String resolved) {
|
||||
if (resolved == null || resolved.isBlank() || resolved.equals(paramName)) {
|
||||
return false;
|
||||
}
|
||||
if (resolved.contains("(") && !isEnumLikeConstant(resolved)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void mergeResolvedBindings(Map<String, String> bindings, Map<String, String> paramValues, String caller) {
|
||||
if (paramValues == null || paramValues.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Map.Entry<String, String> entry : paramValues.entrySet()) {
|
||||
String resolved = resolveBindingValue(caller, entry.getValue(), bindings);
|
||||
if (shouldStoreBinding(entry.getKey(), resolved)) {
|
||||
bindings.put(entry.getKey(), resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveBindingValue(String callerFqn, String rawValue, Map<String, String> bindings) {
|
||||
if (rawValue == null || rawValue.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (rawValue.startsWith("\"") || isEnumLikeConstant(rawValue)) {
|
||||
return normalizeLiteral(rawValue);
|
||||
}
|
||||
if (bindings.containsKey(rawValue)) {
|
||||
return bindings.get(rawValue);
|
||||
}
|
||||
String traced = variableTracer.traceLocalVariable(callerFqn, rawValue, bindings);
|
||||
if (traced != null && !traced.isBlank()) {
|
||||
if (traced.startsWith("\"") || isEnumLikeConstant(traced)) {
|
||||
return traced;
|
||||
}
|
||||
String fromCall = resolveMethodCallFromSource(callerFqn, rawValue, bindings);
|
||||
if (fromCall != null) {
|
||||
return fromCall;
|
||||
}
|
||||
if (!traced.equals(rawValue)) {
|
||||
return traced;
|
||||
}
|
||||
}
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
private String resolveMethodCallFromSource(String callerFqn, String varName, Map<String, String> bindings) {
|
||||
MethodDeclaration md = findMethodDeclaration(callerFqn);
|
||||
if (md == null || md.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
final Expression[] initializer = new Expression[1];
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(VariableDeclarationFragment node) {
|
||||
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
|
||||
initializer[0] = node.getInitializer();
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
if (initializer[0] == null) {
|
||||
return null;
|
||||
}
|
||||
return evaluateExpressionValue(initializer[0], callerFqn, bindings);
|
||||
}
|
||||
|
||||
private String evaluateExpressionValue(Expression expr, String callerFqn, Map<String, String> bindings) {
|
||||
if (expr instanceof StringLiteral sl) {
|
||||
return "\"" + sl.getLiteralValue() + "\"";
|
||||
}
|
||||
if (expr instanceof QualifiedName qn) {
|
||||
return qn.getFullyQualifiedName();
|
||||
}
|
||||
if (expr instanceof SimpleName sn) {
|
||||
return bindings.getOrDefault(sn.getIdentifier(), sn.getIdentifier());
|
||||
}
|
||||
if (expr instanceof MethodInvocation mi) {
|
||||
return evaluateMethodInvocationReturn(mi, callerFqn, bindings);
|
||||
}
|
||||
if (expr instanceof SwitchExpression se) {
|
||||
return constantResolver.evaluateSwitchWithParams(se, bindings, context);
|
||||
}
|
||||
String resolved = constantResolver.resolve(expr, context);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private String evaluateMethodInvocationReturn(MethodInvocation mi, String callerFqn, Map<String, String> bindings) {
|
||||
String ownerFqn = resolveMethodOwnerFqn(mi, callerFqn);
|
||||
if (ownerFqn == null) {
|
||||
return null;
|
||||
}
|
||||
String methodName = mi.getName().getIdentifier();
|
||||
TypeDeclaration owner = context.getTypeDeclaration(ownerFqn);
|
||||
if (owner == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true);
|
||||
if (md == null || md.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, String> callBindings = new HashMap<>(bindings);
|
||||
for (int i = 0; i < md.parameters().size() && i < mi.arguments().size(); i++) {
|
||||
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i);
|
||||
String paramName = param.getName().getIdentifier();
|
||||
String argValue = evaluateExpressionValue((Expression) mi.arguments().get(i), callerFqn, callBindings);
|
||||
if (argValue != null) {
|
||||
callBindings.put(paramName, argValue);
|
||||
}
|
||||
}
|
||||
|
||||
final String[] result = new String[1];
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(ReturnStatement node) {
|
||||
if (node.getExpression() instanceof SwitchExpression se) {
|
||||
result[0] = constantResolver.evaluateSwitchWithParams(se, callBindings, context);
|
||||
} else if (node.getExpression() != null) {
|
||||
result[0] = evaluateExpressionValue(node.getExpression(), ownerFqn + "." + methodName, callBindings);
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return result[0];
|
||||
}
|
||||
|
||||
private String resolveFieldTypeFqn(String callerFqn, String fieldName) {
|
||||
if (callerFqn == null || !callerFqn.contains(".")) {
|
||||
return null;
|
||||
}
|
||||
String className = callerFqn.substring(0, callerFqn.lastIndexOf('.'));
|
||||
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
for (FieldDeclaration field : td.getFields()) {
|
||||
for (Object fragmentObj : field.fragments()) {
|
||||
VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj;
|
||||
if (fragment.getName().getIdentifier().equals(fieldName)) {
|
||||
IVariableBinding binding = fragment.resolveBinding();
|
||||
if (binding != null && binding.getType() != null) {
|
||||
return binding.getType().getErasure().getQualifiedName();
|
||||
}
|
||||
String simpleType = field.getType().toString();
|
||||
TypeDeclaration resolved = context.getTypeDeclaration(simpleType);
|
||||
if (resolved != null) {
|
||||
return context.getFqn(resolved);
|
||||
}
|
||||
if (td.getRoot() instanceof CompilationUnit cu && cu.getPackage() != null) {
|
||||
String pkg = cu.getPackage().getName().getFullyQualifiedName();
|
||||
resolved = context.getTypeDeclaration(pkg + "." + simpleType);
|
||||
if (resolved != null) {
|
||||
return context.getFqn(resolved);
|
||||
}
|
||||
}
|
||||
return simpleType;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveMethodOwnerFqn(MethodInvocation mi, String callerFqn) {
|
||||
IMethodBinding methodBinding = mi.resolveMethodBinding();
|
||||
if (methodBinding != null && methodBinding.getDeclaringClass() != null) {
|
||||
return methodBinding.getDeclaringClass().getErasure().getQualifiedName();
|
||||
}
|
||||
Expression receiver = mi.getExpression();
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
return resolveFieldTypeFqn(callerFqn, sn.getIdentifier());
|
||||
}
|
||||
if (receiver instanceof FieldAccess fa) {
|
||||
IVariableBinding fieldBinding = fa.resolveFieldBinding();
|
||||
if (fieldBinding != null && fieldBinding.getType() != null) {
|
||||
return fieldBinding.getType().getErasure().getQualifiedName();
|
||||
}
|
||||
return resolveFieldTypeFqn(callerFqn, fa.getName().getIdentifier());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private MethodDeclaration findMethodDeclaration(String methodFqn) {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
return null;
|
||||
}
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
return context.findMethodDeclaration(td, methodName, true);
|
||||
}
|
||||
|
||||
private static String normalizeLiteral(String value) {
|
||||
if (value != null && value.startsWith("\"") && value.endsWith("\"")) {
|
||||
return value.substring(1, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static boolean isEnumLikeConstant(String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
int dot = value.lastIndexOf('.');
|
||||
if (dot <= 0 || dot >= value.length() - 1) {
|
||||
return false;
|
||||
}
|
||||
return Character.isUpperCase(value.charAt(dot + 1));
|
||||
}
|
||||
|
||||
private static CallEdge findEdge(
|
||||
String caller, String target, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
|
||||
List<CallEdge> edges = callGraph.get(caller);
|
||||
if (edges == null) {
|
||||
return null;
|
||||
}
|
||||
for (CallEdge edge : edges) {
|
||||
if (edge.getTargetMethod().equals(target) || pathFinder.isHeuristicMatch(edge.getTargetMethod(), target)) {
|
||||
return edge;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,7 @@ 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;
|
||||
@@ -36,13 +33,13 @@ public class SpringComponentDetector {
|
||||
String typeName = annotation.getTypeName().getFullyQualifiedName();
|
||||
if (typeName.endsWith("Scheduled")) {
|
||||
Map<String, String> meta = new HashMap<>();
|
||||
String cron = extractAnnotationValue(annotation, "cron");
|
||||
String cron = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "cron");
|
||||
if (!cron.isEmpty())
|
||||
meta.put("cron", cron);
|
||||
String fixedRate = extractAnnotationValue(annotation, "fixedRate");
|
||||
String fixedRate = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "fixedRate");
|
||||
if (!fixedRate.isEmpty())
|
||||
meta.put("fixedRate", fixedRate);
|
||||
String fixedDelay = extractAnnotationValue(annotation, "fixedDelay");
|
||||
String fixedDelay = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "fixedDelay");
|
||||
if (!fixedDelay.isEmpty())
|
||||
meta.put("fixedDelay", fixedDelay);
|
||||
|
||||
@@ -56,10 +53,10 @@ public class SpringComponentDetector {
|
||||
.build());
|
||||
} else if (typeName.endsWith("EventListener")) {
|
||||
Map<String, String> meta = new HashMap<>();
|
||||
String classes = extractAnnotationValue(annotation, "classes");
|
||||
String classes = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "classes");
|
||||
if (!classes.isEmpty())
|
||||
meta.put("classes", classes);
|
||||
String condition = extractAnnotationValue(annotation, "condition");
|
||||
String condition = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "condition");
|
||||
if (!condition.isEmpty())
|
||||
meta.put("condition", condition);
|
||||
|
||||
@@ -73,11 +70,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 = extractAnnotationValue(annotation, "value");
|
||||
String value = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "value");
|
||||
if (!value.isEmpty())
|
||||
meta.put("pointcut", value);
|
||||
else {
|
||||
String pointcut = extractAnnotationValue(annotation, "pointcut");
|
||||
String pointcut = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "pointcut");
|
||||
if (!pointcut.isEmpty())
|
||||
meta.put("pointcut", pointcut);
|
||||
}
|
||||
@@ -98,20 +95,4 @@ 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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public class SpringMvcDetector {
|
||||
cu.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(TypeDeclaration node) {
|
||||
if (isRestController(node)) {
|
||||
if (isRestController(node) && !node.isInterface()) {
|
||||
processController(node, entryPoints);
|
||||
}
|
||||
// Support WebFlux RouterFunction beans
|
||||
@@ -182,11 +182,11 @@ public class SpringMvcDetector {
|
||||
}
|
||||
|
||||
private String getHttpVerb(String annotationName) {
|
||||
if (annotationName.endsWith("PostMapping") || annotationName.endsWith("POST")) return "POST";
|
||||
if (annotationName.endsWith("GetMapping") || annotationName.endsWith("GET")) return "GET";
|
||||
if (annotationName.endsWith("PutMapping") || annotationName.endsWith("PUT")) return "PUT";
|
||||
if (annotationName.endsWith("DeleteMapping") || annotationName.endsWith("DELETE")) return "DELETE";
|
||||
if (annotationName.endsWith("PatchMapping") || annotationName.endsWith("PATCH")) return "PATCH";
|
||||
if (annotationName.endsWith("PostMapping") || annotationName.endsWith("POST") || "POST".equals(annotationName)) return "POST";
|
||||
if (annotationName.endsWith("GetMapping") || annotationName.endsWith("GET") || "GET".equals(annotationName)) return "GET";
|
||||
if (annotationName.endsWith("PutMapping") || annotationName.endsWith("PUT") || "PUT".equals(annotationName)) return "PUT";
|
||||
if (annotationName.endsWith("DeleteMapping") || annotationName.endsWith("DELETE") || "DELETE".equals(annotationName)) return "DELETE";
|
||||
if (annotationName.endsWith("PatchMapping") || annotationName.endsWith("PATCH") || "PATCH".equals(annotationName)) return "PATCH";
|
||||
if (annotationName.endsWith("RequestMapping")) return "REQUEST";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Classpath entries from the exporter tool itself — not from running Maven/Gradle on the analyzed project.
|
||||
*/
|
||||
public final class ToolingClasspath {
|
||||
|
||||
private ToolingClasspath() {
|
||||
}
|
||||
|
||||
public static List<String> currentJvmJarEntries() {
|
||||
String raw = System.getProperty("java.class.path");
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return Arrays.stream(raw.split(File.pathSeparator))
|
||||
.filter(entry -> entry.endsWith(".jar"))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,111 @@ 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 preferBindingFqn(type, simpleName);
|
||||
}
|
||||
|
||||
private String preferBindingFqn(Type type, String heuristic) {
|
||||
if (!context.isResolveBindings() || heuristic == null || heuristic.contains(".")) {
|
||||
return heuristic;
|
||||
}
|
||||
ITypeBinding binding = type.resolveBinding();
|
||||
if (binding == null || binding.isRecovered()) {
|
||||
return heuristic;
|
||||
}
|
||||
String bindingFqn = binding.getErasure().getQualifiedName();
|
||||
if (bindingFqn == null || bindingFqn.isBlank() || !bindingFqn.contains(".") || bindingFqn.startsWith("<")) {
|
||||
return heuristic;
|
||||
}
|
||||
if (bindingFqn.startsWith("java.") || bindingFqn.startsWith("javax.") || bindingFqn.startsWith("jakarta.")) {
|
||||
return heuristic;
|
||||
}
|
||||
return bindingFqn;
|
||||
}
|
||||
|
||||
public int getParameterIndex(String methodFqn, String paramName) {
|
||||
return getParameterIndex(methodFqn, paramName, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* When {@code allowSyntheticEventPlaceholder} is true, maps generic trigger placeholders
|
||||
* such as {@code e} onto the sole parameter of single-arg methods (e.g. {@code sendEvent}).
|
||||
*/
|
||||
public int getParameterIndex(String methodFqn, String paramName, boolean allowSyntheticEventPlaceholder) {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) return -1;
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
@@ -25,11 +122,42 @@ public class TypeResolver {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
if (allowSyntheticEventPlaceholder
|
||||
&& md.parameters().size() == 1
|
||||
&& isGenericEventParameterName(paramName)) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static boolean isGenericEventParameterName(String paramName) {
|
||||
return paramName != null && GENERIC_EVENT_PARAMETER_NAMES.contains(paramName);
|
||||
}
|
||||
|
||||
public List<String> getParameterNames(String methodFqn) {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
return null;
|
||||
}
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md == null) {
|
||||
return null;
|
||||
}
|
||||
List<String> paramNames = new ArrayList<>();
|
||||
for (Object paramObj : md.parameters()) {
|
||||
SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj;
|
||||
paramNames.add(param.getName().getIdentifier());
|
||||
}
|
||||
return paramNames;
|
||||
}
|
||||
|
||||
public String getParameterName(String methodFqn, int index) {
|
||||
if (methodFqn == null || !methodFqn.contains(".") || index < 0) return null;
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
@@ -60,6 +188,43 @@ public class TypeResolver {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves parameter type from the actual call site in {@code callerFqn}, so overloaded
|
||||
* targets (e.g. {@code send(OrderEvent)} vs {@code send(DocumentEvent)}) use the invoked overload.
|
||||
*/
|
||||
public String getParameterTypeAtCallSite(String callerFqn, String calleeFqn, int paramIndex) {
|
||||
if (callerFqn == null || calleeFqn == null || paramIndex < 0
|
||||
|| !callerFqn.contains(".") || !calleeFqn.contains(".")) {
|
||||
return null;
|
||||
}
|
||||
String callerClass = callerFqn.substring(0, callerFqn.lastIndexOf('.'));
|
||||
String callerMethod = callerFqn.substring(callerFqn.lastIndexOf('.') + 1);
|
||||
String calleeMethod = calleeFqn.substring(calleeFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration td = context.getTypeDeclaration(callerClass);
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, callerMethod, true);
|
||||
if (md == null || md.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
final String[] result = new String[1];
|
||||
md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(org.eclipse.jdt.core.dom.MethodInvocation node) {
|
||||
if (!calleeMethod.equals(node.getName().getIdentifier())) {
|
||||
return super.visit(node);
|
||||
}
|
||||
org.eclipse.jdt.core.dom.IMethodBinding binding = node.resolveMethodBinding();
|
||||
if (binding != null && binding.getParameterTypes().length > paramIndex) {
|
||||
result[0] = binding.getParameterTypes()[paramIndex].getErasure().getQualifiedName();
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return result[0];
|
||||
}
|
||||
|
||||
public boolean isTypeCompatible(String actualType, String expectedType) {
|
||||
if (actualType == null || expectedType == null) return true;
|
||||
if (expectedType.equals("Object") || expectedType.equals("java.lang.Object")) return true;
|
||||
@@ -98,6 +263,17 @@ public class TypeResolver {
|
||||
}
|
||||
|
||||
if (actualType.equals(expectedType)) return true;
|
||||
|
||||
String expectedSimple = expectedType.contains(".")
|
||||
? expectedType.substring(expectedType.lastIndexOf('.') + 1)
|
||||
: expectedType;
|
||||
String actualSimple = actualType.contains(".")
|
||||
? actualType.substring(actualType.lastIndexOf('.') + 1)
|
||||
: actualType;
|
||||
if (expectedSimple.equals(actualSimple) && context.isAmbiguousSimpleName(expectedSimple)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actualType.endsWith("." + expectedType) || expectedType.endsWith("." + actualType)) return true;
|
||||
|
||||
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(actualType);
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
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.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.*;
|
||||
|
||||
@@ -12,17 +20,27 @@ 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);
|
||||
@@ -51,105 +69,93 @@ public class VariableTracer {
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||
if (td != null) {
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md != null && md.getBody() != null) {
|
||||
final Expression[] setterArg = new Expression[1];
|
||||
String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName;
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if (node.getName().getIdentifier().equalsIgnoreCase("set" + propName) || node.getName().getIdentifier().equalsIgnoreCase(propName)) {
|
||||
if (node.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) {
|
||||
if (!node.arguments().isEmpty()) {
|
||||
setterArg[0] = (Expression) node.arguments().get(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md == null || md.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String setterMethodName;
|
||||
String indexedFieldName;
|
||||
if (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();
|
||||
}
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return fieldInitializerFinder.resolveFieldTypeName(td, fieldName, visited);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public String getVariableDeclaredType(String methodFqn, String cleanFieldName) {
|
||||
if (methodFqn == null) return null;
|
||||
if (methodFqn == null) {
|
||||
return null;
|
||||
}
|
||||
String cacheKey = methodFqn + "|" + cleanFieldName;
|
||||
String cached = variableDeclaredTypeCache.get(cacheKey);
|
||||
if (cached != null) {
|
||||
return cached.isEmpty() ? null : cached;
|
||||
}
|
||||
|
||||
String resolved = resolveVariableDeclaredTypeUncached(methodFqn, cleanFieldName);
|
||||
variableDeclaredTypeCache.put(cacheKey, resolved == null ? "" : resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private String resolveVariableDeclaredTypeUncached(String methodFqn, String cleanFieldName) {
|
||||
int lastDot = methodFqn.lastIndexOf('.');
|
||||
if (lastDot > 0) {
|
||||
String fqn = methodFqn.substring(0, lastDot);
|
||||
@@ -270,24 +276,26 @@ 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;
|
||||
}
|
||||
|
||||
public String traceLocalVariable(String methodFqn, String varName) {
|
||||
return traceLocalVariable(methodFqn, varName, null);
|
||||
}
|
||||
|
||||
public String traceLocalVariable(String methodFqn, String varName, Map<String, String> branchBindings) {
|
||||
String result = traceLocalVariableInternal(methodFqn, varName, branchBindings);
|
||||
if (result != null && branchBindings != null && !branchBindings.isEmpty()) {
|
||||
String switchEvaluated = evaluateSwitchAssignment(methodFqn, varName, branchBindings);
|
||||
if (switchEvaluated != null) {
|
||||
return switchEvaluated;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String traceLocalVariableInternal(String methodFqn, String varName, Map<String, String> branchBindings) {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) return null;
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
@@ -295,27 +303,33 @@ public class VariableTracer {
|
||||
if (td != null) {
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md != null && md.getBody() != null) {
|
||||
List<Expression> initializers = new ArrayList<>();
|
||||
List<BranchAssignment> assignments = new ArrayList<>();
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(VariableDeclarationFragment node) {
|
||||
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
|
||||
initializers.add(peelExpression(node.getInitializer()));
|
||||
assignments.add(new BranchAssignment(
|
||||
peelExpression(node.getInitializer()),
|
||||
click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node)));
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
@Override
|
||||
public boolean visit(Assignment node) {
|
||||
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
|
||||
initializers.add(peelExpression(node.getRightHandSide()));
|
||||
assignments.add(new BranchAssignment(
|
||||
peelExpression(node.getRightHandSide()),
|
||||
click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node)));
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
|
||||
if (!initializers.isEmpty()) {
|
||||
|
||||
List<BranchAssignment> filtered = filterAssignmentsByBindings(assignments, branchBindings);
|
||||
if (!filtered.isEmpty()) {
|
||||
List<String> stringified = new ArrayList<>();
|
||||
for (Expression expr : initializers) {
|
||||
for (BranchAssignment assignment : filtered) {
|
||||
Expression expr = assignment.expression();
|
||||
Expression traced = traceVariable(expr);
|
||||
if (traced instanceof MethodInvocation mi) {
|
||||
Expression innerMost = unwrapMethodInvocation(mi, 0, methodFqn);
|
||||
@@ -329,7 +343,7 @@ public class VariableTracer {
|
||||
if (stringified.size() == 1) {
|
||||
return stringified.get(0).replaceAll("->\\s*yield\\s+", "-> ");
|
||||
}
|
||||
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < stringified.size() - 1; i++) {
|
||||
sb.append("true ? ").append(stringified.get(i)).append(" : ");
|
||||
@@ -388,62 +402,79 @@ public class VariableTracer {
|
||||
return null;
|
||||
}
|
||||
|
||||
public String traceLocalVariable(String methodFqn, String varName, Map<String, String> parameterValues) {
|
||||
String result = traceLocalVariable(methodFqn, varName);
|
||||
if (result != null && parameterValues != null && !parameterValues.isEmpty()) {
|
||||
TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
|
||||
if (td != null) {
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md != null && md.getBody() != null) {
|
||||
final ASTNode[] switchNode = new ASTNode[1];
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(VariableDeclarationFragment node) {
|
||||
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
|
||||
Expression init = node.getInitializer();
|
||||
if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION) {
|
||||
switchNode[0] = init;
|
||||
return false;
|
||||
}
|
||||
if (init.getNodeType() == ASTNode.SWITCH_STATEMENT) {
|
||||
switchNode[0] = init;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
@Override
|
||||
public boolean visit(Assignment node) {
|
||||
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
|
||||
Expression rhs = node.getRightHandSide();
|
||||
if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION) {
|
||||
switchNode[0] = rhs;
|
||||
return false;
|
||||
}
|
||||
if (rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
|
||||
switchNode[0] = rhs;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
if (switchNode[0] != null) {
|
||||
if (switchNode[0] instanceof SwitchExpression se) {
|
||||
String evaluated = constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
|
||||
if (evaluated != null) return evaluated;
|
||||
} else if (switchNode[0] instanceof SwitchStatement ss) {
|
||||
String evaluated = constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
|
||||
if (evaluated != null) return evaluated;
|
||||
}
|
||||
private String evaluateSwitchAssignment(String methodFqn, String varName, Map<String, String> parameterValues) {
|
||||
TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
|
||||
if (td == null) {
|
||||
return null;
|
||||
}
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md == null || md.getBody() == null) {
|
||||
return null;
|
||||
}
|
||||
final ASTNode[] switchNode = new ASTNode[1];
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(VariableDeclarationFragment node) {
|
||||
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
|
||||
Expression init = node.getInitializer();
|
||||
if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION || init.getNodeType() == ASTNode.SWITCH_STATEMENT) {
|
||||
switchNode[0] = init;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean visit(Assignment node) {
|
||||
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
|
||||
Expression rhs = node.getRightHandSide();
|
||||
if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION || rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
|
||||
switchNode[0] = rhs;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
if (switchNode[0] instanceof SwitchExpression se) {
|
||||
return constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
|
||||
}
|
||||
if (switchNode[0] instanceof SwitchStatement ss) {
|
||||
return constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<BranchAssignment> filterAssignmentsByBindings(
|
||||
List<BranchAssignment> assignments, Map<String, String> branchBindings) {
|
||||
if (assignments.isEmpty()) {
|
||||
return assignments;
|
||||
}
|
||||
if (branchBindings == null || branchBindings.isEmpty()) {
|
||||
return assignments;
|
||||
}
|
||||
List<BranchAssignment> compatible = new ArrayList<>();
|
||||
List<BranchAssignment> unconstrained = new ArrayList<>();
|
||||
for (BranchAssignment assignment : assignments) {
|
||||
String constraint = assignment.constraint();
|
||||
if (constraint == null || constraint.isBlank()) {
|
||||
unconstrained.add(assignment);
|
||||
continue;
|
||||
}
|
||||
if (BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, branchBindings)) {
|
||||
compatible.add(assignment);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
if (!compatible.isEmpty()) {
|
||||
return compatible;
|
||||
}
|
||||
return unconstrained;
|
||||
}
|
||||
|
||||
private record BranchAssignment(Expression expression, String constraint) {}
|
||||
|
||||
public Map<String, String> buildParameterValuesMap(String caller, String target, Map<String, List<CallEdge>> callGraph, List<String> path, int pathIndex) {
|
||||
Map<String, String> paramValues = new HashMap<>();
|
||||
List<CallEdge> edges = callGraph.get(caller);
|
||||
@@ -601,9 +632,7 @@ public class VariableTracer {
|
||||
String classTarget = target.substring(0, target.lastIndexOf('.'));
|
||||
if (classNeighbor.equals(classTarget)) return true;
|
||||
|
||||
String simpleClassNeighbor = classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
|
||||
String simpleClassTarget = classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
|
||||
if (simpleClassNeighbor.equals(simpleClassTarget)) return true;
|
||||
if (context.areSameTypeOrUnambiguousSimpleMatch(classNeighbor, classTarget)) return true;
|
||||
|
||||
List<String> impls = context.getImplementations(classTarget);
|
||||
if (impls != null) {
|
||||
@@ -631,7 +660,14 @@ public class VariableTracer {
|
||||
String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
|
||||
|
||||
if (simpleClassNeighbor != null && simpleClassTarget != null) {
|
||||
if (simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) return true;
|
||||
String leftClass = classNeighbor != null ? classNeighbor : simpleClassNeighbor;
|
||||
String rightClass = classTarget != null ? classTarget : simpleClassTarget;
|
||||
if (context.areSameTypeOrUnambiguousSimpleMatch(leftClass, rightClass)) return true;
|
||||
if (!context.isAmbiguousSimpleName(simpleClassNeighbor)
|
||||
&& !context.isAmbiguousSimpleName(simpleClassTarget)
|
||||
&& simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget)) {
|
||||
return true;
|
||||
}
|
||||
if (simpleClassNeighbor.equals("this") || simpleClassNeighbor.equals("super")) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -639,79 +675,8 @@ 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) {
|
||||
if (depth > 5) return mi;
|
||||
|
||||
String targetMethodFqn = resolveTargetMethodFqn(mi, methodFqn);
|
||||
if (targetMethodFqn != null && targetMethodFqn.contains(".")) {
|
||||
String className = targetMethodFqn.substring(0, targetMethodFqn.lastIndexOf('.'));
|
||||
String methodName = targetMethodFqn.substring(targetMethodFqn.lastIndexOf('.') + 1);
|
||||
|
||||
TypeDeclaration currentTd = methodFqn != null && methodFqn.contains(".") ? context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.'))) : null;
|
||||
CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
|
||||
|
||||
TypeDeclaration td = context.getTypeDeclaration(className, cu);
|
||||
if (td != null) {
|
||||
MethodDeclaration localMd = context.findMethodDeclaration(td, methodName, true);
|
||||
if (localMd != null) {
|
||||
int paramIdx = getReturnedParameterIndex(localMd);
|
||||
if (paramIdx >= 0 && paramIdx < mi.arguments().size()) {
|
||||
Expression arg = peelExpression((Expression) mi.arguments().get(paramIdx));
|
||||
if (arg instanceof MethodInvocation innerMi) {
|
||||
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mi.getExpression() != null) {
|
||||
String exprStr = mi.getExpression().toString();
|
||||
if (!exprStr.equals("Optional") && !exprStr.equals("Stream") && !exprStr.equals("List") && !exprStr.equals("Set") && !exprStr.equals("Arrays") && !exprStr.equals("Objects") && !exprStr.equals("Mono") && !exprStr.equals("Flux")) {
|
||||
return mi;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mi.arguments().isEmpty()) {
|
||||
String mName = mi.getName().getIdentifier();
|
||||
String lowerName = mName.toLowerCase();
|
||||
|
||||
if (lowerName.startsWith("map") || lowerName.startsWith("parse") ||
|
||||
lowerName.startsWith("convert") || lowerName.startsWith("to") ||
|
||||
lowerName.startsWith("resolve") || lowerName.startsWith("build") ||
|
||||
lowerName.startsWith("create") || lowerName.startsWith("from") ||
|
||||
lowerName.startsWith("transform") || lowerName.startsWith("translate") ||
|
||||
lowerName.startsWith("derive") || lowerName.startsWith("determine") ||
|
||||
lowerName.startsWith("calculate") || lowerName.startsWith("decode") ||
|
||||
lowerName.startsWith("extract") || lowerName.startsWith("get") ||
|
||||
lowerName.startsWith("find") || lowerName.startsWith("validate")) {
|
||||
return mi;
|
||||
}
|
||||
|
||||
Expression arg = peelExpression((Expression) mi.arguments().get(0));
|
||||
if (arg instanceof MethodInvocation innerMi) {
|
||||
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
return mi;
|
||||
return MethodInvocationUnwrapper.unwrap(mi, depth, methodFqn, context, miArg -> resolveTargetMethodFqn(miArg, methodFqn));
|
||||
}
|
||||
|
||||
private String resolveTargetMethodFqn(MethodInvocation mi, String currentMethodFqn) {
|
||||
@@ -733,33 +698,4 @@ public class VariableTracer {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private int getReturnedParameterIndex(MethodDeclaration md) {
|
||||
if (md.getBody() == null) return -1;
|
||||
List<?> parameters = md.parameters();
|
||||
if (parameters.isEmpty()) return -1;
|
||||
|
||||
final List<String> returnedExprs = new ArrayList<>();
|
||||
md.getBody().accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(ReturnStatement node) {
|
||||
if (node.getExpression() != null) {
|
||||
returnedExprs.add(node.getExpression().toString());
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
|
||||
if (returnedExprs.size() == 1) {
|
||||
String retStr = returnedExprs.get(0);
|
||||
for (int i = 0; i < parameters.size(); i++) {
|
||||
if (parameters.get(i) instanceof SingleVariableDeclaration svd) {
|
||||
if (svd.getName().getIdentifier().equals(retStr)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +62,9 @@ public class InjectionPointAnalyzer {
|
||||
try {
|
||||
IAnnotationBinding[] paramAnns = method.getParameterAnnotations(i);
|
||||
String paramQual = getQualifierValue(paramAnns);
|
||||
if (paramQual != null && paramType.getErasure().isEqualTo(binding.getType().getErasure())) {
|
||||
// For setters, verify it roughly matches the field name or just rely on type.
|
||||
// We will rely on type equality for now as a heuristic.
|
||||
if (paramQual != null
|
||||
&& paramType.getErasure().isEqualTo(binding.getType().getErasure())
|
||||
&& parameterMatchesField(method, i, binding.getName())) {
|
||||
return paramQual;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -90,4 +90,29 @@ public class InjectionPointAnalyzer {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean parameterMatchesField(
|
||||
org.eclipse.jdt.core.dom.IMethodBinding method,
|
||||
int parameterIndex,
|
||||
String fieldName) {
|
||||
if (method == null || fieldName == null || fieldName.isBlank() || parameterIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
String[] names;
|
||||
try {
|
||||
names = method.getParameterNames();
|
||||
} catch (Exception ignored) {
|
||||
names = null;
|
||||
}
|
||||
if (names != null && parameterIndex < names.length) {
|
||||
return fieldName.equals(names[parameterIndex]);
|
||||
}
|
||||
// If parameter names aren't available, only accept clear setter-like methods.
|
||||
String methodName = method.getName();
|
||||
if (methodName != null && methodName.startsWith("set") && method.getParameterTypes().length == 1) {
|
||||
String expected = "set" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1);
|
||||
return expected.equals(methodName);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,17 @@ public class SpringDependencyResolver {
|
||||
.filter(bean -> requiredTypeFqn.equals(bean.getTypeFqn()) || bean.getAssignableTypes().contains(requiredTypeFqn))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
log.debug("CANDIDATES AFTER TYPE: {}", candidates.stream().map(c -> c.getTypeFqn() + " (primary=" + c.isPrimary() + " names=" + c.getBeanNames() + " qual=" + c.getQualifiers() + ")").collect(Collectors.toList()));
|
||||
log.debug("CANDIDATES AFTER TYPE: {}", candidates.size());
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug(
|
||||
"CANDIDATE DETAILS: {}",
|
||||
candidates.stream()
|
||||
.map(c -> c.getTypeFqn()
|
||||
+ " (primary=" + c.isPrimary()
|
||||
+ " names=" + c.getBeanNames()
|
||||
+ " qual=" + c.getQualifiers() + ")")
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
if (candidates.isEmpty() || candidates.size() == 1) {
|
||||
log.debug("RETURNING: {}", candidates.size());
|
||||
return candidates;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user