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.
nilerron areturn nilthat 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//nolintis 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
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
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
go vet ./...
Threshold: zero warnings.
Go — Lint (golangci-lint)
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
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
go test -race -count=1 ./...
Threshold: all pass with race detector enabled. -count=1 defeats test cache (CI must run fresh).
Go — Coverage
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 underfrontend/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
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
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:
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
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
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
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
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
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/.
- Per-dir mechanism = Vitest NATIVE glob-keyed
coverage.thresholds(SOW-0012; Vitest ≥ 4, verified on 4.1.7) —vitest.config.tshas'src/components/<Dir>/**': { lines: 80 }per measured dir. A group below 80% lines fails the run (exit 1:ERROR: Coverage for lines (NN%) does not meet "<glob>" threshold (80%)). NO wrapper script; a sharedPER_DIR_LINESconst ties the global + per-dir floors together. - Shared lists (F3):
PER_DIR_GLOBS,COVERAGE_INCLUDE,COVERAGE_EXCLUDED,PER_DIR_LINESlive infrontend/vitest.coverage.mjs;vitest.config.tsimports the measuring lists andcheck-coverage-config.mjsimports all of them (no second copy to drift)..mjs(no TS loader for the Node verifier) + a co-locatedvitest.coverage.d.mtsfor the config's typecheck. - Gotcha — empty glob group vacuously PASSES: an unmatched glob's lines pct is
"Unknown"and"Unknown" < 80isfalse. So add a per-dir key ONLY for a dir incoverage.include, AND every measured component/page dir MUST have a per-dir key — the gated set and the measured set are EQUAL (gatedDirs === measuredDirs), enforced both ways. When you implement+test a new component/page dir, add it to BOTHCOVERAGE_INCLUDEANDPER_DIR_GLOBSinvitest.coverage.mjs, else the per-dir gate silently skips it (or, the reverse, a floor for an unmeasured/excluded dir is a no-op that can never fire). Dirs/files that are intentionally NOT measured go inCOVERAGE_EXCLUDED(invitest.coverage.mjs) with a per-entry rationale:Layout+StatCardare REAL components whose dedicated Vitest unit coverage is deferred (Playwright exercises them today) — NOT placeholders; theAgents/Models/ToolsPhase-3 stubs are bare<ComingSoon/>wrappers;NotFound.tsxis the trivial 404. (ComingSoon.tsxitself IS measured — it has a unit test.)npm run check:coverage-configcatches THREE things on the REAL lists, failing closed and naming the offender: (1) non-vacuity — a per-dir glob matching zero source files; (2) BIDIRECTIONAL lockstep — a measured dir with no per-dir floor (measured ⊄ gated) AND a per-dir floor whose dir is not measured (excluded or absent from include; gated ⊄ measured — a no-op threshold group, R8-2); (3) disk-completeness — a source dir/flat-file undersrc/components//src/pages/in NEITHERCOVERAGE_INCLUDEnorCOVERAGE_EXCLUDED(so shipped source cannot silently escape both coverage and the verifier). It also fails closed on a broad whole-root include glob (e.g.src/pages/**/*.{ts,tsx}), on a per-dir include that is not the EXACT canonical whole-dir shape<root>/<Dir>/**/*.{ts,tsx}(a bare dir, an extension-narrowed**/*.tsx/**/*.ts, a narrow filename, or a deeper subpath — all reject, so a sibling source cannot escape a "measured" dir), on aPER_DIR_GLOBSentry that is not the EXACT canonical threshold shape<root>/<Dir>/**(a bare dir likesrc/components/Foo, or a deeper/narrower glob — a Vitest threshold KEY must end in/**to match file paths, so a bare-dir key matches nothing and that floor vacuously passes; only a canonical entry contributes its dir to the gated set), and on a malformed./..path segment. Both per-dir-root shape checks compare the RAW string, not a normalized one (R8-1):vitest.config.tshands the RAW strings to Vitest (PER_DIR_GLOBS→ picomatch threshold keys vs cleanrelative(root,file)paths;COVERAGE_INCLUDE→ tinyglobby selector), so an entry that is canonical only AFTER normalization (a leading./, a repeated//, or a trailing/) is REJECTED — a///trailing-/key matches nothing (vacuous floor) and the./form is version-fragile. So write per-dir-root entries EXACTLY canonical:src/components/<Dir>/**andsrc/components/<Dir>/**/*.{ts,tsx}— no./, no//, no trailing/. - HTML report at
frontend/coverage/(CI artifactfrontend-coverage-<run_id>);jsonreporter emitscoverage/coverage-final.json;lcovreporter emitscoverage/lcov.infofor Codacy coverage upload. - Two guards, do not conflate: the MECHANISM self-test (
scripts/check-coverage-thresholds.test.sh) proves Vitest's glob-keyed threshold still fails closed on a throwaway 50%-lines fixture — it does NOT read the real config. The real-config verifier (scripts/check-coverage-config.mjs) enforces the real lists' non-vacuity + BIDIRECTIONAL lockstep (gatedDirs === measuredDirs) + disk-completeness + RAW-canonical per-dir-root shapes (per-dir include exactly<root>/<Dir>/**/*.{ts,tsx}, per-dir floor exactly<root>/<Dir>/**, both as the RAW string — no./////trailing-/laundering); its OWN decision logic has a hermetic self-test (scripts/check-coverage-config.test.sh,npm run check:coverage-config:selftest). All are dedicated CIfrontendsteps and run inscripts/lint.sh. - A dir under the floor is a finding to fix with tests — never lower the threshold.
Frontend — E2E
cd frontend && npm run e2e
Playwright headless. One scenario per primary user flow at minimum: sessions list filter, session detail load, sources panel, real-time update via SSE, theme toggle.
Default bind is 127.0.0.1:7710. For local workstations where the installed
ai-viewer service already owns that port, set AI_VIEWER_E2E_PORT=<free-port>;
scripts/gates.sh auto-selects a fallback for the aggregate gate. Keep
reuseExistingServer: false: E2E must test the freshly seeded server, never an
arbitrary existing process or live DB.
Threshold: all pass. Flaky tests are quarantined into a separate suite with a linked SOW to fix; never marked test.skip.
Frontend — Accessibility
cd frontend && npm run e2e:a11y # Playwright + @axe-core/playwright
axe-core runs on every Playwright route. Threshold: zero serious or critical violations.
Frontend — Bundle Size
cd frontend && npm run build
npm run check:bundle-size # node scripts/check-bundle-size.js (defaults to ./dist)
npm run check:bundle-size:selftest # synthetic-fixture self-test of the gate
Enforced gate (SOW-0012), not a report. Classification is manifest-driven: vite.config.ts sets build.manifest: true; the gate reads dist/.vite/manifest.json and gates by Vite's ManifestChunk flags — isEntry chunks are the main chunk (≤ 500 KB gz), isDynamicEntry chunks are per-route lazy chunks (≤ 200 KB gz each). Each MAIN/LAZY entry's budget is its transitive static-import CLOSURE — the gz sum of the entry's .file PLUS the transitive closure of its static imports (deduped within one entry's closure; dynamicImports are separately-budgeted lazy chunks and are NOT followed). So a Rollup-split shared chunk (neither isEntry nor isDynamicEntry, reachable only via an entry's imports[]) IS gated under the entry that pulls it in (the F1 fail-open this closed). Only JS that is neither classified nor inside any gated entry's closure (e.g. a ?worker bundle like forceWorker-*.js absent from the manifest) is reported "ungated", never gated. The script takes an optional dist-dir arg (default ./dist) so the self-test can point it at a fixture. Fail-closed — the complete case list (each exits non-zero, never a silent pass): a missing/empty dist; a missing/invalid/non-object manifest (including a JSON array); zero JS chunks; no MAIN (isEntry) entry; any manifest entry (JS or non-JS, e.g. CSS) whose file is absent on disk (an up-front sweep over every chunk); a JS chunk flagged both isEntry and isDynamicEntry (mutually exclusive — a both-flagged route chunk would be under-budgeted as MAIN); a non-array imports or dynamicImports; a non-string element in an imports array; a static imports element referencing a missing/invalid manifest key; a non-string element in a dynamicImports array; a dynamicImports element referencing a missing manifest key; and a dynamicImports element referencing a JS chunk that is not isDynamicEntry. (The last three close the lazy-chunk half of the contract: a mis-flagged/dangling dynamicImport target would otherwise slip to "ungated" instead of being budgeted as the lazy chunk it is.) Thresholds are named constants in the script. Exceeding a budget requires a SOW — never raise the threshold.
Secrets Scan
scripts/scan-secrets.sh # scans every tracked file for secrets/operator identity
scripts/test/scan-secrets-test.sh # hermetic self-test for rule classes + fail-closed paths
Patterns checked:
- Operator identity, derived at runtime from git/user metadata and
$HOME; no literal operator identity is committed in the scanner. Synthetic placeholder commit identities used only to avoid personal commit metadata (currently exact neutraluserplaceholders plus the matching placeholder email form, case-insensitive) are ignored as Rule-1 derivation inputs. The exact neutral home stemuseris also ignored when it comes only from$HOME, so committed placeholder paths such as/home/user/...stay portable across generic dev VMs. Tracked content is still scanned normally; real derived operator identities are never allow-listed. After placeholder filtering, an empty Rule-1 ban-list is a fail-closed error. This rule is never allow-listed. - Generic secret shapes:
sk-…/sk-ant-…, Slackxox…, AWSAKIA…, bearer tokens, GitHub PATs, GitHub fine-grained PATs, and GitLab PATs. Secret-shape tokens are flagged in every tracked file, including fixtures; the only exemption is a per-token syntheticEXAMPLEmarker used by dirty sanitizer fixtures. .gzarchives are decompressed and scanned; malformed archives are scanned raw and reported. Tracked symlinks are scanned as target-path strings.- Diagnostics are redacted: failure output reports path, line, rule, and a redacted/summary marker only; it never prints the raw matched operator identity, offending line, or secret token.
Threshold: zero hits.
AI-Attribution Scan
scripts/scan-ai-attribution.sh
Greps cmd/, internal/, scripts/, frontend/src/, and frontend/tests/
for comments that attribute code to an external AI reviewer by name — a reviewer
name adjacent to an iteration/priority tag (<name> iter-N, <name> P<digit>)
or an attribution verb (per/pins <name>, <name> flagged/…). The pattern
REQUIRES a reviewer name, so bare domain terms usually do not match; the script
self-excludes (it enumerates the names in its own pattern). Enforces the
no-AI-attribution rule on the public repo — the work stands on its own. Runs
fail-closed in local scripts/gates.sh and the CI gates job: required scan
roots must exist, grep exit 0/1 are handled explicitly, and grep exit >1 fails.
Threshold: zero hits.
Spec Drift
scripts/test/check-contract-matrix-test.sh # hermetic self-test for the field-contract helper
scripts/spec-drift.sh # the 6 spec↔code drift indicators; exit 0 = no drift
scripts/test/spec-drift-test.sh # hermetic self-test: plants each drift class + a clean case
Fail-closed (exits non-zero naming the offending indicator + token). Five
indicators are grep/awk-based because their surfaces are line-oriented; the
contract-matrix indicator delegates to a small Go helper for Go AST JSON tags
and restricted TypeScript interface parsing. Six indicators, the
actual code/spec locations (the old skill named stale paths —
internal/presenter/sse.go, internal/ingest/discover.go — neither exists):
- REST endpoints —
mux.HandleFunc("/api/…", p.<handler>)ininternal/presenter/presenter.goplus each handler's in-bodyr.Methodguard in the presenter package, vs.### <VERB> /api/…headings in.agents/sow/specs/rest-api.md. The compared token is<VERB> <normalized-path>, not path alone;{id}↔:id, single-value:ref↔:id, and catalog groups are normalized.HEADis implicit parity forGET. Keep registered handler method gates directly visible asr.Method != http.Method...comparisons inside the handler body; extracting the guard into a helper is treated as method-extraction failure and correctly fails closed. Exemption: a spec endpoint whose section is marked not registered / Phase 2 / not implemented is NOT drift — documenting a future route ahead of its handler is allowed; every registered route+verb pair must be documented (code→spec unconditional). - SSE event types — the wire kinds in
internal/presenter/events_sse.go(eventPayloadcase "<kind>"arms +event:/control frames;stats_invalidatedis thedefaultarm, sourced fromsubscription_filter.go) vs. the event-type headings in.agents/sow/specs/sse-protocol.md.resyncis a reconnect-control frame (spec §Reconnect Behavior), treated as known. - SQLite columns — every
table.columnpair frominternal/store/migrations/*.sql(CREATE TABLE,CREATE VIRTUAL TABLE … fts5(…),ALTER … ADD COLUMN) vs. SQL schema blocks in.agents/sow/specs/data-model.md. Column direction is code→spec and table-scoped: a global mention of the same column name under another table is not enough. Table names are bidirectional. - Canonical event kinds —
EvXxx EventKind = "<value>"ininternal/canonical/events.govs. the identical fenced block in.agents/sow/specs/canonical-events.md. Bidirectional, exact. - Adapter discovery probes —
format: "<name>"probe structs incmd/ai-viewer-ingest/source_discovery.govs. the matching.agents/sow/specs/adapter-<name>.md(underscore→hyphen) and that spec mentioning the default probe path. - Contract matrix —
testdata/contracts/field-matrix.yamlvs. presenter JSON tags ininternal/presenter/*.go, API TypeScript interfaces infrontend/src/api/types.ts,test_refs, include-token policy, and payload-kindartifact_classnormalization.
Threshold: zero drift — via genuine agreement, never by weakening an indicator. Real drift on the live tree is reported + adjudicated (fix spec or code), not masked.
CI fail-closed rule: .github/workflows/ci.yml must fail if
scripts/spec-drift.sh, scripts/test/spec-drift-test.sh,
scripts/check-contract-matrix.sh, or
scripts/test/check-contract-matrix-test.sh is absent. The gates job runs
the contract-matrix self-test and spec-drift self-test before the live
detector, matching scripts/gates.sh; a detector that cannot catch planted
drift is a failed gate, not an all-clear.
Build
./scripts/build.sh
Builds frontend, embeds, builds both Go binaries. Threshold: clean build, both binaries present, expected size range.
Mutation Testing (optional, recommended)
go-mutesting ./internal/... # quarterly or on critical paths
Mutation testing surfaces tests that pass even when the code is broken. Not enforced per-commit; run quarterly on critical paths (internal/canonical, internal/ingest, adapters) and treat surviving mutants as test gaps to file as SOWs.
Aggregate Scripts
./scripts/lint.sh # build-free module/static analysis: Go module tidy + tracked-file formatters + vet + golangci + gosec + govulncheck AND frontend eslint/tsc/bundle-size/coverage self-tests; fail-fast; frontend section skipped when frontend/ is absent
./scripts/test.sh # ALL tests: Go (race + coverage profile) then, in normal mode, the frontend Vitest run (real per-dir coverage gate); skips frontend when absent (SOW-0010/0012; EXISTS)
./scripts/build.sh # frontend build + the REAL bundle-size gate on the built dist/ + embed + both Go binaries (SOW-0012; EXISTS)
./scripts/check-coverage.sh # Go statement coverage gate, internal/* >= 80% (SOW-0010; EXISTS)
./scripts/check-contract-matrix.sh # DB/API/TypeScript/UI field-contract matrix helper (SOW-0105; EXISTS)
./scripts/spec-drift.sh # the 6 spec↔code drift indicators, fail-closed (SOW-0013/SOW-0105; EXISTS)
./scripts/check-ingestion-parity.sh --fixtures # deterministic source-vs-canonical parity fixture gate (SOW-0097; EXISTS)
./scripts/test/codacy-config-test.sh # Codacy tool/pattern + path policy self-test (SOW-0046; EXISTS)
./scripts/gates.sh # every gate above, in order, fail-fast, per-section timing (SOW-0013; EXISTS)
scripts/lint.sh is the build-free module/static-analysis entrypoint: it runs Go module tidiness, tracked-file Go formatter checks, Go vet/lint/security checks, frontend static checks, the coverage-config verifier, and hermetic gate-logic self-tests. It does NOT run the REAL bundle-size-vs-built-manifest gate (npm run check:bundle-size, needs a vite build) or the REAL coverage run (npm run test -- --run --coverage); those live in the CI frontend job and in scripts/build.sh (which runs check:bundle-size on the just-built dist/) and scripts/test.sh (which runs the frontend npm run test -- --run --coverage after the Go suite, in normal mode). scripts/lint.sh's frontend section ensures frontend/node_modules is present by reusing scripts/build.sh's npm ci / npm install fallback, but only when it is missing (so a warm tree stays fast).
Current state: scripts/lint.sh, scripts/test.sh, scripts/check-coverage.sh, scripts/spec-drift.sh, scripts/check-contract-matrix.sh, scripts/check-ingestion-parity.sh, scripts/codacy-coverage-upload.sh, scripts/test/lint-test.sh, scripts/test/check-contract-matrix-test.sh, scripts/test/check-ingestion-parity-test.sh, scripts/test/codacy-coverage-upload-test.sh, scripts/test/codacy-config-test.sh, scripts/test/install-system-test.sh, and the canonical scripts/gates.sh aggregate all exist (SOW-0009/0010/0012/0013/0044/0046/0097/0105). scripts/gates.sh is the full local workstation gate: fail-fast, per-section timing, and complete coverage of the local gate catalog including the lint formatter-scope self-test, contract-matrix self-test, ingestion parity fixture gate, Codacy coverage-upload self-test, Codacy config self-test, install-system self-test, fuzz seed corpus, Playwright E2E/axe, and the local benchmark regression gate. The benchmark regression gate runs immediately after the cheap lint formatter-scope self-test and before lint/static analysis, build, -race, fuzz, and Playwright sections so the local workstation benchmark measures code behavior, not aggregate-created residual load. CI enforces equivalent gates as dedicated parallel jobs (lint via the same module-tidiness + standalone gofmt/goimports/vet checks, the pinned golangci-lint-action, and standalone gosec/govulncheck; test; frontend; embed-smoke) plus a cross-cutting gates job (secrets/spec-drift/contract-matrix/ingestion-parity/lint-test/Codacy coverage-upload self-test/Codacy config self-test/install-system self-test/AI-attribution fail-closed, contract-matrix, spec-drift, and ingestion-parity self-tests before live detectors, local aggregate presence + syntax check, optional systemd helper) and explicit CodeQL matrix jobs — see "Local Pass + CI Fail Invariant" below.
Required CI jobs are not bootstrap probes anymore. They fail closed when
their mature repo prerequisites disappear: lint/test require Go module files
and their gate scripts; frontend requires frontend package files, Go module
files, scripts/build.sh, and the e2e npm script; embed-smoke requires the
build/smoke/seed-marker scripts plus Go/frontend package files. Do not re-add
green "skip notice" paths for these gates. Only optional helper checks such as
systemd unit lint may skip when their helper file is absent.
Local Pass + CI Fail Invariant
scripts/gates.sh and .github/workflows/ci.yml enforce the same gate contract, but CI keeps expensive gates parallel instead of re-running the full local serial aggregate. So a green local gates.sh followed by a red CI is an investigatable defect, never accepted drift and never "just re-run CI". The deliberate, documented divergences are narrow: CI's lint job uses the version-pinned golangci/golangci-lint-action (cache) at the same pin as scripts/lint.sh (.golangci-lint-version); the benchmark regression gate is local-only (workstation baseline) while CI runs only bench compile-smoke + gate self-test; and the CI gates job runs the cross-cutting scans plus required-script presence/syntax checks rather than the full aggregate. Anything else that disagrees is environment (Go/Node version, OS), test isolation (stale cache, leftover state), or timing — debug from the asymmetric input. Before merge, also confirm EVERY CI row reads pass (see "Verifying CI Before Merge" — --watch can exit 0 on a failed non-required check).
Renaming a CI Job
The ci.yml job IDs (lint, test, frontend, embed-smoke, gates, plus each explicit CodeQL matrix job name: CodeQL (go), CodeQL (javascript-typescript), CodeQL (actions)) are the branch-protection required-status-check contract. The current names are recorded in .github/workflows-checks.yaml (operator-readable, NOT consumed by Actions). Renaming a job silently disables its required check (protection keys by name). So any SOW that renames a job MUST, in the same commit: (1) rename it in ci.yml or codeql.yml, (2) update .github/workflows-checks.yaml, and (3) re-run the full branch-protection gh api -X PUT …/branches/master/protection invocation documented in docs/setup.md. GitHub's PATCH endpoint is only for the nested /protection/required_status_checks update, not the full protection rule. This is the §"Adding a New Gate" rule's sibling for renames.
Code Scanning Defence Layer
CodeQL
Runtime checks:
actionlint .github/workflows/codeql.yml
gh run list --workflow codeql --branch master --limit 5
gh api /repos/netdata/ai-viewer/code-scanning/alerts --jq 'map(select(.state=="open")) | length'
Policy:
- Required contexts are
CodeQL (go),CodeQL (javascript-typescript), andCodeQL (actions). - CodeQL config lives under
.github/codeql/once SOW-0044 lands. Query-suite changes are SOW changes, not drive-by workflow edits. - Suppressions are query/path scoped in the config file with a SOW/issue
rationale. Inline
// codeql[...]suppressions require explicit SOW evidence. - Critical/high alerts are fixed, proven false-positive, or tracked before a SOW closes.
Codacy
Runtime checks:
codacy repository gh netdata ai-viewer --output json
codacy tools gh netdata ai-viewer --output json
codacy issues gh netdata ai-viewer --overview --output json
codacy findings gh netdata ai-viewer --severities Crit
…(truncated)