Claude (review-harness setup) a75e14716b Phase E — append-only memory + diff subcommand (PROMPT.md complete)
Closes the harness's feature set per PROMPT.md modes 2 (Diff Review)
and Phase 5 (Memory). Rules subcommand still pending (it needs
operator-authored .review-rules.md content first; documented as
Phase E follow-up).

internal/memory/ — append-only writer:
- AppendKnownRisks: one JSONL line per confirmed finding per run.
  O_APPEND only; never O_TRUNC. Empty findings list is a no-op
  (doesn't even create the file — keeps clean runs from polluting
  .memory/).
- AppendRunHistory: one JSONL line per run. Run summary stats +
  receipts hash for cross-link.
- WriteProjectProfile: the ONLY non-versioned memory file; snapshot
  semantics, overwrites are explicit + documented.
- 4 unit tests including TestAppendKnownRisks_NeverTruncates which
  is the audit's "no silent overwrite" gate — write twice, assert
  both writes' content survives.

Pipeline phase 5 wires it. Confirmed findings only — suspected
findings might still be wrong, keeping .memory/ authoritative.
Disabled if review-profile.memory.enabled = false.

internal/git/git.go — ChangedFiles helper:
- Probes unstaged + staged + branch diff against main/master.
- Dedup'd, stable order. Empty result on clean tree.
- Graceful failure: returns error if git binary missing or target
  isn't a git repo.

cli/repo.go — Diff subcommand:
- `review-harness diff <path>` runs the same pipeline as scrum but
  scoped to changed files only. Pipeline.Inputs gains DiffOnlyFiles
  filter applied post-Walk.
- Empty diff (clean tree, no commits ahead of base) → exit 0 with
  message; doesn't generate empty reports.
- LLM toggleable via --enable-llm same as scrum.

scanner/walk.go: added .memory to SkipDirs (universal — harness's
own audit trail, scanning it surfaces planted-secret evidence as
new findings — same class as B5 self-skip).

.gitignore tightened: /.memory/ → **/.memory/ to keep test-fixture
.memory dirs from leaking into version control (same fix as
reports/latest pattern).

Verified end-to-end:
- 4 memory unit tests PASS
- Append-only proven: insecure-repo run 1 → 16 known-risks lines;
  run 2 → 44 lines (16 + 28 from new run); run-history grew 1 → 2.
- Diff subcommand against this repo (5 uncommitted Phase E files
  staged) → exit 0, all reports produced, scoped to those 5 files
  only (0 findings on the diff-scoped scan vs 129 on full repo —
  changed files don't contain analyzer-flaggable patterns).

Phase A through E shipped today. Rules subcommand + tests for
internal/{config,scanner,git,llm,reporters,pipeline} remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 02:19:12 -05:00

148 lines
4.4 KiB
Go

package memory
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"local-review-harness/internal/analyzers"
)
// === Gate E1 — append-only contract ===
//
// PROMPT.md hard rule: "Memory should be append-only by default.
// Never silently overwrite prior memory." Every Writer.Append* must
// open with O_APPEND, never O_TRUNC. Truncation is the failure mode
// this package exists to prevent — operators relying on .memory/ to
// see drift across runs would silently lose history.
//
// This test does the receipt-honesty equivalent: write once, write
// again with different content, assert the FIRST write's content is
// still visible in the file.
func TestAppendKnownRisks_NeverTruncates(t *testing.T) {
repo := t.TempDir()
w, err := NewWriter(repo)
if err != nil {
t.Fatal(err)
}
// Run 1: write 2 findings
run1 := []analyzers.Finding{
{ID: "abc111", Title: "first run finding 1", File: "a.go", Severity: analyzers.SeverityHigh},
{ID: "abc222", Title: "first run finding 2", File: "b.go", Severity: analyzers.SeverityMedium},
}
if err := w.AppendKnownRisks("run-1", run1); err != nil {
t.Fatal(err)
}
// Run 2: write 1 different finding
run2 := []analyzers.Finding{
{ID: "def333", Title: "second run finding", File: "c.go", Severity: analyzers.SeverityCritical},
}
if err := w.AppendKnownRisks("run-2", run2); err != nil {
t.Fatal(err)
}
// Read back: file should contain ALL 3 entries (2+1), not just run 2's.
entries := readAll(t, filepath.Join(repo, ".memory", "known-risks.jsonl"))
if len(entries) != 3 {
t.Fatalf("expected 3 entries (2 from run 1 + 1 from run 2); got %d", len(entries))
}
if !strings.Contains(entries[0], "abc111") || !strings.Contains(entries[0], "run-1") {
t.Errorf("first entry should be run 1's first finding; got %q", entries[0])
}
if !strings.Contains(entries[2], "def333") || !strings.Contains(entries[2], "run-2") {
t.Errorf("last entry should be run 2's finding; got %q", entries[2])
}
}
func TestAppendRunHistory_NeverTruncates(t *testing.T) {
repo := t.TempDir()
w, _ := NewWriter(repo)
for i, r := range []RunHistoryEntry{
{RunID: "run-A", RepoPath: repo, TotalFindings: 5},
{RunID: "run-B", RepoPath: repo, TotalFindings: 8},
{RunID: "run-C", RepoPath: repo, TotalFindings: 3},
} {
if err := w.AppendRunHistory(r); err != nil {
t.Fatalf("append %d: %v", i, err)
}
}
entries := readAll(t, filepath.Join(repo, ".memory", "run-history.jsonl"))
if len(entries) != 3 {
t.Fatalf("expected 3 history entries; got %d", len(entries))
}
for i, expected := range []string{"run-A", "run-B", "run-C"} {
var entry RunHistoryEntry
if err := json.Unmarshal([]byte(entries[i]), &entry); err != nil {
t.Fatalf("entry %d: %v", i, err)
}
if entry.RunID != expected {
t.Errorf("entry %d: RunID = %q, want %q", i, entry.RunID, expected)
}
}
}
func TestAppendKnownRisks_EmptyFindingsIsNoop(t *testing.T) {
// Calling Append with zero findings shouldn't even create the file —
// avoids polluting .memory/ with empty files on clean runs.
repo := t.TempDir()
w, _ := NewWriter(repo)
if err := w.AppendKnownRisks("run-empty", nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(repo, ".memory", "known-risks.jsonl")); !os.IsNotExist(err) {
t.Errorf("empty append should not create the file; stat err = %v", err)
}
}
func TestWriteProjectProfile_OverwriteIsAllowed(t *testing.T) {
// project-profile.json is the ONLY memory file allowed to overwrite —
// it's a snapshot, not a log. Verify the overwrite semantic works.
repo := t.TempDir()
w, _ := NewWriter(repo)
if err := w.WriteProjectProfile(ProjectProfile{
RepoPath: repo, FileCount: 100,
}); err != nil {
t.Fatal(err)
}
if err := w.WriteProjectProfile(ProjectProfile{
RepoPath: repo, FileCount: 200,
}); err != nil {
t.Fatal(err)
}
bs, err := os.ReadFile(filepath.Join(repo, ".memory", "project-profile.json"))
if err != nil {
t.Fatal(err)
}
var p ProjectProfile
if err := json.Unmarshal(bs, &p); err != nil {
t.Fatal(err)
}
if p.FileCount != 200 {
t.Errorf("project-profile should reflect last write (200); got %d", p.FileCount)
}
}
// readAll reads a JSONL file; returns one string per line.
func readAll(t *testing.T, path string) []string {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("open %s: %v", path, err)
}
defer f.Close()
var out []string
s := bufio.NewScanner(f)
for s.Scan() {
out = append(out, s.Text())
}
return out
}