From db158200dd31e6686dc0449a862bc883d55bda3d Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Sun, 12 Jul 2026 05:53:17 +0200 Subject: [PATCH] Prune call-graph paths using switch constraints during search. Propagate path bindings while exploring CallEdge constraints in CallGraphPathFinder so impossible dispatch arms are skipped before chain assembly, with inheritance fallback preserved for polymorphic resolution. Co-authored-by: Cursor --- .../service/AbstractCallGraphEngine.java | 4 +- .../analysis/service/CallGraphPathFinder.java | 130 ++++++++++++++++++ .../service/PathBindingEvaluator.java | 25 ++++ .../service/CallGraphPathFinderTest.java | 36 +++++ .../service/PathBindingEvaluatorTest.java | 14 +- 5 files changed, 206 insertions(+), 3 deletions(-) diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index dc2bdb2..63bf389 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -120,7 +120,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String targetMethod = tp.getClassName() + "." + tp.getMethodName(); List> allPaths = new ArrayList<>(); for (String sMethod : startMethods) { - allPaths.addAll(pathFinder.findAllPaths(sMethod, targetMethod, callGraph, new HashSet<>())); + allPaths.addAll(pathFinder.findAllPaths( + sMethod, targetMethod, callGraph, new HashSet<>(), + pathBindingEvaluator, initialBindings)); } Set> uniquePaths = new LinkedHashSet<>(allPaths); for (List path : uniquePaths) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java index 288969e..77587e5 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java @@ -56,6 +56,21 @@ public class CallGraphPathFinder { return findAllPaths(start, target, graph, visited, new boolean[]{false}); } + public List> findAllPaths( + String start, + String target, + Map> graph, + Set visited, + PathBindingEvaluator bindingEvaluator, + Map initialBindings) { + if (bindingEvaluator == null) { + return findAllPaths(start, target, graph, visited); + } + Map bindings = initialBindings == null ? new HashMap<>() : new HashMap<>(initialBindings); + return findAllPathsConstrained( + start, target, graph, visited, new boolean[]{false}, bindingEvaluator, bindings); + } + public List> findAllPaths(String start, String target, Map> graph, Set visited, boolean[] parentHitCycle) { List> result = new ArrayList<>(); java.util.Map.Entry cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(start, target); @@ -175,6 +190,121 @@ public class CallGraphPathFinder { return result; } + private List> findAllPathsConstrained( + String start, + String target, + Map> graph, + Set visited, + boolean[] parentHitCycle, + PathBindingEvaluator bindingEvaluator, + Map bindings) { + List> result = new ArrayList<>(); + if (start.equals(target)) { + result.add(new ArrayList<>(List.of(start))); + return result; + } + if (!visited.add(start)) { + parentHitCycle[0] = true; + return result; + } + + List neighbors = graph.get(start); + if (neighbors != null) { + for (CallEdge edge : neighbors) { + String neighbor = edge.getTargetMethod(); + if (neighbor.startsWith("java.") || neighbor.startsWith("javax.") || neighbor.startsWith("jakarta.") || neighbor.startsWith("org.springframework.")) { + continue; + } + + String resolvedTarget = neighbor.equals(target) || isHeuristicMatch(neighbor, target) ? target : neighbor; + List hopPath = new ArrayList<>(List.of(start, resolvedTarget)); + Map branchBindings = new HashMap<>(bindings); + if (!bindingEvaluator.tryTraverseEdge( + start, resolvedTarget, edge, hopPath, 1, graph, this, branchBindings)) { + continue; + } + + if (resolvedTarget.equals(target)) { + result.add(hopPath); + } else { + List> subPaths = findAllPathsConstrained( + neighbor, target, graph, visited, parentHitCycle, bindingEvaluator, branchBindings); + for (List subPath : subPaths) { + if (subPath.isEmpty() || subPath.get(0).equals(start)) { + continue; + } + List path = new ArrayList<>(); + path.add(start); + path.addAll(subPath); + result.add(path); + } + } + } + } + + if (result.isEmpty() && start.contains(".")) { + result.addAll(findInheritanceFallbackPaths( + start, target, graph, visited, parentHitCycle, bindingEvaluator, bindings)); + } + + visited.remove(start); + return result; + } + + private List> findInheritanceFallbackPaths( + String start, + String target, + Map> graph, + Set visited, + boolean[] parentHitCycle, + PathBindingEvaluator bindingEvaluator, + Map bindings) { + List> result = new ArrayList<>(); + String className = start.substring(0, start.lastIndexOf('.')); + String methodName = start.substring(start.lastIndexOf('.') + 1); + if (className.contains("<")) { + className = className.substring(0, className.indexOf('<')); + } + + TypeDeclaration td = context.getTypeDeclaration(className); + if (td != null) { + String superclass = context.getSuperclassFqn(td); + if (superclass != null) { + String superMethod = superclass + "." + methodName; + if (graph.containsKey(superMethod)) { + List> superPaths = findAllPathsConstrained( + superMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings); + for (List superPath : superPaths) { + List path = new ArrayList<>(); + path.add(start); + path.addAll(superPath); + result.add(path); + } + } + } + } + + if (result.isEmpty()) { + List impls = context.getImplementations(className); + if (impls != null) { + for (String impl : impls) { + String implMethod = impl + "." + methodName; + if (graph.containsKey(implMethod)) { + List> implPaths = findAllPathsConstrained( + implMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings); + for (List implPath : implPaths) { + List path = new ArrayList<>(); + path.add(start); + path.addAll(implPath); + result.add(path); + } + } + } + } + } + return result; + } + public String extractContextMachineId(List path, Map> callGraph) { for (String node : path) { List edges = callGraph.get(node); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java index 6c29533..3895673 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java @@ -83,6 +83,31 @@ public class PathBindingEvaluator { return isPathCompatible(path, callGraph, pathFinder, Map.of()); } + /** + * Returns {@code true} when {@code caller}→{@code callee} via {@code edge} satisfies the edge constraint + * under {@code bindings}; on success, {@code bindings} is updated with propagated parameter values. + */ + public boolean tryTraverseEdge( + String caller, + String callee, + CallEdge edge, + List pathWithCallee, + int calleeIndex, + Map> callGraph, + CallGraphPathFinder pathFinder, + Map bindings) { + if (edge.getConstraint() != null + && !edge.getConstraint().isBlank() + && !BooleanConstraintEvaluator.isCompatibleWithBindings(edge.getConstraint(), bindings)) { + return false; + } + if (variableTracer == null || typeResolver == null) { + return true; + } + enrichBindings(caller, callee, edge, pathWithCallee, calleeIndex, callGraph, pathFinder, bindings); + return true; + } + private void enrichBindings( String caller, String target, diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinderTest.java index c631ffa..742ca4f 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinderTest.java @@ -78,4 +78,40 @@ class CallGraphPathFinderTest { "com.acme.End.finish" ); } + + @Test + void shouldPruneBranchesDuringSearchWhenConstraintsPresent() { + Map> graph = new HashMap<>(); + graph.put("com.acme.Start.run", List.of( + new CallEdge("com.acme.BranchA.step", List.of(), null, "command == \"PAY\""), + new CallEdge("com.acme.BranchB.step", List.of(), null, "command == \"SHIP\"") + )); + graph.put("com.acme.BranchA.step", List.of( + new CallEdge("com.acme.End.finish", List.of()) + )); + graph.put("com.acme.BranchB.step", List.of( + new CallEdge("com.acme.End.finish", List.of()) + )); + + CallGraphPathFinder pathFinder = new CallGraphPathFinder(new CodebaseContext()); + PathBindingEvaluator evaluator = new PathBindingEvaluator( + new CodebaseContext(), null, null, null); + + List> unconstrained = pathFinder.findAllPaths( + "com.acme.Start.run", "com.acme.End.finish", graph, new HashSet<>()); + assertThat(unconstrained).hasSize(2); + + List> payOnly = pathFinder.findAllPaths( + "com.acme.Start.run", + "com.acme.End.finish", + graph, + new HashSet<>(), + evaluator, + Map.of("command", "PAY")); + assertThat(payOnly).hasSize(1); + assertThat(payOnly.get(0)).containsExactly( + "com.acme.Start.run", + "com.acme.BranchA.step", + "com.acme.End.finish"); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluatorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluatorTest.java index 69edea5..621a187 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluatorTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluatorTest.java @@ -65,6 +65,9 @@ class PathBindingEvaluatorTest { Map> graph = engine.buildCallGraph(); CallGraphPathFinder pathFinder = new CallGraphPathFinder(context); + PathBindingEvaluator evaluator = new PathBindingEvaluator( + context, engine.variableTracer, context.getConstantResolver(), engine.typeResolver); + List> rawPaths = pathFinder.findAllPaths( "com.example.ApiController.pay", "com.example.CentralDispatcher.fire", @@ -72,8 +75,15 @@ class PathBindingEvaluatorTest { new java.util.HashSet<>()); assertThat(rawPaths).hasSize(2); - PathBindingEvaluator evaluator = new PathBindingEvaluator( - context, engine.variableTracer, context.getConstantResolver(), engine.typeResolver); + List> constrainedPaths = pathFinder.findAllPaths( + "com.example.ApiController.pay", + "com.example.CentralDispatcher.fire", + graph, + new java.util.HashSet<>(), + evaluator, + Map.of()); + assertThat(constrainedPaths).hasSize(1); + assertThat(constrainedPaths.get(0)).contains("com.example.CentralDispatcher.orderPay"); List dispatchEdges = graph.get("com.example.CentralDispatcher.dispatch"); assertThat(dispatchEdges.stream().map(CallEdge::getConstraint).filter(c -> c != null && !c.isBlank()))