diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java index 62d0a20..9121d8e 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java @@ -268,7 +268,16 @@ public class ConstantResolver { if (fragment.getInitializer() != null) { String rhsVal = resolveExpressionWithParams(fragment.getInitializer(), localVars, context); if (rhsVal == null) rhsVal = resolveInternal(fragment.getInitializer(), context, visited); - if (rhsVal != null) localVars.put(varName, rhsVal); + if (rhsVal != null) { + localVars.put(varName, rhsVal); + } else { + java.util.Map bFields = extractBuilderFields(fragment.getInitializer(), localVars, context, visited); + if (bFields != null) { + for (java.util.Map.Entry e : bFields.entrySet()) { + localVars.put(varName + "." + e.getKey(), e.getValue()); + } + } + } } } } @@ -321,9 +330,19 @@ public class ConstantResolver { } else if (lhs instanceof FieldAccess fa) { varName = "this." + fa.getName().getIdentifier(); } - String rhsVal = resolveExpressionWithParams(assignment.getRightHandSide(), localVars, context); - if (rhsVal == null) rhsVal = resolveInternal(assignment.getRightHandSide(), context, visited); - if (varName != null && rhsVal != null) { + if (varName != null) { + String rhsVal = resolveExpressionWithParams(assignment.getRightHandSide(), localVars, context); + if (rhsVal == null) rhsVal = resolveInternal(assignment.getRightHandSide(), context, visited); + if (rhsVal != null) { + localVars.put(varName, rhsVal); + } else { + java.util.Map bFields = extractBuilderFields(assignment.getRightHandSide(), localVars, context, visited); + if (bFields != null) { + for (java.util.Map.Entry e : bFields.entrySet()) { + localVars.put(varName + "." + e.getKey(), e.getValue()); + } + } + } if (varName.startsWith("this.")) { localVars.put(varName, rhsVal); String bareName = varName.substring(5); @@ -577,6 +596,22 @@ public class ConstantResolver { } } } else { + if (mi.arguments().isEmpty() && mi.getExpression() instanceof SimpleName sn) { + String objName = sn.getIdentifier(); + String methodName = mi.getName().getIdentifier(); + String propName = null; + if (methodName.startsWith("get") && methodName.length() > 3) { + propName = methodName.substring(3, 4).toLowerCase() + methodName.substring(4); + } else { + propName = methodName; + } + String val = paramValues.get(objName + "." + propName); + if (val == null && !propName.equals(methodName)) { + val = paramValues.get(objName + "." + methodName); + } + if (val != null) return val; + } + MethodDeclaration sideEffectMd = findInvokedMethod(mi, context); if (sideEffectMd != null && sideEffectMd.getBody() != null) { TypeDeclaration currentTd = findEnclosingType(sideEffectMd); @@ -613,6 +648,29 @@ public class ConstantResolver { return null; } + private java.util.Map extractBuilderFields(Expression expr, java.util.Map localVars, CodebaseContext context, Set visited) { + if (!(expr instanceof MethodInvocation mi)) return null; + java.util.Map fields = new java.util.HashMap<>(); + MethodInvocation current = mi; + while (current != null) { + String methodName = current.getName().getIdentifier(); + if (!"build".equals(methodName) && !"builder".equals(methodName) && !"toBuilder".equals(methodName)) { + if (current.arguments().size() == 1) { + Expression arg = (Expression) current.arguments().get(0); + String val = resolveExpressionWithParams(arg, localVars, context); + if (val == null) val = resolveInternal(arg, context, visited); + if (val != null) fields.put(methodName, val); + } + } + if (current.getExpression() instanceof MethodInvocation next) { + current = next; + } else { + break; + } + } + return fields.isEmpty() ? null : fields; + } + private String resolveInfix(InfixExpression expr, CodebaseContext context, Set visited) { if (expr.getOperator() != InfixExpression.Operator.PLUS) return null; diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/BuilderLocalVariableTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/BuilderLocalVariableTest.java index 36ad247..7bb3cf3 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/BuilderLocalVariableTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/BuilderLocalVariableTest.java @@ -66,4 +66,59 @@ class BuilderLocalVariableTest { assertThat(chain.getTriggerPoint().getPolymorphicEvents()) .containsExactlyInAnyOrder("OrderEvents.PAY"); } + + @Test + void shouldTraceFluentBuilder(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class MyController { + private MyService service; + public void processEvent() { + MyEvent event = MyEvent.builder().type(MyEnum.STATE_2).build(); + service.process(event.getType()); + } + } + + class MyService { + public void process(MyEnum event) {} + } + + class MyEvent { + private MyEnum type; + public static Builder builder() { return new Builder(); } + public MyEnum getType() { return type; } + public static class Builder { + private MyEnum type; + public Builder type(MyEnum type) { this.type = type; return this; } + public MyEvent build() { return new MyEvent(); } + } + } + + enum MyEnum { STATE_1, STATE_2 } + """; + Files.writeString(tempDir.resolve("MyConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.MyController") + .methodName("processEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.MyService") + .methodName("process") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrder("MyEnum.STATE_2"); + } } diff --git a/state_machine_exporter_html/src/main/resources/html/template.html b/state_machine_exporter_html/src/main/resources/html/template.html index 6b17562..d227765 100644 --- a/state_machine_exporter_html/src/main/resources/html/template.html +++ b/state_machine_exporter_html/src/main/resources/html/template.html @@ -217,6 +217,22 @@ stroke-width: 3.5px; } + .active-path-ambiguous { + stroke: #f97316 !important; + stroke-width: 2.5px !important; + stroke-dasharray: 6, 4; + filter: drop-shadow(0 0 8px rgba(249, 115, 22, 0.4)); + transition: all 0.3s; + } + + .active-path-ambiguous text, a.active-path-ambiguous text { + fill: #000 !important; + font-weight: 900 !important; + paint-order: stroke; + stroke: #fff; + stroke-width: 3.5px; + } + .dimmed { opacity: 0.2 !important; filter: grayscale(1); @@ -418,7 +434,7 @@ if (c.entryPoint.className === ep.className && c.entryPoint.methodName === ep.methodName) { if (c.matchedTransitions && c.matchedTransitions.length > 0) { c.matchedTransitions.forEach(t => { - steps.push({ event: t.event, source: t.sourceState }); + steps.push({ event: t.event, source: t.sourceState, ambiguous: c.triggerPoint && c.triggerPoint.ambiguous }); }); } } @@ -446,10 +462,12 @@ let targetEvent = ""; let targetSource = ""; let targetAnon = ""; + let isAmbiguous = false; if (typeof step === 'object') { targetEvent = normalize(step.event); targetSource = normalize(step.source); + isAmbiguous = step.ambiguous; } else if (step.includes("->")) { const parts = step.split("->"); targetAnon = "#link_anon_" + normalize(parts[0]) + "_" + normalize(parts[1]); @@ -475,7 +493,7 @@ } if (matches) { - highlightLinkGroup(link); + highlightLinkGroup(link, isAmbiguous); } }); }); @@ -494,8 +512,9 @@ }); } - function highlightLinkGroup(link) { - link.classList.add('active-path'); + function highlightLinkGroup(link, isAmbiguous) { + const activeClass = isAmbiguous ? 'active-path-ambiguous' : 'active-path'; + link.classList.add(activeClass); link.querySelectorAll('*').forEach(child => child.classList.remove('dimmed')); // Find path and polygon immediately before the tag (interleaved) @@ -505,11 +524,11 @@ while (prev) { if (prev.tagName === 'path' && !foundPath) { prev.classList.remove('dimmed'); - prev.classList.add('active-path'); + prev.classList.add(activeClass); foundPath = true; } else if (prev.tagName === 'polygon' && !foundPolygon && prev.points?.numberOfItems !== 4) { prev.classList.remove('dimmed'); - prev.classList.add('active-path'); + prev.classList.add(activeClass); foundPolygon = true; } else if (['rect', 'circle', 'ellipse'].includes(prev.tagName) || (prev.tagName === 'polygon' && prev.points?.numberOfItems === 4)) { @@ -522,8 +541,8 @@ } function clearHighlights() { - document.querySelectorAll('.dimmed, .active-path').forEach(el => { - el.classList.remove('dimmed', 'active-path'); + document.querySelectorAll('.dimmed, .active-path, .active-path-ambiguous').forEach(el => { + el.classList.remove('dimmed', 'active-path', 'active-path-ambiguous'); }); } @@ -629,9 +648,11 @@ html += `
Triggered by
`; chains.forEach(c => { const triggerInfo = c.triggerPoint.sourceFile ? ` (${c.triggerPoint.sourceFile}:${c.triggerPoint.lineNumber})` : ''; + const ambiguousLabel = c.triggerPoint.ambiguous ? `
⚠️ Ambiguous Resolution (Over-approximated)
` : ''; html += `
${c.entryPoint.name}
Trigger: ${c.triggerPoint.methodName}${triggerInfo}
+ ${ambiguousLabel} ${renderParameters(c.entryPoint.parameters)}
${c.methodChain.map((m, i) => `
${i+1} ${m}
`).join('')}