EvidenceReportCollector.java
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.evidencereport;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.egothor.methodatlas.ai.AiMethodSuggestion;
import org.egothor.methodatlas.ai.CweMapping;
import org.egothor.methodatlas.emit.TestMethodSink;
/**
* Streaming sink that builds an {@link EvidenceReport} 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 evidence-report logic.
* </p>
*
* <p>
* Only AI-classified, security-relevant methods whose confidence meets the configured
* minimum are collected. Methods without an AI suggestion, or whose suggestion is not
* security-relevant, are silently skipped.
* </p>
*
* <p>
* Package-private because nothing outside the {@code evidencereport} package needs to
* construct one; {@link EvidenceReportFacade} is the sole external entry point.
* </p>
*/
final class EvidenceReportCollector implements TestMethodSink {
/** Minimum AI confidence required to include a method in the report. */
private final double minConfidence;
/** Scan-root paths forwarded to the report header; stored as strings for serialisation. */
private final List<String> scanRoots;
/** Total methods seen (all, not just security-relevant). */
private int totalScanned;
/**
* Accumulated entries grouped by FQCN in encounter order. Each FQCN maps to
* the ordered list of qualifying methods in that class.
*/
private final Map<String, List<EvidenceReportEntry>> accumulator = new LinkedHashMap<>();
/**
* Creates a collector.
*
* @param minConfidence minimum AI confidence required to include a method;
* must be in {@code [0.0, 1.0]}
* @param scanRoots scan-root paths included verbatim in the report header
*/
/* default */ EvidenceReportCollector(double minConfidence, List<Path> scanRoots) {
this.minConfidence = minConfidence;
List<String> roots = new ArrayList<>(scanRoots.size());
for (Path p : scanRoots) {
roots.add(p.toString());
}
this.scanRoots = Collections.unmodifiableList(roots);
}
/**
* Records a single test method.
*
* <p>
* Every call increments the total-scanned counter. Only methods with a non-null,
* security-relevant AI suggestion meeting the minimum confidence threshold are
* added to the evidence set.
* </p>
*
* @param fqcn fully qualified class name
* @param method test method name
* @param beginLine first source line of the method
* @param loc lines of code
* @param contentHash SHA-256 of the enclosing class source, or {@code null}
* @param tags source-declared {@code @Tag} values
* @param displayName {@code @DisplayName} annotation value, or {@code null}
* @param suggestion AI classification, or {@code null}
*/
@Override
@SuppressWarnings("PMD.UseObjectForClearerAPI")
public void record(String fqcn, String method, int beginLine, int loc, String contentHash,
List<String> tags, String displayName, AiMethodSuggestion suggestion) {
totalScanned++;
if (suggestion == null || !suggestion.securityRelevant()) {
return;
}
if (suggestion.confidence() < minConfidence) {
return;
}
List<String> aiTags = suggestion.tags() != null
? Collections.unmodifiableList(new ArrayList<>(suggestion.tags()))
: List.of();
List<String> cwe = CweMapping.forTags(aiTags);
EvidenceReportEntry entry = new EvidenceReportEntry(
fqcn, method, beginLine, loc, contentHash,
tags != null ? Collections.unmodifiableList(new ArrayList<>(tags)) : List.of(),
displayName,
suggestion.displayName(),
suggestion.securityRelevant(),
aiTags,
suggestion.reason(),
suggestion.confidence(),
suggestion.interactionScore(),
cwe);
accumulator.computeIfAbsent(fqcn, k -> new ArrayList<>()).add(entry);
}
/**
* Builds the final {@link EvidenceReport}.
*
* @param toolVersion resolved tool version string; never {@code null}
* @return populated report
*/
/* default */ EvidenceReport buildReport(String toolVersion) {
int secCount = accumulator.values().stream().mapToInt(List::size).sum();
Map<String, List<EvidenceReportEntry>> snapshot = new LinkedHashMap<>();
for (Map.Entry<String, List<EvidenceReportEntry>> e : accumulator.entrySet()) {
snapshot.put(e.getKey(), Collections.unmodifiableList(new ArrayList<>(e.getValue()))); // NOPMD AvoidInstantiatingObjectsInLoops
}
return new EvidenceReport(
Instant.now().toString(),
toolVersion,
scanRoots,
totalScanned,
secCount,
Collections.unmodifiableMap(snapshot));
}
}