CommandSupport.java
package org.egothor.methodatlas.command;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import org.egothor.methodatlas.AiResultCache;
import org.egothor.methodatlas.ClassificationOverride;
import org.egothor.methodatlas.CliConfig;
import org.egothor.methodatlas.TestMethodSink;
import org.egothor.methodatlas.ai.AiClassSuggestion;
import org.egothor.methodatlas.ai.AiMethodSuggestion;
import org.egothor.methodatlas.ai.AiOptions;
import org.egothor.methodatlas.ai.AiSuggestionEngine;
import org.egothor.methodatlas.ai.AiSuggestionEngineImpl;
import org.egothor.methodatlas.ai.AiSuggestionException;
import org.egothor.methodatlas.ai.PromptBuilder;
import org.egothor.methodatlas.ai.SuggestionLookup;
import org.egothor.methodatlas.api.DiscoveredMethod;
import org.egothor.methodatlas.api.SourcePatcher;
import org.egothor.methodatlas.api.TestDiscovery;
import org.egothor.methodatlas.api.TestDiscoveryConfig;
/**
* Shared static infrastructure used by two or more {@link Command} implementations.
*
* <p>
* This utility class centralises provider loading, scan orchestration, AI suggestion
* resolution, content hashing, and other cross-cutting concerns. All methods are
* static; this class cannot be instantiated.
* </p>
*
* <p>
* Methods marked {@code public} ({@link #requireUniqueDiscoveryIds},
* {@link #requireUniquePatcherIds}, {@link #computeFilePrefix},
* {@link #buildAiEngine}, {@link #buildAiCache}, and
* {@link #loadClassificationOverride}) are called either by
* {@link org.egothor.methodatlas.MethodAtlasApp} (a different package) or
* directly by unit tests; all other methods are package-private and intended
* for use within the {@code org.egothor.methodatlas.command} package only.
* </p>
*/
@SuppressWarnings("PMD.CyclomaticComplexity") // centralised utility class; total CC naturally high across many small methods
public final class CommandSupport {
private static final Logger LOG = Logger.getLogger(CommandSupport.class.getName());
private CommandSupport() {
}
// -------------------------------------------------------------------------
// AI runtime bundle (package-private record, shared by scan commands)
// -------------------------------------------------------------------------
/**
* Bundles the AI infrastructure that is constant for the duration of a scan run.
*
* @param options AI configuration
* @param engine AI engine; {@code null} when AI is disabled
* @param override human classification overrides
* @param cache AI result cache
*/
/* default */ record AiRuntime(AiOptions options, AiSuggestionEngine engine,
ClassificationOverride override, AiResultCache cache) {
}
// -------------------------------------------------------------------------
// Factory / loader methods
// -------------------------------------------------------------------------
/**
* Creates the AI suggestion engine for the current run.
*
* <p>
* Returns {@code null} when AI support is disabled. Initialization failures
* are wrapped in an {@link IllegalStateException}.
* </p>
*
* @param aiOptions AI configuration for the current run
* @return initialized AI suggestion engine, or {@code null} when AI is disabled
* @throws IllegalStateException if engine initialization fails
*/
public static AiSuggestionEngine buildAiEngine(AiOptions aiOptions) {
if (!aiOptions.enabled()) {
return null;
}
try {
return new AiSuggestionEngineImpl(aiOptions);
} catch (AiSuggestionException e) {
throw new IllegalStateException("Failed to initialize AI engine", e);
}
}
/**
* Loads the AI result cache from the given CSV file, or returns the empty
* no-op cache when no cache file is configured.
*
* @param cacheFile path to a previous MethodAtlas CSV output, or {@code null}
* @return loaded cache; never {@code null}
* @throws IllegalArgumentException if the file exists but cannot be read or
* parsed
*/
public static AiResultCache buildAiCache(Path cacheFile) {
if (cacheFile == null) {
return AiResultCache.empty();
}
try {
return AiResultCache.load(cacheFile);
} catch (IOException e) {
throw new IllegalArgumentException("Cannot load AI cache file: " + cacheFile, e);
}
}
/**
* Loads the classification override file, or returns the empty no-op
* singleton when no override file is configured.
*
* @param overrideFile path to the YAML override file, or {@code null}
* @return loaded override set; never {@code null}
* @throws IllegalArgumentException if the file exists but cannot be read or
* contains invalid YAML
*/
public static ClassificationOverride loadClassificationOverride(Path overrideFile) {
if (overrideFile == null) {
return ClassificationOverride.empty();
}
try {
return ClassificationOverride.load(overrideFile);
} catch (IOException e) {
throw new IllegalArgumentException("Cannot load override file: " + overrideFile, e);
}
}
// -------------------------------------------------------------------------
// Provider and patcher loading
// -------------------------------------------------------------------------
/**
* Loads all {@link TestDiscovery} providers registered via {@link ServiceLoader},
* configures each with {@code config}, and returns the list.
*
* <p>
* Providers are discovered from the classpath using the standard
* {@code META-INF/services/org.egothor.methodatlas.api.TestDiscovery}
* service file.
* </p>
*
* @param config runtime configuration forwarded to every provider via
* {@link TestDiscovery#configure}
* @return non-empty list of configured providers
* @throws IllegalStateException if no providers are found on the classpath
*/
@SuppressWarnings("PMD.CloseResource") // callers are responsible for closing providers via closeAll()
/* default */ static List<TestDiscovery> loadProviders(TestDiscoveryConfig config) {
List<TestDiscovery> providers = new ArrayList<>();
for (TestDiscovery provider : ServiceLoader.load(TestDiscovery.class)) {
provider.configure(config);
providers.add(provider);
}
if (providers.isEmpty()) {
throw new IllegalStateException(
"No TestDiscovery providers found on the classpath. "
+ "Ensure at least one provider JAR ships the service registration file "
+ "META-INF/services/org.egothor.methodatlas.api.TestDiscovery.");
}
requireUniqueDiscoveryIds(providers);
return providers;
}
/**
* Closes every provider in the list, logging any {@link IOException} at
* {@link Level#FINE} and continuing so that all providers are attempted.
*
* @param providers list of providers to close; never {@code null}
*/
@SuppressWarnings("PMD.CloseResource") // this method IS the close mechanism; p.close() is called explicitly
/* default */ static void closeAll(List<TestDiscovery> providers) {
for (TestDiscovery p : providers) {
try {
p.close();
} catch (IOException e) {
if (LOG.isLoggable(Level.FINE)) {
LOG.log(Level.FINE, "Failed to close provider " + p.pluginId(), e);
}
}
}
}
/**
* Loads all {@link SourcePatcher} providers registered via {@link ServiceLoader},
* configures each with {@code config}, and returns the list.
*
* @param config runtime configuration forwarded to every patcher via
* {@link SourcePatcher#configure}
* @return possibly-empty list of configured patchers
*/
/* default */ static List<SourcePatcher> loadPatchers(TestDiscoveryConfig config) {
List<SourcePatcher> patchers = new ArrayList<>();
for (SourcePatcher patcher : ServiceLoader.load(SourcePatcher.class)) {
patcher.configure(config);
patchers.add(patcher);
}
requireUniquePatcherIds(patchers);
return patchers;
}
/**
* Verifies that every {@link TestDiscovery} provider in the list has a
* unique {@link TestDiscovery#pluginId()}.
*
* @param providers list of configured providers
* @throws IllegalStateException if two or more providers share the same ID
*/
@SuppressWarnings("PMD.CloseResource") // providers are owned by the caller; this method does not close them
public static void requireUniqueDiscoveryIds(List<TestDiscovery> providers) {
Set<String> seen = new LinkedHashSet<>();
for (TestDiscovery p : providers) {
String id = p.pluginId();
if (!seen.add(id)) {
throw new IllegalStateException(
"Duplicate TestDiscovery plugin ID \"" + id + "\": two or more "
+ "registered providers claim the same pluginId(). "
+ "Each provider must declare a unique identifier.");
}
}
}
/**
* Verifies that every {@link SourcePatcher} in the list has a unique
* {@link SourcePatcher#pluginId()}.
*
* @param patchers list of configured patchers
* @throws IllegalStateException if two or more patchers share the same ID
*/
public static void requireUniquePatcherIds(List<SourcePatcher> patchers) {
Set<String> seen = new LinkedHashSet<>();
for (SourcePatcher p : patchers) {
String id = p.pluginId();
if (!seen.add(id)) {
throw new IllegalStateException(
"Duplicate SourcePatcher plugin ID \"" + id + "\": two or more "
+ "registered patchers claim the same pluginId(). "
+ "Each patcher must declare a unique identifier.");
}
}
}
// -------------------------------------------------------------------------
// Scan orchestration
// -------------------------------------------------------------------------
/**
* Scans all roots and forwards each discovered test method to {@code sink}.
*
* @param roots source roots to scan
* @param cliConfig full parsed CLI configuration
* @param discoveryConfig discovery configuration forwarded to providers
* @param aiEngine AI engine providing suggestions; may be {@code null}
* @param sink receiver of discovered test method records
* @param override human classification overrides
* @param aiCache AI result cache
* @return {@code 0} if all files were processed successfully, {@code 1} if any
* file produced a parse or processing error
* @throws IOException if traversing a file tree fails
*/
/* default */ static int scan(List<Path> roots, CliConfig cliConfig, TestDiscoveryConfig discoveryConfig,
AiSuggestionEngine aiEngine, TestMethodSink sink,
ClassificationOverride override, AiResultCache aiCache) throws IOException {
List<TestDiscovery> providers = loadProviders(discoveryConfig);
boolean hadErrors = false;
try {
for (Path root : roots) {
if (runDiscovery(root, providers, cliConfig.aiOptions(), aiEngine, sink,
cliConfig.contentHash(), override, aiCache)) {
hadErrors = true;
}
}
} finally {
closeAll(providers);
}
return hadErrors ? 1 : 0;
}
/**
* Runs all configured {@link TestDiscovery} providers on {@code root},
* merges their results, orchestrates AI analysis per class, and forwards
* each method record to {@code sink}.
*
* <p>
* All providers are run against every root, and their streams are merged
* before grouping by class. This supports multi-language scanning: a JVM
* provider and a .NET provider on the classpath will each scan their own
* file types and contribute distinct {@link DiscoveredMethod} records.
* </p>
*
* @param root directory to scan
* @param providers list of pre-configured discovery providers
* @param aiOptions AI configuration for the current run
* @param aiEngine AI engine, or {@code null} when AI is disabled
* @param sink receiver of discovered test method records
* @param contentHashEnabled whether to include the class content hash
* @param override human classification overrides
* @param aiCache AI result cache
* @return {@code true} if any provider encountered a parse or processing error
* @throws IOException if traversing the file tree fails
*/
@SuppressWarnings("PMD.CloseResource") // providers are owned by the caller; this method does not close them
/* default */ static boolean runDiscovery(Path root, List<TestDiscovery> providers,
AiOptions aiOptions, AiSuggestionEngine aiEngine, TestMethodSink sink,
boolean contentHashEnabled, ClassificationOverride override,
AiResultCache aiCache) throws IOException {
List<DiscoveredMethod> methods = new ArrayList<>();
boolean hadErrors = false;
for (TestDiscovery provider : providers) {
provider.discover(root).forEach(methods::add);
if (provider.hadErrors()) {
hadErrors = true;
}
}
Map<String, List<DiscoveredMethod>> byClass = methods.stream()
.collect(Collectors.groupingBy(DiscoveredMethod::fqcn,
LinkedHashMap::new, Collectors.toList()));
AiRuntime ai = new AiRuntime(aiOptions, aiEngine, override, aiCache);
for (Map.Entry<String, List<DiscoveredMethod>> entry : byClass.entrySet()) {
String fqcn = entry.getKey();
List<DiscoveredMethod> classMethods = entry.getValue();
String classSource = classMethods.get(0).sourceContent().get().orElse(null);
String lookupHash = (contentHashEnabled || aiCache.isActive()) && classSource != null
? computeContentHash(classSource) : null;
String outputHash = contentHashEnabled ? lookupHash : null;
String fileStem = classMethods.get(0).fileStem();
List<String> methodNames = classMethods.stream().map(DiscoveredMethod::method).toList();
List<PromptBuilder.TargetMethod> targetMethods = classMethods.stream()
.map(CommandSupport::toTargetMethod)
.toList();
SuggestionLookup suggestions = resolveSuggestionLookup(
fileStem, fqcn, classSource, methodNames, targetMethods, ai, lookupHash);
for (DiscoveredMethod m : classMethods) {
sink.record(m.fqcn(), m.method(), m.beginLine(), m.loc(), outputHash,
m.tags(), m.displayName(),
suggestions.find(m.method()).orElse(null));
}
}
return hadErrors;
}
/**
* Collects all discovered methods from every root and provider, keyed by
* source-file path. Methods whose {@link DiscoveredMethod#filePath()} is
* {@code null} are silently skipped.
*
* @param roots scan roots
* @param providers configured and already-loaded {@link TestDiscovery} providers
* @return mutable map from source-file path to the methods found in that file;
* insertion order matches discovery order
* @throws IOException if directory traversal fails for any root
*/
@SuppressWarnings({"PMD.AvoidInstantiatingObjectsInLoops",
"PMD.CloseResource"}) // providers are owned by the caller; this method does not close them
/* default */ static Map<Path, List<DiscoveredMethod>> collectMethodsByFile(
List<Path> roots, List<TestDiscovery> providers) throws IOException {
Map<Path, List<DiscoveredMethod>> byFile = new LinkedHashMap<>();
for (Path root : roots) {
for (TestDiscovery provider : providers) {
provider.discover(root).forEach(m -> {
if (m.filePath() != null) {
byFile.computeIfAbsent(m.filePath(), k -> new ArrayList<>()).add(m);
}
});
}
}
return byFile;
}
/**
* Resolves AI security-classification suggestions for every class in
* {@code byClass} and populates {@code tagsToApply} and {@code displayNames}
* with the results for methods that are security-relevant.
*
* <p>A display-name suggestion is only placed into {@code displayNames} when
* the discovered method has no existing {@code @DisplayName} in source
* (i.e. {@link DiscoveredMethod#displayName()} returns {@code null}).
* This prevents AI-generated names from overwriting manually authored ones.</p>
*
* @param byClass discovered methods grouped by FQCN for one source file
* @param ai AI runtime carrying the engine, override, and cache
* @param aiCache AI result cache used to compute the content-hash lookup key
* @param tagsToApply output accumulator: method name → tag values to write
* @param displayNames output accumulator: method name → display name to write
*/
/* default */ static void gatherAiSuggestionsForFile(Map<String, List<DiscoveredMethod>> byClass,
AiRuntime ai, AiResultCache aiCache,
Map<String, List<String>> tagsToApply, Map<String, String> displayNames) {
for (Map.Entry<String, List<DiscoveredMethod>> classEntry : byClass.entrySet()) {
String fqcn = classEntry.getKey();
List<DiscoveredMethod> classMethods = classEntry.getValue();
String classSource = classMethods.get(0).sourceContent().get().orElse(null);
String lookupHash = aiCache.isActive() && classSource != null
? computeContentHash(classSource) : null;
String fileStem = classMethods.get(0).fileStem();
List<String> methodNames = classMethods.stream().map(DiscoveredMethod::method).toList();
List<PromptBuilder.TargetMethod> targetMethods = classMethods.stream()
.map(CommandSupport::toTargetMethod).toList();
SuggestionLookup suggestions = resolveSuggestionLookup(
fileStem, fqcn, classSource, methodNames, targetMethods, ai, lookupHash);
for (DiscoveredMethod m : classMethods) {
AiMethodSuggestion suggestion = suggestions.find(m.method()).orElse(null);
if (suggestion == null || !suggestion.securityRelevant()) {
continue;
}
if (suggestion.displayName() != null && !suggestion.displayName().isBlank()
&& m.displayName() == null) {
displayNames.putIfAbsent(m.method(), suggestion.displayName());
}
if (suggestion.tags() != null && !suggestion.tags().isEmpty()) {
tagsToApply.putIfAbsent(m.method(), suggestion.tags());
}
}
}
}
/**
* Resolves method-level AI suggestions for a class.
*
* <p>
* Returns an empty lookup when no AI engine is available, the method list is
* empty, or (for regular provider-based AI) the class source exceeds the
* configured maximum size. The {@code maxClassChars} limit is only enforced
* when the automated provider is enabled ({@link AiOptions#enabled()}); it is
* not applied in the manual consume phase.
* </p>
*
* @param fileStem dot-separated path stem identifying the source file;
* forwarded to {@link AiSuggestionEngine#suggestForClass}
* @param fqcn fully qualified class name
* @param classSource pretty-printed source text of the class; may be
* {@code null} when source is unavailable
* @param methodNames names of discovered test methods
* @param targetMethods prompt target descriptors for the test methods
* @param ai AI infrastructure for this scan run
* @param contentHash hash of the class source for cache lookup; may be
* {@code null}
* @return lookup of AI suggestions keyed by method name; never {@code null}
*/
/* default */ static SuggestionLookup resolveSuggestionLookup(String fileStem, String fqcn,
String classSource, List<String> methodNames, List<PromptBuilder.TargetMethod> targetMethods,
AiRuntime ai, String contentHash) {
if (methodNames.isEmpty()) {
return SuggestionLookup.from(null);
}
if (ai.engine() == null) {
return SuggestionLookup.from(ai.override().apply(fqcn, null, methodNames));
}
// Check the cache before making an API call.
AiClassSuggestion cached = ai.cache().lookup(contentHash).orElse(null);
if (cached != null) {
return SuggestionLookup.from(ai.override().apply(fqcn, cached, methodNames));
}
if (classSource == null) {
return SuggestionLookup.from(ai.override().apply(fqcn, null, methodNames));
}
if (ai.options().enabled() && classSource.length() > ai.options().maxClassChars()) {
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Skipping AI for {0}: class source too large ({1} chars)",
new Object[] { fqcn, classSource.length() });
}
return SuggestionLookup.from(ai.override().apply(fqcn, null, methodNames));
}
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Querying AI for {0} ({1} methods)", new Object[] { fqcn, targetMethods.size() });
}
try {
AiClassSuggestion aiClassSuggestion =
ai.engine().suggestForClass(fileStem, fqcn, classSource, targetMethods);
return SuggestionLookup.from(ai.override().apply(fqcn, aiClassSuggestion, methodNames));
} catch (AiSuggestionException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "AI suggestion failed for class " + fqcn, e);
}
return SuggestionLookup.from(ai.override().apply(fqcn, null, methodNames));
}
}
// -------------------------------------------------------------------------
// Utility methods
// -------------------------------------------------------------------------
/**
* Computes a SHA-256 content fingerprint of a class source string.
*
* <p>
* The hash is derived from the JavaParser pretty-printed form of the class
* declaration, which normalizes whitespace so that insignificant formatting
* changes do not alter the fingerprint. The result is a 64-character
* lowercase hexadecimal string.
* </p>
*
* @param classSource JavaParser pretty-print of the class declaration
* @return 64-character lowercase hex SHA-256 digest
* @throws IllegalStateException if SHA-256 is unavailable (never in practice;
* SHA-256 is mandated by the Java SE spec)
*/
/* default */ static String computeContentHash(String classSource) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(classSource.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(bytes);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
/**
* Derives the file path prefix used in GitHub Actions workflow command
* annotations from the first configured scan root.
*
* <p>
* The prefix is made relative to the current working directory so that the
* resulting annotation paths (e.g. {@code src/test/java/com/acme/AuthTest.java})
* match what GitHub resolves as inline positions in the PR diff.
* </p>
*
* @param roots configured scan roots; may be empty
* @return forward-slash path ending with {@code /}, or empty string when
* {@code roots} is empty
*/
public static String computeFilePrefix(List<Path> roots) {
if (roots.isEmpty()) {
return "";
}
Path root = roots.get(0).toAbsolutePath().normalize();
String prefix;
try {
Path cwd = Paths.get("").toAbsolutePath();
prefix = cwd.relativize(root).toString().replace('\\', '/');
} catch (IllegalArgumentException e) {
// Different drive on Windows — fall back to the absolute path.
prefix = root.toString().replace('\\', '/');
}
if (!prefix.isEmpty() && !prefix.endsWith("/")) {
prefix += "/";
}
return prefix;
}
/**
* Wraps a {@link TestMethodSink} so that only security-relevant records are
* forwarded to {@code delegate}.
*
* <p>
* When {@code securityOnly} is {@code false} the original {@code delegate} is
* returned unchanged (zero overhead). When {@code true}, a wrapper is returned
* that drops any record whose {@link AiMethodSuggestion} is {@code null} or
* has {@code securityRelevant=false}.
* </p>
*
* @param delegate the underlying sink to forward matching records to
* @param securityOnly whether to enable the filter
* @return filtered sink, or {@code delegate} unchanged when filtering is off
*/
/* default */ static TestMethodSink filterSink(TestMethodSink delegate, boolean securityOnly) {
if (!securityOnly) {
return delegate;
}
return (fqcn, method, beginLine, loc, contentHash, tags, displayName, suggestion) -> {
if (suggestion != null && suggestion.securityRelevant()) {
delegate.record(fqcn, method, beginLine, loc, contentHash, tags, displayName, suggestion);
}
};
}
/**
* Converts a single discovered test method into a prompt target descriptor.
*
* @param m discovered test method
* @return corresponding prompt target descriptor; never {@code null}
* @see PromptBuilder.TargetMethod
*/
/* default */ static PromptBuilder.TargetMethod toTargetMethod(DiscoveredMethod m) {
return new PromptBuilder.TargetMethod(
m.method(),
m.beginLine() > 0 ? m.beginLine() : null,
m.endLine() > 0 ? m.endLine() : null);
}
/**
* Produces a human-readable string identifying which taxonomy configuration
* is in effect, for use in scan metadata output.
*
* @param aiOptions AI configuration for the current run
* @param aiActive whether an AI engine is active for this run
* @return taxonomy descriptor string; never {@code null}
*/
/* default */ static String resolveTaxonomyInfo(AiOptions aiOptions, boolean aiActive) {
if (!aiActive) {
return "n/a (AI disabled)";
}
if (aiOptions.taxonomyFile() != null) {
return "file:" + aiOptions.taxonomyFile().toAbsolutePath();
}
return "built-in/" + aiOptions.taxonomyMode().name().toLowerCase(Locale.ROOT);
}
}