This commit is contained in:
2026-07-16 18:50:33 +02:00
parent e067412db9
commit 1d9c986793
3 changed files with 141 additions and 117 deletions

View File

@@ -2,7 +2,6 @@ package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.enricher.path.SymbolicPathValueEstimator; import click.kamil.springstatemachineexporter.analysis.enricher.path.SymbolicPathValueEstimator;
import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
@@ -29,10 +28,12 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* Narrows wide call-graph polymorphic widens to endpoint-specific enum constants. * Narrows wide call-graph polymorphic widens using dataflow evidence only.
* *
* <p>Uses multiple independent evidence sources (REST path, command keys, constraints, static trigger * <p>Evidence sources: static trigger events, path-binding map values, branch constraints,
* events, method-chain literals, camelCase method names) and only narrows when evidence converges. * sendEvent enum literals along the method chain, and AST path-value estimation.
* REST URL path segments and method-name tokens are intentionally not used — those are not
* dataflow and falsely link endpoints to transitions.
*/ */
public final class CallChainPolyNarrower { public final class CallChainPolyNarrower {
@@ -122,10 +123,9 @@ public final class CallChainPolyNarrower {
CodebaseContext context, CodebaseContext context,
HintVotes votes) { HintVotes votes) {
collectFromStaticTriggerEvent(trigger, configuredConstants, votes); collectFromStaticTriggerEvent(trigger, configuredConstants, votes);
collectFromEntryPoint(chain != null ? chain.getEntryPoint() : null, configuredConstants, votes);
collectFromConstraint(trigger.getConstraint(), configuredConstants, votes); collectFromConstraint(trigger.getConstraint(), configuredConstants, votes);
if (chain != null) { if (chain != null) {
collectFromMethodChain(chain.getMethodChain(), configuredConstants, votes); collectFromPathBindings(chain.getPathBindings(), configuredConstants, votes);
collectFromSendEventLiterals(chain.getMethodChain(), configuredConstants, context, votes); collectFromSendEventLiterals(chain.getMethodChain(), configuredConstants, context, votes);
collectFromPathEstimator(chain, configuredConstants, context, votes); collectFromPathEstimator(chain, configuredConstants, context, votes);
} }
@@ -145,41 +145,33 @@ public final class CallChainPolyNarrower {
addEnumReference(event, configuredConstants, votes, STRONG); addEnumReference(event, configuredConstants, votes, STRONG);
} }
private static void collectFromEntryPoint( /**
EntryPoint entryPoint, * Path bindings come from {@code PathBindingEvaluator} / call-graph dataflow, not from URL text.
*/
private static void collectFromPathBindings(
Map<String, String> pathBindings,
Set<String> configuredConstants, Set<String> configuredConstants,
HintVotes votes) { HintVotes votes) {
if (entryPoint == null) { if (pathBindings == null || pathBindings.isEmpty()) {
return; return;
} }
if (entryPoint.getName() != null) { for (Map.Entry<String, String> entry : pathBindings.entrySet()) {
collectFromPath(entryPoint.getName(), configuredConstants, votes); String key = entry.getKey();
} String value = entry.getValue();
if (entryPoint.getMetadata() != null) { if (value == null || value.isBlank()) {
Object path = entryPoint.getMetadata().get("path");
if (path instanceof String pathValue) {
collectFromPath(pathValue, configuredConstants, votes);
}
}
if (entryPoint.getMethodName() != null) {
addUniqueIdentifierMatch(entryPoint.getMethodName(), configuredConstants, votes, WEAK);
}
}
private static void collectFromPath(String rawPath, Set<String> configuredConstants, HintVotes votes) {
if (rawPath == null || rawPath.isBlank()) {
return;
}
int space = rawPath.indexOf(' ');
String path = space >= 0 ? rawPath.substring(space + 1).trim() : rawPath.trim();
if (path.isBlank()) {
return;
}
for (String segment : path.split("/")) {
if (segment.isBlank() || segment.contains("{")) {
continue; continue;
} }
addPathToken(segment, configuredConstants, votes, STRONG); boolean eventLikeKey = key != null && (
key.equalsIgnoreCase("event")
|| key.equalsIgnoreCase("eventString")
|| key.equalsIgnoreCase("command")
|| key.equalsIgnoreCase("commandKey")
|| key.equalsIgnoreCase("action")
|| key.equalsIgnoreCase("actionKey")
|| key.toLowerCase(Locale.ROOT).endsWith("event"));
int weight = eventLikeKey ? STRONG : MEDIUM;
addEnumReference(value, configuredConstants, votes, weight);
addPathToken(value, configuredConstants, votes, weight);
} }
} }
@@ -222,30 +214,6 @@ public final class CallChainPolyNarrower {
} }
} }
private static void collectFromMethodChain(
List<String> methodChain,
Set<String> configuredConstants,
HintVotes votes) {
if (methodChain == null) {
return;
}
for (String methodFqn : methodChain) {
if (methodFqn == null || !methodFqn.contains(".")) {
continue;
}
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
addUniqueIdentifierMatch(methodName, configuredConstants, votes, WEAK);
for (String token : splitCamelCase(methodName)) {
addPathToken(token, configuredConstants, votes, WEAK);
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
int nestedDot = className.lastIndexOf('.');
if (nestedDot >= 0) {
addUniqueIdentifierMatch(className.substring(nestedDot + 1), configuredConstants, votes, WEAK);
}
}
}
private static void collectFromSendEventLiterals( private static void collectFromSendEventLiterals(
List<String> methodChain, List<String> methodChain,
Set<String> configuredConstants, Set<String> configuredConstants,
@@ -280,18 +248,28 @@ public final class CallChainPolyNarrower {
methodDeclaration.getBody().accept(new ASTVisitor() { methodDeclaration.getBody().accept(new ASTVisitor() {
@Override @Override
public boolean visit(MethodInvocation node) { public boolean visit(MethodInvocation node) {
if (!FIRE_METHOD_NAMES.contains(node.getName().getIdentifier())) { boolean fireSite = FIRE_METHOD_NAMES.contains(node.getName().getIdentifier());
return super.visit(node);
}
for (Object argument : node.arguments()) { for (Object argument : node.arguments()) {
if (!(argument instanceof Expression expression)) { if (!(argument instanceof Expression expression)) {
continue; continue;
} }
if (expression instanceof QualifiedName qualifiedName) { if (expression instanceof QualifiedName qualifiedName) {
addEnumReference(qualifiedName.getFullyQualifiedName(), configuredConstants, votes, STRONG); // Enum constant passed into the next hop (dataflow at the call site).
} else if (expression instanceof SimpleName simpleName) { addEnumReference(
addEnumReference(simpleName.getIdentifier(), configuredConstants, votes, STRONG); qualifiedName.getFullyQualifiedName(),
} else { configuredConstants,
votes,
STRONG);
} else if (fireSite && expression instanceof SimpleName simpleName) {
// Only accept UPPERCASE identifiers that are configured constants —
// plain parameter names like "event" must not vote.
String identifier = simpleName.getIdentifier();
if (identifier != null
&& identifier.equals(identifier.toUpperCase(Locale.ROOT))
&& configuredConstants.contains(identifier)) {
addEnumReference(identifier, configuredConstants, votes, STRONG);
}
} else if (fireSite) {
String resolved = resolver.resolve(expression, context); String resolved = resolver.resolve(expression, context);
if (resolved != null) { if (resolved != null) {
addEnumReference(resolved, configuredConstants, votes, STRONG); addEnumReference(resolved, configuredConstants, votes, STRONG);

View File

@@ -1136,9 +1136,7 @@ public final class MachineEnumCanonicalizer {
List<String> boundEventLiterals = constraint != null && !constraint.isBlank() List<String> boundEventLiterals = constraint != null && !constraint.isBlank()
? extractBoundEventLiteralsFromConstraint(constraint) ? extractBoundEventLiteralsFromConstraint(constraint)
: List.of(); : List.of();
if (boundEventLiterals.size() != 1) { // Dataflow-only: never invent event constants from REST URL path segments.
boundEventLiterals = extractEventLiteralsFromEntryPointPath(entryPoint);
}
if (boundEventLiterals.size() != 1) { if (boundEventLiterals.size() != 1) {
return trigger; return trigger;
} }
@@ -1146,19 +1144,13 @@ public final class MachineEnumCanonicalizer {
if (eventTypeFqn == null || eventTypeFqn.isBlank()) { if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
return trigger; return trigger;
} }
String literal = boundEventLiterals.get(0).toUpperCase(); String literal = boundEventLiterals.get(0);
if (entryPoint != null && entryPoint.getName() != null // Command keys like "order.pay" are not enum constants — take the trailing segment.
&& !entryPoint.getName().toUpperCase().contains("/" + literal + "/") int lastDot = literal.lastIndexOf('.');
&& !entryPoint.getName().toUpperCase().endsWith("/" + literal)) { if (lastDot >= 0 && lastDot + 1 < literal.length()) {
return trigger; literal = literal.substring(lastDot + 1);
}
String machineTypeLiteral = constraint != null
? extractBoundParamLiteralFromConstraint(constraint, "machineType")
: null;
if (machineTypeLiteral != null && entryPoint != null && entryPoint.getName() != null
&& !entryPoint.getName().toUpperCase().contains("/" + machineTypeLiteral.toUpperCase() + "/")) {
return trigger;
} }
literal = literal.toUpperCase();
String constantFqn = eventTypeFqn + "." + literal; String constantFqn = eventTypeFqn + "." + literal;
if (context != null) { if (context != null) {
List<String> machineEnumValues = context.getEnumValues(eventTypeFqn); List<String> machineEnumValues = context.getEnumValues(eventTypeFqn);
@@ -1173,49 +1165,21 @@ public final class MachineEnumCanonicalizer {
.build(); .build();
} }
private static String extractBoundParamLiteralFromConstraint(String constraint, String paramName) {
java.util.regex.Matcher matcher = java.util.regex.Pattern
.compile("\"([^\"]+)\"\\.equalsIgnoreCase\\(" + paramName + "\\)")
.matcher(constraint);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
private static List<String> extractBoundEventLiteralsFromConstraint(String constraint) { private static List<String> extractBoundEventLiteralsFromConstraint(String constraint) {
List<String> literals = new ArrayList<>(); List<String> literals = new ArrayList<>();
java.util.regex.Matcher matcher = java.util.regex.Pattern java.util.regex.Matcher matcher = java.util.regex.Pattern
.compile("\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)") .compile("\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)")
.matcher(constraint); .matcher(constraint);
while (matcher.find()) { while (matcher.find()) {
if ("event".equals(matcher.group(2))) { String param = matcher.group(2);
if ("event".equals(param)
|| "eventString".equals(param)
|| "actionKey".equals(param)
|| "action".equals(param)
|| "commandKey".equals(param)) {
literals.add(matcher.group(1)); literals.add(matcher.group(1));
} }
} }
return literals; return literals;
} }
private static List<String> extractEventLiteralsFromEntryPointPath(EntryPoint entryPoint) {
if (entryPoint == null || entryPoint.getName() == null || entryPoint.getName().isBlank()) {
return List.of();
}
String rawPath = entryPoint.getName();
int space = rawPath.indexOf(' ');
String path = space >= 0 ? rawPath.substring(space + 1).trim() : rawPath.trim();
if (path.isBlank()) {
return List.of();
}
String[] segments = path.split("/");
for (int i = segments.length - 1; i >= 0; i--) {
String segment = segments[i];
if (segment.isBlank() || segment.contains("{")) {
continue;
}
if (segment.matches("[A-Z_][A-Z0-9_]*")) {
return List.of(segment);
}
}
return List.of();
}
} }

View File

@@ -24,10 +24,11 @@ class CallChainPolyNarrowerTest {
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher(); private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
@Test @Test
void shouldNarrowWidePolyUsingExpandedRestPathSegment() { void shouldNotNarrowUsingRestPathSegmentAlone() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY"); Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP"); Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
// Wide poly + URL containing /PAY must NOT invent a narrow — that is not dataflow.
CallChain chain = CallChain.builder() CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder() .entryPoint(EntryPoint.builder()
.name("POST /api/machine/ORDER/transition/PAY") .name("POST /api/machine/ORDER/transition/PAY")
@@ -58,6 +59,47 @@ class CallChainPolyNarrowerTest {
enricher.enrich(result, null, null); enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldNarrowUsingConstraintBindingEvidence() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/machine/ORDER/transition/PAY")
.className("com.example.MachineController")
.methodName("transition")
.build())
.methodChain(List.of(
"com.example.MachineController.transition",
"com.example.StateMachineDispatcher.fireOrder",
"com.example.StateMachine.send"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.constraint("\"PAY\".equalsIgnoreCase(event)")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0); CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1); assertThat(linked.getMatchedTransitions()).hasSize(1);
@@ -68,7 +110,7 @@ class CallChainPolyNarrowerTest {
} }
@Test @Test
void shouldNarrowWidePolyUsingRichDispatcherMethodName() { void shouldNotNarrowUsingMethodNameTokensAlone() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY"); Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP"); Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
@@ -103,6 +145,43 @@ class CallChainPolyNarrowerTest {
enricher.enrich(result, null, null); enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldNarrowUsingPathBindingsFromDataflow() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/orders/pay")
.className("com.example.OrderController")
.methodName("pay")
.build())
.pathBindings(java.util.Map.of("event", "com.example.OrderEvent.PAY"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",
"com.example.OrderEvent.CANCEL"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0); CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1); assertThat(linked.getMatchedTransitions()).hasSize(1);
@@ -293,15 +372,18 @@ class CallChainPolyNarrowerTest {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY"); Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP"); Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
// Without eventTypeFqn, path text must not invent PAY — require dataflow bindings/constraints.
CallChain chain = CallChain.builder() CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder() .entryPoint(EntryPoint.builder()
.name("POST /api/machine/ORDER/transition/PAY") .name("POST /api/machine/ORDER/transition/PAY")
.className("com.example.MachineController") .className("com.example.MachineController")
.methodName("transition") .methodName("transition")
.build()) .build())
.pathBindings(java.util.Map.of("event", "PAY"))
.triggerPoint(TriggerPoint.builder() .triggerPoint(TriggerPoint.builder()
.ambiguous(true) .ambiguous(true)
.event("OrderEvent.valueOf(eventStr)") .event("OrderEvent.valueOf(eventStr)")
.eventTypeFqn("com.example.OrderEvent")
.polymorphicEvents(List.of( .polymorphicEvents(List.of(
"com.example.OrderEvent.PAY", "com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP", "com.example.OrderEvent.SHIP",