AuditWriter.java
package org.egothor.methodatlas.gui.service;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.dataformat.yaml.YAMLMapper;
import tools.jackson.dataformat.yaml.YAMLWriteFeature;
import org.egothor.methodatlas.ai.AiMethodSuggestion;
import org.egothor.methodatlas.emit.TagAiDrift;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Writes audit evidence after a batch "Save All Changes" operation.
*
* <p>Two artefacts are produced in the hidden {@code .methodatlas/}
* subdirectory of the scanned project root:</p>
*
* <ol>
* <li><strong>Timestamped evidence CSV</strong> —
* {@code methodatlas-YYYYMMDD-HHmmss.csv}. One row per saved method.
* The first thirteen columns are the CLI {@code DeltaReport} schema, so
* existing tooling (diff, import, comparison) works without modification
* — {@code DeltaReport} resolves columns by name and ignores any it does
* not recognise. Two extra columns, {@code tags_added} and
* {@code tags_removed}, record the reviewer's tag changes relative to the
* AI suggestion. The file is never overwritten; each Save All creates a
* new timestamped file, giving an append-only audit trail.</li>
* <li><strong>Cumulative override YAML</strong> —
* {@code overrides.yaml}. Uses the {@code ClassificationOverride} YAML
* format consumed by the CLI {@code -override-file} flag so that future
* analysis runs can reproduce the same tag decisions without re-invoking
* the AI. Existing entries are updated in place; new entries are
* appended. The {@code note} field carries the operator name (when
* configured in settings) and the ISO-8601 review timestamp.</li>
* </ol>
*
* <p>A third file, {@value #MANIFEST_FILE}, records the SHA-256 of every
* evidence CSV and of {@code overrides.yaml} in {@code sha256sum} format so the
* audit files are tamper-evident by comparison (hash only, no signature).
* Evidence CSVs are created with create-new semantics; a same-second collision
* yields a {@code -N} suffixed name instead of an overwrite.</p>
*
* <p><strong>Manifest trust model.</strong> The manifest is hash-only: it
* proves that a listed file still has the bytes recorded at its last save, but
* it does not cover itself and carries no signature, so anyone able to edit
* the evidence can regenerate it. Anchor it outside the working tree (commit it,
* or include it in a signed evidence pack) to make tampering detectable. Because
* {@code overrides.yaml} is cumulative and mutable, an edit made outside the GUI
* between two saves is detected by comparing its digest with the previous
* manifest entry before the new save; a mismatch is logged at WARNING and the
* save proceeds, so a silent out-of-band edit is never laundered unnoticed.</p>
*
* <p>Both files are written after source patches have been applied. If
* writing fails the caller should show a warning — the source patches are
* already on disk and must not be rolled back.</p>
*
* <p><strong>Schema note ({@link #SCHEMA_VERSION} 2):</strong> the
* {@code content_hash} column, blank in version 1, now carries the CLI
* per-class SHA-256 content hash.</p>
*
* <p><strong>Schema note ({@link #SCHEMA_VERSION} 3):</strong> the new final
* column {@code ai_taxonomy_version} records the taxonomy version under which
* the AI suggestion was produced (same identifier as the CLI column of the same
* name); it is blank for rows without an AI suggestion.</p>
*
* <p><strong>Schema note (4.0.0):</strong> the {@code tag_ai_drift} column now
* follows the same definition as the CLI, {@link TagAiDrift} — agreement
* between a source-level {@code @Tag("security")} marker and the AI
* security-relevance verdict — rather than a comparison of the whole applied
* and AI tag sets. The reviewer's set changes moved to the dedicated
* {@code tags_added}/{@code tags_removed} columns. See the Migration Guide.</p>
*
* @see org.egothor.methodatlas.gui.model.AppSettings#getOperatorName()
* @see TagAiDrift
*/
public final class AuditWriter {
/**
* Schema version of the GUI audit-trail artefacts. Version 2 populates the
* previously blank {@code content_hash} column, adds
* {@value #MANIFEST_FILE}, and makes evidence CSV names collision-proof.
* Version 3 appends the {@code ai_taxonomy_version} column as the last
* column. Existing column order and the {@code ClassificationOverride}
* fields are unchanged from version 1.
*/
public static final int SCHEMA_VERSION = 3;
/** File name of the SHA-256 manifest inside {@code .methodatlas/}. */
public static final String MANIFEST_FILE = "manifest.sha256";
private static final Logger LOG = Logger.getLogger(AuditWriter.class.getName());
private static final String OVERRIDES_FILE = "overrides.yaml";
private static final String MANIFEST_SEPARATOR = " ";
private static final int SHA256_HEX_LENGTH = 64;
private static final int MANIFEST_LINE_CAPACITY = 96;
/** Highest {@code -N} suffix tried when an evidence CSV name is taken. */
private static final int MAX_COLLISION_SUFFIX = 999;
private static final DateTimeFormatter FILE_TS =
DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
private static final DateTimeFormatter NOTE_TS =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
/** Shared, thread-safe YAML mapper (immutable after build). */
private static final ObjectMapper YAML_MAPPER = YAMLMapper.builder()
.disable(YAMLWriteFeature.WRITE_DOC_START_MARKER)
.build();
private AuditWriter() {}
/**
* Immutable snapshot of one saved method, captured before the staged patch
* is cleared.
*
* @param fqcn fully-qualified class name
* @param method simple method name
* @param loc lines of code (0 when unavailable)
* @param appliedTags tags written to the source file
* @param appliedDisplayName display name written to source, or {@code null}
* @param suggestion AI suggestion at save time, or {@code null}
* @param contentHash SHA-256 content hash of the enclosing class as
* scanned, computed with the CLI hashing
* ({@code ContentHasher.hashClass}); {@code null}
* when the class source was unavailable, in which
* case the {@code content_hash} column is blank
* @param aiTaxonomyVersion taxonomy version identifier under which
* {@code suggestion} was produced; written to the
* {@code ai_taxonomy_version} column only when
* {@code suggestion} is non-{@code null}; may be
* {@code null} (column blank)
*/
public record SavedEntry(
String fqcn,
String method,
int loc,
List<String> appliedTags,
String appliedDisplayName,
AiMethodSuggestion suggestion,
String contentHash,
String aiTaxonomyVersion) {
/**
* Creates an entry without a taxonomy version (the
* {@code ai_taxonomy_version} column is written blank).
*
* @param fqcn fully-qualified class name
* @param method simple method name
* @param loc lines of code (0 when unavailable)
* @param appliedTags tags written to the source file
* @param appliedDisplayName display name written to source, or {@code null}
* @param suggestion AI suggestion at save time, or {@code null}
* @param contentHash class content hash, or {@code null}
*/
public SavedEntry(String fqcn, String method, int loc, List<String> appliedTags,
String appliedDisplayName, AiMethodSuggestion suggestion, String contentHash) {
this(fqcn, method, loc, appliedTags, appliedDisplayName, suggestion, contentHash, null);
}
}
/**
* Writes the evidence CSV, updates the override YAML and refreshes the
* SHA-256 manifest for all supplied saved entries.
*
* <p>The {@code .methodatlas/} directory is created if it does not exist.
* The evidence CSV receives a fresh timestamped name (never an existing
* one, see {@link #write(Path, List, String, Clock)}); the override YAML is
* merged in place; {@value #MANIFEST_FILE} then records the SHA-256 of both
* artefacts.</p>
*
* @param scannedDir root directory that was scanned; artefacts go into
* its {@code .methodatlas/} subdirectory
* @param entries entries that were successfully patched, in discovery
* order; must not be {@code null} or empty
* @param operatorName reviewer identifier for the {@code note} field, or
* an empty string to omit it
* @throws IOException if any output file cannot be written
*/
public static void write(Path scannedDir, List<SavedEntry> entries,
String operatorName) throws IOException {
write(scannedDir, entries, operatorName, Clock.systemDefaultZone());
}
/**
* Clock-injectable variant of {@link #write(Path, List, String)}.
*
* <p>The evidence CSV is created with {@link StandardOpenOption#CREATE_NEW};
* if a file of the same second already exists a {@code -N} counter suffix
* ({@code methodatlas-YYYYMMDD-HHmmss-1.csv}, {@code -2}, ...) is used, so an
* existing audit record is never overwritten.</p>
*
* @param scannedDir root directory that was scanned
* @param entries entries that were successfully patched
* @param operatorName reviewer identifier, or an empty string
* @param clock time source for the file name and the override note
* @throws IOException if any output file cannot be written, or if all
* {@value #MAX_COLLISION_SUFFIX} collision suffixes are taken
*/
// PMD.AvoidCatchingGenericException: deliberately catches RuntimeException so the CSV
// is recorded in the manifest and never orphaned, whatever the YAML step throws.
@SuppressWarnings("PMD.AvoidCatchingGenericException")
/* default */ static void write(Path scannedDir, List<SavedEntry> entries,
String operatorName, Clock clock) throws IOException {
Path auditDir = scannedDir.resolve(".methodatlas");
Files.createDirectories(auditDir);
LocalDateTime now = LocalDateTime.now(clock);
warnIfOverridesEditedOutOfBand(auditDir);
Path csvFile = writeEvidenceCsv(auditDir, entries, now);
try {
updateOverrideYaml(auditDir, entries, operatorName, now);
} catch (IOException | RuntimeException e) {
// The CSV already exists on disk; record it in the manifest even though the
// YAML step failed, so it can never be orphaned outside the manifest.
try {
updateManifest(auditDir, csvFile);
} catch (IOException manifestFailure) {
e.addSuppressed(manifestFailure);
}
throw e;
}
updateManifest(auditDir, csvFile);
}
/**
* Logs a warning when {@code overrides.yaml} no longer matches the digest in the
* previous manifest, i.e. it was edited outside MethodAtlas since the last save.
*
* <p>Must run before the override YAML is rewritten, otherwise the comparison
* would see this save's own changes.</p>
*
* @param dir the {@code .methodatlas/} directory
* @throws IOException if the manifest or the YAML cannot be read
*/
private static void warnIfOverridesEditedOutOfBand(Path dir) throws IOException {
Path yaml = dir.resolve(OVERRIDES_FILE);
String recorded = readManifest(dir.resolve(MANIFEST_FILE)).get(OVERRIDES_FILE);
if (recorded == null || !Files.exists(yaml)) {
return;
}
String actual = sha256Hex(yaml);
if (!actual.equals(recorded) && LOG.isLoggable(Level.WARNING)) {
LOG.log(Level.WARNING, "{0} changed outside MethodAtlas since the last save (manifest {1}, now {2});"
+ " the manifest is refreshed with the new digest. Review the edit.",
new Object[] {yaml, recorded, actual});
}
}
// ── Evidence CSV ──────────────────────────────────────────────────────
/**
* Atomically creates an empty evidence CSV that cannot replace an existing
* file.
*
* @param dir the {@code .methodatlas/} directory
* @param timestamp time used for the file name
* @return path of the newly created, empty file
* @throws IOException if the file cannot be created or the suffix space is
* exhausted
*/
private static Path createEvidenceFile(Path dir, LocalDateTime timestamp) throws IOException {
String base = "methodatlas-" + timestamp.format(FILE_TS);
for (int attempt = 0; attempt <= MAX_COLLISION_SUFFIX; attempt++) {
Path candidate = dir.resolve(base + (attempt == 0 ? "" : "-" + attempt) + ".csv");
if (tryCreate(candidate)) {
return candidate;
}
}
throw new IOException("No free evidence file name for " + base + " in " + dir);
}
/**
* Creates {@code file} only if it does not exist (create-new semantics).
*
* @param file file to create
* @return {@code true} if created, {@code false} if it already existed
* @throws IOException on any other I/O failure
*/
private static boolean tryCreate(Path file) throws IOException {
try {
Files.createFile(file);
return true;
} catch (FileAlreadyExistsException e) {
return false;
}
}
@SuppressWarnings("PMD.NPathComplexity")
private static Path writeEvidenceCsv(Path dir, List<SavedEntry> entries,
LocalDateTime timestamp) throws IOException {
Path csvFile = createEvidenceFile(dir, timestamp);
try (PrintWriter w = new PrintWriter(
Files.newBufferedWriter(csvFile, StandardCharsets.UTF_8,
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING))) {
// Explicit LF (not println) so the file is byte-identical to the LF CLI CSV on every OS.
w.print("fqcn,method,loc,tags,display_name,content_hash,"
+ "ai_security_relevant,ai_display_name,ai_tags,ai_reason,"
+ "ai_interaction_score,ai_confidence,tag_ai_drift,tags_added,tags_removed,ai_taxonomy_version\n");
for (SavedEntry e : entries) {
AiMethodSuggestion ai = e.suggestion();
List<String> applied = e.appliedTags();
w.print(
csv(e.fqcn()) + ","
+ csv(e.method()) + ","
+ (e.loc() > 0 ? e.loc() : "") + ","
+ csv(applied != null ? String.join(";", applied) : "") + ","
+ csv(e.appliedDisplayName() != null ? e.appliedDisplayName() : "") + ","
+ csv(e.contentHash()) + ","
+ (ai != null ? ai.securityRelevant() : "") + ","
+ csv(ai != null && ai.displayName() != null ? ai.displayName() : "") + ","
+ csv(ai != null && ai.tags() != null ? String.join(";", ai.tags()) : "") + ","
+ csv(ai != null && ai.reason() != null ? ai.reason() : "") + ","
+ (ai != null ? String.format(Locale.ROOT, "%.1f", ai.interactionScore()) : "") + ","
+ (ai != null ? String.format(Locale.ROOT, "%.1f", ai.confidence()) : "") + ","
+ driftValue(applied, ai) + ","
+ csv(tagDelta(applied, ai, true)) + ","
+ csv(tagDelta(applied, ai, false)) + ","
+ csv(ai != null ? e.aiTaxonomyVersion() : "") + "\n");
}
}
return csvFile;
}
// ── SHA-256 manifest ──────────────────────────────────────────────────
/**
* Refreshes {@value #MANIFEST_FILE} in the same {@code sha256sum}-compatible
* format as the evidence-pack manifest ({@code <hex> <filename>}, sorted by
* file name).
*
* <p>Entries already recorded for earlier evidence CSVs are carried over
* <em>unchanged</em>, never re-hashed, so a later edit of an old CSV stays
* detectable; the CSV just written and the (mutable, cumulative)
* {@code overrides.yaml} are hashed afresh.</p>
*
* @param dir the {@code .methodatlas/} directory
* @param csvFile evidence CSV written by this run
* @throws IOException if an artefact cannot be read or the manifest written
*/
private static void updateManifest(Path dir, Path csvFile) throws IOException {
Path manifest = dir.resolve(MANIFEST_FILE);
Map<String, String> digests = readManifest(manifest);
for (Path artefact : List.of(csvFile, dir.resolve(OVERRIDES_FILE))) {
// overrides.yaml may be absent when the YAML step failed on a first save.
if (Files.exists(artefact)) {
digests.put(artefact.getFileName().toString(), sha256Hex(artefact));
}
}
StringBuilder out = new StringBuilder(digests.size() * MANIFEST_LINE_CAPACITY);
digests.forEach((name, hash) ->
out.append(hash).append(MANIFEST_SEPARATOR).append(name).append('\n'));
Files.writeString(manifest, out, StandardCharsets.UTF_8);
}
/**
* Parses an existing manifest into a mutable, name-sorted map; malformed lines
* are ignored.
*
* @param manifest manifest path; need not exist
* @return file name to lowercase-hex digest; empty when the manifest is absent
* @throws IOException if the manifest exists but cannot be read
*/
private static Map<String, String> readManifest(Path manifest) throws IOException {
Map<String, String> digests = new TreeMap<>();
if (Files.exists(manifest)) {
for (String line : Files.readAllLines(manifest, StandardCharsets.UTF_8)) {
if (line.length() > SHA256_HEX_LENGTH + MANIFEST_SEPARATOR.length()
&& line.startsWith(MANIFEST_SEPARATOR, SHA256_HEX_LENGTH)) {
digests.put(line.substring(SHA256_HEX_LENGTH + MANIFEST_SEPARATOR.length()),
line.substring(0, SHA256_HEX_LENGTH));
}
}
}
return digests;
}
/**
* Computes the SHA-256 of a file's bytes as lowercase hexadecimal.
*
* @param file file to hash
* @return 64-character lowercase hex digest
* @throws IOException if the file cannot be read
*/
private static String sha256Hex(Path file) throws IOException {
try {
return HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file)));
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-256 not available", e);
}
}
// ── Override YAML ─────────────────────────────────────────────────────
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> loadExistingOverrides(
Path yamlFile, ObjectMapper mapper) throws IOException {
List<Map<String, Object>> existing = new ArrayList<>();
if (!Files.exists(yamlFile)) { return existing; }
Map<String, Object> root = mapper.readValue(yamlFile.toFile(), Map.class);
if (root == null || !(root.get("overrides") instanceof List<?> list)) { return existing; }
for (Object item : list) {
if (item instanceof Map<?, ?> m) {
existing.add(new LinkedHashMap<>((Map<String, Object>) m));
}
}
return existing;
}
@SuppressWarnings({"PMD.NPathComplexity", "PMD.AvoidInstantiatingObjectsInLoops"})
private static void updateOverrideYaml(Path dir, List<SavedEntry> entries,
String operatorName, LocalDateTime timestamp) throws IOException {
Path yamlFile = dir.resolve("overrides.yaml");
ObjectMapper mapper = YAML_MAPPER;
// Load existing overrides (if any) into a mutable list
List<Map<String, Object>> existing = loadExistingOverrides(yamlFile, mapper);
// Build an index keyed by "fqcn#method" for O(1) lookup
Map<String, Integer> idx = new LinkedHashMap<>();
for (int i = 0; i < existing.size(); i++) {
Map<String, Object> entry = existing.get(i);
idx.put(entry.get("fqcn") + "#" + entry.get("method"), i);
}
String note = buildNote(operatorName, timestamp);
for (SavedEntry e : entries) {
AiMethodSuggestion ai = e.suggestion();
List<String> applied = e.appliedTags();
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("fqcn", e.fqcn());
entry.put("method", e.method());
// Applying tags implies security relevance; preserve AI classification otherwise
boolean secRel = (applied != null && !applied.isEmpty())
|| (ai != null && ai.securityRelevant());
entry.put("securityRelevant", secRel);
if (applied != null && !applied.isEmpty()) {
entry.put("tags", new ArrayList<>(applied));
}
if (e.appliedDisplayName() != null && !e.appliedDisplayName().isBlank()) {
entry.put("displayName", e.appliedDisplayName());
}
if (ai != null && ai.reason() != null && !ai.reason().isBlank()) {
entry.put("reason", ai.reason());
}
entry.put("note", note);
String key = e.fqcn() + "#" + e.method();
Integer existingIdx = idx.get(key);
if (existingIdx != null) {
existing.set(existingIdx, entry);
} else {
idx.put(key, existing.size());
existing.add(entry);
}
}
Map<String, Object> root = new LinkedHashMap<>();
root.put("overrides", existing);
mapper.writerWithDefaultPrettyPrinter().writeValue(yamlFile.toFile(), root);
}
// ── Helpers ───────────────────────────────────────────────────────────
private static String buildNote(String operatorName, LocalDateTime timestamp) {
String ts = timestamp.format(NOTE_TS);
if (operatorName != null && !operatorName.isBlank()) {
return "Reviewed " + ts + " by " + operatorName.trim();
}
return "Reviewed " + ts;
}
/**
* Computes the {@code tag_ai_drift} value using the same definition as the
* CLI ({@link TagAiDrift}): the agreement between the applied
* {@code @Tag("security")} marker and the AI security-relevance verdict.
*
* @param applied tags applied to the method, or {@code null}
* @param ai AI suggestion, or {@code null} when AI was not run
* @return {@code none}, {@code tag-only}, or {@code ai-only}; empty string
* when there is no AI suggestion to compare against
*/
private static String driftValue(List<String> applied, AiMethodSuggestion ai) {
if (ai == null) {
return "";
}
TagAiDrift drift = TagAiDrift.compute(applied != null ? applied : List.of(), ai);
return drift != null ? drift.toValue() : "";
}
/**
* Computes the reviewer's tag changes relative to the AI suggestion, sorted
* and joined with {@code ;} for a stable, comparable audit record.
*
* @param applied tags applied to the method, or {@code null}
* @param ai AI suggestion, or {@code null} when AI was not run
* @param added {@code true} for tags the reviewer added beyond the AI
* suggestion; {@code false} for tags the AI suggested that the
* reviewer did not keep
* @return sorted, semicolon-joined tag names; empty string when there is no
* AI suggestion to diff against
*/
private static String tagDelta(List<String> applied, AiMethodSuggestion ai, boolean added) {
if (ai == null) {
return "";
}
List<String> aiTags = ai.tags();
return added ? TagAiDrift.tagDifference(applied, aiTags)
: TagAiDrift.tagDifference(aiTags, applied);
}
/**
* RFC 4180 CSV field quoting, with the same spreadsheet formula-injection
* guard as the CLI emitter: a value whose first character is {@code =},
* {@code +}, {@code -}, or {@code @} is quoted so a spreadsheet does not
* evaluate it as a formula.
*
* @param value field value; may be {@code null}
* @return CSV-safe representation; empty string for {@code null}
*/
private static String csv(String value) {
if (value == null || value.isEmpty()) { return ""; }
boolean formulaPrefix = "=+-@".indexOf(value.charAt(0)) >= 0;
if (formulaPrefix || value.contains(",") || value.contains("\"") || value.contains("\n")
|| value.contains("\r")) {
return "\"" + value.replace("\"", "\"\"") + "\"";
}
return value;
}
}