Compare commits
3 Commits
7ffd157105
...
873e2c91f3
| Author | SHA1 | Date | |
|---|---|---|---|
| 873e2c91f3 | |||
| 58d0701df5 | |||
| 422ea94b28 |
@@ -3,15 +3,13 @@ package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
public class CallChainEnricher implements AnalysisEnricher {
|
||||
@@ -19,32 +17,52 @@ public class CallChainEnricher implements AnalysisEnricher {
|
||||
@Override
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
log.info("Enriching {} with call chains", result.getName());
|
||||
|
||||
StateMachineTypeResolver.MachineTypes machineTypes =
|
||||
StateMachineTypeResolver.resolveTypes(result.getName(), context);
|
||||
|
||||
List<TriggerPoint> canonicalTriggers = intelligence.findTriggerPoints().stream()
|
||||
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
|
||||
.collect(Collectors.toList());
|
||||
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
|
||||
canonicalTriggers, result.getName(), context);
|
||||
|
||||
List<CallChain> chains = intelligence.findCallChains(
|
||||
result.getMetadata().getEntryPoints(),
|
||||
scopedTriggers
|
||||
);
|
||||
chains = chains.stream()
|
||||
.map(chain -> chain.getTriggerPoint() == null
|
||||
? chain
|
||||
: chain.toBuilder()
|
||||
.triggerPoint(MachineEnumCanonicalizer.canonicalizeTriggerPoint(
|
||||
chain.getTriggerPoint(), machineTypes))
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
chains = MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context);
|
||||
|
||||
List<CallChain> chains = buildCallChainsForMachine(result, context, intelligence, true);
|
||||
if (chains == null) {
|
||||
return;
|
||||
}
|
||||
result.addMetadata(CodebaseMetadata.builder()
|
||||
.callChains(chains)
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolves call chains from the codebase call graph for the given machine.
|
||||
* Used on JSON re-export when source is available to refresh {@code polymorphicEvents},
|
||||
* {@code external}, and branch {@code constraint} on trigger points.
|
||||
*/
|
||||
public static List<CallChain> buildCallChainsForMachine(
|
||||
AnalysisResult result,
|
||||
CodebaseContext context,
|
||||
CodebaseIntelligenceProvider intelligence) {
|
||||
return buildCallChainsForMachine(result, context, intelligence, false);
|
||||
}
|
||||
|
||||
public static List<CallChain> buildCallChainsForMachine(
|
||||
AnalysisResult result,
|
||||
CodebaseContext context,
|
||||
CodebaseIntelligenceProvider intelligence,
|
||||
boolean preferMetadataTriggers) {
|
||||
if (result.getMetadata() == null || intelligence == null) {
|
||||
return null;
|
||||
}
|
||||
List<EntryPoint> entryPoints = result.getMetadata().getEntryPoints();
|
||||
if (entryPoints == null || entryPoints.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<TriggerPoint> scopedTriggers = null;
|
||||
if (preferMetadataTriggers) {
|
||||
scopedTriggers = result.getMetadata().getTriggers();
|
||||
}
|
||||
if (scopedTriggers == null || scopedTriggers.isEmpty()) {
|
||||
scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
|
||||
intelligence.findTriggerPoints(),
|
||||
result.getName(),
|
||||
context);
|
||||
}
|
||||
|
||||
List<CallChain> chains = intelligence.findCallChains(entryPoints, scopedTriggers);
|
||||
return MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Extracts normalized domain keys from state machine configuration class names
|
||||
* (e.g. {@code StandardOrderStateMachineConfiguration} → {@code ORDER}).
|
||||
*/
|
||||
public final class MachineDomainKeys {
|
||||
|
||||
private MachineDomainKeys() {
|
||||
}
|
||||
|
||||
public static String extractMachineDomainKey(String machineName) {
|
||||
String simple = machineName.substring(machineName.lastIndexOf('.') + 1);
|
||||
String upper = simple.toUpperCase(Locale.ROOT);
|
||||
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) {
|
||||
int idx = upper.indexOf(marker);
|
||||
if (idx > 0) {
|
||||
return normalizeDomainKey(upper.substring(0, idx));
|
||||
}
|
||||
}
|
||||
int stateIdx = upper.indexOf("STATE");
|
||||
if (stateIdx > 0) {
|
||||
return normalizeDomainKey(upper.substring(0, stateIdx));
|
||||
}
|
||||
return normalizeDomainKey(upper);
|
||||
}
|
||||
|
||||
private static String normalizeDomainKey(String domain) {
|
||||
if (domain == null || domain.isEmpty()) {
|
||||
return domain;
|
||||
}
|
||||
String upper = domain.toUpperCase(Locale.ROOT);
|
||||
if (upper.startsWith("STANDARD") && upper.length() > "STANDARD".length()) {
|
||||
return upper.substring("STANDARD".length());
|
||||
}
|
||||
return upper;
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ public final class MachineScopeFilter {
|
||||
if (entryPoints == null || machineName == null) {
|
||||
return entryPoints == null ? List.of() : entryPoints;
|
||||
}
|
||||
String machineDomain = extractMachineDomainKey(machineName);
|
||||
String machineDomain = MachineDomainKeys.extractMachineDomainKey(machineName);
|
||||
List<EntryPoint> filtered = new ArrayList<>();
|
||||
for (EntryPoint entryPoint : entryPoints) {
|
||||
if (isEntryPointForMachine(entryPoint, machineDomain, machineName, context)) {
|
||||
@@ -92,8 +92,7 @@ public final class MachineScopeFilter {
|
||||
}
|
||||
|
||||
private static boolean isKnownDomainScopedMachine(String machineDomain) {
|
||||
String normalized = normalizeDomainKey(machineDomain);
|
||||
return "ORDER".equals(normalized) || "DOCUMENT".equals(normalized) || "USER".equals(normalized);
|
||||
return "ORDER".equals(machineDomain) || "DOCUMENT".equals(machineDomain) || "USER".equals(machineDomain);
|
||||
}
|
||||
|
||||
private static String inferDomainFromPath(String path) {
|
||||
@@ -108,38 +107,12 @@ public final class MachineScopeFilter {
|
||||
}
|
||||
|
||||
private static boolean domainsMatch(String pathDomain, String machineDomain) {
|
||||
String normalizedPath = normalizeDomainKey(pathDomain);
|
||||
String normalizedMachine = normalizeDomainKey(machineDomain);
|
||||
return normalizedPath.equalsIgnoreCase(normalizedMachine)
|
||||
|| normalizedMachine.startsWith(normalizedPath)
|
||||
|| normalizedPath.startsWith(normalizedMachine);
|
||||
}
|
||||
|
||||
private static String extractMachineDomainKey(String machineName) {
|
||||
String simple = machineName.substring(machineName.lastIndexOf('.') + 1);
|
||||
String upper = simple.toUpperCase(Locale.ROOT);
|
||||
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) {
|
||||
int idx = upper.indexOf(marker);
|
||||
if (idx > 0) {
|
||||
return normalizeDomainKey(upper.substring(0, idx));
|
||||
}
|
||||
if (pathDomain == null || machineDomain == null) {
|
||||
return false;
|
||||
}
|
||||
int stateIdx = upper.indexOf("STATE");
|
||||
if (stateIdx > 0) {
|
||||
return normalizeDomainKey(upper.substring(0, stateIdx));
|
||||
}
|
||||
return normalizeDomainKey(upper);
|
||||
}
|
||||
|
||||
private static String normalizeDomainKey(String domain) {
|
||||
if (domain == null || domain.isEmpty()) {
|
||||
return domain;
|
||||
}
|
||||
String upper = domain.toUpperCase(Locale.ROOT);
|
||||
if (upper.startsWith("STANDARD") && upper.length() > "STANDARD".length()) {
|
||||
return upper.substring("STANDARD".length());
|
||||
}
|
||||
return upper;
|
||||
return pathDomain.equalsIgnoreCase(machineDomain)
|
||||
|| machineDomain.startsWith(pathDomain)
|
||||
|| pathDomain.startsWith(machineDomain);
|
||||
}
|
||||
|
||||
private static boolean isTriggerForMachine(TriggerPoint trigger, String machineName, CodebaseContext context) {
|
||||
|
||||
@@ -8,19 +8,21 @@ import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMar
|
||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
|
||||
public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
|
||||
@@ -35,6 +37,12 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
|
||||
List<CallChain> updatedChains = new ArrayList<>();
|
||||
List<Transition> stateMachineTransitions = result.getTransitions();
|
||||
StateMachineTypeResolver.MachineTypes machineTypes =
|
||||
JsonExportContextFactory.resolveMachineTypes(
|
||||
result.getName(),
|
||||
result.getStateTypeFqn(),
|
||||
result.getEventTypeFqn(),
|
||||
context);
|
||||
|
||||
for (CallChain chain : result.getMetadata().getCallChains()) {
|
||||
TriggerPoint tp = chain.getTriggerPoint();
|
||||
@@ -43,12 +51,15 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
continue;
|
||||
}
|
||||
|
||||
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
tp, machineTypes, context, stateMachineTransitions, true);
|
||||
tp = markRestEndpointExternal(tp, chain);
|
||||
|
||||
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
|
||||
updatedChains.add(chain);
|
||||
continue;
|
||||
}
|
||||
|
||||
String triggerEvent = simplify(tp.getEvent());
|
||||
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null;
|
||||
|
||||
List<MatchedTransition> matched = new ArrayList<>();
|
||||
@@ -90,10 +101,16 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
}
|
||||
|
||||
if (!matched.isEmpty()) {
|
||||
CallChain newChain = chain.toBuilder().matchedTransitions(matched).build();
|
||||
CallChain newChain = chain.toBuilder()
|
||||
.triggerPoint(tp)
|
||||
.matchedTransitions(matched)
|
||||
.build();
|
||||
updatedChains.add(newChain);
|
||||
} else {
|
||||
updatedChains.add(chain);
|
||||
updatedChains.add(chain.toBuilder()
|
||||
.triggerPoint(tp)
|
||||
.matchedTransitions(null)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,30 +139,10 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
return true;
|
||||
}
|
||||
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
constraint, extractMachineDomainKey(machineName));
|
||||
}
|
||||
|
||||
private String extractMachineDomainKey(String machineName) {
|
||||
String simple = machineName.substring(machineName.lastIndexOf('.') + 1);
|
||||
String upper = simple.toUpperCase();
|
||||
|
||||
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) {
|
||||
int idx = upper.indexOf(marker);
|
||||
if (idx > 0) {
|
||||
return upper.substring(0, idx);
|
||||
}
|
||||
}
|
||||
|
||||
int stateIdx = upper.indexOf("STATE");
|
||||
if (stateIdx > 0) {
|
||||
return upper.substring(0, stateIdx);
|
||||
}
|
||||
|
||||
return upper;
|
||||
constraint, MachineDomainKeys.extractMachineDomainKey(machineName));
|
||||
}
|
||||
|
||||
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>();
|
||||
private final java.util.Map<java.util.Map.Entry<String, String>, Boolean> guardedCache = new java.util.HashMap<>();
|
||||
private static final java.util.regex.Pattern SUFFIX_PATTERN = java.util.regex.Pattern.compile("(?i)(.+)(Event|Action|Transition|Command)(s)?$");
|
||||
private static final java.util.regex.Pattern FQN_PATTERN = java.util.regex.Pattern.compile("^.*\\.([A-Z0-9_]+)$");
|
||||
|
||||
@@ -179,42 +176,23 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
|
||||
}
|
||||
|
||||
private boolean isGuardedContains(String smEvent, String triggerEvent) {
|
||||
if (smEvent == null || triggerEvent == null) return false;
|
||||
|
||||
// Use a strictly immutable pair to guarantee 100% collision-free caching regardless of string content
|
||||
java.util.Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(smEvent, triggerEvent);
|
||||
if (guardedCache.containsKey(cacheKey)) return guardedCache.get(cacheKey);
|
||||
|
||||
boolean result = calculateGuardedContains(smEvent, triggerEvent);
|
||||
guardedCache.put(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean calculateGuardedContains(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;
|
||||
private TriggerPoint markRestEndpointExternal(TriggerPoint trigger, CallChain chain) {
|
||||
if (trigger == null || trigger.isExternal() || chain.getEntryPoint() == null) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
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(".*(^|_|-)" + java.util.regex.Pattern.quote(shorter) + "(_|-|$).*");
|
||||
}
|
||||
// For longer strings, a straight contains is usually safe enough
|
||||
return true;
|
||||
EntryPoint entryPoint = chain.getEntryPoint();
|
||||
if (entryPoint.getType() != EntryPoint.Type.REST) {
|
||||
return trigger;
|
||||
}
|
||||
return false;
|
||||
String endpointName = entryPoint.getName();
|
||||
if (endpointName == null || !endpointName.contains("{")) {
|
||||
return trigger;
|
||||
}
|
||||
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
|
||||
!= MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
|
||||
return trigger;
|
||||
}
|
||||
return trigger.toBuilder().external(true).build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -26,7 +27,8 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
|
||||
return;
|
||||
}
|
||||
|
||||
StateMachineTypeResolver.MachineTypes machineTypes = resolveMachineTypes(result, context);
|
||||
StateMachineTypeResolver.MachineTypes machineTypes = JsonExportContextFactory.resolveMachineTypes(
|
||||
result.getName(), result.getStateTypeFqn(), result.getEventTypeFqn(), context);
|
||||
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) {
|
||||
return;
|
||||
}
|
||||
@@ -63,18 +65,4 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
|
||||
.properties(result.getMetadata().getProperties())
|
||||
.build());
|
||||
}
|
||||
|
||||
private static StateMachineTypeResolver.MachineTypes resolveMachineTypes(
|
||||
AnalysisResult result, CodebaseContext context) {
|
||||
StateMachineTypeResolver.MachineTypes resolved =
|
||||
StateMachineTypeResolver.resolveTypes(result.getName(), context);
|
||||
if (resolved.stateTypeFqn() != null || resolved.eventTypeFqn() != null) {
|
||||
return resolved;
|
||||
}
|
||||
if (result.getStateTypeFqn() != null || result.getEventTypeFqn() != null) {
|
||||
return new StateMachineTypeResolver.MachineTypes(
|
||||
result.getStateTypeFqn(), result.getEventTypeFqn());
|
||||
}
|
||||
return StateMachineTypeResolver.MachineTypes.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import java.util.List;
|
||||
|
||||
@@ -51,7 +52,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null
|
||||
&& !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
||||
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
||||
return enumTypesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
|
||||
return MachineEnumCanonicalizer.enumTypesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,12 +100,12 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
|
||||
|
||||
if (eventTypeFqn != null) {
|
||||
return enumTypesMatch(eventTypeFqn, smEnumType);
|
||||
return MachineEnumCanonicalizer.enumTypesMatch(eventTypeFqn, smEnumType);
|
||||
}
|
||||
|
||||
if (triggerEvent.contains(".")) {
|
||||
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
||||
return enumTypesMatch(triggerEnumType, smEnumType);
|
||||
return MachineEnumCanonicalizer.enumTypesMatch(triggerEnumType, smEnumType);
|
||||
}
|
||||
|
||||
// Bare constant name (e.g. after Enum.name()) with no type context must not match every enum
|
||||
@@ -119,24 +120,6 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event;
|
||||
}
|
||||
|
||||
private boolean enumTypesMatch(String type1, String type2) {
|
||||
if (type1 == null || type2 == null) return false;
|
||||
if (type1.equals(type2)) return true;
|
||||
if (type1.endsWith("." + type2) || type2.endsWith("." + type1)) return true;
|
||||
|
||||
String simple1 = simpleName(type1);
|
||||
String simple2 = simpleName(type2);
|
||||
if (!simple1.equals(simple2)) return false;
|
||||
|
||||
// Same simple name only matches when at least one side is package-less (import-style reference).
|
||||
// Prevents com.foo.OrderEvents from matching com.bar.OrderEvents.
|
||||
return !type1.contains(".") || !type2.contains(".");
|
||||
}
|
||||
|
||||
private String simpleName(String name) {
|
||||
return name.contains(".") ? name.substring(name.lastIndexOf('.') + 1) : name;
|
||||
}
|
||||
|
||||
private boolean isStringTypeOrPrimitive(String typeFqn) {
|
||||
if (typeFqn == null) return false;
|
||||
return typeFqn.equals("String") || typeFqn.equals("java.lang.String") ||
|
||||
|
||||
@@ -345,7 +345,7 @@ public class ConstantResolver {
|
||||
String varName = fragment.getName().getIdentifier();
|
||||
declaredLocals.add(varName);
|
||||
if (fragment.getInitializer() != null) {
|
||||
String rhsVal = resolveExpressionWithParams(fragment.getInitializer(), localVars, context);
|
||||
String rhsVal = resolveExpressionWithParams(fragment.getInitializer(), localVars, context, visited);
|
||||
if (rhsVal == null) rhsVal = resolveInternal(fragment.getInitializer(), context, visited);
|
||||
if (rhsVal != null) {
|
||||
localVars.put(varName, rhsVal);
|
||||
@@ -380,7 +380,7 @@ public class ConstantResolver {
|
||||
}
|
||||
for (int i = 0; i < node.arguments().size() && i < sideEffectMd.parameters().size(); i++) {
|
||||
Expression arg = (Expression) node.arguments().get(i);
|
||||
String val = resolveExpressionWithParams(arg, localVars, context);
|
||||
String val = resolveExpressionWithParams(arg, localVars, context, visited);
|
||||
if (val == null) val = resolveInternal(arg, context, visited);
|
||||
if (val != null) {
|
||||
org.eclipse.jdt.core.dom.SingleVariableDeclaration param = (org.eclipse.jdt.core.dom.SingleVariableDeclaration) sideEffectMd.parameters().get(i);
|
||||
@@ -397,7 +397,7 @@ public class ConstantResolver {
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -410,7 +410,7 @@ public class ConstantResolver {
|
||||
varName = "this." + fa.getName().getIdentifier();
|
||||
}
|
||||
if (varName != null) {
|
||||
String rhsVal = resolveExpressionWithParams(assignment.getRightHandSide(), localVars, context);
|
||||
String rhsVal = resolveExpressionWithParams(assignment.getRightHandSide(), localVars, context, visited);
|
||||
if (rhsVal == null) rhsVal = resolveInternal(assignment.getRightHandSide(), context, visited);
|
||||
if (rhsVal != null) {
|
||||
localVars.put(varName, rhsVal);
|
||||
@@ -450,7 +450,7 @@ public class ConstantResolver {
|
||||
String result = evaluateSwitchExpression(se, localVars, context, visited);
|
||||
if (result != null) finalResult[0] = result;
|
||||
} else if (rs.getExpression() != null) {
|
||||
String result = resolveExpressionWithParams(rs.getExpression(), localVars, context);
|
||||
String result = resolveExpressionWithParams(rs.getExpression(), localVars, context, visited);
|
||||
if (result == null) result = resolveInternal(rs.getExpression(), context, visited);
|
||||
if (result != null) finalResult[0] = result;
|
||||
}
|
||||
@@ -463,7 +463,7 @@ public class ConstantResolver {
|
||||
}
|
||||
|
||||
private String evaluateSwitchStatement(org.eclipse.jdt.core.dom.SwitchStatement ss, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
|
||||
String switchVar = resolveExpressionWithParams(ss.getExpression(), paramValues, context);
|
||||
String switchVar = resolveExpressionWithParams(ss.getExpression(), paramValues, context, visited);
|
||||
if (switchVar == null) {
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
for (Object stmtObj : ss.statements()) {
|
||||
@@ -515,7 +515,7 @@ public class ConstantResolver {
|
||||
matchingCase = true;
|
||||
} else {
|
||||
for (Object exprObj : sc.expressions()) {
|
||||
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context);
|
||||
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context, visited);
|
||||
// Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback
|
||||
if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) {
|
||||
caseVal = caseSn.getIdentifier();
|
||||
@@ -550,7 +550,7 @@ public class ConstantResolver {
|
||||
}
|
||||
|
||||
private String evaluateSwitchExpression(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
|
||||
String switchVar = resolveExpressionWithParams(se.getExpression(), paramValues, context);
|
||||
String switchVar = resolveExpressionWithParams(se.getExpression(), paramValues, context, visited);
|
||||
if (switchVar == null) {
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
for (Object stmtObj : se.statements()) {
|
||||
@@ -602,7 +602,7 @@ public class ConstantResolver {
|
||||
matchingCase = true;
|
||||
} else {
|
||||
for (Object exprObj : sc.expressions()) {
|
||||
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context);
|
||||
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues, context, visited);
|
||||
// Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback
|
||||
if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) {
|
||||
caseVal = caseSn.getIdentifier();
|
||||
@@ -636,7 +636,8 @@ public class ConstantResolver {
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveExpressionWithParams(Expression expr, java.util.Map<String, String> paramValues, CodebaseContext context) {
|
||||
private String resolveExpressionWithParams(
|
||||
Expression expr, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
|
||||
if (expr instanceof SimpleName sn) {
|
||||
String name = sn.getIdentifier();
|
||||
if (paramValues.containsKey(name)) return paramValues.get(name);
|
||||
@@ -658,7 +659,7 @@ public class ConstantResolver {
|
||||
arg = (Expression) mi.arguments().get(1);
|
||||
}
|
||||
if (arg != null) {
|
||||
String resolvedArg = resolveExpressionWithParams(arg, paramValues, context);
|
||||
String resolvedArg = resolveExpressionWithParams(arg, paramValues, context, visited);
|
||||
if (resolvedArg != null && resolvedArg.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
|
||||
String enumFqn = null;
|
||||
if (mi.arguments().size() == 2) {
|
||||
@@ -705,14 +706,14 @@ public class ConstantResolver {
|
||||
}
|
||||
for (int i = 0; i < mi.arguments().size() && i < sideEffectMd.parameters().size(); i++) {
|
||||
Expression arg = (Expression) mi.arguments().get(i);
|
||||
String val = resolveExpressionWithParams(arg, paramValues, context);
|
||||
if (val == null) val = resolveInternal(arg, context, new java.util.HashSet<>());
|
||||
String val = resolveExpressionWithParams(arg, paramValues, context, visited);
|
||||
if (val == null) val = resolveInternal(arg, context, visited);
|
||||
if (val != null) {
|
||||
org.eclipse.jdt.core.dom.SingleVariableDeclaration param = (org.eclipse.jdt.core.dom.SingleVariableDeclaration) sideEffectMd.parameters().get(i);
|
||||
inlineLocals.put(param.getName().getIdentifier(), val);
|
||||
}
|
||||
}
|
||||
String ret = evaluateMethodBodyWithLocals(sideEffectMd, inlineLocals, context, new java.util.HashSet<>());
|
||||
String ret = evaluateMethodBodyWithLocals(sideEffectMd, inlineLocals, context, visited);
|
||||
for (java.util.Map.Entry<String, String> entry : inlineLocals.entrySet()) {
|
||||
if (entry.getKey().startsWith("this.")) {
|
||||
paramValues.put(entry.getKey(), entry.getValue());
|
||||
@@ -736,7 +737,7 @@ public class ConstantResolver {
|
||||
if (!"build".equals(methodName) && !"builder".equals(methodName) && !"toBuilder".equals(methodName)) {
|
||||
if (current.arguments().size() == 1) {
|
||||
Expression arg = (Expression) current.arguments().get(0);
|
||||
String val = resolveExpressionWithParams(arg, localVars, context);
|
||||
String val = resolveExpressionWithParams(arg, localVars, context, visited);
|
||||
if (val == null) val = resolveInternal(arg, context, visited);
|
||||
if (val != null) fields.put(methodName, val);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,103 @@ public final class MachineEnumCanonicalizer {
|
||||
return canonical.toBuilder().polymorphicEvents(expanded).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* When a call-chain trigger uses a dynamic fire-site expression ({@code valueOf}, variable, getter)
|
||||
* but has no concrete {@code polymorphicEvents}, infer all machine enum events so transition linking
|
||||
* can proceed. Used only for {@code callChains[].triggerPoint}, not {@code metadata.triggers}.
|
||||
*/
|
||||
public static TriggerPoint ensureCallChainPolymorphicEvents(
|
||||
TriggerPoint trigger,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context) {
|
||||
return ensureCallChainPolymorphicEvents(trigger, machineTypes, context, null, false);
|
||||
}
|
||||
|
||||
public static TriggerPoint ensureCallChainPolymorphicEvents(
|
||||
TriggerPoint trigger,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions) {
|
||||
return ensureCallChainPolymorphicEvents(trigger, machineTypes, context, machineTransitions, false);
|
||||
}
|
||||
|
||||
public static TriggerPoint ensureCallChainPolymorphicEvents(
|
||||
TriggerPoint trigger,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
CodebaseContext context,
|
||||
List<Transition> machineTransitions,
|
||||
boolean skipCanonicalization) {
|
||||
TriggerPoint expanded = skipCanonicalization
|
||||
? trigger
|
||||
: canonicalizeAndExpandTriggerPoint(trigger, machineTypes, context);
|
||||
if (expanded == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
|
||||
return expanded;
|
||||
}
|
||||
if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())) {
|
||||
return expanded;
|
||||
}
|
||||
if (classifyTriggerEvent(expanded.getEvent()) != TriggerEventKind.DYNAMIC_EXPRESSION) {
|
||||
return expanded;
|
||||
}
|
||||
List<String> machineEvents = allPackageCanonicalEnumConstants(machineTypes.eventTypeFqn(), context);
|
||||
if (machineEvents.isEmpty()) {
|
||||
machineEvents = polymorphicEventsFromTransitions(machineTransitions, machineTypes.eventTypeFqn());
|
||||
}
|
||||
if (machineEvents.isEmpty()) {
|
||||
return expanded;
|
||||
}
|
||||
return expanded.toBuilder().polymorphicEvents(machineEvents).ambiguous(true).build();
|
||||
}
|
||||
|
||||
static List<String> polymorphicEventsFromTransitions(
|
||||
List<Transition> machineTransitions,
|
||||
String eventTypeFqn) {
|
||||
if (machineTransitions == null || machineTransitions.isEmpty() || eventTypeFqn == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> events = new ArrayList<>();
|
||||
for (Transition transition : machineTransitions) {
|
||||
if (transition.getEvent() == null) {
|
||||
continue;
|
||||
}
|
||||
String identifier = transition.getEvent().fullIdentifier() != null
|
||||
? transition.getEvent().fullIdentifier()
|
||||
: transition.getEvent().rawName();
|
||||
if (identifier == null || identifier.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String canonical = canonicalizeLabel(identifier, eventTypeFqn);
|
||||
if (classifyTriggerEvent(canonical) == TriggerEventKind.CANONICAL_ENUM
|
||||
&& !events.contains(canonical)) {
|
||||
events.add(canonical);
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private static boolean hasConcretePolymorphicEvents(List<String> polymorphicEvents) {
|
||||
return polymorphicEvents != null
|
||||
&& polymorphicEvents.stream()
|
||||
.anyMatch(pe -> classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM);
|
||||
}
|
||||
|
||||
private static List<String> allPackageCanonicalEnumConstants(String eventTypeFqn, CodebaseContext context) {
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank() || context == null) {
|
||||
return List.of();
|
||||
}
|
||||
String enumType = stripGenerics(eventTypeFqn);
|
||||
List<String> enumValues = context.getEnumValues(enumType);
|
||||
if (enumValues == null || enumValues.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> canonical = new ArrayList<>();
|
||||
for (String value : enumValues) {
|
||||
String constant = value.contains(".") ? value.substring(value.lastIndexOf('.') + 1) : value;
|
||||
canonical.add(enumType + "." + constant);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
public static String qualifyEventIdentifier(String event, String eventTypeFqn) {
|
||||
if (event == null || eventTypeFqn == null || event.isEmpty()) {
|
||||
return event;
|
||||
@@ -419,6 +516,12 @@ public final class MachineEnumCanonicalizer {
|
||||
return enumTypeFqn + "." + constant;
|
||||
}
|
||||
|
||||
if (typePart != null && !typePart.contains(".") && !enumTypesMatch(enumTypeFqn, typePart)
|
||||
&& importStyleEnumTypeMatches(typePart, enumTypeFqn)
|
||||
&& constant.matches("[A-Z_][A-Z0-9_]*")) {
|
||||
return enumTypeFqn + "." + constant;
|
||||
}
|
||||
|
||||
if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) {
|
||||
return enumTypeFqn + "." + stripped;
|
||||
}
|
||||
@@ -446,7 +549,12 @@ public final class MachineEnumCanonicalizer {
|
||||
return ref.substring(0, dot);
|
||||
}
|
||||
|
||||
static boolean enumTypesMatch(String type1, String type2) {
|
||||
private static boolean importStyleEnumTypeMatches(String typePart, String enumTypeFqn) {
|
||||
String simple = simpleName(enumTypeFqn);
|
||||
return simple.equals(typePart) || (simple + "s").equals(typePart);
|
||||
}
|
||||
|
||||
public static boolean enumTypesMatch(String type1, String type2) {
|
||||
if (type1 == null || type2 == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
@@ -177,7 +178,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
TriggerPoint resolved = resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef);
|
||||
if (resolved == null) return null;
|
||||
String entryMethod = path.isEmpty() ? (tp.getClassName() + "." + tp.getMethodName()) : path.get(0);
|
||||
boolean isExternal = isExternalParameter(entryMethod, finalParamNameRef[0]);
|
||||
boolean isExternal = isExternalTrigger(entryMethod, finalParamNameRef[0], path, callGraph);
|
||||
|
||||
String pathConstraint = extractPathConstraints(path, callGraph);
|
||||
String triggerConstraint = resolved.getConstraint();
|
||||
@@ -1021,7 +1022,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
polymorphicEvents = narrowPolymorphicEventsWithPathBindings(polymorphicEvents, path, callGraph, tp);
|
||||
|
||||
Expression runtimeExpr = exprNode instanceof Expression expression ? expression : null;
|
||||
if (isRuntimeEnumParameter(runtimeExpr) || tp.isExternal()) {
|
||||
boolean hasConcretePolymorphic = polymorphicEvents.stream()
|
||||
.anyMatch(pe -> pe != null && !pe.startsWith("<SYMBOLIC:"));
|
||||
if (hasConcretePolymorphic
|
||||
&& (isRuntimeEnumParameter(runtimeExpr) || tp.isExternal()
|
||||
|| MachineEnumCanonicalizer.isDynamicTriggerExpression(resolvedValue))) {
|
||||
polymorphicEvents.removeIf(pe -> pe != null && pe.startsWith("<SYMBOLIC:"));
|
||||
}
|
||||
|
||||
@@ -2730,10 +2735,28 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (!(exprNode instanceof MethodInvocation mi)) {
|
||||
return false;
|
||||
}
|
||||
if (!"valueOf".equals(mi.getName().getIdentifier()) || mi.arguments().size() != 1) {
|
||||
if (!"valueOf".equals(mi.getName().getIdentifier()) || mi.arguments().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return mi.arguments().get(0) instanceof SimpleName;
|
||||
Expression arg = (Expression) mi.arguments().get(0);
|
||||
return !(arg instanceof StringLiteral);
|
||||
}
|
||||
|
||||
private boolean isExternalTrigger(
|
||||
String entryMethod,
|
||||
String finalParamName,
|
||||
List<String> path,
|
||||
Map<String, List<CallEdge>> callGraph) {
|
||||
if (isExternalParameter(entryMethod, finalParamName)) {
|
||||
return true;
|
||||
}
|
||||
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
|
||||
for (String boundName : bindings.keySet()) {
|
||||
if (isExternalParameter(entryMethod, boundName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<String> resolveKeyedMapLookup(
|
||||
@@ -2860,7 +2883,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (hasConcrete) {
|
||||
narrowed.removeIf(pe -> pe != null && pe.startsWith("<SYMBOLIC:"));
|
||||
}
|
||||
if (tp.isExternal()) {
|
||||
if (tp.isExternal() && hasConcrete) {
|
||||
narrowed.removeIf(pe -> pe != null && pe.startsWith("<SYMBOLIC:"));
|
||||
}
|
||||
return narrowed;
|
||||
|
||||
@@ -2,6 +2,13 @@ package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.AnalysisEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher;
|
||||
@@ -67,6 +74,52 @@ public class EnrichmentService {
|
||||
intelligence);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON re-export path: when source and entry points are available, rebuild call chains from the
|
||||
* call graph so dispatcher triggers regain {@code polymorphicEvents}/{@code external}, then relink.
|
||||
*/
|
||||
public void refreshCallChainsAndRelink(
|
||||
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
if (result.getMetadata() != null && context != null && intelligence != null) {
|
||||
List<CallChain> refreshed = CallChainEnricher.buildCallChainsForMachine(result, context, intelligence);
|
||||
if (refreshed != null) {
|
||||
List<CallChain> merged = mergeCallChainsByEntryPoint(result.getMetadata().getCallChains(), refreshed);
|
||||
result.setMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
|
||||
.triggers(result.getMetadata().getTriggers())
|
||||
.entryPoints(result.getMetadata().getEntryPoints())
|
||||
.callChains(merged)
|
||||
.properties(result.getMetadata().getProperties())
|
||||
.build());
|
||||
}
|
||||
}
|
||||
relinkAfterPropertyResolution(result, context, intelligence);
|
||||
}
|
||||
|
||||
private static List<CallChain> mergeCallChainsByEntryPoint(
|
||||
List<CallChain> existing,
|
||||
List<CallChain> refreshed) {
|
||||
Map<String, CallChain> byEntryPoint = new LinkedHashMap<>();
|
||||
if (existing != null) {
|
||||
for (CallChain chain : existing) {
|
||||
byEntryPoint.putIfAbsent(entryPointKey(chain.getEntryPoint()), chain);
|
||||
}
|
||||
}
|
||||
for (CallChain chain : refreshed) {
|
||||
byEntryPoint.put(entryPointKey(chain.getEntryPoint()), chain);
|
||||
}
|
||||
return new ArrayList<>(byEntryPoint.values());
|
||||
}
|
||||
|
||||
private static String entryPointKey(EntryPoint entryPoint) {
|
||||
if (entryPoint == null) {
|
||||
return "";
|
||||
}
|
||||
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
|
||||
return entryPoint.getName();
|
||||
}
|
||||
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
|
||||
}
|
||||
|
||||
private static void runEnrichers(
|
||||
List<AnalysisEnricher> enrichers,
|
||||
AnalysisResult result,
|
||||
|
||||
@@ -16,21 +16,42 @@ public final class JsonExportContextFactory {
|
||||
private JsonExportContextFactory() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the nearest ancestor directory that looks like a Gradle/Maven module
|
||||
* ({@code build.gradle} or {@code pom.xml}), not the monorepo {@code settings.gradle} root.
|
||||
*/
|
||||
public static Path findProjectRoot(Path start) {
|
||||
Path current = start != null ? start.toAbsolutePath().normalize() : null;
|
||||
Path settingsRoot = null;
|
||||
Path buildRoot = null;
|
||||
while (current != null) {
|
||||
if (settingsRoot == null && Files.exists(current.resolve("settings.gradle"))) {
|
||||
settingsRoot = current;
|
||||
}
|
||||
if (buildRoot == null && (Files.exists(current.resolve("pom.xml"))
|
||||
|| Files.exists(current.resolve("build.gradle")))) {
|
||||
buildRoot = current;
|
||||
if (Files.exists(current.resolve("build.gradle")) || Files.exists(current.resolve("pom.xml"))) {
|
||||
return current;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
return settingsRoot != null ? settingsRoot : buildRoot;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when the scanned context actually contains classes referenced by the imported JSON. */
|
||||
public static boolean contextContainsAnalysisSources(
|
||||
click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result,
|
||||
CodebaseContext context) {
|
||||
if (context == null || result == null) {
|
||||
return false;
|
||||
}
|
||||
if (result.getName() != null && context.getTypeDeclaration(result.getName()) != null) {
|
||||
return true;
|
||||
}
|
||||
if (result.getMetadata() == null || result.getMetadata().getEntryPoints() == null) {
|
||||
return false;
|
||||
}
|
||||
for (click.kamil.springstatemachineexporter.analysis.model.EntryPoint entryPoint
|
||||
: result.getMetadata().getEntryPoints()) {
|
||||
if (entryPoint.getClassName() != null
|
||||
&& context.getTypeDeclaration(entryPoint.getClassName()) != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static CodebaseContext scanProjectRoot(Path projectRoot, List<String> activeProfiles) throws IOException {
|
||||
|
||||
@@ -193,8 +193,21 @@ public class ExportService {
|
||||
result.getStateTypeFqn(),
|
||||
result.getEventTypeFqn(),
|
||||
context);
|
||||
// Canonicalize transitions/states for relink matching; trigger/callChain canonicalization
|
||||
// here is superseded by refreshCallChainsAndRelink / relinkAfterPropertyResolution
|
||||
// (TriggerCanonicalizationEnricher runs again before TransitionLinkerEnricher).
|
||||
AnalysisResultFinalizer.applyCanonicalization(result, context, machineTypes);
|
||||
enrichmentService.relinkAfterPropertyResolution(result, context, null);
|
||||
if (context != null && sourceRoot != null
|
||||
&& click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
|
||||
.contextContainsAnalysisSources(result, context)
|
||||
&& result.getMetadata() != null
|
||||
&& result.getMetadata().getEntryPoints() != null
|
||||
&& !result.getMetadata().getEntryPoints().isEmpty()) {
|
||||
JdtIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, sourceRoot);
|
||||
enrichmentService.refreshCallChainsAndRelink(result, context, intelligence);
|
||||
} else {
|
||||
enrichmentService.relinkAfterPropertyResolution(result, context, null);
|
||||
}
|
||||
AnalysisResultFinalizer.finalizeResult(result, context, machineTypes);
|
||||
|
||||
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Regression: dispatcher REST chains with {@code external=false}, dynamic trigger event, and empty
|
||||
* {@code polymorphicEvents} must still link transitions after package-canonical FQN normalization.
|
||||
*/
|
||||
class DispatcherExternalFalseMatchedTransitionsRegressionTest {
|
||||
|
||||
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
|
||||
|
||||
@Test
|
||||
void shouldLinkWhenExternalFalseDynamicTriggerAndPolymorphicEventsWereLost() {
|
||||
Transition pay = new Transition();
|
||||
pay.setSourceStates(List.of(State.of("OrderState.NEW", "com.example.order.OrderState.NEW")));
|
||||
pay.setTargetStates(List.of(State.of("OrderState.PAID", "com.example.order.OrderState.PAID")));
|
||||
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("event")
|
||||
.external(false)
|
||||
.polymorphicEvents(null)
|
||||
.constraint("\"ORDER\".equalsIgnoreCase(machineType)")
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfiguration")
|
||||
.stateTypeFqn("com.example.order.OrderState")
|
||||
.eventTypeFqn("com.example.order.OrderEvent")
|
||||
.transitions(List.of(pay))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions())
|
||||
.hasSize(1)
|
||||
.first()
|
||||
.extracting("event")
|
||||
.isEqualTo("com.example.order.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLinkWhenPolymorphicEventsUseWrongEnumPrefixAfterFqnNormalization() {
|
||||
Transition pay = new Transition();
|
||||
pay.setSourceStates(List.of(State.of("OrderState.NEW", "com.example.order.OrderState.NEW")));
|
||||
pay.setTargetStates(List.of(State.of("OrderState.PAID", "com.example.order.OrderState.PAID")));
|
||||
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("e")
|
||||
.external(false)
|
||||
.polymorphicEvents(List.of("OrderEvents.PAY"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfiguration")
|
||||
.stateTypeFqn("com.example.order.OrderState")
|
||||
.eventTypeFqn("com.example.order.OrderEvent")
|
||||
.transitions(List.of(pay))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
new TriggerCanonicalizationEnricher().enrich(result, null, null);
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions())
|
||||
.hasSize(1)
|
||||
.first()
|
||||
.extracting("event")
|
||||
.isEqualTo("com.example.order.OrderEvent.PAY");
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ class EnricherPipelineRegressionTest {
|
||||
.build();
|
||||
|
||||
CallChain rawChain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/orders/pay")
|
||||
.className("com.example.web.OrderController")
|
||||
.methodName("pay")
|
||||
.build())
|
||||
.triggerPoint(rawTrigger)
|
||||
.build();
|
||||
|
||||
@@ -74,12 +80,12 @@ class EnricherPipelineRegressionTest {
|
||||
.name("com.example.config.OrderStateMachineConfiguration")
|
||||
.transitions(List.of(transition))
|
||||
.metadata(CodebaseMetadata.builder()
|
||||
.entryPoints(List.of())
|
||||
.entryPoints(List.of(rawChain.getEntryPoint()))
|
||||
.triggers(List.of(rawTrigger))
|
||||
.callChains(List.of(rawChain))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
new CallChainEnricher().enrich(result, context, intelligence);
|
||||
new TriggerCanonicalizationEnricher().enrich(result, context, intelligence);
|
||||
new TransitionLinkerEnricher().enrich(result, context, intelligence);
|
||||
|
||||
|
||||
@@ -233,6 +233,44 @@ class MachineEnumCanonicalizerTest {
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRemapMismatchedEnumPrefixToMachineEventType(@TempDir Path tempDir) throws IOException {
|
||||
writeSampleConfig(tempDir);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
|
||||
"com.example.config.OrderStateMachineConfiguration", context);
|
||||
|
||||
assertThat(MachineEnumCanonicalizer.canonicalizeLabel("OrderEvents.PAY", types.eventTypeFqn()))
|
||||
.isEqualTo("com.example.order.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldInferPolymorphicEventsFromTransitionsWhenContextIsUnavailable() {
|
||||
Transition pay = new Transition();
|
||||
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
|
||||
Transition ship = new Transition();
|
||||
ship.setEvent(Event.of("OrderEvent.SHIP", "com.example.order.OrderEvent.SHIP"));
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.event("OrderEvent.valueOf(eventString)")
|
||||
.build();
|
||||
|
||||
StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes(
|
||||
"com.example.order.OrderState", "com.example.order.OrderEvent");
|
||||
|
||||
TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
trigger, types, null, List.of(pay, ship));
|
||||
|
||||
assertThat(linked.getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder(
|
||||
"com.example.order.OrderEvent.PAY",
|
||||
"com.example.order.OrderEvent.SHIP");
|
||||
assertThat(linked.isAmbiguous()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDedupeStatesByCanonicalFullIdentifier(@TempDir Path tempDir) throws IOException {
|
||||
writeSampleConfig(tempDir);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.*;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
@@ -80,7 +81,7 @@ class DispatcherEndpointTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotLinkTransitionsForExternalGenericDispatchEndpoint(@TempDir Path tempDir) throws Exception {
|
||||
void shouldLinkTransitionsForGenericDispatchWhenPolymorphicEventsAreExpanded(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
@@ -99,9 +100,20 @@ class DispatcherEndpointTest {
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
enum OrderState { NEW, PAID, SHIPPED }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
Files.createDirectories(tempDir.resolve("com/example/config"));
|
||||
Files.writeString(tempDir.resolve("com/example/config/OrderStateMachineConfig.java"),
|
||||
"""
|
||||
package com.example.config;
|
||||
import com.example.OrderEvent;
|
||||
import com.example.OrderState;
|
||||
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
|
||||
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
|
||||
}
|
||||
""");
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
@@ -117,20 +129,26 @@ class DispatcherEndpointTest {
|
||||
.build();
|
||||
CallChain chain = engine.findChains(List.of(entry), List.of(trigger)).get(0);
|
||||
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents()).isEmpty();
|
||||
|
||||
Transition payT = new Transition();
|
||||
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
|
||||
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
|
||||
payT.setSourceStates(List.of(State.of("OrderState.NEW", "com.example.OrderState.NEW")));
|
||||
payT.setTargetStates(List.of(State.of("OrderState.PAID", "com.example.OrderState.PAID")));
|
||||
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
|
||||
Transition shipT = new Transition();
|
||||
shipT.setSourceStates(List.of(State.of("OrderState.PAID", "com.example.OrderState.PAID")));
|
||||
shipT.setTargetStates(List.of(State.of("OrderState.SHIPPED", "com.example.OrderState.SHIPPED")));
|
||||
shipT.setEvent(Event.of("OrderEvent.SHIP", "com.example.OrderEvent.SHIP"));
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("OrderStateMachineConfig")
|
||||
.transitions(List.of(payT))
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.transitions(List.of(payT, shipT))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
new TransitionLinkerEnricher().enrich(result, null, null);
|
||||
new TriggerCanonicalizationEnricher().enrich(result, context, null);
|
||||
new TransitionLinkerEnricher().enrich(result, context, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNull();
|
||||
TriggerPoint linkedTrigger = result.getMetadata().getCallChains().get(0).getTriggerPoint();
|
||||
assertThat(linkedTrigger.getPolymorphicEvents())
|
||||
.contains("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP");
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
|
||||
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
|
||||
import click.kamil.springstatemachineexporter.service.ExportService;
|
||||
import click.kamil.springstatemachineexporter.service.JsonImportService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Regression for REST dispatcher chains that lost {@code polymorphicEvents} during JSON round-trip.
|
||||
*/
|
||||
class EnterpriseDispatcherMatchedTransitionsRegressionTest {
|
||||
|
||||
@Test
|
||||
void jsonReExportWithSourceShouldRestoreDispatcherMatchedTransitions(@TempDir Path tempDir) throws Exception {
|
||||
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
|
||||
|
||||
Path astOut = tempDir.resolve("ast");
|
||||
ExportService exportService = new ExportService(List.of(new JsonExporter()));
|
||||
exportService.runExporter(enterprise, astOut, List.of("json"), true, List.of(), null, null,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
|
||||
Path astJson = astOut.resolve(
|
||||
"click.kamil.enterprise.machines.order.OrderStateMachineConfiguration/click.kamil.enterprise.machines.order.OrderStateMachineConfiguration.json");
|
||||
|
||||
AnalysisResult imported = new JsonImportService().importAnalysisResult(astJson);
|
||||
List<CallChain> corruptedChains = imported.getMetadata().getCallChains().stream()
|
||||
.map(chain -> {
|
||||
if (chain.getTriggerPoint() == null
|
||||
|| chain.getMethodChain() == null
|
||||
|| !chain.getMethodChain().stream()
|
||||
.anyMatch(m -> m.contains("StateMachineDispatcher.dispatch"))) {
|
||||
return chain;
|
||||
}
|
||||
return chain.toBuilder()
|
||||
.triggerPoint(chain.getTriggerPoint().toBuilder()
|
||||
.event("event")
|
||||
.external(false)
|
||||
.polymorphicEvents(null)
|
||||
.build())
|
||||
.matchedTransitions(null)
|
||||
.build();
|
||||
})
|
||||
.toList();
|
||||
imported.setMetadata(CodebaseMetadata.builder()
|
||||
.triggers(imported.getMetadata().getTriggers())
|
||||
.entryPoints(imported.getMetadata().getEntryPoints())
|
||||
.callChains(corruptedChains)
|
||||
.properties(imported.getMetadata().getProperties())
|
||||
.build());
|
||||
|
||||
Path brokenJson = tempDir.resolve("broken-order-machine.json");
|
||||
Files.writeString(brokenJson, new JsonExporter().export(imported, ExportOptions.builder().build()));
|
||||
|
||||
Path outputDir = tempDir.resolve("out");
|
||||
exportService.runJsonExporter(
|
||||
brokenJson, outputDir, List.of("json"), List.of(),
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
enterprise);
|
||||
|
||||
Path finalized = outputDir.resolve(
|
||||
"click.kamil.enterprise.machines.order.OrderStateMachineConfiguration/click.kamil.enterprise.machines.order.OrderStateMachineConfiguration.json");
|
||||
AnalysisResult roundTripped = new JsonImportService().importAnalysisResult(finalized);
|
||||
|
||||
assertThat(roundTripped.getMetadata().getCallChains())
|
||||
.as("call chains after re-export")
|
||||
.isNotEmpty();
|
||||
|
||||
CallChain chain = roundTripped.getMetadata().getCallChains().stream()
|
||||
.filter(c -> c.getMethodChain() != null && c.getMethodChain().stream()
|
||||
.anyMatch(m -> m.contains("StateMachineDispatcher.dispatch")))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"no dispatcher chain among: "
|
||||
+ roundTripped.getMetadata().getCallChains().stream()
|
||||
.map(c -> c.getEntryPoint() != null ? c.getEntryPoint().getName() : "null")
|
||||
.toList()));
|
||||
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.as("polymorphicEvents on %s", chain.getEntryPoint().getName())
|
||||
.isNotNull()
|
||||
.isNotEmpty();
|
||||
assertThat(chain.getTriggerPoint().isExternal())
|
||||
.as("external flag on %s", chain.getEntryPoint().getName())
|
||||
.isTrue();
|
||||
assertThat(chain.getMatchedTransitions())
|
||||
.as("matchedTransitions on %s", chain.getEntryPoint().getName())
|
||||
.isNotNull()
|
||||
.isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void astExportTransitionEndpointShouldLinkMatchedTransitions(@TempDir Path tempDir) throws Exception {
|
||||
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
|
||||
ExportService exportService = new ExportService(List.of(new JsonExporter()));
|
||||
exportService.runExporter(enterprise, tempDir, List.of("json"), true, List.of(), null, null,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
|
||||
JsonNode json = readMachineJson(tempDir, "OrderStateMachineConfiguration", new ObjectMapper());
|
||||
JsonNode chain = findTransitionEndpointChain(json);
|
||||
|
||||
assertThat(chain.path("triggerPoint").path("external").asBoolean()).isTrue();
|
||||
assertThat(chain.path("triggerPoint").path("polymorphicEvents").isArray()).isTrue();
|
||||
assertThat(chain.path("triggerPoint").path("polymorphicEvents")).isNotEmpty();
|
||||
assertThat(chain.path("matchedTransitions").isArray()).isTrue();
|
||||
assertThat(chain.path("matchedTransitions")).isNotEmpty();
|
||||
}
|
||||
|
||||
private static JsonNode findTransitionEndpointChain(JsonNode machineJson) {
|
||||
for (JsonNode chain : machineJson.path("metadata").path("callChains")) {
|
||||
if ("POST /api/machine/{machineType}/transition/{event}".equals(
|
||||
chain.path("entryPoint").path("name").asText())) {
|
||||
return chain;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("transition endpoint chain not found");
|
||||
}
|
||||
|
||||
private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper)
|
||||
throws Exception {
|
||||
Path machineDir;
|
||||
try (var stream = Files.list(outputDir)) {
|
||||
machineDir = stream
|
||||
.filter(Files::isDirectory)
|
||||
.filter(path -> path.getFileName().toString().endsWith(configBaseName))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("No output for " + configBaseName));
|
||||
}
|
||||
return mapper.readTree(machineDir.resolve(machineDir.getFileName() + ".json").toFile());
|
||||
}
|
||||
|
||||
private static Path findProjectRoot() {
|
||||
Path current = Path.of(".").toAbsolutePath();
|
||||
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
|
||||
current = current.getParent();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user