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.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
@@ -29,10 +28,12 @@ import java.util.regex.Matcher;
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
* events, method-chain literals, camelCase method names) and only narrows when evidence converges.
* <p>Evidence sources: static trigger events, path-binding map values, branch constraints,
* 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 {
@@ -122,10 +123,9 @@ public final class CallChainPolyNarrower {
CodebaseContext context,
HintVotes votes) {
collectFromStaticTriggerEvent(trigger, configuredConstants, votes);
collectFromEntryPoint(chain != null ? chain.getEntryPoint() : null, configuredConstants, votes);
collectFromConstraint(trigger.getConstraint(), configuredConstants, votes);
if (chain != null) {
collectFromMethodChain(chain.getMethodChain(), configuredConstants, votes);
collectFromPathBindings(chain.getPathBindings(), configuredConstants, votes);
collectFromSendEventLiterals(chain.getMethodChain(), configuredConstants, context, votes);
collectFromPathEstimator(chain, configuredConstants, context, votes);
}
@@ -145,41 +145,33 @@ public final class CallChainPolyNarrower {
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,
HintVotes votes) {
if (entryPoint == null) {
if (pathBindings == null || pathBindings.isEmpty()) {
return;
}
if (entryPoint.getName() != null) {
collectFromPath(entryPoint.getName(), configuredConstants, votes);
}
if (entryPoint.getMetadata() != null) {
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("{")) {
for (Map.Entry<String, String> entry : pathBindings.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (value == null || value.isBlank()) {
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(
List<String> methodChain,
Set<String> configuredConstants,
@@ -280,18 +248,28 @@ public final class CallChainPolyNarrower {
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (!FIRE_METHOD_NAMES.contains(node.getName().getIdentifier())) {
return super.visit(node);
}
boolean fireSite = FIRE_METHOD_NAMES.contains(node.getName().getIdentifier());
for (Object argument : node.arguments()) {
if (!(argument instanceof Expression expression)) {
continue;
}
if (expression instanceof QualifiedName qualifiedName) {
addEnumReference(qualifiedName.getFullyQualifiedName(), configuredConstants, votes, STRONG);
} else if (expression instanceof SimpleName simpleName) {
addEnumReference(simpleName.getIdentifier(), configuredConstants, votes, STRONG);
} else {
// Enum constant passed into the next hop (dataflow at the call site).
addEnumReference(
qualifiedName.getFullyQualifiedName(),
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);
if (resolved != null) {
addEnumReference(resolved, configuredConstants, votes, STRONG);

View File

@@ -1136,9 +1136,7 @@ public final class MachineEnumCanonicalizer {
List<String> boundEventLiterals = constraint != null && !constraint.isBlank()
? extractBoundEventLiteralsFromConstraint(constraint)
: List.of();
if (boundEventLiterals.size() != 1) {
boundEventLiterals = extractEventLiteralsFromEntryPointPath(entryPoint);
}
// Dataflow-only: never invent event constants from REST URL path segments.
if (boundEventLiterals.size() != 1) {
return trigger;
}
@@ -1146,19 +1144,13 @@ public final class MachineEnumCanonicalizer {
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
return trigger;
}
String literal = boundEventLiterals.get(0).toUpperCase();
if (entryPoint != null && entryPoint.getName() != null
&& !entryPoint.getName().toUpperCase().contains("/" + literal + "/")
&& !entryPoint.getName().toUpperCase().endsWith("/" + literal)) {
return trigger;
}
String machineTypeLiteral = constraint != null
? extractBoundParamLiteralFromConstraint(constraint, "machineType")
: null;
if (machineTypeLiteral != null && entryPoint != null && entryPoint.getName() != null
&& !entryPoint.getName().toUpperCase().contains("/" + machineTypeLiteral.toUpperCase() + "/")) {
return trigger;
String literal = boundEventLiterals.get(0);
// Command keys like "order.pay" are not enum constants — take the trailing segment.
int lastDot = literal.lastIndexOf('.');
if (lastDot >= 0 && lastDot + 1 < literal.length()) {
literal = literal.substring(lastDot + 1);
}
literal = literal.toUpperCase();
String constantFqn = eventTypeFqn + "." + literal;
if (context != null) {
List<String> machineEnumValues = context.getEnumValues(eventTypeFqn);
@@ -1173,49 +1165,21 @@ public final class MachineEnumCanonicalizer {
.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) {
List<String> literals = new ArrayList<>();
java.util.regex.Matcher matcher = java.util.regex.Pattern
.compile("\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)")
.matcher(constraint);
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));
}
}
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();
@Test
void shouldNarrowWidePolyUsingExpandedRestPathSegment() {
void shouldNotNarrowUsingRestPathSegmentAlone() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
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()
.entryPoint(EntryPoint.builder()
.name("POST /api/machine/ORDER/transition/PAY")
@@ -58,6 +59,47 @@ class CallChainPolyNarrowerTest {
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);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
@@ -68,7 +110,7 @@ class CallChainPolyNarrowerTest {
}
@Test
void shouldNarrowWidePolyUsingRichDispatcherMethodName() {
void shouldNotNarrowUsingMethodNameTokensAlone() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
@@ -103,6 +145,43 @@ class CallChainPolyNarrowerTest {
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);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
@@ -293,15 +372,18 @@ class CallChainPolyNarrowerTest {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
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()
.entryPoint(EntryPoint.builder()
.name("POST /api/machine/ORDER/transition/PAY")
.className("com.example.MachineController")
.methodName("transition")
.build())
.pathBindings(java.util.Map.of("event", "PAY"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.eventTypeFqn("com.example.OrderEvent")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",