A
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user