Harden cross-module map lookup and add sibling-module regression tests.
Resolve static map field declaring types via import-aware lookup when JDT binding uses the wrong package, and cover qualified inherited maps, static factory receivers, and RequestParam expansion across modules. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -672,9 +672,7 @@ public final class EntryPointBindingExpander {
|
||||
org.eclipse.jdt.core.dom.IVariableBinding fieldBinding = fieldAccess.resolveFieldBinding();
|
||||
if (fieldBinding != null && fieldBinding.isField()) {
|
||||
org.eclipse.jdt.core.dom.ITypeBinding declaringClass = fieldBinding.getDeclaringClass();
|
||||
if (declaringClass != null) {
|
||||
declaringType = context.getTypeDeclaration(declaringClass.getQualifiedName());
|
||||
}
|
||||
declaringType = resolveFieldDeclaringType(declaringClass, compilationUnit, context);
|
||||
}
|
||||
if (declaringType == null) {
|
||||
Expression qualifier = fieldAccess.getExpression();
|
||||
@@ -693,9 +691,7 @@ public final class EntryPointBindingExpander {
|
||||
if (binding instanceof org.eclipse.jdt.core.dom.IVariableBinding variableBinding
|
||||
&& variableBinding.isField()) {
|
||||
org.eclipse.jdt.core.dom.ITypeBinding declaringClass = variableBinding.getDeclaringClass();
|
||||
if (declaringClass != null) {
|
||||
declaringType = context.getTypeDeclaration(declaringClass.getQualifiedName());
|
||||
}
|
||||
declaringType = resolveFieldDeclaringType(declaringClass, compilationUnit, context);
|
||||
}
|
||||
if (declaringType == null && compilationUnit != null) {
|
||||
declaringType = context.resolveStaticImport(fieldName, compilationUnit);
|
||||
@@ -724,6 +720,27 @@ public final class EntryPointBindingExpander {
|
||||
return extractStaticFieldMapInitializer(declaringType, fieldName, context);
|
||||
}
|
||||
|
||||
private static TypeDeclaration resolveFieldDeclaringType(
|
||||
org.eclipse.jdt.core.dom.ITypeBinding declaringClass,
|
||||
CompilationUnit compilationUnit,
|
||||
CodebaseContext context) {
|
||||
if (declaringClass == null) {
|
||||
return null;
|
||||
}
|
||||
org.eclipse.jdt.core.dom.ITypeBinding erased = declaringClass.getErasure();
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(erased.getQualifiedName());
|
||||
if (typeDeclaration != null) {
|
||||
return typeDeclaration;
|
||||
}
|
||||
if (compilationUnit != null) {
|
||||
typeDeclaration = context.getTypeDeclaration(erased.getName(), compilationUnit);
|
||||
if (typeDeclaration != null) {
|
||||
return typeDeclaration;
|
||||
}
|
||||
}
|
||||
return context.getTypeDeclaration(erased.getName());
|
||||
}
|
||||
|
||||
private static boolean isStaticMapFieldReceiver(Expression receiver) {
|
||||
if (receiver instanceof FieldAccess fieldAccess) {
|
||||
org.eclipse.jdt.core.dom.IVariableBinding fieldBinding = fieldAccess.resolveFieldBinding();
|
||||
|
||||
@@ -883,4 +883,227 @@ class MultiModulePathBindingExpanderTest {
|
||||
assertThat(variants).extracting(map -> map.get("commandKey"))
|
||||
.containsExactlyInAnyOrder("order.pay", "order.ship");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExpandCommandKeyFromQualifiedInheritedStaticMapInSiblingModule(@TempDir Path tempDir) throws Exception {
|
||||
Path root = tempDir.resolve("order-system");
|
||||
Files.createDirectories(root);
|
||||
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
|
||||
|
||||
Path apiModule = root.resolve("api-module");
|
||||
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
|
||||
Files.writeString(apiModule.resolve("build.gradle"), "");
|
||||
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/BaseRoutes.java"), """
|
||||
package com.example.routes;
|
||||
import java.util.Map;
|
||||
public abstract class BaseRoutes {
|
||||
protected static final Map<String, String> ROUTES = Map.of(
|
||||
"order.pay", "OrderEvent.PAY",
|
||||
"order.ship", "OrderEvent.SHIP");
|
||||
}
|
||||
""");
|
||||
|
||||
Path coreModule = root.resolve("core-module");
|
||||
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
|
||||
Files.writeString(coreModule.resolve("build.gradle"),
|
||||
"dependencies { implementation project(':api-module') }");
|
||||
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
|
||||
package com.example;
|
||||
import com.example.routes.BaseRoutes;
|
||||
public class GenericCommandController {
|
||||
OrderGateway gateway;
|
||||
public void execute(String commandKey) {
|
||||
gateway.trigger(commandKey);
|
||||
}
|
||||
}
|
||||
class OrderGateway extends BaseRoutes {
|
||||
void trigger(String commandKey) {
|
||||
OrderEvent event = OrderEvent.valueOf(BaseRoutes.ROUTES.get(commandKey));
|
||||
StateMachine sm = new StateMachine();
|
||||
sm.sendEvent(event);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
class StateMachine { void sendEvent(OrderEvent event) {} }
|
||||
""");
|
||||
|
||||
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
|
||||
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
|
||||
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(scanPaths, Collections.emptySet());
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
|
||||
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, callGraph);
|
||||
|
||||
assertThat(variants).extracting(map -> map.get("commandKey"))
|
||||
.containsExactlyInAnyOrder("order.pay", "order.ship");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExpandCommandKeyFromStaticMethodMapReceiverInSiblingModule(@TempDir Path tempDir) throws Exception {
|
||||
Path root = tempDir.resolve("order-system");
|
||||
Files.createDirectories(root);
|
||||
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
|
||||
|
||||
Path apiModule = root.resolve("api-module");
|
||||
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
|
||||
Files.writeString(apiModule.resolve("build.gradle"), "");
|
||||
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/CommandRoutes.java"), """
|
||||
package com.example.routes;
|
||||
import java.util.Map;
|
||||
public class CommandRoutes {
|
||||
public static Map<String, String> routes() {
|
||||
return Map.of(
|
||||
"order.pay", "OrderEvent.PAY",
|
||||
"order.ship", "OrderEvent.SHIP");
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Path coreModule = root.resolve("core-module");
|
||||
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
|
||||
Files.writeString(coreModule.resolve("build.gradle"),
|
||||
"dependencies { implementation project(':api-module') }");
|
||||
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
|
||||
package com.example;
|
||||
import com.example.routes.CommandRoutes;
|
||||
public class GenericCommandController {
|
||||
OrderGateway gateway;
|
||||
public void execute(String commandKey) {
|
||||
gateway.trigger(commandKey);
|
||||
}
|
||||
}
|
||||
class OrderGateway {
|
||||
void trigger(String commandKey) {
|
||||
OrderEvent event = OrderEvent.valueOf(CommandRoutes.routes().get(commandKey));
|
||||
StateMachine sm = new StateMachine();
|
||||
sm.sendEvent(event);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
class StateMachine { void sendEvent(OrderEvent event) {} }
|
||||
""");
|
||||
|
||||
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
|
||||
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
|
||||
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(scanPaths, Collections.emptySet());
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
|
||||
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, callGraph);
|
||||
|
||||
assertThat(variants).extracting(map -> map.get("commandKey"))
|
||||
.containsExactlyInAnyOrder("order.pay", "order.ship");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExpandRequestParamFromStaticMapLookupInSiblingModule(@TempDir Path tempDir) throws Exception {
|
||||
Path root = tempDir.resolve("order-system");
|
||||
Files.createDirectories(root);
|
||||
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
|
||||
|
||||
Path apiModule = root.resolve("api-module");
|
||||
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
|
||||
Files.writeString(apiModule.resolve("build.gradle"), "");
|
||||
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/CommandRoutes.java"), """
|
||||
package com.example.routes;
|
||||
import java.util.Map;
|
||||
public class CommandRoutes {
|
||||
public static final Map<String, String> ROUTES = Map.of(
|
||||
"order.pay", "OrderEvent.PAY",
|
||||
"order.ship", "OrderEvent.SHIP");
|
||||
}
|
||||
""");
|
||||
|
||||
Path coreModule = root.resolve("core-module");
|
||||
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
|
||||
Files.writeString(coreModule.resolve("build.gradle"),
|
||||
"dependencies { implementation project(':api-module') }");
|
||||
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
|
||||
package com.example;
|
||||
import com.example.routes.CommandRoutes;
|
||||
public class GenericCommandController {
|
||||
OrderGateway gateway;
|
||||
public void execute(String commandKey) {
|
||||
gateway.trigger(commandKey);
|
||||
}
|
||||
}
|
||||
class OrderGateway {
|
||||
void trigger(String commandKey) {
|
||||
OrderEvent event = OrderEvent.valueOf(CommandRoutes.ROUTES.get(commandKey));
|
||||
StateMachine sm = new StateMachine();
|
||||
sm.sendEvent(event);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
class StateMachine { void sendEvent(OrderEvent event) {} }
|
||||
""");
|
||||
|
||||
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
|
||||
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
|
||||
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(scanPaths, Collections.emptySet());
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
|
||||
engine.buildCallGraph();
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.GenericCommandController")
|
||||
.methodName("execute")
|
||||
.name("POST /api/commands")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("commandKey")
|
||||
.type("String")
|
||||
.annotations(List.of("RequestParam"))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants =
|
||||
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
|
||||
|
||||
assertThat(variants).extracting(map -> map.get("commandKey"))
|
||||
.containsExactlyInAnyOrder("order.pay", "order.ship");
|
||||
assertThat(EntryPointBindingExpander.withResolvedPath(entryPoint, variants.get(0)).getName())
|
||||
.isEqualTo("POST /api/commands?commandKey=order.pay");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +291,28 @@ class CodebaseContextTest {
|
||||
assertThat(context.areClassesPolymorphicallyCompatible("a.Dispatcher", "b.Dispatcher")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSuperclassFqnShouldPreferImportAwareResolutionWhenBindingMisResolvesPackage() throws IOException {
|
||||
Files.createDirectories(tempDir.resolve("com/example/routes"));
|
||||
Files.writeString(tempDir.resolve("com/example/routes/BaseRoutes.java"), """
|
||||
package com.example.routes;
|
||||
public abstract class BaseRoutes {}
|
||||
""");
|
||||
Files.writeString(tempDir.resolve("com/example/App.java"), """
|
||||
package com.example;
|
||||
import com.example.routes.BaseRoutes;
|
||||
class OrderGateway extends BaseRoutes {}
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(context.getTypeDeclaration("com.example.routes.BaseRoutes")).isNotNull();
|
||||
assertThat(context.getSuperclassFqn(context.getTypeDeclaration("com.example.OrderGateway")))
|
||||
.isEqualTo("com.example.routes.BaseRoutes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFindClassWithFullyQualifiedAnnotation() throws IOException {
|
||||
String source = """
|
||||
|
||||
Reference in New Issue
Block a user