From 80b784da299d7279f87fa1db7b0d4969ceec32bb Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Tue, 14 Jul 2026 18:21:04 +0200 Subject: [PATCH] Resolve nested boolean predicate helpers for path binding. Recursively peel predicate chains like matchesOrder -> isOrderKey(normalize(raw)) when extracting if-chain literals and inlining branch constraints, with regression tests for static-import, multi-module, and nested forms. Co-authored-by: Cursor --- .../service/EntryPointBindingExpander.java | 117 ++++++++++-- .../EntryPointBindingExpanderTest.java | 173 ++++++++++++++++++ .../MultiModulePathBindingExpanderTest.java | 83 +++++++++ 3 files changed, 357 insertions(+), 16 deletions(-) diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java index 4d8156a..45da3e4 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java @@ -1033,13 +1033,18 @@ public final class EntryPointBindingExpander { if (helper == null || !isBooleanReturnType(helper)) { return null; } - return extractSingleEqualsLiteralFromBooleanHelper(helper, context); + return extractBooleanPredicateLiteral(helper, context, new LinkedHashSet<>()); } - private static String extractSingleEqualsLiteralFromBooleanHelper( + private static String extractBooleanPredicateLiteral( MethodDeclaration helper, - CodebaseContext context) { - if (helper.getBody() == null) { + CodebaseContext context, + Set visiting) { + if (helper == null || helper.getBody() == null || !isBooleanReturnType(helper)) { + return null; + } + String helperFqn = methodFqnFromNode(helper, context); + if (helperFqn != null && !visiting.add(helperFqn)) { return null; } String helperParam = singleParameterName(helper); @@ -1047,29 +1052,62 @@ public final class EntryPointBindingExpander { return null; } final int[] returnCount = {0}; - final int[] equalsReturnCount = {0}; + final int[] resolvedCount = {0}; LinkedHashSet literals = new LinkedHashSet<>(); helper.getBody().accept(new ASTVisitor() { @Override public boolean visit(ReturnStatement node) { returnCount[0]++; if (node.getExpression() != null) { - String literal = extractEqualsLiteralForParameter( - node.getExpression(), helperParam, helper, context); + String literal = resolveBooleanPredicateLiteralFromReturn( + node.getExpression(), helperParam, helper, context, visiting); if (literal != null) { - equalsReturnCount[0]++; + resolvedCount[0]++; literals.add(literal); } } return super.visit(node); } }); - if (returnCount[0] == 0 || returnCount[0] != equalsReturnCount[0] || literals.size() != 1) { + if (returnCount[0] == 0 || returnCount[0] != resolvedCount[0] || literals.size() != 1) { return null; } return literals.iterator().next(); } + private static String resolveBooleanPredicateLiteralFromReturn( + Expression expression, + String bindingParam, + MethodDeclaration ownerMethod, + CodebaseContext context, + Set visiting) { + String direct = extractEqualsLiteralForParameter(expression, bindingParam, ownerMethod, context); + if (direct != null) { + return direct; + } + if (!(expression instanceof MethodInvocation invocation) || ownerMethod == null || context == null) { + return null; + } + if (invocation.arguments().size() != 1) { + return null; + } + Expression forwarded = (Expression) invocation.arguments().get(0); + if (!parameterExpressionReferencesBinding(forwarded, bindingParam, ownerMethod, context)) { + return null; + } + MethodDeclaration nested = findHelperMethod(ownerMethod, invocation, context); + if (nested == null || !isBooleanReturnType(nested)) { + return null; + } + return extractBooleanPredicateLiteral(nested, context, visiting); + } + + private static String extractSingleEqualsLiteralFromBooleanHelper( + MethodDeclaration helper, + CodebaseContext context) { + return extractBooleanPredicateLiteral(helper, context, new LinkedHashSet<>()); + } + static String inlineBooleanPredicateConstraint( String constraint, String callerMethodFqn, @@ -1117,29 +1155,76 @@ public final class EntryPointBindingExpander { if (helper == null) { return null; } + if (extractBooleanPredicateLiteral(helper, context, new LinkedHashSet<>()) == null) { + return null; + } + return inlineBooleanPredicateExpression(helper, argumentName, caller, context, new LinkedHashSet<>()); + } + + private static String inlineBooleanPredicateExpression( + MethodDeclaration helper, + String argumentName, + MethodDeclaration ownerMethod, + CodebaseContext context, + Set visiting) { + if (helper == null || helper.getBody() == null) { + return null; + } + String helperFqn = methodFqnFromNode(helper, context); + if (helperFqn != null && !visiting.add(helperFqn)) { + return null; + } String helperParam = singleParameterName(helper); if (helperParam == null) { return null; } - LinkedHashSet returnExpressions = new LinkedHashSet<>(); + LinkedHashSet inlinedReturns = new LinkedHashSet<>(); helper.getBody().accept(new ASTVisitor() { @Override public boolean visit(ReturnStatement node) { if (node.getExpression() != null) { - returnExpressions.add(node.getExpression().toString()); + String inlined = inlineBooleanPredicateReturnExpression( + node.getExpression(), helperParam, argumentName, ownerMethod, context, visiting); + if (inlined != null) { + inlinedReturns.add(inlined); + } } return super.visit(node); } }); - if (returnExpressions.size() != 1) { + return inlinedReturns.size() == 1 ? inlinedReturns.iterator().next() : null; + } + + private static String inlineBooleanPredicateReturnExpression( + Expression expression, + String helperParam, + String argumentName, + MethodDeclaration ownerMethod, + CodebaseContext context, + Set visiting) { + if (extractEqualsLiteralForParameter(expression, helperParam, ownerMethod, context) != null) { + return substituteParameterName(expression.toString(), helperParam, argumentName); + } + if (!(expression instanceof MethodInvocation invocation)) { return null; } - String literal = extractSingleEqualsLiteralFromBooleanHelper(helper, context); - if (literal == null) { + if (invocation.arguments().size() != 1) { return null; } - return returnExpressions.iterator().next() - .replaceAll("\\b" + Pattern.quote(helperParam) + "\\b", argumentName); + Expression forwarded = (Expression) invocation.arguments().get(0); + if (!parameterExpressionReferencesBinding(forwarded, helperParam, ownerMethod, context)) { + return null; + } + MethodDeclaration nested = findHelperMethod(ownerMethod, invocation, context); + if (nested == null || !isBooleanReturnType(nested)) { + return null; + } + String nestedArgument = substituteParameterName(forwarded.toString(), helperParam, argumentName); + return inlineBooleanPredicateExpression(nested, nestedArgument, ownerMethod, context, visiting); + } + + private static String substituteParameterName(String expression, String parameterName, String argumentName) { + return expression.replaceAll("\\b" + Pattern.quote(parameterName) + "\\b", argumentName); } private static MethodDeclaration resolveBooleanPredicateHelper( diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java index 1a383a7..90cfcd0 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java @@ -1182,6 +1182,44 @@ class EntryPointBindingExpanderTest { elseIfConstraint, Map.of("machineType", "ORDER"))).isFalse(); } + @Test + void shouldInlineNestedBooleanPredicateConstraint(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + class StateMachineDispatcher { + void dispatch(String machineType, String eventString) { + if (matchesOrder(machineType)) { + OrderEvent.valueOf(eventString.toUpperCase()); + } + } + boolean matchesOrder(String raw) { + return isOrderKey(normalize(raw)); + } + String normalize(String raw) { + return raw.trim(); + } + boolean isOrderKey(String normalized) { + return "ORDER".equalsIgnoreCase(normalized); + } + } + enum OrderEvent { PAY, SHIP } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + String caller = "com.example.StateMachineDispatcher.dispatch"; + String constraint = EntryPointBindingExpander.inlineBooleanPredicateConstraint( + "matchesOrder(machineType)", caller, context); + assertThat(constraint).contains("normalize(machineType)"); + assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings( + constraint, Map.of("machineType", "ORDER"))).isTrue(); + assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings( + constraint, Map.of("machineType", "DOCUMENT"))).isFalse(); + } + @Test void shouldFilterDelegatedBranchesUsingBooleanPredicateConstraints(@TempDir Path tempDir) throws Exception { String source = """ @@ -1434,4 +1472,139 @@ class EntryPointBindingExpanderTest { assertThat(variants).isEmpty(); } + + @Test + void shouldExpandMachineTypeFromStaticImportBooleanPredicate(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import static com.example.TypeMatcher.matchesOrder; + import static com.example.TypeMatcher.matchesDocument; + public class MachineController { + StateMachineDispatcher dispatcher; + public void transition(String machineType, String event) { + dispatcher.dispatch(machineType, event); + } + } + class StateMachineDispatcher { + void dispatch(String machineType, String eventString) { + if (matchesOrder(machineType)) { + OrderEvent.valueOf(eventString.toUpperCase()); + } else if (matchesDocument(machineType)) { + DocumentEvent.valueOf(eventString.toUpperCase()); + } + } + } + class TypeMatcher { + static boolean matchesOrder(String raw) { + return "ORDER".equalsIgnoreCase(raw.trim()); + } + static boolean matchesDocument(String raw) { + return "DOCUMENT".equalsIgnoreCase(raw.trim()); + } + } + enum OrderEvent { PAY, SHIP } + enum DocumentEvent { SUBMIT, APPROVE } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.MachineController") + .methodName("transition") + .name("POST /api/machine/{machineType}/transition/{event}") + .parameters(List.of( + EntryPoint.Parameter.builder() + .name("machineType") + .type("String") + .annotations(List.of("PathVariable")) + .build(), + EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants.stream().map(v -> v.get("machineType")).distinct()) + .containsExactlyInAnyOrder("ORDER", "DOCUMENT"); + } + + @Test + void shouldExpandMachineTypeFromNestedBooleanPredicateHelper(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class MachineController { + StateMachineDispatcher dispatcher; + public void transition(String machineType, String event) { + dispatcher.dispatch(machineType, event); + } + } + class StateMachineDispatcher { + void dispatch(String machineType, String eventString) { + if (matchesOrder(machineType)) { + OrderEvent.valueOf(eventString.toUpperCase()); + } else if (matchesDocument(machineType)) { + DocumentEvent.valueOf(eventString.toUpperCase()); + } + } + boolean matchesOrder(String raw) { + return isOrderKey(normalize(raw)); + } + boolean matchesDocument(String raw) { + return isDocumentKey(normalize(raw)); + } + String normalize(String raw) { + return raw.trim(); + } + boolean isOrderKey(String normalized) { + return "ORDER".equalsIgnoreCase(normalized); + } + boolean isDocumentKey(String normalized) { + return "DOCUMENT".equalsIgnoreCase(normalized); + } + } + enum OrderEvent { PAY, SHIP } + enum DocumentEvent { SUBMIT, APPROVE } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.MachineController") + .methodName("transition") + .name("POST /api/machine/{machineType}/transition/{event}") + .parameters(List.of( + EntryPoint.Parameter.builder() + .name("machineType") + .type("String") + .annotations(List.of("PathVariable")) + .build(), + EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants.stream().map(v -> v.get("machineType")).distinct()) + .containsExactlyInAnyOrder("ORDER", "DOCUMENT"); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java index 455d985..00f3f7b 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java @@ -265,4 +265,87 @@ class MultiModulePathBindingExpanderTest { .toList()) .containsExactlyInAnyOrder("PAY", "SHIP"); } + + @Test + void shouldExpandMachineTypeFromBooleanPredicateInSiblingModule(@TempDir Path tempDir) throws Exception { + Path root = tempDir.resolve("enterprise-style"); + Files.createDirectories(root); + Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'"); + + Path apiModule = root.resolve("api-module"); + Files.createDirectories(apiModule.resolve("src/main/java/com/example/util")); + Files.writeString(apiModule.resolve("build.gradle"), ""); + Files.writeString(apiModule.resolve("src/main/java/com/example/util/MachineTypeMatcher.java"), """ + package com.example.util; + public class MachineTypeMatcher { + public static boolean matchesOrder(String raw) { + return "ORDER".equalsIgnoreCase(raw.trim()); + } + public static boolean matchesDocument(String raw) { + return "DOCUMENT".equalsIgnoreCase(raw.trim()); + } + } + """); + + Path coreModule = root.resolve("core-module"); + Files.createDirectories(coreModule.resolve("src/main/java/com/example")); + Files.writeString(coreModule.resolve("build.gradle"), + "dependencies { implementation project(':api-module') }"); + Files.writeString(coreModule.resolve("src/main/java/com/example/MachineController.java"), """ + package com.example; + import com.example.util.MachineTypeMatcher; + public class MachineController { + StateMachineDispatcher dispatcher; + public void transition(String machineType, String event) { + dispatcher.dispatch(machineType, event); + } + } + class StateMachineDispatcher { + void dispatch(String machineType, String eventString) { + if (MachineTypeMatcher.matchesOrder(machineType)) { + OrderEvent.valueOf(eventString.toUpperCase()); + } else if (MachineTypeMatcher.matchesDocument(machineType)) { + DocumentEvent.valueOf(eventString.toUpperCase()); + } + } + } + enum OrderEvent { PAY, SHIP } + enum DocumentEvent { SUBMIT, APPROVE } + """); + + SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver(); + ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule); + Set scanPaths = graph.resolveScanPaths(coreModule, false); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(scanPaths, Collections.emptySet()); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> callGraph = + engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.MachineController") + .methodName("transition") + .name("POST /api/machine/{machineType}/transition/{event}") + .parameters(List.of( + EntryPoint.Parameter.builder() + .name("machineType") + .type("String") + .annotations(List.of("PathVariable")) + .build(), + EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph); + + assertThat(variants.stream().map(v -> v.get("machineType")).distinct()) + .containsExactlyInAnyOrder("ORDER", "DOCUMENT"); + } }