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 }