From 5b0778301a477f892d30d9d3abda0985fb80b186 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Tue, 14 Jul 2026 19:20:18 +0200 Subject: [PATCH] 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 --- .../service/EntryPointBindingExpander.java | 29 ++- .../MultiModulePathBindingExpanderTest.java | 223 ++++++++++++++++++ .../ast/common/CodebaseContextTest.java | 22 ++ 3 files changed, 268 insertions(+), 6 deletions(-) diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java index 8a85ed4..4b3dbb8 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java @@ -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(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java index 29d9507..6b6e22e 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java @@ -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 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 scanPaths = graph.resolveScanPaths(coreModule, false); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(scanPaths, Collections.emptySet()); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> 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> 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 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 scanPaths = graph.resolveScanPaths(coreModule, false); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(scanPaths, Collections.emptySet()); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> 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> 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 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 scanPaths = graph.resolveScanPaths(coreModule, false); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(scanPaths, Collections.emptySet()); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> 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> 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"); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContextTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContextTest.java index 347ddc9..38fb925 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContextTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContextTest.java @@ -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 = """