extended analysis

This commit is contained in:
2026-06-14 16:32:51 +02:00
parent 2251a587d9
commit ab474c0766
125 changed files with 4180 additions and 317 deletions

View File

@@ -16,8 +16,7 @@ public class Main {
public static void main(String[] args) {
// Manual DI / Wiring
var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
var enrichmentService = new EnrichmentService(Collections.emptyList());
var exportService = new ExportService(exporters, enrichmentService);
var exportService = new ExportService(exporters);
var command = new ExporterCommand(exportService);
int exitCode = new CommandLine(command).execute(args);

View File

@@ -1,8 +1,9 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
public interface AnalysisEnricher {
void enrich(AnalysisResult result, CodebaseContext context);
void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence);
}

View File

@@ -0,0 +1,28 @@
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.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
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()
);
result.addMetadata(CodebaseMetadata.builder()
.callChains(chains)
.build());
}
}

View File

@@ -0,0 +1,25 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
public class EntryPointEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with entry points", result.getName());
List<EntryPoint> entryPoints = intelligence.findEntryPoints();
result.addMetadata(CodebaseMetadata.builder()
.entryPoints(entryPoints)
.build());
}
}

View File

@@ -0,0 +1,24 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
@Slf4j
public class PropertyEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with properties", result.getName());
Map<String, String> properties = intelligence.resolveProperties();
result.addMetadata(CodebaseMetadata.builder()
.properties(properties)
.build());
}
}

View File

@@ -0,0 +1,26 @@
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.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
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.
result.addMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.triggers(triggers)
.build());
}
}

View File

@@ -1,8 +1,12 @@
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.model.Transition;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.jackson.Jacksonized;
import java.util.Collections;
import java.util.List;
@@ -10,6 +14,8 @@ import java.util.Set;
@Getter
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class AnalysisResult {
private final String name;
private final List<Transition> transitions;
@@ -23,18 +29,22 @@ public class AnalysisResult {
public void addMetadata(CodebaseMetadata newMetadata) {
if (newMetadata == null) return;
List<CodebaseMetadata.TriggerPoint> combinedTriggers = new java.util.ArrayList<>(this.metadata.getTriggers());
combinedTriggers.addAll(newMetadata.getTriggers());
List<TriggerPoint> combinedTriggers = new java.util.ArrayList<>(this.metadata.getTriggers());
if (newMetadata.getTriggers() != null) combinedTriggers.addAll(newMetadata.getTriggers());
List<CodebaseMetadata.EntryPoint> combinedEntryPoints = new java.util.ArrayList<>(this.metadata.getEntryPoints());
combinedEntryPoints.addAll(newMetadata.getEntryPoints());
List<EntryPoint> combinedEntryPoints = new java.util.ArrayList<>(this.metadata.getEntryPoints());
if (newMetadata.getEntryPoints() != null) combinedEntryPoints.addAll(newMetadata.getEntryPoints());
List<CallChain> combinedCallChains = new java.util.ArrayList<>(this.metadata.getCallChains());
if (newMetadata.getCallChains() != null) combinedCallChains.addAll(newMetadata.getCallChains());
java.util.Map<String, String> combinedProperties = new java.util.HashMap<>(this.metadata.getProperties());
combinedProperties.putAll(newMetadata.getProperties());
if (newMetadata.getProperties() != null) combinedProperties.putAll(newMetadata.getProperties());
this.metadata = CodebaseMetadata.builder()
.triggers(Collections.unmodifiableList(combinedTriggers))
.entryPoints(Collections.unmodifiableList(combinedEntryPoints))
.callChains(Collections.unmodifiableList(combinedCallChains))
.properties(Collections.unmodifiableMap(combinedProperties))
.build();
}

View File

@@ -0,0 +1,18 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
import java.util.List;
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class CallChain {
private final EntryPoint entryPoint;
private final List<String> methodChain; // e.g., ["Controller.submit", "Service.process", "Service.send"]
private final TriggerPoint triggerPoint;
}

View File

@@ -1,7 +1,9 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.jackson.Jacksonized;
import java.util.ArrayList;
import java.util.Collections;
@@ -10,19 +12,19 @@ import java.util.Map;
@Getter
@Builder(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class CodebaseMetadata {
private final List<TriggerPoint> triggers;
private final List<EntryPoint> entryPoints;
private final Map<String, String> properties;
@Builder.Default
private final List<TriggerPoint> triggers = Collections.emptyList();
@Builder.Default
private final List<EntryPoint> entryPoints = Collections.emptyList();
@Builder.Default
private final List<CallChain> callChains = Collections.emptyList();
@Builder.Default
private final Map<String, String> properties = Collections.emptyMap();
public static CodebaseMetadata empty() {
return CodebaseMetadata.builder()
.triggers(Collections.emptyList())
.entryPoints(Collections.emptyList())
.properties(Collections.emptyMap())
.build();
return CodebaseMetadata.builder().build();
}
public record TriggerPoint(String className, String methodName, String event, Map<String, String> metadata) {}
public record EntryPoint(String className, String methodName, String type, Map<String, String> metadata) {}
}

View File

@@ -0,0 +1,22 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
import java.util.Map;
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class EntryPoint {
public enum Type { REST, KAFKA, RABBIT, JMS, CUSTOM }
private final Type type;
private final String name; // e.g., "POST /orders" or "Kafka Topic: orders"
private final String className;
private final String methodName;
private final Map<String, String> metadata; // e.g., path, topic, exchange
}

View File

@@ -0,0 +1,17 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
import java.util.List;
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class LibraryHint {
private final String methodFqn; // e.g., "com.thirdparty.Workflow.send"
private final String event; // The event it triggers
}

View File

@@ -0,0 +1,21 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
import java.util.Map;
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class TriggerPoint {
private final String event;
private final String className;
private final String methodName;
private final String sourceModule;
private final String stateMachineId; // Optional: to link to a specific SM instance
private final int lineNumber;
}

View File

@@ -0,0 +1,92 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
@Slf4j
public class ConstantResolver {
public String resolve(Expression expr, CodebaseContext context) {
if (expr == null) return null;
// 1. Literal?
if (expr instanceof StringLiteral sl) {
return sl.getLiteralValue();
}
if (expr instanceof NumberLiteral nl) {
return nl.getToken();
}
if (expr instanceof BooleanLiteral bl) {
return String.valueOf(bl.booleanValue());
}
// 2. JDT Bindings? (Most robust)
IVariableBinding binding = null;
if (expr instanceof SimpleName sn) {
IBinding b = sn.resolveBinding();
if (b instanceof IVariableBinding vb) binding = vb;
} else if (expr instanceof QualifiedName qn) {
IBinding b = qn.resolveBinding();
if (b instanceof IVariableBinding vb) binding = vb;
}
if (binding != null && binding.isField()) {
Object value = binding.getConstantValue();
if (value != null) return value.toString();
}
// 3. Fallback: Manual AST Traversal (if bindings not available or resolved)
if (expr instanceof InfixExpression infix) {
return resolveInfix(infix, context);
}
if (expr instanceof QualifiedName qn) {
return resolveManual(qn, context);
}
return null;
}
private String resolveInfix(InfixExpression expr, CodebaseContext context) {
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
String left = resolve(expr.getLeftOperand(), context);
String right = resolve(expr.getRightOperand(), context);
if (left == null || right == null) return null;
StringBuilder sb = new StringBuilder(left + right);
if (expr.hasExtendedOperands()) {
for (Object operand : expr.extendedOperands()) {
String val = resolve((Expression) operand, context);
if (val == null) return null;
sb.append(val);
}
}
return sb.toString();
}
private String resolveManual(QualifiedName qn, CodebaseContext context) {
String qualifier = qn.getQualifier().toString();
String name = qn.getName().getIdentifier();
CompilationUnit contextCu = (CompilationUnit) qn.getRoot();
TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu);
if (td != null) {
for (FieldDeclaration fd : td.getFields()) {
for (Object fragmentObj : fd.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(name)) {
Expression initializer = fragment.getInitializer();
// Simple recursive resolve (be careful of cycles)
if (initializer instanceof StringLiteral sl) return sl.getLiteralValue();
}
}
}
}
}
return null;
}
}

View File

@@ -0,0 +1,95 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.stream.Stream;
@Slf4j
public class PropertyResolver {
private final List<String> activeProfiles = new ArrayList<>();
public void setActiveProfiles(List<String> profiles) {
this.activeProfiles.clear();
if (profiles != null) {
this.activeProfiles.addAll(profiles);
}
}
public Map<String, String> resolveProperties(Path rootDir) {
Map<String, String> allProperties = new HashMap<>();
// 1. Load base properties
loadMatching(rootDir, "application.properties", allProperties);
loadMatching(rootDir, "application.yml", allProperties);
loadMatching(rootDir, "application.yaml", allProperties);
// 2. Load profile-specific properties (overriding base)
for (String profile : activeProfiles) {
loadMatching(rootDir, "application-" + profile + ".properties", allProperties);
loadMatching(rootDir, "application-" + profile + ".yml", allProperties);
loadMatching(rootDir, "application-" + profile + ".yaml", allProperties);
}
return allProperties;
}
private void loadMatching(Path rootDir, String fileName, Map<String, String> target) {
try (Stream<Path> paths = Files.walk(rootDir)) {
paths.filter(p -> Files.isRegularFile(p))
.filter(p -> p.getFileName().toString().equals(fileName))
.forEach(p -> {
if (p.toString().endsWith(".properties")) {
target.putAll(loadProperties(p));
} else {
target.putAll(loadYaml(p));
}
});
} catch (IOException e) {
log.error("Failed to scan for {} in {}", fileName, rootDir, e);
}
}
private Map<String, String> loadProperties(Path path) {
Map<String, String> props = new HashMap<>();
Properties p = new Properties();
try (InputStream is = Files.newInputStream(path)) {
p.load(is);
p.forEach((k, v) -> props.put(k.toString(), v.toString()));
} catch (IOException e) {
log.warn("Failed to load properties from {}", path);
}
return props;
}
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<>();
}
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);
}
}

View File

@@ -0,0 +1,159 @@
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@Slf4j
@RequiredArgsConstructor
public class CallGraphBuilder {
private final CodebaseContext context;
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
Map<String, List<String>> callGraph = buildCallGraph();
List<CallChain> chains = new ArrayList<>();
for (EntryPoint ep : entryPoints) {
String startMethod = ep.getClassName() + "." + ep.getMethodName();
for (TriggerPoint tp : triggers) {
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<String> path = findPath(startMethod, targetMethod, callGraph, new HashSet<>());
if (path != null) {
chains.add(CallChain.builder()
.entryPoint(ep)
.triggerPoint(tp)
.methodChain(path)
.build());
}
}
}
return chains;
}
private Map<String, List<String>> buildCallGraph() {
Map<String, List<String>> graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
private String currentMethodFqn;
@Override
public boolean visit(MethodDeclaration node) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
currentMethodFqn = context.getFqn(td) + "." + node.getName().getIdentifier();
}
return super.visit(node);
}
@Override
public boolean visit(MethodInvocation node) {
if (currentMethodFqn != null) {
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
for (String calledMethod : calledMethods) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(calledMethod);
}
}
return super.visit(node);
}
});
}
return graph;
}
private List<String> resolveCalledMethodsPolymorphic(MethodInvocation node) {
String baseCalled = resolveCalledMethod(node);
if (baseCalled == null) return Collections.emptyList();
List<String> allResolved = new ArrayList<>();
allResolved.add(baseCalled);
// Polymorphic fan-out
if (baseCalled.contains(".")) {
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1);
List<String> impls = context.getImplementations(className);
for (String impl : impls) {
allResolved.add(impl + "." + methodName);
}
}
return allResolved;
}
private String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
if (receiver == null) {
// Local call or static import (simplified)
TypeDeclaration td = findEnclosingType(node);
return td != null ? context.getFqn(td) + "." + methodName : null;
}
// Try to resolve receiver type
// This is a simplified resolution without full JDT bindings.
// It works for local variables and fields within the same project.
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
return binding.getQualifiedName() + "." + methodName;
}
// Fallback: heuristic resolution for simple cases like 'orderService.process()'
if (receiver instanceof SimpleName sn) {
String receiverName = sn.getIdentifier();
// Look for fields or variables (simplified)
// For now, we'll return a "best guess" if we can't resolve it perfectly
// In a real implementation, we'd use JDT bindings more heavily.
return receiverName + "." + methodName;
}
return null;
}
private List<String> findPath(String start, String target, Map<String, List<String>> graph, Set<String> visited) {
if (start.equals(target)) return new ArrayList<>(List.of(start));
if (!visited.add(start)) return null;
List<String> neighbors = graph.get(start);
if (neighbors != null) {
for (String neighbor : neighbors) {
// Heuristic: neighbor might be 'orderService.process' while target is 'click.kamil...OrderService.process'
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
return new ArrayList<>(List.of(start, target));
}
List<String> path = findPath(neighbor, target, graph, visited);
if (path != null) {
path.add(0, start);
return path;
}
}
}
return null;
}
private boolean isHeuristicMatch(String neighbor, String target) {
// Match 'orderService.process' with 'click.kamil.OrderService.process'
if (target.endsWith("." + neighbor)) return true;
// Match simple names if we don't have FQN
String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target;
String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor;
// Very basic heuristic for now
return neighbor.toLowerCase().contains(simpleTarget.toLowerCase()) || target.toLowerCase().contains(simpleNeighbor.toLowerCase());
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
}

View File

@@ -0,0 +1,30 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import java.util.List;
import java.util.Map;
public interface CodebaseIntelligenceProvider {
/**
* Discovers all locations where events are triggered.
*/
List<TriggerPoint> findTriggerPoints();
/**
* Discovers all entry points (REST, Kafka, etc.).
*/
List<EntryPoint> findEntryPoints();
/**
* Links Entry Points to Trigger Points.
*/
List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers);
/**
* Resolves configuration properties.
*/
Map<String, String> resolveProperties();
}

View File

@@ -11,9 +11,9 @@ import java.util.List;
public class EnrichmentService {
private final List<AnalysisEnricher> enrichers;
public void enrich(AnalysisResult result, CodebaseContext context) {
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
for (AnalysisEnricher enricher : enrichers) {
enricher.enrich(result, context);
enricher.enrich(result, context, intelligence);
}
}
}

View File

@@ -0,0 +1,147 @@
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.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;
@Slf4j
@RequiredArgsConstructor
public class GenericEventDetector {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private final List<LibraryHint> hints;
public List<TriggerPoint> detect(CompilationUnit cu) {
List<TriggerPoint> triggers = new ArrayList<>();
String fileName = cu.getJavaElement() != null ? cu.getJavaElement().getElementName() : "unknown";
log.debug("Scanning for triggers in {}", fileName);
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
String methodName = node.getName().getIdentifier();
// 1. Core sendEvent
if ("sendEvent".equals(methodName)) {
processSendEvent(node, cu, triggers);
}
// 2. Library Hints
processHints(node, cu, triggers);
return super.visit(node);
}
});
return triggers;
}
private void processSendEvent(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
log.info("Found potential trigger: {}", node);
TriggerPoint trigger = buildTriggerPoint(node, cu, null);
if (trigger != null) {
log.info("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;
String calledMethod = resolveCalledMethodName(node);
if (calledMethod == null) return;
log.info("Checking method call '{}' against {} hints", calledMethod, hints.size());
for (LibraryHint hint : hints) {
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) {
log.info("MATCH! Found hint for '{}'", calledMethod);
TriggerPoint trigger = buildTriggerPoint(node, cu, hint.getEvent());
if (trigger != null) {
log.info("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
triggers.add(trigger);
}
}
}
}
private boolean isHintMatch(String called, String hintFqn) {
return hintFqn.endsWith("." + called);
}
private String resolveCalledMethodName(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
if (receiver == null) return methodName;
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;
}
}
}
}
}
return name + "." + methodName;
}
return receiver.toString() + "." + methodName;
}
private TriggerPoint buildTriggerPoint(MethodInvocation node, CompilationUnit cu, String forcedEvent) {
String eventValue;
if (forcedEvent != null) {
eventValue = forcedEvent;
} else {
if (node.arguments().isEmpty()) return null;
Expression eventExpr = (Expression) node.arguments().get(0);
eventValue = constantResolver.resolve(eventExpr, context);
if (eventValue == null) {
eventValue = eventExpr.toString();
}
}
MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration type = findEnclosingType(node);
if (type == null) return null;
return TriggerPoint.builder()
.event(eventValue)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.lineNumber(cu.getLineNumber(node.getStartPosition()))
.build();
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
}

View File

@@ -0,0 +1,75 @@
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.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RequiredArgsConstructor
public class InterceptorDetector {
private final CodebaseContext context;
public List<EntryPoint> detect(CompilationUnit cu) {
List<EntryPoint> entryPoints = new ArrayList<>();
cu.accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
if (isInterceptor(node)) {
processInterceptor(node, entryPoints);
}
return super.visit(node);
}
});
return entryPoints;
}
private boolean isInterceptor(TypeDeclaration node) {
if (node.getSuperclassType() != null) {
// Check for common adapter/base classes if needed
}
for (Object interfaceType : node.superInterfaceTypes()) {
String name = interfaceType.toString();
if (name.contains("HandlerInterceptor") || name.contains("Filter") || name.contains("WebRequestInterceptor")) {
return true;
}
}
return false;
}
private void processInterceptor(TypeDeclaration node, List<EntryPoint> entryPoints) {
String fqn = context.getFqn(node);
for (MethodDeclaration method : node.getMethods()) {
String methodName = method.getName().getIdentifier();
if (isInterceptorMethod(methodName)) {
Map<String, String> metadata = new HashMap<>();
metadata.put("interceptorType", getInterceptorType(node));
entryPoints.add(EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.name("INTERCEPTOR: " + node.getName().getIdentifier() + "." + methodName)
.className(fqn)
.methodName(methodName)
.metadata(metadata)
.build());
}
}
}
private boolean isInterceptorMethod(String name) {
return name.equals("preHandle") || name.equals("postHandle") || name.equals("afterCompletion") || name.equals("doFilter");
}
private String getInterceptorType(TypeDeclaration node) {
for (Object interfaceType : node.superInterfaceTypes()) {
String name = interfaceType.toString();
if (name.contains("HandlerInterceptor")) return "Spring MVC Interceptor";
if (name.contains("Filter")) return "Servlet Filter";
}
return "Generic Interceptor";
}
}

View File

@@ -0,0 +1,74 @@
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.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.CompilationUnit;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Slf4j
public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
private final CodebaseContext context;
private final Path rootDir;
private final ConstantResolver constantResolver = new ConstantResolver();
private final GenericEventDetector eventDetector;
private final SpringMvcDetector mvcDetector;
private final CallGraphBuilder callGraphBuilder;
private final LifecycleDetector lifecycleDetector;
private final InterceptorDetector interceptorDetector;
public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) {
this.context = context;
this.rootDir = rootDir;
this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints());
this.mvcDetector = new SpringMvcDetector(context, constantResolver);
this.callGraphBuilder = new CallGraphBuilder(context);
this.lifecycleDetector = new LifecycleDetector(context);
this.interceptorDetector = new InterceptorDetector(context);
}
@Override
public List<TriggerPoint> findTriggerPoints() {
List<TriggerPoint> allTriggers = new java.util.ArrayList<>();
var cus = context.getCompilationUnits();
log.info("JdtIntelligenceProvider scanning {} compilation units for triggers", cus.size());
for (CompilationUnit cu : cus) {
allTriggers.addAll(eventDetector.detect(cu));
allTriggers.addAll(lifecycleDetector.detect(cu));
}
log.info("Found {} triggers (including lifecycle) in total", allTriggers.size());
return allTriggers;
}
@Override
public List<EntryPoint> findEntryPoints() {
List<EntryPoint> allEntryPoints = new java.util.ArrayList<>();
var cus = context.getCompilationUnits();
log.info("JdtIntelligenceProvider scanning {} compilation units for entry points", cus.size());
for (CompilationUnit cu : cus) {
allEntryPoints.addAll(mvcDetector.detect(cu));
allEntryPoints.addAll(interceptorDetector.detect(cu));
}
log.info("Found {} entry points (including interceptors) in total", allEntryPoints.size());
return allEntryPoints;
}
@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 callGraphBuilder.findChains(entryPoints, triggers);
}
@Override
public Map<String, String> resolveProperties() {
return context.getProperties();
}
}

View File

@@ -0,0 +1,62 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.RequiredArgsConstructor;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.List;
@RequiredArgsConstructor
public class LifecycleDetector {
private final CodebaseContext context;
public List<TriggerPoint> detect(CompilationUnit cu) {
List<TriggerPoint> lifecyclePoints = new ArrayList<>();
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
String methodName = node.getName().getIdentifier();
if ("restore".equals(methodName) || "reset".equals(methodName)) {
TriggerPoint point = buildLifecyclePoint(node, cu, methodName);
if (point != null) {
lifecyclePoints.add(point);
}
}
return super.visit(node);
}
});
return lifecyclePoints;
}
private TriggerPoint buildLifecyclePoint(MethodInvocation node, CompilationUnit cu, String type) {
MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration typeDecl = findEnclosingType(node);
if (typeDecl == null) return null;
return TriggerPoint.builder()
.event("[LIFECYCLE:" + type.toUpperCase() + "]")
.className(context.getFqn(typeDecl))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.lineNumber(cu.getLineNumber(node.getStartPosition()))
.build();
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
}

View File

@@ -0,0 +1,179 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
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.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@RequiredArgsConstructor
public class SpringMvcDetector {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
public List<EntryPoint> detect(CompilationUnit cu) {
List<EntryPoint> entryPoints = new ArrayList<>();
cu.accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
if (isRestController(node)) {
processController(node, entryPoints);
}
return super.visit(node);
}
});
return entryPoints;
}
private boolean isRestController(TypeDeclaration node) {
for (Object modifier : node.modifiers()) {
if (modifier instanceof Annotation annotation) {
String name = annotation.getTypeName().getFullyQualifiedName();
if (name.endsWith("RestController") || name.endsWith("Controller")) {
return true;
}
}
}
return false;
}
private void processController(TypeDeclaration controller, List<EntryPoint> entryPoints) {
String basePath = getMappingPathHierarchical(controller);
for (MethodDeclaration method : controller.getMethods()) {
EntryPoint ep = processMethodHierarchical(method, basePath, controller);
if (ep != null) {
entryPoints.add(ep);
}
}
}
private String getMappingPathHierarchical(TypeDeclaration td) {
// 1. Check class itself
String path = getMappingPath(td);
if (!path.isEmpty()) return path;
// 2. Check interfaces
for (Object interfaceType : td.superInterfaceTypes()) {
TypeDeclaration itd = context.getTypeDeclaration(interfaceType.toString());
if (itd != null) {
path = getMappingPath(itd);
if (!path.isEmpty()) return path;
}
}
// 3. Check superclass
if (td.getSuperclassType() != null) {
TypeDeclaration std = context.getTypeDeclaration(td.getSuperclassType().toString());
if (std != null) {
return getMappingPathHierarchical(std);
}
}
return "";
}
private EntryPoint processMethodHierarchical(MethodDeclaration method, String basePath, TypeDeclaration controller) {
// 1. Check the method itself
EntryPoint ep = processMethod(method, basePath, controller);
if (ep != null) return ep;
// 2. Check interface methods
String methodName = method.getName().getIdentifier();
for (Object interfaceType : controller.superInterfaceTypes()) {
TypeDeclaration itd = context.getTypeDeclaration(interfaceType.toString());
if (itd != null) {
for (MethodDeclaration im : itd.getMethods()) {
if (im.getName().getIdentifier().equals(methodName)) {
ep = processMethod(im, basePath, controller);
if (ep != null) return ep;
}
}
}
}
return null;
}
private EntryPoint processMethod(MethodDeclaration method, String basePath, TypeDeclaration controller) {
for (Object modifier : method.modifiers()) {
if (modifier instanceof Annotation annotation) {
String name = annotation.getTypeName().getFullyQualifiedName();
String verb = getHttpVerb(name);
if (verb != null) {
String methodPath = getMappingPath(annotation);
String fullPath = combinePaths(basePath, methodPath);
Map<String, String> metadata = new HashMap<>();
metadata.put("path", fullPath);
metadata.put("verb", verb);
return EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name(verb + " " + fullPath)
.className(context.getFqn(controller))
.methodName(method.getName().getIdentifier())
.metadata(metadata)
.build();
}
}
}
return null;
}
private String getHttpVerb(String annotationName) {
if (annotationName.endsWith("PostMapping")) return "POST";
if (annotationName.endsWith("GetMapping")) return "GET";
if (annotationName.endsWith("PutMapping")) return "PUT";
if (annotationName.endsWith("DeleteMapping")) return "DELETE";
if (annotationName.endsWith("PatchMapping")) return "PATCH";
if (annotationName.endsWith("RequestMapping")) return "REQUEST";
return null;
}
private String getMappingPath(TypeDeclaration td) {
for (Object modifier : td.modifiers()) {
if (modifier instanceof Annotation annotation) {
if (annotation.getTypeName().getFullyQualifiedName().endsWith("RequestMapping")) {
return getMappingPath(annotation);
}
}
}
return "";
}
private String getMappingPath(Annotation annotation) {
Expression value = null;
if (annotation instanceof SingleMemberAnnotation sma) {
value = sma.getValue();
} else if (annotation instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if ("value".equals(pair.getName().getIdentifier()) || "path".equals(pair.getName().getIdentifier())) {
value = pair.getValue();
break;
}
}
}
if (value != null) {
String resolved = constantResolver.resolve(value, context);
return resolved != null ? resolved : value.toString();
}
return "";
}
private String combinePaths(String base, String method) {
String b = base.startsWith("/") ? base : "/" + base;
if (b.endsWith("/")) b = b.substring(0, b.length() - 1);
String m = method.startsWith("/") ? method : "/" + method;
if ("/".equals(m)) m = "";
return b + m;
}
}

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.ast.app;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.model.Action;
import click.kamil.springstatemachineexporter.model.Guard;
import click.kamil.springstatemachineexporter.model.State;
@@ -26,6 +27,7 @@ import java.util.Set;
import java.util.stream.Collectors;
public class AstTransitionParser {
private static final ConstantResolver constantResolver = new ConstantResolver();
private static final Set<String> SUPPORTED_TRANSITION_METHOD_NAMES = Arrays.stream(TransitionType.values())
.map(TransitionType::getMethodName)
@@ -215,7 +217,12 @@ public class AstTransitionParser {
});
case "event" -> {
Expression resolved = resolveArg((Expression) args.get(0), argsMap);
t.setEvent(QuotedExpression.of(resolved).toStringWithoutQuotes());
String eventValue = constantResolver.resolve(resolved, context);
if (eventValue != null) {
t.setEvent(eventValue);
} else {
t.setEvent(QuotedExpression.of(resolved).toStringWithoutQuotes());
}
}
case "guard" -> {
Expression resolved = resolveArg((Expression) args.get(0), argsMap);

View File

@@ -1,24 +1,19 @@
package click.kamil.springstatemachineexporter.ast.common;
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
import click.kamil.springstatemachineexporter.model.State;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.ImportDeclaration;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.StringLiteral;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.eclipse.jdt.core.dom.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -30,36 +25,135 @@ public class CodebaseContext {
private final Map<String, Path> classPaths = new HashMap<>();
private final Map<String, String> simpleNameToFqn = new HashMap<>();
private final Set<String> ambiguousSimpleNames = new HashSet<>();
private final ConstantResolver constantResolver = new ConstantResolver();
private final PropertyResolver propertyResolver = new PropertyResolver();
private Map<String, String> properties = new HashMap<>();
private List<LibraryHint> libraryHints = new ArrayList<>();
private final ObjectMapper objectMapper = new ObjectMapper();
public void loadLibraryHints(Path hintsFile) throws IOException {
if (Files.exists(hintsFile)) {
System.out.println("Loading hints from " + hintsFile.toAbsolutePath());
this.libraryHints = objectMapper.readValue(hintsFile.toFile(), new TypeReference<List<LibraryHint>>() {});
System.out.println("Loaded " + libraryHints.size() + " library hints");
}
}
public List<LibraryHint> getLibraryHints() {
return Collections.unmodifiableList(libraryHints);
}
private final Set<String> ignorePatterns = new HashSet<>(Set.of("**/test/**", "**/build/**", "**/node_modules/**", "**/out/**"));
private String[] classpath = new String[0];
private String[] sourcepath = new String[0];
private boolean resolveBindings = false;
public Map<String, String> getProperties() {
return Collections.unmodifiableMap(properties);
}
public void setClasspath(List<String> classpath) {
this.classpath = classpath.toArray(new String[0]);
}
public void setSourcepath(List<String> sourcepath) {
this.sourcepath = sourcepath.toArray(new String[0]);
}
public void setResolveBindings(boolean resolveBindings) {
this.resolveBindings = resolveBindings;
}
public void setActiveProfiles(List<String> profiles) {
this.propertyResolver.setActiveProfiles(profiles);
}
public void scan(Path rootDir) throws IOException {
List<Path> javaFiles = FileUtils.findFilesWithExtension(Set.of(rootDir), ".java");
scan(rootDir, Collections.emptySet());
}
private final List<CompilationUnit> allCus = new ArrayList<>();
private final Map<String, List<String>> interfaceToImpls = new HashMap<>();
public void scan(Path rootDir, Set<String> customIgnorePatterns) throws IOException {
this.properties = propertyResolver.resolveProperties(rootDir);
Set<String> activeIgnore = new HashSet<>(ignorePatterns);
activeIgnore.addAll(customIgnorePatterns);
List<Path> javaFiles = FileUtils.findFilesWithExtension(Set.of(rootDir), ".java", activeIgnore);
for (Path javaFile : javaFiles) {
String source = Files.readString(javaFile);
CompilationUnit cu = parse(source);
CompilationUnit cu = parse(source, javaFile.getFileName().toString());
allCus.add(cu);
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
for (Object type : cu.types()) {
if (type instanceof TypeDeclaration td) {
String simpleName = td.getName().getIdentifier();
String fqn = packageName.isEmpty() ? simpleName : packageName + "." + simpleName;
classes.put(fqn, cu);
classPaths.put(fqn, javaFile);
if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName);
if (!existingFqn.equals(fqn)) {
ambiguousSimpleNames.add(simpleName);
}
} else {
simpleNameToFqn.put(simpleName, fqn);
}
indexType(td, packageName, javaFile);
}
}
}
}
private void indexType(TypeDeclaration td, String packageName, Path javaFile) {
String simpleName = td.getName().getIdentifier();
String fqn = packageName.isEmpty() ? simpleName : packageName + "." + simpleName;
classes.put(fqn, (CompilationUnit) td.getRoot());
classPaths.put(fqn, javaFile);
if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName);
if (!existingFqn.equals(fqn)) {
ambiguousSimpleNames.add(simpleName);
}
} else {
simpleNameToFqn.put(simpleName, fqn);
}
// Inheritance Mapping
for (Object itf : td.superInterfaceTypes()) {
String itfName = itf.toString();
interfaceToImpls.computeIfAbsent(itfName, k -> new ArrayList<>()).add(fqn);
}
}
public List<String> getImplementations(String interfaceName) {
// Try direct match
List<String> impls = interfaceToImpls.get(interfaceName);
if (impls != null) return impls;
// Try FQN match if input was simple name
String fqn = simpleNameToFqn.get(interfaceName);
if (fqn != null) return interfaceToImpls.getOrDefault(fqn, Collections.emptyList());
return Collections.emptyList();
}
public State resolveState(Expression expr, CompilationUnit cu) {
if (expr == null)
return null;
// 1. Check for constants
String resolvedValue = constantResolver.resolve(expr, this);
if (resolvedValue != null) {
return State.of(expr.toString(), resolvedValue);
}
// 2. Check for @Value fields (SimpleName)
if (expr instanceof SimpleName sn) {
String fieldName = sn.getIdentifier();
TypeDeclaration td = findEnclosingType(sn);
if (td != null) {
String value = findValueFromField(td, fieldName);
if (value != null) {
return State.of(fieldName, value);
}
}
}
if (expr instanceof StringLiteral sl)
return State.of(sl.getLiteralValue());
@@ -75,6 +169,55 @@ public class CodebaseContext {
return State.of(raw);
}
private String findValueFromField(TypeDeclaration td, String fieldName) {
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(fieldName)) {
// Check for @Value annotation
for (Object mod : fd.modifiers()) {
if (mod instanceof Annotation ann) {
String name = ann.getTypeName().getFullyQualifiedName();
if (name.endsWith("Value")) {
String placeholder = extractAnnotationValue(ann);
return propertyResolver.resolveValue(placeholder, properties);
}
}
}
}
}
}
}
return null;
}
private String extractAnnotationValue(Annotation ann) {
if (ann instanceof SingleMemberAnnotation sma) {
return stripQuotes(sma.getValue().toString());
} else if (ann instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if ("value".equals(pair.getName().getIdentifier())) {
return stripQuotes(pair.getValue().toString());
}
}
}
return null;
}
private String stripQuotes(String s) {
if (s == null) return null;
return s.replaceAll("^\"|\"$", "");
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private String resolveQualifiedName(QualifiedName qn, CompilationUnit cu) {
String qualifier = qn.getQualifier().toString();
String name = qn.getName().getIdentifier();
@@ -97,11 +240,15 @@ public class CodebaseContext {
return name;
}
private CompilationUnit parse(String source) {
private CompilationUnit parse(String source, String unitName) {
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
parser.setSource(source.toCharArray());
parser.setKind(ASTParser.K_COMPILATION_UNIT);
parser.setResolveBindings(false);
parser.setResolveBindings(resolveBindings);
if (resolveBindings) {
parser.setUnitName(unitName);
parser.setEnvironment(classpath, sourcepath, null, true);
}
return (CompilationUnit) parser.createAST(null);
}
@@ -321,4 +468,8 @@ public class CodebaseContext {
private String getSimpleName(Type type) {
return AstUtils.extractSimpleTypeName(type);
}
public Collection<CompilationUnit> getCompilationUnits() {
return Collections.unmodifiableCollection(allCus);
}
}

View File

@@ -1,8 +1,11 @@
package click.kamil.springstatemachineexporter.ast.common;
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@@ -10,6 +13,14 @@ import java.util.stream.Stream;
public class FileUtils {
public static List<Path> findFilesWithExtension(Set<Path> startDirs, String extension) throws RuntimeException {
return findFilesWithExtension(startDirs, extension, Collections.emptySet());
}
public static List<Path> findFilesWithExtension(Set<Path> startDirs, String extension, Set<String> ignorePatterns) throws RuntimeException {
List<PathMatcher> matchers = ignorePatterns.stream()
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
.collect(Collectors.toList());
try (Stream<Path> paths = startDirs.stream()
.flatMap(startDir -> {
try {
@@ -20,6 +31,7 @@ public class FileUtils {
})) {
return paths
.filter(p -> Files.isRegularFile(p) && p.toString().endsWith(extension))
.filter(p -> matchers.stream().noneMatch(m -> m.matches(p)))
.collect(Collectors.toList());
}
}

View File

@@ -19,21 +19,20 @@ public class Dot implements StateMachineExporter {
@Override
public String export(click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result, ExportOptions options) {
return export(result.getName(), result.getTransitions(), result.getStartStates(), result.getEndStates(), options);
}
@Override
public String export(String name,
List<Transition> transitions,
Set<String> startStates,
Set<String> endStates,
ExportOptions options) {
StringBuilder sb = new StringBuilder();
sb.append("digraph statemachine {\n");
sb.append(" rankdir=LR;\n");
sb.append(" node [shape=rounded, style=filled, fillcolor=white, fontname=\"Arial\"];\n");
sb.append(" edge [fontname=\"Arial\", fontsize=10];\n\n");
// --- Core State Machine Rendering ---
renderCore(result.getTransitions(), result.getStartStates(), result.getEndStates(), options, sb);
sb.append("}\n");
return sb.toString();
}
private void renderCore(List<Transition> transitions, Set<String> startStates, Set<String> endStates, ExportOptions options, StringBuilder sb) {
// Choices
Set<String> statesWithChoice = new LinkedHashSet<>();
Map<String, String> choiceColorMap = new HashMap<>();
@@ -187,7 +186,22 @@ public class Dot implements StateMachineExporter {
}
}
}
}
@Override
public String export(String name,
List<Transition> transitions,
Set<String> startStates,
Set<String> endStates,
ExportOptions options) {
StringBuilder sb = new StringBuilder();
sb.append("digraph statemachine {\n");
sb.append(" rankdir=LR;\n");
sb.append(" node [shape=rounded, style=filled, fillcolor=white, fontname=\"Arial\"];\n");
sb.append(" edge [fontname=\"Arial\", fontsize=10];\n\n");
renderCore(transitions, startStates, endStates, options, sb);
sb.append("}\n");
return sb.toString();
}

View File

@@ -19,7 +19,19 @@ public class JsonExporter implements StateMachineExporter {
@Override
public String export(click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result, ExportOptions options) {
return export(result.getName(), result.getTransitions(), result.getStartStates(), result.getEndStates(), options);
try {
java.util.Map<String, Object> output = new java.util.HashMap<>();
output.put("name", result.getName());
output.put("transitions", result.getTransitions());
output.put("startStates", result.getStartStates());
output.put("endStates", result.getEndStates());
output.put("renderChoicesAsDiamonds", options.isRenderChoicesAsDiamonds());
output.put("metadata", result.getMetadata());
return objectMapper.writeValueAsString(output);
} catch (Exception e) {
throw new RuntimeException("Failed to export to JSON", e);
}
}
@Override

View File

@@ -11,6 +11,7 @@ import java.io.InputStreamReader;
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.Optional;
@@ -35,19 +36,7 @@ public class PlantUml implements StateMachineExporter {
@Override
public String export(click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result, ExportOptions options) {
String baseContent = export(result.getName(), result.getTransitions(), result.getStartStates(), result.getEndStates(), options);
// Allow custom styling from metadata
String customStyle = result.getMetadata().getProperties().get("plantuml.style");
if (customStyle != null && !customStyle.isBlank()) {
// Insert custom style before @enduml
int endumlIndex = baseContent.lastIndexOf("@enduml");
if (endumlIndex >= 0) {
return baseContent.substring(0, endumlIndex) + "\n" + customStyle + "\n" + baseContent.substring(endumlIndex);
}
}
return baseContent;
return export(result.getName(), result.getTransitions(), result.getStartStates(), result.getEndStates(), options);
}
@Override
@@ -59,6 +48,18 @@ public class PlantUml implements StateMachineExporter {
StringBuilder sb = new StringBuilder();
sb.append("@startuml\n");
sb.append("!pragma layout smetana\n");
sb.append("hide empty description\n");
sb.append("hide stereotype\n");
// Pre-declare all unique states for layout stability
Set<String> allUniqueStates = new LinkedHashSet<>();
transitions.forEach(t -> {
t.getSourceStates().forEach(s -> allUniqueStates.add(simplify(s.toString())));
t.getTargetStates().forEach(s -> allUniqueStates.add(simplify(s.toString())));
});
allUniqueStates.forEach(s -> sb.append("state ").append(s).append("\n"));
sb.append("\n");
String style = loadBaseStyle();
@@ -82,7 +83,7 @@ public class PlantUml implements StateMachineExporter {
Set<String> statesWithChoice = new java.util.LinkedHashSet<>();
for (Transition t : transitions) {
if (t.getType() == TransitionType.CHOICE) {
if (t.getType() == TransitionType.CHOICE && t.getSourceStates() != null) {
for (State source : t.getSourceStates()) {
statesWithChoice.add(simplify(source.toString()));
}
@@ -137,11 +138,10 @@ public class PlantUml implements StateMachineExporter {
for (String rawTarget : targets) {
String target = simplify(rawTarget);
String transitionSource = source;
String styleClass = getStyleClass(t, source, choiceStateClassMap);
String color = getLegacyColor(t, source, choiceStateClassMap);
sb.append(transitionSource).append(" -[#").append(color).append(",bold]-> ").append(target).append(" <<").append(styleClass).append(">>");
sb.append(source).append(" -[#").append(color).append(",bold]-> ").append(target).append(" <<").append(styleClass).append(">>");
if (t.getEvent() != null && !t.getEvent().isBlank()) {
String eventClass = "e_" + normalize(t.getEvent());

View File

@@ -1,7 +1,13 @@
package click.kamil.springstatemachineexporter.service;
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.app.AstTransitionParser;
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
import click.kamil.springstatemachineexporter.ast.app.TransitionStateUtils;
@@ -10,7 +16,6 @@ import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
@@ -25,36 +30,62 @@ import java.util.List;
import java.util.Set;
@Slf4j
@RequiredArgsConstructor
public class ExportService {
private final List<StateMachineExporter> exporters;
private final EnrichmentService enrichmentService;
public ExportService(List<StateMachineExporter> exporters, EnrichmentService enrichmentService) {
this.exporters = exporters;
this.enrichmentService = enrichmentService;
}
public ExportService(List<StateMachineExporter> exporters) {
this(exporters, new EnrichmentService(List.of(
new TriggerEnricher(),
new EntryPointEnricher(),
new PropertyEnricher(),
new CallChainEnricher()
)));
}
private static final List<String> TARGET_ANNOTATIONS = List.of("EnableStateMachineFactory", "EnableStateMachine");
public static final Set<String> STATE_MACHINE_RETURN_TYPES = Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory");
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList());
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles) throws IOException {
CodebaseContext context = new CodebaseContext();
context.setActiveProfiles(activeProfiles);
context.setSourcepath(List.of(inputDir.toString()));
context.setResolveBindings(true);
Path hintsFile = inputDir.resolve("hints.json");
if (!Files.exists(hintsFile)) {
hintsFile = inputDir.resolve("src/main/resources/hints.json");
}
if (Files.exists(hintsFile)) {
log.info("Loading hints from {}", hintsFile.toAbsolutePath());
context.loadLibraryHints(hintsFile);
}
context.scan(inputDir);
exportAll(context, outputDir, selectedFormats, renderChoicesAsDiamonds);
CodebaseIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, inputDir);
exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds);
}
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats) throws IOException {
JsonImportService jsonImportService = new JsonImportService();
StateMachineModel model = jsonImportService.importJson(jsonFile);
AnalysisResult result = jsonImportService.importAnalysisResult(jsonFile);
AnalysisResult result = AnalysisResult.builder()
.name(model.getName())
.transitions(model.getTransitions())
.startStates(model.getStartStates())
.endStates(model.getEndStates())
.renderChoicesAsDiamonds(model.isRenderChoicesAsDiamonds())
.build();
generateOutputs(outputDir, result, selectedFormats);
}
private void exportAll(CodebaseContext context, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
private void exportAll(CodebaseContext context, CodebaseIntelligenceProvider intelligence, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
Set<String> processedLocations = new HashSet<>();
// 1. Find entry point classes (annotated or extending adapter)
@@ -62,7 +93,7 @@ public class ExportService {
for (TypeDeclaration td : entryPoints) {
String fqn = context.getFqn(td);
if (processedLocations.add(fqn)) {
processEntryPoint(td, outputDir, context, selectedFormats, renderChoicesAsDiamonds);
processEntryPoint(td, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds);
}
}
@@ -75,12 +106,12 @@ public class ExportService {
String uniqueId = parentFqn + "#" + methodName;
if (processedLocations.add(uniqueId)) {
processBeanMethod(m, outputDir, context, selectedFormats, renderChoicesAsDiamonds);
processBeanMethod(m, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds);
}
}
}
private void processEntryPoint(TypeDeclaration td, Path outputDir, CodebaseContext context, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
private void processEntryPoint(TypeDeclaration td, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
String className = context.getFqn(td);
log.info("Processing state machine config: {}", className);
@@ -105,12 +136,12 @@ public class ExportService {
.renderChoicesAsDiamonds(renderChoicesAsDiamonds)
.build();
enrichmentService.enrich(result, context);
enrichmentService.enrich(result, context, intelligence);
generateOutputs(outputDir, result, selectedFormats);
}
private void processBeanMethod(MethodDeclaration m, Path outputDir, CodebaseContext context, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
private void processBeanMethod(MethodDeclaration m, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
TypeDeclaration parentClass = (TypeDeclaration) m.getParent();
String parentFqn = context.getFqn(parentClass);
String methodName = m.getName().getIdentifier();
@@ -131,29 +162,32 @@ public class ExportService {
.renderChoicesAsDiamonds(renderChoicesAsDiamonds)
.build();
enrichmentService.enrich(result, context);
enrichmentService.enrich(result, context, intelligence);
generateOutputs(outputDir, result, selectedFormats);
}
private void generateOutputs(Path outputDir, AnalysisResult result, List<String> selectedFormats) throws IOException {
if (selectedFormats == null || selectedFormats.isEmpty()) {
return;
}
Path targetDir = outputDir.resolve(result.getName());
String actualName = result.getName();
Path targetDir = outputDir.resolve(actualName);
Files.createDirectories(targetDir);
ExportOptions options = ExportOptions.builder()
.useLambdaGuards(true)
.renderChoicesAsDiamonds(result.isRenderChoicesAsDiamonds())
.build();
for (StateMachineExporter output : exporters) {
if (selectedFormats != null && !selectedFormats.isEmpty()) {
boolean match = selectedFormats.stream().anyMatch(f -> output.getFileExtension().toLowerCase().contains(f.toLowerCase()));
if (!match) continue;
for (StateMachineExporter exporter : exporters) {
String ext = exporter.getFileExtension().toLowerCase();
if (!selectedFormats.stream().anyMatch(fmt -> ext.contains(fmt.toLowerCase()))) {
continue;
}
String content = output.export(result, options);
String fileName = result.getName() + output.getFileExtension();
String content = exporter.export(result, options);
String fileName = result.getName() + exporter.getFileExtension();
try (PrintWriter out = new PrintWriter(targetDir.resolve(fileName).toFile())) {
out.println(content);

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.service;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.model.StateMachineModel;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -23,4 +24,8 @@ public class JsonImportService {
}
return model;
}
public AnalysisResult importAnalysisResult(Path jsonFile) throws IOException {
return objectMapper.readValue(jsonFile.toFile(), AnalysisResult.class);
}
}