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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 17:47:45 +02:00
parent 8ef6e5ef8f
commit a70059331b
2 changed files with 164 additions and 9 deletions

View File

@@ -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<String> cases) {
if (!(selector instanceof SimpleName simpleName) || !paramName.equals(simpleName.getIdentifier())) {
Set<String> 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<String> cases) {
IfStatement current = ifStatement;
while (current != null) {

View File

@@ -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<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> 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<Map<String, String>> 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<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> 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<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
}
}