Skip to content

Linting & formatting

This page documents the lint configuration choices that are non-obvious or whose rationale would otherwise be lost.

treefmt formatters

Wired in treefmt.nix. Run via nix fmt or just fmt. Pre-commit hook treefmt runs the same set.

Formatter Scope
nixfmt *.nix
prettier JSON and YAML only
mdformat *.md (with gfm + frontmatter + mkdocs + footnote + toc plugins)
shfmt shell scripts and .envrc, 2-space indent
taplo TOML
just justfile

Prettier's YAML quote rewriting is covered in Treefmt YAML quote gotcha.

Pre-commit hooks

Configured via cachix/git-hooks.nix (input pinned in flake.nix). The .pre-commit-config.yaml symlink is generated by nix develop into the Nix store and is .gitignored. Edits go in the hook modules under nix/hooks/, not the symlink target.

Representative hooks: actionlint, check-jsonschema, commitlint (commit-msg), deadnix, editorconfig-checker, markdownlint, mypy-strict, nixfmt, octoscan, statix, typos, yamllint, shellcheck (skipping justfile), treefmt, zizmor (--min-severity=low), flake-show-fresh, uses-sha-pinned. The full list is generated from the hook manifests — see Git workflow → Pre-commit hooks.

markdownlint rule disables

.markdownlint.json disables eight rules. Rationale per rule:

Rule Name Reason
MD007 Unordered list indentation mdformat's nested-list output uses 4-space indent, conflicting with the default 2-space expectation
MD013 Line length Docs site is MkDocs-Material with prose paragraphs; hard-wrapping at 80/100 chars fights table cells and admonitions
MD024 Multiple headings same content Dashboard and release-notes pages have repeated section headers per release / per artifact
MD032 Lists surrounded by blank lines Common false positives around mkdocs-material admonition trees
MD033 Inline HTML MkDocs-Material relies on <details>, <span> for admonitions and badges
MD038 Spaces inside code-span markers False positives on Mermaid edge-syntax (-->, ---)
MD041 First line top-level heading Some pages (release notes, dashboard) open with a macros / yaml block before the H1
MD060 Table column style mdformat's GFM table output does not keep pipe columns vertically aligned; enforcing a single table style would fight the formatter

Adding or removing a disable: update .markdownlint.json and this table in the same PR so the rationale stays discoverable.

lychee.toml accepts 200, 204, 206, 302. 429 (rate-limited) is not accepted — relying on lychee's built-in retry is preferable to silently letting bot-blocked links pass. If a specific upstream consistently rate-limits anonymous GETs and is known-good, add it to the exclude regex list with a comment rather than expanding accept.

include_verbatim = false skips fenced-code-block content so command examples are not crawled.

Markdown anchor resolution

Every #anchor link in README.md or docs/**/*.md (plus .claude/CLAUDE.md locally, when present) that points at an in-tree .md file (or at its own file) must match a heading slug or an explicit <a name="..."> anchor in the target. Lychee resolves file paths, but neither just lint-links nor links.yml passes --include-fragments, so it never checks anchors at all — without this lint a renamed heading would reach main unreported. Enforced by scripts/check-doc-anchors.sh, wired as the check-doc-anchors pre-commit hook and as a member check of the lint-doc-invariants CI job; a run that finds zero anchor links fails unless LINT_ALLOW_EMPTY_SCAN=1 states that the empty scan is deliberate.

The slug's delete step runs under a C locale, so a non-ASCII byte in a heading is deleted whatever locale the caller runs in. A heading whose text holds a non-ASCII letter, mark or digit must carry an explicit <a name="..."> anchor on the heading line, because GitHub keeps the character in its slug while mkdocs folds it to ASCII and no generated slug resolves on both; a heading without one is a blocking [non-ascii-heading] finding, and one with an anchor answers to that anchor alone, not to a generated slug. Non-ASCII punctuation such as — or → is deleted by both renderers and stays allowed. Finding such a heading needs grep --perl-regexp to read UTF-8 under C.UTF-8; when it cannot, the lint exits 2 instead of passing, and it exits 2 as well when a source or link target cannot be read, holds a NUL byte, is not valid UTF-8, or its headings cannot be read.

Config schema validation

renovate.json, every workflow YAML, every composite-action YAML and .markdownlint.json are validated against pinned JSON Schemas so a misspelled key fails locally instead of being silently ignored by the consumer. Enforced by scripts/check-jsonschema.sh (driving the check-jsonschema tool), wired as the check-jsonschema pre-commit hook and as a member check of the lint-doc-invariants CI job.

Typos

_typos.toml excludes generated and vendored paths (flake.lock, linpeas-pin.json, docs/_data/**, result*, .direnv/**) along with tests/fixtures/** — the fixtures because they contain intentionally malformed content (bad versions, malformed renovate configs); future fixtures may include realistic prose that would otherwise trigger typo flags. The file's one [default.extend-words] entry allows intoto — the in-toto provenance framework name carried by the .intoto.jsonl release-asset suffix.

Zizmor

Workflow-security linter; --min-severity=low to catch everything. Suppressions live in zizmor.yml with documented rationale per suppressed rule + workflow. Do not raise --min-severity above low without a corresponding security-review entry.

Octoscan

Workflow-vulnerability scanner; runs synacktiv/octoscan via a pinned ghcr.io container image against .github/workflows/. The pre-commit hook fires when any .github/workflows/*.yml or *.yaml file is staged; it always scans the full directory to match the CI workflow's invocation. Docker is required — if docker is missing the hook fails with exit 2 (could not run) and hints at SKIP=octoscan for one commit. The pinned image digest and the version label tracked by Renovate both live in scripts/octoscan-scan.sh (single source of truth).

Ephemeral-reference lint

scripts/check-ephemeral-refs.sh gates Markdown prose — and the comments in every shell, Nix and YAML source — against "ephemeral references": tokens that describe what a file replaced or which plan, review pass, ticket, date, or PR introduced it, rather than the CURRENT state of the repo. Tracked files are the durable home for behavior; history is recovered from git log and PR threads, so it must not leak into prose or into a comment.

Scope is every Markdown, shell, Nix and YAML source in the repo, minus the file allowlist below. A source's extension is the whole classifier: .md is read as prose, .sh as shell comments, .nix as Nix comments, .yml/.yaml as YAML comments, and any other extension is skipped rather than guessed at — so a shell library without a .sh suffix is out of scope by construction. Enumeration runs through git ls-files --cached --others --exclude-standard, so it covers files a commit is about to introduce as well as tracked ones, and honors .gitignore so build outputs stay out.

The script has two modes. The default pass is blocking (exit 1 on any hit, printing file:line: [class] token to stderr). The --advisory pass is warn-only — it prints [advisory] file:line: phrase and exits 0 for every hit it finds. The pre-commit hook check-ephemeral-refs runs the blocking pass first, then the advisory pass; the CI lint-doc-invariants group runs the blocking pass. Only the blocking pass gates a merge on a hit.

A malformed doc fails both modes. Stripping runs ahead of the mode branch, and an exempt region that never closes — an unterminated generated block, or an unbalanced code fence — blanks everything after it to end of file. That is indistinguishable in the output from a clean document, so both cases abort with a named diagnostic (unterminated generated block / unterminated code fence) and a non-zero exit rather than scanning a file whose remainder has been silently exempted. Advisory mode inherits that abort: it suppresses findings, not defects. The same holds for the source enumeration itself: a producer that fails, or comes back with no sources to read, exits 2 in either mode (LINT_ALLOW_EMPTY_SCAN=1 accepts a genuinely empty scan set) rather than a scan that read nothing reading as a scan that found nothing wrong.

The candidate pass

Extraction is the expensive half of this lint — shfmt and jq per shell source, awk per source, grep per class per source — and almost no file in the tree carries a banned shape. One grep --files-with-matches for the union of the mode's class regexes therefore runs first, over every scannable source at once, and only the sources it names are extracted and scanned. On the live tree only a small minority of scannable sources carry a candidate token in either pass, and each run states its own split in the scope summary rather than this page holding a copy that goes stale.

A set-aside source cannot be hiding a violation. Every extractor emits, for a given source line, either a blank line or a substring of that same raw line: Markdown emits the line with exempt regions blanked, shell emits # prepended to the comment text shfmt reports — and the raw line carries that # — while Nix and YAML emit the comment text with its indent stripped, and a Nix block comment also loses its /* and */ delimiters. Blanking only removes text, and removing text cannot create a match. The one regex feature that could read differently is the left boundary guard (^|[^-&[:alnum:]_]): where an extracted match binds ^, the same position in the raw line is preceded by whitespace, by line start, or by a /* opener — a comment opener requires whitespace or line start before it, and a Markdown line is the raw line — and none of those is in the guard's negated class. So a match in the extracted stream implies a match in the raw file.

The union is assembled from the same class-regex constants the scan uses rather than written out a second time, because a class that widened without its union widening would start setting aside files the scan would have flagged.

What it gives up: a shell source with no candidate token is never handed to shfmt, so a parse failure in it is not reported. The verdict is unchanged, and shellcheck and the formatter already gate shell parsability. Markdown and Nix keep their structural diagnostics: a second pass reads every set-aside source of those two languages for an unterminated fence, generated block or /* */, because those regions blank everything below them and must stay a named defect wherever they sit.

What asserts the pass is still working. A candidate pass that matched nothing would set every file aside and exit clean, and no file count or verdict would show it. Two things catch that. Each class regex is matched against a fixed canary literal carrying a token of its own class, and the mode's union against every canary it covers, before anything is scanned; a failure exits 2 naming the class. This check answers to no override — an empty tree is a state an operator can vouch for with LINT_ALLOW_EMPTY_SCAN=1, a degenerate matcher is not. And the scope summary states the parsed/pre-filtered split alongside the source counts, so a run that read one file of forty cannot read like a run that read forty.

Blocking classes

Class Shapes
PR / issue refs #123, PR #123, issue #123
Dates-in-prose YYYY-MM-DD, <Month> YYYY, Q1–Q4 YYYY
Planning labels GAP-1, P1.2, Wave-P3, Phase 2, AU-P-4, SC-POST-5, plan 6, F-7
Review-pass labels (D3), (L4, / (L4), Per D3, D5:
Literal .claude/ any .claude/ path — prose must not point into the Claude tooling tree

The PR/issue-ref match is boundary-guarded so anchor targets (#1-anchor) and HTML numeric entities (&#123;) do not trip it — the left guard excludes &, the right guard excludes -. Hex colors are not covered by that guard: #fff escapes only because f is not a digit, and an all-numeric color such as #333 matches the shape. Colors stay clear because Mermaid and CSS blocks are fenced, and fenced blocks are blanked before any class runs. The enumerated planning/review shapes carry the same left-boundary guard so they cannot match inside a larger token (e.g. UTF-8, PDF-1.7, ID5:).

Advisory class (warn-only)

Fuzzy causal-history phrases surface as advisories, never blockers: previously, Migration note, Tightened from, switched from/switched to, legacy <X> was deleted, added in #123, post-PR #123.

Each of those names a past state outright, so it reads the same way whatever it modifies. Bare verbs and prepositions do not: prior to describes when a threat occurs as readily as when a rewrite happened, swapped fits an attacker substituting an asset as well as a change we made, and was reshaped sits inside a present-tense inference about drift that has not happened yet. The class carries only the phrases a regex can judge, because an advisory that is usually wrong teaches a reader to scroll past the one that is right.

The generic <2-3 uppercase letters>-<digits> ticket shape is excluded from the blocking classes for the same reason, and is not matched at all: it false-positives on stable literals such as SHA-256 and UTF-8. Only the enumerated planning and review labels above are blocked.

Exemptions

  • Every extension no extractor claims. Only .md, .sh, .nix, .yml and .yaml are read; everything else is skipped whole. Which types those are is not guessed at and not counted by hand: they are derived by inverting the same type records the scan set is built from, over the files the allowlist below does not already skip, then narrowed to those carrying a full-line # comment — a format with no # convention can hide no comment-borne reference — and scripts/refresh-ephemeral-refs-gap.sh refuses to render them while a blocking shape sits inside one.

    File types no extractor claims and that carry # comments: .awk, .envrc, .gitignore, .ini, .py, .toml, .txt, docs-audit-state, justfile. No blocking shape appears in any of them.

    The ban is therefore a ban on Markdown prose and on shell, Nix and YAML comments, not a tree-wide one — but the gap between the two is gated empty rather than measured once.

  • A # line inside a non-shell YAML block scalar. The YAML matcher reads any line whose first non-space character is #, including inside a run: block. That is deliberate: those blocks are embedded shell, their # lines are genuine comments, and they hold roughly a third of the repo's YAML comments — a population no comment-node reader can reach, because to a YAML parser the whole block is one string. The cost is that a # line in a block scalar that is not shell would be read as a comment. The tree has no such block today, and a banned token quoted in backticks is exempt either way.

  • File allowlist (skipped entirely): CHANGELOG.md, docs/releases.md, everything under tests/fixtures/**, and everything under .claude/**. The first two structurally list PR refs and dates; fixtures carry intentional static data; .claude/ holds Claude tooling rather than user-facing prose, so its workflow-phase and label vocabulary is not an ephemeral reference. The allowlist is also the only file-level escape from a false positive: there is no per-line or per-token suppression comment, though a token inside a code span (in any source), or inside a fence or a generated block (in Markdown), is not read at all.

  • Structural stripping: generated auto-blocks (the BEGIN/END HTML-comment marker pairs), fenced code blocks, and inline code spans are blanked in place (preserving line count) before matching. Stable literals already written in a code span — e.g. an X-GitHub-Api-Version header value — are therefore exempt without any per-literal allowlist. This is why every example token on this page is wrapped in backticks: a code span is invisible to the scanner, so the doc passes its own gate.

Comment extraction

Extracted text reaches one shared set of class regexes whatever language it came from; what differs is how it is extracted. Every extractor emits one line per source line, so a reported line number is the original file's.

  • Shell comments come out of the shfmt --to-json syntax tree, not out of a # regex. A hash inside a string literal, a heredoc body carrying generated changelog prose, and the lint's own class regexes are out of scope by construction rather than by a matcher that has to guess which hash is code.
  • Only the comment at line 1, column 1 is dropped as the shebang. Anchoring on that position rather than on a leading ! in the comment text is what keeps a #!-shaped comment further down the file in scope, and it means a line-1 comment that is indented — so not a shebang — is read like any other. This drop applies to the shell path alone; the Nix path has no line-1 rule.
  • The leading # is kept. shfmt strips the hash from a comment's text and the Nix matcher strips only the indent, so a reference written flush against the hash (#123 …) still reaches the class regexes with its # intact.
  • Nix reads the comments that start their own line, in both comment forms: # line comments and /* … */ blocks, however many lines a block spans. No comment-preserving Nix parser is in this toolchain, and without one a trailing # cannot be told from a # inside a string, nor a mid-line /* from the /* in a quoted glob such as dir/* — of which this repo's Nix has several. Requiring the opener to start its line keeps both forms exact; only the opener is anchored, and a closing */ is honored wherever it falls.
  • A ''…'' block is a Nix string, and the # lines inside one are read. That is deliberate: those blocks carry the hook scripts, whose comments are prose like any other. It applies in every .nix file, not only under nix/hooks/ — nix/wrappers.nix holds such a block too. The accepted cost is that a ''…'' block holding something other than shell — a changelog snippet, a Markdown fragment — would have its # lines read as comments and could false-positive. The file allowlist is the only file-level escape if that ever happens; wrapping the token in backticks is the other.
  • YAML reads only the # comments that start their own line, for the reason the Nix matcher gives. A # line inside a run: block scalar is read like any other: those blocks are embedded shell, and they hold a population no YAML parser can reach, since to one the whole block is a single string. There is no multi-line region to leave open, so this extractor has no unterminated-region failure of its own — only the empty-corpus check below.
  • Backtick code spans inside a comment are exempt, the same rule inline code already gets in Markdown: a comment naming a banned shape as an example — an allowlist entry, a scan-scope note, one of the class regexes — is documentation rather than a reference.

Three further hard-failure cases follow from this — two could-not-run (exit 2) and one doc defect (exit 1). A shell source shfmt cannot parse exits 2 with a named diagnostic, rather than being skipped or read as clean. A Nix block comment that never closes exits 1 with unterminated Nix block comment: the extractor has no way back out of the block, so every line below the opener would be handed on as comment text and the run would report against code. And a run that covered shell yet extracted no shell comments, covered Nix yet extracted no Nix comments, or covered YAML yet extracted no YAML comments, exits 2 — each language answers for its own corpus, because the repo's shell comments outnumber its Nix comments by better than ten to one and a joint total would stay far from zero with the Nix extractor matching nothing at all. A gate that has stopped reading is indistinguishable, by exit code alone, from a gate that read everything and found nothing, so breadth is asserted rather than inferred. LINT_ALLOW_EMPTY_SCAN=1 accepts that too — the same operator escape hatch an empty source set has.

Guarded temp-file creation

Among its exit-code rules, scripts/check-guard-exit-code.sh bans a bare mktemp anywhere under scripts/. An unwritable TMPDIR makes mktemp exit 1, and under set -Eeuo pipefail an unguarded tmp="$(mktemp)" hands that 1 to the caller — the code that means "the repo carries a violation", for a run that inspected nothing. Scratch files come from make_temp (scripts/lib/temp.sh) instead, which passes its arguments through verbatim and exits 2 when the file cannot be created:

tmp="$(make_temp)"
work="$(make_temp --directory)"
probe="$(make_temp --tmpdir="${dir}" probe.XXXXXX)"

Matching is by command position: a whole-line comment is blanked before the rule runs, and a bare ( is not an introducer, so a parenthetical naming the command is not a hit — while a trailing comment or a string operand that puts the word in command position (after a separator, a pipe, a negation, a group opening, $(, or one of the keywords then, do and else) is one. The one sanctioned bare invocation lives in scripts/lib/temp.sh, which the rule skips by its repo-relative path — a second file of that name elsewhere under the scan root is an ordinary script and its bare call is a violation. A call whose failure genuinely is the finding opts out with an inline # exit-code-exempt: <rationale>, which has to open the comment — a sentence merely naming the marker is prose about the rule, not a use of it, and excuses nothing; the rationale must be non-empty, and a clean run prints the exemption count. Matching anchors to the line's first #. Any earlier # on the line — inside a string, a ${x#y} expansion, or anywhere else — can therefore hide a genuine trailing marker, and the miss reports the site rather than silently excusing it, which is the safe direction for this lint to fail in.

A marker the walk never consumes — because its site was rewritten into a compliant shape, moved, or left the file — is reported as its own finding rather than passed over. The clean run's summary line counts it as a third field alongside scripts scanned and exemptions applied, ahead of the sanctioned-site count that keeps the path-keyed exemption visible. Full rationale: Workflow hardening → guard-exit-code.

Guarded repository-root lookup

The same lint bans a bare git rev-parse --show-toplevel anywhere under scripts/. Outside a git work tree git exits 128, which an unguarded assignment hands to the caller in place of the documented 2, and a fallback such as || echo . reads the wrong tree. Scripts resolve the root through repo_toplevel (scripts/lib/repo.sh), which exits 2 naming the script and the directory:

REPO_ROOT="$(repo_toplevel)"
ROOT="${ROOT_OVERRIDE:-$(repo_toplevel)}"
readonly ROOT

Keep the call out of a declaration's arguments: local, readonly, declare, export or typeset returns its own status and loses the 2, and the lint reports that shape too. For local or declare, declare the name first and assign on the next line (local root then root="$(repo_toplevel)"); for readonly or export, assign first and then mark the name, as above. tests/repo-root-guard.test.sh runs every script that calls the helper from outside a work tree. Full rationale: Workflow hardening → Bare repository-root lookup.

Glob-driven scan breadth

scripts/check-enumerate-helper-required.sh bans a for loop that expands a glob at its own loop head and an array assignment that expands one in its element list. Under nullglob a pattern matching nothing expands to nothing — the loop body never runs, the array comes back empty, no violation is found and the run exits 0 — so a scan root that exists and holds nothing is scored a clean tree, and an operator override pointing the scan at the wrong directory reads as a repo with nothing to fix. glob_into (scripts/lib/enumerate.sh) is the glob analogue of enumerate_into: it expands the patterns itself, fills the named array, and exits 2 on an empty match set unless LINT_ALLOW_EMPTY_SCAN=1.

files=()
glob_into files 'workflow YAML' "${DIR}/*.yml" "${DIR}/*.yaml"
for f in "${files[@]}"; do

Note the quoting: the pattern reaches the helper as one quoted string and is expanded inside it, so a caller that has not set nullglob cannot hand the helper an unexpanded pattern that would count as one match.

Detection reads the same syntax tree the producer rule does — a ForClause whose WordIter items carry *, ? or [ in a bare Lit part, or an assignment whose Array elements carry one, at the word itself or one level in, among the Lit parts of the word any parameter expansion there carries — its alternate, default, or pattern-operand word alike. A metacharacter inside quotes is not a pattern and does not count, and a pattern handed to glob_into is an argument of a call expression rather than a loop item, so a compliant call site cannot false-hit.

A pattern copied into a variable is banned at the read rather than at the assignment: an unquoted ${pat} among a loop's iteration words or an array's elements expands whatever pattern this same file assigns that name, while a quoted "${pat}" expands nothing and is exactly how a pattern reaches glob_into. Three shapes are left alone — a name all of whose glob-bearing assignments are themselves classified array assignments, since that site already answers the breadth question and carries the marker if an empty match is normal there; a read under + or :+, which emits only the alternate word and never the value the variable holds; and a site the loop or array position has already reported, so one scan earns one finding and consumes one marker. The rule reads one file and never traces a value to its read, so a name given a pattern on one branch and a data list on another is reported, and a pattern arriving at a loop head through a command substitution or an indirect expansion is not seen at all.

A loop whose empty match set genuinely is the normal state — sweeping a leftover scratch file, say — opts out with an inline # glob-exempt: <rationale> trailing the site's own line or sitting anywhere in the contiguous comment block directly above it; the marker has to open the comment — a sentence naming it is prose, not an exemption — the rationale must be non-empty, the marker word is keyed to the kind of site so the sibling # enumerate-exempt: excuses nothing here, and a clean run prints the exemption count. Full rationale: Workflow hardening → enumerate-helper-required.

Filter-narrowed scan breadth

scripts/check-enumerate-helper-required.sh also bans a filter variable read that stands in for filter_into (scripts/lib/enumerate.sh) instead of reaching it. A filter narrows a scan set the enumeration or glob rules above already proved non-empty, and filter_into is where that narrowing's own size is asserted. A filter variable is any name that is exactly FILTER or ends _FILTER:

declare -a workflow_files=()
glob_into workflow_files 'workflow YAML' "${DIR}/*.yml" "${DIR}/*.yaml"
declare -a selected_files=()
filter_into selected_files 'workflow YAML' "${FILE_FILTER}" "${workflow_files[@]}"
for f in "${selected_files[@]}"; do

A file-scope read reaching that call, as above, is fine. What's banned is a for or while loop reading the raw filter variable again instead of trusting the selection filter_into already returned — that second read re-applies the filter test outside the helper, and whether it runs over the narrowed selection (merely redundant, since every path there already matched) or over a set filter_into never narrowed is not decidable at the read site; the second case is the empty-selection failure filter_into exists to catch, one step later. The loop's reach follows one function hop: a read inside a function whose name appears as a literal command word within the loop's own extent is treated the same as a read written inline, because the loop consumes it exactly as it would consume a read written directly in its body. The hop stops at one level — a function called only by another function the loop calls still sits outside every extent and still evades — and a script that reads a filter variable but never calls filter_into at all is banned too, since nothing there asserts the selection's size. Copying a filter value into a variable whose own name does not match the filter pattern is banned at the assignment itself, wherever the copy is later read: every check above keys on the name of the variable being read, so a value carried under a fresh name would satisfy the letter of all of them while re-introducing the same defect. The sanctioned copies — readonly FILE_FILTER="${WORKFLOW_FILE_FILTER:-}" and its SCRIPT_FILE_FILTER / TEST_FILE_FILTER siblings — stay legal because their own target matches the filter pattern.

A read outside every loop and every hop-reached function stays legal without a marker — the rule tests position, not purpose. The three live reads guard two job-count assertions (check-egress-allowlist.sh) and a reverse allowlist-staleness pass (check-permission-scopes.sh). Any of the four banned shapes — the in-loop re-read, the one-hop function read, the missing filter_into call, or the aliasing assignment — opts out with an inline # filter-exempt: <rationale> trailing the site's own line or sitting anywhere in the contiguous comment block directly above it; the marker has to open the comment — a sentence naming it is prose, not an exemption — the rationale must be non-empty, the marker word is keyed to this shape so neither sibling marker excuses it, and a clean run prints the exemption count. Full rationale: Workflow hardening → enumerate-helper-required.

A purely diagnostic in-loop read (a printf naming the filter, say) is still flagged: the rule cannot tell that apart from a re-derived selection, so it takes a marker like any other in-loop or hop-reached read.

All three markers — # enumerate-exempt:, # glob-exempt:, and # filter-exempt: — are censused the same way regardless of which rule's sites a file happens to hold: a marker the walk never consumes — because its site was rewritten into a compliant shape, moved, or left the file — is reported as its own finding rather than passed over. The clean run's summary line counts it as a fourth field alongside files scanned, sites classified and exemptions applied.

YAML aliases in workflow reads

A script that reads a workflow node through an alias resolves it with yq's explode, which replaces each alias with a copy of what it stands for. A read of one node hands explode only that node, so aliases elsewhere in the file cost nothing. The name lookups described under "Text from files in yq expressions" that use explode are the exception: each hands it the whole map it searches, which is the workflow's jobs: for the stale-entry read of check-permission-scopes.sh and the job lookups of check-notify-arms.sh, and the whole file for the allowlist reads of check-permission-scopes.sh, the category map of refresh-ci-dag.sh and the manifest of run-lint-group.sh. An alias nest anywhere in such a map is expanded on every lookup.

The copies are not capped. Aliases that each repeat another alias several times multiply at every level: a 408-byte workflow whose on: nests seven levels of nine aliases makes yq take about 1.8 GB. No script limits yq's memory. When yq cannot allocate it dies, printing a Go runtime dump, and the script treats the read as one that failed, by the exit contract its header states. Such a workflow is never passed; one that fits in memory is read in full.

A workflow's job keys are read through two explode passes (RESOLVE_JOB_ALIASES in scripts/lib/job-keys.sh). One pass resolves the aliases a node holds and leaves those inside the copies, so a jobs: written as an alias, or merging in a map, can still hold a key written as an alias after it; the second pass resolves that. A job key is a scalar, so a key alias stands for a scalar and no third level exists: a merge chain of any depth resolves in the same two passes, provided the read passes --yaml-fix-merge-anchor-to-spec (YQ_MERGE_SPEC), as each read that uses RESOLVE_JOB_ALIASES does. A lint that refuses a job key counts a merge key still present under jobs:, whether written inline or brought in through an alias, as a refused key.

GitHub Actions reads a root key written as an alias (name: &k jobs, then *k :) as the key it stands for. Every read of a root jobs:, env:, permissions: or concurrency: key takes the node from ROOT_KEY_NODE in scripts/lib/job-keys.sh, as the on: reads take theirs from ON_NODE: the node is the value of the one root key that is the name or an alias of it, and a root holding two, one plain and one an alias, makes yq fail ("jobs: is given more than once"), which each script handles as a read that failed. check-workflow-concurrency.sh, which reports a value written as an alias by its kind, takes its node from ROOT_KEY_RAW_NODE, which finds the key the same way and leaves its value as written.

A lint that reads only the jobs: node hands explode that node alone (JOBS_NODE in scripts/lib/job-keys.sh): a jobs: written as an alias is read through, and the keys inside it stay as written. The lints that list the job keys and then look each one up (check-fork-guard-release.sh, check-egress-allowlist.sh, check-size-label-ignores.sh) rely on that: a key written as an alias is listed and looked up as the same text, so the job it names is read, under the alias's own spelling in a finding. The lints that name a job from its key (among them check-harden-runner-first.sh, check-harden-runner-block.sh, check-setup-nix-required.sh, check-min-permissions.sh, check-notify-arms.sh and refresh-enforcement-matrix.sh) resolve the key and name the job by what it stands for.

Text from files in bash arithmetic

Bash evaluates as an expression the text of an operand in contexts including (( )), $(( )), let, the -eq-style tests of [[ ]], an indexed array's subscript, a ${var:offset} slice and any value stored in a declare -i variable. A name in that text is read as a variable (an unset one stops a set -u script), an array subscript in it runs any command substitution it holds, a leading zero makes it octal, and a number past the integer range wraps. In a script file, an error in $(( )) abandons the top-level command it sits in (a whole loop, or a whole main call) and the script goes on to its next top-level command without tripping set -e, so a lint can exit 0 having read only part of its input. In a (( )) condition the error reads as false.

A script that puts text it did not compute itself into arithmetic first matches it against a regex whose digit class is ASCII under any locale (see Locale-bound ranges in bash regexes, grep and sed) and bounds its length, or it compares the text as text. A number a tool prints is text too when it travels in a delimited row beside free text: a field holding the delimiter moves the fields after it, and one holding a line break as well can make whole rows of its own that each look well formed. check-egress-allowlist.sh tests the free text before it splits its rows for that reason. check-flake-lock-staleness.sh and check-egress-allowlist.sh state the rule each applies in their headers.

No lint enforces this. Telling a script-owned operand from file text needs the value's provenance through assignments, reads and helpers, and nearly every operand such a trace flags in scripts/ is a counter, a grep -c or wc count, date +%s output, or a number the script's own awk, jq or yq program printed. A provenance label also misses text that reaches a field through its delimiter.

Locale-bound ranges in bash regexes, grep and sed

A range in a bash [[ … =~ … ]] regex follows the locale's collation. Under en_US.UTF-8, a typical developer shell's locale, which the devShell and the pre-commit hooks inherit, [0-9] also matches digits such as ٣, 5 and ², and [a-f] or [A-Za-z] match é. Under C, POSIX and C.UTF-8 (the CI runner's locale) a range holds ASCII only. A validator written with a range therefore accepts on a developer's machine a value CI refuses, and CI cannot see the difference.

In a bash =~ regex, spell a digit or hex class out ([0123456789], [0123456789abcdef]). Match a regex that holds a letter range, or one whose text must stay as written, through ascii_match in scripts/lib/ascii-match.sh: it runs the match under C.UTF-8, where ranges hold ASCII and character classes keep the reading the runner gives them, and it leaves BASH_REMATCH set. A function that needs a byte reading instead declares local LC_ALL=C.

A lint that selects a remote action by its owner and repository matches them with their ASCII letters in either case, through the regex ascii_fold_regex in the same library prints: one bracket of the two letters ([aA]) per letter, for a bash =~ or a yq test(). A case-insensitive regex flag ((?i)) is not used for this, because it also folds letters outside ASCII: yq reads the Kelvin sign as k and the long s as s under it.

In a GNU grep pattern or a GNU sed script, a bracket with a letter endpoint ([a-z], [0-9a-f]) or a negated one ([^0-9]) follows the locale the same way; a positive bracket of digit ranges ([0-9]) does not. Run such a call under LC_ALL=C, as a prefix assignment or in a function or script that sets a C locale. A call that must follow the locale, such as a probe of it, carries the marker # regex-range-ascii: locale-bound: <reason> on the line directly above it.

check-regex-range-ascii.sh reads the whole sed script, so a bracket in replacement text or in a y command is reported too; mark such a call. gawk matches a range by code point in every locale (also under --posix, with IGNORECASE, and in a dynamic regex), so an awk range needs no pin and the lint does not read awk.

Other matchers measured on this tree do not need this: a positive [0-9] is ASCII in GNU grep, sed, gawk, mawk, jq and yq in every locale, and bash glob patterns read ranges as ASCII while globasciiranges is on (the default). POSIX classes such as [[:space:]] and [[:alpha:]] match characters outside ASCII in any UTF-8 locale, C.UTF-8 included; those are settled in Locale-bound character classes.

A harness scenario that shows a script's answer under a locale pins the locale with LC_ALL and first calls require_locale_gap from scripts/lib/locale-gap.sh, which fails the run when the locale cannot show the difference. A scenario under a locale that is not installed would run under C and pass whatever the script does. That probe asks bash; a scenario whose subject is a grep range calls require_grep_locale_gap instead, which asks the grep on PATH whether [a-z] admits é, and one whose subject is a sed range calls require_sed_locale_gap, which asks the sed on PATH the same question. GNU grep does not use the regex engine bash uses, and keeps a lone positive [0-9] ASCII in every locale while it follows the locale for [a-z] and for a mixed range such as [0-9a-f].

Enforced by scripts/check-regex-range-ascii.sh; what it reads and does not read is stated in its header.

Locale-bound character classes

[[:space:]], [[:alpha:]] and [[:alnum:]] match characters outside ASCII in any UTF-8 locale, C.UTF-8 included, in a bash =~ test, a bash glob pattern (${s//[[:space:]]/}, ${s%%[![:space:]]*}), GNU grep -E, sed -E and gawk: an em space (U+2003) is [[:space:]], é is [[:alpha:]]. Under C and POSIX none of them match. A letter range in grep -E ([a-z], [A-Za-z0-9._/-]) matches é under en_US.UTF-8 only. tr works on bytes and [:space:] leaves an em space alone. jq's \s, \d and \w match non-ASCII characters in every locale, C included. The one script that uses them, the fix-pass ledger checker, does so on purpose: text made only of Unicode spaces and format characters counts as blank there, which is the reading the check wants.

What a non-ASCII character does at a site depends on the consumer of the text, so the sites are settled by consumer, not by pattern:

  • Workflow and composite-action YAML. A text scanner and the YAML reader could disagree about a line holding such a character, and what GitHub Actions does cannot be checked offline. The Unicode spaces and line separators are refused in these files (workflow-ascii-space in workflow-hardening.md), which settles [[:space:]]. Other non-ASCII characters are not refused, so [[:alpha:]] and [[:alnum:]] still follow the locale in a scanner of these files.
  • Nix files. Nix rejects U+2003 as an invalid token, so a scanner that treats it as blank only accepts a file Nix then refuses.
  • Markdown. The site build reads an em space after # as a heading separator, as [[:space:]] does, so the scanners agree with their consumer.
  • Text the script wrote itself, and tr. Not reachable by a pull request, or byte-based.

A new scanner of workflow YAML needs no space class of its own: the file is ASCII-spaced by the lint above. A scanner of text from another source that must treat only space and tab as blank spells them out ([ \t]).

Text from files in yq expressions

A yq expression is a program, and text spliced into it is parsed as part of that program. A job key or a scope name read from a file, or a group name given as an argument, can close the quoted segment it was spliced into and go on as code: it can read another node, read nothing, or call the operators that read environment variables (env, strenv, envsubst) or files (load, load_str, load_props) and print what they read through error(). Text that breaks the expression, such as a quote or a space in a quoted path segment, stops the read instead.

A script hands such text to yq as data and never splices it into the expression: it sets an environment variable on the yq call and reads it with strenv. A lookup by that text is still not exact, because yq reads * and ? as wildcards both in an index (.[strenv(X)]) and in an == comparison, so a* also finds abc. A script that needs the exact key therefore finds it by comparing base64 text, which holds neither character: explode(.) | [to_entries[] | select((.key | tostring | @base64) == (strenv(X) | @base64))] | reverse | .[0] | .value. explode resolves merge keys and aliased entries, which to_entries does not follow, and the last of the matches is read, as yq's own index reads a name written twice. A key written as an alias is found by its anchor's text after explode.

A lookup whose name came from keys on the same map leaves explode out. keys prints a key written as an alias as the alias (*ka), explode would turn it into its anchor's text, and the two would never match; the lints that list jobs this way look a job up in jobs: as written. A path read after the lookup (.steps[], .permissions) then follows aliases and merge keys inside the job, and a read that prints the whole job and must search what an alias or a merge key brings in hands the matched job alone to explode.

A number yq printed in an earlier read goes back through env. check-run-block-strict.sh looks up no job by its key: its rows hold the document, job position and step index yq printed, and its reads find the job by them.

No script passes yq's --security-disable-env-ops, which also turns off strenv, or --security-disable-file-ops; with nothing spliced into an expression, neither guards anything. yq's system operator stays off unless --security-enable-system-operator is passed, and no script passes it.

No lint enforces this. Most expressions in scripts/ built with a variable splice a readonly constant, and an expression handed to a helper reaches yq as a single variable, so a matcher on the yq call cannot tell file text from a constant.

Job keys and merge lists in the jobs: reads

Lints that list a workflow's job keys, one per line, and read each back, or iterate jobs: with .jobs[] or to_entries, read files GitHub has not validated. GitHub Actions refuses a job id outside [A-Za-z_][A-Za-z0-9_-]*, and does not support YAML merge keys (actionlint reports GitHub Actions does not support YAML merge key), so a runnable workflow holds neither. A lint that reads such a file anyway misreads it:

  • a key holding a line break lists as two names, and an empty key lists as none, so the job under it is read as other jobs or not read;
  • a merge key (<<) directly under jobs: lists as the job <<, and the jobs it brings in are never reached by .jobs[] or to_entries.

A lint that lists job keys through the shared reading therefore counts a job key that is empty, holds a tab, a line break, a carriage return or a NUL, is not a scalar, or is a merge key as a finding that names the file and the key, and does not read that file's jobs. check-ci-job-in-summary.sh and check-prose-ci-names.sh still list the names they can resolve in such a file, so the refusal is its only finding and their other checks do not report those names missing. The shared reading refuses the carriage return and the NUL because yq counts a lone carriage return as a line break and a line-by-line read does not, and bash drops a NUL from the text it reads. Most of the lints listed below with a check of their own refuse a line break, a tab and a NUL in that check, and no such check refuses a carriage return. check-min-permissions.sh and check-egress-allowlist.sh also run the shared reading, which refuses the carriage return and the NUL for them; the others read a carriage return as part of the name. A generator (refresh-ci-dag.sh, refresh-enforcement-matrix.sh) stops with exit 2 and leaves its output untouched instead. The shared reading is first_odd_job_key and odd_job_key_message in scripts/lib/job-keys.sh; a lint that refuses odd job ids with a check of its own (check-permission-scopes.sh, check-min-permissions.sh, check-job-timeout-minutes.sh, check-checkout-persist-credentials.sh, check-upload-artifact-strict.sh, check-commitlint-config-explicit.sh, check-egress-allowlist.sh) refuses the merge key in that check too. A lint that looks up one named job (.jobs.verify) reads through a merge key, which is correct, and has no job key to refuse.

A merge key given a list (<<: [*P, *Q]) with a conflicting key is read first mapping wins, as the YAML merge specification says. yq reads it last mapping wins unless it is given --yaml-fix-merge-anchor-to-spec, so the scripts that source the library pass "${YQ_MERGE_SPEC[@]}" from it on their reads of a workflow that follow a merge key; the flag also silences the warning yq prints when it is absent. A workflow lint that does not source the library, check-workflow-on-branches.sh among them, reads a merge key by yq's own rule. A lint that reads a job after explode takes the first mapping as well.

The allowlist of check-permission-scopes.sh is split into tab-separated workflow, job and scope rows, so a workflow, job or scope name in it that holds a tab or a line break is refused (exit 1) before the rows are split: it could forge a row for another entry.

docs-audit-pressure.sh lists job ids only to name them in an advisory report, and its verdict is the commit count, so it does not exit on these keys. It leaves a workflow holding one out of the job lists at both refs (first_unlistable_job_key), so none of that workflow's jobs reads as added or removed, and names the number of such workflows in a line of the report. A merge key under jobs: is read through to the jobs it brings in, as the list reads it, and is not itself refused.

The same report's lint-group member list has no such guard. It splits yq's output on line breaks and drops a NUL, so a member holding a line break is listed as two names and one holding a NUL as one name without it; an empty member, or one holding a tab, a carriage return or any other character outside a job id (JOB_ID_RE in the script), is omitted. Refusing would end the reminder's run and lose its verdict, and the list is advisory. check-ci-job-in-summary.sh stops CI on an empty member and on one that spans lines, so a manifest on a measured ref holds neither.

No lint enforces this. tests/lib-job-keys.test.sh pins the library, and each lint's harness pins its own refusals.

Treefmt YAML quote gotcha

Prettier rewrites single-quoted YAML scalars to double-quoted. Run nix fmt before push, or use double quotes from the start.

mdformat-toc table-of-contents

Long markdown files opt in to an auto-generated TOC by seeding two markers:

<!-- mdformat-toc start --slug=github --maxlevel=3 --minlevel=2 -->
<!-- mdformat-toc end -->

The mdformat-toc plugin (wired in treefmt.nix) regenerates the TOC between the markers on every nix fmt / treefmt invocation. Files without markers are untouched. slug=github matches the GFM anchor scheme used by MkDocs-Material and gh-rendered previews; minlevel=2 skips the H1 title; maxlevel=3 keeps the TOC shallow on long runbooks. The plugin also writes an explicit <a name="..."> anchor next to each in-range heading. MkDocs-Material's own toc slugify collapses separator runs, so on some headings it disagrees with the GFM slug; the explicit anchor gives both resolvers one target to reach, and scripts/check-doc-anchors.sh accepts it as a target too.

Seeded files: docs/security/verification.md, docs/runbooks/dockerhub-recovery.md. Add a file to the list by pasting the marker pair anywhere after the H1, then running nix fmt.

Flake-show auto-block

scripts/refresh-flake-show.sh regenerates the auto-block in docs/reference/flake-outputs.md. Pre-commit hook flake-show-fresh has NIX_BUILD_TOP sandbox guard. Freshness of the generated block is also enforced in CI by the required doc-freshness context via tests/refresh-flake-show.test.sh, so a stale doc is caught even when the pre-commit hook is bypassed.

Generated-document ownership

Twelve regions are written by a refresh-*.sh generator and must not be hand-edited — one per @generates / @generates-block declaration those generators carry, which grep -h '^# @generates' scripts/refresh-*.sh lists (README.md carries two of them). Two are whole files: docs/reference/test-harnesses.md and docs/security/enforcement-matrix.md, whose generators emit the heading and every line around the table. The other ten are marked-off blocks inside a hand-written page:

  1. the flake-show block above;
  2. the scripts-reference block in docs/reference/scripts.md;
  3. the just-recipes block in docs/reference/just-recipes.md;
  4. the matching recipe block in README.md;
  5. the treefmt-config block in docs/reference/treefmt-config.md;
  6. the ci-dag block in docs/architecture/ci-dag.md;
  7. the pre-commit hook table in git.md;
  8. the CI summary block in README.md;
  9. the pin-parity block in auto-update.md;
  10. the ephemeral-refs-gap block above.

refresh-just-recipes.sh owns two of those, which is why eleven generators produce twelve regions. This list splits by what the generator emits; the annotations split by size-label ownership, where a page whose block is the whole of its content is @generates even though it splices into a fixed header — see labeling.md for the annotation convention. Prose outside a block is ordinary hand-written documentation and is edited normally.

Each has a *-fresh pre-commit hook, which is the gate a hand edit actually trips: the hooks exit 0 under NIX_BUILD_TOP, so they run on the commit path rather than inside nix flake check. Each also has a tests/refresh-*.test.sh harness in the required doc-freshness context, but most of those regenerate before asserting --check, which tests the generator rather than the committed file; refresh-flake-show, refresh-pin-parity and refresh-ephemeral-refs-gap are the three that --check the tree as committed. A block hand-edited past the hooks — through the web UI, or with --no-verify — is therefore caught by the next regeneration, not by CI.

Fixing content inside one of these regions means editing its generator — or, for the enforcement matrix, the enforcer: / ci: / hook: annotations in docs/invariant-index.md it renders from.

CHANGELOG.md is generator-owned too, on different terms: git-cliff writes it, no *-fresh hook covers it, and freshness is asserted by scripts/check-changelog-fresh.sh in the required changelog-links job over the released sections only — ## Unreleased changes with every merged commit. See changelog.md.

Scripts-reference round trip

A freshness gate proves a generator ran, not that it is right: when the generator drops or rewrites text, the committed page and a fresh render agree on the wrong output. scripts/check-scripts-reference-roundtrip.sh checks the scripts reference end to end instead. It reads every header the generator reads — each scripts/*.sh entry point not starting with an underscore, and each scripts/lib/*.sh library with its function @description blocks — with its own reader, not scripts/_script_docs.awk, because a checker built on the parser would agree with every text the parser drops. It renders the committed page with python-markdown and the extensions mkdocs.yml loads, configured as mkdocs.yml configures them, because several rewrite text rather than only markup: inlinehilite strips a #!lang prefix from a code span, and snippets replaces a line with a file. It reads mkdocs.yml as mkdocs does: the built-in toc, tables and fenced_code extensions load first, !ENV reads the first set variable as a plain YAML scalar, else its default, and !!python/name: imports the object it names. An extension list that cannot be read or loaded is a could-not-run, and so is a config that inherits another through INHERIT, which the check does not follow. Like the site build, it imports what mkdocs.yml names and reads the files snippets points at, so mkdocs.yml is trusted input to it, as it is to the site build.

Each unit of header text — a tag and the lines that continue it — is matched in its own part of the entry. A tag is # @tag, one blank after the hash; a comment that indents an @tag further is prose, and the page must show it as written. An @arg, @option, @exitcode or @stdout must be one whole item of its list, and each item is matched once, so two identical annotations need two items. An @example must be the entry's example block, line for line with the blank lines at its edges dropped and tabs expanded to four-column stops, as python-markdown expands them; text on an @example tag line is required, and the generator does not print it. Description text is found as whole words, in order, in the part before the first list label. Text other than an @example is compared with whitespace collapsed, and outside a colon-led indented run the backticks of a code span are dropped — but only those: a backtick the page shows literally is an altered unit. In description text, a list marker at the start of a prose line (-, *, +, or a number with . or )) may also be absent. Prose after a blank comment line that closes an @arg, @option, @exitcode or @stdout is a further description unit. A leading marker may be absent because the page passes through mdformat, whose CommonMark rules decide which lines become list items, before python-markdown renders it by its own rules, so the marker may show as text or as a bullet.

A table in description text is read by GFM's rules, because mdformat decides what is a table and python-markdown renders a table mdformat writes as its own block: a row holding a pipe, followed by a delimiter row of as many cells, both indented less than four columns. Its rows run to a blank line, a line indented four columns or more, a list item or a code fence. Its cells' text must appear as words in the description, without the pipes and the delimiter row; a cell splits at every pipe a backslash does not escape, inside a code span or not. Outside a code span, a pipe after one or two backslashes shows bare; inside one, the backslash shows. The renderers disagree on a delimiter row whose cells hold only colons and blanks: GFM reads no table, so mdformat leaves the lines as text, and python-markdown renders a table when they open a block. Such a run may show as its text or as its cells, split as python-markdown splits them, which keeps a pipe inside a code span in its cell. Other pipes that do not form a table are text and must show as written. A table under an @arg, @option, @exitcode or @stdout is joined into one line, and one in an @example or a colon-led indented run is fenced, so neither renders as a table and each is compared as written. A finding inside a table names the line of its header row.

An indented run whose lead-in line ends in a colon must be exactly one preformatted block on the page, line for line with its blank lines and indentation, a tab in the indent read as two spaces and an odd indent rounded up to even as the generator writes a fence, and a tab later in the line expanded to a four-column stop as python-markdown expands it; each fence is matched once, in order. Collapsing a run into a paragraph keeps every word and still loses the shape, so the word comparison alone would pass it.

Header text the generator never reads is a finding, in these shapes: prose before the first tag, other than the file's own path line, a first-line shebang and a # shellcheck directive; a # @description, # @arg, # @option, # @example, # @exitcode or # @stdout line in a comment block between the header's first blank line and the first line of code; and, in a library, one of those annotations outside a function's @description block, or a @description block that no function line follows.

Six limits are known. The check reads from header to page only: text the page adds, such as an invented sentence or item, is not reported, and text counts as shown when it is in the rendered text, even where a browser would hide it. A description compared as words does not see its blocks change kind — a sentence the page shows as a list item or a quote still matches. A table's cells are compared as one run of words, so a page that merges, splits or regroups cells while keeping their words in order passes, and a run with a dashless delimiter row passes whichever of its two readings the page shows. A run of backticks that opens no matching run is compared as the regular expression reads it, which can differ from the renderer. A line such as - - -, which python-markdown renders as a rule, is read as text. And since a leading marker may be absent, a page that drops a marker the header meant as text passes, and the numbers of a list rendered as a numbered list are not compared.

Header text outside a code span is Markdown on the page. A <placeholder> renders as an HTML tag and vanishes, a < in <- or <= shows with a backslash, and a glob's asterisks can open emphasis, so put such text in backticks. Escaping a < in the generator does not work: mdformat rewrites &lt; to \<, which python-markdown does not treat as an escape, so the site prints the backslash and still swallows the tag. In a table cell, a pipe inside a code span still ends the cell, and mdformat drops the cells a row has beyond its header's; escaping it as \| keeps the cell, but the site shows the backslash inside the code span.

It runs in the lint-doc-invariants group and as the check-scripts-reference-roundtrip pre-commit hook. Both run it with the Python bound in nix/renderer-python.nix, which carries python-markdown, Pygments, pymdown-extensions and PyYAML itself, plus the type checker in Python type checking. The hook puts that Python first on its own PATH. The group runs in devShells.default, where other inputs propagate their own python3, so the shell lists the bound Python as its first buildInputs entry and its interpreter leads PATH; listed later, a propagated python3 would run the check instead. checks.devshell-renderer-python fails nix flake check when it is not first. A python3 that cannot import python-markdown or PyYAML makes the check exit 2.

Python type checking

The mypy-strict pre-commit hook runs mypy --strict over every .py file under scripts/ and docs/_data/, and fails on any error. A Python file elsewhere in the tree is not checked until its directory is added to files in mypy.ini. mypy.ini holds the file list and every option, and the hook runs bare mypy, so a local mypy in the devShell checks the same files the same way.

The hook's interpreter is the round-trip check's own Python from nix/renderer-python.nix, which also carries mypy and the types-Markdown and types-PyYAML stubs: the code is checked against the python-markdown and PyYAML versions it runs with. The hook writes no cache (cache_dir = /dev/null), needs no network, and has no NIX_BUILD_TOP guard, so it runs inside nix flake check too, which is how the CI flake-check job enforces it.

The PyYAML stubs leave some loader methods unannotated, among them construct_object, resolve and add_multi_constructor, and strict mode rejects a call to any of them. mypy names such a call by the receiver's class, so untyped_calls_exclude names the checker's own SiteLoader, which declares no methods of its own: only the methods it inherits from PyYAML are exempt.

Payload shape-gate scenario coverage

scripts/check-payload-shape-scenario.sh requires every script matching a measured four-arm external-payload predicate (gh api, a *_JSON_OVERRIDE variable, a bare stdin slurp, or a scoped flake.lock read) to carry a harness scenario that feeds it a malformed payload and asserts exit 2, or a # payload-subject-exempt: <rationale> marker. The scenario match is anchored to the scenario call's own bare positional exit-code argument (parsed via shfmt --to-json), not to any 2 appearing as a substring, so a message that merely mentions the digit 2 in prose cannot be mistaken for the exit-code argument. Full rationale: Workflow hardening → payload-shape-scenario.

Reason-ladder unearned exemption

scripts/check-verify-reason-ladder.sh also rejects a # reason-ladder-exempt: <reason> marker that excuses nothing. The marker sits on a step's own id: line, so it always names a step that genuinely exists in the verify job — there is no shape here where a marker is attached to no site at all. What it can be is unearned, in either of two shapes: on a step id the attribution env already references via steps.<id>.outcome, where coverage would have passed that step whether or not the marker existed, or on the attribution step's own id, which coverage skips unconditionally regardless of any marker. Either shape is reported as drift and fails the run. Unlike this lint's sibling exemption markers, an unearned exemption here adds no field to the clean summary line, which stays a three-count line. Full rationale: Verification → Ladder coverage is linted.