diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java index 1163428..3287e29 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java @@ -14,8 +14,13 @@ import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicRoutingEngine; +import click.kamil.springstatemachineexporter.analysis.enricher.routing.RoutingEngine; + public class TransitionLinkerEnricher implements AnalysisEnricher { + private final RoutingEngine routingEngine = new HeuristicRoutingEngine(); + @Override public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) { if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) { @@ -125,156 +130,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName) { - // First, check explicit variable targeting (like contextMachineId) - String targetVar = chain.getContextMachineId(); - if (targetVar == null && chain.getTriggerPoint() != null) { - targetVar = chain.getTriggerPoint().getStateMachineId(); - } - - String simplifiedMachineName = currentMachineName != null ? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase() : ""; - - if (targetVar != null && !targetVar.isEmpty()) { - targetVar = targetVar.toLowerCase(); - // E.g., if target is "myStateMachine", ensure current machine name contains "my" - // If the variable name ends with StateMachine, we extract the prefix - if (targetVar.endsWith("statemachine")) { - String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length()); - if (!prefix.isEmpty() && !simplifiedMachineName.contains(prefix)) { - return false; - } - } - } - - - - - // Generic approach combining Prefix, Package, and String-distance heuristics - // without relying on a hardcoded list of magic keywords. - if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) { - String smPackage = getPackageName(currentMachineName); - String smSimple = getSimpleClassName(currentMachineName); - String smPrefix = getFirstCamelCaseWord(smSimple); - - boolean hasPositiveMatch = false; - boolean hasStrongMismatch = false; - - for (String method : chain.getMethodChain()) { - String chainClass = getClassNameOnly(method); - String chainPackage = getPackageName(chainClass); - String chainSimple = getSimpleClassName(chainClass); - String chainPrefix = getFirstCamelCaseWord(chainSimple); - - // 1. Explicit Prefix Match (e.g. ElectronicsOrderService & ElectronicsStateMachine) - if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) { - hasPositiveMatch = true; - } - - // 2. Explicit Sub-Package Match (e.g. com.company.electronics.service & com.company.electronics.sm) - // We consider it a match if they share a package segment beyond the first 2 (which are usually com.company) - String deepShared = getDeepSharedPackage(smPackage, chainPackage); - if (deepShared != null) { - hasPositiveMatch = true; - } - - // Determine if there is a strong mismatch. - // A strong mismatch happens if the chain class has a VERY distinct prefix/package - // that contradicts the SM's distinct prefix/package. - // Without magic keywords, we guess if a prefix is distinct if it's long enough and doesn't match. - if (smPrefix != null && chainPrefix != null && !smPrefix.equals(chainPrefix)) { - // They have different first CamelCase words. - // This could be Electronics vs ComputerStore, or it could be Common vs ComputerStore. - // If they diverge at a deep package level, it's a strong mismatch. - if (isDeepPackageDivergence(smPackage, chainPackage)) { - hasStrongMismatch = true; - } - } - } - - // If we found a positive structural match, route it! - if (hasPositiveMatch) { - return true; - } - - // If there's no positive match, but we detected a strong structural mismatch, reject it! - if (hasStrongMismatch) { - return false; - } - - // If neither, we allow it (fallback for common/shared endpoints without distinct domains) - } - - return true; - } - - private String getPackageName(String fqn) { - if (fqn == null || !fqn.contains(".")) return ""; - return fqn.substring(0, fqn.lastIndexOf('.')); - } - - private String getSimpleClassName(String fqn) { - if (fqn == null) return ""; - if (!fqn.contains(".")) return fqn; - return fqn.substring(fqn.lastIndexOf('.') + 1); - } - - private String getClassNameOnly(String methodFqn) { - if (methodFqn == null) return ""; - String clean = methodFqn; - if (clean.contains("(")) { - clean = clean.substring(0, clean.indexOf('(')); - } - if (clean.contains(".")) { - clean = clean.substring(0, clean.lastIndexOf('.')); - } - return clean; - } - - private String getFirstCamelCaseWord(String simpleName) { - if (simpleName == null || simpleName.isEmpty()) return null; - // e.g. "ElectronicsOrderService" -> "Electronics" - String[] words = simpleName.split("(? 0) { - return words[0]; - } - return null; - } - - private String getDeepSharedPackage(String pkg1, String pkg2) { - if (pkg1 == null || pkg2 == null || pkg1.isEmpty() || pkg2.isEmpty()) return null; - String[] p1 = pkg1.split("\\."); - String[] p2 = pkg2.split("\\."); - - // Find the deepest common segment index - int matchIdx = -1; - for (int i = 0; i < Math.min(p1.length, p2.length); i++) { - if (p1[i].equals(p2[i])) { - matchIdx = i; - } else { - break; - } - } - // If they share at least 3 segments (e.g. com.company.electronics), it's a deep match - if (matchIdx >= 2) { - StringBuilder sb = new StringBuilder(); - for(int i = 0; i <= matchIdx; i++) { - sb.append(p1[i]).append("."); - } - return sb.toString(); - } - return null; - } - - private boolean isDeepPackageDivergence(String pkg1, String pkg2) { - if (pkg1 == null || pkg2 == null || pkg1.isEmpty() || pkg2.isEmpty()) return false; - String[] p1 = pkg1.split("\\."); - String[] p2 = pkg2.split("\\."); - - // If they share exactly the root (com.company) but diverge at the 3rd segment (e.g. electronics vs computerstore) - // Then it's a divergence. - if (p1.length >= 3 && p2.length >= 3) { - return p1[0].equals(p2[0]) && p1[1].equals(p2[1]) && !p1[2].equals(p2[2]); - } - return false; + return routingEngine.isRoutedToCorrectMachine(chain, currentMachineName); } private String simplify(String name) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicRoutingEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicRoutingEngine.java new file mode 100644 index 0000000..1a8756b --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicRoutingEngine.java @@ -0,0 +1,188 @@ +package click.kamil.springstatemachineexporter.analysis.enricher.routing; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; + +import java.util.HashSet; +import java.util.Set; + +public class HeuristicRoutingEngine implements RoutingEngine { + + @Override + public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName) { + String targetVar = chain.getContextMachineId(); + if (targetVar == null && chain.getTriggerPoint() != null) { + targetVar = chain.getTriggerPoint().getStateMachineId(); + } + + String simplifiedMachineName = currentMachineName != null ? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase() : ""; + + if (targetVar != null && !targetVar.isEmpty()) { + targetVar = targetVar.toLowerCase(); + if (targetVar.endsWith("statemachine")) { + String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length()); + if (!prefix.isEmpty()) { + if (simplifiedMachineName.contains(prefix)) { + return true; // Explicit positive match + } else { + return false; // Explicit negative match + } + } + } else if (simplifiedMachineName.contains(targetVar)) { + return true; + } + } + + if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) { + String smPackage = getPackageName(currentMachineName); + String smSimple = getSimpleClassName(currentMachineName); + String smPrefix = getFirstCamelCaseWord(smSimple); + + boolean hasPositiveMatch = false; + boolean hasStrongMismatch = false; + + for (String method : chain.getMethodChain()) { + String chainClass = getClassNameOnly(method); + String chainPackage = getPackageName(chainClass); + String chainSimple = getSimpleClassName(chainClass); + String chainPrefix = getFirstCamelCaseWord(chainSimple); + + if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) { + hasPositiveMatch = true; + } + + if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) { + hasPositiveMatch = true; + } + + if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix)) { + hasStrongMismatch = true; + } + } + + if (hasPositiveMatch) { + return true; + } + if (hasStrongMismatch) { + return false; + } + } + return true; + } + + private boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) { + if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false; + + if (smPackage.startsWith(chainPackage) || chainPackage.startsWith(smPackage)) { + return true; + } + + // Find deepest common package root + String[] p1 = smPackage.split("\\."); + String[] p2 = chainPackage.split("\\."); + int matchIdx = -1; + for (int i = 0; i < Math.min(p1.length, p2.length); i++) { + if (p1[i].equals(p2[i])) { + matchIdx = i; + } else { + break; + } + } + + // If the state machine's prefix is part of the shared common package, + // then the state machine represents the parent domain of the caller. + if (smPrefix != null && matchIdx >= 0) { + String lowerPrefix = smPrefix.toLowerCase(); + for (int i = 0; i <= matchIdx; i++) { + if (p1[i].toLowerCase().contains(lowerPrefix) || lowerPrefix.contains(p1[i].toLowerCase())) { + return true; + } + } + } + + return false; + } + + private boolean isDomainMismatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) { + if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false; + + String[] p1 = smPackage.split("\\."); + String[] p2 = chainPackage.split("\\."); + + int matchIdx = -1; + for (int i = 0; i < Math.min(p1.length, p2.length); i++) { + if (p1[i].equals(p2[i])) { + matchIdx = i; + } else { + break; + } + } + + Set smDivergentTerms = new HashSet<>(); + if (smPrefix != null) smDivergentTerms.add(smPrefix.toLowerCase()); + for (int i = matchIdx + 1; i < p1.length; i++) { + smDivergentTerms.add(p1[i].toLowerCase()); + } + + Set chainDivergentTerms = new HashSet<>(); + if (chainPrefix != null) chainDivergentTerms.add(chainPrefix.toLowerCase()); + for (int i = matchIdx + 1; i < p2.length; i++) { + chainDivergentTerms.add(p2[i].toLowerCase()); + } + + if (!smDivergentTerms.isEmpty() && !chainDivergentTerms.isEmpty()) { + Set intersection = new HashSet<>(smDivergentTerms); + intersection.retainAll(chainDivergentTerms); + return intersection.isEmpty(); + } + + return false; + } + + private Set extractDomainTerms(String pkg, String prefix) { + Set terms = new HashSet<>(); + if (pkg != null && !pkg.isEmpty()) { + String[] segments = pkg.split("\\."); + // Take segments from index 2 onwards if length > 2 + int start = segments.length > 2 ? 2 : 0; + for (int i = start; i < segments.length; i++) { + terms.add(segments[i].toLowerCase()); + } + } + if (prefix != null && !prefix.isEmpty()) { + terms.add(prefix.toLowerCase()); + } + return terms; + } + + private String getPackageName(String fqn) { + if (fqn == null || !fqn.contains(".")) return ""; + return fqn.substring(0, fqn.lastIndexOf('.')); + } + + private String getSimpleClassName(String fqn) { + if (fqn == null) return ""; + if (!fqn.contains(".")) return fqn; + return fqn.substring(fqn.lastIndexOf('.') + 1); + } + + private String getClassNameOnly(String methodFqn) { + if (methodFqn == null) return ""; + String clean = methodFqn; + if (clean.contains("(")) { + clean = clean.substring(0, clean.indexOf('(')); + } + if (clean.contains(".")) { + clean = clean.substring(0, clean.lastIndexOf('.')); + } + return clean; + } + + private String getFirstCamelCaseWord(String simpleName) { + if (simpleName == null || simpleName.isEmpty()) return null; + String[] words = simpleName.split("(? 0) { + return words[0]; + } + return null; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/RoutingEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/RoutingEngine.java new file mode 100644 index 0000000..91ff468 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/RoutingEngine.java @@ -0,0 +1,7 @@ +package click.kamil.springstatemachineexporter.analysis.enricher.routing; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; + +public interface RoutingEngine { + boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName); +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicRoutingEngineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicRoutingEngineTest.java new file mode 100644 index 0000000..826637b --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicRoutingEngineTest.java @@ -0,0 +1,103 @@ +package click.kamil.springstatemachineexporter.analysis.enricher.routing; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +@Tag("heuristic_bean_resolution") +public class HeuristicRoutingEngineTest { + + private final HeuristicRoutingEngine engine = new HeuristicRoutingEngine(); + + @Test + public void testDeepPackageDivergenceMismatch() { + // This is the bug case: sharing a deep root, but diverging into different domains + CallChain chain = CallChain.builder() + .methodChain(Arrays.asList("com.acme.corp.division.project.orders.OrderService.pay()")) + .build(); + + String machineName = "com.acme.corp.division.project.payments.PaymentStateMachine"; + + boolean result = engine.isRoutedToCorrectMachine(chain, machineName); + + // They share com.acme.corp.division.project, but diverge into orders vs payments + // This should be a strong mismatch, so it should return false + assertFalse(result, "Deep package divergence into different domains should be rejected"); + } + + @Test + public void testSameDomainMatch() { + CallChain chain = CallChain.builder() + .methodChain(Arrays.asList("com.acme.ecommerce.orders.OrderService.process()")) + .build(); + + String machineName = "com.acme.ecommerce.orders.OrderStateMachine"; + + boolean result = engine.isRoutedToCorrectMachine(chain, machineName); + + // Same exact domain, should match + assertTrue(result, "Same domain should be accepted"); + } + + @Test + public void testSubPackageMatch() { + CallChain chain = CallChain.builder() + .methodChain(Arrays.asList("com.acme.ecommerce.orders.impl.OrderServiceImpl.process()")) + .build(); + + String machineName = "com.acme.ecommerce.orders.OrderStateMachine"; + + boolean result = engine.isRoutedToCorrectMachine(chain, machineName); + + // Subpackage should match + assertTrue(result, "Sub-packages of the same domain should be accepted"); + } + + @Test + public void testSharedDomainTermMatch() { + CallChain chain = CallChain.builder() + .methodChain(Arrays.asList("com.acme.orders.web.OrderController.submit()")) + .build(); + + String machineName = "com.acme.orders.service.OrderStateMachine"; + + boolean result = engine.isRoutedToCorrectMachine(chain, machineName); + + // Different subpackages, but they share the "order" domain term and common prefix + assertTrue(result, "Divergent packages that share a domain term should be accepted"); + } + + @Test + public void testExplicitTargetVariableMatch() { + CallChain chain = CallChain.builder() + .methodChain(Arrays.asList("com.acme.common.GlobalTrigger.fire()")) + .contextMachineId("paymentStateMachine") // Explicitly targets payment + .build(); + + String machineName = "com.acme.corp.payments.PaymentStateMachine"; + + boolean result = engine.isRoutedToCorrectMachine(chain, machineName); + + // Mismatched domains, but explicit variable targeting works + assertTrue(result, "Explicit variable target matching the machine name should be accepted"); + } + + @Test + public void testExplicitTargetVariableMismatch() { + CallChain chain = CallChain.builder() + .methodChain(Arrays.asList("com.acme.common.GlobalTrigger.fire()")) + .contextMachineId("paymentStateMachine") // Explicitly targets payment + .build(); + + String machineName = "com.acme.corp.orders.OrderStateMachine"; // But this is Order + + boolean result = engine.isRoutedToCorrectMachine(chain, machineName); + + // Explicit variable targeting completely conflicts + assertFalse(result, "Explicit variable target mismatching the machine name should be rejected"); + } +}