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:
2026-07-14 19:20:18 +02:00
parent d19d4f20ef
commit 5b0778301a
3 changed files with 268 additions and 6 deletions

View File

@@ -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();

View File

@@ -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");
}
}

View File

@@ -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 = """