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 4267849..1163428 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 @@ -146,46 +146,138 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { } - // Second, filter by vertical routing based on Service class name - if (chain.getMethodChain() != null) { - boolean hasElectronicsService = false; - boolean hasComputerStoreService = 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 className = method; - if (method.contains(".")) { - className = method.substring(0, method.lastIndexOf('.')); - if (className.contains(".")) { - className = className.substring(className.lastIndexOf('.') + 1); - } + 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; } - String lowerClass = className.toLowerCase(); - if (lowerClass.contains("electronics")) { - hasElectronicsService = true; - } else if (lowerClass.contains("computerstore")) { - hasComputerStoreService = 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; + } } } - String lowerMachine = currentMachineName != null ? currentMachineName.toLowerCase() : ""; - boolean isElectronicsMachine = lowerMachine.contains("electronics"); - // If the machine is an electronics machine, it must NOT be triggered by computer store - if (isElectronicsMachine && hasComputerStoreService && !hasElectronicsService) { + // 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 the machine is NOT an electronics machine (e.g. standard computer store SM), - // it must NOT be triggered by electronics services - if (!isElectronicsMachine && hasElectronicsService && !hasComputerStoreService) { - return false; - } + // If neither, we allow it (fallback for common/shared endpoints without distinct domains) } return true; } - private String simplify(String name) { + 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; + } + + private String simplify(String name) { if (name == null) return null; // Strip common suffixes String simplified = name.replaceAll("(?i)(.*)(Event|Action|Transition|Command)(s)?$", "$1"); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java index 0ade420..5fdc117 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java @@ -296,7 +296,11 @@ public class CallGraphBuilder { if (!visited.add(fqn)) return Collections.emptyList(); List constants = new ArrayList<>(); - TypeDeclaration td = contextCu != null ? context.getTypeDeclaration(className, contextCu) : context.getTypeDeclaration(className); + TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null; + if (tempTd == null) { + tempTd = context.getTypeDeclaration(className); + } + final TypeDeclaration td = tempTd; if (td == null) { System.out.println("DEEP TRACE FAILED TO FIND TD: " + className); } @@ -815,57 +819,109 @@ public class CallGraphBuilder { private List traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set visited) { List results = new ArrayList<>(); - for (MethodDeclaration md : td.getMethods()) { - if (md.isConstructor() && md.getBody() != null) { - md.getBody().accept(new ASTVisitor() { - @Override - public boolean visit(Assignment node) { - Expression left = node.getLeftHandSide(); - if ((left instanceof SimpleName && ((SimpleName) left).getIdentifier().equals(fieldName)) || - (left instanceof FieldAccess && ((FieldAccess) left).getName().getIdentifier().equals(fieldName))) { - String val = constantResolver.resolve(node.getRightHandSide(), context); - if (val != null) results.add(val); - } - return super.visit(node); + + // 1. Check Field Declarations + for (org.eclipse.jdt.core.dom.FieldDeclaration fd : td.getFields()) { + for (Object fragObj : fd.fragments()) { + org.eclipse.jdt.core.dom.VariableDeclarationFragment frag = (org.eclipse.jdt.core.dom.VariableDeclarationFragment) fragObj; + if (frag.getName().getIdentifier().equals(fieldName) && frag.getInitializer() != null) { + Expression right = frag.getInitializer(); + if (right instanceof MethodInvocation mi) { + String calledName = mi.getName().getIdentifier(); + String targetClass = context.getFqn(td); + CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null; + results.addAll(resolveMethodReturnConstant(targetClass, calledName, 0, new HashSet<>(visited), cu)); + } else { + String val = constantResolver.resolve(right, context); + if (val != null) results.add(val); } - @Override - public boolean visit(ConstructorInvocation node) { - for (Object argObj : node.arguments()) { - Expression arg = (Expression) argObj; - String val = constantResolver.resolve(arg, context); - if (val != null) { - if (val.startsWith("ENUM_SET:")) { - for (String eVal : val.substring(9).split(",")) { - results.add(eVal.substring(eVal.lastIndexOf('.') + 1)); - } - } else { - results.add(val); - } - } - } - return super.visit(node); - } - - @Override - public boolean visit(SuperConstructorInvocation node) { - for (Object argObj : node.arguments()) { - Expression arg = (Expression) argObj; - String val = constantResolver.resolve(arg, context); - if (val != null) { - if (val.startsWith("ENUM_SET:")) { - for (String eVal : val.substring(9).split(",")) { - results.add(eVal.substring(eVal.lastIndexOf('.') + 1)); - } - } else { - results.add(val); - } - } - } - return super.visit(node); - } - }); + } } } + + org.eclipse.jdt.core.dom.ASTVisitor assignmentVisitor = new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(Assignment node) { + Expression left = node.getLeftHandSide(); + if ((left instanceof SimpleName && ((SimpleName) left).getIdentifier().equals(fieldName)) || + (left instanceof FieldAccess && ((FieldAccess) left).getName().getIdentifier().equals(fieldName))) { + + Expression right = node.getRightHandSide(); + if (right instanceof MethodInvocation mi) { + String calledName = mi.getName().getIdentifier(); + String targetClass = context.getFqn(td); + + if (mi.getExpression() != null && mi.resolveMethodBinding() != null && mi.resolveMethodBinding().getDeclaringClass() != null) { + targetClass = mi.resolveMethodBinding().getDeclaringClass().getQualifiedName(); + } else if (mi.getExpression() instanceof SimpleName) { + String exprName = ((SimpleName) mi.getExpression()).getIdentifier(); + CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null; + TypeDeclaration targetTd = context.resolveStaticImport(exprName, cu); + if (targetTd != null) { + targetClass = context.getFqn(targetTd); + } + } + + CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null; + results.addAll(resolveMethodReturnConstant(targetClass, calledName, 0, new HashSet<>(visited), cu)); + } else { + String val = constantResolver.resolve(right, context); + if (val != null) results.add(val); + } + } + return super.visit(node); + } + @Override + public boolean visit(ConstructorInvocation node) { + for (Object argObj : node.arguments()) { + Expression arg = (Expression) argObj; + String val = constantResolver.resolve(arg, context); + if (val != null) { + if (val.startsWith("ENUM_SET:")) { + for (String eVal : val.substring(9).split(",")) { + results.add(eVal.substring(eVal.lastIndexOf('.') + 1)); + } + } else { + results.add(val); + } + } + } + return super.visit(node); + } + + @Override + public boolean visit(SuperConstructorInvocation node) { + for (Object argObj : node.arguments()) { + Expression arg = (Expression) argObj; + String val = constantResolver.resolve(arg, context); + if (val != null) { + if (val.startsWith("ENUM_SET:")) { + for (String eVal : val.substring(9).split(",")) { + results.add(eVal.substring(eVal.lastIndexOf('.') + 1)); + } + } else { + results.add(val); + } + } + } + return super.visit(node); + } + }; + + // 2. Check Constructors + for (MethodDeclaration md : td.getMethods()) { + if (md.isConstructor() && md.getBody() != null) { + md.getBody().accept(assignmentVisitor); + } + } + + // 3. Check Initializers + for (Object bodyDecl : td.bodyDeclarations()) { + if (bodyDecl instanceof org.eclipse.jdt.core.dom.Initializer init && init.getBody() != null) { + init.getBody().accept(assignmentVisitor); + } + } + return results; } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java index 97106d7..98d7461 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java @@ -229,14 +229,14 @@ class TransitionLinkerEnricherTest { CallChain chain = CallChain.builder() .triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build()) .methodChain(List.of( - "com.example.ElectronicsOrderController.post()", - "com.example.ElectronicsOrderService.processOrderEvent()" + "com.example.electronics.ElectronicsOrderController.post()", + "com.example.electronics.ElectronicsOrderService.processOrderEvent()" )) .build(); // Testing against Electronics SM - should match AnalysisResult resultElectronics = AnalysisResult.builder() - .name("ElectronicsOwnDeliveryStateMachineConfiguration") + .name("com.example.electronics.ElectronicsOwnDeliveryStateMachineConfiguration") .transitions(List.of(t1)) .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) .build(); @@ -247,7 +247,7 @@ class TransitionLinkerEnricherTest { // Testing against ComputerStore SM - should NOT match because chain has Electronics service AnalysisResult resultComputer = AnalysisResult.builder() - .name("OwnDeliveryStateMachineConfiguration") // the non-electronics one + .name("com.example.computerstore.OwnDeliveryStateMachineConfiguration") // the non-electronics one .transitions(List.of(t1)) .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) .build(); @@ -275,7 +275,7 @@ class TransitionLinkerEnricherTest { .build(); AnalysisResult resultElectronics = AnalysisResult.builder() - .name("ElectronicsOwnDeliveryStateMachineConfiguration") + .name("com.example.electronics.ElectronicsOwnDeliveryStateMachineConfiguration") .transitions(List.of(t1)) .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) .build(); @@ -287,7 +287,7 @@ class TransitionLinkerEnricherTest { assertThat(updatedChainElec.getMatchedTransitions()).hasSize(1); AnalysisResult resultComputer = AnalysisResult.builder() - .name("OwnDeliveryStateMachineConfiguration") + .name("com.example.computerstore.OwnDeliveryStateMachineConfiguration") .transitions(List.of(t1)) .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) .build(); @@ -299,4 +299,83 @@ class TransitionLinkerEnricherTest { assertThat(updatedChainComp.getMatchedTransitions()).hasSize(1); } + + @Test + void shouldFilterCrossStateMachineRoutingWithMultipleVerticals() { + Transition t1 = new Transition(); + t1.setSourceStates(List.of(State.of("NEW", "NEW"))); + t1.setTargetStates(List.of(State.of("ASSEMBLED", "ASSEMBLED"))); + t1.setEvent(Event.of("ASSEMBLE", "ASSEMBLE")); + + // Chain 1: Electronics + CallChain chainElec = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build()) + .methodChain(List.of("com.example.electronics.ElectronicsOrderController.post()")) + .build(); + + // Chain 2: Furniture + CallChain chainFurn = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build()) + .methodChain(List.of("com.example.furniture.FurnitureOrderController.post()")) + .build(); + + // Chain 3: Groceries + CallChain chainGroc = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build()) + .methodChain(List.of("com.example.groceries.GroceriesOrderController.post()")) + .build(); + + // SM 1: Electronics + AnalysisResult resElec = AnalysisResult.builder() + .name("com.example.electronics.ElectronicsOwnDeliveryStateMachineConfiguration") + .transitions(List.of(t1)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build()) + .build(); + + // SM 2: Furniture + AnalysisResult resFurn = AnalysisResult.builder() + .name("com.example.furniture.FurnitureOwnDeliveryStateMachineConfiguration") + .transitions(List.of(t1)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build()) + .build(); + + // SM 3: Groceries + AnalysisResult resGroc = AnalysisResult.builder() + .name("com.example.groceries.GroceriesStateMachineConfiguration") + .transitions(List.of(t1)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build()) + .build(); + + // SM 4: Computer Store (The catch-all base one in computerstore package) + AnalysisResult resComp = AnalysisResult.builder() + .name("com.example.computerstore.OwnDeliveryStateMachineConfiguration") + .transitions(List.of(t1)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build()) + .build(); + + // Test Electronics SM - Should only keep Electronics Chain + enricher.enrich(resElec, null, null); + assertThat(resElec.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); // Elec + assertThat(resElec.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn + assertThat(resElec.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc + + // Test Furniture SM - Should only keep Furniture Chain + enricher.enrich(resFurn, null, null); + assertThat(resFurn.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec + assertThat(resFurn.getMetadata().getCallChains().get(1).getMatchedTransitions()).hasSize(1); // Furn + assertThat(resFurn.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc + + // Test Groceries SM - Should only keep Groceries Chain + enricher.enrich(resGroc, null, null); + assertThat(resGroc.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec + assertThat(resGroc.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn + assertThat(resGroc.getMetadata().getCallChains().get(2).getMatchedTransitions()).hasSize(1); // Groc + + // Test ComputerStore SM - Should reject ALL because none belong to computerstore + enricher.enrich(resComp, null, null); + assertThat(resComp.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec + assertThat(resComp.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn + assertThat(resComp.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc + } + } \ No newline at end of file diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilderTest.java index c5f5b1d..36ecf19 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilderTest.java @@ -1723,4 +1723,137 @@ class CallGraphBuilderTest { org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).contains("THIS_CALL_CONSTANT"); } + + @Test + void shouldResolveConstantsFromDynamicMethodAssignmentInConstructor() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void handleStatus() { + service.process(new OrderCancelledEvent().getType()); + } + } + + class OrderCancelledEvent { + private String cancelEventType; + public OrderCancelledEvent() { + this.cancelEventType = assertMatchingCancelEventType("some_sender"); + } + private String assertMatchingCancelEventType(String sender) { + return "CANCELLED_BY_SENDER"; + } + public String getType() { return cancelEventType; } + } + + class OrderService { + public void process(String event) {} + } + """; + Path tempDir = Files.createTempDirectory("callgraph_dynamic_rhs"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderController").methodName("handleStatus").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + org.assertj.core.api.Assertions.assertThat(chains).hasSize(1); + org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).contains("CANCELLED_BY_SENDER"); + } + + @Test + void shouldResolveConstantsFromAbstractSuperclass() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void handleStatus() { + service.process(new PostAcceptanceOrderModificationsRejectedEvent().getType()); + } + } + + abstract class AbstractPostAcceptanceOrderModificationsEvent { + private String event; + public AbstractPostAcceptanceOrderModificationsEvent(String event) { + this.event = event; + } + public String getType() { return event; } + } + + class PostAcceptanceOrderModificationsRejectedEvent extends AbstractPostAcceptanceOrderModificationsEvent { + public PostAcceptanceOrderModificationsRejectedEvent() { + super("POST_ACCEPTANCE_REJECTED"); + } + } + + class OrderService { + public void process(String event) {} + } + """; + Path tempDir = Files.createTempDirectory("callgraph_post_acc"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderController").methodName("handleStatus").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + org.assertj.core.api.Assertions.assertThat(chains).hasSize(1); + org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).contains("POST_ACCEPTANCE_REJECTED"); + } + + @Test + void shouldResolveConstantsFromFieldDeclarationsAndInitializers() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void handleStatus1() { + service.process(new EventWithFieldDecl().getType()); + } + public void handleStatus2() { + service.process(new EventWithInitBlock().getType()); + } + } + + class EventWithFieldDecl { + private String event = "FIELD_DECL"; + public String getType() { return event; } + } + + class EventWithInitBlock { + private String event; + { + event = "INIT_BLOCK"; + } + public String getType() { return event; } + } + + class OrderService { + public void process(String event) {} + } + """; + Path tempDir = Files.createTempDirectory("callgraph_field_init"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint ep1 = EntryPoint.builder().className("com.example.OrderController").methodName("handleStatus1").build(); + EntryPoint ep2 = EntryPoint.builder().className("com.example.OrderController").methodName("handleStatus2").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build(); + List chains = builder.findChains(List.of(ep1, ep2), List.of(trigger)); + + org.assertj.core.api.Assertions.assertThat(chains).hasSize(2); + + java.util.List allEvents = new java.util.ArrayList<>(); + chains.forEach(c -> allEvents.addAll(c.getTriggerPoint().getPolymorphicEvents())); + org.assertj.core.api.Assertions.assertThat(allEvents).containsExactlyInAnyOrder("FIELD_DECL", "INIT_BLOCK"); + } + } \ No newline at end of file