Skip to content

Commit 51283dd

Browse files
committed
chore(tests): acceptance matrix + provider-transition + secret guard; docs
Squash of the test/docs slice, the adversarial-review fixes, and the PR-review resolutions (Gemini/CodeAnt comments evaluated on merit). Tests + docs (was 0ca7a40): - acceptance_test.go: end-to-end matrix over the terminal-state scenarios (complete/partial/failed/skipped/cancelled/waive, plus a cancelled mid-run row where selected is a strict superset of the outcome sets); provider-transition resume case now starts from a PARTIAL run and asserts the resume flips it to complete with parent_session_id intact and an unchanged artifact checksum (input identity retained); TestManifestNeverLeaksSecrets is the canonical secret guard. - manifest_test.go: TestManifestSchemaLock pins the literal schema version (1) and the top-level JSON key set (the previous check compared the constant to itself). The standalone Finalize test was folded into the acceptance matrix (failure-class + artifact columns). - emit_run_result_test.go: no-files manifest case folded into the legacy-status regression table. - agent/manifest_test.go: newManifestAgent helper replaces three copies of the Args literal. - docs: run-manifest schema, state->exit-code mapping, --waive flag; files invariant corrected to the superset wording; commit-mode range fields documented. Adversarial-review fixes (was 6afe1bf): - viewer/store.go (HIGH): peekSession read only the last JSONL line for session_end; run_manifest is now the last line, so the session-list fast path lost duration/files/failures. Checks the last two lines; regression test fails on the old code. - manifest.go: cancelled run whose only covered items are waives is partial, not failed; ManifestFiles doc corrected (selected is a superset); Finalize made idempotent (written-once guard). PR-review resolutions: - session logging to stderr (gemini r3585811920): the [ocr session] messages went to stdout, which carries the --format json payload; all four sites (incl. two pre-existing) now use os.Stderr. - remote-URL redaction (codeant r3585831274): redactRemoteURL (nee stripURLUserinfo) also strips query/fragment, so ?access_token=... can no longer reach the manifest; scp-style remotes still pass through (they fail url.Parse and carry no password). Verified E2E. - content-based scan fingerprint (codeant r3585834011): scanFingerprint hashes mode\0path\0content (ScanItem.Content is already in memory), matching the diff path's content-sensitive fingerprint; the path-only ponytail debt is paid. - scan panic recovery (codeant r3585834021): scan dispatch goroutines now recover, record the item as failed with class "panic", and let Finalize still write the manifest — mirrors the diff-review dispatch. Pre-existing gap, but newly load-bearing for the finalize-always contract. Panic row added to the scan parity table. - superset comments (codeant r3585835296): the false equality invariant wording in SetSelected and recordSelectionAndArtifact now matches the manifest contract. - session.ArtifactChecksum: the sort/join/sha256 tail duplicated in the agent and scan recordSelectionAndArtifact now lives in one place so the fingerprint-join convention cannot diverge. Full go test ./... green (2034); -race green on session/agent/scan/ viewer/cmd; make check clean. E2E: failing review persists ... -> session_end -> run_manifest with state=failed and class=provider_error; no-diff review emits valid skipped JSON with the manifest; a remote with userinfo+query credentials is fully redacted.
1 parent ff745d4 commit 51283dd

15 files changed

Lines changed: 558 additions & 183 deletions

File tree

cmd/opencodereview/emit_run_result_test.go

Lines changed: 20 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -325,22 +325,30 @@ func TestEmitRunResult_JSONIncludesManifest(t *testing.T) {
325325
}
326326

327327
// TestLegacyStatusValuesUnchanged locks the legacy `status` field to its
328-
// pre-manifest values across the warning/error paths. manifest.state is the
329-
// new machine contract; status must not drift.
328+
// pre-manifest values across the success/warning/error/no-files paths, and
329+
// asserts every path still carries the manifest. manifest.state is the new
330+
// machine contract; status must not drift.
330331
func TestLegacyStatusValuesUnchanged(t *testing.T) {
331-
man := &session.RunManifest{State: session.StatePartial}
332332
tests := []struct {
333-
name string
334-
warnings []agent.AgentWarning
335-
wantStatus string
333+
name string
334+
filesReviewed int64
335+
warnings []agent.AgentWarning
336+
manifestState string
337+
wantStatus string
338+
wantManifestState string
336339
}{
337-
{"clean success", nil, "success"},
338-
{"subtask errors", []agent.AgentWarning{{Type: "subtask_error", File: "a.go", Message: "boom"}}, "completed_with_errors"},
339-
{"non-error warnings", []agent.AgentWarning{{Type: "token_threshold_exceeded", Message: "big"}}, "completed_with_warnings"},
340+
{"clean success", 2, nil, session.StatePartial, "success", session.StatePartial},
341+
{"subtask errors", 2, []agent.AgentWarning{{Type: "subtask_error", File: "a.go", Message: "boom"}}, session.StatePartial, "completed_with_errors", session.StatePartial},
342+
{"non-error warnings", 2, []agent.AgentWarning{{Type: "token_threshold_exceeded", Message: "big"}}, session.StatePartial, "completed_with_warnings", session.StatePartial},
343+
{"no files skipped", 0, nil, session.StateSkipped, "skipped", session.StateSkipped},
340344
}
341345
for _, tt := range tests {
342346
t.Run(tt.name, func(t *testing.T) {
343-
ag := &mockResultProvider{filesReviewed: 2, warnings: tt.warnings, manifest: man}
347+
ag := &mockResultProvider{
348+
filesReviewed: tt.filesReviewed,
349+
warnings: tt.warnings,
350+
manifest: &session.RunManifest{State: tt.manifestState},
351+
}
344352
got := captureStdout(t, func() {
345353
if err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil); err != nil {
346354
t.Fatalf("err: %v", err)
@@ -353,35 +361,13 @@ func TestLegacyStatusValuesUnchanged(t *testing.T) {
353361
if out.Status != tt.wantStatus {
354362
t.Errorf("status = %q, want %q", out.Status, tt.wantStatus)
355363
}
356-
if out.Manifest == nil || out.Manifest.State != session.StatePartial {
357-
t.Errorf("manifest.state should be present and partial regardless of legacy status")
364+
if out.Manifest == nil || out.Manifest.State != tt.wantManifestState {
365+
t.Errorf("manifest.state should be present (%q) regardless of legacy status, got %+v", tt.wantManifestState, out.Manifest)
358366
}
359367
})
360368
}
361369
}
362370

363-
func TestEmitRunResult_JSONNoFilesIncludesManifest(t *testing.T) {
364-
ag := &mockResultProvider{
365-
filesReviewed: 0,
366-
manifest: &session.RunManifest{State: session.StateSkipped},
367-
}
368-
got := captureStdout(t, func() {
369-
if err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil); err != nil {
370-
t.Fatalf("err: %v", err)
371-
}
372-
})
373-
var out jsonOutput
374-
if err := json.Unmarshal([]byte(got), &out); err != nil {
375-
t.Fatalf("unmarshal: %v", err)
376-
}
377-
if out.Status != "skipped" {
378-
t.Errorf("status = %q, want skipped", out.Status)
379-
}
380-
if out.Manifest == nil || out.Manifest.State != session.StateSkipped {
381-
t.Errorf("no-files path should still carry the skipped manifest, got %+v", out.Manifest)
382-
}
383-
}
384-
385371
func TestEmitRunResult_JSONIncludesSessionID(t *testing.T) {
386372
ag := &mockResultProvider{filesReviewed: 1, sessionID: "session-99"}
387373
got := captureStdout(t, func() {

cmd/opencodereview/runmeta.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,31 +108,35 @@ func computeRulesHash(customRulePath, repoDir, ocrVersion string) string {
108108
return hex.EncodeToString(h.Sum(nil))
109109
}
110110

111-
// repoIdentity returns the origin remote URL (with userinfo stripped) and the
112-
// HEAD commit SHA. Both tolerate git failure by returning "".
111+
// repoIdentity returns the origin remote URL (with userinfo/query redacted)
112+
// and the HEAD commit SHA. Both tolerate git failure by returning "".
113113
func repoIdentity(repoDir string) (remoteURL, headSHA string) {
114114
if repoDir == "" {
115115
return "", ""
116116
}
117117
if out, err := runGitCmdStdout(repoDir, "remote", "get-url", "origin"); err == nil {
118-
remoteURL = stripURLUserinfo(strings.TrimSpace(string(out)))
118+
remoteURL = redactRemoteURL(strings.TrimSpace(string(out)))
119119
}
120120
if out, err := runGitCmdStdout(repoDir, "rev-parse", "HEAD"); err == nil {
121121
headSHA = strings.TrimSpace(string(out))
122122
}
123123
return remoteURL, headSHA
124124
}
125125

126-
// stripURLUserinfo removes a user[:password]@ prefix from a standard URL so a
127-
// token embedded in an https remote never lands in the manifest. scp-style
128-
// git remotes (git@host:path) carry no password and parse as opaque, so they
129-
// pass through unchanged.
130-
func stripURLUserinfo(raw string) string {
126+
// redactRemoteURL removes the user[:password]@ prefix and any query/fragment
127+
// from a standard URL so a token embedded in an https remote (userinfo or
128+
// ?access_token=…) never lands in the manifest, matching baseURLHost's
129+
// redaction of the config-hash input. scp-style git remotes (git@host:path)
130+
// fail url.Parse, carry no password, and pass through unchanged.
131+
func redactRemoteURL(raw string) string {
131132
u, err := url.Parse(raw)
132-
if err != nil || u.User == nil {
133+
if err != nil {
133134
return raw
134135
}
135136
u.User = nil
137+
u.RawQuery = ""
138+
u.Fragment = ""
139+
u.RawFragment = ""
136140
return u.String()
137141
}
138142

cmd/opencodereview/runmeta_test.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,20 @@ func TestConfigHashRedaction(t *testing.T) {
6161
}
6262
}
6363

64-
func TestStripURLUserinfo(t *testing.T) {
64+
func TestRedactRemoteURL(t *testing.T) {
6565
tests := []struct {
6666
in, want string
6767
}{
6868
{"https://user:token@github.com/acme/repo.git", "https://github.com/acme/repo.git"},
6969
{"https://github.com/acme/repo.git", "https://github.com/acme/repo.git"},
70+
{"https://github.com/acme/repo.git?access_token=SECRET", "https://github.com/acme/repo.git"},
71+
{"https://user:token@github.com/acme/repo.git?access_token=SECRET#frag", "https://github.com/acme/repo.git"},
7072
{"git@github.com:acme/repo.git", "git@github.com:acme/repo.git"}, // scp-style: no password, unchanged
7173
{"", ""},
7274
}
7375
for _, tt := range tests {
74-
if got := stripURLUserinfo(tt.in); got != tt.want {
75-
t.Errorf("stripURLUserinfo(%q) = %q, want %q", tt.in, got, tt.want)
76+
if got := redactRemoteURL(tt.in); got != tt.want {
77+
t.Errorf("redactRemoteURL(%q) = %q, want %q", tt.in, got, tt.want)
7678
}
7779
}
7880
}
@@ -94,7 +96,7 @@ func gitInitRepo(t *testing.T) string {
9496
}
9597
}
9698
run("init", "-q")
97-
run("remote", "add", "origin", "https://alice:secrettoken@example.com/acme/repo.git")
99+
run("remote", "add", "origin", "https://alice:secrettoken@example.com/acme/repo.git?access_token=querysecret")
98100
if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hi"), 0o644); err != nil {
99101
t.Fatal(err)
100102
}
@@ -106,7 +108,7 @@ func gitInitRepo(t *testing.T) string {
106108
func TestRepoIdentityStripsUserinfo(t *testing.T) {
107109
dir := gitInitRepo(t)
108110
remoteURL, headSHA := repoIdentity(dir)
109-
if strings.Contains(remoteURL, "secrettoken") || strings.Contains(remoteURL, "alice:") {
111+
if strings.Contains(remoteURL, "secrettoken") || strings.Contains(remoteURL, "alice:") || strings.Contains(remoteURL, "querysecret") {
110112
t.Errorf("repo remote URL leaks credentials: %q", remoteURL)
111113
}
112114
if remoteURL != "https://example.com/acme/repo.git" {

internal/agent/agent.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"errors"
88
"fmt"
99
"runtime/debug"
10-
"sort"
1110
"strings"
1211
"sync"
1312
"sync/atomic"
@@ -386,8 +385,9 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
386385
return nil, fmt.Errorf("all diffs filtered out by token size")
387386
}
388387
// Establish the coverage denominator and input-identity checksum before
389-
// resume/dispatch so the manifest invariant selected == completed + reused
390-
// + failed + waived holds.
388+
// resume/dispatch so every dispatched outcome lands in a bucket selected
389+
// already covers (selected is a superset of completed ∪ reused ∪ failed
390+
// ∪ waived; equality only for fully-covered runs).
391391
a.recordSelectionAndArtifact()
392392
toDispatch := a.applyResume(a.diffs)
393393

@@ -567,9 +567,7 @@ func (a *Agent) recordSelectionAndArtifact() {
567567
fps = append(fps, reviewItemFingerprint(mode, d))
568568
}
569569
a.session.SetSelected(selected)
570-
sort.Strings(fps)
571-
sum := sha256.Sum256([]byte(strings.Join(fps, "\x00")))
572-
a.session.SetArtifactChecksum(fmt.Sprintf("%x", sum))
570+
a.session.SetArtifactChecksum(session.ArtifactChecksum(fps))
573571
}
574572

575573
func resumedFromSession(resume *session.ResumeState) string {

internal/agent/manifest_test.go

Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,30 @@ func classCounts(m *session.RunManifest) map[string]int {
5858
return out
5959
}
6060

61+
// newManifestAgent builds an Agent wired to the stub routeClient with the
62+
// template/tool boilerplate shared by every test in this file. mutate (may be
63+
// nil) tweaks the Args before construction.
64+
func newManifestAgent(sess *session.SessionHistory, mainContent string, maxTokens int, mutate func(*Args)) *Agent {
65+
args := Args{
66+
LLMClient: routeClient{},
67+
Model: "test",
68+
Session: sess,
69+
Tools: tool.NewRegistry(),
70+
Template: template.Template{
71+
MaxTokens: maxTokens,
72+
MaxToolRequestTimes: 5,
73+
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: mainContent}}},
74+
},
75+
MainToolDefs: []llm.ToolDef{{Type: "function", Function: llm.FunctionDef{Name: "task_done", Description: "done"}}},
76+
}
77+
if mutate != nil {
78+
mutate(&args)
79+
}
80+
a := New(args)
81+
a.currentDate = "2026-07-15 08:00"
82+
return a
83+
}
84+
6185
func TestRunManifest_Scenarios(t *testing.T) {
6286
bigTpl := strings.Repeat("word ", 120) + " {{diff}}"
6387

@@ -137,19 +161,7 @@ func TestRunManifest_Scenarios(t *testing.T) {
137161
for _, tt := range tests {
138162
t.Run(tt.name, func(t *testing.T) {
139163
sess := session.New(t.TempDir(), "main", "test", session.SessionOptions{ReviewMode: session.ReviewModeRange})
140-
a := New(Args{
141-
LLMClient: routeClient{},
142-
Model: "test",
143-
Session: sess,
144-
Tools: tool.NewRegistry(),
145-
Template: template.Template{
146-
MaxTokens: tt.maxTokens,
147-
MaxToolRequestTimes: 5,
148-
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: tt.mainContent}}},
149-
},
150-
MainToolDefs: []llm.ToolDef{{Type: "function", Function: llm.FunctionDef{Name: "task_done", Description: "done"}}},
151-
})
152-
a.currentDate = "2026-07-15 08:00"
164+
a := newManifestAgent(sess, tt.mainContent, tt.maxTokens, nil)
153165
a.diffs = tt.diffs
154166

155167
_, _ = a.dispatchSubtasks(context.Background())
@@ -189,22 +201,11 @@ func TestRunManifest_Scenarios(t *testing.T) {
189201
func TestWaiveCoverage(t *testing.T) {
190202
build := func(waive []string) *session.RunManifest {
191203
sess := session.New(t.TempDir(), "main", "test", session.SessionOptions{ReviewMode: session.ReviewModeRange})
192-
a := New(Args{
193-
LLMClient: routeClient{},
194-
Model: "test",
195-
Session: sess,
196-
Tools: tool.NewRegistry(),
197-
Template: template.Template{
198-
MaxTokens: 100000,
199-
MaxToolRequestTimes: 5,
200-
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "{{diff}}"}}},
201-
},
202-
MainToolDefs: []llm.ToolDef{{Type: "function", Function: llm.FunctionDef{Name: "task_done"}}},
204+
a := newManifestAgent(sess, "{{diff}}", 100000, func(args *Args) {
203205
// Resume must be non-nil for applyResume (and thus waive) to run.
204-
Resume: &session.ResumeState{SessionID: "prev", Items: map[string]session.ResumeItem{}},
205-
WaivePaths: waive,
206+
args.Resume = &session.ResumeState{SessionID: "prev", Items: map[string]session.ResumeItem{}}
207+
args.WaivePaths = waive
206208
})
207-
a.currentDate = "d"
208209
a.diffs = []model.Diff{
209210
{NewPath: "ok.go", OldPath: "ok.go", Diff: "+ok", Insertions: 1},
210211
{NewPath: "flaky.go", OldPath: "flaky.go", Diff: "+DO_FAIL", Insertions: 1},
@@ -242,19 +243,7 @@ func TestWaiveCoverage(t *testing.T) {
242243
func TestArtifactChecksumStableAndOrderIndependent(t *testing.T) {
243244
mk := func(order []model.Diff) string {
244245
sess := session.New(t.TempDir(), "main", "test", session.SessionOptions{ReviewMode: session.ReviewModeRange})
245-
a := New(Args{
246-
LLMClient: routeClient{},
247-
Model: "test",
248-
Session: sess,
249-
Tools: tool.NewRegistry(),
250-
Template: template.Template{
251-
MaxTokens: 100000,
252-
MaxToolRequestTimes: 5,
253-
MainTask: template.LlmConversation{Messages: []template.ChatMessage{{Role: "user", Content: "{{diff}}"}}},
254-
},
255-
MainToolDefs: []llm.ToolDef{{Type: "function", Function: llm.FunctionDef{Name: "task_done"}}},
256-
})
257-
a.currentDate = "d"
246+
a := newManifestAgent(sess, "{{diff}}", 100000, nil)
258247
a.diffs = order
259248
a.recordSelectionAndArtifact()
260249
sess.Finalize()

internal/scan/agent.go

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9-
"sort"
9+
"runtime/debug"
1010
"strings"
1111
"sync"
1212
"sync/atomic"
@@ -459,13 +459,11 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
459459
}
460460

461461
// scanFingerprint derives a stable per-item fingerprint for the manifest.
462-
//
463-
// ponytail: content-hash if scan grows resume. Scan has no diff and no resume
464-
// today, so a path-based fingerprint is enough to identify the item and to
465-
// compute the artifact checksum; upgrade to hashing it.Content when resume
466-
// lands on the scan path.
467-
func scanFingerprint(path string) string {
468-
sum := sha256.Sum256([]byte(session.ReviewModeFullScan + "\x00" + path))
462+
// Content is hashed alongside the path (the item already carries the full
463+
// file in memory), so the artifact checksum changes whenever the scanned
464+
// inputs do — same guarantee the diff path gets from hashing d.Diff.
465+
func scanFingerprint(it model.ScanItem) string {
466+
sum := sha256.Sum256([]byte(session.ReviewModeFullScan + "\x00" + it.Path + "\x00" + it.Content))
469467
return fmt.Sprintf("%x", sum)
470468
}
471469

@@ -477,12 +475,10 @@ func (a *Agent) recordSelectionAndArtifact() {
477475
fps := make([]string, 0, len(a.items))
478476
for i := range a.items {
479477
selected = append(selected, a.items[i].Path)
480-
fps = append(fps, scanFingerprint(a.items[i].Path))
478+
fps = append(fps, scanFingerprint(a.items[i]))
481479
}
482480
a.session.SetSelected(selected)
483-
sort.Strings(fps)
484-
sum := sha256.Sum256([]byte(strings.Join(fps, "\x00")))
485-
a.session.SetArtifactChecksum(fmt.Sprintf("%x", sum))
481+
a.session.SetArtifactChecksum(session.ArtifactChecksum(fps))
486482
}
487483

488484
// resolveBatchStrategy reads the strategy from the scan template, defaulting
@@ -544,6 +540,21 @@ func (a *Agent) dispatchBatch(ctx context.Context, batchIdx int, batch []model.S
544540
go func(it model.ScanItem) {
545541
defer wg.Done()
546542
defer func() { <-sem }()
543+
// A panic while scanning one file must be isolated exactly like an
544+
// error return: counted in subtaskFailed and recorded as a failed
545+
// item with class "panic", so other files still complete and the
546+
// manifest is still finalized. Mirrors the diff-review dispatch.
547+
defer func() {
548+
if r := recover(); r != nil {
549+
atomic.AddInt64(&a.subtaskFailed, 1)
550+
a.session.RecordReviewItemFailed(it.Path, it.Path, it.Path, scanFingerprint(it), session.FailurePanic, fmt.Sprintf("panic: %v", r))
551+
fmt.Fprintf(stdout.Writer(), "[ocr] Scan subtask panic for %s (batch #%d): %v\n%s\n", it.Path, batchIdx, r, debug.Stack())
552+
telemetry.ErrorEvent(ctx, "scan.subtask.panic", fmt.Errorf("panic: %v", r),
553+
telemetry.AnyToAttr("file.path", it.Path),
554+
telemetry.AnyToAttr("batch.index", batchIdx))
555+
a.recordWarning("scan_subtask_error", it.Path, fmt.Sprintf("panic: %v", r))
556+
}
557+
}()
547558

548559
var fileCtx context.Context
549560
var cancel context.CancelFunc
@@ -597,7 +608,7 @@ func (a *Agent) executeSubtask(ctx context.Context, it model.ScanItem) error {
597608

598609
messages := a.renderMessages(it, rule, planGuidance)
599610

600-
fingerprint := scanFingerprint(it.Path)
611+
fingerprint := scanFingerprint(it)
601612

602613
tokenCount := llmloop.CountMessagesTokens(messages)
603614
maxAllowed := a.args.Template.MaxTokens

0 commit comments

Comments
 (0)