Skip to content

Commit dbe57fa

Browse files
committed
chore(tests): acceptance matrix + provider-transition + secret guard; docs
Tests + docs: - 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 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 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 stated with the superset wording; commit-mode range fields documented. Hardening: - viewer/store.go: 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). - session logging to stderr: the [ocr session] messages went to stdout, which carries the --format json payload; all four sites now use os.Stderr. - redactRemoteURL (nee stripURLUserinfo) also strips query/fragment, so ?access_token=... can never reach the manifest; scp-style remotes still pass through (they fail url.Parse and carry no password). Verified E2E. - scanFingerprint hashes mode\0path\0content (ScanItem.Content is already in memory), matching the diff path's content-sensitive fingerprint. - scan dispatch goroutines now recover from panics, record the item as failed with class "panic", and let Finalize still write the manifest, mirroring the diff-review dispatch. Panic row added to the scan parity table. - 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. - computeRulesHash hashes the resolver's resolved rule layers (new rules.UserLayers accessor) instead of re-reading the top-level rule.json bytes: rule entries that reference files (e.g. team.md) are expanded to content by rules.NewResolver, so editing a referenced file now changes rules_hash even though rule.json's bytes do not. Test locks referenced-file edit -> hash change. - scan executeSubtask drains the async CommentWorkerPool before reading CommentsForPath and persisting review_item_done; without the per-file Await the checkpoint could persist an incomplete comment set while async code_comment workers were still running (the diff path already had this Await). - filterLargeDiffs/filterLargeScans record each dropped oversized file as failed with class skipped_limit (plus a warning), so the manifest's selected denominator includes pre-filtered files: a partially filtered run is partial, an all-filtered run is failed with per-file failures rather than an empty "skipped". Two agent table rows + a scan filter test lock this. - persist.go/history.go: corrected stale close-ownership comments (WriteSessionEnd no longer closes the file; flushAndClose does). Full go test ./... green (2037); -race green on session/agent/scan/ llmloop/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 dbe57fa

18 files changed

Lines changed: 729 additions & 230 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/review_cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ func runReview(args []string) error {
102102
if rt.AppCfg != nil {
103103
lang = rt.AppCfg.Language
104104
}
105-
runMeta := buildRunMeta(rt.Endpoint, lang, Version, cc.RepoDir, opts.rulePath, opts.concurrency,
105+
runMeta := buildRunMeta(rt.Endpoint, lang, Version, cc.RepoDir, cc.Resolver, opts.concurrency,
106106
resolveRange(cc.RepoDir, opts.from, opts.to, opts.commit))
107107

108108
ag := agent.New(agent.Args{

cmd/opencodereview/runmeta.go

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ import (
55
"encoding/hex"
66
"encoding/json"
77
"net/url"
8-
"os"
9-
"path/filepath"
108
"strings"
119

10+
"github.com/open-code-review/open-code-review/internal/config/rules"
1211
"github.com/open-code-review/open-code-review/internal/llm"
1312
"github.com/open-code-review/open-code-review/internal/session"
1413
)
@@ -29,14 +28,14 @@ type configHashInput struct {
2928
// buildRunMeta assembles the manifest metadata the command layer owns:
3029
// tool version, provider, config/rules hashes, repo identity, and (for the
3130
// range/commit paths) the resolved range SHAs.
32-
func buildRunMeta(ep llm.ResolvedEndpoint, language, ocrVersion, repoDir, customRulePath string, concurrency int, rng resolvedRange) session.RunMeta {
31+
func buildRunMeta(ep llm.ResolvedEndpoint, language, ocrVersion, repoDir string, resolver rules.Resolver, concurrency int, rng resolvedRange) session.RunMeta {
3332
remoteURL, headSHA := repoIdentity(repoDir)
3433
return session.RunMeta{
3534
OCRVersion: ocrVersion,
3635
Provider: ep.Protocol,
3736
Concurrency: concurrency,
3837
ConfigHash: computeConfigHash(ep, language),
39-
RulesHash: computeRulesHash(customRulePath, repoDir, ocrVersion),
38+
RulesHash: computeRulesHash(resolver, ocrVersion),
4039
RepoRemoteURL: remoteURL,
4140
RepoHeadSHA: headSHA,
4241
RangeFromSHA: rng.fromSHA,
@@ -76,63 +75,69 @@ func baseURLHost(raw string) string {
7675
return u.Host
7776
}
7877

79-
// computeRulesHash hashes each loaded rule layer as (label + \0 + raw bytes),
80-
// in a stable order. Missing layers contribute nothing. The embedded system
81-
// layer has no file, so it contributes label + tool version (it only changes
82-
// when the binary does).
83-
//
84-
// ponytail: reads the rule files by their known paths rather than plumbing raw
85-
// bytes out of rules.NewResolver — same inputs, no new API surface. If rule
86-
// loading grows more layers, mirror them here.
87-
func computeRulesHash(customRulePath, repoDir, ocrVersion string) string {
78+
// computeRulesHash hashes each loaded rule layer as (label + \0 + canonical
79+
// JSON of the resolved layer), in a stable order. "Resolved" means rule
80+
// entries that reference files (e.g. a rule.json entry pointing at team.md)
81+
// have already been expanded to that file's content by rules.NewResolver, so
82+
// the hash tracks the effective runtime rules — editing a referenced file
83+
// changes the hash even though rule.json's bytes do not. Missing layers
84+
// contribute nothing. The embedded system layer has no file, so it
85+
// contributes label + tool version (it only changes when the binary does).
86+
func computeRulesHash(resolver rules.Resolver, ocrVersion string) string {
8887
h := sha256.New()
89-
add := func(label, path string) {
90-
data, err := os.ReadFile(path)
88+
add := func(label string, layer *rules.ProjectRule) {
89+
if layer == nil {
90+
return
91+
}
92+
data, err := json.Marshal(layer)
9193
if err != nil {
9294
return
9395
}
9496
h.Write([]byte(label))
9597
h.Write([]byte{0})
9698
h.Write(data)
9799
}
98-
if customRulePath != "" {
99-
add("custom", customRulePath)
100-
}
101-
if repoDir != "" {
102-
add("project", filepath.Join(repoDir, ".opencodereview", "rule.json"))
103-
}
104-
if home, err := os.UserHomeDir(); err == nil {
105-
add("global", filepath.Join(home, ".opencodereview", "rule.json"))
100+
if ul, ok := resolver.(interface {
101+
UserLayers() (custom, project, global *rules.ProjectRule)
102+
}); ok {
103+
custom, project, global := ul.UserLayers()
104+
add("custom", custom)
105+
add("project", project)
106+
add("global", global)
106107
}
107108
h.Write([]byte("system\x00" + ocrVersion))
108109
return hex.EncodeToString(h.Sum(nil))
109110
}
110111

111-
// repoIdentity returns the origin remote URL (with userinfo stripped) and the
112-
// HEAD commit SHA. Both tolerate git failure by returning "".
112+
// repoIdentity returns the origin remote URL (with userinfo/query redacted)
113+
// and the HEAD commit SHA. Both tolerate git failure by returning "".
113114
func repoIdentity(repoDir string) (remoteURL, headSHA string) {
114115
if repoDir == "" {
115116
return "", ""
116117
}
117118
if out, err := runGitCmdStdout(repoDir, "remote", "get-url", "origin"); err == nil {
118-
remoteURL = stripURLUserinfo(strings.TrimSpace(string(out)))
119+
remoteURL = redactRemoteURL(strings.TrimSpace(string(out)))
119120
}
120121
if out, err := runGitCmdStdout(repoDir, "rev-parse", "HEAD"); err == nil {
121122
headSHA = strings.TrimSpace(string(out))
122123
}
123124
return remoteURL, headSHA
124125
}
125126

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 {
127+
// redactRemoteURL removes the user[:password]@ prefix and any query/fragment
128+
// from a standard URL so a token embedded in an https remote (userinfo or
129+
// ?access_token=…) never lands in the manifest, matching baseURLHost's
130+
// redaction of the config-hash input. scp-style git remotes (git@host:path)
131+
// fail url.Parse, carry no password, and pass through unchanged.
132+
func redactRemoteURL(raw string) string {
131133
u, err := url.Parse(raw)
132-
if err != nil || u.User == nil {
134+
if err != nil {
133135
return raw
134136
}
135137
u.User = nil
138+
u.RawQuery = ""
139+
u.Fragment = ""
140+
u.RawFragment = ""
136141
return u.String()
137142
}
138143

cmd/opencodereview/runmeta_test.go

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"testing"
99
"time"
1010

11+
"github.com/open-code-review/open-code-review/internal/config/rules"
1112
"github.com/open-code-review/open-code-review/internal/llm"
1213
)
1314

@@ -61,18 +62,20 @@ func TestConfigHashRedaction(t *testing.T) {
6162
}
6263
}
6364

64-
func TestStripURLUserinfo(t *testing.T) {
65+
func TestRedactRemoteURL(t *testing.T) {
6566
tests := []struct {
6667
in, want string
6768
}{
6869
{"https://user:token@github.com/acme/repo.git", "https://github.com/acme/repo.git"},
6970
{"https://github.com/acme/repo.git", "https://github.com/acme/repo.git"},
71+
{"https://github.com/acme/repo.git?access_token=SECRET", "https://github.com/acme/repo.git"},
72+
{"https://user:token@github.com/acme/repo.git?access_token=SECRET#frag", "https://github.com/acme/repo.git"},
7073
{"git@github.com:acme/repo.git", "git@github.com:acme/repo.git"}, // scp-style: no password, unchanged
7174
{"", ""},
7275
}
7376
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)
77+
if got := redactRemoteURL(tt.in); got != tt.want {
78+
t.Errorf("redactRemoteURL(%q) = %q, want %q", tt.in, got, tt.want)
7679
}
7780
}
7881
}
@@ -94,7 +97,7 @@ func gitInitRepo(t *testing.T) string {
9497
}
9598
}
9699
run("init", "-q")
97-
run("remote", "add", "origin", "https://alice:secrettoken@example.com/acme/repo.git")
100+
run("remote", "add", "origin", "https://alice:secrettoken@example.com/acme/repo.git?access_token=querysecret")
98101
if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hi"), 0o644); err != nil {
99102
t.Fatal(err)
100103
}
@@ -106,7 +109,7 @@ func gitInitRepo(t *testing.T) string {
106109
func TestRepoIdentityStripsUserinfo(t *testing.T) {
107110
dir := gitInitRepo(t)
108111
remoteURL, headSHA := repoIdentity(dir)
109-
if strings.Contains(remoteURL, "secrettoken") || strings.Contains(remoteURL, "alice:") {
112+
if strings.Contains(remoteURL, "secrettoken") || strings.Contains(remoteURL, "alice:") || strings.Contains(remoteURL, "querysecret") {
110113
t.Errorf("repo remote URL leaks credentials: %q", remoteURL)
111114
}
112115
if remoteURL != "https://example.com/acme/repo.git" {
@@ -138,22 +141,46 @@ func TestResolveRangeReturnsSHAs(t *testing.T) {
138141
}
139142

140143
func TestComputeRulesHashChangesWithLayer(t *testing.T) {
144+
t.Setenv("HOME", t.TempDir()) // isolate the global ~/.opencodereview layer
141145
dir := t.TempDir()
142-
h1 := computeRulesHash("", dir, "1.0.0")
143-
// Adding a project rule file changes the hash.
146+
newResolver := func() rules.Resolver {
147+
t.Helper()
148+
r, _, err := rules.NewResolver(dir, "")
149+
if err != nil {
150+
t.Fatal(err)
151+
}
152+
return r
153+
}
154+
writeFile := func(path, content string) {
155+
t.Helper()
156+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
157+
t.Fatal(err)
158+
}
159+
}
160+
h1 := computeRulesHash(newResolver(), "1.0.0")
161+
162+
// Adding a project rule layer changes the hash.
144163
rulesDir := filepath.Join(dir, ".opencodereview")
145164
if err := os.MkdirAll(rulesDir, 0o755); err != nil {
146165
t.Fatal(err)
147166
}
148-
if err := os.WriteFile(filepath.Join(rulesDir, "rule.json"), []byte(`{"rules":[]}`), 0o644); err != nil {
149-
t.Fatal(err)
150-
}
151-
h2 := computeRulesHash("", dir, "1.0.0")
167+
writeFile(filepath.Join(rulesDir, "rule.json"), `{"rules":[{"path":"**/*.go","rule":"team.md"}]}`)
168+
writeFile(filepath.Join(dir, "team.md"), "rule text A")
169+
h2 := computeRulesHash(newResolver(), "1.0.0")
152170
if h1 == h2 {
153171
t.Error("rules hash should change when a rule layer appears")
154172
}
173+
174+
// Editing the referenced rule file changes the effective rules, so the
175+
// hash must change even though rule.json's own bytes are unchanged.
176+
writeFile(filepath.Join(dir, "team.md"), "rule text B")
177+
h3 := computeRulesHash(newResolver(), "1.0.0")
178+
if h3 == h2 {
179+
t.Error("rules hash should change when a referenced rule file changes")
180+
}
181+
155182
// Version bump changes the embedded-system contribution.
156-
if computeRulesHash("", dir, "2.0.0") == h2 {
183+
if computeRulesHash(newResolver(), "2.0.0") == h3 {
157184
t.Error("rules hash should change when tool version changes")
158185
}
159186
}

cmd/opencodereview/scan_cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ func runScan(args []string) error {
187187
lang = rt.AppCfg.Language
188188
}
189189
// Scan has no diff range; resolvedRange stays empty.
190-
runMeta := buildRunMeta(rt.Endpoint, lang, Version, cc.RepoDir, opts.rulePath, opts.concurrency, resolvedRange{})
190+
runMeta := buildRunMeta(rt.Endpoint, lang, Version, cc.RepoDir, cc.Resolver, opts.concurrency, resolvedRange{})
191191

192192
ag := scan.NewAgent(scan.Args{
193193
RepoDir: cc.RepoDir,

internal/agent/agent.go

Lines changed: 12 additions & 8 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 {
@@ -858,8 +856,14 @@ func (a *Agent) filterLargeDiffs(diffs []model.Diff) []model.Diff {
858856
for _, d := range diffs {
859857
tokens := llm.CountTokens(d.Diff)
860858
if tokens > limit {
861-
fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s (~%d tokens exceeds 80%% of max_tokens(%d))\n",
862-
d.NewPath, tokens, a.args.Template.MaxTokens)
859+
msg := fmt.Sprintf("diff tokens (~%d) exceed 80%% of max_tokens(%d)", tokens, a.args.Template.MaxTokens)
860+
fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s (%s)\n", d.NewPath, msg)
861+
// Record the drop so the manifest's coverage denominator still
862+
// counts this file (as failed/skipped_limit) instead of silently
863+
// deselecting it — mirrors the prompt-level threshold path.
864+
a.recordWarning("token_threshold_exceeded", d.NewPath, msg)
865+
a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath,
866+
reviewItemFingerprint(a.reviewMode(), d), session.FailureSkippedLimit, msg)
863867
skipped++
864868
continue
865869
}

0 commit comments

Comments
 (0)