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 398c3f4..8d3ee3b 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 @@ -70,7 +70,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { } } - if (hasPolyMatch || (polyEvents.isEmpty() && (isWildcard || smEvent.equals(triggerEvent) || triggerEvent.toLowerCase().contains(smEvent.toLowerCase()) || smEvent.toLowerCase().contains(triggerEvent.toLowerCase())))) { + if (hasPolyMatch || (polyEvents.isEmpty() && (isWildcard || smEvent.equals(triggerEvent)))) { // Event matches or is a wildcard for (State smSourceState : t.getSourceStates()) { String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName(); @@ -157,4 +157,31 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { // For remaining full caps with underscores (like EVENT_X), keep as is or try to simplify return simplified; } -} + + private boolean isGuardedContains(String smEvent, String triggerEvent) { + // Only fire if it looks like a simple identifier (no spaces, no parens) + // Prevents unresolved AST nodes (e.g. "event.getPayload()") from accidentally matching "Event" + if (smEvent.contains(" ") || smEvent.contains("(") || smEvent.contains(")") || + triggerEvent.contains(" ") || triggerEvent.contains("(") || triggerEvent.contains(")")) { + return false; + } + + String shorter = smEvent.length() < triggerEvent.length() ? smEvent : triggerEvent; + String longer = smEvent.length() >= triggerEvent.length() ? smEvent : triggerEvent; + + shorter = shorter.toLowerCase(); + longer = longer.toLowerCase(); + + if (longer.contains(shorter)) { + // Guard: If the matching part is very short (< 4 chars), it must be bounded by word separators + // to avoid matching completely unrelated words (e.g., "PAY" matching "PAYMENT" or "E" matching "UPDATE") + if (shorter.length() < 4) { + return longer.matches(".*(^|_|-)" + shorter + "(_|-|$).*"); + } + // For longer strings, a straight contains is usually safe enough + return true; + } + return false; + } + +} \ No newline at end of file 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 7f3ee03..0db191c 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 @@ -159,12 +159,34 @@ public class CallGraphBuilder { } String methodName = resolvedValue.substring(lastDot + 1, openParen); + String declaredType = null; + String sourceMethod = null; + if (varName != null) { - // Resolve in the first method in the path where the variable might be declared for (String methodFqn : path) { - String declaredType = getVariableDeclaredType(methodFqn, varName); + declaredType = getVariableDeclaredType(methodFqn, varName); + if (declaredType != null) { + sourceMethod = methodFqn; + break; + } + } + } else { + String baseExpr = resolvedValue.substring(0, lastDot); + if (baseExpr.contains("new ")) { + int newIdx = baseExpr.indexOf("new "); + int openParenBase = baseExpr.indexOf('(', newIdx); + if (openParenBase > newIdx) { + declaredType = baseExpr.substring(newIdx + 4, openParenBase).trim(); + if (declaredType.contains("<")) { + declaredType = declaredType.substring(0, declaredType.indexOf('<')); + } + sourceMethod = "inline-instantiation"; + } + } + } + if (declaredType != null) { - System.out.println("DEEP TRACE: " + methodFqn + " " + varName + " -> " + declaredType); + System.out.println("DEEP TRACE: " + sourceMethod + " " + (varName != null ? varName : "inline") + " -> " + declaredType); List typesToInspect = new ArrayList<>(); typesToInspect.add(declaredType); typesToInspect.addAll(context.getImplementations(declaredType)); @@ -172,14 +194,13 @@ public class CallGraphBuilder { for (String type : typesToInspect) { Set visited = new HashSet<>(); - // We must find the compilation unit to pass for simple names! - // Let's pass the first available CU for this class TypeDeclaration baseTd = context.getTypeDeclaration(type); CompilationUnit cuToUse = null; if (baseTd != null && baseTd.getRoot() instanceof CompilationUnit) { cuToUse = (CompilationUnit) baseTd.getRoot(); } else { - String entryClassName = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + String firstPathMethod = path.get(0); + String entryClassName = firstPathMethod.substring(0, firstPathMethod.lastIndexOf('.')); TypeDeclaration entryTd = context.getTypeDeclaration(entryClassName); if (entryTd != null && entryTd.getRoot() instanceof CompilationUnit) { cuToUse = (CompilationUnit) entryTd.getRoot(); @@ -193,9 +214,6 @@ public class CallGraphBuilder { } } } - break; - } - } } } } 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 904adaf..a14bc36 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 @@ -192,4 +192,30 @@ class TransitionLinkerEnricherTest { CallChain updatedChain = result.getMetadata().getCallChains().get(0); assertThat(updatedChain.getMatchedTransitions()).hasSize(1); } -} + + @Test + void shouldNotFalsePositiveOnSubstringContains() { + // This test proves that the heuristic is entirely removed and false positives are eliminated + Transition t1 = new Transition(); + t1.setSourceStates(List.of(State.of("NEW", "NEW"))); + t1.setTargetStates(List.of(State.of("PAID", "PAID"))); + t1.setEvent(Event.of("PAY", "PAY")); + + // Trigger point sends "PAYMENT" which contains "PAY". Under the old heuristics, this would falsely match! + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("PAYMENT").build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .transitions(List.of(t1)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + // It should NOT match, because "PAYMENT" != "PAY" strictly. + CallChain updatedChain = result.getMetadata().getCallChains().get(0); + assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty(); + } + +} \ 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 cea823a..b6377a0 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 @@ -1337,4 +1337,187 @@ class CallGraphBuilderTest { org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("STRING_LITERAL_EVENT"); } + + @Test + void shouldResolveConstantsFromInlineInstantiation() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void handleStatus() { + service.process(new DomainEvent().getEvent()); + } + } + + class DomainEvent { + public String getEvent() { return "INLINE_EVENT"; } + } + + class OrderService { + public void process(String event) {} + } + """; + Path tempDir = Files.createTempDirectory("callgraph_inline_inst"); + 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()).containsExactlyInAnyOrder("INLINE_EVENT"); + } + + + @Test + void shouldResolveConstantsFromInlineInstantiationWithArguments() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void handleStatus() { + service.process(new DomainEvent("PAYLOAD_ID").getEvent()); + } + } + + class DomainEvent { + private String id; + public DomainEvent(String id) { this.id = id; } + public String getEvent() { return "INLINE_EVENT_WITH_ARGS"; } + } + + class OrderService { + public void process(String event) {} + } + """; + Path tempDir = Files.createTempDirectory("callgraph_inline_inst_args"); + 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()).containsExactlyInAnyOrder("INLINE_EVENT_WITH_ARGS"); + } + + + @Test + void shouldResolveConstantsFromInlineInstantiationWithGenerics() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void handleStatus() { + service.process(new DomainEvent().getEvent()); + } + } + + class DomainEvent { + public String getEvent() { return "INLINE_EVENT_WITH_GENERICS"; } + } + + class OrderService { + public void process(String event) {} + } + """; + Path tempDir = Files.createTempDirectory("callgraph_inline_inst_gen"); + 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()).containsExactlyInAnyOrder("INLINE_EVENT_WITH_GENERICS"); + } + + + @Test + void shouldResolveConstantsFromInlineInstantiationInheritedMethod() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void handleStatus() { + service.process(new SpecificDomainEvent().getEvent()); + } + } + + abstract class BaseDomainEvent { + public String getEvent() { return "BASE_EVENT_RETURN"; } + } + + class SpecificDomainEvent extends BaseDomainEvent { + // no getEvent here, it inherits it! + } + + class OrderService { + public void process(String event) {} + } + """; + Path tempDir = Files.createTempDirectory("callgraph_inline_inst_hier"); + 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()).containsExactlyInAnyOrder("BASE_EVENT_RETURN"); + } + + + @Test + void shouldResolveConstantsFromInlineInstantiationWithComplexArguments() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void handleStatus(Order o, String abc) { + service.process(new DomainEvent(o.getId(), abc).getEvent()); + } + } + + class Order { + public String getId() { return "123"; } + } + + class DomainEvent { + private String id; + private String extra; + public DomainEvent(String id, String extra) { this.id = id; this.extra = extra; } + public String getEvent() { return "INLINE_EVENT_COMPLEX_ARGS"; } + } + + class OrderService { + public void process(String event) {} + } + """; + Path tempDir = Files.createTempDirectory("callgraph_inline_inst_complex_args"); + 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()).containsExactlyInAnyOrder("INLINE_EVENT_COMPLEX_ARGS"); + } + } \ No newline at end of file