From adb6126c9efe16cbc9d4c3df4696bdb66c18fa22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 18:57:25 +0300 Subject: [PATCH 1/8] Add review-quality eval harness, diagnostic functionality, specific exception handling, findings for data race and hardcoded secret, mutable default arg, nil deref, off-by-one loop, panic-on-bad-input, error handling in ParsePort and Save functions, mock data for SQLi and SSRF vulns, defer resp.Body.Close() in fetch.go, wg.Add(1) in RunAll function, mock_response.json with XSS innerHTML --- CHANGELOG.md | 44 ++++ Makefile | 11 +- README.md | 34 +++ internal/eval/doc.go | 26 ++ internal/eval/eval_test.go | 173 ++++++++++++ internal/eval/fixture.go | 144 ++++++++++ internal/eval/live_test.go | 149 +++++++++++ internal/eval/runner.go | 98 +++++++ internal/eval/score.go | 246 ++++++++++++++++++ .../corpus/bare-except-python/README.md | 3 + .../corpus/bare-except-python/expected.json | 7 + .../corpus/bare-except-python/input.diff | 13 + .../bare-except-python/mock_response.json | 1 + .../corpus/clean-comment-typo-go/README.md | 3 + .../clean-comment-typo-go/expected.json | 7 + .../corpus/clean-comment-typo-go/input.diff | 11 + .../clean-comment-typo-go/mock_response.json | 1 + .../corpus/clean-new-test-go/README.md | 3 + .../corpus/clean-new-test-go/expected.json | 8 + .../corpus/clean-new-test-go/input.diff | 14 + .../clean-new-test-go/mock_response.json | 1 + .../testdata/corpus/clean-rename-go/README.md | 12 + .../corpus/clean-rename-go/expected.json | 16 ++ .../corpus/clean-rename-go/input.diff | 12 + .../corpus/clean-rename-go/mock_response.json | 1 + .../corpus/command-injection-go/README.md | 3 + .../corpus/command-injection-go/expected.json | 7 + .../corpus/command-injection-go/input.diff | 14 + .../command-injection-go/mock_response.json | 1 + .../corpus/data-race-map-go/README.md | 3 + .../corpus/data-race-map-go/expected.json | 7 + .../corpus/data-race-map-go/input.diff | 21 ++ .../data-race-map-go/mock_response.json | 1 + .../corpus/hardcoded-secret-go/README.md | 3 + .../corpus/hardcoded-secret-go/expected.json | 7 + .../corpus/hardcoded-secret-go/input.diff | 11 + .../hardcoded-secret-go/mock_response.json | 1 + .../mutable-default-arg-python/README.md | 3 + .../mutable-default-arg-python/expected.json | 7 + .../mutable-default-arg-python/input.diff | 11 + .../mock_response.json | 1 + .../testdata/corpus/nil-deref-go/README.md | 11 + .../corpus/nil-deref-go/expected.json | 15 ++ .../testdata/corpus/nil-deref-go/input.diff | 15 ++ .../corpus/nil-deref-go/mock_response.json | 1 + .../corpus/off-by-one-loop-go/README.md | 3 + .../corpus/off-by-one-loop-go/expected.json | 7 + .../corpus/off-by-one-loop-go/input.diff | 13 + .../off-by-one-loop-go/mock_response.json | 1 + .../corpus/panic-on-bad-input-go/README.md | 3 + .../panic-on-bad-input-go/expected.json | 8 + .../corpus/panic-on-bad-input-go/input.diff | 15 ++ .../panic-on-bad-input-go/mock_response.json | 1 + .../corpus/path-traversal-go/README.md | 3 + .../corpus/path-traversal-go/expected.json | 7 + .../corpus/path-traversal-go/input.diff | 14 + .../path-traversal-go/mock_response.json | 1 + .../corpus/sql-injection-go/README.md | 11 + .../corpus/sql-injection-go/expected.json | 15 ++ .../corpus/sql-injection-go/input.diff | 21 ++ .../sql-injection-go/mock_response.json | 1 + .../testdata/corpus/ssrf-python/README.md | 3 + .../testdata/corpus/ssrf-python/expected.json | 7 + .../testdata/corpus/ssrf-python/input.diff | 13 + .../corpus/ssrf-python/mock_response.json | 1 + .../corpus/string-concat-in-loop-go/README.md | 3 + .../string-concat-in-loop-go/expected.json | 7 + .../string-concat-in-loop-go/input.diff | 12 + .../mock_response.json | 1 + .../corpus/swallowed-error-go/README.md | 3 + .../corpus/swallowed-error-go/expected.json | 7 + .../corpus/swallowed-error-go/input.diff | 13 + .../swallowed-error-go/mock_response.json | 1 + .../corpus/unchecked-type-assert-go/README.md | 3 + .../unchecked-type-assert-go/expected.json | 7 + .../unchecked-type-assert-go/input.diff | 12 + .../mock_response.json | 1 + .../corpus/unclosed-file-go/README.md | 3 + .../corpus/unclosed-file-go/expected.json | 8 + .../corpus/unclosed-file-go/input.diff | 17 ++ .../unclosed-file-go/mock_response.json | 1 + .../corpus/unclosed-resp-body-go/README.md | 3 + .../unclosed-resp-body-go/expected.json | 7 + .../corpus/unclosed-resp-body-go/input.diff | 18 ++ .../unclosed-resp-body-go/mock_response.json | 1 + .../corpus/unclosed-sql-rows-go/README.md | 3 + .../corpus/unclosed-sql-rows-go/expected.json | 8 + .../corpus/unclosed-sql-rows-go/input.diff | 20 ++ .../unclosed-sql-rows-go/mock_response.json | 1 + .../corpus/waitgroup-misuse-go/README.md | 3 + .../corpus/waitgroup-misuse-go/expected.json | 7 + .../corpus/waitgroup-misuse-go/input.diff | 19 ++ .../waitgroup-misuse-go/mock_response.json | 1 + .../corpus/weak-crypto-md5-go/README.md | 3 + .../corpus/weak-crypto-md5-go/expected.json | 7 + .../corpus/weak-crypto-md5-go/input.diff | 14 + .../weak-crypto-md5-go/mock_response.json | 1 + .../corpus/xss-innerhtml-js/README.md | 3 + .../corpus/xss-innerhtml-js/expected.json | 7 + .../corpus/xss-innerhtml-js/input.diff | 11 + .../xss-innerhtml-js/mock_response.json | 1 + 101 files changed, 1565 insertions(+), 1 deletion(-) create mode 100644 internal/eval/doc.go create mode 100644 internal/eval/eval_test.go create mode 100644 internal/eval/fixture.go create mode 100644 internal/eval/live_test.go create mode 100644 internal/eval/runner.go create mode 100644 internal/eval/score.go create mode 100644 internal/eval/testdata/corpus/bare-except-python/README.md create mode 100644 internal/eval/testdata/corpus/bare-except-python/expected.json create mode 100644 internal/eval/testdata/corpus/bare-except-python/input.diff create mode 100644 internal/eval/testdata/corpus/bare-except-python/mock_response.json create mode 100644 internal/eval/testdata/corpus/clean-comment-typo-go/README.md create mode 100644 internal/eval/testdata/corpus/clean-comment-typo-go/expected.json create mode 100644 internal/eval/testdata/corpus/clean-comment-typo-go/input.diff create mode 100644 internal/eval/testdata/corpus/clean-comment-typo-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/clean-new-test-go/README.md create mode 100644 internal/eval/testdata/corpus/clean-new-test-go/expected.json create mode 100644 internal/eval/testdata/corpus/clean-new-test-go/input.diff create mode 100644 internal/eval/testdata/corpus/clean-new-test-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/clean-rename-go/README.md create mode 100644 internal/eval/testdata/corpus/clean-rename-go/expected.json create mode 100644 internal/eval/testdata/corpus/clean-rename-go/input.diff create mode 100644 internal/eval/testdata/corpus/clean-rename-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/command-injection-go/README.md create mode 100644 internal/eval/testdata/corpus/command-injection-go/expected.json create mode 100644 internal/eval/testdata/corpus/command-injection-go/input.diff create mode 100644 internal/eval/testdata/corpus/command-injection-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/data-race-map-go/README.md create mode 100644 internal/eval/testdata/corpus/data-race-map-go/expected.json create mode 100644 internal/eval/testdata/corpus/data-race-map-go/input.diff create mode 100644 internal/eval/testdata/corpus/data-race-map-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/hardcoded-secret-go/README.md create mode 100644 internal/eval/testdata/corpus/hardcoded-secret-go/expected.json create mode 100644 internal/eval/testdata/corpus/hardcoded-secret-go/input.diff create mode 100644 internal/eval/testdata/corpus/hardcoded-secret-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/mutable-default-arg-python/README.md create mode 100644 internal/eval/testdata/corpus/mutable-default-arg-python/expected.json create mode 100644 internal/eval/testdata/corpus/mutable-default-arg-python/input.diff create mode 100644 internal/eval/testdata/corpus/mutable-default-arg-python/mock_response.json create mode 100644 internal/eval/testdata/corpus/nil-deref-go/README.md create mode 100644 internal/eval/testdata/corpus/nil-deref-go/expected.json create mode 100644 internal/eval/testdata/corpus/nil-deref-go/input.diff create mode 100644 internal/eval/testdata/corpus/nil-deref-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/off-by-one-loop-go/README.md create mode 100644 internal/eval/testdata/corpus/off-by-one-loop-go/expected.json create mode 100644 internal/eval/testdata/corpus/off-by-one-loop-go/input.diff create mode 100644 internal/eval/testdata/corpus/off-by-one-loop-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/panic-on-bad-input-go/README.md create mode 100644 internal/eval/testdata/corpus/panic-on-bad-input-go/expected.json create mode 100644 internal/eval/testdata/corpus/panic-on-bad-input-go/input.diff create mode 100644 internal/eval/testdata/corpus/panic-on-bad-input-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/path-traversal-go/README.md create mode 100644 internal/eval/testdata/corpus/path-traversal-go/expected.json create mode 100644 internal/eval/testdata/corpus/path-traversal-go/input.diff create mode 100644 internal/eval/testdata/corpus/path-traversal-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/sql-injection-go/README.md create mode 100644 internal/eval/testdata/corpus/sql-injection-go/expected.json create mode 100644 internal/eval/testdata/corpus/sql-injection-go/input.diff create mode 100644 internal/eval/testdata/corpus/sql-injection-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/ssrf-python/README.md create mode 100644 internal/eval/testdata/corpus/ssrf-python/expected.json create mode 100644 internal/eval/testdata/corpus/ssrf-python/input.diff create mode 100644 internal/eval/testdata/corpus/ssrf-python/mock_response.json create mode 100644 internal/eval/testdata/corpus/string-concat-in-loop-go/README.md create mode 100644 internal/eval/testdata/corpus/string-concat-in-loop-go/expected.json create mode 100644 internal/eval/testdata/corpus/string-concat-in-loop-go/input.diff create mode 100644 internal/eval/testdata/corpus/string-concat-in-loop-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/swallowed-error-go/README.md create mode 100644 internal/eval/testdata/corpus/swallowed-error-go/expected.json create mode 100644 internal/eval/testdata/corpus/swallowed-error-go/input.diff create mode 100644 internal/eval/testdata/corpus/swallowed-error-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/unchecked-type-assert-go/README.md create mode 100644 internal/eval/testdata/corpus/unchecked-type-assert-go/expected.json create mode 100644 internal/eval/testdata/corpus/unchecked-type-assert-go/input.diff create mode 100644 internal/eval/testdata/corpus/unchecked-type-assert-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/unclosed-file-go/README.md create mode 100644 internal/eval/testdata/corpus/unclosed-file-go/expected.json create mode 100644 internal/eval/testdata/corpus/unclosed-file-go/input.diff create mode 100644 internal/eval/testdata/corpus/unclosed-file-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/unclosed-resp-body-go/README.md create mode 100644 internal/eval/testdata/corpus/unclosed-resp-body-go/expected.json create mode 100644 internal/eval/testdata/corpus/unclosed-resp-body-go/input.diff create mode 100644 internal/eval/testdata/corpus/unclosed-resp-body-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/unclosed-sql-rows-go/README.md create mode 100644 internal/eval/testdata/corpus/unclosed-sql-rows-go/expected.json create mode 100644 internal/eval/testdata/corpus/unclosed-sql-rows-go/input.diff create mode 100644 internal/eval/testdata/corpus/unclosed-sql-rows-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/waitgroup-misuse-go/README.md create mode 100644 internal/eval/testdata/corpus/waitgroup-misuse-go/expected.json create mode 100644 internal/eval/testdata/corpus/waitgroup-misuse-go/input.diff create mode 100644 internal/eval/testdata/corpus/waitgroup-misuse-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/weak-crypto-md5-go/README.md create mode 100644 internal/eval/testdata/corpus/weak-crypto-md5-go/expected.json create mode 100644 internal/eval/testdata/corpus/weak-crypto-md5-go/input.diff create mode 100644 internal/eval/testdata/corpus/weak-crypto-md5-go/mock_response.json create mode 100644 internal/eval/testdata/corpus/xss-innerhtml-js/README.md create mode 100644 internal/eval/testdata/corpus/xss-innerhtml-js/expected.json create mode 100644 internal/eval/testdata/corpus/xss-innerhtml-js/input.diff create mode 100644 internal/eval/testdata/corpus/xss-innerhtml-js/mock_response.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a9c03ee..ce6c421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,50 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v > Tags prior to **v0.4.0** were cut in the private repository and produced no > public artifacts; the first publicly released version is v0.4.0. +## [Unreleased] + +### Added +- **Review-quality eval harness (`make eval`).** A maintainer-facing + harness that scores actual review output against a curated known-answer + corpus and reports precision / recall / false-positive rate (ADR-0018, + v1.4.0 "Trust & quality"). The corpus lives at + `internal/eval/testdata/corpus//` — one directory per fixture with + `input.diff` (the change under review), `expected.json` (the answer key), + and `mock_response.json` (scripted findings for the deterministic tier). + Scoring matches produced findings on file + line-tolerance + severity + floor and reuses the locked `--json` schema v1 `findings[]` (no new + output contract). Two tiers: `make eval` runs the mock provider over the + corpus (deterministic, runs in plain `go test ./...`, validates the + harness + matcher — part of CI) and `make eval-live` runs a real provider + (resolved from `COMMITBRIEF_EVAL_PROVIDER`/`COMMITBRIEF_EVAL_API_KEY` or, + with no env vars, the default provider in `~/.commitbrief/config.yml`; + behind the `live` build tag, non-deterministic, the source of README + quality numbers — never a CI gate). Ships with a 23-fixture seed corpus + spanning security (SQL/command/path/SSRF/XSS injection, weak crypto, + hardcoded secret), correctness (nil deref, off-by-one, unchecked type + assert, mutable default arg), concurrency (data race, WaitGroup misuse), + resource leaks (unclosed file/response-body/SQL-rows), error handling + (swallowed error, bare except, panic-on-input), a performance case, and + three clean controls (rename, comment-typo, added test) that must stay + silent. Several fixtures annotate more than one expected finding where the + diff genuinely contains secondary defects (e.g. a second panic, an ignored + `rows.Scan` error, a truncating fixed-buffer read). +- **`make eval-dump` diagnostic.** Prints every finding a live provider + produces per fixture, tagged `match` / `EXTRA`, to decide whether an + EXTRA is a legitimate secondary defect to annotate or genuine noise to + leave as a measured false positive. +- **`COMMITBRIEF_EVAL_PROVIDER` / `COMMITBRIEF_EVAL_MODEL` overrides.** + Select the eval provider and model via env while the API key is read + from `~/.commitbrief/config.yml`, so one config benchmarks every + provider/model without putting a key on the command line. `RunCorpus` + retries each fixture (linear backoff) to ride over transient provider + 503s during a run. +- **README "Measured review quality" table.** First published scorecard + across five models (Haiku 4.5 / Sonnet 4.6 / Opus 4.8 / Gemini 2.5 Flash + / GPT-4o, 2026-05-29): recall 0.88–1.00, false-positive rate 0.00–0.40, + precision 0.58–0.78 (conservative floor; recall + FPR are the cleaner + signals). + ## [1.3.0] ### Added diff --git a/Makefile b/Makefile index ddbbfab..750f204 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ LDFLAGS := -s -w \ GO ?= go -.PHONY: help build test test-live bench lint fmt tidy clean check release-check license-check i18n-check spdx-check security-check manpage smoke +.PHONY: help build test test-live eval eval-live bench lint fmt tidy clean check release-check license-check i18n-check spdx-check security-check manpage smoke help: ## Show this help @awk 'BEGIN {FS = ":.*## "} /^[a-zA-Z_-]+:.*## / {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) @@ -27,6 +27,15 @@ test: ## Run unit + integration tests (live provider tests excluded) test-live: ## Run live provider tests (real API keys required) $(GO) test -tags=live ./... +eval: ## Deterministic mock-tier review-quality eval (CI-safe; ADR-0018) + $(GO) test ./internal/eval/ -run TestEvalMockCorpus -v + +eval-live: ## Live-provider review-quality eval (uses COMMITBRIEF_EVAL_PROVIDER or ~/.commitbrief/config.yml) + $(GO) test -tags=live -count=1 -timeout=20m ./internal/eval/ -run '^TestEvalLive$$' -v + +eval-dump: ## Diagnostic: print every finding a live provider produces per fixture (match/EXTRA) + $(GO) test -tags=live -count=1 -timeout=20m ./internal/eval/ -run '^TestEvalLiveDump$$' -v + bench: ## Run local-pipeline + cache benchmarks (PRD §7.1 targets) $(GO) test -bench=. -benchmem -run=^$$ ./internal/diff ./internal/cache diff --git a/README.md b/README.md index 2276751..8432dcd 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,40 @@ read on your diff before another human (or your future self) sees it. system prompt; per-user `OUTPUT.md` controls how findings are formatted. +## Measured review quality + +CommitBrief ships an eval harness (`make eval`) that scores real review +output against a 23-fixture known-answer corpus — 25 planted defects +across security, correctness, concurrency, resource-leak, error-handling +and performance categories, plus 3 clean controls a good review must stay +silent on. Numbers below are from `make eval-live`, captured 2026-05-29 +(mean of *Runs* live runs each): + +| Model | Recall | False-positive rate | Precision | Runs | +|--------------------|:------:|:-------------------:|:---------:|:----:| +| Claude Haiku 4.5 | 1.00 | 0.00 | 0.68 | 5 | +| Claude Sonnet 4.6 | 1.00 | 0.20 | 0.62 | 3 | +| Claude Opus 4.8 | 0.96 | 0.00 | 0.58 | 3 | +| Gemini 2.5 Flash | 0.97 | 0.27 | 0.76 | 3 | +| OpenAI GPT-4o | 0.88 | 0.40 | 0.78 | 3 | + +- **Recall** — share of planted defects caught. The Claude models catch + essentially all of them (even the cheapest, Haiku, misses none); Gemini + is close behind; GPT-4o misses about one in eight. +- **False-positive rate** — findings landing on a clean-control line, i.e. + flagging a benign change. Haiku and Opus stay silent on every control; + GPT-4o is the noisiest here. +- **Precision** — a *conservative floor*: it counts any finding outside + the answer key as a false positive, but on these small diffs many + "extra" findings are legitimate secondary observations (a second panic, + an ignored error) rather than noise. The terser models (GPT-4o, Gemini) + score higher on precision precisely because they say less — at the cost + of recall. Read recall + false-positive rate as the cleaner signals; + precision is sensitive to how exhaustively the corpus is annotated. + +Reproduce any row locally with `COMMITBRIEF_EVAL_PROVIDER= make +eval-live` (uses the key already in `~/.commitbrief/config.yml`). + ## Install ### Homebrew (macOS / Linux) diff --git a/internal/eval/doc.go b/internal/eval/doc.go new file mode 100644 index 0000000..0b261e2 --- /dev/null +++ b/internal/eval/doc.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package eval is CommitBrief's review-quality eval harness (ADR-0018). +// +// It scores a provider's actual review output against a curated +// known-answer corpus and reports precision / recall / false-positive +// rate. The corpus lives under testdata/corpus//, one directory per +// fixture: input.diff (the change under review), expected.json (the answer +// key), and, for the deterministic tier, mock_response.json (scripted +// findings fed to the mock provider). +// +// Two execution tiers (ADR-0018 §3): +// +// - Deterministic tier — TestEvalMockCorpus runs the corpus through the +// mock provider with each fixture's scripted response. It validates the +// harness, matcher, and scoring math, runs in plain `go test ./...`, and +// is therefore part of the CI gate. It does NOT measure model quality. +// +// - Live tier — TestEvalLive (behind the `live` build tag, like the rest +// of the live provider tests) runs the corpus through a real provider +// and prints the quality scorecard. Non-deterministic and gated; it is +// the source of the README quality numbers, never a CI gate. +// +// The harness consumes provider output through the locked --json schema v1 +// findings[] (ADR-0014); it introduces no new output contract. +package eval diff --git a/internal/eval/eval_test.go b/internal/eval/eval_test.go new file mode 100644 index 0000000..d2b6ce4 --- /dev/null +++ b/internal/eval/eval_test.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package eval + +import ( + "context" + "path/filepath" + "testing" + + "github.com/CommitBrief/commitbrief/internal/provider/mock" + "github.com/CommitBrief/commitbrief/internal/render" +) + +func corpusDir() string { return filepath.Join("testdata", "corpus") } + +// TestEvalMockCorpus is the deterministic tier (ADR-0018 §3): it runs every +// corpus fixture through the mock provider with that fixture's scripted +// "ideal" response and asserts the scoring invariant below. It runs under +// plain `go test ./...`, so a regression in the harness, the matcher, or a +// fixture's internal consistency fails CI. It does NOT measure model +// quality — the mock's answers are authored, not earned. +// +// Invariant: each fixture ships a mock_response.json that is the *ideal* +// answer to its own expected.json, so scoring it must yield perfect recall, +// no false positives, and no silence violations. That single property +// validates, for every fixture and at any corpus size: +// - the diff, answer key, and mock response all load and parse; +// - the matcher pairs the ideal answer to each expected finding (so the +// fixture's file / line / severity-floor are internally consistent); +// - the scripted answer trips none of the fixture's silence anchors. +// +// It needs no hand-maintained per-fixture tally, so the corpus can grow +// without touching this test. +func TestEvalMockCorpus(t *testing.T) { + fixtures, err := LoadCorpus(corpusDir()) + if err != nil { + t.Fatalf("LoadCorpus: %v", err) + } + + sc := Scorecard{Provider: "mock", Model: "mock-model"} + for _, fx := range fixtures { + if fx.MockResponse == "" { + t.Fatalf("fixture %q: missing mock_response.json (required by the deterministic tier)", fx.Name) + } + m := mock.New() + m.ResponseContent = fx.MockResponse + + score, runErr := RunFixture(context.Background(), m, fx, "") + if runErr != nil { + t.Fatalf("fixture %q: RunFixture: %v", fx.Name, runErr) + } + sc.Fixtures = append(sc.Fixtures, score) + + if score.TruePositives != len(fx.Expected) || score.FalseNegatives != 0 { + t.Errorf("fixture %q: ideal mock recall imperfect — TP=%d FN=%d, want TP=%d FN=0 (check expected.json vs mock_response.json file/line/severity alignment)", + fx.Name, score.TruePositives, score.FalseNegatives, len(fx.Expected)) + } + if score.FalsePositives != 0 { + t.Errorf("fixture %q: ideal mock produced %d false positive(s) — every mock finding must match an expected finding", fx.Name, score.FalsePositives) + } + if score.SilenceViolations != 0 { + t.Errorf("fixture %q: ideal mock tripped %d silence anchor(s) — no mock finding may land on a must_stay_silent_on line", fx.Name, score.SilenceViolations) + } + } + + // With ideal scripted responses the aggregate is perfect; this guards + // the aggregation math, not the model. + if got := sc.Recall(); got != 1 { + t.Errorf("aggregate recall = %v, want 1", got) + } + if got := sc.Precision(); got != 1 { + t.Errorf("aggregate precision = %v, want 1", got) + } + if got := sc.FalsePositiveRate(); got != 0 { + t.Errorf("aggregate false-positive rate = %v, want 0", got) + } + t.Logf("deterministic corpus: %d fixtures, all ideal-mock-perfect", len(fixtures)) +} + +func TestLoadCorpusSorted(t *testing.T) { + fixtures, err := LoadCorpus(corpusDir()) + if err != nil { + t.Fatalf("LoadCorpus: %v", err) + } + if len(fixtures) < 3 { + t.Fatalf("expected at least 3 fixtures, got %d", len(fixtures)) + } + for i := 1; i < len(fixtures); i++ { + if fixtures[i-1].Name > fixtures[i].Name { + t.Errorf("fixtures not sorted: %q before %q", fixtures[i-1].Name, fixtures[i].Name) + } + } +} + +func TestScoreMatching(t *testing.T) { + fx := Fixture{ + Name: "unit", + Expected: []ExpectedFinding{ + {ID: "a", File: "pkg/a.go", Line: 100, LineTol: 3, Category: "security", MinSeverity: render.SeverityHigh}, + }, + MustStaySilentOn: []SilenceAnchor{ + {File: "pkg/b.go", Line: 50, Reason: "rename"}, + }, + } + + t.Run("exact match within tolerance", func(t *testing.T) { + got := Score([]render.Finding{ + {Severity: render.SeverityCritical, File: "pkg/a.go", Line: 102}, + }, fx) + if got.TruePositives != 1 || got.FalsePositives != 0 || got.FalseNegatives != 0 { + t.Errorf("got %+v, want TP=1 FP=0 FN=0", got) + } + }) + + t.Run("severity below floor does not match", func(t *testing.T) { + got := Score([]render.Finding{ + {Severity: render.SeverityLow, File: "pkg/a.go", Line: 100}, + }, fx) + if got.TruePositives != 0 || got.FalseNegatives != 1 || got.FalsePositives != 1 { + t.Errorf("got %+v, want TP=0 FN=1 FP=1", got) + } + }) + + t.Run("line outside tolerance does not match", func(t *testing.T) { + got := Score([]render.Finding{ + {Severity: render.SeverityHigh, File: "pkg/a.go", Line: 110}, + }, fx) + if got.TruePositives != 0 || got.FalseNegatives != 1 || got.FalsePositives != 1 { + t.Errorf("got %+v, want TP=0 FN=1 FP=1", got) + } + }) + + t.Run("wrong file does not match", func(t *testing.T) { + got := Score([]render.Finding{ + {Severity: render.SeverityHigh, File: "pkg/z.go", Line: 100}, + }, fx) + if got.TruePositives != 0 || got.FalseNegatives != 1 || got.FalsePositives != 1 { + t.Errorf("got %+v, want TP=0 FN=1 FP=1", got) + } + }) + + t.Run("range overlap matches", func(t *testing.T) { + got := Score([]render.Finding{ + {Severity: render.SeverityHigh, File: "pkg/a.go", Line: 96, LineEnd: 105}, + }, fx) + if got.TruePositives != 1 { + t.Errorf("got TP=%d, want 1 (expected line 100 inside [96,105])", got.TruePositives) + } + }) + + t.Run("silence anchor violation is counted", func(t *testing.T) { + got := Score([]render.Finding{ + {Severity: render.SeverityMedium, File: "pkg/b.go", Line: 51}, + }, fx) + if got.SilenceViolations != 1 { + t.Errorf("got silenceViolations=%d, want 1", got.SilenceViolations) + } + if got.FalsePositives != 1 { + t.Errorf("got FP=%d, want 1 (no expected finding matches)", got.FalsePositives) + } + if got.FalseNegatives != 1 { + t.Errorf("got FN=%d, want 1 (the security finding was missed)", got.FalseNegatives) + } + }) + + t.Run("clean diff with silent finding is fully recalled", func(t *testing.T) { + clean := Fixture{Name: "clean"} + got := Score(nil, clean) + if got.Recall() != 1 || got.Precision() != 1 { + t.Errorf("clean diff: got recall=%v precision=%v, want 1/1", got.Recall(), got.Precision()) + } + }) +} diff --git a/internal/eval/fixture.go b/internal/eval/fixture.go new file mode 100644 index 0000000..15a6f49 --- /dev/null +++ b/internal/eval/fixture.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package eval + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + + "github.com/CommitBrief/commitbrief/internal/render" +) + +// defaultLineTolerance is the ± window applied when an expected finding +// does not set its own LineTol. A model that flags the right defect a few +// lines off (diff drift, multi-line statements) should still count as a +// hit; ADR-0018 §2 fixes the default at 3. +const defaultLineTolerance = 3 + +// ExpectedFinding is one entry in a fixture answer key — a defect the +// review SHOULD surface (ADR-0018 §1). Category is reporting metadata, not +// a match criterion: the locked findings schema carries no category field. +type ExpectedFinding struct { + ID string `json:"id"` + File string `json:"file"` + Line int `json:"line"` + LineTol int `json:"line_tol,omitempty"` + Category string `json:"category"` + MinSeverity render.Severity `json:"min_severity,omitempty"` + Summary string `json:"summary"` +} + +// tolerance returns the effective line window for this expected finding. +func (e ExpectedFinding) tolerance() int { + if e.LineTol > 0 { + return e.LineTol + } + return defaultLineTolerance +} + +// SilenceAnchor marks a line a good review should NOT flag. A produced +// finding landing on (File, ~Line) is a measured false positive +// (ADR-0018 §2). +type SilenceAnchor struct { + File string `json:"file"` + Line int `json:"line"` + Reason string `json:"reason"` +} + +// answerKey is the on-disk shape of expected.json. +type answerKey struct { + Language string `json:"language"` + ExpectedFindings []ExpectedFinding `json:"expected_findings"` + MustStaySilentOn []SilenceAnchor `json:"must_stay_silent_on"` +} + +// Fixture is one known-answer corpus entry: a diff plus its answer key. +// MockResponse is the scripted findings JSON used by the deterministic +// tier; it is empty when the fixture ships no mock_response.json. +type Fixture struct { + Name string + Dir string + Language string + Diff string + Expected []ExpectedFinding + MustStaySilentOn []SilenceAnchor + MockResponse string +} + +// LoadFixture reads a single corpus directory: input.diff + expected.json +// (required) and mock_response.json (optional, for the deterministic tier). +func LoadFixture(dir string) (Fixture, error) { + name := filepath.Base(dir) + + diffBytes, err := os.ReadFile(filepath.Join(dir, "input.diff")) + if err != nil { + return Fixture{}, fmt.Errorf("eval: fixture %q: read input.diff: %w", name, err) + } + + keyBytes, err := os.ReadFile(filepath.Join(dir, "expected.json")) + if err != nil { + return Fixture{}, fmt.Errorf("eval: fixture %q: read expected.json: %w", name, err) + } + var key answerKey + if err := json.Unmarshal(keyBytes, &key); err != nil { + return Fixture{}, fmt.Errorf("eval: fixture %q: parse expected.json: %w", name, err) + } + for i, e := range key.ExpectedFindings { + if e.File == "" { + return Fixture{}, fmt.Errorf("eval: fixture %q: expected finding %d: missing file", name, i) + } + if e.MinSeverity != "" && !e.MinSeverity.IsValid() { + return Fixture{}, fmt.Errorf("eval: fixture %q: expected finding %d: invalid min_severity %q", name, i, e.MinSeverity) + } + } + + mockResp, err := os.ReadFile(filepath.Join(dir, "mock_response.json")) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return Fixture{}, fmt.Errorf("eval: fixture %q: read mock_response.json: %w", name, err) + } + + return Fixture{ + Name: name, + Dir: dir, + Language: key.Language, + Diff: string(diffBytes), + Expected: key.ExpectedFindings, + MustStaySilentOn: key.MustStaySilentOn, + MockResponse: string(mockResp), + }, nil +} + +// LoadCorpus loads every fixture under root — each child directory that +// contains an input.diff. Fixtures are returned sorted by name so every +// run iterates deterministically. +func LoadCorpus(root string) ([]Fixture, error) { + entries, err := os.ReadDir(root) + if err != nil { + return nil, fmt.Errorf("eval: read corpus %q: %w", root, err) + } + var fixtures []Fixture + for _, entry := range entries { + if !entry.IsDir() { + continue + } + dir := filepath.Join(root, entry.Name()) + if _, statErr := os.Stat(filepath.Join(dir, "input.diff")); statErr != nil { + continue // not a fixture directory + } + fx, loadErr := LoadFixture(dir) + if loadErr != nil { + return nil, loadErr + } + fixtures = append(fixtures, fx) + } + if len(fixtures) == 0 { + return nil, fmt.Errorf("eval: no fixtures found under %q", root) + } + sort.Slice(fixtures, func(i, j int) bool { return fixtures[i].Name < fixtures[j].Name }) + return fixtures, nil +} diff --git a/internal/eval/live_test.go b/internal/eval/live_test.go new file mode 100644 index 0000000..f697101 --- /dev/null +++ b/internal/eval/live_test.go @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build live + +package eval + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/CommitBrief/commitbrief/internal/config" + "github.com/CommitBrief/commitbrief/internal/provider" + + // Blank-import the API providers so the registry knows them under the + // live build. CLI-backed providers emit plain text (no findings JSON) + // and so cannot be scored by this harness; API providers only. + _ "github.com/CommitBrief/commitbrief/internal/provider/anthropic" + _ "github.com/CommitBrief/commitbrief/internal/provider/cohere" + _ "github.com/CommitBrief/commitbrief/internal/provider/deepseek" + _ "github.com/CommitBrief/commitbrief/internal/provider/gemini" + _ "github.com/CommitBrief/commitbrief/internal/provider/mistral" + _ "github.com/CommitBrief/commitbrief/internal/provider/ollama" + _ "github.com/CommitBrief/commitbrief/internal/provider/openai" +) + +// TestEvalLive is the live tier (ADR-0018 §3): it runs the corpus through a +// real provider and prints the quality scorecard. It is non-deterministic +// and gated behind the `live` build tag, so it never runs in CI and is +// never a gate. It is the source of the README quality numbers. +// +// Provider resolution (first match wins): +// 1. COMMITBRIEF_EVAL_PROVIDER (+ COMMITBRIEF_EVAL_API_KEY, +// COMMITBRIEF_EVAL_MODEL) — explicit override. +// 2. The default provider in the user's ~/.commitbrief/config.yml — so +// `make eval-live` works against the configured provider with no env +// vars and the API key never passes through the shell. +// +// If neither yields a provider with an API key, the test skips. +func TestEvalLive(t *testing.T) { + p, model := resolveLiveProvider(t) + + fixtures, err := LoadCorpus(corpusDir()) + if err != nil { + t.Fatalf("LoadCorpus: %v", err) + } + + sc, err := RunCorpus(context.Background(), p, model, fixtures) + if err != nil { + t.Fatalf("RunCorpus: %v", err) + } + + t.Logf("eval scorecard — provider=%s model=%s (%d fixtures)", sc.Provider, sc.Model, len(sc.Fixtures)) + for _, f := range sc.Fixtures { + t.Logf(" %-26s TP=%d FN=%d FP=%d precision=%.2f recall=%.2f fpr=%.2f", + f.Fixture, f.TruePositives, f.FalseNegatives, f.FalsePositives, + f.Precision(), f.Recall(), f.FalsePositiveRate()) + } + t.Logf(" TOTAL precision=%.2f recall=%.2f false-positive-rate=%.2f", + sc.Precision(), sc.Recall(), sc.FalsePositiveRate()) + for _, cr := range sc.CategoryRecall() { + t.Logf(" category %-16s recall=%d/%d", cr.Category, cr.Caught, cr.Total) + } +} + +// TestEvalLiveDump is a diagnostic (not a scorer): it prints every finding a +// real provider produces for each fixture, tagged `match` (pairs an expected +// finding) or `EXTRA` (no expected finding). Use it to decide whether an +// EXTRA is a legitimate secondary defect — which should be annotated into +// expected.json (and mirrored in mock_response.json) — or genuine noise to +// leave as a measured false positive. Run: make eval-dump. +func TestEvalLiveDump(t *testing.T) { + p, model := resolveLiveProvider(t) + + fixtures, err := LoadCorpus(corpusDir()) + if err != nil { + t.Fatalf("LoadCorpus: %v", err) + } + + for _, fx := range fixtures { + findings, ferr := reviewFindings(context.Background(), p, fx, model) + if ferr != nil { + t.Errorf("fixture %q: %v", fx.Name, ferr) + continue + } + t.Logf("── %s (%d expected, %d produced) ──", fx.Name, len(fx.Expected), len(findings)) + for _, f := range findings { + tag := "EXTRA" + for _, e := range fx.Expected { + if matchesExpected(f, e) { + tag = "match" + break + } + } + t.Logf(" [%-5s] %-8s %s:%d %s", tag, f.Severity, f.File, f.Line, f.Title) + } + } +} + +// resolveLiveProvider builds a provider for the live eval. The provider +// name comes from COMMITBRIEF_EVAL_PROVIDER, else the default provider in +// ~/.commitbrief/config.yml. The API key, model, and base URL are read +// from that provider's config entry, with COMMITBRIEF_EVAL_API_KEY and +// COMMITBRIEF_EVAL_MODEL as optional overrides. Selecting a provider via +// env therefore reuses its configured key — the key never has to appear on +// the command line, and one config can be benchmarked across providers and +// models. It never logs the API key. +func resolveLiveProvider(t *testing.T) (provider.Provider, string) { + t.Helper() + + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("cannot resolve home dir: %v", err) + } + path := filepath.Join(home, ".commitbrief", "config.yml") + cfg, err := config.LoadFile(path) + if err != nil { + t.Fatalf("load %s: %v", path, err) + } + + name := os.Getenv("COMMITBRIEF_EVAL_PROVIDER") + if name == "" { + if cfg == nil || cfg.Provider == "" { + t.Skipf("set COMMITBRIEF_EVAL_PROVIDER or configure a default provider in %s", path) + } + name = cfg.Provider + } + + var pc config.ProviderConfig + if cfg != nil { + pc = cfg.Providers[name] + } + if key := os.Getenv("COMMITBRIEF_EVAL_API_KEY"); key != "" { + pc.APIKey = key + } + if model := os.Getenv("COMMITBRIEF_EVAL_MODEL"); model != "" { + pc.Model = model + } + if pc.APIKey == "" { + t.Skipf("provider %q has no api_key (in %s or COMMITBRIEF_EVAL_API_KEY)", name, path) + } + + p, err := provider.New(name, pc) + if err != nil { + t.Fatalf("provider.New(%q): %v", name, err) + } + return p, pc.Model +} diff --git a/internal/eval/runner.go b/internal/eval/runner.go new file mode 100644 index 0000000..ef5b155 --- /dev/null +++ b/internal/eval/runner.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package eval + +import ( + "context" + "fmt" + "time" + + "github.com/CommitBrief/commitbrief/internal/lang" + "github.com/CommitBrief/commitbrief/internal/prompt" + "github.com/CommitBrief/commitbrief/internal/provider" + "github.com/CommitBrief/commitbrief/internal/render" + "github.com/CommitBrief/commitbrief/internal/rules" +) + +// buildRequest assembles the review request for a fixture using the +// embedded default rules and the English locale — the same prompt the CLI +// sends on a default-config run, so the eval measures the shipped path +// rather than a bespoke prompt. +func buildRequest(fx Fixture, model string) provider.Request { + p := prompt.Build(rules.Default(), lang.CoerceCLIFlag("en"), fx.Diff) + return provider.Request{ + Model: model, + SystemPrompt: p.System, + UserPrompt: p.User, + Lang: "en", + } +} + +// reviewFindings runs one fixture through a provider and returns the parsed +// findings. An empty model uses the provider's default model. Shared by +// RunFixture (scoring) and the live diagnostic dump. +func reviewFindings(ctx context.Context, p provider.Provider, fx Fixture, model string) ([]render.Finding, error) { + if model == "" { + model = p.DefaultModel() + } + resp, err := p.Review(ctx, buildRequest(fx, model)) + if err != nil { + return nil, fmt.Errorf("eval: fixture %q: review: %w", fx.Name, err) + } + findings, err := render.ParseFindings(resp.Content) + if err != nil { + return nil, fmt.Errorf("eval: fixture %q: parse findings: %w", fx.Name, err) + } + return findings, nil +} + +// RunFixture runs one fixture through a provider and scores the result. +// An empty model uses the provider's default model. +func RunFixture(ctx context.Context, p provider.Provider, fx Fixture, model string) (FixtureScore, error) { + findings, err := reviewFindings(ctx, p, fx, model) + if err != nil { + return FixtureScore{}, err + } + return Score(findings, fx), nil +} + +// corpusAttempts is how many times RunCorpus tries each fixture before +// giving up. Live providers occasionally return a transient 503 ("high +// demand") that would otherwise abort a whole 23-fixture run; a couple of +// retries with backoff rides over the spike. The deterministic mock tier +// calls RunFixture directly and never hits this path. +const corpusAttempts = 3 + +// RunCorpus runs every fixture through the provider and returns a +// Scorecard. Each fixture is retried up to corpusAttempts times with +// linear backoff; if it still fails the error aborts the run. +func RunCorpus(ctx context.Context, p provider.Provider, model string, fixtures []Fixture) (Scorecard, error) { + sc := Scorecard{Provider: p.Name(), Model: model} + if model == "" { + sc.Model = p.DefaultModel() + } + for _, fx := range fixtures { + var ( + s FixtureScore + err error + ) + for attempt := 1; attempt <= corpusAttempts; attempt++ { + s, err = RunFixture(ctx, p, fx, model) + if err == nil { + break + } + if attempt < corpusAttempts { + select { + case <-ctx.Done(): + return Scorecard{}, ctx.Err() + case <-time.After(time.Duration(attempt) * 2 * time.Second): + } + } + } + if err != nil { + return Scorecard{}, err + } + sc.Fixtures = append(sc.Fixtures, s) + } + return sc, nil +} diff --git a/internal/eval/score.go b/internal/eval/score.go new file mode 100644 index 0000000..7493f10 --- /dev/null +++ b/internal/eval/score.go @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package eval + +import ( + "path/filepath" + "sort" + + "github.com/CommitBrief/commitbrief/internal/render" +) + +// severityRank maps a severity to an ascending integer (info=0 … +// critical=4) so the "actual ≥ min_severity" floor is a plain comparison. +// Unknown severities sort below info; the findings parser rejects them +// upstream, so the -1 branch is defensive only. +func severityRank(s render.Severity) int { + switch s { + case render.SeverityCritical: + return 4 + case render.SeverityHigh: + return 3 + case render.SeverityMedium: + return 2 + case render.SeverityLow: + return 1 + case render.SeverityInfo: + return 0 + } + return -1 +} + +// FixtureScore is the per-fixture outcome of scoring produced findings +// against the answer key. +type FixtureScore struct { + Fixture string + + TruePositives int // expected findings that were matched + FalseNegatives int // expected findings that were missed + FalsePositives int // produced findings that matched no expected finding + + SilenceViolations int // produced findings landing on a silence anchor + SilenceAnchors int // total silence anchors in the fixture + + // CaughtByCategory / MissedByCategory attribute each expected finding to + // its category, giving a per-category recall breakdown (ADR-0018 §2). + CaughtByCategory map[string]int + MissedByCategory map[string]int +} + +// Precision = TP / (TP + FP). A run that produced no findings is vacuously +// precise (returns 1) so it does not divide by zero or drag an aggregate. +func (s FixtureScore) Precision() float64 { + produced := s.TruePositives + s.FalsePositives + if produced == 0 { + return 1 + } + return float64(s.TruePositives) / float64(produced) +} + +// Recall = TP / (TP + FN). A fixture that expects nothing (a clean diff) is +// fully recalled by definition (returns 1). +func (s FixtureScore) Recall() float64 { + expected := s.TruePositives + s.FalseNegatives + if expected == 0 { + return 1 + } + return float64(s.TruePositives) / float64(expected) +} + +// FalsePositiveRate = silence violations ÷ silence anchors. Returns 0 when +// the fixture defines no anchors. +func (s FixtureScore) FalsePositiveRate() float64 { + if s.SilenceAnchors == 0 { + return 0 + } + return float64(s.SilenceViolations) / float64(s.SilenceAnchors) +} + +// matchesExpected reports whether a produced finding satisfies an expected +// finding's file + line-tolerance + severity-floor criteria (ADR-0018 §2). +func matchesExpected(f render.Finding, e ExpectedFinding) bool { + if filepath.ToSlash(f.File) != filepath.ToSlash(e.File) { + return false + } + if !withinTolerance(f, e) { + return false + } + if e.MinSeverity != "" && severityRank(f.Severity) < severityRank(e.MinSeverity) { + return false + } + return true +} + +// withinTolerance reports whether the finding's line — or its +// [Line, LineEnd] range — lands within ±tolerance of the expected line. +func withinTolerance(f render.Finding, e ExpectedFinding) bool { + tol := e.tolerance() + if abs(f.Line-e.Line) <= tol { + return true + } + if f.LineEnd > f.Line && e.Line >= f.Line-tol && e.Line <= f.LineEnd+tol { + return true + } + return false +} + +// hitsSilence reports whether a produced finding lands on a silence anchor +// (same file, line within the default tolerance). +func hitsSilence(f render.Finding, a SilenceAnchor) bool { + if filepath.ToSlash(f.File) != filepath.ToSlash(a.File) { + return false + } + return abs(f.Line-a.Line) <= defaultLineTolerance +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +// Score matches produced findings against a fixture's answer key using +// one-to-one greedy assignment (ADR-0018 §2) and returns the tally. The +// corpus is assumed fully annotated, so any produced finding that matches +// no expected finding counts as a false positive. +func Score(produced []render.Finding, fx Fixture) FixtureScore { + score := FixtureScore{ + Fixture: fx.Name, + SilenceAnchors: len(fx.MustStaySilentOn), + CaughtByCategory: map[string]int{}, + MissedByCategory: map[string]int{}, + } + + matched := make([]bool, len(produced)) + + for _, exp := range fx.Expected { + caught := false + for i, f := range produced { + if matched[i] { + continue + } + if matchesExpected(f, exp) { + matched[i] = true + caught = true + break + } + } + if caught { + score.TruePositives++ + score.CaughtByCategory[exp.Category]++ + } else { + score.FalseNegatives++ + score.MissedByCategory[exp.Category]++ + } + } + + for i, f := range produced { + if !matched[i] { + score.FalsePositives++ + } + for _, anchor := range fx.MustStaySilentOn { + if hitsSilence(f, anchor) { + score.SilenceViolations++ + break + } + } + } + + return score +} + +// Scorecard aggregates fixture scores for one provider+model run. +type Scorecard struct { + Provider string + Model string + Fixtures []FixtureScore +} + +// totals sums the raw counts across every fixture in the scorecard. +func (sc Scorecard) totals() (tp, fn, fp, sv, sa int) { + for _, s := range sc.Fixtures { + tp += s.TruePositives + fn += s.FalseNegatives + fp += s.FalsePositives + sv += s.SilenceViolations + sa += s.SilenceAnchors + } + return tp, fn, fp, sv, sa +} + +// Precision is the corpus-wide TP / (TP + FP). +func (sc Scorecard) Precision() float64 { + tp, _, fp, _, _ := sc.totals() + if tp+fp == 0 { + return 1 + } + return float64(tp) / float64(tp+fp) +} + +// Recall is the corpus-wide TP / (TP + FN). +func (sc Scorecard) Recall() float64 { + tp, fn, _, _, _ := sc.totals() + if tp+fn == 0 { + return 1 + } + return float64(tp) / float64(tp+fn) +} + +// FalsePositiveRate is the corpus-wide silence violations ÷ silence anchors. +func (sc Scorecard) FalsePositiveRate() float64 { + _, _, _, sv, sa := sc.totals() + if sa == 0 { + return 0 + } + return float64(sv) / float64(sa) +} + +// CategoryRecall is per-category recall for one expected-finding category. +type CategoryRecall struct { + Category string + Caught int + Total int +} + +// CategoryRecall returns recall per expected-finding category, sorted by +// category name for deterministic output. +func (sc Scorecard) CategoryRecall() []CategoryRecall { + caught := map[string]int{} + total := map[string]int{} + for _, s := range sc.Fixtures { + for cat, n := range s.CaughtByCategory { + caught[cat] += n + total[cat] += n + } + for cat, n := range s.MissedByCategory { + total[cat] += n + } + } + out := make([]CategoryRecall, 0, len(total)) + for cat, t := range total { + out = append(out, CategoryRecall{Category: cat, Caught: caught[cat], Total: t}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Category < out[j].Category }) + return out +} diff --git a/internal/eval/testdata/corpus/bare-except-python/README.md b/internal/eval/testdata/corpus/bare-except-python/README.md new file mode 100644 index 0000000..09d999b --- /dev/null +++ b/internal/eval/testdata/corpus/bare-except-python/README.md @@ -0,0 +1,3 @@ +# Fixture: bare-except-python +**Category:** error-handling · **Language:** Python +A bare `except: pass` wraps process(payload) and silently swallows every exception. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/bare-except-python/expected.json b/internal/eval/testdata/corpus/bare-except-python/expected.json new file mode 100644 index 0000000..844bc0d --- /dev/null +++ b/internal/eval/testdata/corpus/bare-except-python/expected.json @@ -0,0 +1,7 @@ +{ + "language": "python", + "expected_findings": [ + {"id":"bare-except-pass","file":"app/task.py","line":9,"line_tol":3,"category":"error-handling","min_severity":"medium","summary":"A bare except: pass swallows every exception silently; catch specific exceptions and log or re-raise."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/bare-except-python/input.diff b/internal/eval/testdata/corpus/bare-except-python/input.diff new file mode 100644 index 0000000..9f08ca9 --- /dev/null +++ b/internal/eval/testdata/corpus/bare-except-python/input.diff @@ -0,0 +1,13 @@ +diff --git a/app/task.py b/app/task.py +index 1111111..2222222 100644 +--- a/app/task.py ++++ b/app/task.py +@@ -5,5 +5,8 @@ def run_task(payload): + logger.info("starting task") + result = None +- result = process(payload) ++ try: ++ result = process(payload) ++ except: ++ pass + return result diff --git a/internal/eval/testdata/corpus/bare-except-python/mock_response.json b/internal/eval/testdata/corpus/bare-except-python/mock_response.json new file mode 100644 index 0000000..6f704a8 --- /dev/null +++ b/internal/eval/testdata/corpus/bare-except-python/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"app/task.py","line":9,"title":"Bare except swallows all errors","description":"The bare except: pass catches every exception including KeyboardInterrupt and SystemExit and discards it, so failures in process(payload) vanish without a trace.","suggestion":"Catch a specific exception type and log or re-raise: except SomeError as e: logger.exception(e); raise."}]} diff --git a/internal/eval/testdata/corpus/clean-comment-typo-go/README.md b/internal/eval/testdata/corpus/clean-comment-typo-go/README.md new file mode 100644 index 0000000..8e8e3d2 --- /dev/null +++ b/internal/eval/testdata/corpus/clean-comment-typo-go/README.md @@ -0,0 +1,3 @@ +# Fixture: clean-comment-typo-go +**Category:** clean control · **Language:** Go +The change only fixes a spelling typo inside a comment with no behavior change, so a good review must stay silent. Hand-authored clean control (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/clean-comment-typo-go/expected.json b/internal/eval/testdata/corpus/clean-comment-typo-go/expected.json new file mode 100644 index 0000000..32ed54d --- /dev/null +++ b/internal/eval/testdata/corpus/clean-comment-typo-go/expected.json @@ -0,0 +1,7 @@ +{ + "language": "go", + "expected_findings": [], + "must_stay_silent_on": [ + {"file":"internal/doc/doc.go","line":4,"reason":"The change only fixes a spelling typo (recieves -> receives) inside a // comment; no code or behavior changes."} + ] +} diff --git a/internal/eval/testdata/corpus/clean-comment-typo-go/input.diff b/internal/eval/testdata/corpus/clean-comment-typo-go/input.diff new file mode 100644 index 0000000..9ba5c12 --- /dev/null +++ b/internal/eval/testdata/corpus/clean-comment-typo-go/input.diff @@ -0,0 +1,11 @@ +diff --git a/internal/doc/doc.go b/internal/doc/doc.go +index 1111111..2222222 100644 +--- a/internal/doc/doc.go ++++ b/internal/doc/doc.go +@@ -3,5 +3,5 @@ package doc + // Render returns the rendered document body. +-// It recieves the parsed sections in order. ++// It receives the parsed sections in order. + func Render(sections []Section) string { + return join(sections) + } diff --git a/internal/eval/testdata/corpus/clean-comment-typo-go/mock_response.json b/internal/eval/testdata/corpus/clean-comment-typo-go/mock_response.json new file mode 100644 index 0000000..a609d55 --- /dev/null +++ b/internal/eval/testdata/corpus/clean-comment-typo-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[]} diff --git a/internal/eval/testdata/corpus/clean-new-test-go/README.md b/internal/eval/testdata/corpus/clean-new-test-go/README.md new file mode 100644 index 0000000..f219e18 --- /dev/null +++ b/internal/eval/testdata/corpus/clean-new-test-go/README.md @@ -0,0 +1,3 @@ +# Fixture: clean-new-test-go +**Category:** clean control · **Language:** Go +The change adds a small, correct unit test (TestAdd) and is entirely benign, so a good review must stay silent. Hand-authored clean control (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/clean-new-test-go/expected.json b/internal/eval/testdata/corpus/clean-new-test-go/expected.json new file mode 100644 index 0000000..6c75d39 --- /dev/null +++ b/internal/eval/testdata/corpus/clean-new-test-go/expected.json @@ -0,0 +1,8 @@ +{ + "language": "go", + "expected_findings": [], + "must_stay_silent_on": [ + {"file":"internal/math/add_test.go","line":12,"reason":"Adds a correct, well-formed unit test function TestAdd; defining a new test is not a defect."}, + {"file":"internal/math/add_test.go","line":13,"reason":"A straightforward assertion (Add(2,3) == 5) with a clear failure message; nothing to flag."} + ] +} diff --git a/internal/eval/testdata/corpus/clean-new-test-go/input.diff b/internal/eval/testdata/corpus/clean-new-test-go/input.diff new file mode 100644 index 0000000..f664901 --- /dev/null +++ b/internal/eval/testdata/corpus/clean-new-test-go/input.diff @@ -0,0 +1,14 @@ +diff --git a/internal/math/add_test.go b/internal/math/add_test.go +index 1111111..2222222 100644 +--- a/internal/math/add_test.go ++++ b/internal/math/add_test.go +@@ -8,3 +8,9 @@ func TestSub(t *testing.T) { + t.Fatalf("Sub(3,1) = %d, want 2", got) + } + } ++ ++func TestAdd(t *testing.T) { ++ if got := Add(2, 3); got != 5 { ++ t.Fatalf("Add(2,3) = %d, want 5", got) ++ } ++} diff --git a/internal/eval/testdata/corpus/clean-new-test-go/mock_response.json b/internal/eval/testdata/corpus/clean-new-test-go/mock_response.json new file mode 100644 index 0000000..a609d55 --- /dev/null +++ b/internal/eval/testdata/corpus/clean-new-test-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[]} diff --git a/internal/eval/testdata/corpus/clean-rename-go/README.md b/internal/eval/testdata/corpus/clean-rename-go/README.md new file mode 100644 index 0000000..7cb01b2 --- /dev/null +++ b/internal/eval/testdata/corpus/clean-rename-go/README.md @@ -0,0 +1,12 @@ +# Fixture: clean-rename-go + +**Category:** (none — clean diff) · **Language:** Go + +A pure local-variable rename (`cnt` → `count`) with no behavioral change. +A good review must stay **silent**: there are no expected findings, and the +two renamed lines are `must_stay_silent_on` anchors. This fixture measures +the false-positive axis — an over-eager provider that "finds" something +here is producing noise. + +**Provenance:** hand-authored clean control (ADR-0018 §1). Every corpus +needs at least one zero-finding diff to catch over-flagging. diff --git a/internal/eval/testdata/corpus/clean-rename-go/expected.json b/internal/eval/testdata/corpus/clean-rename-go/expected.json new file mode 100644 index 0000000..00838cc --- /dev/null +++ b/internal/eval/testdata/corpus/clean-rename-go/expected.json @@ -0,0 +1,16 @@ +{ + "language": "go", + "expected_findings": [], + "must_stay_silent_on": [ + { + "file": "internal/util/count.go", + "line": 5, + "reason": "Pure local-variable rename (cnt -> count). Semantically identical; flagging it is noise." + }, + { + "file": "internal/util/count.go", + "line": 6, + "reason": "The returned variable's rename mirrors line 5; not a finding." + } + ] +} diff --git a/internal/eval/testdata/corpus/clean-rename-go/input.diff b/internal/eval/testdata/corpus/clean-rename-go/input.diff new file mode 100644 index 0000000..869364c --- /dev/null +++ b/internal/eval/testdata/corpus/clean-rename-go/input.diff @@ -0,0 +1,12 @@ +diff --git a/internal/util/count.go b/internal/util/count.go +index ccccccc..ddddddd 100644 +--- a/internal/util/count.go ++++ b/internal/util/count.go +@@ -3,6 +3,6 @@ package util + // CountItems returns how many items the slice holds. + func CountItems(items []string) int { +- cnt := len(items) +- return cnt ++ count := len(items) ++ return count + } diff --git a/internal/eval/testdata/corpus/clean-rename-go/mock_response.json b/internal/eval/testdata/corpus/clean-rename-go/mock_response.json new file mode 100644 index 0000000..a609d55 --- /dev/null +++ b/internal/eval/testdata/corpus/clean-rename-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[]} diff --git a/internal/eval/testdata/corpus/command-injection-go/README.md b/internal/eval/testdata/corpus/command-injection-go/README.md new file mode 100644 index 0000000..084ce74 --- /dev/null +++ b/internal/eval/testdata/corpus/command-injection-go/README.md @@ -0,0 +1,3 @@ +# Fixture: command-injection-go +**Category:** security · **Language:** Go +RunUserCommand passes untrusted input to `exec.Command("sh", "-c", userInput)`, enabling OS command injection. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/command-injection-go/expected.json b/internal/eval/testdata/corpus/command-injection-go/expected.json new file mode 100644 index 0000000..c907a55 --- /dev/null +++ b/internal/eval/testdata/corpus/command-injection-go/expected.json @@ -0,0 +1,7 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"cmd-injection","file":"internal/exec/run.go","line":16,"line_tol":3,"category":"security","min_severity":"critical","summary":"User input is passed to sh -c, enabling OS command injection; pass arguments as separate exec.Command args or validate against an allowlist."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/command-injection-go/input.diff b/internal/eval/testdata/corpus/command-injection-go/input.diff new file mode 100644 index 0000000..cc377fb --- /dev/null +++ b/internal/eval/testdata/corpus/command-injection-go/input.diff @@ -0,0 +1,14 @@ +diff --git a/internal/exec/run.go b/internal/exec/run.go +index 1111111..2222222 100644 +--- a/internal/exec/run.go ++++ b/internal/exec/run.go +@@ -10,3 +10,9 @@ package exec + func existing() { + _ = "noop" + } ++ ++// RunUserCommand executes a caller-supplied command string. ++func RunUserCommand(userInput string) ([]byte, error) { ++ cmd := exec.Command("sh", "-c", userInput) ++ return cmd.CombinedOutput() ++} diff --git a/internal/eval/testdata/corpus/command-injection-go/mock_response.json b/internal/eval/testdata/corpus/command-injection-go/mock_response.json new file mode 100644 index 0000000..7660e88 --- /dev/null +++ b/internal/eval/testdata/corpus/command-injection-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"critical","file":"internal/exec/run.go","line":16,"title":"OS command injection via sh -c","description":"RunUserCommand passes the untrusted userInput string directly to exec.Command(\"sh\", \"-c\", userInput), allowing an attacker to inject arbitrary shell commands.","suggestion":"Avoid invoking a shell; call exec.Command with the program and its arguments as separate parameters, or strictly validate userInput against an allowlist before execution."}]} diff --git a/internal/eval/testdata/corpus/data-race-map-go/README.md b/internal/eval/testdata/corpus/data-race-map-go/README.md new file mode 100644 index 0000000..59a5648 --- /dev/null +++ b/internal/eval/testdata/corpus/data-race-map-go/README.md @@ -0,0 +1,3 @@ +# Fixture: data-race-map-go +**Category:** concurrency · **Language:** Go +Multiple goroutines write to a shared map concurrently with no mutex, causing a data race. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/data-race-map-go/expected.json b/internal/eval/testdata/corpus/data-race-map-go/expected.json new file mode 100644 index 0000000..825a58b --- /dev/null +++ b/internal/eval/testdata/corpus/data-race-map-go/expected.json @@ -0,0 +1,7 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"map-data-race","file":"internal/counter/counter.go","line":12,"line_tol":3,"category":"concurrency","min_severity":"high","summary":"Multiple goroutines write to the shared counts map without synchronization, causing a data race; guard writes with a sync.Mutex or use sync.Map."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/data-race-map-go/input.diff b/internal/eval/testdata/corpus/data-race-map-go/input.diff new file mode 100644 index 0000000..f40818c --- /dev/null +++ b/internal/eval/testdata/corpus/data-race-map-go/input.diff @@ -0,0 +1,21 @@ +diff --git a/internal/counter/counter.go b/internal/counter/counter.go +index 1111111..2222222 100644 +--- a/internal/counter/counter.go ++++ b/internal/counter/counter.go +@@ -3,6 +3,15 @@ package counter + import "sync" + + func CountWords(docs []string) map[string]int { +- return nil ++ counts := make(map[string]int) ++ var wg sync.WaitGroup ++ for _, doc := range docs { ++ wg.Add(1) ++ go func(d string) { ++ defer wg.Done() ++ counts[d]++ ++ }(doc) ++ } ++ wg.Wait() ++ return counts + } diff --git a/internal/eval/testdata/corpus/data-race-map-go/mock_response.json b/internal/eval/testdata/corpus/data-race-map-go/mock_response.json new file mode 100644 index 0000000..d477dc8 --- /dev/null +++ b/internal/eval/testdata/corpus/data-race-map-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"internal/counter/counter.go","line":12,"title":"Concurrent map write is a data race","description":"Each goroutine increments the shared counts map concurrently without any synchronization, which is an unsynchronized concurrent map write and will trigger Go's race detector or panic at runtime.","suggestion":"Protect the map with a sync.Mutex around counts[d]++, or aggregate per-goroutine results and merge after wg.Wait(), or use sync.Map."}]} diff --git a/internal/eval/testdata/corpus/hardcoded-secret-go/README.md b/internal/eval/testdata/corpus/hardcoded-secret-go/README.md new file mode 100644 index 0000000..4e5c07b --- /dev/null +++ b/internal/eval/testdata/corpus/hardcoded-secret-go/README.md @@ -0,0 +1,3 @@ +# Fixture: hardcoded-secret-go +**Category:** security · **Language:** Go +The `apiToken` constant hardcodes a live `sk_live_` API secret directly in source. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/hardcoded-secret-go/expected.json b/internal/eval/testdata/corpus/hardcoded-secret-go/expected.json new file mode 100644 index 0000000..9332783 --- /dev/null +++ b/internal/eval/testdata/corpus/hardcoded-secret-go/expected.json @@ -0,0 +1,7 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"hardcoded-secret","file":"internal/client/config.go","line":13,"line_tol":3,"category":"security","min_severity":"high","summary":"A live API token is hardcoded as a source constant; remove it, rotate the leaked credential, and load secrets from an environment variable or secret store."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/hardcoded-secret-go/input.diff b/internal/eval/testdata/corpus/hardcoded-secret-go/input.diff new file mode 100644 index 0000000..3358560 --- /dev/null +++ b/internal/eval/testdata/corpus/hardcoded-secret-go/input.diff @@ -0,0 +1,11 @@ +diff --git a/internal/client/config.go b/internal/client/config.go +index 1111111..2222222 100644 +--- a/internal/client/config.go ++++ b/internal/client/config.go +@@ -8,3 +8,6 @@ package client + func existing() { + _ = "noop" + } ++ ++// apiToken authenticates outbound API requests. ++const apiToken = "sk_live_51H*REDACTED*abcdef0123456789ABCDEF" diff --git a/internal/eval/testdata/corpus/hardcoded-secret-go/mock_response.json b/internal/eval/testdata/corpus/hardcoded-secret-go/mock_response.json new file mode 100644 index 0000000..59ef496 --- /dev/null +++ b/internal/eval/testdata/corpus/hardcoded-secret-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"internal/client/config.go","line":13,"title":"Hardcoded API secret committed to source","description":"The apiToken constant embeds a live sk_live_ API credential directly in source, so anyone with repository or binary access obtains a valid secret and the key is permanently exposed in git history.","suggestion":"Remove the literal, rotate the leaked token immediately, and load the credential at runtime from an environment variable or a secret manager."}]} diff --git a/internal/eval/testdata/corpus/mutable-default-arg-python/README.md b/internal/eval/testdata/corpus/mutable-default-arg-python/README.md new file mode 100644 index 0000000..09843c7 --- /dev/null +++ b/internal/eval/testdata/corpus/mutable-default-arg-python/README.md @@ -0,0 +1,3 @@ +# Fixture: mutable-default-arg-python +**Category:** correctness · **Language:** Python +add_item declares a mutable list default argument, causing state to leak and accumulate across separate calls. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/mutable-default-arg-python/expected.json b/internal/eval/testdata/corpus/mutable-default-arg-python/expected.json new file mode 100644 index 0000000..1dd9b99 --- /dev/null +++ b/internal/eval/testdata/corpus/mutable-default-arg-python/expected.json @@ -0,0 +1,7 @@ +{ + "language": "python", + "expected_findings": [ + {"id":"mutable-default-arg","file":"app/util.py","line":4,"line_tol":3,"category":"correctness","min_severity":"medium","summary":"add_item uses a mutable list as a default argument, so the same list is shared across calls; default to None and create a new list inside the function."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/mutable-default-arg-python/input.diff b/internal/eval/testdata/corpus/mutable-default-arg-python/input.diff new file mode 100644 index 0000000..dfb010f --- /dev/null +++ b/internal/eval/testdata/corpus/mutable-default-arg-python/input.diff @@ -0,0 +1,11 @@ +diff --git a/app/util.py b/app/util.py +index 1111111..2222222 100644 +--- a/app/util.py ++++ b/app/util.py +@@ -1,3 +1,7 @@ + """Collection helpers.""" + + ++def add_item(x, items=[]): ++ items.append(x) ++ return items diff --git a/internal/eval/testdata/corpus/mutable-default-arg-python/mock_response.json b/internal/eval/testdata/corpus/mutable-default-arg-python/mock_response.json new file mode 100644 index 0000000..60e316f --- /dev/null +++ b/internal/eval/testdata/corpus/mutable-default-arg-python/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"medium","file":"app/util.py","line":4,"title":"Mutable default argument shared across calls","description":"The default value items=[] is evaluated once at function definition, so every call that omits items mutates and accumulates into the same shared list, producing surprising cross-call state.","suggestion":"Use items=None as the default and initialize a fresh list inside the function: if items is None: items = []."}]} diff --git a/internal/eval/testdata/corpus/nil-deref-go/README.md b/internal/eval/testdata/corpus/nil-deref-go/README.md new file mode 100644 index 0000000..4b13777 --- /dev/null +++ b/internal/eval/testdata/corpus/nil-deref-go/README.md @@ -0,0 +1,11 @@ +# Fixture: nil-deref-go + +**Category:** correctness · **Language:** Go + +`TouchEntry` calls `lookup`, which returns `nil` on a cache miss, then +dereferences the result (`e.AccessedAt = ...`) with no nil check — a +guaranteed panic on any absent key. A competent review must flag the +dereference (new-file line 24) at `high` severity or above. + +**Provenance:** hand-authored planted defect (ADR-0018 §1). Representative +of the "map lookup returns zero/nil, used unchecked" class. diff --git a/internal/eval/testdata/corpus/nil-deref-go/expected.json b/internal/eval/testdata/corpus/nil-deref-go/expected.json new file mode 100644 index 0000000..0eb248f --- /dev/null +++ b/internal/eval/testdata/corpus/nil-deref-go/expected.json @@ -0,0 +1,15 @@ +{ + "language": "go", + "expected_findings": [ + { + "id": "nil-deref-1", + "file": "internal/cache/entry.go", + "line": 24, + "line_tol": 3, + "category": "correctness", + "min_severity": "high", + "summary": "lookup() returns nil on a cache miss, but the result e is dereferenced (e.AccessedAt) without a nil check — panics on any missing key. Guard with `if e == nil { return }`." + } + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/nil-deref-go/input.diff b/internal/eval/testdata/corpus/nil-deref-go/input.diff new file mode 100644 index 0000000..09b91bd --- /dev/null +++ b/internal/eval/testdata/corpus/nil-deref-go/input.diff @@ -0,0 +1,15 @@ +diff --git a/internal/cache/entry.go b/internal/cache/entry.go +index aaaaaaa..bbbbbbb 100644 +--- a/internal/cache/entry.go ++++ b/internal/cache/entry.go +@@ -17,3 +17,11 @@ import "time" + func (c *Cache) lookup(key string) *Entry { + return c.entries[key] + } ++ ++// TouchEntry refreshes the access time of a cached entry. ++func (c *Cache) TouchEntry(key string) { ++ e := c.lookup(key) // returns nil on a cache miss ++ e.AccessedAt = time.Now() // nil-pointer dereference when key is absent ++ c.entries[key] = e ++} diff --git a/internal/eval/testdata/corpus/nil-deref-go/mock_response.json b/internal/eval/testdata/corpus/nil-deref-go/mock_response.json new file mode 100644 index 0000000..e96b47c --- /dev/null +++ b/internal/eval/testdata/corpus/nil-deref-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"internal/cache/entry.go","line":24,"title":"Possible nil-pointer dereference","description":"lookup() returns nil when the key is absent, but TouchEntry dereferences the returned *Entry (e.AccessedAt) without a nil check, panicking on any cache miss.","suggestion":"Check for nil before use: if e == nil { return }."}]} diff --git a/internal/eval/testdata/corpus/off-by-one-loop-go/README.md b/internal/eval/testdata/corpus/off-by-one-loop-go/README.md new file mode 100644 index 0000000..3ea3c0f --- /dev/null +++ b/internal/eval/testdata/corpus/off-by-one-loop-go/README.md @@ -0,0 +1,3 @@ +# Fixture: off-by-one-loop-go +**Category:** correctness · **Language:** Go +The loop bound i <= len(s) indexes one past the end of the string, causing an index-out-of-range panic. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/off-by-one-loop-go/expected.json b/internal/eval/testdata/corpus/off-by-one-loop-go/expected.json new file mode 100644 index 0000000..3086797 --- /dev/null +++ b/internal/eval/testdata/corpus/off-by-one-loop-go/expected.json @@ -0,0 +1,7 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"off-by-one-loop","file":"internal/scan/scan.go","line":5,"line_tol":3,"category":"correctness","min_severity":"high","summary":"The loop condition i <= len(s) reads s[len(s)] on the final iteration, causing an index-out-of-range panic; use i < len(s)."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/off-by-one-loop-go/input.diff b/internal/eval/testdata/corpus/off-by-one-loop-go/input.diff new file mode 100644 index 0000000..ed0216b --- /dev/null +++ b/internal/eval/testdata/corpus/off-by-one-loop-go/input.diff @@ -0,0 +1,13 @@ +diff --git a/internal/scan/scan.go b/internal/scan/scan.go +index 1111111..2222222 100644 +--- a/internal/scan/scan.go ++++ b/internal/scan/scan.go +@@ -1,5 +1,10 @@ + package scan + ++// Touch reads every byte of s. ++func Touch(s string) { ++ for i := 0; i <= len(s); i++ { ++ _ = s[i] ++ } ++} diff --git a/internal/eval/testdata/corpus/off-by-one-loop-go/mock_response.json b/internal/eval/testdata/corpus/off-by-one-loop-go/mock_response.json new file mode 100644 index 0000000..9531d64 --- /dev/null +++ b/internal/eval/testdata/corpus/off-by-one-loop-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"internal/scan/scan.go","line":5,"title":"Off-by-one loop causes index-out-of-range panic","description":"The condition i <= len(s) lets i reach len(s), so s[i] indexes one past the last valid byte on the final iteration and panics with index out of range.","suggestion":"Change the loop condition to i < len(s) so the index stays within bounds, or iterate with for i := range s."}]} diff --git a/internal/eval/testdata/corpus/panic-on-bad-input-go/README.md b/internal/eval/testdata/corpus/panic-on-bad-input-go/README.md new file mode 100644 index 0000000..9083332 --- /dev/null +++ b/internal/eval/testdata/corpus/panic-on-bad-input-go/README.md @@ -0,0 +1,3 @@ +# Fixture: panic-on-bad-input-go +**Category:** error-handling · **Language:** Go +ParsePort panics on bad input instead of returning an error — twice: on an unparseable string (line 9) and on an out-of-range value (line 12); both are expected findings. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/panic-on-bad-input-go/expected.json b/internal/eval/testdata/corpus/panic-on-bad-input-go/expected.json new file mode 100644 index 0000000..4d83f7c --- /dev/null +++ b/internal/eval/testdata/corpus/panic-on-bad-input-go/expected.json @@ -0,0 +1,8 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"panic-on-bad-input","file":"internal/parse/parse.go","line":9,"line_tol":3,"category":"error-handling","min_severity":"high","summary":"ParsePort panics on unparseable user input instead of returning an error; return the error to the caller."}, + {"id":"panic-on-out-of-range","file":"internal/parse/parse.go","line":12,"line_tol":3,"category":"error-handling","min_severity":"high","summary":"ParsePort also panics on an out-of-range port value instead of returning an error; return an error for invalid input."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/panic-on-bad-input-go/input.diff b/internal/eval/testdata/corpus/panic-on-bad-input-go/input.diff new file mode 100644 index 0000000..aada8ce --- /dev/null +++ b/internal/eval/testdata/corpus/panic-on-bad-input-go/input.diff @@ -0,0 +1,15 @@ +diff --git a/internal/parse/parse.go b/internal/parse/parse.go +index 1111111..2222222 100644 +--- a/internal/parse/parse.go ++++ b/internal/parse/parse.go +@@ -7,6 +7,9 @@ func ParsePort(raw string) (int, error) { + n, err := strconv.Atoi(raw) + if err != nil { +- return 0, err ++ panic("invalid port: " + raw) + } ++ if n < 0 || n > 65535 { ++ panic("port out of range") ++ } + return n, nil + } diff --git a/internal/eval/testdata/corpus/panic-on-bad-input-go/mock_response.json b/internal/eval/testdata/corpus/panic-on-bad-input-go/mock_response.json new file mode 100644 index 0000000..67987b9 --- /dev/null +++ b/internal/eval/testdata/corpus/panic-on-bad-input-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"internal/parse/parse.go","line":9,"title":"Panic on invalid user input","description":"ParsePort already returns an error, yet on a parse failure it calls panic instead of returning that error, which can crash the whole process on untrusted input.","suggestion":"Return the error to the caller: if err != nil { return 0, fmt.Errorf(\"invalid port %q: %w\", raw, err) }."},{"severity":"high","file":"internal/parse/parse.go","line":12,"title":"Panic on out-of-range port","description":"When the parsed port is outside 0-65535, ParsePort calls panic instead of returning an error, crashing the process on attacker-controlled input.","suggestion":"Return an error instead: if n < 0 || n > 65535 { return 0, fmt.Errorf(\"port out of range: %d\", n) }."}]} diff --git a/internal/eval/testdata/corpus/path-traversal-go/README.md b/internal/eval/testdata/corpus/path-traversal-go/README.md new file mode 100644 index 0000000..b0b454a --- /dev/null +++ b/internal/eval/testdata/corpus/path-traversal-go/README.md @@ -0,0 +1,3 @@ +# Fixture: path-traversal-go +**Category:** security · **Language:** Go +ReadUserFile joins `userPath` to `baseDir` and reads it without cleaning, so `../` escapes the base directory. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/path-traversal-go/expected.json b/internal/eval/testdata/corpus/path-traversal-go/expected.json new file mode 100644 index 0000000..9ec3e8c --- /dev/null +++ b/internal/eval/testdata/corpus/path-traversal-go/expected.json @@ -0,0 +1,7 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"path-traversal","file":"internal/files/serve.go","line":26,"line_tol":3,"category":"security","min_severity":"high","summary":"userPath is joined to baseDir without cleaning or containment checks, so ../ sequences escape baseDir; clean the path and verify it stays within baseDir before reading."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/path-traversal-go/input.diff b/internal/eval/testdata/corpus/path-traversal-go/input.diff new file mode 100644 index 0000000..57ee979 --- /dev/null +++ b/internal/eval/testdata/corpus/path-traversal-go/input.diff @@ -0,0 +1,14 @@ +diff --git a/internal/files/serve.go b/internal/files/serve.go +index 1111111..2222222 100644 +--- a/internal/files/serve.go ++++ b/internal/files/serve.go +@@ -20,3 +20,9 @@ package files + func existing() { + _ = "noop" + } ++ ++// ReadUserFile reads a file under baseDir selected by the caller. ++func ReadUserFile(baseDir, userPath string) ([]byte, error) { ++ p := filepath.Join(baseDir, userPath) ++ return os.ReadFile(p) ++} diff --git a/internal/eval/testdata/corpus/path-traversal-go/mock_response.json b/internal/eval/testdata/corpus/path-traversal-go/mock_response.json new file mode 100644 index 0000000..d70599b --- /dev/null +++ b/internal/eval/testdata/corpus/path-traversal-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"internal/files/serve.go","line":26,"title":"Path traversal via unvalidated user path","description":"ReadUserFile joins userPath onto baseDir with filepath.Join and reads it without cleaning or containment checks, so an input like ../../etc/passwd escapes baseDir and discloses arbitrary files.","suggestion":"Clean the joined path with filepath.Clean and confirm it remains within baseDir (e.g. strings.HasPrefix on the absolute paths or filepath.Rel without a leading ..) before calling os.ReadFile."}]} diff --git a/internal/eval/testdata/corpus/sql-injection-go/README.md b/internal/eval/testdata/corpus/sql-injection-go/README.md new file mode 100644 index 0000000..3a5eb40 --- /dev/null +++ b/internal/eval/testdata/corpus/sql-injection-go/README.md @@ -0,0 +1,11 @@ +# Fixture: sql-injection-go + +**Category:** security · **Language:** Go + +A new `FindUserByName` method builds its SQL statement by concatenating an +untrusted `name` argument straight into the query string — a textbook SQL +injection. A competent review must flag the concatenation line (new-file +line 14) at `high` severity or above and suggest a parameterized query. + +**Provenance:** hand-authored planted defect (ADR-0018 §1). Representative +of the `database/sql` string-building anti-pattern. diff --git a/internal/eval/testdata/corpus/sql-injection-go/expected.json b/internal/eval/testdata/corpus/sql-injection-go/expected.json new file mode 100644 index 0000000..3f6e0c1 --- /dev/null +++ b/internal/eval/testdata/corpus/sql-injection-go/expected.json @@ -0,0 +1,15 @@ +{ + "language": "go", + "expected_findings": [ + { + "id": "sqli-1", + "file": "internal/store/user.go", + "line": 14, + "line_tol": 3, + "category": "security", + "min_severity": "high", + "summary": "SQL query built by string-concatenating an untrusted name parameter — classic SQL injection. Should use a parameterized query (db.QueryRow(\"... WHERE name = ?\", name))." + } + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/sql-injection-go/input.diff b/internal/eval/testdata/corpus/sql-injection-go/input.diff new file mode 100644 index 0000000..a7668db --- /dev/null +++ b/internal/eval/testdata/corpus/sql-injection-go/input.diff @@ -0,0 +1,21 @@ +diff --git a/internal/store/user.go b/internal/store/user.go +index 1111111..2222222 100644 +--- a/internal/store/user.go ++++ b/internal/store/user.go +@@ -9,4 +9,15 @@ import ( + "database/sql" + ) + ++// FindUserByName looks up a single user row by display name. ++func (s *Store) FindUserByName(name string) (*User, error) { ++ query := "SELECT id, name, email FROM users WHERE name = '" + name + "'" ++ row := s.db.QueryRow(query) ++ var u User ++ if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil { ++ return nil, err ++ } ++ return &u, nil ++} ++ + // ExistingHelper is unchanged context below the new function. + func (s *Store) ping() error { return s.db.Ping() } diff --git a/internal/eval/testdata/corpus/sql-injection-go/mock_response.json b/internal/eval/testdata/corpus/sql-injection-go/mock_response.json new file mode 100644 index 0000000..ee4f52e --- /dev/null +++ b/internal/eval/testdata/corpus/sql-injection-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"critical","file":"internal/store/user.go","line":14,"title":"SQL injection via string concatenation","description":"FindUserByName builds its SQL query by concatenating the untrusted name argument directly into the statement, allowing an attacker to inject arbitrary SQL.","suggestion":"Use a parameterized query: s.db.QueryRow(\"SELECT id, name, email FROM users WHERE name = ?\", name)."}]} diff --git a/internal/eval/testdata/corpus/ssrf-python/README.md b/internal/eval/testdata/corpus/ssrf-python/README.md new file mode 100644 index 0000000..b15b2d6 --- /dev/null +++ b/internal/eval/testdata/corpus/ssrf-python/README.md @@ -0,0 +1,3 @@ +# Fixture: ssrf-python +**Category:** security · **Language:** Python +A user-supplied URL is passed straight to requests.get with no allowlist or validation, enabling server-side request forgery. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/ssrf-python/expected.json b/internal/eval/testdata/corpus/ssrf-python/expected.json new file mode 100644 index 0000000..f27c332 --- /dev/null +++ b/internal/eval/testdata/corpus/ssrf-python/expected.json @@ -0,0 +1,7 @@ +{ + "language": "python", + "expected_findings": [ + {"id":"ssrf-requests-get","file":"app/fetch.py","line":6,"line_tol":3,"category":"security","min_severity":"high","summary":"requests.get is called on an unvalidated user-supplied URL, enabling server-side request forgery; validate the URL against an allowlist of permitted hosts/schemes before fetching."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/ssrf-python/input.diff b/internal/eval/testdata/corpus/ssrf-python/input.diff new file mode 100644 index 0000000..5131051 --- /dev/null +++ b/internal/eval/testdata/corpus/ssrf-python/input.diff @@ -0,0 +1,13 @@ +diff --git a/app/fetch.py b/app/fetch.py +index 1111111..2222222 100644 +--- a/app/fetch.py ++++ b/app/fetch.py +@@ -1,5 +1,9 @@ + import requests + + + def fetch_remote(user_supplied_url): ++ # Forward the request to whatever URL the caller passed in. ++ response = requests.get(user_supplied_url, timeout=5) ++ response.raise_for_status() ++ return response.text diff --git a/internal/eval/testdata/corpus/ssrf-python/mock_response.json b/internal/eval/testdata/corpus/ssrf-python/mock_response.json new file mode 100644 index 0000000..807667b --- /dev/null +++ b/internal/eval/testdata/corpus/ssrf-python/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"app/fetch.py","line":6,"title":"Server-side request forgery via unvalidated URL","description":"requests.get is invoked directly on user_supplied_url with no allowlist or scheme/host validation, so an attacker can coerce the server into requesting internal addresses such as 169.254.169.254 or localhost.","suggestion":"Validate user_supplied_url against an allowlist of permitted schemes and hosts, and reject internal or link-local addresses, before calling requests.get."}]} diff --git a/internal/eval/testdata/corpus/string-concat-in-loop-go/README.md b/internal/eval/testdata/corpus/string-concat-in-loop-go/README.md new file mode 100644 index 0000000..341f5eb --- /dev/null +++ b/internal/eval/testdata/corpus/string-concat-in-loop-go/README.md @@ -0,0 +1,3 @@ +# Fixture: string-concat-in-loop-go +**Category:** performance · **Language:** Go +Build accumulates the result with += inside a for loop instead of strings.Builder, causing O(n^2) allocations. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/string-concat-in-loop-go/expected.json b/internal/eval/testdata/corpus/string-concat-in-loop-go/expected.json new file mode 100644 index 0000000..3605846 --- /dev/null +++ b/internal/eval/testdata/corpus/string-concat-in-loop-go/expected.json @@ -0,0 +1,7 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"concat-in-loop","file":"internal/report/build.go","line":6,"line_tol":3,"category":"performance","min_severity":"low","summary":"String is built with result += inside a loop, causing O(n^2) allocations; use strings.Builder instead."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/string-concat-in-loop-go/input.diff b/internal/eval/testdata/corpus/string-concat-in-loop-go/input.diff new file mode 100644 index 0000000..ff83765 --- /dev/null +++ b/internal/eval/testdata/corpus/string-concat-in-loop-go/input.diff @@ -0,0 +1,12 @@ +diff --git a/internal/report/build.go b/internal/report/build.go +index 1111111..2222222 100644 +--- a/internal/report/build.go ++++ b/internal/report/build.go +@@ -4,6 +4,9 @@ func Build(lines []string) string { + result := "" +- return strings.Join(lines, "\n") ++ for _, line := range lines { ++ result += line + "\n" ++ } ++ return result + } diff --git a/internal/eval/testdata/corpus/string-concat-in-loop-go/mock_response.json b/internal/eval/testdata/corpus/string-concat-in-loop-go/mock_response.json new file mode 100644 index 0000000..0301f6d --- /dev/null +++ b/internal/eval/testdata/corpus/string-concat-in-loop-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"low","file":"internal/report/build.go","line":6,"title":"String concatenation in a loop","description":"result += line + \"\\n\" reallocates and copies the entire string on every iteration, giving O(n^2) behavior for large inputs.","suggestion":"Accumulate with a strings.Builder (b.WriteString(line); b.WriteByte('\\n')) or keep the original strings.Join(lines, \"\\n\")."}]} diff --git a/internal/eval/testdata/corpus/swallowed-error-go/README.md b/internal/eval/testdata/corpus/swallowed-error-go/README.md new file mode 100644 index 0000000..9779cef --- /dev/null +++ b/internal/eval/testdata/corpus/swallowed-error-go/README.md @@ -0,0 +1,3 @@ +# Fixture: swallowed-error-go +**Category:** error-handling · **Language:** Go +The error returned by f.Write is assigned to _ and ignored, hiding write failures. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/swallowed-error-go/expected.json b/internal/eval/testdata/corpus/swallowed-error-go/expected.json new file mode 100644 index 0000000..a282cf0 --- /dev/null +++ b/internal/eval/testdata/corpus/swallowed-error-go/expected.json @@ -0,0 +1,7 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"swallowed-write-err","file":"internal/save/save.go","line":15,"line_tol":3,"category":"error-handling","min_severity":"medium","summary":"The error from f.Write is discarded into _, so a failed write is silently ignored; capture and return it."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/swallowed-error-go/input.diff b/internal/eval/testdata/corpus/swallowed-error-go/input.diff new file mode 100644 index 0000000..7648703 --- /dev/null +++ b/internal/eval/testdata/corpus/swallowed-error-go/input.diff @@ -0,0 +1,13 @@ +diff --git a/internal/save/save.go b/internal/save/save.go +index 1111111..2222222 100644 +--- a/internal/save/save.go ++++ b/internal/save/save.go +@@ -10,6 +10,9 @@ func Save(path string, data []byte) error { + if err != nil { + return err + } + defer f.Close() ++ ++ _, _ = f.Write(data) ++ return nil + } diff --git a/internal/eval/testdata/corpus/swallowed-error-go/mock_response.json b/internal/eval/testdata/corpus/swallowed-error-go/mock_response.json new file mode 100644 index 0000000..b549dcb --- /dev/null +++ b/internal/eval/testdata/corpus/swallowed-error-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"internal/save/save.go","line":15,"title":"Write error discarded","description":"The return value of f.Write is assigned to _ and the error is dropped, so a partial or failed write goes unnoticed and Save reports success.","suggestion":"Capture the error: if _, err := f.Write(data); err != nil { return err }."}]} diff --git a/internal/eval/testdata/corpus/unchecked-type-assert-go/README.md b/internal/eval/testdata/corpus/unchecked-type-assert-go/README.md new file mode 100644 index 0000000..5d3a8d3 --- /dev/null +++ b/internal/eval/testdata/corpus/unchecked-type-assert-go/README.md @@ -0,0 +1,3 @@ +# Fixture: unchecked-type-assert-go +**Category:** correctness · **Language:** Go +A single-value type assertion i.(string) panics at runtime when the interface holds a non-string value. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/unchecked-type-assert-go/expected.json b/internal/eval/testdata/corpus/unchecked-type-assert-go/expected.json new file mode 100644 index 0000000..0f9df86 --- /dev/null +++ b/internal/eval/testdata/corpus/unchecked-type-assert-go/expected.json @@ -0,0 +1,7 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"unchecked-type-assertion","file":"internal/decode/decode.go","line":5,"line_tol":3,"category":"correctness","min_severity":"high","summary":"The single-value type assertion i.(string) panics when i holds a non-string dynamic type; use the two-value form v, ok := i.(string) and handle the failure."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/unchecked-type-assert-go/input.diff b/internal/eval/testdata/corpus/unchecked-type-assert-go/input.diff new file mode 100644 index 0000000..575ea0d --- /dev/null +++ b/internal/eval/testdata/corpus/unchecked-type-assert-go/input.diff @@ -0,0 +1,12 @@ +diff --git a/internal/decode/decode.go b/internal/decode/decode.go +index 1111111..2222222 100644 +--- a/internal/decode/decode.go ++++ b/internal/decode/decode.go +@@ -1,5 +1,9 @@ + package decode + ++// AsString extracts the string payload from an arbitrary value. ++func AsString(i interface{}) string { ++ v := i.(string) ++ return v ++} diff --git a/internal/eval/testdata/corpus/unchecked-type-assert-go/mock_response.json b/internal/eval/testdata/corpus/unchecked-type-assert-go/mock_response.json new file mode 100644 index 0000000..fd9e96e --- /dev/null +++ b/internal/eval/testdata/corpus/unchecked-type-assert-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"internal/decode/decode.go","line":5,"title":"Unchecked type assertion panics on wrong dynamic type","description":"v := i.(string) is a single-value type assertion that panics with 'interface conversion' whenever the caller passes a value whose dynamic type is not string, crashing the program on untrusted or unexpected input.","suggestion":"Use the comma-ok form, v, ok := i.(string), and return an error or a sensible default when ok is false instead of panicking."}]} diff --git a/internal/eval/testdata/corpus/unclosed-file-go/README.md b/internal/eval/testdata/corpus/unclosed-file-go/README.md new file mode 100644 index 0000000..5abcdc8 --- /dev/null +++ b/internal/eval/testdata/corpus/unclosed-file-go/README.md @@ -0,0 +1,3 @@ +# Fixture: unclosed-file-go +**Category:** resource-leak + correctness · **Language:** Go +os.Open returns an *os.File that is never closed (line 8, fd leak) and the single fixed-1024-byte f.Read silently truncates larger files despite the ReadAll name (line 13); both are expected findings. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/unclosed-file-go/expected.json b/internal/eval/testdata/corpus/unclosed-file-go/expected.json new file mode 100644 index 0000000..bbec82f --- /dev/null +++ b/internal/eval/testdata/corpus/unclosed-file-go/expected.json @@ -0,0 +1,8 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"unclosed-file","file":"internal/io/read.go","line":8,"line_tol":3,"category":"resource-leak","min_severity":"medium","summary":"The *os.File from os.Open is never closed, leaking a file descriptor; add defer f.Close() after the error check."}, + {"id":"truncating-read","file":"internal/io/read.go","line":13,"line_tol":3,"category":"correctness","min_severity":"medium","summary":"ReadAll reads into a fixed 1024-byte buffer with a single f.Read, silently truncating any file larger than 1024 bytes; use io.ReadAll(f) to read the whole file."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/unclosed-file-go/input.diff b/internal/eval/testdata/corpus/unclosed-file-go/input.diff new file mode 100644 index 0000000..5cb0890 --- /dev/null +++ b/internal/eval/testdata/corpus/unclosed-file-go/input.diff @@ -0,0 +1,17 @@ +diff --git a/internal/io/read.go b/internal/io/read.go +index 1111111..2222222 100644 +--- a/internal/io/read.go ++++ b/internal/io/read.go +@@ -5,6 +5,13 @@ package io + import "os" + + func ReadAll(path string) ([]byte, error) { +- return nil, nil ++ f, err := os.Open(path) ++ if err != nil { ++ return nil, err ++ } ++ buf := make([]byte, 1024) ++ n, err := f.Read(buf) ++ return buf[:n], err + } diff --git a/internal/eval/testdata/corpus/unclosed-file-go/mock_response.json b/internal/eval/testdata/corpus/unclosed-file-go/mock_response.json new file mode 100644 index 0000000..27d398f --- /dev/null +++ b/internal/eval/testdata/corpus/unclosed-file-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"medium","file":"internal/io/read.go","line":8,"title":"Opened file is never closed","description":"os.Open returns an *os.File that is never closed on any return path, leaking a file descriptor each time ReadAll is called and eventually exhausting the process file-descriptor limit.","suggestion":"Add defer f.Close() immediately after the err != nil check so the file is released on every return path."},{"severity":"medium","file":"internal/io/read.go","line":13,"title":"ReadAll truncates files past 1024 bytes","description":"The function reads a single time into a fixed 1024-byte buffer and returns buf[:n], so any file larger than 1024 bytes is silently truncated despite the ReadAll name.","suggestion":"Read the whole file: return io.ReadAll(f) (with defer f.Close())."}]} diff --git a/internal/eval/testdata/corpus/unclosed-resp-body-go/README.md b/internal/eval/testdata/corpus/unclosed-resp-body-go/README.md new file mode 100644 index 0000000..b87aad2 --- /dev/null +++ b/internal/eval/testdata/corpus/unclosed-resp-body-go/README.md @@ -0,0 +1,3 @@ +# Fixture: unclosed-resp-body-go +**Category:** resource-leak · **Language:** Go +http.Get returns a response whose Body is never closed, leaking the connection. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/unclosed-resp-body-go/expected.json b/internal/eval/testdata/corpus/unclosed-resp-body-go/expected.json new file mode 100644 index 0000000..8b23709 --- /dev/null +++ b/internal/eval/testdata/corpus/unclosed-resp-body-go/expected.json @@ -0,0 +1,7 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"unclosed-resp-body","file":"internal/http/fetch.go","line":8,"line_tol":3,"category":"resource-leak","min_severity":"medium","summary":"resp.Body from http.Get is never closed, leaking the connection; add defer resp.Body.Close() after the error check."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/unclosed-resp-body-go/input.diff b/internal/eval/testdata/corpus/unclosed-resp-body-go/input.diff new file mode 100644 index 0000000..0c85cab --- /dev/null +++ b/internal/eval/testdata/corpus/unclosed-resp-body-go/input.diff @@ -0,0 +1,18 @@ +diff --git a/internal/http/fetch.go b/internal/http/fetch.go +index 1111111..2222222 100644 +--- a/internal/http/fetch.go ++++ b/internal/http/fetch.go +@@ -6,6 +6,13 @@ package http + + func Fetch(url string) ([]byte, error) { +- return nil, nil ++ resp, err := http.Get(url) ++ if err != nil { ++ return nil, err ++ } ++ data, err := io.ReadAll(resp.Body) ++ if err != nil { ++ return nil, err ++ } ++ return data, nil + } diff --git a/internal/eval/testdata/corpus/unclosed-resp-body-go/mock_response.json b/internal/eval/testdata/corpus/unclosed-resp-body-go/mock_response.json new file mode 100644 index 0000000..008c100 --- /dev/null +++ b/internal/eval/testdata/corpus/unclosed-resp-body-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"medium","file":"internal/http/fetch.go","line":8,"title":"HTTP response body is never closed","description":"http.Get returns a response whose Body is never closed, leaking the underlying TCP connection and preventing transport connection reuse, eventually exhausting available connections.","suggestion":"Add defer resp.Body.Close() immediately after confirming err == nil so the body is closed on every return path."}]} diff --git a/internal/eval/testdata/corpus/unclosed-sql-rows-go/README.md b/internal/eval/testdata/corpus/unclosed-sql-rows-go/README.md new file mode 100644 index 0000000..09c789e --- /dev/null +++ b/internal/eval/testdata/corpus/unclosed-sql-rows-go/README.md @@ -0,0 +1,3 @@ +# Fixture: unclosed-sql-rows-go +**Category:** resource-leak + error-handling · **Language:** Go +db.Query returns a *sql.Rows that is never closed (line 9, leaking a connection) and the rows.Scan error is discarded into _ (line 16); both are expected findings. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/unclosed-sql-rows-go/expected.json b/internal/eval/testdata/corpus/unclosed-sql-rows-go/expected.json new file mode 100644 index 0000000..549c7de --- /dev/null +++ b/internal/eval/testdata/corpus/unclosed-sql-rows-go/expected.json @@ -0,0 +1,8 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"unclosed-sql-rows","file":"internal/store/list.go","line":9,"line_tol":3,"category":"resource-leak","min_severity":"medium","summary":"The *sql.Rows from db.Query is never closed, leaking a database connection; add defer rows.Close() after the error check."}, + {"id":"ignored-scan-error","file":"internal/store/list.go","line":16,"line_tol":3,"category":"error-handling","min_severity":"medium","summary":"The error from rows.Scan is discarded into _, so a malformed row is silently appended as a zero value; capture and handle the error."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/unclosed-sql-rows-go/input.diff b/internal/eval/testdata/corpus/unclosed-sql-rows-go/input.diff new file mode 100644 index 0000000..9748346 --- /dev/null +++ b/internal/eval/testdata/corpus/unclosed-sql-rows-go/input.diff @@ -0,0 +1,20 @@ +diff --git a/internal/store/list.go b/internal/store/list.go +index 1111111..2222222 100644 +--- a/internal/store/list.go ++++ b/internal/store/list.go +@@ -7,6 +7,14 @@ package store + + func ListNames(db *sql.DB) ([]string, error) { +- return nil, nil ++ rows, err := db.Query("SELECT name FROM users") ++ if err != nil { ++ return nil, err ++ } ++ var names []string ++ for rows.Next() { ++ var name string ++ _ = rows.Scan(&name) ++ names = append(names, name) ++ } ++ return names, rows.Err() + } diff --git a/internal/eval/testdata/corpus/unclosed-sql-rows-go/mock_response.json b/internal/eval/testdata/corpus/unclosed-sql-rows-go/mock_response.json new file mode 100644 index 0000000..72f6190 --- /dev/null +++ b/internal/eval/testdata/corpus/unclosed-sql-rows-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"medium","file":"internal/store/list.go","line":9,"title":"sql.Rows is never closed","description":"db.Query returns a *sql.Rows that is never closed, holding the underlying database connection open and leaking it from the connection pool, which can exhaust the pool under load.","suggestion":"Add defer rows.Close() immediately after the err != nil check so the connection is returned to the pool on every return path."},{"severity":"medium","file":"internal/store/list.go","line":16,"title":"rows.Scan error is ignored","description":"The error returned by rows.Scan is discarded into the blank identifier, so a malformed or NULL column is silently appended as the zero value instead of being surfaced.","suggestion":"Capture and handle it: if err := rows.Scan(&name); err != nil { return nil, err }."}]} diff --git a/internal/eval/testdata/corpus/waitgroup-misuse-go/README.md b/internal/eval/testdata/corpus/waitgroup-misuse-go/README.md new file mode 100644 index 0000000..09deb46 --- /dev/null +++ b/internal/eval/testdata/corpus/waitgroup-misuse-go/README.md @@ -0,0 +1,3 @@ +# Fixture: waitgroup-misuse-go +**Category:** concurrency · **Language:** Go +wg.Add(1) is called inside the spawned goroutine instead of before the go statement, racing with wg.Wait(). Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/waitgroup-misuse-go/expected.json b/internal/eval/testdata/corpus/waitgroup-misuse-go/expected.json new file mode 100644 index 0000000..2516f8d --- /dev/null +++ b/internal/eval/testdata/corpus/waitgroup-misuse-go/expected.json @@ -0,0 +1,7 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"waitgroup-add-in-goroutine","file":"internal/worker/pool.go","line":10,"line_tol":3,"category":"concurrency","min_severity":"high","summary":"wg.Add(1) runs inside the spawned goroutine and races with wg.Wait(); call wg.Add(1) before the go statement."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/waitgroup-misuse-go/input.diff b/internal/eval/testdata/corpus/waitgroup-misuse-go/input.diff new file mode 100644 index 0000000..63e44f1 --- /dev/null +++ b/internal/eval/testdata/corpus/waitgroup-misuse-go/input.diff @@ -0,0 +1,19 @@ +diff --git a/internal/worker/pool.go b/internal/worker/pool.go +index 1111111..2222222 100644 +--- a/internal/worker/pool.go ++++ b/internal/worker/pool.go +@@ -4,6 +4,14 @@ package worker + import "sync" + + func RunAll(tasks []func()) { +- return ++ var wg sync.WaitGroup ++ for _, task := range tasks { ++ go func(t func()) { ++ wg.Add(1) ++ defer wg.Done() ++ t() ++ }(task) ++ } ++ wg.Wait() + } diff --git a/internal/eval/testdata/corpus/waitgroup-misuse-go/mock_response.json b/internal/eval/testdata/corpus/waitgroup-misuse-go/mock_response.json new file mode 100644 index 0000000..503dc72 --- /dev/null +++ b/internal/eval/testdata/corpus/waitgroup-misuse-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"internal/worker/pool.go","line":10,"title":"wg.Add called inside the goroutine races with Wait","description":"Calling wg.Add(1) from within the spawned goroutine means the counter may not be incremented before wg.Wait() observes it, so Wait can return before tasks finish, and concurrent Add/Wait is itself unsafe.","suggestion":"Move wg.Add(1) to before the go statement so the counter is incremented on the parent goroutine prior to wg.Wait()."}]} diff --git a/internal/eval/testdata/corpus/weak-crypto-md5-go/README.md b/internal/eval/testdata/corpus/weak-crypto-md5-go/README.md new file mode 100644 index 0000000..64ce1bd --- /dev/null +++ b/internal/eval/testdata/corpus/weak-crypto-md5-go/README.md @@ -0,0 +1,3 @@ +# Fixture: weak-crypto-md5-go +**Category:** security · **Language:** Go +HashPassword hashes passwords with `md5.Sum`, a broken and unsalted algorithm unsuitable for credentials. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/weak-crypto-md5-go/expected.json b/internal/eval/testdata/corpus/weak-crypto-md5-go/expected.json new file mode 100644 index 0000000..cf01f84 --- /dev/null +++ b/internal/eval/testdata/corpus/weak-crypto-md5-go/expected.json @@ -0,0 +1,7 @@ +{ + "language": "go", + "expected_findings": [ + {"id":"weak-crypto-md5","file":"internal/auth/hash.go","line":21,"line_tol":3,"category":"security","min_severity":"high","summary":"Passwords are hashed with fast, broken MD5 and no salt; use a memory-hard password hash such as bcrypt, scrypt, or Argon2id instead."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/weak-crypto-md5-go/input.diff b/internal/eval/testdata/corpus/weak-crypto-md5-go/input.diff new file mode 100644 index 0000000..4407257 --- /dev/null +++ b/internal/eval/testdata/corpus/weak-crypto-md5-go/input.diff @@ -0,0 +1,14 @@ +diff --git a/internal/auth/hash.go b/internal/auth/hash.go +index 1111111..2222222 100644 +--- a/internal/auth/hash.go ++++ b/internal/auth/hash.go +@@ -15,3 +15,9 @@ package auth + func existing() { + _ = "noop" + } ++ ++// HashPassword derives a stored hash from a plaintext password. ++func HashPassword(password string) string { ++ sum := md5.Sum([]byte(password)) ++ return hex.EncodeToString(sum[:]) ++} diff --git a/internal/eval/testdata/corpus/weak-crypto-md5-go/mock_response.json b/internal/eval/testdata/corpus/weak-crypto-md5-go/mock_response.json new file mode 100644 index 0000000..05329de --- /dev/null +++ b/internal/eval/testdata/corpus/weak-crypto-md5-go/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"internal/auth/hash.go","line":21,"title":"Weak password hashing with MD5","description":"HashPassword uses md5.Sum, an unsalted and computationally cheap hash that is broken for password storage and trivially brute-forced or reversed via rainbow tables.","suggestion":"Replace md5.Sum with a memory-hard, salted password hashing function such as bcrypt (golang.org/x/crypto/bcrypt), scrypt, or Argon2id."}]} diff --git a/internal/eval/testdata/corpus/xss-innerhtml-js/README.md b/internal/eval/testdata/corpus/xss-innerhtml-js/README.md new file mode 100644 index 0000000..4b8447b --- /dev/null +++ b/internal/eval/testdata/corpus/xss-innerhtml-js/README.md @@ -0,0 +1,3 @@ +# Fixture: xss-innerhtml-js +**Category:** security · **Language:** JavaScript +Untrusted userInput is assigned to element.innerHTML, producing a DOM-based cross-site scripting vulnerability. Hand-authored planted defect (ADR-0018 §1). diff --git a/internal/eval/testdata/corpus/xss-innerhtml-js/expected.json b/internal/eval/testdata/corpus/xss-innerhtml-js/expected.json new file mode 100644 index 0000000..431430e --- /dev/null +++ b/internal/eval/testdata/corpus/xss-innerhtml-js/expected.json @@ -0,0 +1,7 @@ +{ + "language": "javascript", + "expected_findings": [ + {"id":"dom-xss-innerhtml","file":"src/render.js","line":4,"line_tol":3,"category":"security","min_severity":"high","summary":"Untrusted userInput is assigned to element.innerHTML, allowing DOM-based XSS; use textContent or sanitize the input before inserting it."} + ], + "must_stay_silent_on": [] +} diff --git a/internal/eval/testdata/corpus/xss-innerhtml-js/input.diff b/internal/eval/testdata/corpus/xss-innerhtml-js/input.diff new file mode 100644 index 0000000..8d196da --- /dev/null +++ b/internal/eval/testdata/corpus/xss-innerhtml-js/input.diff @@ -0,0 +1,11 @@ +diff --git a/src/render.js b/src/render.js +index 1111111..2222222 100644 +--- a/src/render.js ++++ b/src/render.js +@@ -1,4 +1,8 @@ + export function renderComment(userInput) { + const element = document.getElementById("comment"); ++ // Inject the raw comment text into the DOM. ++ element.innerHTML = userInput; ++ return element; + } diff --git a/internal/eval/testdata/corpus/xss-innerhtml-js/mock_response.json b/internal/eval/testdata/corpus/xss-innerhtml-js/mock_response.json new file mode 100644 index 0000000..9913cce --- /dev/null +++ b/internal/eval/testdata/corpus/xss-innerhtml-js/mock_response.json @@ -0,0 +1 @@ +{"findings":[{"severity":"high","file":"src/render.js","line":4,"title":"DOM-based XSS via innerHTML assignment","description":"userInput is assigned directly to element.innerHTML without escaping or sanitization, so attacker-controlled markup or script-bearing attributes execute in the victim's browser.","suggestion":"Assign the value with element.textContent instead, or sanitize userInput with a trusted library such as DOMPurify before using innerHTML."}]} From e6304657c4fe8543f9a1a5607d5347dd253681d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 19:12:24 +0300 Subject: [PATCH 2/8] Add Goodhart protection for held-out slice, separate scorecards by slice --- CHANGELOG.md | 14 ++++- README.md | 60 ++++++++++-------- internal/eval/eval_test.go | 62 +++++++++++++++++++ internal/eval/fixture.go | 33 ++++++++++ internal/eval/live_test.go | 24 +++++-- internal/eval/score.go | 21 +++++++ .../corpus/bare-except-python/expected.json | 1 + .../corpus/clean-new-test-go/expected.json | 1 + .../corpus/off-by-one-loop-go/expected.json | 1 + .../corpus/path-traversal-go/expected.json | 1 + .../unclosed-resp-body-go/expected.json | 1 + .../corpus/waitgroup-misuse-go/expected.json | 1 + 12 files changed, 185 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce6c421..82865c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,13 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v produces per fixture, tagged `match` / `EXTRA`, to decide whether an EXTRA is a legitimate secondary defect to annotate or genuine noise to leave as a measured false positive. +- **Held-out slice (Goodhart protection).** A fixture can set + `"held_out": true`; ~26% of the corpus (6/23, spanning all defect + categories + a clean control) is held out from any prompt/corpus tuning. + `make eval-live` reports FULL / DEV / HELD-OUT scorecards separately, and + the deterministic `TestHeldOutSlice` fails if the slice is emptied, + drops below 15%, or stops being representative — so the protection + cannot be silently disabled (ADR-0018 §Goodhart). - **`COMMITBRIEF_EVAL_PROVIDER` / `COMMITBRIEF_EVAL_MODEL` overrides.** Select the eval provider and model via env while the API key is read from `~/.commitbrief/config.yml`, so one config benchmarks every @@ -48,9 +55,10 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v 503s during a run. - **README "Measured review quality" table.** First published scorecard across five models (Haiku 4.5 / Sonnet 4.6 / Opus 4.8 / Gemini 2.5 Flash - / GPT-4o, 2026-05-29): recall 0.88–1.00, false-positive rate 0.00–0.40, - precision 0.58–0.78 (conservative floor; recall + FPR are the cleaner - signals). + / GPT-4o, 2026-05-29), each cell reported as `dev · held` (tunable slice + vs held-out generalization slice). Every model recalls the full held-out + slice; precision 0.48–0.84 is a conservative floor (recall + FP-rate are + the cleaner signals). ## [1.3.0] diff --git a/README.md b/README.md index 8432dcd..b8849d7 100644 --- a/README.md +++ b/README.md @@ -43,33 +43,39 @@ CommitBrief ships an eval harness (`make eval`) that scores real review output against a 23-fixture known-answer corpus — 25 planted defects across security, correctness, concurrency, resource-leak, error-handling and performance categories, plus 3 clean controls a good review must stay -silent on. Numbers below are from `make eval-live`, captured 2026-05-29 -(mean of *Runs* live runs each): - -| Model | Recall | False-positive rate | Precision | Runs | -|--------------------|:------:|:-------------------:|:---------:|:----:| -| Claude Haiku 4.5 | 1.00 | 0.00 | 0.68 | 5 | -| Claude Sonnet 4.6 | 1.00 | 0.20 | 0.62 | 3 | -| Claude Opus 4.8 | 0.96 | 0.00 | 0.58 | 3 | -| Gemini 2.5 Flash | 0.97 | 0.27 | 0.76 | 3 | -| OpenAI GPT-4o | 0.88 | 0.40 | 0.78 | 3 | - -- **Recall** — share of planted defects caught. The Claude models catch - essentially all of them (even the cheapest, Haiku, misses none); Gemini - is close behind; GPT-4o misses about one in eight. -- **False-positive rate** — findings landing on a clean-control line, i.e. - flagging a benign change. Haiku and Opus stay silent on every control; - GPT-4o is the noisiest here. -- **Precision** — a *conservative floor*: it counts any finding outside - the answer key as a false positive, but on these small diffs many - "extra" findings are legitimate secondary observations (a second panic, - an ignored error) rather than noise. The terser models (GPT-4o, Gemini) - score higher on precision precisely because they say less — at the cost - of recall. Read recall + false-positive rate as the cleaner signals; - precision is sensitive to how exhaustively the corpus is annotated. - -Reproduce any row locally with `COMMITBRIEF_EVAL_PROVIDER= make -eval-live` (uses the key already in `~/.commitbrief/config.yml`). +silent on. About a quarter of the corpus is a **held-out slice** that +prompt and corpus tuning never inspect, so each cell below reports +`dev · held` — the tunable slice and the held-out generalization slice +separately (ADR-0018). Numbers are from `make eval-live`, captured +2026-05-29 (mean of *Runs* live runs each): + +| Model | Recall (dev · held) | FP-rate (dev · held) | Precision (dev · held) | Runs | +|--------------------|:-------------------:|:--------------------:|:----------------------:|:----:| +| Claude Haiku 4.5 | 1.00 · 1.00 | 0.00 · 0.00 | 0.70 · 0.62 | 5 | +| Claude Sonnet 4.6 | 1.00 · 1.00 | 0.00 · 0.50 | 0.68 · 0.48 | 3 | +| Claude Opus 4.8 | 0.94 · 1.00 | 0.00 · 0.00 | 0.61 · 0.53 | 3 | +| Gemini 2.5 Flash | 0.96 · 1.00 | 0.44 · 0.00 | 0.84 · 0.56 | 3 | +| OpenAI GPT-4o | 0.85 · 1.00 | 0.44 · 0.33 | 0.79 · 0.75 | 3 | + +- **Recall** — share of planted defects caught. Every model recalls the + full held-out slice; the dev dips (Opus, GPT-4o) come from the harder + multi-finding dev fixtures, not from missing whole defects. +- **FP-rate** — findings landing on a clean-control line (flagging a benign + change). Note where the noise lives: Sonnet trips the held-out clean + control; Gemini and GPT-4o trip the dev ones. +- **Precision** — a *conservative floor*: any finding outside the answer + key counts as a false positive, but on these small diffs many "extra" + findings are legitimate secondary observations (a second panic, an + ignored error) rather than noise. The terser models (GPT-4o, Gemini) + score higher precisely because they say less — at the cost of recall. + Read recall + FP-rate as the cleaner signals; precision is sensitive to + how exhaustively the corpus is annotated. + +The two slices are **not difficulty-matched** — the split exists to catch +overfitting in *future* tuning (a dev gain that doesn't carry to held-out), +not for a direct dev-vs-held comparison today. Reproduce any row with +`COMMITBRIEF_EVAL_PROVIDER= make eval-live`, which prints FULL / DEV / +HELD-OUT scorecards (using the key already in `~/.commitbrief/config.yml`). ## Install diff --git a/internal/eval/eval_test.go b/internal/eval/eval_test.go index d2b6ce4..0cc885c 100644 --- a/internal/eval/eval_test.go +++ b/internal/eval/eval_test.go @@ -5,6 +5,7 @@ package eval import ( "context" "path/filepath" + "sort" "testing" "github.com/CommitBrief/commitbrief/internal/provider/mock" @@ -92,6 +93,67 @@ func TestLoadCorpusSorted(t *testing.T) { } } +// TestHeldOutSlice guards the Goodhart protection (ADR-0018 §Goodhart): the +// corpus must keep a non-trivial, representative held-out slice that prompt +// and corpus tuning never inspect. If someone moves every fixture into the +// tunable dev slice (so "the eval always passes"), this fails. +func TestHeldOutSlice(t *testing.T) { + fixtures, err := LoadCorpus(corpusDir()) + if err != nil { + t.Fatalf("LoadCorpus: %v", err) + } + + var held, dev []Fixture + for _, fx := range fixtures { + if fx.HeldOut { + held = append(held, fx) + } else { + dev = append(dev, fx) + } + } + + if len(held) == 0 { + t.Fatal("held-out slice is empty — Goodhart protection is disabled") + } + if len(dev) == 0 { + t.Fatal("dev slice is empty — nothing left to tune against") + } + if len(dev) < len(held) { + t.Errorf("dev slice (%d) should be larger than the held-out slice (%d)", len(dev), len(held)) + } + + frac := float64(len(held)) / float64(len(fixtures)) + if frac < 0.15 || frac > 0.40 { + t.Errorf("held-out fraction %.2f is outside [0.15, 0.40] (%d/%d) — re-balance the slice", frac, len(held), len(fixtures)) + } + + // Representativeness: the held-out slice must span several categories and + // include a clean control, or its generalization estimate is biased. + catSet := map[string]struct{}{} + clean := false + for _, fx := range held { + for _, c := range fx.Categories() { + catSet[c] = struct{}{} + if c == "clean" { + clean = true + } + } + } + cats := make([]string, 0, len(catSet)) + for c := range catSet { + cats = append(cats, c) + } + sort.Strings(cats) + + if len(cats) < 3 { + t.Errorf("held-out slice spans only %d categories %v; needs >=3 for a fair generalization estimate", len(cats), cats) + } + if !clean { + t.Error("held-out slice has no clean control; cannot measure held-out false-positive behavior") + } + t.Logf("held-out slice: %d/%d fixtures (%.0f%%), categories=%v", len(held), len(fixtures), frac*100, cats) +} + func TestScoreMatching(t *testing.T) { fx := Fixture{ Name: "unit", diff --git a/internal/eval/fixture.go b/internal/eval/fixture.go index 15a6f49..2b5816e 100644 --- a/internal/eval/fixture.go +++ b/internal/eval/fixture.go @@ -55,6 +55,14 @@ type answerKey struct { Language string `json:"language"` ExpectedFindings []ExpectedFinding `json:"expected_findings"` MustStaySilentOn []SilenceAnchor `json:"must_stay_silent_on"` + + // HeldOut marks a fixture as part of the held-out slice (ADR-0018 + // §Goodhart). Held-out fixtures must never be inspected while tuning + // the prompt or the corpus; they exist only to give an unbiased + // generalization estimate. If dev-slice recall improves but held-out + // recall does not, the change overfit the corpus rather than improving + // reviews. Default false → the fixture is in the tunable dev slice. + HeldOut bool `json:"held_out,omitempty"` } // Fixture is one known-answer corpus entry: a diff plus its answer key. @@ -68,6 +76,30 @@ type Fixture struct { Expected []ExpectedFinding MustStaySilentOn []SilenceAnchor MockResponse string + + // HeldOut marks the fixture as part of the generalization-only slice; + // see answerKey.HeldOut. + HeldOut bool +} + +// Categories returns the distinct categories a fixture exercises: the +// category of each expected finding, or "clean" for a clean control (no +// expected findings). Used to check that the held-out slice is +// representative rather than concentrated in one category. +func (fx Fixture) Categories() []string { + if len(fx.Expected) == 0 { + return []string{"clean"} + } + seen := map[string]struct{}{} + var out []string + for _, e := range fx.Expected { + if _, ok := seen[e.Category]; ok { + continue + } + seen[e.Category] = struct{}{} + out = append(out, e.Category) + } + return out } // LoadFixture reads a single corpus directory: input.diff + expected.json @@ -110,6 +142,7 @@ func LoadFixture(dir string) (Fixture, error) { Expected: key.ExpectedFindings, MustStaySilentOn: key.MustStaySilentOn, MockResponse: string(mockResp), + HeldOut: key.HeldOut, }, nil } diff --git a/internal/eval/live_test.go b/internal/eval/live_test.go index f697101..ec4fb0f 100644 --- a/internal/eval/live_test.go +++ b/internal/eval/live_test.go @@ -51,16 +51,30 @@ func TestEvalLive(t *testing.T) { t.Fatalf("RunCorpus: %v", err) } - t.Logf("eval scorecard — provider=%s model=%s (%d fixtures)", sc.Provider, sc.Model, len(sc.Fixtures)) + logScorecard(t, "FULL", sc, true) + // Report the dev and held-out slices separately so overfitting is + // visible at a glance (ADR-0018 §Goodhart): a prompt change that lifts + // DEV recall but not HELD-OUT recall has overfit the corpus. + logScorecard(t, "DEV (tunable)", sc.Dev(), false) + logScorecard(t, "HELD-OUT (generalization)", sc.HeldOut(), false) +} + +// logScorecard prints a scorecard's per-fixture lines, totals, and (when +// withCategories) the per-category recall breakdown. +func logScorecard(t *testing.T, label string, sc Scorecard, withCategories bool) { + t.Helper() + t.Logf("── %s — provider=%s model=%s (%d fixtures) ──", label, sc.Provider, sc.Model, len(sc.Fixtures)) for _, f := range sc.Fixtures { - t.Logf(" %-26s TP=%d FN=%d FP=%d precision=%.2f recall=%.2f fpr=%.2f", + t.Logf(" %-26s TP=%d FN=%d FP=%d P=%.2f R=%.2f FPR=%.2f", f.Fixture, f.TruePositives, f.FalseNegatives, f.FalsePositives, f.Precision(), f.Recall(), f.FalsePositiveRate()) } - t.Logf(" TOTAL precision=%.2f recall=%.2f false-positive-rate=%.2f", + t.Logf(" TOTAL precision=%.2f recall=%.2f false-positive-rate=%.2f", sc.Precision(), sc.Recall(), sc.FalsePositiveRate()) - for _, cr := range sc.CategoryRecall() { - t.Logf(" category %-16s recall=%d/%d", cr.Category, cr.Caught, cr.Total) + if withCategories { + for _, cr := range sc.CategoryRecall() { + t.Logf(" category %-16s recall=%d/%d", cr.Category, cr.Caught, cr.Total) + } } } diff --git a/internal/eval/score.go b/internal/eval/score.go index 7493f10..5678fa9 100644 --- a/internal/eval/score.go +++ b/internal/eval/score.go @@ -33,6 +33,7 @@ func severityRank(s render.Severity) int { // against the answer key. type FixtureScore struct { Fixture string + HeldOut bool // mirrors Fixture.HeldOut so a Scorecard can be split TruePositives int // expected findings that were matched FalseNegatives int // expected findings that were missed @@ -127,6 +128,7 @@ func abs(n int) int { func Score(produced []render.Finding, fx Fixture) FixtureScore { score := FixtureScore{ Fixture: fx.Name, + HeldOut: fx.HeldOut, SilenceAnchors: len(fx.MustStaySilentOn), CaughtByCategory: map[string]int{}, MissedByCategory: map[string]int{}, @@ -216,6 +218,25 @@ func (sc Scorecard) FalsePositiveRate() float64 { return float64(sv) / float64(sa) } +// slice returns a Scorecard containing only the fixtures whose HeldOut flag +// equals heldOut, preserving provider/model. +func (sc Scorecard) slice(heldOut bool) Scorecard { + out := Scorecard{Provider: sc.Provider, Model: sc.Model} + for _, f := range sc.Fixtures { + if f.HeldOut == heldOut { + out.Fixtures = append(out.Fixtures, f) + } + } + return out +} + +// Dev returns the tunable slice (fixtures the prompt/corpus may be tuned +// against). HeldOut returns the generalization-only slice (ADR-0018 +// §Goodhart). A change is overfitting when Dev recall rises but HeldOut +// recall does not. +func (sc Scorecard) Dev() Scorecard { return sc.slice(false) } +func (sc Scorecard) HeldOut() Scorecard { return sc.slice(true) } + // CategoryRecall is per-category recall for one expected-finding category. type CategoryRecall struct { Category string diff --git a/internal/eval/testdata/corpus/bare-except-python/expected.json b/internal/eval/testdata/corpus/bare-except-python/expected.json index 844bc0d..d64b82c 100644 --- a/internal/eval/testdata/corpus/bare-except-python/expected.json +++ b/internal/eval/testdata/corpus/bare-except-python/expected.json @@ -1,4 +1,5 @@ { + "held_out": true, "language": "python", "expected_findings": [ {"id":"bare-except-pass","file":"app/task.py","line":9,"line_tol":3,"category":"error-handling","min_severity":"medium","summary":"A bare except: pass swallows every exception silently; catch specific exceptions and log or re-raise."} diff --git a/internal/eval/testdata/corpus/clean-new-test-go/expected.json b/internal/eval/testdata/corpus/clean-new-test-go/expected.json index 6c75d39..d27a453 100644 --- a/internal/eval/testdata/corpus/clean-new-test-go/expected.json +++ b/internal/eval/testdata/corpus/clean-new-test-go/expected.json @@ -1,4 +1,5 @@ { + "held_out": true, "language": "go", "expected_findings": [], "must_stay_silent_on": [ diff --git a/internal/eval/testdata/corpus/off-by-one-loop-go/expected.json b/internal/eval/testdata/corpus/off-by-one-loop-go/expected.json index 3086797..a59abf4 100644 --- a/internal/eval/testdata/corpus/off-by-one-loop-go/expected.json +++ b/internal/eval/testdata/corpus/off-by-one-loop-go/expected.json @@ -1,4 +1,5 @@ { + "held_out": true, "language": "go", "expected_findings": [ {"id":"off-by-one-loop","file":"internal/scan/scan.go","line":5,"line_tol":3,"category":"correctness","min_severity":"high","summary":"The loop condition i <= len(s) reads s[len(s)] on the final iteration, causing an index-out-of-range panic; use i < len(s)."} diff --git a/internal/eval/testdata/corpus/path-traversal-go/expected.json b/internal/eval/testdata/corpus/path-traversal-go/expected.json index 9ec3e8c..94f82f1 100644 --- a/internal/eval/testdata/corpus/path-traversal-go/expected.json +++ b/internal/eval/testdata/corpus/path-traversal-go/expected.json @@ -1,4 +1,5 @@ { + "held_out": true, "language": "go", "expected_findings": [ {"id":"path-traversal","file":"internal/files/serve.go","line":26,"line_tol":3,"category":"security","min_severity":"high","summary":"userPath is joined to baseDir without cleaning or containment checks, so ../ sequences escape baseDir; clean the path and verify it stays within baseDir before reading."} diff --git a/internal/eval/testdata/corpus/unclosed-resp-body-go/expected.json b/internal/eval/testdata/corpus/unclosed-resp-body-go/expected.json index 8b23709..071e7f1 100644 --- a/internal/eval/testdata/corpus/unclosed-resp-body-go/expected.json +++ b/internal/eval/testdata/corpus/unclosed-resp-body-go/expected.json @@ -1,4 +1,5 @@ { + "held_out": true, "language": "go", "expected_findings": [ {"id":"unclosed-resp-body","file":"internal/http/fetch.go","line":8,"line_tol":3,"category":"resource-leak","min_severity":"medium","summary":"resp.Body from http.Get is never closed, leaking the connection; add defer resp.Body.Close() after the error check."} diff --git a/internal/eval/testdata/corpus/waitgroup-misuse-go/expected.json b/internal/eval/testdata/corpus/waitgroup-misuse-go/expected.json index 2516f8d..4fc83a5 100644 --- a/internal/eval/testdata/corpus/waitgroup-misuse-go/expected.json +++ b/internal/eval/testdata/corpus/waitgroup-misuse-go/expected.json @@ -1,4 +1,5 @@ { + "held_out": true, "language": "go", "expected_findings": [ {"id":"waitgroup-add-in-goroutine","file":"internal/worker/pool.go","line":10,"line_tol":3,"category":"concurrency","min_severity":"high","summary":"wg.Add(1) runs inside the spawned goroutine and races with wg.Wait(); call wg.Add(1) before the go statement."} From 673e074aac308afb53645c3ecbcc0feb35e71209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 19:28:14 +0300 Subject: [PATCH 3/8] Add `--show-prompt` flag and `guard.token_preflight` config --- CHANGELOG.md | 10 +++ README.md | 7 +- internal/cli/config.go | 12 ++- internal/cli/prompt_preflight_test.go | 115 ++++++++++++++++++++++++++ internal/cli/review.go | 71 ++++++++++++++++ internal/cli/root.go | 2 + internal/config/config.go | 10 ++- internal/i18n/messages.en.yml | 4 + internal/i18n/messages.tr.yml | 4 + 9 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 internal/cli/prompt_preflight_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 82865c6..ea1c5cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v ## [Unreleased] ### Added +- **`--show-prompt` flag.** Prints the exact system + user prompt that + would be sent to the model, then exits — no provider call, no cache + lookup, no cost. Reflects every prompt-shaping flag (scope, + `--with-context`, `--cli`/`--provider`, `--lang`) and honours `--output`. + A transparency inspector for "what exactly leaves my machine?" (v1.4). +- **`guard.token_preflight` config (opt-in, default false).** When on, a + review whose estimated prompt tokens exceed the provider's context + window prompts for confirmation (TTY) or aborts (non-TTY) before the + paid round-trip, instead of letting the provider reject it with a raw + 400. Off by default — the estimate is a chars/4 heuristic (ADR-0003, v1.4). - **Review-quality eval harness (`make eval`).** A maintainer-facing harness that scores actual review output against a curated known-answer corpus and reports precision / recall / false-positive rate (ADR-0018, diff --git a/README.md b/README.md index b8849d7..0e36435 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,9 @@ CLI-tool-backed providers; mutually exclusive with `--json` / `--markdown`), `--with-context` (CLI providers only — let the host CLI read project files beyond the diff to ground the review; see below), `--allow-secrets` (acknowledge a flagged credential in -the diff), `--no-cost-check` (skip cost preflight), `--color`. See +the diff), `--no-cost-check` (skip cost preflight), +`--show-prompt` (print the exact system + user prompt that would be sent, +then exit — no provider call, no cost; honours `--output`), `--color`. See `commitbrief --help`. ### `--with-context` (CLI providers only) @@ -395,6 +397,9 @@ cache: enabled: true ttl_days: 7 max_size_mb: 0 # 0 = unlimited; >0 evicts oldest entries past the cap +guard: + secret_scan: true # scan diff + rules for credential patterns before sending + token_preflight: false # opt-in: confirm/abort when the prompt overflows the model's context window command: default: "" # args applied to a bare `commitbrief`; empty = `--staged` ``` diff --git a/internal/cli/config.go b/internal/cli/config.go index 74c6c44..b18466f 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -196,8 +196,10 @@ func configFieldGet(cfg *config.Config, path string) (string, error) { switch parts[1] { case "secret_scan": return strconv.FormatBool(cfg.Guard.SecretScan), nil + case "token_preflight": + return strconv.FormatBool(cfg.Guard.TokenPreflight), nil default: - return "", fmt.Errorf("config: unknown field %q in guard (allowed: secret_scan)", parts[1]) + return "", fmt.Errorf("config: unknown field %q in guard (allowed: secret_scan, token_preflight)", parts[1]) } case "cost": @@ -339,8 +341,14 @@ func configFieldSet(cfg *config.Config, path, value string) error { return fmt.Errorf("config: guard.secret_scan: %w", err) } cfg.Guard.SecretScan = b + case "token_preflight": + b, err := parseConfigBool(value) + if err != nil { + return fmt.Errorf("config: guard.token_preflight: %w", err) + } + cfg.Guard.TokenPreflight = b default: - return fmt.Errorf("config: unknown field %q in guard (allowed: secret_scan)", parts[1]) + return fmt.Errorf("config: unknown field %q in guard (allowed: secret_scan, token_preflight)", parts[1]) } return nil diff --git a/internal/cli/prompt_preflight_test.go b/internal/cli/prompt_preflight_test.go new file mode 100644 index 0000000..88b972b --- /dev/null +++ b/internal/cli/prompt_preflight_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package cli + +import ( + "strings" + "testing" + + "github.com/CommitBrief/commitbrief/internal/config" + "github.com/CommitBrief/commitbrief/internal/prompt" + "github.com/CommitBrief/commitbrief/internal/provider/mock" +) + +// ---------- token preflight (guard.token_preflight, ADR-0003) ---------- + +func TestHandleTokenPreflightWithinWindowSilent(t *testing.T) { + resetGlobalFlags(t) + cmd, errBuf := stubCmd(t) + app := stubApp(t, 0) + + prov := mock.New() // default ContextWindow is 100_000 + p := prompt.Prompt{System: "short system", User: "short user"} + + if handleTokenPreflight(cmd, app, prov, p, "mock-model", emptyStdin()) { + t.Error("prompt that fits the window must not abort") + } + if errBuf.Len() > 0 { + t.Errorf("within-window preflight must be silent; got stderr:\n%s", errBuf.String()) + } +} + +func TestHandleTokenPreflightExceedsNonInteractiveAborts(t *testing.T) { + resetGlobalFlags(t) + cmd, errBuf := stubCmd(t) + app := stubApp(t, 0) + + prov := mock.New() + prov.Window = 10 // tiny context window + + // EstimatedTokens is chars/4; a few hundred chars easily clears 10. + p := prompt.Prompt{ + System: strings.Repeat("system prompt content ", 40), + User: strings.Repeat("diff line content ", 40), + } + if !p.ExceedsContext(prov.ContextWindow("mock-model")) { + t.Fatal("test setup: prompt should exceed the tiny window") + } + + // Test stdin is not a TTY → non-interactive abort path. + if !handleTokenPreflight(cmd, app, prov, p, "mock-model", emptyStdin()) { + t.Error("over-window prompt in non-interactive mode must abort") + } + got := errBuf.String() + if !strings.Contains(got, "context window") { + t.Errorf("expected the over-window warning on stderr; got:\n%s", got) + } + if !strings.Contains(got, "non-interactive") { + t.Errorf("expected the non-interactive abort notice; got:\n%s", got) + } +} + +// ---------- guard.token_preflight config round-trip ---------- + +func TestConfigFieldTokenPreflightRoundTrip(t *testing.T) { + cfg := config.Default() + + // Default is opt-in → false. + if got, err := configFieldGet(cfg, "guard.token_preflight"); err != nil || got != "false" { + t.Fatalf("default guard.token_preflight = %q, err=%v; want \"false\"", got, err) + } + + if err := configFieldSet(cfg, "guard.token_preflight", "true"); err != nil { + t.Fatalf("set guard.token_preflight: %v", err) + } + if !cfg.Guard.TokenPreflight { + t.Error("set did not flip the struct field") + } + if got, err := configFieldGet(cfg, "guard.token_preflight"); err != nil || got != "true" { + t.Errorf("after set, guard.token_preflight = %q, err=%v; want \"true\"", got, err) + } +} + +func TestConfigFieldGuardUnknownFieldListsTokenPreflight(t *testing.T) { + cfg := config.Default() + _, err := configFieldGet(cfg, "guard.bogus") + if err == nil || !strings.Contains(err.Error(), "token_preflight") { + t.Errorf("unknown guard field error should list token_preflight; got: %v", err) + } +} + +// ---------- --show-prompt ---------- + +func TestShowPromptEmitsPromptAndSkipsProvider(t *testing.T) { + e := newCLIEnv(t) + + if err := e.run("--staged", "--show-prompt"); err != nil { + t.Fatalf("--show-prompt: %v\nstderr:\n%s", err, e.errOut.String()) + } + out := e.out.String() + + for _, want := range []string{"===== SYSTEM PROMPT =====", "===== USER PROMPT ====="} { + if !strings.Contains(out, want) { + t.Errorf("--show-prompt output missing %q; got:\n%s", want, out) + } + } + // The staged diff body must appear in the user prompt. + if !strings.Contains(out, "func Login") { + t.Errorf("--show-prompt should include the staged diff; got:\n%s", out) + } + // Proof no review ran: the mock provider's canned finding title must + // not appear — --show-prompt exits before any provider call. + if strings.Contains(out, "mock review output") { + t.Errorf("--show-prompt must not invoke the provider; saw mock review output:\n%s", out) + } +} diff --git a/internal/cli/review.go b/internal/cli/review.go index 03114fa..a2337b0 100644 --- a/internal/cli/review.go +++ b/internal/cli/review.go @@ -220,6 +220,18 @@ func runReview(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) er p = prompt.Build(loaded, app.Lang, numberedDiff) } + // --show-prompt: emit the exact system + user prompt that would be sent + // and stop here. No provider call, no cache lookup, no cost — a pure + // transparency inspector that reflects every prompt-shaping flag + // (scope, --with-context, --cli/--provider, --lang) because it prints + // the already-assembled prompt. Placed after the guard/secret scan so a + // secret in what you're about to dump is still surfaced first. + if global.showPrompt { + prog.Finish() + prog.Clear() + return showPromptOutput(cmd, p) + } + model := app.Config.Providers[app.Config.Provider].Model if model == "" { model = prov.DefaultModel() @@ -295,6 +307,20 @@ func runReview(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) er prog.Finish() // Preparing → done + // Token preflight (ADR-0003, opt-in via guard.token_preflight). We're + // past the cache lookup and about to spend tokens; if the estimated + // prompt overflows the provider's context window, catch it here with a + // friendly confirm/abort instead of letting the provider reject it with + // a raw 400. Off by default — the estimate is a chars/4 heuristic and a + // false positive shouldn't block a review nobody asked to guard. + if app.Config.Guard.TokenPreflight { + prog.Pause() + if abort := handleTokenPreflight(cmd, app, prov, p, model, stdinReader); abort { + return errors.New(app.Catalog.T("guard.tokens.aborted_user")) + } + prog.Resume() + } + // Cost preflight (11.5.6): we're past the cache lookup and about to // spend real tokens. If the estimated cost exceeds the configured // threshold, prompt the user (TTY) or abort (non-TTY). The only @@ -481,6 +507,51 @@ func wrapPlainText(content string) string { return plainTextRule + "\n\n" + body + "\n\n" + plainTextRule + "\n\n" } +// showPromptOutput writes the assembled system + user prompt to the output +// sink (stdout, or --output FILE) and is the terminal step of a +// --show-prompt run. The markers are deliberately fixed English (debug- +// grade, like the dry-run columns) so the dump is greppable and stable +// regardless of --lang. No trailing provider call happens after this. +func showPromptOutput(cmd *cobra.Command, p prompt.Prompt) error { + w, closer, err := openOutput(cmd) + if err != nil { + return err + } + defer closer() + if _, err := fmt.Fprintf(w, "===== SYSTEM PROMPT =====\n%s\n\n===== USER PROMPT =====\n%s\n", p.System, p.User); err != nil { + return fmt.Errorf("show-prompt: write: %w", err) + } + return nil +} + +// handleTokenPreflight warns when the estimated prompt overflows the +// provider's context window and asks the user to confirm. Returns true when +// the caller should abort. Only reached when guard.token_preflight is on. +// Mirrors handleCostPreflight: confirm on a TTY, abort non-interactively. +// The estimate is the chars/4 heuristic shared with dry-run; a provider +// that reports a non-positive window (ExceedsContext guards this) never +// triggers the prompt. +func handleTokenPreflight(cmd *cobra.Command, app *appContext, prov provider.Provider, p prompt.Prompt, model string, stdin *bufio.Reader) bool { + window := prov.ContextWindow(model) + if !p.ExceedsContext(window) { + return false + } + + w := cmd.ErrOrStderr() + _, _ = fmt.Fprintln(w, app.Catalog.T("guard.tokens.exceeds", p.EstimatedTokens(), prov.Name(), window)) + if !ui.IsStdinTTY(os.Stdin) { + _, _ = fmt.Fprintln(w, app.Catalog.T("guard.tokens.aborted_non_interactive")) + return true + } + + _, _ = fmt.Fprint(w, app.Catalog.T("guard.tokens.confirm_prompt")) + answer, err := readPromptLine(stdin) + if err != nil || answer == "" { + return true + } + return !ui.AcceptsYes(answer, app.Catalog) +} + // suggestCommitMessage runs a second, free-form provider call (ADR-0015) // to produce a Conventional Commit message for the staged diff and prints // it to stdout after the review. No-op unless --suggest-commit is set. diff --git a/internal/cli/root.go b/internal/cli/root.go index 957a18d..11f6b36 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -40,6 +40,7 @@ type globalFlags struct { color string cli string // --cli ; shorthand that resolves to provider "-cli" withContext bool // --with-context; CLI providers only — let the host CLI read project files beyond the diff (ADR-0017) + showPrompt bool // --show-prompt; print the assembled system+user prompt and exit (no provider call) files []string // global --file (repeatable); path filter applied post-parse dirs []string // global --dir (repeatable); prefix filter applied post-parse genMan string // hidden: --gen-man writes man pages and exits @@ -103,6 +104,7 @@ func newRootCmd() *cobra.Command { flags.StringSliceVarP(&global.dirs, "dir", "d", nil, "review only files under these directories (repeatable); combines with the active scope flag") flags.StringVar(&global.cli, "cli", "", "use a locally-installed CLI tool (claude|gemini|codex) as the review backend; shorthand for --provider -cli") flags.BoolVar(&global.withContext, "with-context", false, "let the CLI provider read project files beyond the diff to ground the review (CLI providers only; the host CLI's agent reads your repo — see --help)") + flags.BoolVar(&global.showPrompt, "show-prompt", false, "print the exact system + user prompt that would be sent, then exit (no provider call, no cost)") cmd.MarkFlagsMutuallyExclusive("provider", "cli") // UC-07: CLI providers emit pre-formatted plain text that goes // straight to the user. --json / --markdown drive structured diff --git a/internal/config/config.go b/internal/config/config.go index a957232..2183cef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,8 +42,16 @@ type CostConfig struct { // v0.8.0 (ADR-0007 follow-up); leaving it true is the safe default, // false disables it entirely for users who pipeline outputs through a // secrets manager and don't want the prompt. +// +// TokenPreflight is an opt-in (default false) guard (ADR-0003): when on, +// a review whose estimated prompt tokens exceed the provider's context +// window prompts for confirmation (TTY) or aborts (non-TTY) before the +// paid round-trip, instead of letting the provider reject it with a raw +// 400. Off by default because the estimate is a chars/4 heuristic and a +// false positive shouldn't block a review nobody asked to guard. type GuardConfig struct { - SecretScan bool `yaml:"secret_scan"` + SecretScan bool `yaml:"secret_scan"` + TokenPreflight bool `yaml:"token_preflight"` } type ProviderConfig struct { diff --git a/internal/i18n/messages.en.yml b/internal/i18n/messages.en.yml index 018cf3d..4ec3bfe 100644 --- a/internal/i18n/messages.en.yml +++ b/internal/i18n/messages.en.yml @@ -24,6 +24,10 @@ cost.estimate: "⚠ Estimated cost: $%.4f (threshold: $%.4f)" cost.confirm_prompt: " Proceed with the review? [y/N]: " cost.aborted_user: "aborted: cost preflight" cost.aborted_non_interactive: "Aborted (non-interactive); pass --no-cost-check or raise cost.warn_threshold_usd to override." +guard.tokens.exceeds: "⚠ Estimated prompt is %d tokens; %s's context window for this model is %d." +guard.tokens.confirm_prompt: " Send it anyway? [y/N]: " +guard.tokens.aborted_user: "aborted: token preflight" +guard.tokens.aborted_non_interactive: "Aborted (non-interactive); set guard.token_preflight=false to override." fail_on.threshold_reached: "%d finding(s) at or above '%s' severity" fail_on.degraded_skipped: "ℹ --fail-on skipped: LLM produced unparseable output, no findings to evaluate." diff --git a/internal/i18n/messages.tr.yml b/internal/i18n/messages.tr.yml index dfb0755..444a586 100644 --- a/internal/i18n/messages.tr.yml +++ b/internal/i18n/messages.tr.yml @@ -24,6 +24,10 @@ cost.estimate: "⚠ Tahmini maliyet: $%.4f (eşik: $%.4f)" cost.confirm_prompt: " Review devam etsin mi? [e/H]: " cost.aborted_user: "iptal edildi: cost preflight" cost.aborted_non_interactive: "İptal edildi (etkileşimsiz); zorlamak için --no-cost-check veya cost.warn_threshold_usd değerini yükseltin." +guard.tokens.exceeds: "⚠ Tahmini prompt %d token; %s sağlayıcısının bu model için bağlam penceresi %d." +guard.tokens.confirm_prompt: " Yine de gönderilsin mi? [e/H]: " +guard.tokens.aborted_user: "iptal edildi: token preflight" +guard.tokens.aborted_non_interactive: "İptal edildi (etkileşimsiz); geçersiz kılmak için guard.token_preflight=false yapın." fail_on.threshold_reached: "'%s' seviyesinde veya üstünde %d bulgu" fail_on.degraded_skipped: "ℹ --fail-on atlandı: LLM çözümlenemeyen çıktı üretti, değerlendirilecek bulgu yok." From 0885cafdcea6794f2a9d0e70c529a93de64f65ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 19:44:02 +0300 Subject: [PATCH 4/8] Update CommitBrief local config and cache paths --- .gitignore | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 1ddcd61..033215a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,5 @@ Thumbs.db .env .env.local -# CommitBrief runtime artifacts when dogfooding the tool inside its own repo -# (.commitbrief/config.yml is per-user; cache/ is local-only) -/.commitbrief/ -reviews/ +# CommitBrief local config and cache +.commitbrief/ From 2bf45910de3bb92a52172d103c6aaecc99bf1612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 19:53:52 +0300 Subject: [PATCH 5/8] Update corpus composition counts and add error handling function --- README.md | 2 +- internal/eval/eval_test.go | 79 ++++++++++++++++++++++++++++++++++++++ internal/eval/runner.go | 49 +++++++++++++++++++---- internal/eval/score.go | 12 +++++- 4 files changed, 131 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0e36435..3eb0104 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ read on your diff before another human (or your future self) sees it. ## Measured review quality CommitBrief ships an eval harness (`make eval`) that scores real review -output against a 23-fixture known-answer corpus — 25 planted defects +output against a 23-fixture known-answer corpus — 23 planted defects across security, correctness, concurrency, resource-leak, error-handling and performance categories, plus 3 clean controls a good review must stay silent on. About a quarter of the corpus is a **held-out slice** that diff --git a/internal/eval/eval_test.go b/internal/eval/eval_test.go index 0cc885c..3eef5f3 100644 --- a/internal/eval/eval_test.go +++ b/internal/eval/eval_test.go @@ -4,6 +4,7 @@ package eval import ( "context" + "errors" "path/filepath" "sort" "testing" @@ -12,6 +13,73 @@ import ( "github.com/CommitBrief/commitbrief/internal/render" ) +// Corpus composition the published docs cite (README "Measured review +// quality", the web Benchmarks section, CHANGELOG, ADR-0018). These are +// locked here so a fixture added or annotated without updating the numbers +// fails CI instead of silently drifting the published figures. +const ( + wantFixtures = 23 + wantPlantedDefects = 23 + wantCleanControls = 3 + wantHeldOut = 6 +) + +// TestCorpusComposition pins the counts the docs advertise (drift guard +// requested in dogfooding review). Update both the consts and the docs +// together when the corpus changes. +func TestCorpusComposition(t *testing.T) { + fixtures, err := LoadCorpus(corpusDir()) + if err != nil { + t.Fatalf("LoadCorpus: %v", err) + } + + defects, clean, held := 0, 0, 0 + for _, fx := range fixtures { + defects += len(fx.Expected) + if len(fx.Expected) == 0 { + clean++ + } + if fx.HeldOut { + held++ + } + } + + if len(fixtures) != wantFixtures { + t.Errorf("fixtures = %d, want %d (update consts + README/web/CHANGELOG)", len(fixtures), wantFixtures) + } + if defects != wantPlantedDefects { + t.Errorf("planted defects = %d, want %d (README/web say %d)", defects, wantPlantedDefects, wantPlantedDefects) + } + if clean != wantCleanControls { + t.Errorf("clean controls = %d, want %d", clean, wantCleanControls) + } + if held != wantHeldOut { + t.Errorf("held-out fixtures = %d, want %d", held, wantHeldOut) + } +} + +func TestIsRetriable(t *testing.T) { + cases := []struct { + msg string + want bool + }{ + {"eval: fixture \"x\": review: gemini: Error 503, Status: UNAVAILABLE", true}, + {"eval: fixture \"x\": review: rate limit exceeded (429)", true}, + {"eval: fixture \"x\": review: context deadline exceeded", true}, + {"eval: fixture \"x\": review: 401 unauthorized: bad api key", false}, + {"eval: fixture \"x\": parse findings: unexpected end of JSON input", false}, + {"eval: fixture \"x\": review: model not found", false}, + } + for _, c := range cases { + if got := isRetriable(errors.New(c.msg)); got != c.want { + t.Errorf("isRetriable(%q) = %v, want %v", c.msg, got, c.want) + } + } + if isRetriable(nil) { + t.Error("isRetriable(nil) must be false") + } +} + func corpusDir() string { return filepath.Join("testdata", "corpus") } // TestEvalMockCorpus is the deterministic tier (ADR-0018 §3): it runs every @@ -225,6 +293,17 @@ func TestScoreMatching(t *testing.T) { } }) + t.Run("multi-line finding spanning a silence anchor counts", func(t *testing.T) { + // Start line 40 is well outside the ±3 window of the anchor at 50, + // but the finding's [40,55] range covers it — must still violate. + got := Score([]render.Finding{ + {Severity: render.SeverityMedium, File: "pkg/b.go", Line: 40, LineEnd: 55}, + }, fx) + if got.SilenceViolations != 1 { + t.Errorf("got silenceViolations=%d, want 1 (range [40,55] covers anchor at 50)", got.SilenceViolations) + } + }) + t.Run("clean diff with silent finding is fully recalled", func(t *testing.T) { clean := Fixture{Name: "clean"} got := Score(nil, clean) diff --git a/internal/eval/runner.go b/internal/eval/runner.go index ef5b155..e58012b 100644 --- a/internal/eval/runner.go +++ b/internal/eval/runner.go @@ -5,6 +5,7 @@ package eval import ( "context" "fmt" + "strings" "time" "github.com/CommitBrief/commitbrief/internal/lang" @@ -63,9 +64,36 @@ func RunFixture(ctx context.Context, p provider.Provider, fx Fixture, model stri // calls RunFixture directly and never hits this path. const corpusAttempts = 3 +// isRetriable reports whether a fixture error is a transient provider +// condition worth retrying. Deterministic failures (unparseable response) +// and anything without a recognized transient signature fail fast — a +// retry would just re-spend on the live tier. Best-effort string match: the +// provider packages wrap heterogeneous SDK errors, so there's no single +// error type to switch on. +func isRetriable(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "parse findings") { + return false // a re-run yields the same unparseable output + } + for _, sig := range []string{ + "503", "502", "500", "429", + "unavailable", "overloaded", "timeout", "deadline", + "temporarily", "rate limit", "try again", + } { + if strings.Contains(msg, sig) { + return true + } + } + return false +} + // RunCorpus runs every fixture through the provider and returns a -// Scorecard. Each fixture is retried up to corpusAttempts times with -// linear backoff; if it still fails the error aborts the run. +// Scorecard. A fixture hitting a transient provider error (isRetriable) is +// retried up to corpusAttempts times with linear backoff; a non-transient +// failure aborts the run after the first attempt. func RunCorpus(ctx context.Context, p provider.Provider, model string, fixtures []Fixture) (Scorecard, error) { sc := Scorecard{Provider: p.Name(), Model: model} if model == "" { @@ -81,12 +109,17 @@ func RunCorpus(ctx context.Context, p provider.Provider, model string, fixtures if err == nil { break } - if attempt < corpusAttempts { - select { - case <-ctx.Done(): - return Scorecard{}, ctx.Err() - case <-time.After(time.Duration(attempt) * 2 * time.Second): - } + // Only retry transient provider hiccups (503/429/timeout). A hard + // failure — auth error, or a deterministically unparseable + // response — won't fix itself, and each live-tier retry is a real + // billable call, so fail fast instead of burning corpusAttempts. + if attempt == corpusAttempts || !isRetriable(err) { + break + } + select { + case <-ctx.Done(): + return Scorecard{}, ctx.Err() + case <-time.After(time.Duration(attempt) * 2 * time.Second): } } if err != nil { diff --git a/internal/eval/score.go b/internal/eval/score.go index 5678fa9..c9ecdbe 100644 --- a/internal/eval/score.go +++ b/internal/eval/score.go @@ -106,12 +106,20 @@ func withinTolerance(f render.Finding, e ExpectedFinding) bool { } // hitsSilence reports whether a produced finding lands on a silence anchor -// (same file, line within the default tolerance). +// (same file, line within the default tolerance). A multi-line finding +// [Line, LineEnd] that spans the anchor counts too — mirroring +// withinTolerance's range-overlap logic, so a finding whose start is far +// from the anchor but whose body covers it is still caught as a violation. func hitsSilence(f render.Finding, a SilenceAnchor) bool { if filepath.ToSlash(f.File) != filepath.ToSlash(a.File) { return false } - return abs(f.Line-a.Line) <= defaultLineTolerance + if abs(f.Line-a.Line) <= defaultLineTolerance { + return true + } + return f.LineEnd > f.Line && + a.Line >= f.Line-defaultLineTolerance && + a.Line <= f.LineEnd+defaultLineTolerance } func abs(n int) int { From d26990fdc4f334517a0a4e24d3aa53dc562e30bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 20:03:43 +0300 Subject: [PATCH 6/8] Fix progress spinner on `TERM=dumb` and refine HTTP code matching --- CHANGELOG.md | 14 ++++++++++++++ internal/eval/eval_test.go | 9 ++++++++- internal/eval/runner.go | 33 ++++++++++++++++++++++++++++++++- internal/ui/color.go | 13 ++++++++++++- internal/ui/ui_test.go | 22 ++++++++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea1c5cb..2ef236a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v ## [Unreleased] +### Fixed +- **Progress spinner floods on `TERM=dumb` terminals.** Dumb terminals + (emacs `M-x shell`, some IDE consoles, bare ptys) report as a TTY but + ignore cursor-movement escapes, so the animated renderer appended a new + line every frame instead of redrawing in place — a stage like "Searching + for changes…" would repeat endlessly. `ColorEnabled` now demotes + `TERM=dumb` to plain mode (one line per stage). Workaround on older + builds: `--color never` or `NO_COLOR=1`. (`--color always` still + overrides for terminals you know handle ANSI.) +- **`isRetriable` (eval harness) matched HTTP codes as bare substrings.** A + non-transient error embedding `500`/`503` in a token count or duration + (e.g. "requested 130500 tokens", "1500ms") was wrongly retried as a + billable live call. Status codes now match on a digit boundary. + ### Added - **`--show-prompt` flag.** Prints the exact system + user prompt that would be sent to the model, then exits — no provider call, no cache diff --git a/internal/eval/eval_test.go b/internal/eval/eval_test.go index 3eef5f3..24e293d 100644 --- a/internal/eval/eval_test.go +++ b/internal/eval/eval_test.go @@ -48,7 +48,7 @@ func TestCorpusComposition(t *testing.T) { t.Errorf("fixtures = %d, want %d (update consts + README/web/CHANGELOG)", len(fixtures), wantFixtures) } if defects != wantPlantedDefects { - t.Errorf("planted defects = %d, want %d (README/web say %d)", defects, wantPlantedDefects, wantPlantedDefects) + t.Errorf("planted defects = %d, want %d (update consts + README/web/CHANGELOG)", defects, wantPlantedDefects) } if clean != wantCleanControls { t.Errorf("clean controls = %d, want %d", clean, wantCleanControls) @@ -69,6 +69,13 @@ func TestIsRetriable(t *testing.T) { {"eval: fixture \"x\": review: 401 unauthorized: bad api key", false}, {"eval: fixture \"x\": parse findings: unexpected end of JSON input", false}, {"eval: fixture \"x\": review: model not found", false}, + // Boundary guard: a context-length error embeds "500" inside a token + // count but is NOT a transient 500 — must not be retried. + {"eval: fixture \"x\": review: maximum context length is 128000 tokens, however you requested 130500 tokens", false}, + // A duration string carrying "1500ms" must not look like a 500. + {"eval: fixture \"x\": review: request took 1500ms then was rejected: invalid request", false}, + // A genuine standalone 503 still retries. + {"eval: fixture \"x\": review: provider returned http 503", true}, } for _, c := range cases { if got := isRetriable(errors.New(c.msg)); got != c.want { diff --git a/internal/eval/runner.go b/internal/eval/runner.go index e58012b..3e399ad 100644 --- a/internal/eval/runner.go +++ b/internal/eval/runner.go @@ -78,8 +78,8 @@ func isRetriable(err error) bool { if strings.Contains(msg, "parse findings") { return false // a re-run yields the same unparseable output } + // Word-shaped transient signals are safe as plain substrings. for _, sig := range []string{ - "503", "502", "500", "429", "unavailable", "overloaded", "timeout", "deadline", "temporarily", "rate limit", "try again", } { @@ -87,9 +87,40 @@ func isRetriable(err error) bool { return true } } + // HTTP status codes must match on a digit boundary, or a token-count / + // byte-size / duration string ("requested 130500 tokens", "1500ms") + // would be mistaken for a 500/503 and retried as a billable call — the + // exact waste this function exists to prevent. + for _, code := range []string{"429", "500", "502", "503"} { + if hasStatusToken(msg, code) { + return true + } + } return false } +// hasStatusToken reports whether code appears in msg bounded by non-digits +// (or string edges) on both sides, so "503" matches "error 503," but not +// "130503". +func hasStatusToken(msg, code string) bool { + for from := 0; ; { + i := strings.Index(msg[from:], code) + if i < 0 { + return false + } + i += from + beforeOK := i == 0 || !isASCIIDigit(msg[i-1]) + end := i + len(code) + afterOK := end >= len(msg) || !isASCIIDigit(msg[end]) + if beforeOK && afterOK { + return true + } + from = i + 1 + } +} + +func isASCIIDigit(b byte) bool { return b >= '0' && b <= '9' } + // RunCorpus runs every fixture through the provider and returns a // Scorecard. A fixture hitting a transient provider error (isRetriable) is // retried up to corpusAttempts times with linear backoff; a non-transient diff --git a/internal/ui/color.go b/internal/ui/color.go index d81e002..4ad5d59 100644 --- a/internal/ui/color.go +++ b/internal/ui/color.go @@ -32,7 +32,15 @@ func ParseColorMode(s string) ColorMode { // // - ColorNever or NO_COLOR/COMMITBRIEF_NO_COLOR env set → false // - ColorAlways → true (caller's choice, even when piped) -// - ColorAuto → true only if w is a TTY +// - ColorAuto → true only if w is a TTY on a terminal that processes ANSI +// +// TERM=dumb is treated as no-color even on a TTY: dumb terminals (emacs +// M-x shell, some IDE consoles, bare ptys) report as terminals but do NOT +// honor cursor-movement escapes. Letting the animated progress renderer +// run there makes every redraw append instead of overwrite — the spinner +// "floods" the screen with one repeated line per frame. Demoting to plain +// (one line per stage) keeps those terminals readable. An explicit +// `--color always` still overrides, for users who know their terminal. func ColorEnabled(w io.Writer, mode ColorMode) bool { if mode == ColorNever { return false @@ -43,6 +51,9 @@ func ColorEnabled(w io.Writer, mode ColorMode) bool { if mode == ColorAlways { return true } + if os.Getenv("TERM") == "dumb" { + return false + } return isTerminal(w) } diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 85e7478..e1162e2 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -44,6 +44,28 @@ func TestColorEnabledAutoOffOnNonTTY(t *testing.T) { } } +func TestColorEnabledDumbTermDemotesAuto(t *testing.T) { + // TERM=dumb must force ColorAuto off (the animated renderer would + // flood a terminal that can't process cursor escapes). On a non-TTY + // buffer auto is already off, so this asserts the guard doesn't error + // and stays off; the meaningful regression it locks is the explicit + // override below. + t.Setenv("TERM", "dumb") + if ColorEnabled(&bytes.Buffer{}, ColorAuto) { + t.Error("TERM=dumb + ColorAuto must be false") + } +} + +func TestColorEnabledAlwaysWinsOverDumbTerm(t *testing.T) { + // An explicit --color always still wins over TERM=dumb: the user has + // said they know their terminal. Confirms the dumb guard sits after + // the ColorAlways short-circuit. + t.Setenv("TERM", "dumb") + if !ColorEnabled(&bytes.Buffer{}, ColorAlways) { + t.Error("--color always must override TERM=dumb") + } +} + func TestColorEnabledRespectsNoColorEnv(t *testing.T) { t.Setenv("NO_COLOR", "1") if ColorEnabled(&bytes.Buffer{}, ColorAlways) { From bb18e960246a178bae56c12b49012ff6957325ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 20:11:27 +0300 Subject: [PATCH 7/8] Fix progress spinner flooding and add live elapsed-time counter --- CHANGELOG.md | 25 +++++++--- internal/ui/progress.go | 106 +++++++++++++++++++++++++++++++++++----- internal/ui/ui_test.go | 62 +++++++++++++++++++++++ 3 files changed, 172 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ef236a..ba07c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,20 +11,29 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v ## [Unreleased] ### Fixed -- **Progress spinner floods on `TERM=dumb` terminals.** Dumb terminals - (emacs `M-x shell`, some IDE consoles, bare ptys) report as a TTY but - ignore cursor-movement escapes, so the animated renderer appended a new - line every frame instead of redrawing in place — a stage like "Searching - for changes…" would repeat endlessly. `ColorEnabled` now demotes - `TERM=dumb` to plain mode (one line per stage). Workaround on older - builds: `--color never` or `NO_COLOR=1`. (`--color always` still - overrides for terminals you know handle ANSI.) +- **Progress spinner floods the screen (repeats a stage line every frame).** + The animated renderer redraws in place by moving the cursor up + `prevLen` *logical* lines, but a stage line longer than the terminal + width wraps to multiple *physical* rows — so the cursor-up under-counted, + the tree marched downward, and the top line (e.g. "Searching for + changes…") was left behind on every frame. The long `--with-context` + security-warning line triggered this on normal-width terminals. Rendered + lines are now clipped to the terminal width so they never wrap, keeping + the in-place redraw exact. Additionally, `TERM=dumb` terminals (emacs + `M-x shell`, some IDE consoles) — which report as a TTY but ignore + cursor-movement escapes — now fall back to plain mode. Workaround on + older builds: `--color never` or `NO_COLOR=1`. - **`isRetriable` (eval harness) matched HTTP codes as bare substrings.** A non-transient error embedding `500`/`503` in a token count or duration (e.g. "requested 130500 tokens", "1500ms") was wrongly retried as a billable live call. Status codes now match on a digit boundary. ### Added +- **Live elapsed-time counter on the active progress stage.** Once a stage + has run for more than a second, the animated tree shows a muted timer + beside it (e.g. `Thinking… 0:42`), so a slow `--with-context` agent call + reads as working rather than frozen. Fast stages stay clean; the timer's + width is reserved out of the line budget so it never causes wrapping. - **`--show-prompt` flag.** Prints the exact system + user prompt that would be sent to the model, then exits — no provider call, no cache lookup, no cost. Reflects every prompt-shaping flag (scope, diff --git a/internal/ui/progress.go b/internal/ui/progress.go index 1c60dab..f358b1d 100644 --- a/internal/ui/progress.go +++ b/internal/ui/progress.go @@ -5,10 +5,13 @@ package ui import ( "fmt" "io" + "os" "strings" "sync" "sync/atomic" "time" + + "golang.org/x/term" ) // Progress is the staged-spinner driving the review pipeline's @@ -35,17 +38,19 @@ import ( // single goroutine driving the pipeline. The animation lives in its // own goroutine that reads the stage list under a mutex. type Progress struct { - w io.Writer - mode progressMode - mu sync.Mutex - stages []stage - stop chan struct{} - done chan struct{} - active atomic.Bool - closed atomic.Bool - paused atomic.Bool - frame int - prevLen int // number of stage lines drawn last render; tracked so we can `cursor up N` on next redraw + w io.Writer + mode progressMode + mu sync.Mutex + stages []stage + stop chan struct{} + done chan struct{} + active atomic.Bool + closed atomic.Bool + paused atomic.Bool + frame int + prevLen int // number of stage lines drawn last render; tracked so we can `cursor up N` on next redraw + width int // terminal columns; 0 = unknown (no truncation). Lines are clipped to this so they never wrap — a wrapped line would occupy more physical rows than prevLen counts, desyncing the cursor-up redraw and flooding the screen. + activeSince time.Time // when the current active stage started; drives the live elapsed-time counter shown on slow stages (e.g. "Thinking… 0:42") so a long agent call reads as working, not frozen. } // stage is one entry in the tree. @@ -87,6 +92,14 @@ func NewProgress(w io.Writer, mode ColorMode, quiet bool) *Progress { p.mode = progressAnimated p.stop = make(chan struct{}) p.done = make(chan struct{}) + // Capture terminal width so redraw can clip lines and never wrap. + // Animated mode implies a TTY writer, so GetSize normally succeeds; + // a failure leaves width 0 (clipping disabled — best-effort). + if f, ok := w.(*os.File); ok { + if cols, _, err := term.GetSize(int(f.Fd())); err == nil { + p.width = cols + } + } default: p.mode = progressPlain } @@ -124,6 +137,7 @@ func (p *Progress) Start(label string) { p.stages[n-1].state = stageDone } p.stages = append(p.stages, stage{label: label, state: stageActive}) + p.activeSince = time.Now() p.mu.Unlock() p.kickAnimation() @@ -342,6 +356,16 @@ func (p *Progress) redraw() { if frame < 0 { frame = 0 // final frame: use the "settled" appearance of the active dot } + // Clip budgets so no rendered line wraps (see Progress.width). A label + // line is "├─ ⏺ " (5 cols) + label; an error line is " " (3 cols) + + // text. A zero budget means "width unknown" → no clipping. + labelBudget, errBudget := 0, 0 + if p.width > 5 { + labelBudget = p.width - 5 + } + if p.width > 3 { + errBudget = p.width - 3 + } for i, st := range p.stages { isLast := i == len(p.stages)-1 connector := "├─ " @@ -362,12 +386,31 @@ func (p *Progress) redraw() { buf.WriteString(stageInfoLeader) } buf.WriteString(" ") - buf.WriteString(st.label) + // Live elapsed-time counter on the active stage (only once it's run + // long enough to be worth showing, so fast stages stay clean). Its + // visible width is reserved out of the label budget so the line + // still never wraps. + budget := labelBudget + var timer string + if st.state == stageActive && p.frame >= 0 { + if d := time.Since(p.activeSince); d >= elapsedShowAfter { + timer = formatElapsed(d) + } + } + if timer != "" && budget > 0 { + if budget -= len([]rune(timer)) + 1; budget < 1 { + budget = 1 + } + } + buf.WriteString(clip(st.label, budget)) + if timer != "" { + buf.WriteString(" " + stageTimerColor + timer + "\033[0m") + } buf.WriteString("\033[K\n") // clear to end-of-line + newline // Error line under a failed stage. if st.state == stageFail && st.err != nil { buf.WriteString(" ") - buf.WriteString(stageFailErrorLine(st.err.Error())) + buf.WriteString(stageFailErrorLine(clip(st.err.Error(), errBudget))) buf.WriteString("\033[K\n") } // Trunk separator between adjacent stages — gives each line @@ -416,6 +459,24 @@ func (p *Progress) clearArea() { const progressFrameInterval = 180 * time.Millisecond +// elapsedShowAfter is how long a stage must run before its live timer +// appears. Below this, fast stages (searching, preparing) stay clean +// instead of flickering "0s". +const elapsedShowAfter = time.Second + +// stageTimerColor is the muted gray (#5b6273, ink-300) the elapsed-time +// counter renders in, so it sits quietly beside the active stage label. +const stageTimerColor = "\033[38;2;91;98;115m" + +// formatElapsed renders a duration as "42s" under a minute, "1:23" beyond. +func formatElapsed(d time.Duration) string { + s := int(d.Seconds()) + if s < 60 { + return fmt.Sprintf("%ds", s) + } + return fmt.Sprintf("%d:%02d", s/60, s%60) +} + // breathing color stops, cards.go palette aligned. 6-frame cycle: // // 0: muted (#3a3f4f) → 1: mid-1 (#4f5563) → 2: mid-2 (#6b7280) @@ -438,6 +499,25 @@ const ( stageInfoLeader = " " // bare space (no glyph for info lines) ) +// clip truncates s to at most max display columns (rune count, a good +// enough proxy here), appending "…" when it cuts. max <= 0 means "no +// limit" (terminal width unknown). Keeping rendered lines within the +// terminal width is what prevents wrapping, which would otherwise desync +// the cursor-up redraw and flood the screen. +func clip(s string, max int) string { + if max <= 0 { + return s + } + r := []rune(s) + if len(r) <= max { + return s + } + if max == 1 { + return "…" + } + return string(r[:max-1]) + "…" +} + func animatedDotColor(frame int) string { return breathingColors[frame%len(breathingColors)] + "⏺\033[0m" } diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index e1162e2..ed52bdd 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -7,6 +7,7 @@ import ( "io" "strings" "testing" + "time" "github.com/CommitBrief/commitbrief/internal/i18n" ) @@ -44,6 +45,67 @@ func TestColorEnabledAutoOffOnNonTTY(t *testing.T) { } } +func TestClip(t *testing.T) { + cases := []struct { + in string + max int + want string + }{ + {"hello", 0, "hello"}, // 0 = no limit (width unknown) + {"hello", -1, "hello"}, // negative = no limit + {"hello", 10, "hello"}, // fits + {"hello", 5, "hello"}, // exact + {"hello world", 5, "hell…"}, + {"abcdef", 3, "ab…"}, + {"hi", 1, "…"}, + } + for _, c := range cases { + if got := clip(c.in, c.max); got != c.want { + t.Errorf("clip(%q, %d) = %q, want %q", c.in, c.max, got, c.want) + } + } +} + +func TestFormatElapsed(t *testing.T) { + cases := map[time.Duration]string{ + 0: "0s", + 3 * time.Second: "3s", + 59 * time.Second: "59s", + 60 * time.Second: "1:00", + 83 * time.Second: "1:23", + (10*60 + 5) * time.Second: "10:05", + } + for d, want := range cases { + if got := formatElapsed(d); got != want { + t.Errorf("formatElapsed(%s) = %q, want %q", d, got, want) + } + } +} + +func TestRedrawShowsElapsedOnActiveStage(t *testing.T) { + // Drive redraw directly (no animation goroutine) so the assertion is + // deterministic. An active stage older than elapsedShowAfter must carry + // its elapsed counter; a freshly-started one must not. + var buf bytes.Buffer + p := &Progress{w: &buf, mode: progressAnimated, frame: 1} + p.stages = []stage{{label: "Thinking...", state: stageActive}} + p.activeSince = time.Now().Add(-83 * time.Second) + p.redraw() + if got := buf.String(); !strings.Contains(got, "Thinking...") || + !strings.Contains(got, "1:23") || !strings.Contains(got, stageTimerColor) { + t.Errorf("active stage redraw should show label + elapsed 1:23; got:\n%q", got) + } + + buf.Reset() + p2 := &Progress{w: &buf, mode: progressAnimated, frame: 1} + p2.stages = []stage{{label: "Searching...", state: stageActive}} + p2.activeSince = time.Now() // just started → below elapsedShowAfter + p2.redraw() + if got := buf.String(); strings.Contains(got, stageTimerColor) { + t.Errorf("just-started stage must not show a timer yet; got:\n%q", got) + } +} + func TestColorEnabledDumbTermDemotesAuto(t *testing.T) { // TERM=dumb must force ColorAuto off (the animated renderer would // flood a terminal that can't process cursor escapes). On a non-TTY From 080cbc2b5cd4dd4c5e74d4cb0fc19b0c835644d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 20:14:33 +0300 Subject: [PATCH 8/8] Update version to 1.4.0 and add --show-prompt option in man pages --- CHANGELOG.md | 2 +- man/commitbrief-cache-clear.1 | 4 ++++ man/commitbrief-cache-inspect.1 | 4 ++++ man/commitbrief-cache-prune.1 | 4 ++++ man/commitbrief-cache-stats.1 | 4 ++++ man/commitbrief-cache.1 | 4 ++++ man/commitbrief-completion-bash.1 | 4 ++++ man/commitbrief-completion-fish.1 | 4 ++++ man/commitbrief-completion-powershell.1 | 4 ++++ man/commitbrief-completion-zsh.1 | 4 ++++ man/commitbrief-completion.1 | 4 ++++ man/commitbrief-compress.1 | 4 ++++ man/commitbrief-config-get.1 | 4 ++++ man/commitbrief-config-set.1 | 4 ++++ man/commitbrief-config-show.1 | 4 ++++ man/commitbrief-config.1 | 4 ++++ man/commitbrief-diff.1 | 4 ++++ man/commitbrief-doctor.1 | 4 ++++ man/commitbrief-dry-run.1 | 4 ++++ man/commitbrief-init.1 | 4 ++++ man/commitbrief-install-hook.1 | 4 ++++ man/commitbrief-list.1 | 4 ++++ man/commitbrief-providers-list.1 | 4 ++++ man/commitbrief-providers-test.1 | 4 ++++ man/commitbrief-providers-use.1 | 4 ++++ man/commitbrief-providers.1 | 4 ++++ man/commitbrief-remote-pr.1 | 4 ++++ man/commitbrief-remote.1 | 4 ++++ man/commitbrief-setup.1 | 4 ++++ man/commitbrief.1 | 4 ++++ 30 files changed, 117 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba07c4f..28273fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v > Tags prior to **v0.4.0** were cut in the private repository and produced no > public artifacts; the first publicly released version is v0.4.0. -## [Unreleased] +## [1.4.0] - 2026-05-29 ### Fixed - **Progress spinner floods the screen (repeats a stage line every frame).** diff --git a/man/commitbrief-cache-clear.1 b/man/commitbrief-cache-clear.1 index 4b3d11a..f4c5828 100644 --- a/man/commitbrief-cache-clear.1 +++ b/man/commitbrief-cache-clear.1 @@ -90,6 +90,10 @@ Remove cached LLM responses for this repo \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-cache-inspect.1 b/man/commitbrief-cache-inspect.1 index 6774b61..4d08492 100644 --- a/man/commitbrief-cache-inspect.1 +++ b/man/commitbrief-cache-inspect.1 @@ -94,6 +94,10 @@ Dumps one cached entry's metadata (provider, model, language, timestamps, freshn \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-cache-prune.1 b/man/commitbrief-cache-prune.1 index ef3e106..97c06b0 100644 --- a/man/commitbrief-cache-prune.1 +++ b/man/commitbrief-cache-prune.1 @@ -98,6 +98,10 @@ Without flags, defaults to \fB--keep-last 500 --older-than 7d\fR\&. Entries surv \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-cache-stats.1 b/man/commitbrief-cache-stats.1 index 528d675..1a40b00 100644 --- a/man/commitbrief-cache-stats.1 +++ b/man/commitbrief-cache-stats.1 @@ -90,6 +90,10 @@ Summarizes the repo-local response cache at /.commitbrief/cache/: total entries \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-cache.1 b/man/commitbrief-cache.1 index 03ae737..695c82d 100644 --- a/man/commitbrief-cache.1 +++ b/man/commitbrief-cache.1 @@ -90,6 +90,10 @@ Inspect and manage the local response cache \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-completion-bash.1 b/man/commitbrief-completion-bash.1 index 3d85c9a..5d54086 100644 --- a/man/commitbrief-completion-bash.1 +++ b/man/commitbrief-completion-bash.1 @@ -121,6 +121,10 @@ You will need to start a new shell for this setup to take effect. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-completion-fish.1 b/man/commitbrief-completion-fish.1 index 1d6af29..6aa0848 100644 --- a/man/commitbrief-completion-fish.1 +++ b/man/commitbrief-completion-fish.1 @@ -111,6 +111,10 @@ You will need to start a new shell for this setup to take effect. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-completion-powershell.1 b/man/commitbrief-completion-powershell.1 index dcaeac0..3c74c5c 100644 --- a/man/commitbrief-completion-powershell.1 +++ b/man/commitbrief-completion-powershell.1 @@ -105,6 +105,10 @@ to your powershell profile. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-completion-zsh.1 b/man/commitbrief-completion-zsh.1 index 7a8f7d0..bf3186a 100644 --- a/man/commitbrief-completion-zsh.1 +++ b/man/commitbrief-completion-zsh.1 @@ -125,6 +125,10 @@ You will need to start a new shell for this setup to take effect. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-completion.1 b/man/commitbrief-completion.1 index 86561e8..4470c1e 100644 --- a/man/commitbrief-completion.1 +++ b/man/commitbrief-completion.1 @@ -91,6 +91,10 @@ See each sub-command's help for details on how to use the generated script. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-compress.1 b/man/commitbrief-compress.1 index 4dc5096..d738548 100644 --- a/man/commitbrief-compress.1 +++ b/man/commitbrief-compress.1 @@ -107,6 +107,10 @@ an ISO timestamp before the file is replaced. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-config-get.1 b/man/commitbrief-config-get.1 index 83b7283..5df9a5e 100644 --- a/man/commitbrief-config-get.1 +++ b/man/commitbrief-config-get.1 @@ -97,6 +97,10 @@ Examples: \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-config-set.1 b/man/commitbrief-config-set.1 index c728ab0..ab49c48 100644 --- a/man/commitbrief-config-set.1 +++ b/man/commitbrief-config-set.1 @@ -105,6 +105,10 @@ By default writes to ~/.commitbrief/config.yml; --local writes to the repo. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-config-show.1 b/man/commitbrief-config-show.1 index af1d4ec..2c5ee61 100644 --- a/man/commitbrief-config-show.1 +++ b/man/commitbrief-config-show.1 @@ -90,6 +90,10 @@ Print the merged configuration (API keys masked) \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-config.1 b/man/commitbrief-config.1 index 61c7c11..6185fcd 100644 --- a/man/commitbrief-config.1 +++ b/man/commitbrief-config.1 @@ -90,6 +90,10 @@ Show, get, or set individual configuration values \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-diff.1 b/man/commitbrief-diff.1 index 607ce5e..4ff4fea 100644 --- a/man/commitbrief-diff.1 +++ b/man/commitbrief-diff.1 @@ -90,6 +90,10 @@ Review the output of \fBgit diff \fR\&. Arguments are forwarded verbatim t \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-doctor.1 b/man/commitbrief-doctor.1 index 5a19c15..8a34b3d 100644 --- a/man/commitbrief-doctor.1 +++ b/man/commitbrief-doctor.1 @@ -98,6 +98,10 @@ run produces no output. \fB--provider\fP="" override configured provider +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-dry-run.1 b/man/commitbrief-dry-run.1 index 7b2ec37..4e6d0ad 100644 --- a/man/commitbrief-dry-run.1 +++ b/man/commitbrief-dry-run.1 @@ -98,6 +98,10 @@ Build prompt and report what would be sent; no API call \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-init.1 b/man/commitbrief-init.1 index 0be0716..b2a41ae 100644 --- a/man/commitbrief-init.1 +++ b/man/commitbrief-init.1 @@ -103,6 +103,10 @@ to overwrite the existing file(s) too. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-install-hook.1 b/man/commitbrief-install-hook.1 index 558a61b..df29790 100644 --- a/man/commitbrief-install-hook.1 +++ b/man/commitbrief-install-hook.1 @@ -122,6 +122,10 @@ comment). Refuses to touch a hook that doesn't carry our marker. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-list.1 b/man/commitbrief-list.1 index 13ab4c9..daa37c0 100644 --- a/man/commitbrief-list.1 +++ b/man/commitbrief-list.1 @@ -90,6 +90,10 @@ Print the command reference \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-providers-list.1 b/man/commitbrief-providers-list.1 index a7d38b5..490bf35 100644 --- a/man/commitbrief-providers-list.1 +++ b/man/commitbrief-providers-list.1 @@ -90,6 +90,10 @@ Show configured providers (active marker, model, API key status) \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-providers-test.1 b/man/commitbrief-providers-test.1 index 59cbb88..87fb059 100644 --- a/man/commitbrief-providers-test.1 +++ b/man/commitbrief-providers-test.1 @@ -90,6 +90,10 @@ Ping a configured provider to verify the API key and reachability \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-providers-use.1 b/man/commitbrief-providers-use.1 index 1264b58..068483a 100644 --- a/man/commitbrief-providers-use.1 +++ b/man/commitbrief-providers-use.1 @@ -94,6 +94,10 @@ Switch the active default provider (no API keys changed) \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-providers.1 b/man/commitbrief-providers.1 index 8ed94f6..3a1235a 100644 --- a/man/commitbrief-providers.1 +++ b/man/commitbrief-providers.1 @@ -90,6 +90,10 @@ List, switch, and test configured LLM providers \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-remote-pr.1 b/man/commitbrief-remote-pr.1 index 1130b5a..7960a46 100644 --- a/man/commitbrief-remote-pr.1 +++ b/man/commitbrief-remote-pr.1 @@ -105,6 +105,10 @@ or a full URL. See ADR-0016. \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-remote.1 b/man/commitbrief-remote.1 index f1a4e1a..889e11f 100644 --- a/man/commitbrief-remote.1 +++ b/man/commitbrief-remote.1 @@ -94,6 +94,10 @@ they don't produce structured findings). \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief-setup.1 b/man/commitbrief-setup.1 index 7e6c313..5c1590a 100644 --- a/man/commitbrief-setup.1 +++ b/man/commitbrief-setup.1 @@ -94,6 +94,10 @@ Interactive provider + API key wizard \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB--suggest-commit\fP[=false] after the review, suggest a Conventional Commit message for the staged diff (requires --staged; prints to stdout; not with --json/--markdown/--output) diff --git a/man/commitbrief.1 b/man/commitbrief.1 index cc4bddf..6fe265c 100644 --- a/man/commitbrief.1 +++ b/man/commitbrief.1 @@ -89,6 +89,10 @@ Local LLM-powered code review of git diffs \fB-q\fP, \fB--quiet\fP[=false] suppress info messages on stderr +.PP +\fB--show-prompt\fP[=false] + print the exact system + user prompt that would be sent, then exit (no provider call, no cost) + .PP \fB-s\fP, \fB--staged\fP[=false] review staged changes (default)