From d3c2e2e7c89e6456ec3548da42ea995272ca2d30 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 10 Aug 2026 09:55:54 +0200 Subject: [PATCH] feat(findings): add the .claudedo findings store --- src/ClaudeDo.Worker/Findings/FindingsStore.cs | 116 ++++++++++++++++ .../Findings/Interfaces/IFindingsStore.cs | 22 +++ .../Findings/FindingsStoreTests.cs | 127 ++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 src/ClaudeDo.Worker/Findings/FindingsStore.cs create mode 100644 src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreTests.cs diff --git a/src/ClaudeDo.Worker/Findings/FindingsStore.cs b/src/ClaudeDo.Worker/Findings/FindingsStore.cs new file mode 100644 index 00000000..7354777c --- /dev/null +++ b/src/ClaudeDo.Worker/Findings/FindingsStore.cs @@ -0,0 +1,116 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace ClaudeDo.Worker.Findings; + +/// +/// Owns <working-dir>/.claudedo/ — one markdown file per finding plus a rebuilt INDEX.md. +/// Pure filesystem: the caller supplies the head commit and source task id. +/// INDEX.md is always regenerated from the files on disk, never patched, so findings the user +/// deleted or renamed by hand disappear from the index on the next write. +/// +public sealed class FindingsStore : IFindingsStore +{ + /// INDEX.md is read by every run; past this many entries it stops paying for itself. + public const int WarnThreshold = 80; + + private static readonly Regex SlugPattern = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled); + private readonly SemaphoreSlim _indexLock = new(1, 1); + + public static string StoreDir(string workingDir) => Path.Combine(workingDir, ".claudedo"); + public static string TrapsDir(string workingDir) => Path.Combine(StoreDir(workingDir), "traps"); + public static string IndexPath(string workingDir) => Path.Combine(StoreDir(workingDir), "INDEX.md"); + + public async Task SaveAsync(string workingDir, FindingInput input, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(input.Slug) || input.Slug.Length > 60 || !SlugPattern.IsMatch(input.Slug)) + throw new ArgumentException( + $"Slug '{input.Slug}' is invalid. Use lowercase kebab-case, max 60 chars (e.g. 'conpty-arg-quoting').", + nameof(input)); + if (string.IsNullOrWhiteSpace(input.Title)) + throw new ArgumentException("Title must not be empty.", nameof(input)); + if (string.IsNullOrWhiteSpace(input.Body)) + throw new ArgumentException("Body must not be empty.", nameof(input)); + + var trapsDir = TrapsDir(workingDir); + Directory.CreateDirectory(trapsDir); + + var path = Path.Combine(trapsDir, input.Slug + ".md"); + var created = !File.Exists(path); + + await File.WriteAllTextAsync(path, Render(input), ct); + + int total; + await _indexLock.WaitAsync(ct); + try + { + total = await RebuildIndexAsync(workingDir, ct); + } + finally + { + _indexLock.Release(); + } + + return new SaveFindingOutcome(input.Slug, path, created, total, total >= WarnThreshold); + } + + private static string Render(FindingInput input) + { + var sb = new StringBuilder(); + sb.Append("---\n"); + sb.Append($"slug: {input.Slug}\n"); + sb.Append($"scope: {input.Scope}\n"); + sb.Append($"source-task: {input.SourceTaskId}\n"); + sb.Append($"verified-against: {input.VerifiedAgainst}\n"); + sb.Append("---\n\n"); + sb.Append($"# {input.Title.Trim()}\n\n"); + sb.Append(input.Body.Trim()); + sb.Append('\n'); + return sb.ToString(); + } + + private static async Task RebuildIndexAsync(string workingDir, CancellationToken ct) + { + var trapsDir = TrapsDir(workingDir); + var slugs = Directory.Exists(trapsDir) + ? Directory.EnumerateFiles(trapsDir, "*.md") + .Select(Path.GetFileNameWithoutExtension) + .Where(s => !string.IsNullOrEmpty(s)) + .Select(s => s!) + .OrderBy(s => s, StringComparer.Ordinal) + .ToList() + : new List(); + + var sb = new StringBuilder(); + sb.Append("# Findings\n\n"); + sb.Append("Traps and invariants for this project. Read this index first, then open only the\n"); + sb.Append("findings that touch your task. Generated by ClaudeDo — edits to entries belong in\n"); + sb.Append("`traps/`, this file is rewritten on every save.\n\n"); + + if (slugs.Count == 0) + { + sb.Append("_No findings yet._\n"); + } + else + { + foreach (var slug in slugs) + { + var title = await ReadTitleAsync(Path.Combine(trapsDir, slug + ".md"), slug, ct); + sb.Append($"- [{slug}](traps/{slug}.md) — {title}\n"); + } + } + + await File.WriteAllTextAsync(IndexPath(workingDir), sb.ToString(), ct); + return slugs.Count; + } + + private static async Task ReadTitleAsync(string path, string fallback, CancellationToken ct) + { + foreach (var line in await File.ReadAllLinesAsync(path, ct)) + { + if (line.StartsWith("# ", StringComparison.Ordinal)) + return line[2..].Trim(); + } + return fallback; + } +} diff --git a/src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs b/src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs new file mode 100644 index 00000000..eb015b52 --- /dev/null +++ b/src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs @@ -0,0 +1,22 @@ +namespace ClaudeDo.Worker.Findings; + +/// One durable finding, as handed in by a caller. Git and DB lookups happen above this layer. +public sealed record FindingInput( + string Slug, + string Title, + string Body, + string Scope, + string SourceTaskId, + string VerifiedAgainst); + +public sealed record SaveFindingOutcome( + string Slug, + string Path, + bool Created, + int TotalFindings, + bool NearCapacity); + +public interface IFindingsStore +{ + Task SaveAsync(string workingDir, FindingInput input, CancellationToken ct); +} diff --git a/tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreTests.cs b/tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreTests.cs new file mode 100644 index 00000000..843aa492 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreTests.cs @@ -0,0 +1,127 @@ +using ClaudeDo.Worker.Findings; + +namespace ClaudeDo.Worker.Tests.Findings; + +public sealed class FindingsStoreTests : IDisposable +{ + private readonly string _root; + + public FindingsStoreTests() + { + _root = Path.Combine(Path.GetTempPath(), "cdo-findings-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } + } + + private static FindingInput Input(string slug, string title = "Something is not what it looks like") + => new(slug, title, "Body line one.\n\nBody line two.", "src/ClaudeDo.Worker", "task-1", "abc1234"); + + [Fact] + public async Task SaveAsync_WritesFindingFileWithFrontmatter() + { + var store = new FindingsStore(); + + var outcome = await store.SaveAsync(_root, Input("conpty-arg-quoting"), CancellationToken.None); + + Assert.True(outcome.Created); + Assert.Equal("conpty-arg-quoting", outcome.Slug); + + var text = await File.ReadAllTextAsync(Path.Combine(_root, ".claudedo", "traps", "conpty-arg-quoting.md")); + Assert.StartsWith("---\n", text.Replace("\r\n", "\n")); + Assert.Contains("slug: conpty-arg-quoting", text); + Assert.Contains("scope: src/ClaudeDo.Worker", text); + Assert.Contains("source-task: task-1", text); + Assert.Contains("verified-against: abc1234", text); + Assert.Contains("# Something is not what it looks like", text); + Assert.Contains("Body line one.", text); + } + + [Fact] + public async Task SaveAsync_SameSlugOverwritesInsteadOfCreatingASecondFile() + { + var store = new FindingsStore(); + await store.SaveAsync(_root, Input("dup", "First title"), CancellationToken.None); + + var outcome = await store.SaveAsync(_root, Input("dup", "Second title"), CancellationToken.None); + + Assert.False(outcome.Created); + Assert.Equal(1, outcome.TotalFindings); + var files = Directory.GetFiles(Path.Combine(_root, ".claudedo", "traps")); + Assert.Single(files); + var text = await File.ReadAllTextAsync(files[0]); + Assert.Contains("# Second title", text); + Assert.DoesNotContain("# First title", text); + } + + [Fact] + public async Task SaveAsync_RebuildsIndexFromDiskSortedBySlug() + { + var store = new FindingsStore(); + await store.SaveAsync(_root, Input("zulu", "Zulu trap"), CancellationToken.None); + await store.SaveAsync(_root, Input("alpha", "Alpha trap"), CancellationToken.None); + + var index = await File.ReadAllTextAsync(Path.Combine(_root, ".claudedo", "INDEX.md")); + var lines = index.Replace("\r\n", "\n").Split('\n'); + + var alphaAt = Array.FindIndex(lines, l => l.Contains("alpha")); + var zuluAt = Array.FindIndex(lines, l => l.Contains("zulu")); + Assert.True(alphaAt >= 0 && zuluAt >= 0); + Assert.True(alphaAt < zuluAt); + Assert.Contains("- [alpha](traps/alpha.md) — Alpha trap", index); + } + + [Fact] + public async Task SaveAsync_IndexDropsFindingsDeletedOnDisk() + { + var store = new FindingsStore(); + await store.SaveAsync(_root, Input("gone", "Gone trap"), CancellationToken.None); + await store.SaveAsync(_root, Input("stays", "Stays trap"), CancellationToken.None); + + File.Delete(Path.Combine(_root, ".claudedo", "traps", "gone.md")); + await store.SaveAsync(_root, Input("stays", "Stays trap"), CancellationToken.None); + + var index = await File.ReadAllTextAsync(Path.Combine(_root, ".claudedo", "INDEX.md")); + Assert.DoesNotContain("gone", index); + Assert.Contains("stays", index); + } + + [Theory] + [InlineData("../escape")] + [InlineData("has space")] + [InlineData("UPPER")] + [InlineData("trailing-")] + [InlineData("")] + public async Task SaveAsync_RejectsInvalidSlug(string slug) + { + var store = new FindingsStore(); + + await Assert.ThrowsAsync( + () => store.SaveAsync(_root, Input(slug), CancellationToken.None)); + } + + [Fact] + public async Task SaveAsync_FlagsNearCapacityAtWarnThreshold() + { + var store = new FindingsStore(); + SaveFindingOutcome outcome = null!; + for (var i = 0; i < FindingsStore.WarnThreshold; i++) + outcome = await store.SaveAsync(_root, Input($"trap-{i:000}"), CancellationToken.None); + + Assert.Equal(FindingsStore.WarnThreshold, outcome.TotalFindings); + Assert.True(outcome.NearCapacity); + } + + [Fact] + public async Task SaveAsync_DoesNotFlagNearCapacityBelowThreshold() + { + var store = new FindingsStore(); + + var outcome = await store.SaveAsync(_root, Input("only-one"), CancellationToken.None); + + Assert.False(outcome.NearCapacity); + } +}