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:
@@ -336,16 +336,88 @@ public final class EntryPointBindingExpander {
|
|||||||
if (visited.size() > 24) {
|
if (visited.size() > 24) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
MethodDeclaration method = findMethodDeclaration(state.methodFqn(), context);
|
||||||
|
List<String> bindingNames = bindingNamesForParameter(method, state.paramName());
|
||||||
|
for (String bindingName : bindingNames) {
|
||||||
List<String> localCases = extractor.extract(
|
List<String> localCases = extractor.extract(
|
||||||
state.methodFqn(), state.paramName(), context, partialBindings);
|
state.methodFqn(), bindingName, context, partialBindings);
|
||||||
if (!localCases.isEmpty()) {
|
if (!localCases.isEmpty()) {
|
||||||
return localCases;
|
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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user