ControlCoverageCollector.java
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.coverage;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NavigableSet;
import java.util.Set;
import java.util.TreeSet;
import org.egothor.methodatlas.ai.AiMethodSuggestion;
import org.egothor.methodatlas.emit.TestMethodSink;
/**
* Streaming sink that builds a {@link ControlCoverageReport} from scan
* records observed during a single run.
*
* <p>
* The collector implements {@link TestMethodSink} so the orchestration layer
* can hand it the same record stream that drives SARIF/CSV emitters, without
* a second scan and without coupling the emitters to compliance logic.
* </p>
*
* <p>
* Package-private because nothing outside the {@code coverage} package needs
* to construct one; {@link CoverageFacade} is the sole external entry point.
* </p>
*/
final class ControlCoverageCollector implements TestMethodSink {
/** Schema version of the produced report. */
private static final String SCHEMA_VERSION = "1";
/** Separator between framework prefix and bare ID in the control key. */
private static final String CONTROL_ID_PREFIX_SEP = "-";
/** Tag-source label when only source annotations contribute. */
private static final String TAG_SOURCE_ANNOTATION = "source";
/** Tag-source label when only AI classification contributes. */
private static final String TAG_SOURCE_AI = "ai";
/** Tag-source label when both source annotations and AI contribute. */
private static final String TAG_SOURCE_BOTH = "both";
/** Multiplier used to round percentages to two decimal places. */
private static final double COVERAGE_PERCENT_SCALE = 10_000.0;
/** Divisor that converts the rounded ratio back into a percentage. */
private static final double COVERAGE_PERCENT_DIVISOR = 100.0;
/** Confidence assigned to source-derived evidence (always certain). */
private static final double FULL_CONFIDENCE = 1.0;
/** Mapping that drives tag → control lookup. */
private final ControlMapping mapping;
/** Minimum AI confidence required to count an AI-only classification. */
private final double minConfidence;
/**
* Accumulated covering tests, keyed by canonical control key
* (e.g. {@code "ASVS-4.1.1"}). Insertion order is unimportant here — the
* report-build step sorts the resulting set lexicographically.
*/
private final Map<String, List<CoverageTestEntry>> accumulator = new LinkedHashMap<>();
/**
* Creates a collector backed by {@code mapping}.
*
* @param mapping loaded control mapping; must not be {@code null}
* @param minConfidence minimum AI confidence required for an AI-only
* classification to count; source-derived evidence
* is always counted irrespective of this value
*/
/* default */ ControlCoverageCollector(ControlMapping mapping, double minConfidence) {
this.mapping = mapping;
this.minConfidence = minConfidence;
}
/**
* Records evidence contributed by a single test method.
*
* <p>
* Resolution algorithm:
* </p>
* <ol>
* <li>Filter the source {@code tags} list to only tags that appear in
* the mapping (others are silently skipped — not an error).</li>
* <li>Filter the AI {@link AiMethodSuggestion#tags()} the same way, but
* only when the suggestion is non-null, security-relevant, and meets
* the configured minimum confidence threshold. {@code null} AI tag
* lists are treated as empty.</li>
* <li>Determine {@code tagSource} and {@code confidence}: source-only
* and AI+source both yield certainty ({@code 1.0}); AI-only carries
* the AI confidence forward verbatim.</li>
* <li>Merge the two tag lists preserving first-seen order, drop the
* method when the merged list is empty, and otherwise append a
* {@link CoverageTestEntry} under every linked control key.</li>
* </ol>
*
* @param fqcn fully qualified class name
* @param method test method name
* @param beginLine ignored
* @param loc ignored
* @param contentHash ignored
* @param tags source-declared tags
* @param displayName optional human-readable display name
* @param suggestion optional AI classification
*/
@Override
@SuppressWarnings({"PMD.UseObjectForClearerAPI", "PMD.AvoidInstantiatingObjectsInLoops"})
public void record(String fqcn, String method, int beginLine, int loc, String contentHash,
List<String> tags, String displayName, AiMethodSuggestion suggestion) {
List<String> sourceMappable = filterMappable(tags);
List<String> aiMappable = filterAiMappable(suggestion);
if (sourceMappable.isEmpty() && aiMappable.isEmpty()) {
return;
}
String tagSource;
double confidence;
if (sourceMappable.isEmpty()) {
tagSource = TAG_SOURCE_AI;
confidence = suggestion.confidence();
} else if (aiMappable.isEmpty()) {
tagSource = TAG_SOURCE_ANNOTATION;
confidence = FULL_CONFIDENCE;
} else {
tagSource = TAG_SOURCE_BOTH;
confidence = FULL_CONFIDENCE;
}
List<String> merged = mergeUnique(sourceMappable, aiMappable);
CoverageTestEntry entry = new CoverageTestEntry(
fqcn, method, displayName, merged, tagSource, confidence);
for (String tag : merged) {
List<ControlEntry> controls = mapping.tagToControls().get(tag);
for (ControlEntry control : controls) {
String key = controlKey(control.id());
accumulator.computeIfAbsent(key, k -> new ArrayList<>()).add(entry);
}
}
}
/**
* Builds the final {@link ControlCoverageReport}.
*
* @param toolVersion resolved tool version string; never {@code null}
* @return populated report with sorted coverage, gaps, and statistics
*/
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
/* default */ ControlCoverageReport buildReport(String toolVersion) {
NavigableSet<String> allKeys = new TreeSet<>();
Map<String, ControlEntry> firstControlByKey = new LinkedHashMap<>();
mapping.tagToControls().forEach((tag, entries) -> entries.forEach(control -> {
String key = controlKey(control.id());
allKeys.add(key);
firstControlByKey.putIfAbsent(key, control);
}));
Map<String, CoverageControlEntry> coverage = new LinkedHashMap<>();
for (String key : allKeys) {
List<CoverageTestEntry> tests = accumulator.get(key);
if (tests == null || tests.isEmpty()) {
continue;
}
ControlEntry metadata = firstControlByKey.get(key);
String chapter = chapterFor(key, metadata);
String chapterTitle = chapterTitleFor(key, metadata);
coverage.put(key, new CoverageControlEntry(chapter, chapterTitle,
Collections.unmodifiableList(new ArrayList<>(tests))));
}
List<String> gaps = new ArrayList<>();
for (String key : allKeys) {
if (!coverage.containsKey(key)) {
gaps.add(key);
}
}
CoverageStatistics statistics = buildStatistics(allKeys.size(), coverage.size());
return new ControlCoverageReport(
SCHEMA_VERSION,
Instant.now().toString(),
toolVersion,
mapping.framework(),
mapping.frameworkVersion(),
mapping.source(),
Collections.unmodifiableMap(coverage),
Collections.unmodifiableList(gaps),
statistics);
}
/**
* Picks the first non-null {@code chapter} value attached to the control
* across every mapping tag that points at it.
*
* @param key canonical control key (e.g. {@code "ASVS-4.1.1"})
* @param fallback control entry used when no tag-keyed value is set
* @return chapter label or {@code null}
*/
private String chapterFor(String key, ControlEntry fallback) {
for (List<ControlEntry> entries : mapping.tagToControls().values()) {
for (ControlEntry candidate : entries) {
if (controlKey(candidate.id()).equals(key) && candidate.chapter() != null) {
return candidate.chapter();
}
}
}
return fallback.chapter();
}
/**
* Picks the first non-null {@code chapterTitle} for the control. Same
* resolution policy as {@link #chapterFor(String, ControlEntry)}.
*
* @param key canonical control key
* @param fallback control entry used when no tag-keyed value is set
* @return chapter title or {@code null}
*/
private String chapterTitleFor(String key, ControlEntry fallback) {
for (List<ControlEntry> entries : mapping.tagToControls().values()) {
for (ControlEntry candidate : entries) {
if (controlKey(candidate.id()).equals(key) && candidate.chapterTitle() != null) {
return candidate.chapterTitle();
}
}
}
return fallback.chapterTitle();
}
/**
* Computes aggregate counts and the rounded coverage percentage.
*
* @param total total distinct control IDs declared in the mapping
* @param covered count of controls with at least one covering test
* @return populated statistics record
*/
private static CoverageStatistics buildStatistics(int total, int covered) {
double ratio = total == 0 ? 0.0 : covered / (double) total;
double percent = Math.round(ratio * COVERAGE_PERCENT_SCALE) / COVERAGE_PERCENT_DIVISOR;
return new CoverageStatistics(total, covered, total - covered, percent);
}
/**
* Returns the canonical {@code <FRAMEWORK>-<id>} key used throughout the
* report. The framework prefix is upper-cased so {@code "asvs"} and
* {@code "ASVS"} mapping files produce identical output.
*
* @param id bare requirement ID from a {@link ControlEntry}
* @return canonical control key
*/
private String controlKey(String id) {
return mapping.framework().toUpperCase(Locale.ROOT) + CONTROL_ID_PREFIX_SEP + id;
}
/**
* Drops every tag that is not present in {@link ControlMapping#tagToControls()}.
*
* @param tags raw tag list; may be {@code null}
* @return new list containing only mappable tags; never {@code null}
*/
private List<String> filterMappable(List<String> tags) {
if (tags == null || tags.isEmpty()) {
return List.of();
}
List<String> result = new ArrayList<>(tags.size());
for (String tag : tags) {
if (mapping.tagToControls().containsKey(tag)) {
result.add(tag);
}
}
return result;
}
/**
* Extracts the mappable subset of an AI suggestion's tags, applying the
* security-relevance and minimum-confidence filters.
*
* @param suggestion AI suggestion; may be {@code null}
* @return new list containing only mappable AI tags; never {@code null}
*/
private List<String> filterAiMappable(AiMethodSuggestion suggestion) {
if (suggestion == null || !suggestion.securityRelevant()) {
return List.of();
}
if (suggestion.confidence() < minConfidence) {
return List.of();
}
return filterMappable(suggestion.tags());
}
/**
* Returns an unmodifiable union of two lists preserving first-seen order.
*
* @param first first list (typically source tags)
* @param second second list (typically AI tags)
* @return unmodifiable union with stable order
*/
private static List<String> mergeUnique(List<String> first, List<String> second) {
Set<String> merged = new LinkedHashSet<>(first);
merged.addAll(second);
return List.copyOf(merged);
}
}