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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 17:44:21 +02:00
parent a3789ceb3c
commit 5348dafb69
2 changed files with 255 additions and 6 deletions

View File

@@ -336,16 +336,88 @@ public final class EntryPointBindingExpander {
if (visited.size() > 24) { if (visited.size() > 24) {
break; break;
} }
List<String> localCases = extractor.extract( MethodDeclaration method = findMethodDeclaration(state.methodFqn(), context);
state.methodFqn(), state.paramName(), context, partialBindings); List<String> bindingNames = bindingNamesForParameter(method, state.paramName());
if (!localCases.isEmpty()) { for (String bindingName : bindingNames) {
return localCases; List<String> localCases = extractor.extract(
state.methodFqn(), bindingName, context, partialBindings);
if (!localCases.isEmpty()) {
return localCases;
}
} }
enqueueParameterAliasSuccessors(state, context, callGraph, partialBindings, queue); enqueueParameterAliasSuccessors(state, context, callGraph, partialBindings, queue);
} }
return List.of(); return List.of();
} }
private static List<String> bindingNamesForParameter(MethodDeclaration method, String paramName) {
if (paramName == null || paramName.isBlank()) {
return List.of();
}
LinkedHashSet<String> 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<String> collectIntraMethodParameterAliases(MethodDeclaration method, String paramName) {
Set<String> aliases = new LinkedHashSet<>();
if (method == null || method.getBody() == null || paramName == null || paramName.isBlank()) {
return aliases;
}
Set<String> 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<String> 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( private static void enqueueParameterAliasSuccessors(
ParameterAliasState state, ParameterAliasState state,
CodebaseContext context, CodebaseContext context,
@@ -423,7 +495,7 @@ public final class EntryPointBindingExpander {
if (method == null || method.getBody() == null || context == null) { if (method == null || method.getBody() == null || context == null) {
return List.of(); return List.of();
} }
if (!hasParameter(method, paramName)) { if (!tracksBindingName(method, paramName)) {
return List.of(); return List.of();
} }
LinkedHashSet<String> enumTypes = new LinkedHashSet<>(); LinkedHashSet<String> enumTypes = new LinkedHashSet<>();
@@ -522,7 +594,7 @@ public final class EntryPointBindingExpander {
if (method == null || method.getBody() == null) { if (method == null || method.getBody() == null) {
return List.of(); return List.of();
} }
if (!hasParameter(method, paramName)) { if (!tracksBindingName(method, paramName)) {
return List.of(); return List.of();
} }
LinkedHashSet<String> cases = new LinkedHashSet<>(); LinkedHashSet<String> cases = new LinkedHashSet<>();
@@ -623,6 +695,21 @@ public final class EntryPointBindingExpander {
return null; 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) { private static boolean hasParameter(MethodDeclaration method, String paramName) {
for (Object paramObj : method.parameters()) { for (Object paramObj : method.parameters()) {
if (paramObj instanceof SingleVariableDeclaration param if (paramObj instanceof SingleVariableDeclaration param

View File

@@ -370,4 +370,166 @@ class EntryPointBindingExpanderTest {
"ORDER".equals(variant.get("machineType")) "ORDER".equals(variant.get("machineType"))
&& List.of("PAY", "SHIP").contains(variant.get("event")))).isTrue(); && 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<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> 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<Map<String, String>> 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<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 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<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");
}
} }