From a70059331bde2ffb5c6abb908a28b7459627405c Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Tue, 14 Jul 2026 17:47:45 +0200 Subject: [PATCH] Resolve path bindings through cross-class static helpers. EntryPointBindingExpander follows single-arg normalize helpers declared on other types and accepts switch selectors wrapped by those helpers when extracting string case arms. Co-authored-by: Cursor --- .../service/EntryPointBindingExpander.java | 65 +++++++++-- .../EntryPointBindingExpanderTest.java | 108 ++++++++++++++++++ 2 files changed, 164 insertions(+), 9 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 ad15b19..5f6f2f3 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 @@ -600,12 +600,43 @@ public final class EntryPointBindingExpander { MethodDeclaration ownerMethod, MethodInvocation helperInvocation, CodebaseContext context) { - ASTNode parent = ownerMethod.getParent(); - if (!(parent instanceof TypeDeclaration typeDeclaration)) { - return null; - } String helperName = helperInvocation.getName().getIdentifier(); - return context.findMethodDeclaration(typeDeclaration, helperName, true); + CompilationUnit compilationUnit = ownerMethod.getRoot() instanceof CompilationUnit cu ? cu : null; + Expression receiver = helperInvocation.getExpression(); + TypeDeclaration declaringType; + if (receiver == null) { + ASTNode parent = ownerMethod.getParent(); + if (!(parent instanceof TypeDeclaration typeDeclaration)) { + return null; + } + declaringType = typeDeclaration; + } else { + declaringType = resolveHelperDeclaringType(receiver, compilationUnit, context); + if (declaringType == null) { + return null; + } + } + return context.findMethodDeclaration(declaringType, helperName, true); + } + + private static TypeDeclaration resolveHelperDeclaringType( + Expression receiver, + CompilationUnit compilationUnit, + CodebaseContext context) { + org.eclipse.jdt.core.dom.ITypeBinding binding = receiver.resolveTypeBinding(); + if (binding != null) { + TypeDeclaration typeDeclaration = context.getTypeDeclaration(binding.getQualifiedName()); + if (typeDeclaration != null) { + return typeDeclaration; + } + } + if (receiver instanceof QualifiedName qualifiedName) { + return context.getTypeDeclaration(qualifiedName.getFullyQualifiedName()); + } + if (receiver instanceof SimpleName simpleName) { + return context.getTypeDeclaration(simpleName.getIdentifier(), compilationUnit); + } + return null; } private static boolean isTrivialSingleArgStringHelper(MethodDeclaration helper) { @@ -668,13 +699,13 @@ public final class EntryPointBindingExpander { method.getBody().accept(new ASTVisitor() { @Override public boolean visit(SwitchStatement node) { - collectCasesFromSwitch(node.getExpression(), node.statements(), paramName, cases); + collectCasesFromSwitch(node.getExpression(), node.statements(), paramName, cases, method, context); return super.visit(node); } @Override public boolean visit(SwitchExpression node) { - collectCasesFromSwitch(node.getExpression(), node.statements(), paramName, cases); + collectCasesFromSwitch(node.getExpression(), node.statements(), paramName, cases, method, context); return super.visit(node); } @@ -691,8 +722,10 @@ public final class EntryPointBindingExpander { Expression selector, List statements, String paramName, - Set cases) { - if (!(selector instanceof SimpleName simpleName) || !paramName.equals(simpleName.getIdentifier())) { + Set cases, + MethodDeclaration ownerMethod, + CodebaseContext context) { + if (!switchSelectorReferencesParameter(selector, paramName, ownerMethod, context)) { return; } for (Object statementObj : statements) { @@ -706,6 +739,20 @@ public final class EntryPointBindingExpander { } } + private static boolean switchSelectorReferencesParameter( + Expression selector, + String paramName, + MethodDeclaration ownerMethod, + CodebaseContext context) { + if (selector instanceof SimpleName simpleName) { + return paramName.equals(simpleName.getIdentifier()); + } + if (ownerMethod != null && context != null && selector instanceof MethodInvocation invocation) { + return isSingleArgHelperForwardingParameter(invocation, paramName, ownerMethod, context); + } + return false; + } + private static void collectCasesFromIfChain(IfStatement ifStatement, String paramName, Set cases) { IfStatement current = ifStatement; while (current != null) { 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 146924b..134f41a 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 @@ -579,4 +579,112 @@ class EntryPointBindingExpanderTest { assertThat(variants).extracting(map -> map.get("event")) .containsExactlyInAnyOrder("PAY", "SHIP"); } + + @Test + void shouldExpandEventWhenParameterIsPassedThroughCrossClassStaticHelper(@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) { + OrderEvent.valueOf(EventNormalizer.normalize(event)); + } + } + class EventNormalizer { + static String normalize(String raw) { + return raw.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 shouldExpandPathVariableFromSwitchOnCrossClassStaticHelper(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class GenericCommandController { + CommandGateway gateway; + public void execute(String commandKey) { + gateway.executeViaMapper(commandKey); + } + } + class CommandGateway { + StringCommandMapper mapper; + void executeViaMapper(String commandKey) { + mapper.fromString(commandKey); + } + } + class StringCommandMapper { + DomainCommand fromString(String commandKey) { + return switch (CommandKeys.normalize(commandKey)) { + case "order.pay" -> DomainCommand.ORDER_PAY; + case "order.ship" -> DomainCommand.ORDER_SHIP; + default -> throw new IllegalArgumentException(commandKey); + }; + } + } + class CommandKeys { + static String normalize(String raw) { + return raw.trim(); + } + } + enum DomainCommand { ORDER_PAY, ORDER_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.GenericCommandController") + .methodName("execute") + .name("POST /api/commands/{commandKey}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("commandKey") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("commandKey")) + .containsExactlyInAnyOrder("order.pay", "order.ship"); + } }