AiResponseArchive.java

1
// SPDX-License-Identifier: Apache-2.0
2
// Copyright 2026 Egothor
3
// Copyright 2026 Accenture
4
package org.egothor.methodatlas.evidence;
5
6
import java.io.BufferedWriter;
7
import java.io.IOException;
8
import java.nio.charset.StandardCharsets;
9
import java.nio.file.Files;
10
import java.nio.file.Path;
11
import java.time.Instant;
12
import java.util.ArrayList;
13
import java.util.LinkedHashMap;
14
import java.util.List;
15
import java.util.Map;
16
17
import tools.jackson.databind.json.JsonMapper;
18
19
import org.egothor.methodatlas.ai.AiResponseListener;
20
21
/**
22
 * Buffers AI provider responses during an evidence-pack scan and flushes them
23
 * as a JSON-Lines file ({@code ai-responses.jsonl}) when the scan completes.
24
 *
25
 * <p>
26
 * This class is package-private because it is an implementation detail of the
27
 * {@link EvidencePackCommand}; nothing outside the {@code evidence} package
28
 * should depend on its internal structure.
29
 * </p>
30
 *
31
 * <p>
32
 * Entries are buffered in insertion order. {@link #flush(Path)} is a no-op
33
 * when no entries have been captured: an absent file is a stronger signal to
34
 * auditors than an empty one.
35
 * </p>
36
 */
37
final class AiResponseArchive implements AiResponseListener {
38
39
    /** Entries buffered until {@link #flush(Path)} is invoked. */
40
    private final List<Entry> records = new ArrayList<>();
41
42
    /**
43
     * Appends a single response record to the in-memory buffer.
44
     *
45
     * @param contentHash    SHA-256 hash of the analysed class; may be {@code null}
46
     * @param fqcn           fully qualified class name
47
     * @param prompt         rendered prompt sent to the provider
48
     * @param response       response text returned by the provider
49
     * @param modelId        provider-specific model identifier
50
     * @param promptTokens   approximate prompt token count; {@code -1} when unknown
51
     * @param responseTokens approximate response token count; {@code -1} when unknown
52
     */
53
    @Override
54
    @SuppressWarnings("PMD.UseObjectForClearerAPI")
55
    public void onResponse(String contentHash, String fqcn,
56
            String prompt, String response,
57
            String modelId, int promptTokens, int responseTokens) {
58
        records.add(new Entry(contentHash, fqcn, prompt, response, modelId,
59
                promptTokens, responseTokens, Instant.now().toString()));
60
    }
61
62
    /**
63
     * Returns the number of buffered response records.
64
     *
65
     * @return record count; non-negative
66
     */
67
    /* default */ int size() {
68 1 1. size : replaced int return with 0 for org/egothor/methodatlas/evidence/AiResponseArchive::size → NO_COVERAGE
        return records.size();
69
    }
70
71
    /**
72
     * Writes the buffered records as one JSON object per line to
73
     * {@code outputFile}, encoded in UTF-8.
74
     *
75
     * <p>
76
     * When no records have been buffered the method returns without creating
77
     * the file: callers rely on the absence of {@code ai-responses.jsonl} to
78
     * communicate "no AI was used" to auditors.
79
     * </p>
80
     *
81
     * @param outputFile path of the JSONL file to create or overwrite
82
     * @throws IOException if writing fails
83
     */
84
    /* default */ void flush(Path outputFile) throws IOException {
85 2 1. flush : removed conditional - replaced equality check with true → SURVIVED
2. flush : removed conditional - replaced equality check with false → KILLED
        if (records.isEmpty()) {
86
            return;
87
        }
88
        JsonMapper mapper = JsonMapper.builder().build();
89
        // One LinkedHashMap per record is intentional — each row carries
90
        // independent values and the records list does not retain payloads.
91
        try (BufferedWriter writer = Files.newBufferedWriter(outputFile, StandardCharsets.UTF_8)) {
92
            for (Entry record : records) {
93 1 1. flush : removed call to java/io/BufferedWriter::write → NO_COVERAGE
                writer.write(mapper.writeValueAsString(toPayload(record)));
94 1 1. flush : removed call to java/io/BufferedWriter::write → NO_COVERAGE
                writer.write('\n');
95
            }
96
        }
97
    }
98
99
    /**
100
     * Materialises one record as an insertion-ordered map suitable for
101
     * JSON serialisation.
102
     *
103
     * @param record buffered AI response
104
     * @return populated map; never {@code null}
105
     */
106
    private static Map<String, Object> toPayload(Entry record) {
107
        Map<String, Object> payload = new LinkedHashMap<>();
108
        payload.put("contentHash", record.contentHash);
109
        payload.put("fqcn", record.fqcn);
110
        payload.put("promptTokens", record.promptTokens);
111
        payload.put("responseTokens", record.responseTokens);
112
        payload.put("prompt", record.prompt);
113
        payload.put("response", record.response);
114
        payload.put("modelId", record.modelId);
115
        payload.put("timestampUtc", record.timestampUtc);
116 1 1. toPayload : replaced return value with Collections.emptyMap for org/egothor/methodatlas/evidence/AiResponseArchive::toPayload → NO_COVERAGE
        return payload;
117
    }
118
119
    /**
120
     * Internal value type used to capture one provider call. Kept as a
121
     * private inner class so it cannot leak into the public API of the
122
     * evidence package.
123
     */
124
    private static final class Entry {
125
        /** SHA-256 of the analysed class; may be {@code null}. */
126
        private final String contentHash;
127
        /** Fully qualified class name passed to the provider. */
128
        private final String fqcn;
129
        /** Rendered prompt text. */
130
        private final String prompt;
131
        /** Raw response text returned by the provider. */
132
        private final String response;
133
        /** Provider-specific model identifier; may be {@code null}. */
134
        private final String modelId;
135
        /** Approximate prompt token count; {@code -1} when unknown. */
136
        private final int promptTokens;
137
        /** Approximate response token count; {@code -1} when unknown. */
138
        private final int responseTokens;
139
        /** ISO-8601 UTC capture timestamp. */
140
        private final String timestampUtc;
141
142
        /* default */ Entry(String contentHash, String fqcn, String prompt, String response,
143
                String modelId, int promptTokens, int responseTokens, String timestampUtc) {
144
            this.contentHash = contentHash;
145
            this.fqcn = fqcn;
146
            this.prompt = prompt;
147
            this.response = response;
148
            this.modelId = modelId;
149
            this.promptTokens = promptTokens;
150
            this.responseTokens = responseTokens;
151
            this.timestampUtc = timestampUtc;
152
        }
153
    }
154
}

Mutations

68

1.1
Location : size
Killed by : none
replaced int return with 0 for org/egothor/methodatlas/evidence/AiResponseArchive::size → NO_COVERAGE

85

1.1
Location : flush
Killed by : org.egothor.methodatlas.EvidencePackCommandTest.[engine:junit-jupiter]/[class:org.egothor.methodatlas.EvidencePackCommandTest]/[method:writesExpectedArtefactsForUnsignedAsvsPack(java.nio.file.Path)]
removed conditional - replaced equality check with false → KILLED

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

93

1.1
Location : flush
Killed by : none
removed call to java/io/BufferedWriter::write → NO_COVERAGE

94

1.1
Location : flush
Killed by : none
removed call to java/io/BufferedWriter::write → NO_COVERAGE

116

1.1
Location : toPayload
Killed by : none
replaced return value with Collections.emptyMap for org/egothor/methodatlas/evidence/AiResponseArchive::toPayload → NO_COVERAGE

Active mutators

Tests examined


Report generated by PIT 1.22.1