clang-tidy translation-unit coverage gate
A clang-tidy run checks only the files its compile database or file list names. A translation unit no lane reads is never linted, and nothing reports it: an adopter repository found that dozens of its GPU sources had never been through clang-tidy. The coverage gate closes that gap. It fails when git tracks a C, C++, CUDA, HIP or Objective-C++ translation unit that no declared clang-tidy lane reads and no live exception excuses, and it names each such file.
The gate is tidycoverage.Check in
internal/tidycoverage/coverage.go.
The audit runs it, and praetorctl ci tidy-coverage runs it on its own.
Quick start
Declare each clang-tidy run as a lane in .standards.yaml, and name the units no lane can read
in the top-level exceptions list:
clang_tidy:
lanes:
- name: "cpu"
compile_database: "build/compile_commands.json"
- name: "metal"
files: ".config/clang-tidy/metal-files.txt"
exceptions:
- rule: "clang-tidy-coverage"
path: "src/win32/getopt.c"
reason: "Windows-only unit; no lane configures a Windows build yet"
expires: "2026-12-31"
The audit runs the gate in the pre-commit and pre-push hooks. A compile_database lane is a
build output, so the audit judges coverage only where every lane's database is written and
otherwise skips with the lane named (below). Run the gate that fails closed in the CI
job that has just configured the build:
Without that job, a repository with a compile_database lane has no run that fails when the
database is missing.
When it runs
| Where | Runs when | Otherwise | A compile_database lane's database is not written |
|---|---|---|---|
praetorctl audit |
the resolved policy's linters name clang-tidy, as the native-gpu-systems profile does, or .standards.yaml declares clang_tidy |
prints [SKIP] clang-tidy translation-unit coverage not checked: with the reason, neither a pass nor a failure |
skips with that [SKIP] line, naming each such lane |
praetorctl ci tidy-coverage [--dir=.] |
always | — | fails closed |
auditTidyCoverage in cmd/standardsctl/tidy_coverage.go makes the choice
(TestAuditTidyCoverage_Boundary_EnablementAndSkips). The audit runs in the pre-commit and
pre-push hooks adoption writes, so every adopter of a profile that declares clang-tidy gets
the gate without further wiring. The linters key is consumed for this one name only
(.config/archetype-coverage.yaml).
A repository that tracks no translation unit is skipped with the reason
the repository tracks no C, C++, CUDA, HIP or Objective-C++ translation unit, whatever it
declares (TestCheck_Boundary_SkipAndEdges).
What counts as a translation unit
A path git ls-files --cached lists whose suffix is .c, .cc, .cpp, .cxx, .c++,
.cppm, .cu, .hip or .mm, compared without case. Headers are read through the units that
include them and are not counted. An untracked file is not counted until it is staged. A file
named like a unit that clang has no mode for, or that only an include reads, needs an
exceptions entry.
Lanes
clang_tidy.lanes lists 1 to 64 lanes (config.ValidateClangTidy in
internal/config/clang_tidy.go). Each lane has a unique name and exactly one source, a clean
repository-relative path:
| Key | Reads |
|---|---|
compile_database |
A JSON compilation database, the compile_commands.json CMake and Meson write. Each entry's file is resolved against its directory; entries outside the repository, such as dependencies built in the same tree, are left out. A repository root reached through a symbolic link is matched in both forms. |
files |
A text file with one repository-relative path per line; blank lines and lines starting with # are skipped. A line that leaves the repository is an error. |
A lane whose source is missing, unreadable, not a compilation database, empty, or holds an entry
without a file fails the gate closed, with the lane named. It never counts as a lane that reads
nothing (TestCheck_Negative_UnreadableLaneFailsClosed).
One case differs between the two runs. A compile database is a build output, and the hooks run
the audit in checkouts that have not been built. So when a compile_database lane's database
does not exist, the audit skips the gate rather than judge coverage without that lane:
[SKIP] clang-tidy translation-unit coverage not checked: no compile database written in this checkout for lane cpu (build/compile_commands.json); praetorctl ci tidy-coverage judges coverage where a build writes it.
The audit still reads the other lanes first and fails on any of their inputs that is unreadable,
and it fails on a compile database that exists but is malformed or empty. A files list is
tracked, not built, so a missing one fails the audit too. praetorctl ci tidy-coverage fails
closed on a missing compile database as on every other unreadable input
(tidycoverage.Options.SkipUnbuilt, TestCheck_Boundary_SkipUnbuiltCompileDatabase,
TestAuditTidyCoverage_Boundary_UnbuiltCompileDatabaseSkips). A developer who has built a lane
locally gets the full gate from the audit. To judge coverage in the hooks without a build, use a
tracked files lane, such as the units a clang-tidy run last measured.
Exceptions
The top-level exceptions list is the repository's one declared per-file exception list
(AGENTS.md rule 14). The gate reads the entries whose rule is clang-tidy-coverage; the
credits gate of a Praetor checkout reads those whose rule is credits
(Credits & Acknowledgements).
The Security stage reads gosec entries with one exact path, a gosec_rules list,
reason, explicit category, entered and expires. It removes matching live findings before
the ratchet, reports a count per entry, and rejects stale entries; expired entries excuse
nothing (gosec guide).
For a HISS rule decided by the invariant scanner, a live entry removes only matching findings
from the touched-file and new-finding ratchet checks. The audit prints each excused finding
with its reason and expiry. Expired entries excuse nothing, and an entry matching no finding in
its file and function is stale and fails the audit (applyHISSExceptions in
cmd/standardsctl/audit_exceptions.go; TestAudit_HISSException*).
| Key | Rule |
|---|---|
rule |
A rule some gate reads from the list: the HISS registry IDs HISS-01 through HISS-21 (hisscatalog.RuleIDs()), clang-tidy-coverage, credits (config.ExceptionRuleCredits), test-timeout for a workflow that sets a test timeout above the 30-minute per-package bound (forge.AuditTestTimeouts, #1381), job-timeout for a workflow job missing timeout-minutes or exceeding measured/static bounds (forge.AuditJobTimeouts), root-license-notice for the root licence check, reuse-annotation-order for a REUSE.toml too large for the annotation order check, api-compatibility (config.ExceptionRuleAPICompatibility) for a Go module the API compatibility gate cannot build (A module that cannot be built even so), declared-linters (config.ExceptionRuleDeclaredLinters) for a declared linter no workflow runs yet (Declared linters), mutants for a tracked mutation-tool config that excludes mutants, codespell (config.ExceptionRuleCodespell) for an adopter's codespell skip of one engine-emitted file (Codespell skips for emitted files), or backlog.caps.* for a backlog category over its cap (Exceptions for backlog caps). HISS-11 names a release workflow with a supply-chain gap (Declaring a gap); HISS-10 names a workflow with build lanes that cannot use warnings as errors (Build-warnings gate); HISS-18 names a workflow that must run on every branch push or draft pull request (Workflow trigger audit); HISS-12 names one file and one gitleaks rule id (tool), optionally pinned by a fingerprint, for the history secret scan (internal/secretscan); HISS-19 names one repository file with duplicate function blocks (HISS-19: Reuse Before Writing). |
gosec_rules |
For rule: gosec, a nonempty list of distinct IDs matching G plus three digits; invalid on other rules. |
path or glob |
Exactly one, except for declared-linters and the backlog cap rules. HISS scanner exceptions use one path; no glob. path names one clean repository-relative file without glob characters. Other glob entries follow the docs_surfaces glob rules: * stays inside one segment and a ** segment spans any number. |
function |
Optional for a HISS scanner rule. Matches the scanner finding's function symbol and narrows that file's exception; omitted means the entry covers matching findings anywhere in that file. |
fingerprint |
For HISS-19, an optional 8–64 hexadecimal character prefix of the clone group's reported hash, matched case-insensitively. For HISS-12, the existing optional finding fingerprint. |
lines |
Optional for HISS-19 only: "START-END", positive 1-based inclusive bounds on a clone location's start line. Malformed or reversed ranges are refused. When both fingerprint and lines are present, both must match. Omit both to retain whole-path dedupe matching; an entry matching no scoped location stays stale. |
tool |
Only and always for declared-linters and backlog cap rules: the linter or backlog target the entry excuses. Neither sets path or glob, and no other rule takes tool (config.ValidateExceptions). |
reason |
One line, at most 1024 bytes. |
expires |
An explicit YYYY-MM-DD date. A states its own expiry within the 90-day bound that entries without a category keep; B, C and D allow at most 3, 10 and 120 days. The entry holds through its expiry day. |
category |
The explicit field takes precedence. If omitted, a reason beginning with category A, category B, category C or category D supplies the category, case-insensitively. Otherwise the 90-day cap remains. The mutants and codespell rules require a category. |
entered |
Optional YYYY-MM-DD date anchoring the expiry cap. When omitted, the cap is measured from today. The maximum expiry is entered (or today) plus the applicable interval; an earlier expiry is allowed. |
The mutants rule names one tracked mutation-tool configuration by path, such as
.cargo/mutants.toml. Its category covers all exclusions in that file. The audit reads cargo-mutants
exclude_re and exclude_globs; mutmut's only_mutate, do_not_mutate and
do_not_mutate_patterns in
pyproject.toml or setup.cfg; Stryker JSON config (mutate negations, ignorePatterns and
mutator.excludedMutations); PIT Maven pom.xml (excludedClasses, excludedMethods and
avoidCallsTo); Gremlins .gremlins.yaml (unleash.exclude-files); and go-mutesting configs
named go-mutesting.yaml, .mutesting.yaml or mutesting.yaml (exclude_dirs,
disable_mutators, enable_mutators and ignore_source_lines). Nonempty exclusion settings need a live categorized
entry; empty settings need none. Stryker JavaScript/TypeScript config files and PIT Gradle build
scripts are not read because they execute code or use a dynamic DSL; go-mutesting configurations
at arbitrary paths are not discoverable because the tool loads them only when passed by flag.
Source attributes, comments and command-line-only exclusions are outside this file audit. For
example, an exception entered 2026-10-16 in category D expires 2027-02-13:
exceptions:
- rule: mutants
path: .cargo/mutants.toml
category: D
entered: 2026-10-16
reason: generated code is not yet mutation-tested
expires: 2027-02-13
LoadManifest refuses a malformed entry, and the gate applies the same validator,
config.ValidateExceptions in internal/config/exceptions.go
(TestValidateExceptionsNegative, TestValidateExceptionsBoundary). An expired entry is still
a valid declaration: the gate treats it as missing and fails on its files. The audit's last step
also fails every expired entry, whatever its rule, so an entry its own gate never refuses (a
backlog cap under its cap, a reported-only rule such as HISS-18) cannot stay expired, and
praetorctl exceptions exits 1 while any entry has expired (auditExpiredExceptions in
cmd/standardsctl/audit_exceptions.go; TestAudit_ExpiredBacklogCapEntryFailsTheAudit). An entry that
excuses no unit every lane leaves unread, because a lane now reads the file or the file is gone,
is stale and fails until it is removed (TestCheck_Negative_ExpiredAndStaleExceptions).
The audit also fails every entry whose path names a file that does not exist, or whose glob
matches none, the same way it fails an expired entry, and the message names the rule, the entry
and the missing path (#1259; auditExceptionsExpiry in cmd/standardsctl/audit_exceptions.go).
One planned file is the exception: the HISS-11 entry adoption writes for the adopter's release
workflow release.yml, still to be added, passes while no workflow writes provenance. The audit asks the code that writes that entry, and once a provenance workflow
exists the entry fails like any other stale one.
A HISS-12 entry is not checked against the tree either: its finding lives in history, where the file
may no longer exist, and the secret gate fails an entry no finding uses
(TestAuditExceptionTargets_SecretsHistoryPath).
Inspect declared exceptions and their remaining validity with praetorctl exceptions list
(cmd/standardsctl/exceptions.go, TestExceptionsList_Positive_ListsEntries):
Filter entries due to expire soon by passing --expiring-within=<days>
(TestExceptionsList_Positive_FiltersByExpiringWithin, TestExceptionsList_Boundary_ExactDaysAndZero):
The governance audit (praetorctl audit) warns before an exception expires: 14 days
for uncategorized entries (config.ExceptionExpiryWarningDays), or a per-category
window of min(14, interval/2) days for categorized entries (category B: 1 day, C: 5 days,
D: 14 days, A: half the declared interval up to 14 days; entry.WarningDays(),
cmd/standardsctl/audit_exceptions.go, TestAuditExceptionsExpiry_3D,
TestAudit_ExceptionsExpiryWarning_Integration), prompting maintainers to address
or renew the debt before the owning gate fails upon expiry:
[WARN] Exceptions: the exceptions entry (rule HISS-11, .github/workflows/rel.yml) expires on 2026-10-24 (14 days left): Level 2 builder
Data paths
When a repository tracks test data files with translation-unit suffixes (for example, .hip sample inputs a parser reads, never compiled), declare their repository-relative directories under clang_tidy.data_paths:
clang_tidy:
lanes:
- name: "cpu"
compile_database: "build/compile_commands.json"
data_paths:
- "tests/data"
Use data_paths for files that are never compiled as translation units. The declared paths form an explicit allow-list: tracked files lying under a declared path are skipped as data, and the gate reports how many it skipped. Like the exceptions list, it fails on stale configuration: any declared data path under which no tracked translation unit lies is reported as a finding (data path X holds no tracked translation unit; remove it from clang_tidy.data_paths). If every tracked unit in the repository lies under declared data paths, the gate skips with every tracked translation unit lies under a declared data path.
Reading the output
A pass names what each lane read, including any skipped data units:
[PASS] clang-tidy translation-unit coverage: all 120 tracked translation units read by a lane (cpu: 98, metal: 14) or excused by a live exception (8), 5 under declared data paths.
A failure lists one line per file or entry before the summary:
- src/planted.cc: read by no clang-tidy lane and named by no exceptions entry
- src/win32/getopt.c: read by no clang-tidy lane; its exception expired on 2026-10-05
- exceptions entry src/gone.c (clang-tidy-coverage): excuses no translation unit that every lane leaves unread; remove the entry
- data path tests/empty holds no tracked translation unit; remove it from clang_tidy.data_paths
A gate that cannot run reports
[FAIL] clang-tidy translation-unit coverage could not run: with the cause, such as
clang-tidy lane cpu: compile database build/compile_commands.json cannot be read.
Not yet on this list
Two older exception mechanisms keep their own formats: the npm audit gate reads
.config/security/npm-audit-exceptions.json (scripts/npm_audit_gate.py), and the HISS-01
cleanup-goto exception is declared under hiss.exceptions with its own document. Both could
move onto the top-level exceptions list as further rules; neither is migrated yet.
Like .standards.yaml exceptions, scripts/npm_audit_gate.py warns 14 days before an
active npm audit exception expires (EXCEPTION_EXPIRY_WARNING_DAYS).
Category cap boundaries, the 90-day bound of category A and reason-prefix precedence are covered by TestValidateExceptionsCategoryCaps in internal/config/exceptions_expiry_test.go. The separate npm audit exception list retains its own 90-day bound.