GapCollector.java

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.gap;

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 java.util.Set;

import org.egothor.methodatlas.ai.AiMethodSuggestion;
import org.egothor.methodatlas.emit.TestMethodSink;

/**
 * Streaming sink that builds a {@link GapReport} 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 gap-analysis logic.
 * </p>
 *
 * <p>
 * Only AI-classified, security-relevant methods are counted. Methods without an
 * AI suggestion, or whose suggestion is not security-relevant, or whose confidence
 * falls below the configured threshold, are silently skipped.
 * </p>
 *
 * <p>
 * Package-private because nothing outside the {@code gap} package needs to
 * construct one; {@link GapFacade} is the sole external entry point.
 * </p>
 */
final class GapCollector implements TestMethodSink {

    /** Schema version of the produced report. */
    private static final String SCHEMA_VERSION = "1";

    /** 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;

    /**
     * Canonical ordered set of the nine built-in taxonomy tags, mirroring the
     * order in {@code DefaultSecurityTaxonomy}.
     */
    private static final List<String> TAXONOMY_TAGS = List.of(
            "auth", "access-control", "crypto", "input-validation",
            "injection", "data-protection", "logging", "error-handling", "owasp");

    /** Fast lookup set for {@link #TAXONOMY_TAGS}. */
    private static final Set<String> TAXONOMY_TAG_SET = Set.copyOf(TAXONOMY_TAGS);

    /** Minimum AI confidence required to count a classification. */
    private final double minConfidence;

    /**
     * Accumulated methods per taxonomy tag. Initialised with all nine tags so
     * every tag appears in the report even when no method covers it.
     */
    private final Map<String, List<GapReportMethod>> accumulator;

    /**
     * Creates a collector that counts AI-classified security tests per taxonomy domain.
     *
     * @param minConfidence minimum AI confidence required for a classification to
     *                      be counted; must be in {@code [0.0, 1.0]}
     */
    /* default */ GapCollector(double minConfidence) {
        this.minConfidence = minConfidence;
        Map<String, List<GapReportMethod>> acc = new LinkedHashMap<>();
        for (String tag : TAXONOMY_TAGS) {
            acc.put(tag, new ArrayList<>()); // NOPMD AvoidInstantiatingObjectsInLoops
        }
        this.accumulator = acc;
    }

    /**
     * Records evidence contributed by a single test method.
     *
     * <p>
     * Only methods whose AI suggestion is non-null, security-relevant, and meets
     * the minimum confidence threshold are counted. For each qualifying AI tag that
     * belongs to the built-in taxonomy, the method is recorded under that domain.
     * </p>
     *
     * @param fqcn        fully qualified class name
     * @param method      test method name
     * @param beginLine   ignored
     * @param loc         ignored
     * @param contentHash ignored
     * @param tags        ignored (gap report relies on AI classification only)
     * @param displayName optional human-readable display name
     * @param suggestion  optional AI classification
     */
    @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 (suggestion == null || !suggestion.securityRelevant()) {
            return;
        }
        if (suggestion.confidence() < minConfidence) {
            return;
        }
        List<String> aiTags = suggestion.tags();
        if (aiTags == null || aiTags.isEmpty()) {
            return;
        }
        GapReportMethod entry = new GapReportMethod(fqcn, method, displayName);
        for (String tag : aiTags) {
            if (TAXONOMY_TAG_SET.contains(tag)) {
                accumulator.get(tag).add(entry);
            }
        }
    }

    /**
     * Builds the final {@link GapReport}.
     *
     * @param toolVersion resolved tool version string; never {@code null}
     * @return populated report with per-domain coverage, gap list, and summary
     */
    /* default */ GapReport buildReport(String toolVersion) {
        Map<String, GapReportDomain> domains = new LinkedHashMap<>();
        List<String> gaps = new ArrayList<>();
        int covered = 0;
        for (String tag : TAXONOMY_TAGS) {
            List<GapReportMethod> methods = accumulator.get(tag);
            List<GapReportMethod> copy = new ArrayList<>(methods); // NOPMD AvoidInstantiatingObjectsInLoops
            domains.put(tag, new GapReportDomain(methods.size(), // NOPMD AvoidInstantiatingObjectsInLoops
                    Collections.unmodifiableList(copy)));
            if (methods.isEmpty()) {
                gaps.add(tag);
            } else {
                covered++;
            }
        }
        int total = TAXONOMY_TAGS.size();
        double percent = total == 0 ? 0.0
                : Math.round(covered / (double) total * COVERAGE_PERCENT_SCALE) / COVERAGE_PERCENT_DIVISOR;
        GapReportSummary summary = new GapReportSummary(total, covered, total - covered, percent);
        return new GapReport(
                SCHEMA_VERSION,
                Instant.now().toString(),
                toolVersion,
                Collections.unmodifiableMap(domains),
                Collections.unmodifiableList(gaps),
                summary);
    }
}