From 5348dafb698f59fad76b0fefd0671eea7bf6d283 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Tue, 14 Jul 2026 17:44:21 +0200 Subject: [PATCH] Follow intra-method parameter aliases during path binding expansion. Local renames like eventCode = event or normalized = event.trim() no longer break Enum.valueOf case extraction; add multi-hop and normalization coverage in EntryPointBindingExpanderTest. Co-authored-by: Cursor --- .../service/EntryPointBindingExpander.java | 99 ++++++++++- .../EntryPointBindingExpanderTest.java | 162 ++++++++++++++++++ 2 files changed, 255 insertions(+), 6 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 d3021f8..3240c7f 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 @@ -336,16 +336,88 @@ public final class EntryPointBindingExpander { if (visited.size() > 24) { break; } - List localCases = extractor.extract( - state.methodFqn(), state.paramName(), context, partialBindings); - if (!localCases.isEmpty()) { - return localCases; + MethodDeclaration method = findMethodDeclaration(state.methodFqn(), context); + List bindingNames = bindingNamesForParameter(method, state.paramName()); + for (String bindingName : bindingNames) { + List localCases = extractor.extract( + state.methodFqn(), bindingName, context, partialBindings); + if (!localCases.isEmpty()) { + return localCases; + } } enqueueParameterAliasSuccessors(state, context, callGraph, partialBindings, queue); } return List.of(); } + private static List bindingNamesForParameter(MethodDeclaration method, String paramName) { + if (paramName == null || paramName.isBlank()) { + return List.of(); + } + LinkedHashSet names = new LinkedHashSet<>(); + names.add(paramName); + if (method != null) { + names.addAll(collectIntraMethodParameterAliases(method, paramName)); + } + return List.copyOf(names); + } + + /** + * Names assigned directly from a parameter within the same method, including transitive renames + * ({@code String code = event}, {@code String normalized = code.trim()}). + */ + static Set collectIntraMethodParameterAliases(MethodDeclaration method, String paramName) { + Set aliases = new LinkedHashSet<>(); + if (method == null || method.getBody() == null || paramName == null || paramName.isBlank()) { + return aliases; + } + Set known = new LinkedHashSet<>(); + known.add(paramName); + boolean growing = true; + while (growing) { + growing = false; + int before = aliases.size(); + method.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationFragment node) { + Expression initializer = node.getInitializer(); + if (initializer != null && expressionReferencesKnownBinding(initializer, known)) { + aliases.add(node.getName().getIdentifier()); + } + return super.visit(node); + } + + @Override + public boolean visit(Assignment node) { + if (node.getLeftHandSide() instanceof SimpleName left + && expressionReferencesKnownBinding(node.getRightHandSide(), known)) { + aliases.add(left.getIdentifier()); + } + return super.visit(node); + } + }); + known.addAll(aliases); + growing = aliases.size() > before; + } + return aliases; + } + + private static boolean expressionReferencesKnownBinding(Expression expression, Set knownNames) { + if (expression instanceof SimpleName simpleName) { + return knownNames.contains(simpleName.getIdentifier()); + } + if (expression instanceof MethodInvocation invocation + && invocation.arguments().isEmpty() + && invocation.getExpression() instanceof SimpleName receiver) { + String methodName = invocation.getName().getIdentifier(); + if (("toUpperCase".equals(methodName) || "toLowerCase".equals(methodName) || "trim".equals(methodName)) + && knownNames.contains(receiver.getIdentifier())) { + return true; + } + } + return false; + } + private static void enqueueParameterAliasSuccessors( ParameterAliasState state, CodebaseContext context, @@ -423,7 +495,7 @@ public final class EntryPointBindingExpander { if (method == null || method.getBody() == null || context == null) { return List.of(); } - if (!hasParameter(method, paramName)) { + if (!tracksBindingName(method, paramName)) { return List.of(); } LinkedHashSet enumTypes = new LinkedHashSet<>(); @@ -522,7 +594,7 @@ public final class EntryPointBindingExpander { if (method == null || method.getBody() == null) { return List.of(); } - if (!hasParameter(method, paramName)) { + if (!tracksBindingName(method, paramName)) { return List.of(); } LinkedHashSet cases = new LinkedHashSet<>(); @@ -623,6 +695,21 @@ public final class EntryPointBindingExpander { return null; } + private static boolean tracksBindingName(MethodDeclaration method, String name) { + if (hasParameter(method, name)) { + return true; + } + for (Object paramObj : method.parameters()) { + if (paramObj instanceof SingleVariableDeclaration param) { + String paramName = param.getName().getIdentifier(); + if (collectIntraMethodParameterAliases(method, paramName).contains(name)) { + return true; + } + } + } + return false; + } + private static boolean hasParameter(MethodDeclaration method, String paramName) { for (Object paramObj : method.parameters()) { if (paramObj instanceof SingleVariableDeclaration param 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 7787009..450c2d8 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 @@ -370,4 +370,166 @@ class EntryPointBindingExpanderTest { "ORDER".equals(variant.get("machineType")) && List.of("PAY", "SHIP").contains(variant.get("event")))).isTrue(); } + + @Test + void shouldExpandEventThroughMultiHopParameterRenames(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class MachineController { + RoutingGateway gateway; + public void transition(String machineType, String event) { + gateway.route(machineType, event); + } + } + class RoutingGateway { + void route(String domain, String actionKey) { + dispatcher.dispatch(domain, actionKey); + } + StateMachineDispatcher dispatcher; + } + class StateMachineDispatcher { + void dispatch(String machineType, String eventString) { + if ("ORDER".equalsIgnoreCase(machineType)) { + fireOrder(eventString); + } else if ("DOCUMENT".equalsIgnoreCase(machineType)) { + fireDocument(eventString); + } + } + void fireOrder(String eventString) { + OrderEvent.valueOf(eventString.toUpperCase()); + } + void fireDocument(String eventString) { + DocumentEvent.valueOf(eventString.toUpperCase()); + } + } + 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).anySatisfy(variant -> { + assertThat(variant).containsEntry("machineType", "ORDER"); + assertThat(variant.get("event")).isIn("PAY", "SHIP"); + }); + assertThat(EntryPointBindingExpander.withResolvedPath( + entryPoint, Map.of("machineType", "ORDER", "event", "PAY")).getName()) + .isEqualTo("POST /api/machine/ORDER/transition/PAY"); + } + + @Test + void shouldExpandEventWhenParameterIsRenamedWithinMethodBody(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class OrderController { + OrderGateway gateway; + public void transition(String event) { + gateway.trigger(event); + } + } + class OrderGateway { + void trigger(String event) { + String eventCode = event; + OrderEvent.valueOf(eventCode.toUpperCase()); + } + } + enum OrderEvent { PAY, SHIP } + """; + 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.OrderController") + .methodName("transition") + .name("POST /api/order/{event}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("event")) + .containsExactlyInAnyOrder("PAY", "SHIP"); + } + + @Test + void shouldExpandEventWhenParameterIsNormalizedWithinMethodBody(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class OrderController { + OrderGateway gateway; + public void transition(String event) { + gateway.trigger(event); + } + } + class OrderGateway { + void trigger(String event) { + String normalized = event.trim(); + OrderEvent.valueOf(normalized.toUpperCase()); + } + } + enum OrderEvent { PAY, SHIP } + """; + 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.OrderController") + .methodName("transition") + .name("POST /api/order/{event}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("event")) + .containsExactlyInAnyOrder("PAY", "SHIP"); + } }