Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/Unwritten.Storage/FindingsLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Unwritten.Storage;

/// <summary>
/// Append-only log of every findings result the tool hands to a consumer
/// (MCP model, CLI, Stop hook), written to <c>.unwritten/findings.log</c> 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 <see cref="MaxBytes"/> the oldest entries are trimmed away.
/// </summary>
public static class FindingsLog
{
/// <summary>Cap on the log file size; oldest entries are trimmed once exceeded.</summary>
private const long MaxBytes = 1024 * 1024;

/// <summary>Trim down to this size (newest entries kept) so trims stay infrequent.</summary>
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");

/// <summary>
/// Appends one entry recording the findings returned to a consumer.
/// <paramref name="source"/> names the surface ("mcp", "cli", "stop-hook").
/// </summary>
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<int>();
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);
}
}
3 changes: 3 additions & 0 deletions src/Unwritten.Storage/IndexStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
53 changes: 49 additions & 4 deletions src/Unwritten/CheckCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)."));
Expand Down Expand Up @@ -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}."));
Expand Down Expand Up @@ -256,5 +260,46 @@ private static void PrintSuppressed(TextWriter output, IReadOnlyList<AnnotatedHo
}
}

/// <summary>
/// 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).
/// </summary>
private static void LogFindings(
string repoPath,
string source,
IReadOnlyList<string> entities,
double minConfidence,
double failAt,
IReadOnlyList<AnnotatedHole> active,
IReadOnlyList<HoleResult> memberHoles,
PersistedIndex? members,
IReadOnlyList<AnnotatedHole> suppressed,
IReadOnlyList<AnnotatedHole> 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);
}
2 changes: 1 addition & 1 deletion src/Unwritten/HookCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 7 additions & 4 deletions src/Unwritten/UnwrittenTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
98 changes: 98 additions & 0 deletions tests/Unwritten.Integration.Tests/FindingsLogTests.cs
Original file line number Diff line number Diff line change
@@ -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)
{
}
}

/// <summary>Splits the log into its pretty-printed entries (each starts with "{" at column 0).</summary>
private static List<string> ReadEntries(string repoPath)
{
var entries = new List<string>();
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<string>(), 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 });
}
}
Loading