ScanOrchestrator.java
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.command;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import org.egothor.methodatlas.AiCacheEntry;
import org.egothor.methodatlas.AiCacheStore;
import org.egothor.methodatlas.AiResultCache;
import org.egothor.methodatlas.emit.ClassificationOverride;
import org.egothor.methodatlas.CliConfig;
import org.egothor.methodatlas.emit.CompositeTestMethodSink;
import org.egothor.methodatlas.emit.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.AiSuggestionException;
import org.egothor.methodatlas.ai.CredentialTriageVerdict;
import org.egothor.methodatlas.ai.PromptBuilder;
import org.egothor.methodatlas.ai.SuggestionLookup;
import org.egothor.methodatlas.api.DiscoveredMethod;
import org.egothor.methodatlas.api.TestDiscovery;
import org.egothor.methodatlas.api.TestDiscoveryConfig;
/**
* Orchestrates the scan-and-emit loop that every command mode is built around.
*
* <p>
* Each {@link Command} mode varies in three places — output format, per-record
* sink behaviour, and whether records stream or buffer — but they all share
* the same core sequence:
* </p>
* <ol>
* <li>Load all configured {@link TestDiscovery} providers.</li>
* <li>For each scan root, run every provider, merge their results, and
* group methods by class.</li>
* <li>For each class, optionally consult the AI engine through a layered
* cache + override lookup.</li>
* <li>Forward each method record to the supplied sink.</li>
* <li>Close all providers.</li>
* </ol>
*
* <p>
* This class owns that sequence. Commands compose it with a
* {@link PluginLoader} (passed in at construction) and configure the
* per-record sink, AI runtime, and content-hash policy at call time.
* </p>
*
* <h2>API shape</h2>
*
* <p>
* Two entry points serve the two common patterns:
* </p>
* <ul>
* <li>{@link #scan} manages the provider lifecycle internally; it is the
* right call for SARIF, JSON, and GitHub-annotation modes that buffer
* or emit unconditionally.</li>
* <li>{@link #runDiscovery} processes a single root against pre-loaded
* providers; it is the right call for CSV and plain-text modes that
* compute per-root metadata (such as the {@code source_root} column)
* before forwarding records.</li>
* </ul>
*
* <p>
* The apply-tags flow has its own shape: {@link #collectMethodsByFile}
* groups discovered methods by source file (the caller owns the provider
* lifecycle so it can read each provider's
* {@link TestDiscovery#hadErrors()} afterwards), and
* {@link #gatherAiSuggestionsForFile} resolves AI suggestions for one
* file at a time.
* </p>
*
* <h2>Thread safety</h2>
*
* <p>
* This class is thread-safe. The injected {@link PluginLoader} is
* thread-safe and {@link java.util.ServiceLoader} resolution is
* idempotent; nothing else is shared between calls.
* </p>
*
* <p>
* When {@code parallelAi} is enabled (see
* {@link #ScanOrchestrator(PluginLoader, java.util.Optional, boolean)}),
* AI classification calls for different classes within one root are issued
* concurrently via {@link java.util.stream.Stream#parallel()}. All objects
* read during that phase ({@link AiResultCache}, the classification override,
* and the underlying {@link java.net.http.HttpClient}) are thread-safe.
* Sink emission is always sequential, so output ordering matches discovery
* order regardless of AI call completion order.
* </p>
*
* @see PluginLoader
* @see AiRuntime
* @see Command
* @since 1.0.0
*/
// Class-level total complexity grows with every orchestrated mode; no single method exceeds the cap.
@SuppressWarnings("PMD.CyclomaticComplexity")
public final class ScanOrchestrator {
private static final Logger LOG = Logger.getLogger(ScanOrchestrator.class.getName());
private final PluginLoader pluginLoader;
/**
* Optional secondary sink that observes every record alongside the
* command's primary sink. {@code null} means no fan-out.
*/
private final TestMethodSink extraSink;
/**
* When {@code true}, AI classification calls for different classes are
* issued concurrently via {@link java.util.stream.Stream#parallel()}
* before records are emitted in the original discovery order.
*/
private final boolean parallelAi;
/**
* Redaction service applied to the AI payload when {@code -redact-comments}
* is set; never {@code null}.
*/
private final CommentRedaction redaction;
/**
* Creates a new orchestrator with no extra sink and parallel AI disabled.
*
* @param pluginLoader plugin loader used by {@link #scan} and
* {@link #collectMethodsByFile}; must not be
* {@code null}
*/
public ScanOrchestrator(PluginLoader pluginLoader) {
this(pluginLoader, Optional.empty(), false);
}
/**
* Creates a new orchestrator that fans out every record to {@code extraSink}
* in addition to the command's primary sink.
*
* <p>
* The extra sink, when present, is composed with the primary sink at the
* {@link #runDiscovery} boundary so every command mode (SARIF, JSON, CSV,
* GitHub annotations) sees the same fan-out automatically. When
* {@code extraSink} is {@link Optional#empty()} the orchestrator behaves
* identically to the legacy single-sink constructor.
* </p>
*
* @param pluginLoader plugin loader used by {@link #scan} and
* {@link #collectMethodsByFile}; must not be
* {@code null}
* @param extraSink optional secondary sink invoked in addition to the
* command-supplied primary sink; must not be
* {@code null} (use {@link Optional#empty()})
*/
public ScanOrchestrator(PluginLoader pluginLoader, Optional<TestMethodSink> extraSink) {
this(pluginLoader, extraSink, false);
}
/**
* Creates a new orchestrator with full configuration.
*
* @param pluginLoader plugin loader used by {@link #scan} and
* {@link #collectMethodsByFile}; must not be
* {@code null}
* @param extraSink optional secondary sink invoked in addition to the
* command-supplied primary sink; must not be
* {@code null} (use {@link Optional#empty()})
* @param parallelAi when {@code true}, AI classification calls are issued
* concurrently; output is still emitted in discovery order
*/
public ScanOrchestrator(PluginLoader pluginLoader, Optional<TestMethodSink> extraSink,
boolean parallelAi) {
this(pluginLoader, extraSink, parallelAi, CommentRedaction.none());
}
/**
* Creates a new orchestrator with full configuration and a comment-redaction
* service.
*
* @param pluginLoader plugin loader; must not be {@code null}
* @param extraSink optional secondary sink; must not be {@code null}
* @param parallelAi when {@code true}, AI classification calls are issued concurrently
* @param redaction redaction service consulted when {@code -redact-comments} is set;
* must not be {@code null}
*/
public ScanOrchestrator(PluginLoader pluginLoader, Optional<TestMethodSink> extraSink,
boolean parallelAi, CommentRedaction redaction) {
this.pluginLoader = pluginLoader;
this.extraSink = extraSink.orElse(null);
this.parallelAi = parallelAi;
this.redaction = redaction;
}
/**
* Returns the redaction service this orchestrator applies to AI payloads, so
* other AI-sending components (credential triage) share the same fail-closed policy.
*
* @return the redaction service; never {@code null}
*/
public CommentRedaction redaction() {
return redaction;
}
/**
* Scans every configured root, forwarding each discovered test method to
* {@code sink}. Loads and closes the {@link TestDiscovery} providers
* internally so callers do not need to manage the lifecycle.
*
* <p>
* When {@code secretCtx} is supplied, credential triage is folded into each
* per-class AI call so the class source is sent to the provider once for both
* classification and triage; pass {@code null} for classification only.
* </p>
*
* @param roots source roots to scan; must not be {@code null}
* @param cliConfig full parsed CLI configuration
* @param discoveryConfig discovery configuration forwarded to providers
* @param aiEngine AI engine providing suggestions; may be
* {@code null} when AI is disabled
* @param sink receiver of discovered test method records
* @param override human classification overrides
* @param aiCache AI result cache
* @param secretCtx credential-triage context, or {@code null} to disable
* the fold (classification only)
* @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
* @since 1.0.0
*/
public int scan(List<Path> roots, CliConfig cliConfig, TestDiscoveryConfig discoveryConfig,
AiSuggestionEngine aiEngine, TestMethodSink sink,
ClassificationOverride override, AiResultCache aiCache,
CredentialTriageContext secretCtx) throws IOException {
List<TestDiscovery> providers = pluginLoader.loadProviders(discoveryConfig);
boolean hadErrors = false;
// Accumulates one cache entry per processed class across all roots; written
// once at the end when -ai-cache-out is set.
Map<String, AiCacheEntry> cacheAll =
cliConfig.aiCacheOut() != null ? new LinkedHashMap<>() : null;
try {
for (Path root : roots) {
DiscoveryResult result = runDiscoveryInternal(root, providers, cliConfig.aiOptions(),
aiEngine, sink, cliConfig.contentHash(), override, aiCache, secretCtx);
if (result.hadErrors()) {
hadErrors = true;
}
if (cacheAll != null) {
cacheAll.putAll(result.cacheEntries());
}
}
} finally {
pluginLoader.closeAll(providers);
}
if (cacheAll != null) {
AiCacheStore.write(cliConfig.aiCacheOut(), cacheAll.values());
}
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>
* Providers are passed in pre-loaded; callers manage the lifecycle
* (typically through {@link PluginLoader#closeAll(List)} in a
* {@code finally} block) so that they can share one provider list across
* multiple roots while still computing per-root metadata before each
* call.
* </p>
*
* <p>
* When {@code secretCtx} is supplied, credential triage is folded into each
* per-class AI call; pass {@code null} for classification only.
* </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 in
* emitted records
* @param override human classification overrides
* @param aiCache AI result cache
* @param secretCtx credential-triage context, or {@code null} to disable the fold
* @return {@code true} if any provider encountered a parse or processing
* error
* @throws IOException if traversing the file tree fails
* @since 1.0.0
*/
public boolean runDiscovery(Path root, List<TestDiscovery> providers,
AiOptions aiOptions, AiSuggestionEngine aiEngine, TestMethodSink sink,
boolean contentHashEnabled, ClassificationOverride override,
AiResultCache aiCache, CredentialTriageContext secretCtx) throws IOException {
return runDiscoveryInternal(root, providers, aiOptions, aiEngine, sink, contentHashEnabled,
override, aiCache, secretCtx).hadErrors();
}
/**
* Body of {@link #runDiscovery(Path, List, AiOptions, AiSuggestionEngine, TestMethodSink,
* boolean, ClassificationOverride, AiResultCache, CredentialTriageContext)} that also returns one
* {@link AiCacheEntry} per processed class (cache hits preserved, misses freshly computed) so the
* caller can persist the unified AI result cache.
*
* @return the error flag plus the per-class cache entries keyed by content hash
*/
@SuppressWarnings("PMD.CloseResource") // providers are owned by the caller; this method does not close them
private DiscoveryResult runDiscoveryInternal(Path root, List<TestDiscovery> providers,
AiOptions aiOptions, AiSuggestionEngine aiEngine, TestMethodSink sink,
boolean contentHashEnabled, ClassificationOverride override,
AiResultCache aiCache, CredentialTriageContext secretCtx) throws IOException {
TestMethodSink effectiveSink = wrapWithExtraSink(sink);
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);
// The prompt-catalogue signature gates cache reuse and tags written entries;
// only meaningful when AI is enabled.
String promptSignature = aiEngine == null ? null : AiCacheKey.signature(aiOptions);
// Fail-closed pre-pass: redact every class first so an unredactable file aborts
// the run before the first provider call.
Map<String, String> redacted = redactClasses(byClass, aiOptions, aiEngine != null);
Map<String, ClassResolution> resolutions = resolveAll(
byClass, redacted, ai, promptSignature, contentHashEnabled, secretCtx);
Map<String, AiCacheEntry> cacheEntries = new LinkedHashMap<>();
for (Map.Entry<String, List<DiscoveredMethod>> entry : byClass.entrySet()) {
emitClass(entry, resolutions.get(entry.getKey()), promptSignature, effectiveSink, cacheEntries, root);
}
return new DiscoveryResult(hadErrors, cacheEntries);
}
/**
* Resolves AI suggestions for all classes, sequentially or in parallel depending on
* {@link #parallelAi}. Returns a map keyed by FQCN; keyed in the same order as
* {@code byClass} when sequential, unordered when parallel (callers must iterate
* {@code byClass} for emission order).
*
* @param byClass methods grouped by FQCN
* @param redacted redacted class sources by FQCN (empty when redaction is off)
* @param ai AI runtime (engine, override, cache)
* @param promptSignature current prompt-catalogue signature, or {@code null}
* @param contentHashEnabled whether content hashes should be computed
* @param secretCtx credential-triage context, or {@code null}
* @return per-FQCN resolution results; never {@code null}
*/
private Map<String, ClassResolution> resolveAll(
Map<String, List<DiscoveredMethod>> byClass, Map<String, String> redacted, AiRuntime ai,
String promptSignature, boolean contentHashEnabled, CredentialTriageContext secretCtx) {
if (parallelAi) {
Map<String, ClassResolution> result = new ConcurrentHashMap<>(byClass.size() * 2);
byClass.entrySet().parallelStream().forEach(entry ->
result.put(entry.getKey(),
resolveClass(entry, redacted, ai, promptSignature, contentHashEnabled, secretCtx)));
return result;
}
Map<String, ClassResolution> result = new LinkedHashMap<>(byClass.size() * 2);
for (Map.Entry<String, List<DiscoveredMethod>> entry : byClass.entrySet()) {
result.put(entry.getKey(), resolveClass(entry, redacted, ai, promptSignature, contentHashEnabled, secretCtx));
}
return result;
}
/**
* Resolves the AI suggestion for one class without touching the sink.
* All objects read here ({@link AiResultCache}, {@link org.egothor.methodatlas.emit.ClassificationOverride},
* and the underlying {@link java.net.http.HttpClient}) are thread-safe, so this
* method may be called from a parallel stream.
*
* @param entry one class's discovered methods, keyed by FQCN
* @param redacted redacted class sources by FQCN; a class present here is hashed and
* sent in this form instead of the source on disk
* @param ai AI runtime (engine, override, cache)
* @param promptSignature current prompt-catalogue signature, or {@code null}
* @param contentHashEnabled whether content hashes should be computed
* @param secretCtx credential-triage context, or {@code null}
* @return the resolution result for this class; never {@code null}
*/
private static ClassResolution resolveClass(Map.Entry<String, List<DiscoveredMethod>> entry,
Map<String, String> redacted, AiRuntime ai, String promptSignature, boolean contentHashEnabled,
CredentialTriageContext secretCtx) {
String fqcn = entry.getKey();
List<DiscoveredMethod> classMethods = entry.getValue();
// groupingBy never produces an empty value list, so the first method is always
// present; it is the representative carrying the class-level fields (source
// content and file stem are identical across a class's methods).
DiscoveredMethod representative = classMethods.get(0);
String classSource = redacted.containsKey(fqcn) ? redacted.get(fqcn)
: representative.sourceContent().get().orElse(null);
// The content hash (computed on the redacted text when redaction is on, so it reflects
// what was sent) is needed to read the cache, to emit the hash column, and to
// key a written cache entry — so compute it whenever any of those apply.
String lookupHash = (contentHashEnabled || ai.cache().isActive() || ai.engine() != null)
&& classSource != null
? ContentHasher.hashClass(classSource) : null;
String outputHash = contentHashEnabled ? lookupHash : null;
String fileStem = representative.fileStem();
List<String> methodNames = classMethods.stream().map(DiscoveredMethod::method).toList();
List<PromptBuilder.TargetMethod> targetMethods = classMethods.stream()
.map(ScanOrchestrator::toTargetMethod)
.toList();
Resolved resolved = resolveSuggestionLookup(fileStem, fqcn, classSource, methodNames,
targetMethods, ai, lookupHash, promptSignature, secretCtx);
return new ClassResolution(resolved, lookupHash, outputHash);
}
/**
* Emits one class's records to the sink and records the cache entry when applicable.
* Must be called sequentially (in discovery order) to preserve output determinism.
*
* @param entry one class's discovered methods, keyed by FQCN
* @param resolution pre-computed resolution for this class
* @param promptSignature current prompt-catalogue signature, or {@code null}
* @param effectiveSink sink receiving the per-method records
* @param cacheEntries accumulator for cache entries, keyed by content hash
* @param root scan root the methods were discovered under; used to derive each
* method's root-relative source path for location-bearing sinks
* @throws IOException if the sink fails to record a method
*/
private static void emitClass(Map.Entry<String, List<DiscoveredMethod>> entry,
ClassResolution resolution, String promptSignature,
TestMethodSink effectiveSink, Map<String, AiCacheEntry> cacheEntries,
Path root) throws IOException {
SuggestionLookup suggestions = resolution.resolved().lookup();
String outputHash = resolution.outputHash();
for (DiscoveredMethod m : entry.getValue()) {
effectiveSink.recordWithSourcePath(m.fqcn(), m.method(), m.beginLine(), m.loc(), outputHash,
m.tags(), m.displayName(),
suggestions.find(m.method()).orElse(null), SourcePaths.relativeSourcePath(root, m.filePath()));
}
String lookupHash = resolution.lookupHash();
AiClassSuggestion cacheable = resolution.resolved().cacheable();
if (cacheable != null && lookupHash != null) {
cacheEntries.put(lookupHash, new AiCacheEntry(lookupHash, promptSignature, cacheable));
}
}
/**
* Wraps {@code primary} with {@link CompositeTestMethodSink} when an
* extra sink was supplied to the constructor; returns {@code primary}
* unchanged otherwise.
*
* @param primary command-supplied primary sink
* @return effective sink to feed during this discovery pass
*/
private TestMethodSink wrapWithExtraSink(TestMethodSink primary) {
if (extraSink == null) {
return primary;
}
return new CompositeTestMethodSink(primary, extraSink);
}
/**
* Collects all discovered methods from every configured root, keyed by
* source-file path. Methods whose {@link DiscoveredMethod#filePath()} is
* {@code null} are silently skipped.
*
* <p>
* Providers are passed in pre-loaded so the caller can read each
* provider's {@link TestDiscovery#hadErrors()} after the call and decide
* how to propagate the exit code; the caller also owns closing them.
* </p>
*
* @param roots scan roots; must not be {@code null}
* @param providers configured and already-loaded discovery providers;
* must not be {@code null}
* @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
public 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 to tag values to write
* @param displayNames output accumulator: method name to display name to write
* @since 1.0.0
*/
public void gatherAiSuggestionsForFile(Map<String, List<DiscoveredMethod>> byClass,
AiRuntime ai, AiResultCache aiCache,
Map<String, List<String>> tagsToApply, Map<String, String> displayNames) {
String promptSignature = ai.engine() == null ? null : AiCacheKey.signatureUnchecked(ai.options());
Map<String, String> redacted = redactClasses(byClass, ai.options(), ai.engine() != null);
for (Map.Entry<String, List<DiscoveredMethod>> classEntry : byClass.entrySet()) {
String fqcn = classEntry.getKey();
List<DiscoveredMethod> classMethods = classEntry.getValue();
// groupingBy never produces an empty value list; the first method is the
// representative carrying the class-level fields (source content and file
// stem are identical across a class's methods).
DiscoveredMethod representative = classMethods.get(0);
String classSource = redacted.containsKey(fqcn) ? redacted.get(fqcn)
: representative.sourceContent().get().orElse(null);
String lookupHash = aiCache.isActive() && classSource != null
? ContentHasher.hashClass(classSource) : null;
String fileStem = representative.fileStem();
List<String> methodNames = classMethods.stream().map(DiscoveredMethod::method).toList();
List<PromptBuilder.TargetMethod> targetMethods = classMethods.stream()
.map(ScanOrchestrator::toTargetMethod).toList();
SuggestionLookup suggestions = resolveSuggestionLookup(
fileStem, fqcn, classSource, methodNames, targetMethods, ai, lookupHash,
promptSignature, null).lookup();
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());
}
}
}
}
/**
* Verifies up front that every class of every given file can be redacted, so an
* unredactable file aborts a multi-file run (apply-tags) before the first provider
* call. A no-op unless {@code -redact-comments} is set and an AI engine is active.
*
* @param methodsPerFile discovered methods of each file that would reach the AI provider
* @param ai AI runtime carrying the options and engine
* @throws RedactionAbortedException if any class cannot be redacted
*/
public void requireRedactable(java.util.Collection<List<DiscoveredMethod>> methodsPerFile, AiRuntime ai) {
for (List<DiscoveredMethod> methods : methodsPerFile) {
Map<String, List<DiscoveredMethod>> byClass = methods.stream()
.collect(Collectors.groupingBy(DiscoveredMethod::fqcn,
LinkedHashMap::new, Collectors.toList()));
redactClasses(byClass, ai.options(), ai.engine() != null);
}
}
/**
* Wraps a {@link TestMethodSink} so that only records that pass all
* active filters are forwarded to {@code delegate}.
*
* <p>
* Two independent filters are supported and composed in order:
* </p>
* <ol>
* <li><b>Security-only filter</b> — when {@code securityOnly} is
* {@code true}, records whose {@link AiMethodSuggestion} is
* {@code null} or has {@code securityRelevant=false} are dropped.</li>
* <li><b>Confidence threshold filter</b> — when {@code confidenceEnabled}
* is {@code true} <em>and</em> {@code minConfidence > 0.0}, records
* whose {@link AiMethodSuggestion} is {@code null} or has a
* {@link AiMethodSuggestion#confidence()} below {@code minConfidence}
* are dropped. This filter is a no-op when {@code confidenceEnabled}
* is {@code false} because the confidence field is always
* {@code 0.0} when confidence scoring was not requested.</li>
* </ol>
*
* <p>
* When neither filter is active the original {@code delegate} is
* returned unchanged (zero overhead).
* </p>
*
* @param delegate the underlying sink to forward matching
* records to
* @param securityOnly whether to enable the security-relevance filter
* @param minConfidence minimum confidence score (inclusive) required
* to pass the confidence filter; {@code 0.0}
* disables it
* @param confidenceEnabled whether confidence scoring was requested;
* must be {@code true} for the confidence
* filter to activate
* @return filtered sink, or {@code delegate} unchanged when all filters
* are off
* @since 1.0.0
*/
public TestMethodSink filterSink(TestMethodSink delegate, boolean securityOnly,
double minConfidence, boolean confidenceEnabled) {
TestMethodSink sink = delegate;
if (securityOnly) {
sink = new FilteringSink(sink, s -> s != null && s.securityRelevant());
}
if (confidenceEnabled && minConfidence > 0.0) {
final double threshold = minConfidence;
sink = new FilteringSink(sink, s -> s != null && s.confidence() >= threshold);
}
return sink;
}
/**
* Forwards only records whose AI suggestion satisfies a predicate, preserving the
* real source path so location-bearing downstream sinks (SARIF, GitHub annotations)
* still receive it.
*/
private static final class FilteringSink implements TestMethodSink {
private final TestMethodSink next;
private final java.util.function.Predicate<AiMethodSuggestion> accept;
/* default */ FilteringSink(TestMethodSink next, java.util.function.Predicate<AiMethodSuggestion> accept) {
this.next = next;
this.accept = accept;
}
@Override
@SuppressWarnings("PMD.UseObjectForClearerAPI")
public void record(String fqcn, String method, int beginLine, int loc, String contentHash,
List<String> tags, String displayName, AiMethodSuggestion suggestion) {
if (accept.test(suggestion)) {
next.record(fqcn, method, beginLine, loc, contentHash, tags, displayName, suggestion);
}
}
@Override
@SuppressWarnings("PMD.UseObjectForClearerAPI")
public void recordWithSourcePath(String fqcn, String method, int beginLine, int loc, String contentHash,
List<String> tags, String displayName, AiMethodSuggestion suggestion, String sourcePath) {
if (accept.test(suggestion)) {
next.recordWithSourcePath(fqcn, method, beginLine, loc, contentHash, tags, displayName, suggestion,
sourcePath);
}
}
}
// -------------------------------------------------------------------------
// Static utilities
// -------------------------------------------------------------------------
/**
* Converts a single discovered test method into a prompt target descriptor.
*
* <p>
* Exposed publicly because {@link ManualPrepareCommand} also needs to
* build prompt-target lists from discovered methods when writing manual
* work files; the conversion logic must stay aligned across both call
* sites to keep the prompt format consistent.
* </p>
*
* @param m discovered test method; must not be {@code null}
* @return corresponding prompt target descriptor; never {@code null}
* @see PromptBuilder.TargetMethod
*/
public static PromptBuilder.TargetMethod toTargetMethod(DiscoveredMethod m) {
return new PromptBuilder.TargetMethod(
m.method(),
m.beginLine() > 0 ? m.beginLine() : null,
m.endLine() > 0 ? m.endLine() : null);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Redacts the representative source of every class when {@code -redact-comments} is
* active, sequentially, before any provider call. Sources are transformed in memory;
* the files on disk (and therefore source write-back) are never touched.
*
* @param byClass discovered methods grouped by FQCN
* @param options AI options of the run
* @param aiActive whether an AI engine will receive class source
* @return redacted source by FQCN; empty when redaction is off or nothing is sent
* @throws RedactionAbortedException if any class cannot be redacted
*/
private Map<String, String> redactClasses(Map<String, List<DiscoveredMethod>> byClass,
AiOptions options, boolean aiActive) {
if (!aiActive || options == null || !options.redactComments()) {
return Map.of();
}
Map<String, String> redacted = new LinkedHashMap<>(byClass.size() * 2);
for (Map.Entry<String, List<DiscoveredMethod>> entry : byClass.entrySet()) {
DiscoveredMethod representative = entry.getValue().get(0);
String source = representative.sourceContent().get().orElse(null);
if (source != null) {
redacted.put(entry.getKey(), redaction.redactOrThrow(representative.filePath(), source));
}
}
return redacted;
}
/**
* Resolves the AI answer for one class, consulting the signature-gated cache
* before any provider call and serving cached credential verdicts when present.
*
* @param promptSignature the current run's prompt-catalogue signature, or
* {@code null} when AI is disabled
* @return the override-applied lookup plus the raw answer to cache (or
* {@code null} when nothing should be cached)
*/
private static Resolved resolveSuggestionLookup(String fileStem, String fqcn,
String classSource, List<String> methodNames, List<PromptBuilder.TargetMethod> targetMethods,
AiRuntime ai, String contentHash, String promptSignature, CredentialTriageContext secretCtx) {
if (methodNames.isEmpty()) {
return new Resolved(SuggestionLookup.from(null), null);
}
if (ai.engine() == null) {
return new Resolved(SuggestionLookup.from(ai.override().apply(fqcn, null, methodNames)), null);
}
List<PromptBuilder.CredentialCandidateRef> candidates =
secretCtx == null ? List.of() : secretCtx.candidatesFor(fqcn);
// Cache first, gated on the prompt-catalogue signature. A hit serves the
// classification AND (when this run triages credentials) the verdicts —
// from the same cached answer, with no provider call.
AiClassSuggestion cached = ai.cache().classification(contentHash, promptSignature).orElse(null);
if (cached != null) {
AiClassSuggestion cacheable = candidates.isEmpty() ? cached
: serveOrTriageVerdicts(ai, fqcn, classSource, contentHash, promptSignature,
candidates, cached, secretCtx);
return new Resolved(SuggestionLookup.from(ai.override().apply(fqcn, cached, methodNames)), cacheable);
}
if (classSource == null) {
return new Resolved(SuggestionLookup.from(ai.override().apply(fqcn, null, methodNames)), null);
}
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 new Resolved(SuggestionLookup.from(ai.override().apply(fqcn, null, methodNames)), null);
}
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "Querying AI for {0} ({1} methods)", new Object[] { fqcn, targetMethods.size() });
}
try {
// Fold credential triage into the single classification call when there
// are candidates, so the class source is sent to the provider once.
AiClassSuggestion aiClassSuggestion;
if (candidates.isEmpty()) {
aiClassSuggestion = ai.engine().suggestForClass(fileStem, fqcn, classSource, targetMethods);
} else {
aiClassSuggestion =
ai.engine().suggestForClass(fileStem, fqcn, classSource, targetMethods, candidates);
secretCtx.recordVerdicts(fqcn, aiClassSuggestion.secrets());
}
return new Resolved(
SuggestionLookup.from(ai.override().apply(fqcn, aiClassSuggestion, methodNames)),
aiClassSuggestion);
} catch (AiSuggestionException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "AI suggestion failed for class " + fqcn, e);
}
return new Resolved(SuggestionLookup.from(ai.override().apply(fqcn, null, methodNames)), null);
}
}
/**
* On a classification cache hit, supplies the credential verdicts: cached when
* present for this signature, otherwise a one-off dedicated triage call (so a
* class whose classification was cached without verdicts still gets scored).
*
* @return the cached suggestion augmented with the resolved verdicts, for re-caching
*/
private static AiClassSuggestion serveOrTriageVerdicts(AiRuntime ai, String fqcn, String classSource,
String contentHash, String promptSignature,
List<PromptBuilder.CredentialCandidateRef> candidates, AiClassSuggestion cached,
CredentialTriageContext secretCtx) {
Optional<List<CredentialTriageVerdict>> cachedVerdicts =
ai.cache().verdicts(contentHash, promptSignature);
if (cachedVerdicts.isPresent()) {
secretCtx.recordVerdicts(fqcn, cachedVerdicts.get());
return withSecrets(cached, cachedVerdicts.get());
}
if (classSource == null) {
return cached;
}
try {
List<CredentialTriageVerdict> verdicts = ai.engine().triageSecrets(fqcn, classSource, candidates);
secretCtx.recordVerdicts(fqcn, verdicts);
return withSecrets(cached, verdicts);
} catch (AiSuggestionException e) {
if (LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "Credential triage failed for cached class " + fqcn, e);
}
return cached;
}
}
/**
* Returns a copy of {@code suggestion} with its credential verdicts replaced.
*
* @param suggestion the classification result to copy; never {@code null}
* @param secrets the verdicts to attach; may be {@code null}
* @return a new suggestion carrying {@code secrets}
*/
private static AiClassSuggestion withSecrets(AiClassSuggestion suggestion,
List<CredentialTriageVerdict> secrets) {
return new AiClassSuggestion(suggestion.className(), suggestion.classSecurityRelevant(),
suggestion.classTags(), suggestion.classReason(), suggestion.methods(), secrets);
}
/**
* The outcome of resolving one class: the override-applied per-method lookup, and
* the raw AI answer to persist in the cache ({@code null} when nothing should be
* cached — no AI, no methods, oversized source, or a failed call).
*
* @param lookup per-method suggestion lookup fed to the sink; never {@code null}
* @param cacheable the full AI answer to cache, or {@code null}
*/
private record Resolved(SuggestionLookup lookup, AiClassSuggestion cacheable) {
}
/**
* The result of resolving AI suggestions for one class, separated from sink
* emission so that resolution can be parallelised while emission stays sequential.
*
* @param resolved override-applied suggestion lookup and optional cache answer
* @param lookupHash SHA-256 of the class source, used to key the cache entry;
* {@code null} when hashing was not requested or source was absent
* @param outputHash same hash for inclusion in emitted records, or {@code null}
* when {@code -content-hash} is not enabled
*/
private record ClassResolution(Resolved resolved, String lookupHash, String outputHash) {
}
/**
* The result of scanning one root: whether any provider errored, and the unified
* cache entries collected for the classes processed in that root.
*
* @param hadErrors {@code true} if any provider reported a non-fatal error
* @param cacheEntries cache entries keyed by content hash; never {@code null}
*/
private record DiscoveryResult(boolean hadErrors, Map<String, AiCacheEntry> cacheEntries) {
}
}