13 Commits

34 changed files with 3673 additions and 190 deletions

View File

@@ -1,6 +1,5 @@
1.extract from all possible formats (ask chatgpt) 1.extract from all possible formats (ask chatgpt)
[DONE] json
[TODO] xml, maybe yaml [TODO] xml, maybe yaml
2. [PLAN] Support external dependencies (JARs/compiled classes): 2. [PLAN] Support external dependencies (JARs/compiled classes):

View File

@@ -36,6 +36,7 @@ dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' 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-jdk8:2.17.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310: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' compileOnly 'org.projectlombok:lombok:1.18.46'
annotationProcessor '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 class Main {
public static void main(String[] args) { 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 // Manual DI / Wiring
var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter()); var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
var exportService = new ExportService(exporters); var exportService = new ExportService(exporters);

View File

@@ -14,8 +14,13 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicRoutingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.RoutingEngine;
public class TransitionLinkerEnricher implements AnalysisEnricher { public class TransitionLinkerEnricher implements AnalysisEnricher {
private final RoutingEngine routingEngine = new HeuristicRoutingEngine();
@Override @Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) { public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) { if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
@@ -41,37 +46,75 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
if (t.getEvent() != null) { if (t.getEvent() != null) {
String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName(); String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName();
String smEvent = simplify(smEventRaw); 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");
if (isWildcard) {
String targetVar = chain.getContextMachineId();
if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId();
}
// We no longer hard-block wildcards without a specific routing context.
// If a project doesn't use standard SM persisters (e.g. restores state manually),
// contextMachineId will be null. We should still link the wildcard to provide SOME visibility,
// rather than completely hiding the endpoint.
}
List<String> polyEvents = tp.getPolymorphicEvents() != null ? tp.getPolymorphicEvents() : java.util.Collections.emptyList();
boolean hasPolyMatch = false;
for (String pe : polyEvents) {
String simplePe = pe;
if (pe.contains(".")) {
simplePe = pe.substring(pe.lastIndexOf('.') + 1);
}
if (simplePe.equals(smEventRaw) || simplePe.equals(smEvent)) {
hasPolyMatch = true;
break;
}
}
if (hasPolyMatch || (polyEvents.isEmpty() && (isWildcard || smEvent.equals(triggerEvent)))) {
// Event matches or is a wildcard
for (State smSourceState : t.getSourceStates()) { for (State smSourceState : t.getSourceStates()) {
String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName(); String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
String smSource = simplify(smSourceRaw); String smSource = simplify(smSourceRaw);
if (triggerSource == null || triggerSource.equals(smSource)) { if (triggerSource == null || triggerSource.equals(smSource)) {
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceRaw)
.targetState(smSourceRaw)
.event(smEventRaw)
.build();
if (isRoutedToCorrectMachine(chain, result.getName())) {
matched.add(mt);
}
} else {
for (State smTargetState : t.getTargetStates()) { for (State smTargetState : t.getTargetStates()) {
String sourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName(); String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName();
matched.add(MatchedTransition.builder() MatchedTransition mt = MatchedTransition.builder()
.sourceState(sourceRaw) .sourceState(smSourceRaw)
.targetState(targetRaw) .targetState(targetRaw)
.event(smEventRaw) .event(smEventRaw)
.build());
}
}
}
}
}
}
// 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(); .build();
if (isRoutedToCorrectMachine(chain, result.getName())) {
matched.add(mt);
}
}
}
}
}
}
}
}
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 // Update the metadata with the new call chains
@@ -85,12 +128,48 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
result.setMetadata(updatedMetadata); result.setMetadata(updatedMetadata);
} }
private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName) {
return routingEngine.isRoutedToCorrectMachine(chain, currentMachineName);
}
private String simplify(String name) { private String simplify(String name) {
if (name == null) return ""; if (name == null) return null;
int dot = name.lastIndexOf('.'); // Strip common suffixes
if (dot >= 0) { String simplified = name.replaceAll("(?i)(.*)(Event|Action|Transition|Command)(s)?$", "$1");
return name.substring(dot + 1); if (simplified.isEmpty()) {
simplified = name;
} }
return name; // 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;
} }
private boolean isGuardedContains(String smEvent, String triggerEvent) {
// Only fire if it looks like a simple identifier (no spaces, no parens)
// Prevents unresolved AST nodes (e.g. "event.getPayload()") from accidentally matching "Event"
if (smEvent.contains(" ") || smEvent.contains("(") || smEvent.contains(")") ||
triggerEvent.contains(" ") || triggerEvent.contains("(") || triggerEvent.contains(")")) {
return false;
}
String shorter = smEvent.length() < triggerEvent.length() ? smEvent : triggerEvent;
String longer = smEvent.length() >= triggerEvent.length() ? smEvent : triggerEvent;
shorter = shorter.toLowerCase();
longer = longer.toLowerCase();
if (longer.contains(shorter)) {
// Guard: If the matching part is very short (< 4 chars), it must be bounded by word separators
// to avoid matching completely unrelated words (e.g., "PAY" matching "PAYMENT" or "E" matching "UPDATE")
if (shorter.length() < 4) {
return longer.matches(".*(^|_|-)" + shorter + "(_|-|$).*");
}
// For longer strings, a straight contains is usually safe enough
return true;
}
return false;
}
} }

View File

@@ -0,0 +1,188 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import java.util.HashSet;
import java.util.Set;
public class HeuristicRoutingEngine implements RoutingEngine {
@Override
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName) {
String targetVar = chain.getContextMachineId();
if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId();
}
String simplifiedMachineName = currentMachineName != null ? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase() : "";
if (targetVar != null && !targetVar.isEmpty()) {
targetVar = targetVar.toLowerCase();
if (targetVar.endsWith("statemachine")) {
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
if (!prefix.isEmpty()) {
if (simplifiedMachineName.contains(prefix)) {
return true; // Explicit positive match
} else {
return false; // Explicit negative match
}
}
} else if (simplifiedMachineName.contains(targetVar)) {
return true;
}
}
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) {
String smPackage = getPackageName(currentMachineName);
String smSimple = getSimpleClassName(currentMachineName);
String smPrefix = getFirstCamelCaseWord(smSimple);
boolean hasPositiveMatch = false;
boolean hasStrongMismatch = false;
for (String method : chain.getMethodChain()) {
String chainClass = getClassNameOnly(method);
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasStrongMismatch = true;
}
}
if (hasPositiveMatch) {
return true;
}
if (hasStrongMismatch) {
return false;
}
}
return true;
}
private boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
if (smPackage.startsWith(chainPackage) || chainPackage.startsWith(smPackage)) {
return true;
}
// Find deepest common package root
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
int matchIdx = -1;
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
if (p1[i].equals(p2[i])) {
matchIdx = i;
} else {
break;
}
}
// If the state machine's prefix is part of the shared common package,
// then the state machine represents the parent domain of the caller.
if (smPrefix != null && matchIdx >= 0) {
String lowerPrefix = smPrefix.toLowerCase();
for (int i = 0; i <= matchIdx; i++) {
if (p1[i].toLowerCase().contains(lowerPrefix) || lowerPrefix.contains(p1[i].toLowerCase())) {
return true;
}
}
}
return false;
}
private boolean isDomainMismatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
int matchIdx = -1;
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
if (p1[i].equals(p2[i])) {
matchIdx = i;
} else {
break;
}
}
Set<String> smDivergentTerms = new HashSet<>();
if (smPrefix != null) smDivergentTerms.add(smPrefix.toLowerCase());
for (int i = matchIdx + 1; i < p1.length; i++) {
smDivergentTerms.add(p1[i].toLowerCase());
}
Set<String> chainDivergentTerms = new HashSet<>();
if (chainPrefix != null) chainDivergentTerms.add(chainPrefix.toLowerCase());
for (int i = matchIdx + 1; i < p2.length; i++) {
chainDivergentTerms.add(p2[i].toLowerCase());
}
if (!smDivergentTerms.isEmpty() && !chainDivergentTerms.isEmpty()) {
Set<String> intersection = new HashSet<>(smDivergentTerms);
intersection.retainAll(chainDivergentTerms);
return intersection.isEmpty();
}
return false;
}
private Set<String> extractDomainTerms(String pkg, String prefix) {
Set<String> terms = new HashSet<>();
if (pkg != null && !pkg.isEmpty()) {
String[] segments = pkg.split("\\.");
// Take segments from index 2 onwards if length > 2
int start = segments.length > 2 ? 2 : 0;
for (int i = start; i < segments.length; i++) {
terms.add(segments[i].toLowerCase());
}
}
if (prefix != null && !prefix.isEmpty()) {
terms.add(prefix.toLowerCase());
}
return terms;
}
private String getPackageName(String fqn) {
if (fqn == null || !fqn.contains(".")) return "";
return fqn.substring(0, fqn.lastIndexOf('.'));
}
private String getSimpleClassName(String fqn) {
if (fqn == null) return "";
if (!fqn.contains(".")) return fqn;
return fqn.substring(fqn.lastIndexOf('.') + 1);
}
private String getClassNameOnly(String methodFqn) {
if (methodFqn == null) return "";
String clean = methodFqn;
if (clean.contains("(")) {
clean = clean.substring(0, clean.indexOf('('));
}
if (clean.contains(".")) {
clean = clean.substring(0, clean.lastIndexOf('.'));
}
return clean;
}
private String getFirstCamelCaseWord(String simpleName) {
if (simpleName == null || simpleName.isEmpty()) return null;
String[] words = simpleName.split("(?<!^)(?=[A-Z])");
if (words.length > 0) {
return words[0];
}
return null;
}
}

View File

@@ -0,0 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
public interface RoutingEngine {
boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName);
}

View File

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

View File

@@ -13,5 +13,7 @@ import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public class LibraryHint { public class LibraryHint {
private final String methodFqn; // e.g., "com.thirdparty.Workflow.send" 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

@@ -20,4 +20,5 @@ public class TriggerPoint {
private final String stateMachineId; // Optional: to link to a specific SM instance private final String stateMachineId; // Optional: to link to a specific SM instance
private final String sourceState; // Optional: if we can determine the expected current state private final String sourceState; // Optional: if we can determine the expected current state
private final int lineNumber; private final int lineNumber;
private final java.util.List<String> polymorphicEvents; // NEW: stores concrete events resolved via deep polymorphism
} }

View File

@@ -48,13 +48,33 @@ public class ConstantResolver {
return resolveInfix(infix, context, visited); return resolveInfix(infix, context, visited);
} }
if (expr instanceof QualifiedName qn) { if (expr instanceof QualifiedName qn) {
return resolveManual(qn, context, visited); String val = resolveManual(qn, context, visited);
return val != null ? val : qn.toString();
} }
if (expr instanceof SimpleName sn) { if (expr instanceof SimpleName sn) {
return resolveManual(sn, context, visited); return resolveManual(sn, context, visited);
} }
if (expr instanceof MethodInvocation mi) { if (expr instanceof MethodInvocation mi) {
if ((mi.getName().getIdentifier().equals("name") || mi.getName().getIdentifier().equals("toString")) && mi.arguments().isEmpty()) {
String val = resolveInternal(mi.getExpression(), context, visited);
if (val != null) {
// If it was resolved as a fully qualified enum string like "MyEvents.ORDER_PAID", strip it
if (val.contains(".")) {
return val.substring(val.lastIndexOf('.') + 1);
}
return val;
}
// Fallback for unresolved AST nodes
if (mi.getExpression() instanceof QualifiedName qn) {
return qn.getName().getIdentifier();
} else if (mi.getExpression() instanceof SimpleName sn) {
return sn.getIdentifier();
}
}
IMethodBinding mb = mi.resolveMethodBinding(); IMethodBinding mb = mi.resolveMethodBinding();
if (mb != null) { if (mb != null) {
ITypeBinding returnType = mb.getReturnType(); ITypeBinding returnType = mb.getReturnType();

View File

@@ -105,25 +105,32 @@ public class PropertyResolver {
} }
private Map<String, String> loadYaml(Path path) { private Map<String, String> loadYaml(Path path) {
// Placeholder for future YAML support Map<String, String> props = new HashMap<>();
log.warn("YAML parsing not fully implemented yet for {}", path); try {
return new HashMap<>(); 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) { public String resolveValue(String placeholder, Map<String, String> properties) {
if (placeholder == null || !placeholder.contains("${")) return placeholder; return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(placeholder, properties);
// 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

@@ -34,10 +34,12 @@ public class CallGraphBuilder {
for (EntryPoint ep : entryPoints) { for (EntryPoint ep : entryPoints) {
String startMethod = ep.getClassName() + "." + ep.getMethodName(); String startMethod = ep.getClassName() + "." + ep.getMethodName();
boolean foundAny = false;
for (TriggerPoint tp : triggers) { for (TriggerPoint tp : triggers) {
String targetMethod = tp.getClassName() + "." + tp.getMethodName(); String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<String> path = findPath(startMethod, targetMethod, callGraph, new HashSet<>()); List<String> path = findPath(startMethod, targetMethod, callGraph, new HashSet<>());
if (path != null) { if (path != null) {
foundAny = true;
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph); TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph);
String contextMachineId = extractContextMachineId(path, callGraph); String contextMachineId = extractContextMachineId(path, callGraph);
chains.add(CallChain.builder() chains.add(CallChain.builder()
@@ -48,6 +50,9 @@ public class CallGraphBuilder {
.build()); .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; return chains;
} }
@@ -60,6 +65,17 @@ public class CallGraphBuilder {
String currentParamName = event; String currentParamName = event;
String resolvedValue = 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 // Walk backwards up the call chain
for (int i = path.size() - 1; i > 0; i--) { for (int i = path.size() - 1; i > 0; i--) {
@@ -68,6 +84,21 @@ public class CallGraphBuilder {
// Find parameter index in target method // Find parameter index in target method
int paramIndex = getParameterIndex(target, currentParamName); int paramIndex = getParameterIndex(target, currentParamName);
if (paramIndex < 0) {
// Not a parameter. Maybe it's a local variable initialized from a parameter or method call?
String tracedVar = traceLocalVariable(target, currentParamName);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
// Extract method calls like .getType() from the traced variable
int dotIdx = tracedVar.indexOf('.');
if (dotIdx > 0 && dotIdx + 1 < tracedVar.length() && Character.isLowerCase(tracedVar.charAt(dotIdx + 1))) {
methodSuffix = tracedVar.substring(dotIdx) + methodSuffix;
tracedVar = tracedVar.substring(0, dotIdx);
}
currentParamName = tracedVar;
resolvedValue = tracedVar + methodSuffix;
paramIndex = getParameterIndex(target, currentParamName);
}
}
if (paramIndex < 0) { if (paramIndex < 0) {
break; // Parameter name changed or not found, stop tracing break; // Parameter name changed or not found, stop tracing
} }
@@ -81,8 +112,14 @@ public class CallGraphBuilder {
if (paramIndex < edge.getArguments().size()) { if (paramIndex < edge.getArguments().size()) {
String arg = edge.getArguments().get(paramIndex); String arg = edge.getArguments().get(paramIndex);
if (arg != null) { if (arg != null) {
// If the argument passed has a method call, extract it
int dotIdx = arg.indexOf('.');
if (dotIdx > 0 && dotIdx + 1 < arg.length() && Character.isLowerCase(arg.charAt(dotIdx + 1))) {
methodSuffix = arg.substring(dotIdx) + methodSuffix;
arg = arg.substring(0, dotIdx);
}
currentParamName = arg; currentParamName = arg;
resolvedValue = arg; resolvedValue = arg + methodSuffix;
found = true; found = true;
break; break;
} }
@@ -93,13 +130,150 @@ public class CallGraphBuilder {
if (!found) break; // Could not map argument if (!found) break; // Could not map argument
} }
if (!resolvedValue.equals(event)) { // Final check on the entry method
String entryMethod = path.get(0);
int entryParamIndex = getParameterIndex(entryMethod, currentParamName);
if (entryParamIndex < 0) {
String tracedVar = traceLocalVariable(entryMethod, currentParamName);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
int dotIdx = tracedVar.indexOf('.');
if (dotIdx > 0 && dotIdx + 1 < tracedVar.length() && Character.isLowerCase(tracedVar.charAt(dotIdx + 1))) {
methodSuffix = tracedVar.substring(dotIdx) + methodSuffix;
tracedVar = tracedVar.substring(0, dotIdx);
}
currentParamName = tracedVar;
resolvedValue = tracedVar + methodSuffix;
}
}
List<String> polymorphicEvents = new ArrayList<>();
if (resolvedValue.matches(".*\\.[a-zA-Z0-9_]+\\(\\)")) {
int lastDot = resolvedValue.lastIndexOf('.');
int firstDot = resolvedValue.indexOf('.');
int openParen = resolvedValue.indexOf('(', lastDot);
if (lastDot > 0 && openParen > lastDot) {
String varName = resolvedValue.substring(0, firstDot);
if (varName.contains("(")) {
varName = null;
}
String methodName = resolvedValue.substring(lastDot + 1, openParen);
String declaredType = null;
String sourceMethod = null;
if (varName != null) {
for (String methodFqn : path) {
declaredType = getVariableDeclaredType(methodFqn, varName);
if (declaredType != null) {
sourceMethod = methodFqn;
break;
}
}
} else {
String baseExpr = resolvedValue.substring(0, lastDot);
if (baseExpr.contains("new ")) {
int newIdx = baseExpr.indexOf("new ");
int openParenBase = baseExpr.indexOf('(', newIdx);
if (openParenBase > newIdx) {
declaredType = baseExpr.substring(newIdx + 4, openParenBase).trim();
if (declaredType.contains("<")) {
declaredType = declaredType.substring(0, declaredType.indexOf('<'));
}
sourceMethod = "inline-instantiation";
}
}
}
if (declaredType != null) {
System.out.println("DEEP TRACE: " + sourceMethod + " " + (varName != null ? varName : "inline") + " -> " + declaredType);
List<String> typesToInspect = new ArrayList<>();
if ("inline-instantiation".equals(sourceMethod)) {
typesToInspect.add(declaredType);
} else {
typesToInspect.add(declaredType);
typesToInspect.addAll(context.getImplementations(declaredType));
}
System.out.println("DEEP TRACE IMPLS: " + typesToInspect);
for (String type : typesToInspect) {
Set<String> visited = new HashSet<>();
TypeDeclaration baseTd = context.getTypeDeclaration(type);
CompilationUnit cuToUse = null;
if (baseTd != null && baseTd.getRoot() instanceof CompilationUnit) {
cuToUse = (CompilationUnit) baseTd.getRoot();
} else {
String firstPathMethod = path.get(0);
String entryClassName = firstPathMethod.substring(0, firstPathMethod.lastIndexOf('.'));
TypeDeclaration entryTd = context.getTypeDeclaration(entryClassName);
if (entryTd != null && entryTd.getRoot() instanceof CompilationUnit) {
cuToUse = (CompilationUnit) entryTd.getRoot();
}
}
List<String> constants = resolveMethodReturnConstant(type, methodName, 0, visited, cuToUse);
System.out.println("DEEP TRACE RETURN: " + type + " -> " + constants);
for (String constant : constants) {
if (!polymorphicEvents.contains(constant)) {
polymorphicEvents.add(constant);
}
}
}
}
}
}
// LAST RESORT FALLBACK: If AST deep trace failed to find a valid ALL_CAPS constant
// (e.g. it returned a field name like 'type', or nothing), we can scrape the resolvedValue string directly
// for string literals or enum-like constants.
boolean hasValidConstant = false;
for (String ev : polymorphicEvents) {
String val = ev.contains(".") ? ev.substring(ev.lastIndexOf('.') + 1) : ev;
if (val.equals(val.toUpperCase()) && val.length() > 2) {
hasValidConstant = true;
break;
}
}
if (!hasValidConstant && resolvedValue.contains("(")) {
java.util.List<String> scraped = new java.util.ArrayList<>();
// Extract "STRING_LITERALS"
java.util.regex.Matcher m1 = java.util.regex.Pattern.compile("\"([^\"]+)\"").matcher(resolvedValue);
while (m1.find()) {
scraped.add(m1.group(1));
}
// Extract ENUM_LIKE_CONSTANTS
java.util.regex.Matcher m2 = java.util.regex.Pattern.compile("\\b([A-Z_]{3,})\\b").matcher(resolvedValue);
while (m2.find()) {
if (!scraped.contains(m2.group(1))) {
scraped.add(m2.group(1));
}
}
if (!scraped.isEmpty()) {
polymorphicEvents.clear();
polymorphicEvents.addAll(scraped);
}
}
// Clean up any remaining invalid AST fallbacks (like 'type', 'event') if we found valid ones
if (polymorphicEvents.size() > 1) {
polymorphicEvents.removeIf(e -> {
String val = e.contains(".") ? e.substring(e.lastIndexOf('.') + 1) : e;
return !val.equals(val.toUpperCase()) || val.length() <= 2;
});
}
if (!resolvedValue.equals(event) || !polymorphicEvents.isEmpty()) {
return TriggerPoint.builder() return TriggerPoint.builder()
.event(resolvedValue) .event(resolvedValue)
.className(tp.getClassName()) .className(tp.getClassName())
.methodName(tp.getMethodName()) .methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile()) .sourceFile(tp.getSourceFile())
.lineNumber(tp.getLineNumber()) .lineNumber(tp.getLineNumber())
.polymorphicEvents(polymorphicEvents)
.build(); .build();
} }
@@ -125,6 +299,181 @@ public class CallGraphBuilder {
return -1; return -1;
} }
private String getVariableDeclaredType(String methodFqn, String varName) {
if (methodFqn == null || !methodFqn.contains(".")) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
for (Object pObj : md.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) pObj;
if (svd.getName().getIdentifier().equals(varName)) {
return svd.getType().toString();
}
}
final String[] foundType = new String[1];
if (md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType().toString();
}
}
return super.visit(node);
}
});
}
return foundType[0];
}
}
return null;
}
private List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) {
if (depth > 20) return Collections.emptyList();
String fqn = className + "." + methodName;
if (!visited.add(fqn)) return Collections.emptyList();
List<String> constants = new ArrayList<>();
TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null;
if (tempTd == null) {
tempTd = context.getTypeDeclaration(className);
}
final TypeDeclaration td = tempTd;
if (td == null) {
System.out.println("DEEP TRACE FAILED TO FIND TD: " + className);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
Expression retExpr = node.getExpression();
if (retExpr != null) {
boolean handled = false;
if (retExpr instanceof MethodInvocation mi) {
// Follow delegation first
String called = resolveCalledMethod(mi);
System.out.println("DEEP TRACE RESOLVED CALLED: " + called);
if (called != null && called.contains(".")) {
if (visited.contains(called)) {
handled = true;
} else {
String cName = called.substring(0, called.lastIndexOf('.'));
String mName = called.substring(called.lastIndexOf('.') + 1);
TypeDeclaration targetTd = contextCu != null ? context.getTypeDeclaration(cName, contextCu) : context.getTypeDeclaration(cName);
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
handled = true;
}
}
}
}
if (!handled) {
String val = constantResolver.resolve(retExpr, context);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
constants.add(eVal.substring(eVal.lastIndexOf('.') + 1));
}
} else {
constants.add(val);
}
} else if (retExpr instanceof QualifiedName qn) {
constants.add(qn.toString());
} else if (retExpr instanceof SimpleName sn) {
List<String> consts = traceFieldInConstructors(td, sn.getIdentifier(), context, visited);
if (!consts.isEmpty()) {
constants.addAll(consts);
} else {
constants.add(sn.toString());
}
}
}
}
return super.visit(node);
}
});
}
}
visited.remove(fqn);
return constants;
}
private String traceLocalVariable(String methodFqn, String varName) {
if (methodFqn == null || !methodFqn.contains(".")) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
final Expression[] initializer = new Expression[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializer[0] = node.getInitializer();
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
initializer[0] = node.getRightHandSide();
}
return super.visit(node);
}
});
if (initializer[0] != null) {
Expression expr = traceVariable(initializer[0]);
if (expr instanceof MethodInvocation mi) {
// Unwrapper logic: If wrapper method is called, extract its arguments recursively
Expression innerMost = unwrapMethodInvocation(mi, 0);
if (innerMost instanceof MethodInvocation innerMi) {
if (innerMi.getExpression() instanceof SimpleName sn) {
return sn.getIdentifier() + "." + innerMi.getName().getIdentifier() + "()";
}
return innerMi.getName().getIdentifier() + "()";
}
if (innerMost instanceof SimpleName sn) {
return sn.getIdentifier();
}
return innerMost.toString();
}
if (expr instanceof SimpleName sn) {
return sn.getIdentifier();
}
return expr.toString();
}
}
}
return null;
}
private Expression unwrapMethodInvocation(MethodInvocation mi, int depth) {
if (depth > 5) return mi;
if (!mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0);
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1);
}
return arg;
}
return mi;
}
private String extractContextMachineId(List<String> path, Map<String, List<CallEdge>> callGraph) { private String extractContextMachineId(List<String> path, Map<String, List<CallEdge>> callGraph) {
for (String node : path) { for (String node : path) {
List<CallEdge> edges = callGraph.get(node); List<CallEdge> edges = callGraph.get(node);
@@ -143,22 +492,26 @@ public class CallGraphBuilder {
return null; return null;
} }
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private Map<String, List<CallEdge>> buildCallGraph() { private Map<String, List<CallEdge>> buildCallGraph() {
graph = new HashMap<>(); graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) { for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() { cu.accept(new ASTVisitor() {
@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 @Override
public boolean visit(MethodInvocation node) { public boolean visit(MethodInvocation node) {
if (currentMethodFqn != null) { MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node); List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments()); List<String> args = resolveArguments(node.arguments());
for (String calledMethod : calledMethods) { for (String calledMethod : calledMethods) {
@@ -168,13 +521,31 @@ public class CallGraphBuilder {
if (argObj instanceof ExpressionMethodReference emr) { if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString(); String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) { if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration td = findEnclosingType(node); TypeDeclaration td2 = findEnclosingType(node);
if (td != null) { if (td2 != null) {
String refMethod = resolveMethodInType(td, emr.getName().getIdentifier()); String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) { if (refMethod != null) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(refMethod, args)); 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));
}
}
}
} }
} }
} }
@@ -184,7 +555,11 @@ public class CallGraphBuilder {
@Override @Override
public boolean visit(SuperMethodInvocation node) { public boolean visit(SuperMethodInvocation node) {
if (currentMethodFqn != null) { MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier(); String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node); TypeDeclaration td = findEnclosingType(node);
if (td != null) { if (td != null) {
@@ -203,6 +578,7 @@ public class CallGraphBuilder {
} }
} }
} }
}
return super.visit(node); return super.visit(node);
} }
}); });
@@ -246,10 +622,16 @@ public class CallGraphBuilder {
} }
// Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...)) // Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...))
expr = traceVariable(expr); Expression tracedExpr = traceVariable(expr);
if (expr instanceof ClassInstanceCreation cic) { if (tracedExpr instanceof QualifiedName || tracedExpr instanceof ClassInstanceCreation || tracedExpr instanceof StringLiteral || tracedExpr instanceof NumberLiteral) {
if (!cic.arguments().isEmpty()) { expr = tracedExpr; // Only accept trace if it resolves to a pure constant/primitive/constructor
expr = (Expression) cic.arguments().get(0); }
if (expr instanceof ClassInstanceCreation cic && !cic.arguments().isEmpty()) {
Expression firstArg = (Expression) cic.arguments().get(0);
String resolved = constantResolver.resolve(firstArg, context);
if (resolved != null) {
expr = firstArg; // Only unwrap if it's actually a constant
} }
} }
@@ -299,7 +681,18 @@ public class CallGraphBuilder {
return binding.getQualifiedName() + "." + methodName; return binding.getQualifiedName() + "." + methodName;
} }
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
}
if (receiver instanceof SimpleName sn) { if (receiver instanceof SimpleName sn) {
String fallbackTypeFqn = resolveReceiverTypeFallback(sn);
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
String receiverName = sn.getIdentifier(); String receiverName = sn.getIdentifier();
return receiverName + "." + methodName; return receiverName + "." + methodName;
} }
@@ -307,6 +700,85 @@ public class CallGraphBuilder {
return null; 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) { private String resolveMethodInType(TypeDeclaration td, String methodName) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) { if (md != null) {
@@ -336,6 +808,11 @@ public class CallGraphBuilder {
} }
} }
} }
if (log.isDebugEnabled()) {
log.debug("Path search dead-end at {} when looking for {}", start, target);
}
visited.remove(start); visited.remove(start);
return null; return null;
} }
@@ -384,11 +861,112 @@ public class CallGraphBuilder {
return expr; return expr;
} }
private MethodDeclaration findEnclosingMethod(ASTNode node) { private List<String> traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set<String> visited) {
ASTNode parent = node.getParent(); List<String> results = new ArrayList<>();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent(); // 1. Check Field Declarations
for (org.eclipse.jdt.core.dom.FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
org.eclipse.jdt.core.dom.VariableDeclarationFragment frag = (org.eclipse.jdt.core.dom.VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName) && frag.getInitializer() != null) {
Expression right = frag.getInitializer();
if (right instanceof MethodInvocation mi) {
String calledName = mi.getName().getIdentifier();
String targetClass = context.getFqn(td);
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
results.addAll(resolveMethodReturnConstant(targetClass, calledName, 0, new HashSet<>(visited), cu));
} else {
String val = constantResolver.resolve(right, context);
if (val != null) results.add(val);
} }
return (MethodDeclaration) parent;
} }
}
}
org.eclipse.jdt.core.dom.ASTVisitor assignmentVisitor = new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(Assignment node) {
Expression left = node.getLeftHandSide();
if ((left instanceof SimpleName && ((SimpleName) left).getIdentifier().equals(fieldName)) ||
(left instanceof FieldAccess && ((FieldAccess) left).getName().getIdentifier().equals(fieldName))) {
Expression right = node.getRightHandSide();
if (right instanceof MethodInvocation mi) {
String calledName = mi.getName().getIdentifier();
String targetClass = context.getFqn(td);
if (mi.getExpression() != null && mi.resolveMethodBinding() != null && mi.resolveMethodBinding().getDeclaringClass() != null) {
targetClass = mi.resolveMethodBinding().getDeclaringClass().getQualifiedName();
} else if (mi.getExpression() instanceof SimpleName) {
String exprName = ((SimpleName) mi.getExpression()).getIdentifier();
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
TypeDeclaration targetTd = context.resolveStaticImport(exprName, cu);
if (targetTd != null) {
targetClass = context.getFqn(targetTd);
}
}
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
results.addAll(resolveMethodReturnConstant(targetClass, calledName, 0, new HashSet<>(visited), cu));
} else {
String val = constantResolver.resolve(right, context);
if (val != null) results.add(val);
}
}
return super.visit(node);
}
@Override
public boolean visit(ConstructorInvocation node) {
for (Object argObj : node.arguments()) {
Expression arg = (Expression) argObj;
String val = constantResolver.resolve(arg, context);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
results.add(eVal.substring(eVal.lastIndexOf('.') + 1));
}
} else {
results.add(val);
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperConstructorInvocation node) {
for (Object argObj : node.arguments()) {
Expression arg = (Expression) argObj;
String val = constantResolver.resolve(arg, context);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
results.add(eVal.substring(eVal.lastIndexOf('.') + 1));
}
} else {
results.add(val);
}
}
}
return super.visit(node);
}
};
// 2. Check Constructors
for (MethodDeclaration md : td.getMethods()) {
if (md.isConstructor() && md.getBody() != null) {
md.getBody().accept(assignmentVisitor);
}
}
// 3. Check Initializers
for (Object bodyDecl : td.bodyDeclarations()) {
if (bodyDecl instanceof org.eclipse.jdt.core.dom.Initializer init && init.getBody() != null) {
init.getBody().accept(assignmentVisitor);
}
}
return results;
}
} }

View File

@@ -64,7 +64,18 @@ public class GenericEventDetector {
for (LibraryHint hint : hints) { for (LibraryHint hint : hints) {
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) { 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) { if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) { for (TriggerPoint trigger : builtTriggers) {
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent()); log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
@@ -291,7 +302,8 @@ public class GenericEventDetector {
} else if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) { } else if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) {
// If the event is obtained by calling a method on a provider/supplier parameter, // If the event is obtained by calling a method on a provider/supplier parameter,
// return the provider's name so CallGraphBuilder can trace the lambda argument // return the provider's name so CallGraphBuilder can trace the lambda argument
return sn.getIdentifier(); String traced = extractEventFromMessageBuilder(sn);
return traced != null ? traced : sn.getIdentifier();
} }
Expression receiver = current.getExpression(); Expression receiver = current.getExpression();

View File

@@ -24,6 +24,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
private final CallGraphBuilder callGraphBuilder; private final CallGraphBuilder callGraphBuilder;
private final LifecycleDetector lifecycleDetector; private final LifecycleDetector lifecycleDetector;
private final InterceptorDetector interceptorDetector; private final InterceptorDetector interceptorDetector;
private final SpringComponentDetector componentDetector;
public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) { public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) {
this.context = context; this.context = context;
@@ -34,6 +35,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
this.callGraphBuilder = new CallGraphBuilder(context); this.callGraphBuilder = new CallGraphBuilder(context);
this.lifecycleDetector = new LifecycleDetector(context); this.lifecycleDetector = new LifecycleDetector(context);
this.interceptorDetector = new InterceptorDetector(context); this.interceptorDetector = new InterceptorDetector(context);
this.componentDetector = new SpringComponentDetector(context);
} }
@Override @Override
@@ -58,6 +60,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
allEntryPoints.addAll(mvcDetector.detect(cu)); allEntryPoints.addAll(mvcDetector.detect(cu));
allEntryPoints.addAll(messagingDetector.detect(cu)); allEntryPoints.addAll(messagingDetector.detect(cu));
allEntryPoints.addAll(interceptorDetector.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()); log.info("Found {} entry points (including interceptors and listeners) in total", allEntryPoints.size());
return allEntryPoints; 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,10 +213,19 @@ public class CodebaseContext {
} }
// Inheritance Mapping // Inheritance Mapping
for (Object itf : td.superInterfaceTypes()) { // Track implementations (for both interfaces and superclasses)
String itfName = itf.toString(); 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); 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 // Recursively index nested types
for (Object type : td.getTypes()) { for (Object type : td.getTypes()) {
@@ -227,28 +236,38 @@ public class CodebaseContext {
} }
public List<String> getImplementations(String interfaceName) { 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 // Try direct match
List<String> impls = interfaceToImpls.get(interfaceName); List<String> directImpls = interfaceToImpls.get(typeName);
if (impls != null) return impls;
// Try FQN match if input was simple name // Try FQN match if input was simple name
String fqn = simpleNameToFqn.get(interfaceName); if (directImpls == null) {
if (fqn != null && interfaceToImpls.containsKey(fqn)) { String fqn = simpleNameToFqn.get(typeName);
return interfaceToImpls.get(fqn); if (fqn != null) {
directImpls = interfaceToImpls.get(fqn);
}
} }
// Try simple name match if input was FQN // Try simple name match if input was FQN
if (interfaceName.contains(".")) { if (directImpls == null && typeName.contains(".")) {
String simpleName = interfaceName.substring(interfaceName.lastIndexOf('.') + 1); String simpleName = typeName.substring(typeName.lastIndexOf('.') + 1);
List<String> simpleImpls = interfaceToImpls.get(simpleName); directImpls = 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;
}
} }
return Collections.emptyList(); 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) { 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") @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; 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 @Override
public Integer call() throws Exception { public Integer call() throws Exception {
var out = spec.commandLine().getOut(); var out = spec.commandLine().getOut();
var err = spec.commandLine().getErr(); 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) { if (inputDir == null && jsonFile == null) {
inputDir = Path.of("."); inputDir = Path.of(".");
} }

View File

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

View File

@@ -118,4 +118,264 @@ class TransitionLinkerEnricherTest {
CallChain updatedChain = result.getMetadata().getCallChains().get(0); CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).isNull(); 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())
.contextMachineId("testMachine")
.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 shouldNotMatchArbitraryGetXMethodWildcard() {
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.getId()").build())
.contextMachineId("testMachine")
.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()).isNull();
}
@Test
void shouldAllowWildcardWhenContextMachineIdIsMissing() {
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())
// NO contextMachineId or stateMachineId
.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 shouldNotFalsePositiveOnSubstringContains() {
// This test proves that the heuristic is entirely removed and false positives are eliminated
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"));
// Trigger point sends "PAYMENT" which contains "PAY". Under the old heuristics, this would falsely match!
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PAYMENT").build())
.build();
AnalysisResult result = AnalysisResult.builder()
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
// It should NOT match, because "PAYMENT" != "PAY" strictly.
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
}
@Test
void shouldFilterCrossStateMachineRoutingByVertical() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("ASSEMBLED", "ASSEMBLED")));
t1.setEvent(Event.of("ASSEMBLE", "ASSEMBLE"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of(
"com.example.electronics.ElectronicsOrderController.post()",
"com.example.electronics.ElectronicsOrderService.processOrderEvent()"
))
.build();
// Testing against Electronics SM - should match
AnalysisResult resultElectronics = AnalysisResult.builder()
.name("com.example.electronics.ElectronicsOwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(resultElectronics, null, null);
CallChain updatedChainElec = resultElectronics.getMetadata().getCallChains().get(0);
assertThat(updatedChainElec.getMatchedTransitions()).hasSize(1);
// Testing against ComputerStore SM - should NOT match because chain has Electronics service
AnalysisResult resultComputer = AnalysisResult.builder()
.name("com.example.computerstore.OwnDeliveryStateMachineConfiguration") // the non-electronics one
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(resultComputer, null, null);
CallChain updatedChainComp = resultComputer.getMetadata().getCallChains().get(0);
assertThat(updatedChainComp.getMatchedTransitions()).isNullOrEmpty();
}
@Test
void shouldAllowSharedServicesToRouteToMultipleStateMachines() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("PROCESSED", "PROCESSED")));
t1.setEvent(Event.of("PROCESS", "PROCESS"));
// Chain contains NO vertical prefix (e.g. CommonOrderService)
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PROCESS").build())
.methodChain(List.of(
"com.example.CommonOrderController.post()",
"com.example.CommonOrderService.processOrderEvent()"
))
.build();
AnalysisResult resultElectronics = AnalysisResult.builder()
.name("com.example.electronics.ElectronicsOwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(resultElectronics, null, null);
CallChain updatedChainElec = resultElectronics.getMetadata().getCallChains().get(0);
// Should match Electronics because it's a shared service (no computerstore service in path)
assertThat(updatedChainElec.getMatchedTransitions()).hasSize(1);
AnalysisResult resultComputer = AnalysisResult.builder()
.name("com.example.computerstore.OwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(resultComputer, null, null);
CallChain updatedChainComp = resultComputer.getMetadata().getCallChains().get(0);
// Should ALSO match ComputerStore because it's a shared service
assertThat(updatedChainComp.getMatchedTransitions()).hasSize(1);
}
@Test
void shouldFilterCrossStateMachineRoutingWithMultipleVerticals() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("ASSEMBLED", "ASSEMBLED")));
t1.setEvent(Event.of("ASSEMBLE", "ASSEMBLE"));
// Chain 1: Electronics
CallChain chainElec = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of("com.example.electronics.ElectronicsOrderController.post()"))
.build();
// Chain 2: Furniture
CallChain chainFurn = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of("com.example.furniture.FurnitureOrderController.post()"))
.build();
// Chain 3: Groceries
CallChain chainGroc = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of("com.example.groceries.GroceriesOrderController.post()"))
.build();
// SM 1: Electronics
AnalysisResult resElec = AnalysisResult.builder()
.name("com.example.electronics.ElectronicsOwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build())
.build();
// SM 2: Furniture
AnalysisResult resFurn = AnalysisResult.builder()
.name("com.example.furniture.FurnitureOwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build())
.build();
// SM 3: Groceries
AnalysisResult resGroc = AnalysisResult.builder()
.name("com.example.groceries.GroceriesStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build())
.build();
// SM 4: Computer Store (The catch-all base one in computerstore package)
AnalysisResult resComp = AnalysisResult.builder()
.name("com.example.computerstore.OwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build())
.build();
// Test Electronics SM - Should only keep Electronics Chain
enricher.enrich(resElec, null, null);
assertThat(resElec.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); // Elec
assertThat(resElec.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn
assertThat(resElec.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc
// Test Furniture SM - Should only keep Furniture Chain
enricher.enrich(resFurn, null, null);
assertThat(resFurn.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec
assertThat(resFurn.getMetadata().getCallChains().get(1).getMatchedTransitions()).hasSize(1); // Furn
assertThat(resFurn.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc
// Test Groceries SM - Should only keep Groceries Chain
enricher.enrich(resGroc, null, null);
assertThat(resGroc.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec
assertThat(resGroc.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn
assertThat(resGroc.getMetadata().getCallChains().get(2).getMatchedTransitions()).hasSize(1); // Groc
// Test ComputerStore SM - Should reject ALL because none belong to computerstore
enricher.enrich(resComp, null, null);
assertThat(resComp.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec
assertThat(resComp.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn
assertThat(resComp.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc
}
} }

View File

@@ -0,0 +1,103 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
@Tag("heuristic_bean_resolution")
public class HeuristicRoutingEngineTest {
private final HeuristicRoutingEngine engine = new HeuristicRoutingEngine();
@Test
public void testDeepPackageDivergenceMismatch() {
// This is the bug case: sharing a deep root, but diverging into different domains
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.corp.division.project.orders.OrderService.pay()"))
.build();
String machineName = "com.acme.corp.division.project.payments.PaymentStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName);
// They share com.acme.corp.division.project, but diverge into orders vs payments
// This should be a strong mismatch, so it should return false
assertFalse(result, "Deep package divergence into different domains should be rejected");
}
@Test
public void testSameDomainMatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.ecommerce.orders.OrderService.process()"))
.build();
String machineName = "com.acme.ecommerce.orders.OrderStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName);
// Same exact domain, should match
assertTrue(result, "Same domain should be accepted");
}
@Test
public void testSubPackageMatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.ecommerce.orders.impl.OrderServiceImpl.process()"))
.build();
String machineName = "com.acme.ecommerce.orders.OrderStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName);
// Subpackage should match
assertTrue(result, "Sub-packages of the same domain should be accepted");
}
@Test
public void testSharedDomainTermMatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.orders.web.OrderController.submit()"))
.build();
String machineName = "com.acme.orders.service.OrderStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName);
// Different subpackages, but they share the "order" domain term and common prefix
assertTrue(result, "Divergent packages that share a domain term should be accepted");
}
@Test
public void testExplicitTargetVariableMatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.common.GlobalTrigger.fire()"))
.contextMachineId("paymentStateMachine") // Explicitly targets payment
.build();
String machineName = "com.acme.corp.payments.PaymentStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName);
// Mismatched domains, but explicit variable targeting works
assertTrue(result, "Explicit variable target matching the machine name should be accepted");
}
@Test
public void testExplicitTargetVariableMismatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.common.GlobalTrigger.fire()"))
.contextMachineId("paymentStateMachine") // Explicitly targets payment
.build();
String machineName = "com.acme.corp.orders.OrderStateMachine"; // But this is Order
boolean result = engine.isRoutedToCorrectMachine(chain, machineName);
// Explicit variable targeting completely conflicts
assertFalse(result, "Explicit variable target mismatching the machine name should be rejected");
}
}

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

@@ -45,6 +45,35 @@ public class GenericEventDetectorTest {
assertThat(triggers.get(0).getMethodName()).isEqualTo("a"); assertThat(triggers.get(0).getMethodName()).isEqualTo("a");
} }
@Test
void testMessageBuilderIntermediateVariable(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("MyService.java"),
"package com.example;\n" +
"import org.springframework.messaging.support.MessageBuilder;\n" +
"import org.springframework.messaging.Message;\n" +
"import org.springframework.statemachine.StateMachine;\n" +
"public class MyService {\n" +
" private StateMachine<String, String> stateMachine;\n" +
" private void a(MyInterface myEvent) {\n" +
" MessageBuilder<MyInterface> messageBuilder = MessageBuilder.withPayload(myEvent).setHeader(\"k\", \"v\");\n" +
" stateMachine.sendEvent(messageBuilder.build());\n" +
" }\n" +
"}\n");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), List.of());
CompilationUnit cu = context.getCompilationUnits().iterator().next();
List<TriggerPoint> triggers = detector.detect(cu);
assertThat(triggers).hasSize(1);
assertThat(triggers.get(0).getEvent()).isEqualTo("myEvent");
assertThat(triggers.get(0).getMethodName()).isEqualTo("a");
}
@Test @Test
void testEnumGetterUnionExtraction(@TempDir Path tempDir) throws IOException { void testEnumGetterUnionExtraction(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example"); Path dir = tempDir.resolve("com/example");

View File

@@ -8,7 +8,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 17 "lineNumber" : 17,
"polymorphicEvents" : null
}, { }, {
"event" : "PLACE_ORDER", "event" : "PLACE_ORDER",
"className" : "click.kamil.examples.enterprise.service.OrderServiceImpl", "className" : "click.kamil.examples.enterprise.service.OrderServiceImpl",
@@ -17,7 +18,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 16 "lineNumber" : 16,
"polymorphicEvents" : null
}, { }, {
"event" : "CANCEL_ORDER", "event" : "CANCEL_ORDER",
"className" : "click.kamil.examples.enterprise.service.OrderServiceImpl", "className" : "click.kamil.examples.enterprise.service.OrderServiceImpl",
@@ -26,7 +28,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 21 "lineNumber" : 21,
"polymorphicEvents" : null
}, { }, {
"event" : "PAY_ORDER", "event" : "PAY_ORDER",
"className" : "click.kamil.examples.enterprise.service.ReactivePaymentService", "className" : "click.kamil.examples.enterprise.service.ReactivePaymentService",
@@ -35,7 +38,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 18 "lineNumber" : 18,
"polymorphicEvents" : null
}, { }, {
"event" : "SHIP_ORDER", "event" : "SHIP_ORDER",
"className" : "click.kamil.examples.enterprise.messaging.ShippingJmsListener", "className" : "click.kamil.examples.enterprise.messaging.ShippingJmsListener",
@@ -44,7 +48,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 17 "lineNumber" : 17,
"polymorphicEvents" : null
}, { }, {
"event" : "RETURN_ORDER", "event" : "RETURN_ORDER",
"className" : "click.kamil.examples.enterprise.messaging.ReturnsRabbitListener", "className" : "click.kamil.examples.enterprise.messaging.ReturnsRabbitListener",
@@ -53,7 +58,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 17 "lineNumber" : 17,
"polymorphicEvents" : null
} ], } ],
"entryPoints" : [ { "entryPoints" : [ {
"type" : "REST", "type" : "REST",
@@ -185,7 +191,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 16 "lineNumber" : 16,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -219,7 +226,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 21 "lineNumber" : 21,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -248,10 +256,11 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 17 "lineNumber" : 17,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ ] "matchedTransitions" : null
}, { }, {
"entryPoint" : { "entryPoint" : {
"type" : "REST", "type" : "REST",
@@ -278,7 +287,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 18 "lineNumber" : 18,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -312,7 +322,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 17 "lineNumber" : 17,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -346,7 +357,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 17 "lineNumber" : 17,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {

View File

@@ -8,7 +8,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 34 "lineNumber" : 34,
"polymorphicEvents" : null
}, { }, {
"event" : "AUDIT_EVENT", "event" : "AUDIT_EVENT",
"className" : "click.kamil.examples.statemachine.extended.web.AuditInterceptor", "className" : "click.kamil.examples.statemachine.extended.web.AuditInterceptor",
@@ -17,7 +18,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 16 "lineNumber" : 16,
"polymorphicEvents" : null
}, { }, {
"event" : "EXTERNAL_TRIGGER", "event" : "EXTERNAL_TRIGGER",
"className" : "click.kamil.examples.statemachine.extended.service.OrderService", "className" : "click.kamil.examples.statemachine.extended.service.OrderService",
@@ -26,7 +28,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 19 "lineNumber" : 19,
"polymorphicEvents" : null
}, { }, {
"event" : "SUBMIT_EVENT", "event" : "SUBMIT_EVENT",
"className" : "click.kamil.examples.statemachine.extended.service.OrderService", "className" : "click.kamil.examples.statemachine.extended.service.OrderService",
@@ -35,7 +38,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 20 "lineNumber" : 20,
"polymorphicEvents" : null
}, { }, {
"event" : "CANCEL_EVENT", "event" : "CANCEL_EVENT",
"className" : "click.kamil.examples.statemachine.extended.service.OrderService", "className" : "click.kamil.examples.statemachine.extended.service.OrderService",
@@ -44,7 +48,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25 "lineNumber" : 25,
"polymorphicEvents" : null
}, { }, {
"event" : "[LIFECYCLE:RESTORE]", "event" : "[LIFECYCLE:RESTORE]",
"className" : "click.kamil.examples.statemachine.extended.service.OrderService", "className" : "click.kamil.examples.statemachine.extended.service.OrderService",
@@ -53,7 +58,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 29 "lineNumber" : 29,
"polymorphicEvents" : null
}, { }, {
"event" : "REACTIVE_EVENT", "event" : "REACTIVE_EVENT",
"className" : "click.kamil.examples.statemachine.extended.service.ReactiveOrderService", "className" : "click.kamil.examples.statemachine.extended.service.ReactiveOrderService",
@@ -62,7 +68,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 18 "lineNumber" : 18,
"polymorphicEvents" : null
} ], } ],
"entryPoints" : [ { "entryPoints" : [ {
"type" : "REST", "type" : "REST",
@@ -160,7 +167,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 19 "lineNumber" : 19,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -190,7 +198,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 20 "lineNumber" : 20,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -220,7 +229,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25 "lineNumber" : 25,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -250,7 +260,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 34 "lineNumber" : 34,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -284,10 +295,11 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 29 "lineNumber" : 29,
"polymorphicEvents" : null
}, },
"contextMachineId" : "orderId", "contextMachineId" : "orderId",
"matchedTransitions" : [ ] "matchedTransitions" : null
}, { }, {
"entryPoint" : { "entryPoint" : {
"type" : "REST", "type" : "REST",
@@ -314,7 +326,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 18 "lineNumber" : 18,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -343,7 +356,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 16 "lineNumber" : 16,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {

View File

@@ -8,7 +8,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 34 "lineNumber" : 34,
"polymorphicEvents" : null
}, { }, {
"event" : "AUDIT_EVENT", "event" : "AUDIT_EVENT",
"className" : "click.kamil.examples.statemachine.extended.web.AuditInterceptor", "className" : "click.kamil.examples.statemachine.extended.web.AuditInterceptor",
@@ -17,7 +18,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 16 "lineNumber" : 16,
"polymorphicEvents" : null
}, { }, {
"event" : "EXTERNAL_TRIGGER", "event" : "EXTERNAL_TRIGGER",
"className" : "click.kamil.examples.statemachine.extended.service.OrderService", "className" : "click.kamil.examples.statemachine.extended.service.OrderService",
@@ -26,7 +28,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 19 "lineNumber" : 19,
"polymorphicEvents" : null
}, { }, {
"event" : "SUBMIT_EVENT", "event" : "SUBMIT_EVENT",
"className" : "click.kamil.examples.statemachine.extended.service.OrderService", "className" : "click.kamil.examples.statemachine.extended.service.OrderService",
@@ -35,7 +38,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 20 "lineNumber" : 20,
"polymorphicEvents" : null
}, { }, {
"event" : "CANCEL_EVENT", "event" : "CANCEL_EVENT",
"className" : "click.kamil.examples.statemachine.extended.service.OrderService", "className" : "click.kamil.examples.statemachine.extended.service.OrderService",
@@ -44,7 +48,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25 "lineNumber" : 25,
"polymorphicEvents" : null
}, { }, {
"event" : "[LIFECYCLE:RESTORE]", "event" : "[LIFECYCLE:RESTORE]",
"className" : "click.kamil.examples.statemachine.extended.service.OrderService", "className" : "click.kamil.examples.statemachine.extended.service.OrderService",
@@ -53,7 +58,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 29 "lineNumber" : 29,
"polymorphicEvents" : null
}, { }, {
"event" : "REACTIVE_EVENT", "event" : "REACTIVE_EVENT",
"className" : "click.kamil.examples.statemachine.extended.service.ReactiveOrderService", "className" : "click.kamil.examples.statemachine.extended.service.ReactiveOrderService",
@@ -62,7 +68,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 18 "lineNumber" : 18,
"polymorphicEvents" : null
} ], } ],
"entryPoints" : [ { "entryPoints" : [ {
"type" : "REST", "type" : "REST",
@@ -160,7 +167,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 19 "lineNumber" : 19,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -190,7 +198,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 20 "lineNumber" : 20,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -220,7 +229,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25 "lineNumber" : 25,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -250,7 +260,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 34 "lineNumber" : 34,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -284,10 +295,11 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 29 "lineNumber" : 29,
"polymorphicEvents" : null
}, },
"contextMachineId" : "orderId", "contextMachineId" : "orderId",
"matchedTransitions" : [ ] "matchedTransitions" : null
}, { }, {
"entryPoint" : { "entryPoint" : {
"type" : "REST", "type" : "REST",
@@ -314,7 +326,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 18 "lineNumber" : 18,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -343,7 +356,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 16 "lineNumber" : 16,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {

View File

@@ -8,7 +8,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 15 "lineNumber" : 15,
"polymorphicEvents" : null
} ], } ],
"entryPoints" : [ { "entryPoints" : [ {
"type" : "REST", "type" : "REST",
@@ -55,7 +56,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 15 "lineNumber" : 15,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {

View File

@@ -8,7 +8,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 40 "lineNumber" : 40,
"polymorphicEvents" : null
}, { }, {
"event" : "ORDER_EVENT", "event" : "ORDER_EVENT",
"className" : "click.kamil.maven.core.MavenOrderStateMachine.OrderService", "className" : "click.kamil.maven.core.MavenOrderStateMachine.OrderService",
@@ -17,7 +18,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 52 "lineNumber" : 52,
"polymorphicEvents" : null
} ], } ],
"entryPoints" : [ { "entryPoints" : [ {
"type" : "REST", "type" : "REST",
@@ -103,7 +105,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 40 "lineNumber" : 40,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {

View File

@@ -8,7 +8,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 18 "lineNumber" : 18,
"polymorphicEvents" : null
} ], } ],
"entryPoints" : [ { "entryPoints" : [ {
"type" : "REST", "type" : "REST",
@@ -67,7 +68,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 18 "lineNumber" : 18,
"polymorphicEvents" : null
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {

View File

@@ -8,7 +8,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25 "lineNumber" : 25,
"polymorphicEvents" : null
}, { }, {
"event" : "eventProvider", "event" : "eventProvider",
"className" : "click.kamil.service.StateMachineServiceImpl", "className" : "click.kamil.service.StateMachineServiceImpl",
@@ -17,7 +18,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 50 "lineNumber" : 50,
"polymorphicEvents" : null
}, { }, {
"event" : "customMessage", "event" : "customMessage",
"className" : "click.kamil.service.StateMachineServiceImpl", "className" : "click.kamil.service.StateMachineServiceImpl",
@@ -26,7 +28,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 78 "lineNumber" : 78,
"polymorphicEvents" : null
} ], } ],
"entryPoints" : [ { "entryPoints" : [ {
"type" : "REST", "type" : "REST",
@@ -140,7 +143,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25 "lineNumber" : 25,
"polymorphicEvents" : [ ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -170,7 +174,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25 "lineNumber" : 25,
"polymorphicEvents" : [ ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -200,7 +205,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25 "lineNumber" : 25,
"polymorphicEvents" : [ ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -234,7 +240,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25 "lineNumber" : 25,
"polymorphicEvents" : [ ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -268,7 +275,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 78 "lineNumber" : 78,
"polymorphicEvents" : [ ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -302,7 +310,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25 "lineNumber" : 25,
"polymorphicEvents" : [ ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -336,7 +345,8 @@
"sourceModule" : null, "sourceModule" : null,
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 50 "lineNumber" : 50,
"polymorphicEvents" : [ ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {

View File

@@ -13,6 +13,14 @@ import java.util.List;
public class Main { public class Main {
public static void main(String[] args) { 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;
}
}
// Wiring including the specialized HTML exporter // Wiring including the specialized HTML exporter
var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter(), new HtmlExporter()); var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter(), new HtmlExporter());
var exportService = new ExportService(exporters); var exportService = new ExportService(exporters);

View File

@@ -59,11 +59,19 @@ public class HtmlExporterCommand implements Callable<Integer> {
@Option(names = {"--no-metadata-pane"}, description = "Disable rendering the left metadata pane (entry points, flows) in the HTML viewer.") @Option(names = {"--no-metadata-pane"}, description = "Disable rendering the left metadata pane (entry points, flows) in the HTML viewer.")
private boolean noMetadataPane; private boolean noMetadataPane;
@Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false")
private boolean debug;
@Override @Override
public Integer call() throws Exception { public Integer call() throws Exception {
var out = spec.commandLine().getOut(); var out = spec.commandLine().getOut();
var err = spec.commandLine().getErr(); 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) { if (inputDir == null && jsonFile == null) {
inputDir = Path.of("."); inputDir = Path.of(".");
} }

View File

@@ -238,10 +238,27 @@
.tooltip-content { .tooltip-content {
padding: 20px; padding: 20px;
max-width: 500px; max-width: 500px;
max-height: 60vh;
overflow-y: auto;
word-break: break-word; word-break: break-word;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/* Custom Scrollbar for Tooltips */
.tooltip-content::-webkit-scrollbar {
width: 6px;
}
.tooltip-content::-webkit-scrollbar-track {
background: transparent;
}
.tooltip-content::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.tooltip-content::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.payload-box { .payload-box {
background: #0f172a; background: #0f172a;
color: #e2e8f0; color: #e2e8f0;

View File

@@ -81,13 +81,6 @@ public class HtmlExporterTest {
// 7. Assertions - Surgical SVG Identification // 7. Assertions - Surgical SVG Identification
// Every event should be wrapped in an identifying hyperlink for robust JS selection // Every event should be wrapped in an identifying hyperlink for robust JS selection
java.nio.file.Files.writeString(java.nio.file.Paths.get("debug_html.html"), html);
click.kamil.springstatemachineexporter.exporter.ExportOptions plantUmlOptions = click.kamil.springstatemachineexporter.exporter.ExportOptions.builder()
.embedIdentifiers(true)
.build();
click.kamil.springstatemachineexporter.exporter.PlantUml pumlExporter = new click.kamil.springstatemachineexporter.exporter.PlantUml();
java.nio.file.Files.writeString(java.nio.file.Paths.get("debug_puml.puml"), pumlExporter.export(result, plantUmlOptions));
assertThat(html).contains("__CREATE"); assertThat(html).contains("__CREATE");
assertThat(html).contains("__ASSIGN"); assertThat(html).contains("__ASSIGN");