This commit is contained in:
2026-06-19 19:18:23 +02:00
parent d6b1571f18
commit 344e295106
21 changed files with 628 additions and 65 deletions

View File

@@ -36,6 +36,7 @@ dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.17.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1'
compileOnly 'org.projectlombok:lombok:1.18.46'
annotationProcessor 'org.projectlombok:lombok:1.18.46'

View File

@@ -14,6 +14,14 @@ import java.util.List;
public class Main {
public static void main(String[] args) {
// Enable diagnostic mode early before SLF4J initializes
for (String arg : args) {
if ("--debug".equals(arg)) {
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug");
break;
}
}
// Manual DI / Wiring
var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
var exportService = new ExportService(exporters);

View File

@@ -41,8 +41,13 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
if (t.getEvent() != null) {
String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName();
String smEvent = simplify(smEventRaw);
if (smEvent.equals(triggerEvent)) {
// Event matches. Check source state if provided
boolean isWildcard = triggerEvent.equals("event") || triggerEvent.equals("e") ||
triggerEvent.equals("msg") || triggerEvent.equals("message") ||
triggerEvent.equals("payload") || triggerEvent.matches(".*\\.get[A-Z].*\\(\\)");
if (isWildcard || smEvent.equals(triggerEvent) || triggerEvent.toLowerCase().contains(smEvent.toLowerCase()) || smEvent.toLowerCase().contains(triggerEvent.toLowerCase())) {
// Event matches or is a wildcard
for (State smSourceState : t.getSourceStates()) {
String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
String smSource = simplify(smSourceRaw);
@@ -50,11 +55,14 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
for (State smTargetState : t.getTargetStates()) {
String sourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName();
matched.add(MatchedTransition.builder()
MatchedTransition mt = MatchedTransition.builder()
.sourceState(sourceRaw)
.targetState(targetRaw)
.event(smEventRaw)
.build());
.build();
if (isRoutedToCorrectMachine(chain, result.getName())) {
matched.add(mt);
}
}
}
}
@@ -62,16 +70,12 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
}
// Create a new CallChain with the matched transitions
CallChain updatedChain = CallChain.builder()
.entryPoint(chain.getEntryPoint())
.methodChain(chain.getMethodChain())
.triggerPoint(chain.getTriggerPoint())
.contextMachineId(chain.getContextMachineId())
.matchedTransitions(matched)
.build();
updatedChains.add(updatedChain);
if (!matched.isEmpty()) {
CallChain newChain = chain.toBuilder().matchedTransitions(matched).build();
updatedChains.add(newChain);
} else {
updatedChains.add(chain);
}
}
// Update the metadata with the new call chains
@@ -85,12 +89,35 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
result.setMetadata(updatedMetadata);
}
private String simplify(String name) {
if (name == null) return "";
int dot = name.lastIndexOf('.');
if (dot >= 0) {
return name.substring(dot + 1);
private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName) {
// If the chain's target expression or qualifier indicates a specific machine name, verify it
String targetVar = chain.getContextMachineId();
if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId();
}
return name;
if (targetVar != null && !targetVar.isEmpty()) {
targetVar = targetVar.toLowerCase();
// E.g., if target is "myStateMachine", ensure current machine name contains "my"
String simplifiedMachineName = currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase();
// If the variable name ends with StateMachine, we extract the prefix
if (targetVar.endsWith("statemachine")) {
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
if (!prefix.isEmpty() && !simplifiedMachineName.contains(prefix)) {
return false;
}
}
}
return true;
}
private String simplify(String name) {
if (name == null) return null;
// Strip common suffixes
String simplified = name.replaceAll("(?i)(.*)(Event|Action|Transition|Command)(s)?$", "$1");
// Simplify full identifiers to just the last part (enum name)
simplified = simplified.replaceAll("^.*\\.([A-Z0-9_]+)$", "$1");
// For remaining full caps with underscores (like EVENT_X), keep as is or try to simplify
return simplified;
}
}

View File

@@ -8,7 +8,7 @@ import lombok.extern.jackson.Jacksonized;
import java.util.List;
@Data
@Builder
@Builder(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class CallChain {

View File

@@ -13,5 +13,7 @@ import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public class LibraryHint {
private final String methodFqn; // e.g., "com.thirdparty.Workflow.send"
private final String event; // The event it triggers
private final String event; // The event it triggers (static)
private final Integer eventArgumentIndex; // e.g., 0 to extract from the 0th argument
private final String eventArgumentMethod; // e.g., "getType" to extract from argument method call
}

View File

@@ -105,25 +105,32 @@ public class PropertyResolver {
}
private Map<String, String> loadYaml(Path path) {
// Placeholder for future YAML support
log.warn("YAML parsing not fully implemented yet for {}", path);
return new HashMap<>();
Map<String, String> props = new HashMap<>();
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());
Map<String, Object> map = mapper.readValue(path.toFile(), new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
flattenYaml("", map, props);
} catch (IOException e) {
log.warn("Failed to load YAML from {}", path);
}
return props;
}
@SuppressWarnings("unchecked")
private void flattenYaml(String prefix, Map<String, Object> map, Map<String, String> result) {
if (map == null) return;
for (Map.Entry<String, Object> entry : map.entrySet()) {
String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey();
Object value = entry.getValue();
if (value instanceof Map) {
flattenYaml(key, (Map<String, Object>) value, result);
} else if (value != null) {
result.put(key, value.toString());
}
}
}
public String resolveValue(String placeholder, Map<String, String> properties) {
if (placeholder == null || !placeholder.contains("${")) return placeholder;
// Very basic placeholder extraction: ${key} or ${key:default}
String content = placeholder.substring(placeholder.indexOf("${") + 2, placeholder.lastIndexOf("}"));
String key = content;
String defaultValue = null;
if (content.contains(":")) {
int colonIndex = content.indexOf(":");
key = content.substring(0, colonIndex);
defaultValue = content.substring(colonIndex + 1);
}
return properties.getOrDefault(key, defaultValue);
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(placeholder, properties);
}
}

View File

@@ -34,10 +34,12 @@ public class CallGraphBuilder {
for (EntryPoint ep : entryPoints) {
String startMethod = ep.getClassName() + "." + ep.getMethodName();
boolean foundAny = false;
for (TriggerPoint tp : triggers) {
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<String> path = findPath(startMethod, targetMethod, callGraph, new HashSet<>());
if (path != null) {
foundAny = true;
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph);
String contextMachineId = extractContextMachineId(path, callGraph);
chains.add(CallChain.builder()
@@ -48,6 +50,9 @@ public class CallGraphBuilder {
.build());
}
}
if (!foundAny && log.isDebugEnabled()) {
log.debug("Entry point {} ({}) did not reach any trigger points. Graph nodes available: {}", ep.getName(), startMethod, callGraph.keySet());
}
}
return chains;
}
@@ -60,6 +65,17 @@ public class CallGraphBuilder {
String currentParamName = event;
String resolvedValue = event;
String methodSuffix = "";
// Extract method calls like .getType() so we can trace the base parameter
int dotIndex = currentParamName.indexOf('.');
if (dotIndex > 0 && dotIndex + 1 < currentParamName.length()) {
char nextChar = currentParamName.charAt(dotIndex + 1);
if (Character.isLowerCase(nextChar)) {
methodSuffix = currentParamName.substring(dotIndex);
currentParamName = currentParamName.substring(0, dotIndex);
}
}
// Walk backwards up the call chain
for (int i = path.size() - 1; i > 0; i--) {
@@ -82,7 +98,7 @@ public class CallGraphBuilder {
String arg = edge.getArguments().get(paramIndex);
if (arg != null) {
currentParamName = arg;
resolvedValue = arg;
resolvedValue = arg + methodSuffix;
found = true;
break;
}
@@ -175,6 +191,23 @@ public class CallGraphBuilder {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(refMethod, args));
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(impl + "." + emr.getName().getIdentifier(), args));
}
}
}
}
}
@@ -300,6 +333,10 @@ public class CallGraphBuilder {
}
if (receiver instanceof SimpleName sn) {
String fallbackTypeFqn = resolveReceiverTypeFallback(sn);
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
String receiverName = sn.getIdentifier();
return receiverName + "." + methodName;
}
@@ -307,6 +344,85 @@ public class CallGraphBuilder {
return null;
}
private String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier();
// 1. Check local variables in enclosing method
MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode);
if (enclosingMethod != null) {
// Check parameters
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(svd.getType(), receiverNameNode);
}
}
// Check method body (local variables)
if (enclosingMethod.getBody() != null) {
Type[] foundType = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType();
}
}
return super.visit(node);
}
});
if (foundType[0] != null) {
return resolveTypeToFqn(foundType[0], receiverNameNode);
}
}
}
// 2. Check fields in enclosing class
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
if (enclosingType != null) {
for (FieldDeclaration field : enclosingType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(field.getType(), receiverNameNode);
}
}
}
}
return null;
}
private String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode);
} else {
simpleName = type.toString();
}
CompilationUnit cu = (CompilationUnit) contextNode.getRoot();
TypeDeclaration td = context.getTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching if CodebaseContext doesn't know it (e.g., external library)
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
return simpleName;
}
private String resolveMethodInType(TypeDeclaration td, String methodName) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
@@ -336,6 +452,11 @@ public class CallGraphBuilder {
}
}
}
if (log.isDebugEnabled()) {
log.debug("Path search dead-end at {} when looking for {}", start, target);
}
visited.remove(start);
return null;
}

View File

@@ -64,7 +64,18 @@ public class GenericEventDetector {
for (LibraryHint hint : hints) {
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) {
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, hint.getEvent());
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());

View File

@@ -24,6 +24,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
private final CallGraphBuilder callGraphBuilder;
private final LifecycleDetector lifecycleDetector;
private final InterceptorDetector interceptorDetector;
private final SpringComponentDetector componentDetector;
public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) {
this.context = context;
@@ -34,6 +35,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
this.callGraphBuilder = new CallGraphBuilder(context);
this.lifecycleDetector = new LifecycleDetector(context);
this.interceptorDetector = new InterceptorDetector(context);
this.componentDetector = new SpringComponentDetector(context);
}
@Override
@@ -58,6 +60,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
allEntryPoints.addAll(mvcDetector.detect(cu));
allEntryPoints.addAll(messagingDetector.detect(cu));
allEntryPoints.addAll(interceptorDetector.detect(cu));
allEntryPoints.addAll(componentDetector.detect(cu));
}
log.info("Found {} entry points (including interceptors and listeners) in total", allEntryPoints.size());
return allEntryPoints;

View File

@@ -0,0 +1,117 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
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;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RequiredArgsConstructor
public class SpringComponentDetector {
private final CodebaseContext context;
public List<EntryPoint> detect(CompilationUnit cu) {
List<EntryPoint> entryPoints = new ArrayList<>();
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodDeclaration node) {
if (!(node.getParent() instanceof TypeDeclaration)) {
return super.visit(node);
}
TypeDeclaration parentTd = (TypeDeclaration) node.getParent();
for (Object modifier : node.modifiers()) {
if (modifier instanceof Annotation annotation) {
String typeName = annotation.getTypeName().getFullyQualifiedName();
if (typeName.endsWith("Scheduled")) {
Map<String, String> meta = new HashMap<>();
String cron = extractAnnotationValue(annotation, "cron");
if (!cron.isEmpty())
meta.put("cron", cron);
String fixedRate = extractAnnotationValue(annotation, "fixedRate");
if (!fixedRate.isEmpty())
meta.put("fixedRate", fixedRate);
String fixedDelay = extractAnnotationValue(annotation, "fixedDelay");
if (!fixedDelay.isEmpty())
meta.put("fixedDelay", fixedDelay);
entryPoints.add(EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.name("@Scheduled: " + node.getName().getIdentifier())
.className(context.getFqn(parentTd))
.methodName(node.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn(parentTd)))
.metadata(meta)
.build());
} else if (typeName.endsWith("EventListener")) {
Map<String, String> meta = new HashMap<>();
String classes = extractAnnotationValue(annotation, "classes");
if (!classes.isEmpty())
meta.put("classes", classes);
String condition = extractAnnotationValue(annotation, "condition");
if (!condition.isEmpty())
meta.put("condition", condition);
entryPoints.add(EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.name("@EventListener: " + node.getName().getIdentifier())
.className(context.getFqn(parentTd))
.methodName(node.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn(parentTd)))
.metadata(meta)
.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");
if (!value.isEmpty())
meta.put("pointcut", value);
else {
String pointcut = extractAnnotationValue(annotation, "pointcut");
if (!pointcut.isEmpty())
meta.put("pointcut", pointcut);
}
entryPoints.add(EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.name("@" + annotation.getTypeName().getFullyQualifiedName() + ": " + node.getName().getIdentifier())
.className(context.getFqn(parentTd))
.methodName(node.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn(parentTd)))
.metadata(meta)
.build());
}
}
}
return super.visit(node);
}
});
return entryPoints;
}
private String extractAnnotationValue(Annotation annotation, String memberName) {
if (annotation instanceof SingleMemberAnnotation sma) {
if ("value".equals(memberName)) {
return sma.getValue().toString();
}
} else if (annotation instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if (pair.getName().getIdentifier().equals(memberName)) {
return pair.getValue().toString();
}
}
}
return "";
}
}

View File

@@ -213,9 +213,18 @@ public class CodebaseContext {
}
// Inheritance Mapping
for (Object itf : td.superInterfaceTypes()) {
String itfName = itf.toString();
interfaceToImpls.computeIfAbsent(itfName, k -> new ArrayList<>()).add(fqn);
// Track implementations (for both interfaces and superclasses)
if (td.superInterfaceTypes() != null) {
for (Object itfObj : td.superInterfaceTypes()) {
Type itf = (Type) itfObj;
String itfName = extractTypeName(itf);
interfaceToImpls.computeIfAbsent(itfName, k -> new ArrayList<>()).add(fqn);
}
}
Type superclass = td.getSuperclassType();
if (superclass != null) {
String superName = extractTypeName(superclass);
interfaceToImpls.computeIfAbsent(superName, k -> new ArrayList<>()).add(fqn);
}
// Recursively index nested types
@@ -227,28 +236,38 @@ public class CodebaseContext {
}
public List<String> getImplementations(String interfaceName) {
Set<String> allImpls = new HashSet<>();
collectImplementations(interfaceName, allImpls, new HashSet<>());
return new ArrayList<>(allImpls);
}
private void collectImplementations(String typeName, Set<String> results, Set<String> visited) {
if (!visited.add(typeName)) return;
// Try direct match
List<String> impls = interfaceToImpls.get(interfaceName);
if (impls != null) return impls;
List<String> directImpls = interfaceToImpls.get(typeName);
// Try FQN match if input was simple name
String fqn = simpleNameToFqn.get(interfaceName);
if (fqn != null && interfaceToImpls.containsKey(fqn)) {
return interfaceToImpls.get(fqn);
}
// Try simple name match if input was FQN
if (interfaceName.contains(".")) {
String simpleName = interfaceName.substring(interfaceName.lastIndexOf('.') + 1);
List<String> simpleImpls = interfaceToImpls.get(simpleName);
if (simpleImpls != null) {
// To be perfectly safe, we could check if simpleNameToFqn matches,
// but for call graphs, returning potential implementations is fine.
return simpleImpls;
if (directImpls == null) {
String fqn = simpleNameToFqn.get(typeName);
if (fqn != null) {
directImpls = interfaceToImpls.get(fqn);
}
}
return Collections.emptyList();
// Try simple name match if input was FQN
if (directImpls == null && typeName.contains(".")) {
String simpleName = typeName.substring(typeName.lastIndexOf('.') + 1);
directImpls = interfaceToImpls.get(simpleName);
}
if (directImpls != null) {
for (String impl : directImpls) {
results.add(impl);
// Recursively find implementations of the implementation (e.g., subclasses of an abstract class)
collectImplementations(impl, results, visited);
}
}
}
private void indexEnum(EnumDeclaration ed, String parentFqn, Path javaFile) {

View File

@@ -54,11 +54,19 @@ public class ExporterCommand implements Callable<Integer> {
@Option(names = {"--state"}, description = "Format for states when they are enums: fn (fullName, default), fqn (fully qualified name), sn (short name)", defaultValue = "fn")
private click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat;
@Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false")
private boolean debug;
@Override
public Integer call() throws Exception {
var out = spec.commandLine().getOut();
var err = spec.commandLine().getErr();
if (debug) {
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug");
out.println(CommandLine.Help.Ansi.AUTO.string("@|bold,cyan Diagnostic mode enabled|@"));
}
if (inputDir == null && jsonFile == null) {
inputDir = Path.of(".");
}

View File

@@ -23,8 +23,7 @@ import java.util.List;
public class GoldenUpdater {
private static final List<StateMachineExporter> exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
private static final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList());
private static final ExportService exportService = new ExportService(exporters, enrichmentService);
private static final ExportService exportService = new ExportService(exporters);
public static void main(String[] args) throws Exception {
List<TestScenario> scenarios = provideTestScenarios();

View File

@@ -118,4 +118,50 @@ class TransitionLinkerEnricherTest {
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).isNull();
}
@Test
void shouldMatchWildcardVariable() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("PAID", "PAID")));
t1.setEvent(Event.of("PAY", "PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("event").build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("testMachine")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
}
@Test
void shouldMatchMethodCallWildcard() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("PAID", "PAID")));
t1.setEvent(Event.of("PAY", "PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("richEvent.getType()").build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("testMachine")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
}
}

View File

@@ -0,0 +1,60 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class PropertyResolverTest {
@Test
void testYamlPropertiesAreLoadedAndFlattened(@TempDir Path tempDir) throws IOException {
Path applicationYml = tempDir.resolve("application.yml");
Files.writeString(applicationYml, """
messaging:
sqs:
integration-system1:
callback:
queue: "integration-system1-queue"
""");
PropertyResolver resolver = new PropertyResolver();
Map<String, String> props = resolver.resolveProperties(tempDir);
assertThat(props).containsEntry("messaging.sqs.integration-system1.callback.queue", "integration-system1-queue");
}
@Test
void testPropertiesFilesAreLoaded(@TempDir Path tempDir) throws IOException {
Path applicationProps = tempDir.resolve("application.properties");
Files.writeString(applicationProps, "messaging.sqs.integration-system1.callback.queue=integration-system1-queue-props");
PropertyResolver resolver = new PropertyResolver();
Map<String, String> props = resolver.resolveProperties(tempDir);
assertThat(props).containsEntry("messaging.sqs.integration-system1.callback.queue", "integration-system1-queue-props");
}
@Test
void testPlaceholderResolution() {
PropertyResolver resolver = new PropertyResolver();
Map<String, String> properties = Map.of("messaging.sqs.integration-system1.callback.queue", "integration-system1-queue");
String resolved = resolver.resolveValue("SQS: ${messaging.sqs.integration-system1.callback.queue}", properties);
assertThat(resolved).isEqualTo("SQS: integration-system1-queue");
}
@Test
void testPlaceholderResolutionWithDefault() {
PropertyResolver resolver = new PropertyResolver();
Map<String, String> properties = Map.of();
String resolved = resolver.resolveValue("SQS: ${messaging.sqs.queue:default-queue}", properties);
assertThat(resolved).isEqualTo("SQS: default-queue");
}
}

View File

@@ -0,0 +1,118 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class CallGraphBuilderTest {
@Test
void shouldFindCallChainWithLambdaMethodReference(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderService {
private final EventDispatcher dispatcher = new EventDispatcher();
public void processOrder() {
dispatcher.dispatch("order", this::sendEvent);
}
public void sendEvent(String event) {
// Dummy trigger
}
}
class EventDispatcher {
public void dispatch(String order, java.util.function.Consumer<String> callback) {
callback.accept("MY_EVENT");
}
}
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderService")
.methodName("processOrder")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.OrderService")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getMethodChain()).containsExactly(
"com.example.OrderService.processOrder",
"com.example.OrderService.sendEvent"
);
}
@Test
void shouldExtractContextMachineId(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class PersisterService {
private final StateMachinePersister persister = new StateMachinePersister();
public void updateOrderState(String orderId) {
StateMachine sm = persister.restore(null, "machine:" + orderId);
sm.sendEvent("PAY");
}
}
class StateMachinePersister {
public StateMachine restore(Object sm, String contextObj) {
return new StateMachine();
}
}
class StateMachine {
public void sendEvent(String event) {}
}
""";
Files.writeString(tempDir.resolve("PersisterService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.PersisterService")
.methodName("updateOrderState")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("PAY")
.build();
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getContextMachineId()).isNotNull();
// Since it's tracing constant concatenation: "machine:" + orderId, which might resolve dynamically or string format.
// It should at least be present (it typically extracts 'machine:' + orderId into expression).
}
}

View File

@@ -251,7 +251,7 @@
"lineNumber" : 17
},
"contextMachineId" : null,
"matchedTransitions" : [ ]
"matchedTransitions" : null
}, {
"entryPoint" : {
"type" : "REST",

View File

@@ -287,7 +287,7 @@
"lineNumber" : 29
},
"contextMachineId" : "orderId",
"matchedTransitions" : [ ]
"matchedTransitions" : null
}, {
"entryPoint" : {
"type" : "REST",

View File

@@ -287,7 +287,7 @@
"lineNumber" : 29
},
"contextMachineId" : "orderId",
"matchedTransitions" : [ ]
"matchedTransitions" : null
}, {
"entryPoint" : {
"type" : "REST",