| 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 |
|
| 85 |
1.1 2.2 |
|
| 93 |
1.1 |
|
| 94 |
1.1 |
|
| 116 |
1.1 |