GapCollector.java

1
// SPDX-License-Identifier: Apache-2.0
2
// Copyright 2026 Egothor
3
// Copyright 2026 Accenture
4
package org.egothor.methodatlas.gap;
5
6
import java.time.Instant;
7
import java.util.ArrayList;
8
import java.util.Collections;
9
import java.util.LinkedHashMap;
10
import java.util.List;
11
import java.util.Map;
12
import java.util.Set;
13
14
import org.egothor.methodatlas.ai.AiMethodSuggestion;
15
import org.egothor.methodatlas.emit.TestMethodSink;
16
17
/**
18
 * Streaming sink that builds a {@link GapReport} from scan records observed during
19
 * a single run.
20
 *
21
 * <p>
22
 * The collector implements {@link TestMethodSink} so the orchestration layer can
23
 * hand it the same record stream that drives SARIF/CSV emitters, without a second
24
 * scan and without coupling the emitters to gap-analysis logic.
25
 * </p>
26
 *
27
 * <p>
28
 * Only AI-classified, security-relevant methods are counted. Methods without an
29
 * AI suggestion, or whose suggestion is not security-relevant, or whose confidence
30
 * falls below the configured threshold, are silently skipped.
31
 * </p>
32
 *
33
 * <p>
34
 * Package-private because nothing outside the {@code gap} package needs to
35
 * construct one; {@link GapFacade} is the sole external entry point.
36
 * </p>
37
 */
38
final class GapCollector implements TestMethodSink {
39
40
    /** Schema version of the produced report. */
41
    private static final String SCHEMA_VERSION = "1";
42
43
    /** Multiplier used to round percentages to two decimal places. */
44
    private static final double COVERAGE_PERCENT_SCALE = 10_000.0;
45
46
    /** Divisor that converts the rounded ratio back into a percentage. */
47
    private static final double COVERAGE_PERCENT_DIVISOR = 100.0;
48
49
    /**
50
     * Canonical ordered set of the nine built-in taxonomy tags, mirroring the
51
     * order in {@code DefaultSecurityTaxonomy}.
52
     */
53
    private static final List<String> TAXONOMY_TAGS = List.of(
54
            "auth", "access-control", "crypto", "input-validation",
55
            "injection", "data-protection", "logging", "error-handling", "owasp");
56
57
    /** Fast lookup set for {@link #TAXONOMY_TAGS}. */
58
    private static final Set<String> TAXONOMY_TAG_SET = Set.copyOf(TAXONOMY_TAGS);
59
60
    /** Minimum AI confidence required to count a classification. */
61
    private final double minConfidence;
62
63
    /**
64
     * Accumulated methods per taxonomy tag. Initialised with all nine tags so
65
     * every tag appears in the report even when no method covers it.
66
     */
67
    private final Map<String, List<GapReportMethod>> accumulator;
68
69
    /**
70
     * Creates a collector that counts AI-classified security tests per taxonomy domain.
71
     *
72
     * @param minConfidence minimum AI confidence required for a classification to
73
     *                      be counted; must be in {@code [0.0, 1.0]}
74
     */
75
    /* default */ GapCollector(double minConfidence) {
76
        this.minConfidence = minConfidence;
77
        Map<String, List<GapReportMethod>> acc = new LinkedHashMap<>();
78
        for (String tag : TAXONOMY_TAGS) {
79
            acc.put(tag, new ArrayList<>()); // NOPMD AvoidInstantiatingObjectsInLoops
80
        }
81
        this.accumulator = acc;
82
    }
83
84
    /**
85
     * Records evidence contributed by a single test method.
86
     *
87
     * <p>
88
     * Only methods whose AI suggestion is non-null, security-relevant, and meets
89
     * the minimum confidence threshold are counted. For each qualifying AI tag that
90
     * belongs to the built-in taxonomy, the method is recorded under that domain.
91
     * </p>
92
     *
93
     * @param fqcn        fully qualified class name
94
     * @param method      test method name
95
     * @param beginLine   ignored
96
     * @param loc         ignored
97
     * @param contentHash ignored
98
     * @param tags        ignored (gap report relies on AI classification only)
99
     * @param displayName optional human-readable display name
100
     * @param suggestion  optional AI classification
101
     */
102
    @Override
103
    @SuppressWarnings("PMD.UseObjectForClearerAPI")
104
    public void record(String fqcn, String method, int beginLine, int loc, String contentHash,
105
            List<String> tags, String displayName, AiMethodSuggestion suggestion) {
106 4 1. record : removed conditional - replaced equality check with false → KILLED
2. record : removed conditional - replaced equality check with true → KILLED
3. record : removed conditional - replaced equality check with false → KILLED
4. record : removed conditional - replaced equality check with true → KILLED
        if (suggestion == null || !suggestion.securityRelevant()) {
107
            return;
108
        }
109 3 1. record : changed conditional boundary → KILLED
2. record : removed conditional - replaced comparison check with false → KILLED
3. record : removed conditional - replaced comparison check with true → KILLED
        if (suggestion.confidence() < minConfidence) {
110
            return;
111
        }
112
        List<String> aiTags = suggestion.tags();
113 4 1. record : removed conditional - replaced equality check with false → SURVIVED
2. record : removed conditional - replaced equality check with true → SURVIVED
3. record : removed conditional - replaced equality check with false → KILLED
4. record : removed conditional - replaced equality check with true → KILLED
        if (aiTags == null || aiTags.isEmpty()) {
114
            return;
115
        }
116
        GapReportMethod entry = new GapReportMethod(fqcn, method, displayName);
117
        for (String tag : aiTags) {
118 2 1. record : removed conditional - replaced equality check with true → KILLED
2. record : removed conditional - replaced equality check with false → KILLED
            if (TAXONOMY_TAG_SET.contains(tag)) {
119
                accumulator.get(tag).add(entry);
120
            }
121
        }
122
    }
123
124
    /**
125
     * Builds the final {@link GapReport}.
126
     *
127
     * @param toolVersion resolved tool version string; never {@code null}
128
     * @return populated report with per-domain coverage, gap list, and summary
129
     */
130
    /* default */ GapReport buildReport(String toolVersion) {
131
        Map<String, GapReportDomain> domains = new LinkedHashMap<>();
132
        List<String> gaps = new ArrayList<>();
133
        int covered = 0;
134
        for (String tag : TAXONOMY_TAGS) {
135
            List<GapReportMethod> methods = accumulator.get(tag);
136
            List<GapReportMethod> copy = new ArrayList<>(methods); // NOPMD AvoidInstantiatingObjectsInLoops
137
            domains.put(tag, new GapReportDomain(methods.size(), // NOPMD AvoidInstantiatingObjectsInLoops
138
                    Collections.unmodifiableList(copy)));
139 2 1. buildReport : removed conditional - replaced equality check with true → KILLED
2. buildReport : removed conditional - replaced equality check with false → KILLED
            if (methods.isEmpty()) {
140
                gaps.add(tag);
141
            } else {
142 1 1. buildReport : Changed increment from 1 to -1 → KILLED
                covered++;
143
            }
144
        }
145
        int total = TAXONOMY_TAGS.size();
146 2 1. buildReport : removed conditional - replaced equality check with false → SURVIVED
2. buildReport : removed conditional - replaced equality check with true → KILLED
        double percent = total == 0 ? 0.0
147 3 1. buildReport : Replaced double multiplication with division → KILLED
2. buildReport : Replaced double division with multiplication → KILLED
3. buildReport : Replaced double division with multiplication → KILLED
                : Math.round(covered / (double) total * COVERAGE_PERCENT_SCALE) / COVERAGE_PERCENT_DIVISOR;
148 1 1. buildReport : Replaced integer subtraction with addition → KILLED
        GapReportSummary summary = new GapReportSummary(total, covered, total - covered, percent);
149 1 1. buildReport : replaced return value with null for org/egothor/methodatlas/gap/GapCollector::buildReport → KILLED
        return new GapReport(
150
                SCHEMA_VERSION,
151
                Instant.now().toString(),
152
                toolVersion,
153
                Collections.unmodifiableMap(domains),
154
                Collections.unmodifiableList(gaps),
155
                summary);
156
    }
157
}

Mutations

106

1.1
Location : record
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:atMinConfidenceBoundary_included()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : record
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:atMinConfidenceBoundary_included()]
removed conditional - replaced equality check with true → KILLED

3.3
Location : record
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:nonSecurityRelevant_isSkipped()]
removed conditional - replaced equality check with false → KILLED

4.4
Location : record
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:nullSuggestion_isSkipped()]
removed conditional - replaced equality check with true → KILLED

109

1.1
Location : record
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:atMinConfidenceBoundary_included()]
changed conditional boundary → KILLED

2.2
Location : record
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:belowMinConfidence_excluded()]
removed conditional - replaced comparison check with false → KILLED

3.3
Location : record
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:atMinConfidenceBoundary_included()]
removed conditional - replaced comparison check with true → KILLED

113

1.1
Location : record
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:atMinConfidenceBoundary_included()]
removed conditional - replaced equality check with false → KILLED

2.2
Location : record
Killed by : none
removed conditional - replaced equality check with false → SURVIVED
Covering tests

3.3
Location : record
Killed by : none
removed conditional - replaced equality check with true → SURVIVED Covering tests

4.4
Location : record
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:atMinConfidenceBoundary_included()]
removed conditional - replaced equality check with true → KILLED

118

1.1
Location : record
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:unrecognisedTag_ignored()]
removed conditional - replaced equality check with true → KILLED

2.2
Location : record
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:atMinConfidenceBoundary_included()]
removed conditional - replaced equality check with false → KILLED

139

1.1
Location : buildReport
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:atMinConfidenceBoundary_included()]
removed conditional - replaced equality check with true → KILLED

2.2
Location : buildReport
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:nullSuggestion_isSkipped()]
removed conditional - replaced equality check with false → KILLED

142

1.1
Location : buildReport
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:allDomainsCovered_hundredPercent()]
Changed increment from 1 to -1 → KILLED

146

1.1
Location : buildReport
Killed by : none
removed conditional - replaced equality check with false → SURVIVED
Covering tests

2.2
Location : buildReport
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:allDomainsCovered_hundredPercent()]
removed conditional - replaced equality check with true → KILLED

147

1.1
Location : buildReport
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:allDomainsCovered_hundredPercent()]
Replaced double multiplication with division → KILLED

2.2
Location : buildReport
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:allDomainsCovered_hundredPercent()]
Replaced double division with multiplication → KILLED

3.3
Location : buildReport
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:allDomainsCovered_hundredPercent()]
Replaced double division with multiplication → KILLED

148

1.1
Location : buildReport
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:allDomainsCovered_hundredPercent()]
Replaced integer subtraction with addition → KILLED

149

1.1
Location : buildReport
Killed by : org.egothor.methodatlas.gap.GapCollectorTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.gap.GapCollectorTest]/[method:nullSuggestion_isSkipped()]
replaced return value with null for org/egothor/methodatlas/gap/GapCollector::buildReport → KILLED

Active mutators

Tests examined


Report generated by PIT 1.30.1 support