diff --git a/src/Unwritten.Storage/FindingsLog.cs b/src/Unwritten.Storage/FindingsLog.cs new file mode 100644 index 0000000..c788ccb --- /dev/null +++ b/src/Unwritten.Storage/FindingsLog.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Unwritten.Storage; + +/// +/// Append-only log of every findings result the tool hands to a consumer +/// (MCP model, CLI, Stop hook), written to .unwritten/findings.log as a +/// stream of pretty-printed JSON entries. Best-effort: logging must never fail +/// a check, so all I/O errors are swallowed. Size-capped: once the file grows +/// past the oldest entries are trimmed away. +/// +public static class FindingsLog +{ + /// Cap on the log file size; oldest entries are trimmed once exceeded. + private const long MaxBytes = 1024 * 1024; + + /// Trim down to this size (newest entries kept) so trims stay infrequent. + private const long TrimTargetBytes = MaxBytes / 2; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static string GetLogPath(string repoPath) => + Path.Combine(repoPath, ".unwritten", "findings.log"); + + /// + /// Appends one entry recording the findings returned to a consumer. + /// names the surface ("mcp", "cli", "stop-hook"). + /// + public static void Append(string repoPath, string source, object result) + { + try + { + string directory = Path.Combine(repoPath, ".unwritten"); + Directory.CreateDirectory(directory); + IndexStore.EnsureSelfGitignore(directory); + + var entry = new { Timestamp = DateTimeOffset.UtcNow, Source = source, Result = result }; + string path = GetLogPath(repoPath); + File.AppendAllText(path, JsonSerializer.Serialize(entry, JsonOptions) + Environment.NewLine); + TrimIfOverCap(path); + } + catch + { + // Best-effort by design: a broken log must never fail a check, and on + // the MCP path stdout is protocol so there is nowhere safe to report. + } + } + + private static void TrimIfOverCap(string path) + { + if (new FileInfo(path).Length <= MaxBytes) + { + return; + } + + var lines = File.ReadAllLines(path); + + // Entries are pretty-printed, so each spans many lines; an entry starts + // where its opening brace sits alone at column 0. + var starts = new List(); + for (int i = 0; i < lines.Length; i++) + { + if (lines[i] == "{") + { + starts.Add(i); + } + } + + if (starts.Count <= 1) + { + return; + } + + // Walk entries newest-first, keeping whole entries (always at least the + // newest) until the trim target is reached. Char count approximates + // bytes well enough for a cap on mostly-ASCII JSON. + long kept = 0; + int firstKept = -1; + for (int s = starts.Count - 1; s >= 0; s--) + { + int end = s + 1 < starts.Count ? starts[s + 1] : lines.Length; + long entrySize = 0; + for (int i = starts[s]; i < end; i++) + { + entrySize += lines[i].Length + Environment.NewLine.Length; + } + + if (firstKept >= 0 && kept + entrySize > TrimTargetBytes) + { + break; + } + + kept += entrySize; + firstKept = starts[s]; + } + + // Unique temp name then move: an MCP server and a hook can log concurrently. + string tempPath = Path.Combine(Path.GetDirectoryName(path)!, Path.GetRandomFileName() + ".tmp"); + File.WriteAllLines(tempPath, lines.Skip(firstKept)); + File.Move(tempPath, path, overwrite: true); + } +} diff --git a/src/Unwritten.Storage/IndexStore.cs b/src/Unwritten.Storage/IndexStore.cs index 756f25e..a690f67 100644 --- a/src/Unwritten.Storage/IndexStore.cs +++ b/src/Unwritten.Storage/IndexStore.cs @@ -12,6 +12,9 @@ public static class IndexStore private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + // Deliberately compact: the index is machine state with no human reader, + // it is deserialized fresh on every CLI/hook invocation, and on large + // repos it reaches tens of MB — indentation would add ~60% to that cost. WriteIndented = false, }; diff --git a/src/Unwritten/CheckCommand.cs b/src/Unwritten/CheckCommand.cs index 68807b3..a16a65e 100644 --- a/src/Unwritten/CheckCommand.cs +++ b/src/Unwritten/CheckCommand.cs @@ -28,7 +28,7 @@ public static class CheckCommand --strict Treat content-suppressed holes as real holes. """; - public static int Run(string[] args, IndexManager indexManager, GitTransactionSource gitSource, TextWriter output) + public static int Run(string[] args, IndexManager indexManager, GitTransactionSource gitSource, TextWriter output, string logSource = "cli") { bool staged = false; bool strict = false; @@ -129,6 +129,13 @@ public static int Run(string[] args, IndexManager indexManager, GitTransactionSo (memberHoles, ignoredMemberHoles) = IgnoreFilter.SplitMemberHoles(members.Index, memberHoles, ignores); } + var failingFileHoles = active.Where(a => a.Hole.Confidence >= failAt).Select(a => a.Hole).ToList(); + var failingMemberHoles = memberHoles.Where(h => h.Confidence >= failAt).ToList(); + bool failing = failingFileHoles.Count > 0 || failingMemberHoles.Count > 0; + + LogFindings(repoPath, logSource, entities, minConfidence, failAt, active, memberHoles, members, + suppressed, ignoredMemberHoles, failing); + if (active.Count == 0 && memberHoles.Count == 0) { output.WriteLine(Inv($"No holes at confidence >= {minConfidence:0.00} for {entities.Length} file(s).")); @@ -178,9 +185,6 @@ public static int Run(string[] args, IndexManager indexManager, GitTransactionSo PrintSuppressed(output, suppressed); PrintSuppressed(output, ignoredMemberHoles); - var failingFileHoles = active.Where(a => a.Hole.Confidence >= failAt).Select(a => a.Hole).ToList(); - var failingMemberHoles = memberHoles.Where(h => h.Confidence >= failAt).ToList(); - bool failing = failingFileHoles.Count > 0 || failingMemberHoles.Count > 0; if (failing) { output.WriteLine(Inv($"FAIL: at least one hole at confidence >= {failAt:0.00}.")); @@ -256,5 +260,46 @@ private static void PrintSuppressed(TextWriter output, IReadOnlyList + /// Records what this check reported to its consumer in .unwritten/findings.log + /// (same structured shape as the MCP tool logs, so the log reads uniformly). + /// + private static void LogFindings( + string repoPath, + string source, + IReadOnlyList entities, + double minConfidence, + double failAt, + IReadOnlyList active, + IReadOnlyList memberHoles, + PersistedIndex? members, + IReadOnlyList suppressed, + IReadOnlyList ignoredMemberHoles, + bool failing) + { + object HoleDto(HoleResult h, string? holeLocation = null) => new + { + h.Hole, + HoleLocation = holeLocation, + h.Trigger, + h.Confidence, + h.CoChanges, + h.TotalChanges, + }; + + FindingsLog.Append(repoPath, source, new + { + CheckedFiles = entities, + MinConfidence = minConfidence, + FailAt = failAt, + Holes = active.Select(a => HoleDto(a.Hole)).ToArray(), + MemberHoles = memberHoles.Select(h => HoleDto(h, members!.Index.GetEntityLocation(h.Hole))).ToArray(), + Suppressed = suppressed.Concat(ignoredMemberHoles) + .Select(a => new { a.Hole.Hole, a.Hole.Trigger, a.Reason }) + .ToArray(), + Failing = failing, + }); + } + private static string Inv(FormattableString message) => FormattableString.Invariant(message); } diff --git a/src/Unwritten/HookCommand.cs b/src/Unwritten/HookCommand.cs index e2b2917..2dc7da8 100644 --- a/src/Unwritten/HookCommand.cs +++ b/src/Unwritten/HookCommand.cs @@ -93,7 +93,7 @@ public static int Stop(IndexManager indexManager, GitTransactionSource gitSource string repoPath = indexManager.ResolveRepoRoot( string.IsNullOrWhiteSpace(cwd) ? Directory.GetCurrentDirectory() : cwd); using var buffer = new StringWriter(); - int exitCode = CheckCommand.Run(["--repo", repoPath], indexManager, gitSource, buffer); + int exitCode = CheckCommand.Run(["--repo", repoPath], indexManager, gitSource, buffer, logSource: "stop-hook"); if (exitCode != 1) { return 0; diff --git a/src/Unwritten/UnwrittenTools.cs b/src/Unwritten/UnwrittenTools.cs index 4a7cb91..3cee32e 100644 --- a/src/Unwritten/UnwrittenTools.cs +++ b/src/Unwritten/UnwrittenTools.cs @@ -110,19 +110,22 @@ baseRef is null suppressReason, }; - return Serialize(new + var result = new { - holes = annotated.Select(ToHoleDto), + holes = annotated.Select(ToHoleDto).ToArray(), memberHoles = memberReport is null ? null : memberHoles.Select(h => MemberDto(h, null)) - .Concat(ignoredMemberHoles.Select(a => MemberDto(a.Hole, a.Reason))), + .Concat(ignoredMemberHoles.Select(a => MemberDto(a.Hole, a.Reason))) + .ToArray(), changedMembers = memberReport?.ChangedMembers, checkedFiles, minConfidence = floor, baseRef, notes = notes.Count > 0 ? notes : null, - }); + }; + FindingsLog.Append(repoPath, "mcp", result); + return Serialize(result); } [McpServerTool(Name = "reindex")] diff --git a/tests/Unwritten.Integration.Tests/FindingsLogTests.cs b/tests/Unwritten.Integration.Tests/FindingsLogTests.cs new file mode 100644 index 0000000..d03dafa --- /dev/null +++ b/tests/Unwritten.Integration.Tests/FindingsLogTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using Unwritten.Storage; + +namespace Unwritten.Integration.Tests; + +public class FindingsLogTests : IDisposable +{ + private readonly string _repo = Path.Combine( + Path.GetTempPath(), "unwritten-findingslog", Path.GetRandomFileName()); + + public void Dispose() + { + try + { + Directory.Delete(_repo, recursive: true); + } + catch (DirectoryNotFoundException) + { + } + } + + /// Splits the log into its pretty-printed entries (each starts with "{" at column 0). + private static List ReadEntries(string repoPath) + { + var entries = new List(); + foreach (string line in File.ReadLines(FindingsLog.GetLogPath(repoPath))) + { + if (line == "{") + { + entries.Add(line); + } + else + { + entries[^1] += Environment.NewLine + line; + } + } + + return entries; + } + + [Fact] + public void AppendsOnePrettyPrintedEntryPerCall() + { + FindingsLog.Append(_repo, "cli", new { Holes = new[] { "docs/CHANGELOG.md" }, Failing = true }); + FindingsLog.Append(_repo, "mcp", new { Holes = Array.Empty(), Failing = false }); + + var entries = ReadEntries(_repo); + + Assert.Equal(2, entries.Count); + using var first = JsonDocument.Parse(entries[0]); + Assert.Equal("cli", first.RootElement.GetProperty("source").GetString()); + Assert.Equal("docs/CHANGELOG.md", + first.RootElement.GetProperty("result").GetProperty("holes")[0].GetString()); + Assert.True(first.RootElement.TryGetProperty("timestamp", out _)); + using var second = JsonDocument.Parse(entries[1]); + Assert.Equal("mcp", second.RootElement.GetProperty("source").GetString()); + } + + [Fact] + public void SelfGitignoresTheLogDirectory() + { + FindingsLog.Append(_repo, "cli", new { Failing = false }); + + Assert.Equal("*\n", File.ReadAllText(Path.Combine(_repo, ".unwritten", ".gitignore"))); + } + + [Fact] + public void TrimsOldestEntriesOnceTheCapIsExceeded() + { + // Each entry is ~1 KB, so ~1100 of them overshoot the 1 MB cap. + string padding = new('x', 1000); + for (int i = 0; i < 1100; i++) + { + FindingsLog.Append(_repo, "cli", new { Sequence = i, Padding = padding }); + } + + var entries = ReadEntries(_repo); + + Assert.True(new FileInfo(FindingsLog.GetLogPath(_repo)).Length <= 1024 * 1024); + Assert.NotEmpty(entries); + + // The newest entry survives; the oldest was trimmed away. + using var last = JsonDocument.Parse(entries[^1]); + Assert.Equal(1099, last.RootElement.GetProperty("result").GetProperty("sequence").GetInt32()); + using var first = JsonDocument.Parse(entries[0]); + Assert.True(first.RootElement.GetProperty("result").GetProperty("sequence").GetInt32() > 0); + } + + [Fact] + public void SwallowsIoFailuresInsteadOfThrowing() + { + // Make the .unwritten path unusable by creating it as a FILE. + Directory.CreateDirectory(_repo); + File.WriteAllText(Path.Combine(_repo, ".unwritten"), "not a directory"); + + FindingsLog.Append(_repo, "cli", new { Failing = false }); + } +}