- name
- project-quality-gates
- description
- Catalog of every automated quality gate ai-viewer enforces — commands, thresholds, and what to do when a gate fails. Use before claiming any work done, before any commit, when adding a new gate, or when investigating a CI failure. The runtime companion to .agents/sow/specs/quality-gates.md.
# Quality Gates
## Operating Rule
Every gate listed here runs in CI on every push — except the benchmark regression gate (`scripts/check-bench.sh`), which is a local/workstation gate (its baseline is not comparable to CI-runner hardware; CI runs the bench compile-smoke + the gate's hardware-independent self-test). The assistant runs them all locally before reporting work done. If a gate fails: fix the root cause. **Never weaken a gate to make it pass.** Lowering a threshold or marking a test skipped to land a PR is a contract breach.
**nolint policy** (two cases, both require a reason — never a bare `//nolint`):
- **Deferred-fix suppression** (the finding is real but fixing it now is out of scope): forbidden unless the active SOW justifies it AND the directive links the tracking issue/SOW — `//nolint:rule // <reason>; see SOW-XXXX`.
- **Permanent-architectural suppression** (the finding is a verified false-positive for a deliberate, durable pattern — e.g. `nilerr` on a `return nil` that intentionally lets a stricter downstream check win, or on poll-loop rotation-tolerance): allowed with a `//nolint:rule // <reason>` stating *why the pattern is correct*. No tracking link is required because there is nothing to fix later. The reason must be specific; "false positive" alone is not enough. Prefer a config-level exclusion (with a YAML rationale comment) when a whole file or rule class is involved. Bare/unexplained `//nolint` is always a contract breach.
When a new pattern emerges that warrants enforcement, add it here AND to `.agents/sow/specs/quality-gates.md` AND to CI in the same commit.
## Gate Catalog
### Go — Module Tidiness
```bash
go mod tidy
git diff --exit-code go.mod go.sum
```
Threshold: zero diffs. `scripts/lint.sh` mirrors the CI `lint` job and runs
this before formatter/linter/security checks. A stale module graph is a local
gate failure, not a CI-only surprise.
### Go — Formatting
```bash
gofmt -l <tracked Go files> # zero unformatted tracked files expected
goimports -l <tracked Go files> # zero unformatted tracked files expected
```
Threshold: zero diffs. `scripts/lint.sh` runs standalone `gofmt` and
`goimports@latest` exactly like CI before `golangci-lint`; both surfaces derive
the input set from `git ls-files -z -- '*.go'`, so ignored/untracked local files
such as `frontend/node_modules/**` cannot cause local-only formatter failures.
An empty tracked Go file list is fail-closed. Golangci's formatters are defense
in depth. Auto-fix locally with `gofmt -w` and `goimports -w` before commit.
`scripts/test/lint-test.sh` is the hermetic self-test for the tracked-file
formatter contract; it plants ignored/untracked malformed Go and must fail if
the formatter gate regresses to walking `.`.
### Go — Vet
```bash
go vet ./...
```
Threshold: zero warnings.
### Go — Lint (golangci-lint)
```bash
golangci-lint run --timeout=5m # umbrella: also enforces fmt + vet
```
golangci-lint is **v2** (`.golangci.yml` has `version: "2"`). `golangci-lint run` is the umbrella gate — with the formatters enabled it enforces Go — Format, and the `govet` linter covers Go — Vet, so a clean `run` means fmt+vet+lint all pass. (In golangci-lint v2, `golangci-lint run` REPORTS formatter violations — gofmt/goimports/gofumpt — as failures; `golangci-lint fmt` is the separate auto-fix path. `run` enforces formatting; it is not merely a linter-only pass. Note: `--enable-only <formatter>` is rejected because formatters are configured under `formatters:`, not enabled as linters — this does not mean `run` skips them.) Run it locally via `./scripts/lint.sh` after the module-tidiness, standalone `gofmt`, standalone `goimports@latest`, and `go vet` checks that mirror the CI `lint` job. The version is pinned in `.golangci-lint-version` (single source for CI + `scripts/lint.sh`); CI runs it via `golangci/golangci-lint-action` at that pinned version, and local `scripts/lint.sh` fails when the installed binary cannot be parsed or differs from the pin.
`.golangci.yml` enables linters: `errcheck`, `govet`, `ineffassign`, `staticcheck`, `unused`, `errorlint`, `gocritic`, `revive`, `gocyclo`, `misspell`, `nilerr`, `prealloc`, `unconvert`, `unparam`, `whitespace`, `bodyclose`, `noctx`; formatters: `gofmt`, `goimports`, `gofumpt`. NOT enabled: `gosimple` (v2 merged it into `staticcheck`) and `gosec` (runs standalone — see Go — Security — to avoid duplicate analysis).
Tuning (rationale in the `.golangci.yml` inline comments): `gocyclo` uses `min-complexity: 25` (the stream parsers/scanners/tailers and the SOW-0007 ingest event loop legitimately sit at 16–24; 15 is false-positive churn on reviewed hot-path code). `_test.go` is excluded from the style/complexity linters (`gocyclo`, `noctx`, `unparam`, `prealloc`, `revive`, `gocritic`) but NOT from the bug-finders. `frontend/node_modules` is path-excluded (a transitive npm dep ships a non-project Go file).
Threshold: zero warnings.
### Go — Security
```bash
gosec -severity medium -confidence medium ./...
govulncheck ./...
```
Threshold: zero high/critical findings from gosec; zero known vulnerabilities from govulncheck. Govulncheck runs in CI on a schedule plus every push so newly-disclosed CVEs surface fast.
**GOTCHA — standalone pinned `gosec` ≠ golangci's bundled gosec.** CI's "Go — Security" step and `scripts/lint.sh` both install and run a **pinned standalone** `gosec@v2.26.1`; that version ships newer analyzers (e.g. **G705** XSS-taint) that golangci-lint's older *bundled* gosec does not have (which is why `gosec` is NOT enabled as a golangci linter — it runs standalone). So `golangci-lint run` returning 0 does NOT mean `gosec` passes — they are different gates. Always run the standalone `gosec -severity medium -confidence medium ./...` locally (install the pin: `go install github.com/securego/gosec/v2/cmd/gosec@v2.26.1`, or just run `./scripts/lint.sh`, which installs/verifies the pinned gosec + runs golangci + govulncheck) AND `govulncheck ./...` before pushing — not just `gofmt`/`vet`/`golangci-lint`/`test`/`build`. (govulncheck exits 0 when a required-but-uncalled module has a CVE — "your code doesn't appear to call" — that is a pass.)
**Suppressions:** gosec honors the **hash** form `// #nosec G705 -- justification` (NOT `//nosec`). Per the Operating Rule, any `#nosec`/`//nolint` MUST be a verified false positive AND justified in the active SOW. Prefer restructuring to the gosec-clean pattern of a sibling handler over suppressing; suppress only when the finding is provably impossible (e.g. body is trusted embedded build output served on an exact-match route).
## Verifying CI Before PR Merge (do not trust `--watch` exit code)
When GA or explicit-PR flow is active, branch protection may still have no
required status checks before SOW-0013's post-merge setup runs. After setup, the
required contexts are recorded in `.github/workflows-checks.yaml` and
`docs/setup.md`. In both states, never gate a merge on
`gh pr checks <pr> --watch` alone: it fails only on required checks, so a
non-required red row can still leave `--watch` green. Before `gh pr merge`, run
`gh pr checks <pr>` and confirm EVERY row reads `pass` (no `fail`/`pending`).
### Go — Tests
```bash
go test -race -count=1 ./...
```
Threshold: all pass with race detector enabled. `-count=1` defeats test cache (CI must run fresh).
### Go — Coverage
```bash
go test -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out | tail -1
```
Thresholds:
- **Statement** coverage (Go has no native branch coverage; branch is deferred). Gated set = every `internal/*` package (gated **iff** the import path contains `/internal/` and not `/cmd/`): each ≥ 80% AND their aggregate ≥ 80%. Excluded (reported, not gated): `/cmd/` (binaries + nested dev tools) and any non-`internal/` path, e.g. vendored Go a frontend npm dep ships under `frontend/node_modules/`.
- New code in the PR: ≥ 90% lines — **deferred (SOW-0036)**, not yet enforced (see Enforcement note below).
Enforcement: `scripts/check-coverage.sh coverage.out` fails if any gated (`internal/*`) package or the gated aggregate is < 80% statements; it runs as a build-failing CI step (the `test` job) and as the local pre-commit gate, with synthetic-fixture self-tests (`scripts/test/check-coverage-test.sh`). New-code-in-PR ≥ 90% is deferred to a follow-up SOW (diff↔coverage intersector).
### Go — Fuzzing
```bash
go test -run='^Fuzz' ./internal/adapters/... # per-push: seed corpus, deterministic
go test -run='^$' -fuzz='^FuzzParseSnapshot$' -fuzztime=5m ./internal/adapters/aiagent_v2/ # nightly: explore (one target per pkg)
```
Every adapter parser exposes at least one `FuzzXxx` target (10 across the 5 adapter packages). `internal/canonical` has **no** fuzz target — it owns no parsers/decoders. CI per-push runs the seed corpus deterministically (`-run='^Fuzz'`, PR-blocking); `fuzz-nightly.yml` runs `-fuzz -fuzztime=5m` per target (non-blocking, uploads any crash reproducer). Commit a found reproducer under `testdata/fuzz/<Target>/` to make it a deterministic per-push regression. Auto-filing a GitHub issue on crash is a deferred follow-up.
Threshold: zero crashes per run. A seed-corpus crash blocks merge.
### Go — Ingestion Parity
```bash
scripts/test/check-ingestion-parity-test.sh # hermetic wrapper self-test
scripts/check-ingestion-parity.sh --fixtures # source-vs-canonical parity fixture gate
go test -count=1 ./internal/parity ./internal/ingest ./cmd/ai-viewer-ingest -run 'Parity|Source|Manifest|Diff|Canonical|Matrix|CheckParity'
go test -run='^Fuzz' ./internal/parity # deterministic parity fuzz seed corpus
```
SOW-0097 gate. The wrapper is the named local/CI fixture gate over the parity
source extractors, canonical extractor, diff engine, ingest parity tests,
matrix drift tests, `ai-viewer-ingest check-parity` CLI tests, and
`internal/parity` fuzz seeds. It exists so future agents run one durable gate
instead of remembering scattered package-level commands.
Threshold: exit zero only when every fixture parity check is clean. Missing
wrapper/script, invalid wrapper mode, missing required parity package, malformed
fixture, source extractor error, canonical extractor error, source/canonical
mismatch, unverifiable artifact, matrix drift or open matrix gap, parity fuzz
seed crash, or failing CLI mismatch test is a gate failure.
Live local diagnostic entry point:
```bash
ai-viewer-ingest check-parity --db /opt/ai-viewer/data/index.db --source <adapter:path> ...
```
The live full gate is workstation-only because it reads private local sources.
Do not publish live manifests or raw payload bodies. Until SOW-0097 closes, the
advanced live controls (streaming manifests, snapshot mutation detection,
resume, sample mode, timeout, memory cap) remain open requirements, not a reason
to claim full live parity done.
### Go — Benchmarks
```bash
scripts/check-bench.sh # count=6, -cpu=1, -p=1, benchstat vs bench/baseline.txt, > 20% sec/op gate
scripts/test/check-bench-test.sh # hardware-independent self-test of the gate's benchstat parser
```
Marked benchmarks (`func BenchmarkXxx`) exist for the 11 performance-critical paths: ai-agent v2 adapter `Scan`, ai-agent v2 adapter `Tail`, claude-code adapter `Scan`, claude-code adapter `Tail`, Codex adapter `Scan`, Codex adapter `Tail`, Opencode adapter `Scan`, Opencode adapter `Tail`, SQLite batch insert, REST query path, SSE fanout. (No canonical encode/decode benchmark — canonical events are constructed directly, never serialized.)
Threshold: a statistically-significant **> 20% sec/op** regression for any individual benchmark fails `scripts/check-bench.sh` (the `geomean` aggregate + custom `ReportMetric` values are not gated; benchstat's `~` neutralizes noisy benchmarks). In real local/workstation mode, the same benchmark name must regress on the script's second benchmark attempt before the gate exits red; a first-run-only regression, or disjoint first/second-attempt regression sets, is reported as local measurement noise and exits green. The retry is not a threshold change: both attempts compare against the same checked-in `bench/baseline.txt` with the same `-count=6`, `-cpu=1`, `-p=1`, and parser, while compare-file mode remains single-pass so the self-test still proves a real >20% regression exits non-zero. The script runs the serial hot-path suite with `go test -p=1 -run=^$ -bench=. -benchmem -count=6 -cpu=1`; do not remove `-cpu=1` or `-p=1` unless a later explicit SOW proves the suite should measure multi-P or cross-package scheduler behavior. `-cpu=1` pins the benchmark binary's Go benchmark CPU list; `-p=1` serializes package benchmark binaries so the gate does not create cross-package contention. Real benchmark attempts fail closed with exit 2 before sampling when the 1-minute host load is at or above `0.50 * effective_GOMAXPROCS`, when `/proc/loadavg` is unavailable or malformed, or when the effective CPU divisor cannot be resolved. Passing and failing runs emit compact diagnostics (Go version, effective `GOMAXPROCS`, benchmark CPU setting, package parallelism, busy-host threshold, package list, baseline/current paths, and load averages) without process command lines. It is a **local/workstation** gate — `bench/baseline.txt` is workstation-measured (carries benchmark-code provenance: an implementing commit SHA when available, or a same-commit `git blame` note when benchmark code and baseline land together, plus the exact command and `goos/goarch/pkg/cpu` config lines) and is not comparable to GitHub-runner hardware, so CI runs only the bench compile-smoke + the gate self-test, not the regression gate itself. Baseline refresh requires an explicit SOW (no auto-update); SOW-0058 owns the original `-cpu=1` baseline refresh.
Fail-closed benchmark-gate behavior is part of the contract: missing, empty, or
benchmarkless baseline, missing/empty current output, failed `go test`, failed
`benchstat`, dropped/renamed baseline benchmark, disjoint benchmark config
groups, busy-host preflight failure, unavailable/malformed loadavg source, and
unavailable effective CPU divisor all exit non-zero. The self-test must
dynamically exercise both compare-file parser behavior and real-mode
retry/error behavior with hermetic fakes, including disjoint first/second-attempt
regression sets and busy-host preflight cases; static string assertions alone
are not enough for the retry path.
CI's `Require benchmarks` compile-smoke presence check must normalize both
suffixed Go benchmark rows (`BenchmarkName-N`) and unsuffixed `-cpu=1` rows
(`BenchmarkName`) from `bench/baseline.txt`, then compare those logical names
against the implemented `func BenchmarkXxx` set. The benchmark self-test must
assert this parser contract, because a workstation baseline refresh can
otherwise pass local gates and fail only in CI.
Gated benchmarks must isolate the intended hot path from helper-goroutine
scheduler noise. For serial production paths (for example SSE `Hub.Deliver`
fanout), use deterministic buffering/pre-seeding in the fixture instead of
background helper goroutines inside the timed environment; otherwise the local
workstation gate can fail on unchanged code under ordinary desktop/VM load.
For very small serial hot paths, a benchmark operation may run a deterministic
fixed batch to amortize timer/scheduler noise, but it must keep reporting the
per-hot-path-unit metric (for example deliveries/sec) for human interpretation.
### Go — Race + Stress
```bash
go test -race -count=10 ./... # local pre-commit on concurrency-touching changes
```
For changes to ingest pipeline, SSE hub, or anything with channels/goroutines: run `scripts/test.sh --stress 10` locally; CI runs `-count=1` per push (the `test` job) and `-count=10` race stress nightly (`race-stress-nightly.yml`).
### Frontend — Lint
```bash
cd frontend && npm run lint # the `lint` script bakes in --max-warnings=0
```
ESLint flat config with `@typescript-eslint`, `eslint-plugin-react`, `eslint-plugin-react-hooks`, `eslint-plugin-jsx-a11y`, `eslint-plugin-import`. Threshold: zero warnings (the `lint` npm script owns `--max-warnings=0`; `scripts/lint.sh` and CI run plain `npm run lint`).
### Frontend — Type Check
```bash
cd frontend && npm run typecheck # invokes `tsc --noEmit`
```
`tsconfig.json` enforces: `strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `noUnusedLocals`, `noUnusedParameters`.
Threshold: zero errors.
### Frontend — Unit/Component Tests
```bash
cd frontend && npm run test -- --run --coverage # REAL gate (run by scripts/test.sh after the Go suite, and in CI)
cd frontend && npm run check:coverage-config # verify the REAL per-dir floors (RAW-canonical shape + non-vacuity + BIDIRECTIONAL lockstep + disk-completeness + whole-dir include shape)
cd frontend && npm run check:coverage-thresholds:selftest # hermetic gate-MECHANISM self-test (throwaway fixture)
```
Vitest + React Testing Library. Threshold: all pass; global aggregate floor (≥ 80% lines/stmts/funcs, ≥ 75% branches) **plus a per-directory ≥ 80% lines floor** for every measured dir under `src/components/` and `src/pages/`.
View on GitHub