Export linkResolution in JSON and tighten source-proven trigger scoping.

Wire enterprise regression tests for linkResolution, extend REST body enum detection to nested DTOs/records, infer if-branch source states only from provable literals, and fix String/java.lang.String routing equivalence so shared-service scoping stays fail-closed without dropping valid chains.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 18:10:13 +02:00
parent f8f64487d1
commit 169fae88ab
28 changed files with 1898 additions and 109 deletions

View File

@@ -24,7 +24,7 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
boolean mismatched = false;
if (triggerEventFqn != null && machineEventFqn != null) {
if (eraseGenerics(triggerEventFqn).equals(eraseGenerics(machineEventFqn))) {
if (typesEquivalent(triggerEventFqn, machineEventFqn)) {
matched = true;
} else if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
mismatched = true;
@@ -34,7 +34,7 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
}
}
if (triggerStateFqn != null && machineStateFqn != null) {
if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) {
if (typesEquivalent(triggerStateFqn, machineStateFqn)) {
matched = true;
} else if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
mismatched = true;
@@ -322,6 +322,27 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return type;
}
private boolean typesEquivalent(String type1, String type2) {
if (type1 == null || type2 == null) {
return false;
}
String erased1 = normalizePrimitiveFqn(eraseGenerics(type1));
String erased2 = normalizePrimitiveFqn(eraseGenerics(type2));
return erased1.equals(erased2);
}
private String normalizePrimitiveFqn(String type) {
if (type == null) {
return null;
}
return switch (type) {
case "java.lang.String" -> "String";
case "java.lang.Object" -> "Object";
case "java.io.Serializable" -> "Serializable";
default -> type;
};
}
private boolean isTypeMismatched(String type1, String type2) {
if (type1 == null || type2 == null) return false;
type1 = eraseGenerics(type1);

View File

@@ -5,6 +5,8 @@ import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.HashSet;
import java.util.Set;
/**
* Single source-derived policy for marking REST triggers as external vs internal.
@@ -173,6 +175,9 @@ public final class ExternalTriggerPolicy {
if (isEnumDtoField(bodyBinding, fieldName, context)) {
return new RestParamBinding(false, false, true, true);
}
if (containsNestedEnumField(bodyBinding, fieldName, context)) {
return new RestParamBinding(false, false, true, true);
}
}
return null;
}
@@ -218,6 +223,38 @@ public final class ExternalTriggerPolicy {
return false;
}
private static boolean containsNestedEnumField(
ITypeBinding bodyBinding, String fieldName, CodebaseContext context) {
return containsNestedEnumField(bodyBinding, fieldName, context, new HashSet<>());
}
private static boolean containsNestedEnumField(
ITypeBinding bodyBinding,
String fieldName,
CodebaseContext context,
Set<String> visited) {
if (bodyBinding == null || fieldName == null || context == null) {
return false;
}
String bodyFqn = bodyBinding.getQualifiedName();
if (bodyFqn == null || !visited.add(bodyFqn)) {
return false;
}
for (IVariableBinding component : bodyBinding.getDeclaredFields()) {
ITypeBinding nestedType = component.getType();
if (nestedType == null || nestedType.isPrimitive() || nestedType.isEnum()) {
continue;
}
if (isEnumRecordComponent(nestedType, fieldName) || isEnumDtoField(nestedType, fieldName, context)) {
return true;
}
if (containsNestedEnumField(nestedType, fieldName, context, visited)) {
return true;
}
}
return false;
}
private static boolean hasParameterAstAnnotation(SingleVariableDeclaration param, String simpleName) {
for (Object modifier : param.modifiers()) {
if (modifier instanceof Annotation annotation

View File

@@ -477,20 +477,15 @@ public class GenericEventDetector {
if (isRoutingParameter(left) || isRoutingParameter(right)) {
return null;
}
// Usually one is a method call like getState() or a variable like `state`
// and the other is the constant enum like `OrderState.PENDING` or `"PENDING"`
// If one is a QualifiedName (enum constant) or StringLiteral, it's likely the state
if (left instanceof QualifiedName || left instanceof StringLiteral || left instanceof FieldAccess) {
return getSimpleNameString(left);
String leftState = resolveProvableStateLiteral(left);
if (leftState != null) {
return leftState;
}
if (right instanceof QualifiedName || right instanceof StringLiteral || right instanceof FieldAccess) {
return getSimpleNameString(right);
String rightState = resolveProvableStateLiteral(right);
if (rightState != null) {
return rightState;
}
// Fallback
return getSimpleNameString(right);
}
} else if (expr instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier();
@@ -501,22 +496,12 @@ public class GenericEventDetector {
if (isRoutingParameter(receiver) || isRoutingParameter(arg)) {
return null;
}
// If receiver is null (e.g., implicit this), fall back to arg
if (receiver == null) {
return getSimpleNameString(arg);
String receiverState = resolveProvableStateLiteral(receiver);
if (receiverState != null) {
return receiverState;
}
// Prioritize the one that looks like a constant (QualifiedName or StringLiteral)
if (receiver instanceof QualifiedName || receiver instanceof StringLiteral || receiver instanceof FieldAccess) {
return getSimpleNameString(receiver);
}
if (arg instanceof QualifiedName || arg instanceof StringLiteral || arg instanceof FieldAccess) {
return getSimpleNameString(arg);
}
// Fallback to receiver
return getSimpleNameString(receiver);
return resolveProvableStateLiteral(arg);
}
}
return null;