Ctrl K
ComunidadGuías…

prek.toml

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Configuration file for `prek`, a git hook framework written in Rust.
# See https://prek.j178.dev for more information.
#:schema https://www.schemastore.org/prek.json

# Every hook runs at commit time and at no other stage. A hook without `stages` runs at EVERY
# stage, so a `.git/hooks/pre-push` shim left by an older `prek install` (from when the
# `pytest` hook ran pre-push, removed 2026-10-02) re-ran all of them over the pushed range,
# fixers included (#4018). Setting this here covers every checkout and any future shim; a hook
# that genuinely belongs at another stage sets its own `stages`.
#
# default_stages does NOT reach a hook whose upstream manifest declares `stages` itself, and
# pre-commit-hooks v6.0.0 lists `[pre-commit, pre-push, manual]` on three of ours. So every hook
# from a remote repo repeats `stages = ["pre-commit"]`, which also covers a manifest that gains
# `stages` on a Renovate bump. ENFORCED: ansible/tests/repo/test_prek_hooks_run_only_at_commit.py
default_stages = ["pre-commit"]

# Standard cleanup hooks
[[repos]]
repo = "https://github.com/pre-commit/pre-commit-hooks"
rev = "v6.0.0"

[[repos.hooks]]
id = "trailing-whitespace"
stages = ["pre-commit"]
# A vendored upstream manifest under a role's files/upstream/ is pinned byte for byte to a
# sha256 (scripts/dev/vendor_manifest.py), so no fixer may rewrite it.
exclude = "^ansible/roles/[^/]+/[^/]+/files/upstream/"

[[repos.hooks]]
id = "end-of-file-fixer"
stages = ["pre-commit"]
exclude = "^ansible/roles/[^/]+/[^/]+/files/upstream/"

[[repos.hooks]]
id = "check-yaml"
stages = ["pre-commit"]
# Flux's vendored install is a multi-document stream, which this hook refuses without
# --allow-multiple-documents. Allowing that repo-wide would pass a stray `---` in every other
# file, so the one stream is excluded instead: the validate-k8s-manifests hook parses its
# render, and ansible/tests/k8s/test_flux_role.py reads it.
# An upstream file under files/upstream/ is such a stream too, and its own pin covers it.
exclude = "^ansible/roles/(k8s/flux/files/gotk-components\\.yaml$|[^/]+/[^/]+/files/upstream/)"

[[repos.hooks]]
# Guards renovate.json (a syntax error there silently disables ALL dependency management)
# and every other JSON in the repo (Grafana dashboards, settings).
id = "check-json"
stages = ["pre-commit"]

[[repos.hooks]]
# prek.toml and pyproject.toml configure every other gate; a TOML syntax error in either
# fails those gates with a parser traceback rather than a pointer at the line.
id = "check-toml"
stages = ["pre-commit"]

[[repos.hooks]]
id = "check-merge-conflict"
stages = ["pre-commit"]

[[repos.hooks]]
id = "check-added-large-files"
stages = ["pre-commit"]

[[repos.hooks]]
id = "mixed-line-ending"
stages = ["pre-commit"]
args = ["--fix=lf"]
exclude = "^ansible/roles/[^/]+/[^/]+/files/upstream/"

# Ansible linting
[[repos]]
repo = "https://github.com/ansible/ansible-lint"
rev = "v26.10.1"

[[repos.hooks]]
id = "ansible-lint"
stages = ["pre-commit"]
# Scoped to `ansible/` and paired with pass_filenames — ansible-lint lints the files it is
# GIVEN instead of walking the tree itself. Its cost is almost purely per-file (measured:
# one task file 1.45s, five 1.98s, the whole repo 37s locally / 89.7s on the
# runner), so handing it a diff is what makes a PR's lint ~2s instead of ~90s.
#
# Coverage is unchanged on a full run. A walk examines 954 files, but exactly 325 of those are
# YAML and all 325 are tracked files under `ansible/` — the rest are .py/.j2/.sh that
# ansible-lint classifies and no ansible rule applies to. That 325 is precisely what this
# files/exclude pair passes.
#
# The regex must do its own excluding: `.ansible-lint`'s exclude_paths are NOT honoured for
# explicitly-passed files (verified — it happily lints `.github/workflows/ci.yml` when named
# directly). `ansible/collections/` needs no exclude here because it is gitignored and prek
# only ever passes tracked files, but secrets.yml is tracked AND encrypted, so it does.
files = "^ansible/.*\\.ya?ml$"
# home-assistant/files/ is Home Assistant config, not Ansible: configuration.yaml carries HA's
# own YAML tags (`!include`, `!include_dir_merge_list`, `!secret`), which ansible-lint's
# loader cannot construct, and the validate-ha-config hook below is that tree's linter.
# flux/files/gotk-components.yaml is Flux's install as `flux install --export` writes it, never
# edited, so a regeneration diffs as upstream's change alone; its block-sequence indentation
# trips yaml[indentation]. The validate-k8s-manifests hook validates its render.
# vars/cluster-manifests/ holds SOPS-encrypted Secret manifests (target slice D3). A YAML file
# under a `vars/` directory reads to ansible-lint as a vars file, which fails on `apiVersion`.
# They stay under ansible/vars/ because the creation rules in ansible/.sops.yaml and the
# runbooks point there, and `cluster_vars.py check` validates them instead.
# files/upstream/ holds upstream manifests byte for byte, which this repo's lint rules do not
# govern (scripts/dev/vendor_manifest.py).
exclude ="^ansible/(vars/secrets\\.yml|vars/cluster-manifests/.*|roles/k8s/home-assistant/files/.*|roles/k8s/flux/files/gotk-components\\.yaml|roles/[^/]+/[^/]+/files/upstream/.*)$"
# Cap ansible-core <3 so a surprise upstream major can't silently change lint behavior
# or break this hook on a day nothing in the repo changed (these deps aren't lock-pinned).
additional_dependencies = ["ansible-core>=2.18,<3.0", "jmespath"]
# NB the `--` sentinel and `"$@"`, same as bash-syntax-check below: prek appends the filenames
# to the entry, and after `bash -c '<script>'` those become the SCRIPT's positional params. A
# bare `... && ansible-lint -c .ansible-lint` would silently lint nothing at all.
#
# The `$# -eq 0` guard is load-bearing, and the opposite of what the line above implies.
# prek runs this hook even when NO staged file matches `files:` above, and `ansible-lint`
# with no paths does not lint nothing — it WALKS THE FILESYSTEM. So a commit touching only
# docs got a full-tree lint of ~1,600 files, including untracked and gitignored ones that
# CI never sees, and any violation among them rejected the commit. That is what stopped
# `docs-refresh.sh` committing: it stages only `docs/reference` and `docs/assets/generated`,
# so this hook received zero filenames, walked, failed, and the script reset -- leaving the
# tree permanently dirty, which in turn parked gitops-deploy behind origin.
#
# The exclude_paths in `.ansible-lint` are the older, partial answer to the same walk (see
# their comments, which name the worktree and styles/ trees). Excluding paths is a chase:
# every new untracked directory is another entry. Not walking is the fix.
#
# The `export` keeps ansible-core's local temp directory out of `~/.ansible/tmp`. ansible-galaxy
# and ansible-lint both import ansible-core's constants, which create `ansible-local-<pid>…`
# there and remove it only from an atexit handler. A hook killed by a signal, as when a
# harness kills a background `git commit`, runs no handler and leaves the directory behind
# (#6260). The target is the directory `ansible/tests/leakguard.py` sweeps of dead pids at
# the start of every pytest run, so a killed lint's directory lasts until the next run.
#
# ENFORCED by scripts/tests/test_ansible_lint_hook.py.
entry = "bash -c 'if [ \"$#\" -eq 0 ]; then exit 0; fi; export ANSIBLE_LOCAL_TEMP=\"${TMPDIR:-/tmp}/pytest-ansible-local-$(id -u)\"; ansible-galaxy collection install -r ansible/requirements.yml -p ansible/collections && ansible-lint -c .ansible-lint \"$@\"' --"
pass_filenames = true
# REQUIRED, not a tuning knob. Without it prek shards the filenames across parallel
# invocations — measured at 16 batches of ~21-39 files for a full run, each one re-running the
# `ansible-galaxy install` and paying ansible-lint's startup again, which made `--all-files`
# SLOWER than the walk it replaced (43.8s vs 37.0s) and lints each batch without sight of the
# others. One invocation gets the whole list, and ansible-lint parallelises internally anyway
# (it saturates ~3 cores on its own).
require_serial = true

# Secret scanning
[[repos]]
repo = "https://github.com/gitleaks/gitleaks"
rev = "v8.30.1"

[[repos.hooks]]
id = "gitleaks"
stages = ["pre-commit"]

# Shell script linting. shellcheck-py ships a precompiled shellcheck binary via pip
# (language = python) — no host apt package and no Docker, so it works unmodified on the
# ubuntu-latest CI runner via prek's own hook-env bootstrap. All severities are on by
# default (not just --severity=error): the repo's shell scripts are currently
# shellcheck-clean, so there's nothing to suppress yet. OPTIONAL (off-by-default) rules
# are a separate axis: the triaged accept/exclude policy for them lives in .shellcheckrc,
# which also silences `shellcheck -o all` sweeps (rc disables win over -o all).
[[repos]]
repo = "https://github.com/shellcheck-py/shellcheck-py"
rev = "v0.11.0.1"

[[repos.hooks]]
id = "shellcheck"
stages = ["pre-commit"]
# .bashrc is a SOURCED dotfile (no shebang by design — `shell(1)` sources it, never execs it) and
# is the stock Debian/Ubuntu skeleton .bashrc plus one guarded nvm block, not a script this gate
# is meant to police. Excluded rather than annotated so the near-stock file stays diffable against
# upstream. The findings it trips are all expected for that class of file: SC2148 (no shebang —
# correct, it's sourced), SC1090/SC1091 (can't follow `~/.bash_aliases` / nvm's dynamic path —
# intentionally conditional/per-host), SC2015 (`test ... && eval || eval` fallback idiom, not
# if/then/else — deliberate here).
exclude = "^ansible/roles/setup/config_files/files/\\.bashrc$"

# Render container docker-compose.yml.j2 templates with stubbed vars and assert
# the output is valid YAML — catches broken indentation / label-macro edits that
# ansible-lint and check-yaml miss (they don't render .j2 files).
[[repos]]
repo = "local"

[[repos.hooks]]
id = "validate-compose-templates"
name = "Validate rendered docker-compose templates"
entry = "uv run --frozen python scripts/validate/compose_templates.py"
language = "system"
pass_filenames = false
files = "^(cluster/services\\.yml|scripts/renderer/renderer_lib/jinja_filters\\.py|scripts/validate/validate_lib/ok_lines\\.py|hosts/components/pi-compose/services/[^/]+/compose\\.yaml\\.j2|ansible/templates/[^/]+\\.yml\\.j2|ansible/inventory/(host_vars/[^/]+|group_vars/all)\\.yml|scripts/validate/compose_templates\\.py|scripts/lib/ansible_jinja_env\\.py|ansible/filter_plugins/[^/]+\\.py|scripts/lib/(ansible_inventory|cli_help|exit_codes|estate|git|k8s_context|k8s_yaml|render_context|render_guard|repo_paths|yaml_fast)\\.py)$"

# Structurally validate the Home Assistant config (pure Python, no Docker): YAML syntax,
# duplicate keys, broken !include targets, and a syntax parse-check of inline + custom_templates
# Jinja. Catches authoring errors before a deploy recreates the container. Schema validation
# (hass --script check_config) is intentionally out of scope.
[[repos.hooks]]
id = "validate-ha-config"
name = "Validate Home Assistant config"
entry = "uv run --frozen python scripts/home_assistant/validate_ha_config.py"
language = "system"
pass_filenames = false
files = "^(ansible/roles/k8s/home-assistant/(templates/config|files)/.*|scripts/home_assistant/validate_ha_config\\.py|scripts/diagnostics/probe_lib/(core|ha|obs_api)\\.py|scripts/home_assistant/(ha_state_checks|ha_state_model)\\.py|scripts/lib/(ansible_inventory|cli_help|exit_codes|repo_paths|yaml_fast)\\.py)$"

# Validate that every provisioned Grafana dashboard's datasource references resolve to a
# datasource declared in datasources.yml.j2 (uid or name) or a Grafana built-in. Catches the
# silent "No data" / stale-uid class (e.g. a lingering hand-imported uid) before deploy.
[[repos.hooks]]
id = "validate-grafana-dashboards"
name = "Validate Grafana dashboard datasource uids"
entry = "uv run --frozen python scripts/validate/grafana_dashboard_uids.py"
language = "system"
pass_filenames = false
files = "^(ansible/roles/k8s/observability/files/dashboards/.*\\.json|ansible/roles/k8s/observability/templates/grafana\\.yaml\\.j2|scripts/validate/grafana_dashboard_uids\\.py|scripts/lib/(cli_help|exit_codes|repo_paths)\\.py)$"

# Check the alert plane's inputs with the tools that load them. promtool checks the rendered
# prometheus.yml, every rule file and every rule test; amtool checks the Alertmanager config;
# pint lints the rule files offline under scripts/validate/pint/offline.hcl.
# lokitool parses loki-homelab's LogQL rule files, and the hook fails when those files,
# loki_homelab_rule_groups and the rendered loki-homelab-rules ConfigMap disagree. alloy boots
# the pinned Alloy on loki-homelab's rendered config.alloy, because Alloy's parser rejects
# selectors that Loki accepts (#6177) and `alloy validate` does not build the components.
# scripts/validate/alert_tools.sh provisions promtool and amtool at the observability role's
# image tags, lokitool and alloy at loki-homelab's, and pint at its own pin, into alert-tools/ under the
# git common dir, shared by every worktree of the clone. CI's `alert_rules.py --prove-red` step
# is the tools' red proof.
#
# CI cost on ubuntu-26.04 runners, 2026-11-09: 118-154s, the sweep's costliest hook by ~10x (#7728).
#
# The `files` regex names every input the hook reads, because a PR run skips a hook no
# changed file matches: the role's rule, rule-test and Alertmanager sources and the templates
# that render them; loki-homelab's rule files, their ConfigMap template and its defaults, which
# list the groups and pin lokitool's version; the pint configs; its defaults, whose image tags
# set the tool versions; the inventory and
# the scrape_jobs filter, which feed prometheus.yml's loops; and the render's import closure,
# as validate-k8s-manifests names it. pass_filenames = false, so prek runs one instance and
# never races two provisions.
[[repos.hooks]]
id = "validate-alert-rules"
name = "Validate the alert plane and the Alloy config with promtool, amtool, pint, lokitool and alloy"
entry = "uv run --frozen python scripts/validate/alert_rules.py"
language = "system"
pass_filenames = false
files = "^(cluster/services\\.yml|scripts/renderer/(renderer_lib/[^/]+|shadow_diff)\\.py|scripts/deploy_tools/(chained_timeout|changed_set|kustomizations)\\.py|scripts/lib/plain_scalar\\.py|scripts/lib/narrow_git\\.py|ansible/roles/k8s/observability/(files/rules|tests/rules|files/alertmanager|tests/alertmanager)/.*|ansible/roles/k8s/loki-homelab/(files/rules/.*|templates/(rules-)?configmap\\.yaml\\.j2|templates/config/config\\.alloy\\.j2|defaults/main\\.yml)|ansible/roles/k8s/observability/templates/(prometheus|prometheus-rules|alertmanager|alertmanager-secret)\\.yaml\\.j2|ansible/roles/k8s/observability/defaults/main\\.yml|ansible/inventory/(host_vars/[^/]+|group_vars/all)\\.yml|ansible/filter_plugins/[^/]+\\.py|scripts/validate/alert_.*|scripts/validate/notification_renders\\.py|scripts/validate/pint/.*|scripts/validate/k8s_manifests\\.py|scripts/validate/validate_lib/(ok_lines|k8s_net_rules|k8s_schema|rule_test_parts)\\.py|scripts/lib/(ansible_inventory|ansible_jinja_env|cli_help|exit_codes|estate|git|jinja_comments|k8s_context|k8s_role_callers|k8s_roles|k8s_yaml|render_context|render_guard|repo_paths|yaml_fast)\\.py)$"

# Same render-then-parse guard as validate-compose-templates above, for the k8s manifests.
# Also parses the YAML *embedded* in ConfigMap/Secret values — the manifest wrapping Traefik's
# static config and Authelia's configuration.yml is trivially valid whatever those opaque block
# scalars contain, so an outer-only check would miss the indentation bugs that matter most. A
# broken manifest otherwise first shows up as `error validating data` partway through a kubectl
# apply, with some objects applied and some not.
[[repos.hooks]]
id = "validate-k8s-manifests"
name = "Validate rendered k8s manifest templates"
entry = "uv run --frozen python scripts/validate/k8s_manifests.py"
language = "system"
pass_filenames = false
# daniel-box's host_vars is in scope because the validator renders each role with that host's
# real containers_list entry (port, hostname) rather than a stub.
#
# The six `scripts/lib/k8s_*.py` modules are named one by one rather than matched by a
# `k8s_.*` pattern. The validator's logic lives in them, so an entry naming only
# `k8s_manifests.py` would stop firing on the code that does the work — a hook that quietly
# covers less is exactly what a pattern here would hide next
# time, since a rename makes an alternation fail loudly and a pattern match nothing.
files = "^(cluster/services\\.yml|scripts/renderer/(renderer_lib/[^/]+|shadow_diff)\\.py|scripts/deploy_tools/(chained_timeout|changed_set|kustomizations)\\.py|scripts/lib/plain_scalar\\.py|scripts/lib/narrow_git\\.py|scripts/validate/validate_lib/ok_lines\\.py|ansible/roles/k8s/.*\\.j2|ansible/templates/ingressroute\\.yml\\.j2|ansible/inventory/(group_vars/all|host_vars/daniel-box)\\.yml|scripts/validate/k8s_manifests\\.py|scripts/lib/(ansible_inventory|ansible_jinja_env|cli_help|exit_codes|estate|git|jinja_comments|k8s_context|k8s_role_callers|k8s_roles|k8s_yaml|render_context|render_guard|repo_paths|yaml_fast)\\.py|scripts/validate/validate_lib/(k8s_net_rules|k8s_schema)\\.py|scripts/validate/schemas/(kubernetes\\.io|traefik\\.io)/.*\\.json|ansible/filter_plugins/[^/]+\\.py)$"

# Render every Jinja-templated shell script (*.sh.j2) with stubbed vars and lint the OUTPUT
# (bash -n + shellcheck) — the same render-then-lint pattern as validate-compose-templates /
# validate-k8s-manifests, but for shell instead of YAML. identify tags a `.sh.j2` file as
# {jinja, text}, never `shell`, so the bash-syntax-check / shellcheck hooks above never see these
# templates no matter how broken the rendered script is. shellcheck comes via the `shellcheck-py`
# dev dependency (pyproject.toml) so `uv run` can reach it; the script fails closed (not skips) if
# shellcheck isn't on PATH, so this can't silently degrade to bash -n alone.
[[repos.hooks]]
id = "validate-shell-templates"
name = "Validate rendered shell templates (bash -n + shellcheck)"
entry = "uv run --frozen python scripts/validate/shell_templates.py"
language = "system"
pass_filenames = false
# group_vars/all.yml is in scope because the validator renders with it (ALL_VARS) — an
# edit there can change what the rendered scripts contain. The five library modules are
# where the render, the lint and the two cron rules actually live; naming only the entry point
# would leave the hook's selector matching a 197-line facade while its subject sat elsewhere.
# A host component's templates are in scope since H3.8b, with its component.yml, whose
# defaults the render reads, and each host's package.yml, which names the host it renders for.
files = "^(scripts/renderer/renderer_lib/jinja_filters\\.py|hosts/components/.*|hosts/[^/]+/package\\.yml|scripts/validate/validate_lib/ok_lines\\.py|ansible/roles/.*\\.sh\\.j2|ansible/inventory/group_vars/all\\.yml|scripts/validate/shell_templates\\.py|scripts/lib/(ansible_inventory|ansible_jinja_env|cli_help|exit_codes|estate|git|k8s_context|k8s_yaml|render_context|render_guard|repo_paths|yaml_fast)\\.py|scripts/validate/validate_lib/(cron_checks|cron_targets|shell_lint)\\.py|ansible/filter_plugins/[^/]+\\.py)$"

# Render every systemd unit template (*.service.j2 / *.timer.j2) with stubbed vars PLUS the
# owning role's real defaults, and verify the output with `systemd-analyze verify`. Neither
# validate-shell-templates (*.sh.j2) nor validate-k8s-manifests (roles/k8s/*/templates/*.j2)
# covers these — without this hook a typo'd directive key reaches a live host with systemd
# loading the unit anyway and only a journal warning to show for it. systemd-analyze's own exit
# status is ignored — it reads 0 on a typo'd key on at least one measured host — so the verdict
# comes from scanning stderr for a diagnostic attributed to the rendered unit's own path. Also
# renders every polkit rule beside these units (*.rules.j2, JavaScript, not a service/timer) and
# `node --check`s each — the sibling gap: content-checked by the roles' tests, but their JS
# syntax nowhere.
[[repos.hooks]]
id = "validate-unit-templates"
name = "Validate rendered systemd unit templates (systemd-analyze verify)"
entry = "uv run --frozen python scripts/validate/unit_templates.py"
language = "system"
pass_filenames = false
# Every setup role's defaults/main.yml is in scope because the validator layers the OWNING
# role's real defaults onto the render context — a schedule/timeout default moving there can
# change whether the rendered unit still parses (e.g. renovate_agent_oncalendar,
# gitops_deploy_tick_interval). group_vars/all.yml is in scope for the same reason
# validate-shell-templates includes it: host_python_version and the other cross-role fallbacks
# it carries feed every unit's render. The polkit rules are a third suffix in the first
# alternative. Every file under hosts/ and the host renderer's modules are in scope because the
# validator renders every host package through that renderer (H3.2c); a package unit may be a
# static file, so no suffix narrows the hosts/ alternative.
files = "^(scripts/lib/narrow_git\\.py|hosts/.*|scripts/renderer/renderer_lib/[^/]+\\.py|scripts/deploy_tools/(chained_timeout|changed_set|kustomizations)\\.py|scripts/validate/validate_lib/ok_lines\\.py|ansible/roles/.*\\.(service|timer|socket|rules)\\.j2|ansible/roles/setup/.*/defaults/main\\.yml|ansible/inventory/group_vars/all\\.yml|scripts/validate/unit_templates\\.py|scripts/lib/ansible_jinja_env\\.py|ansible/filter_plugins/[^/]+\\.py|scripts/lib/(ansible_inventory|cli_help|exit_codes|estate|git|jinja_comments|k8s_context|k8s_role_callers|k8s_roles|k8s_yaml|render_context|render_guard|repo_paths|yaml_fast)\\.py)$"

# Flag bash's `${#var}` length expansion in a Jinja template, before anything renders it. `{#`
# opens a Jinja comment, so a shell script inside a `.j2` that writes `${#var}` either fails the
# render with `Missing end of comment tag` (a message naming neither bash nor a fix) or, when a
# `#}` appears later, has everything between the two silently dropped from the output. The
# render-then-lint hooks above cannot see either case: the first never renders, and the second
# renders with the evidence already deleted. This one scans template TEXT, across every `.j2` in
# the tree rather than one validator's own subtree.
[[repos.hooks]]
id = "validate-jinja-bash-collisions"
name = "Flag bash ${#var} length expansions in Jinja templates"
entry = "uv run --frozen python scripts/validate/jinja_bash_collisions.py"
language = "system"
pass_filenames = false
# Every template in the tree, plus the scanner itself. No inventory or defaults file is in
# scope: this reads template text and renders nothing, so no variable can change its verdict.
files = "^(ansible/.*\\.j2|scripts/validate/jinja_bash_collisions\\.py|scripts/lib/(cli_help|exit_codes|repo_paths)\\.py)$"

# Fail when a root-level file exists that `.gitignore`'s `/*` hides from git. Such a file
# stages nothing and lands nothing through a green CI and a clean merge. A
# hidden file is by definition never staged, so no `files` pattern can trigger this: it runs
# on every commit, and on a clean CI checkout it finds nothing to report.
[[repos.hooks]]
id = "validate-root-ignored-files"
name = "Root-level files hidden by .gitignore's deny-all"
entry = "uv run --frozen python scripts/validate/root_ignored_files.py"
language = "system"
pass_filenames = false
always_run = true

# Render every setup-plane template and fail on a variable nothing defines. The guards above
# stop at their own trees — containers, k8s, *.sh.j2, *.service.j2 — so a setup template with
# an undefined variable, a Jinja syntax error, or a rename its consumer missed passes the whole
# gate green. The setup plane is hand-applied by construction, so no
# routine deploy catches it either: the first run of a bad template is a bring-up someone
# reaches for during an incident. A name may be undefined only when it is Ansible-supplied, a
# SOPS secret in the plaintext rotation registry, supplied at runtime by a setup-plane task, or
# defined in the inventory — the last two derived from the tree, so a producer renamed without
# its consumer is exactly what stops matching. The YAML templates are additionally parsed.
[[repos.hooks]]
id = "validate-setup-templates"
name = "Render setup-plane templates and check every variable resolves"
entry = "uv run --frozen python scripts/validate/setup_templates.py"
language = "system"
pass_filenames = false
# The setup roles' tasks are in scope, not just their templates: the runtime-supplied names
# come from the `set_fact` / `vars:` / `register:` keys those tasks carry, so a rename there
# changes the verdict for a template the same commit may not touch. secret_rotation.yml is in
# scope for the same reason — it is the plaintext census of SOPS names (never values), and a
# secret renamed there without its template is the shape this guard exists to catch.
files = "^(scripts/renderer/renderer_lib/jinja_filters\\.py|scripts/validate/validate_lib/ok_lines\\.py|ansible/roles/setup/.*\\.(j2|yml)|ansible/inventory/(group_vars/all|host_vars/[^/]+)\\.yml|ansible/secret_rotation\\.yml|scripts/validate/setup_templates\\.py|scripts/lib/ansible_jinja_env\\.py|ansible/filter_plugins/[^/]+\\.py|scripts/lib/(ansible_inventory|cli_help|exit_codes|estate|git|k8s_context|k8s_yaml|render_context|render_guard|repo_paths|yaml_fast)\\.py)$"

# Lint the GitHub Actions workflows themselves. `check-yaml` above only proves they PARSE —
# nothing until now checked that a job's `needs:` names a job that exists, that a `${{ }}`
# expression references a real context, or that a matrix key is spelled the way the steps read
# it. Those fail at trigger time, in a workflow whose whole job is to be the gate. actionlint
# also runs shellcheck over every `run:` block (shellcheck-py is already a dev dep, so it is on
# PATH under `uv run`), which the shellcheck hook above cannot do — identify tags a workflow as
# yaml, so the shell embedded in it is invisible to a shell-typed hook.
#
# Via the actionlint-py package + `uv run`, matching the other local hooks, NOT the upstream
# rhysd/actionlint pre-commit repo: that hook is `language = "golang"`, which on a machine
# without Go (daniel-box) makes prek fetch a ~270 MB managed toolchain and build from source.
#
# It reads `.github/actionlint.yaml`, which declares the one hosted runner label actionlint's
# own hard-coded list does not carry yet. That file says which, and when to delete it.
[[repos.hooks]]
id = "actionlint"
name = "Lint GitHub Actions workflows"
entry = "uv run --frozen actionlint"
language = "system"
files = "^\\.github/workflows/[^/]+\\.ya?ml$"

# The security half of workflow linting, which actionlint does not do: a checkout that leaves
# the token on disk, a `${{ }}` expression injected into a `run:` shell, an unpinned action, a
# `pull_request_target` reading the PR head. Offline audits
# only: the online ones ask GitHub's API and would need a token on every commit. It also audits
# the local composite actions under .github/actions/, which actionlint does not lint at all.
[[repos.hooks]]
id = "zizmor"
name = "Audit GitHub Actions workflows (zizmor)"
entry = "uv run --frozen zizmor --no-progress --no-online-audits"
language = "system"
files = "^\\.github/(workflows/[^/]+|actions/[^/]+/action)\\.ya?ml$"

# Gate that the secret-rotation registry (ansible/secret_rotation.yml) stays in sync with the
# encrypted secrets.yml — fails the commit if a secret was added/removed without running
# `secret_rotation.py sync`. Read-only: reads plaintext key NAMES only, never decrypts a value
# (no age key needed), so it's CI-safe. Catches the drift at commit time instead of a day later
# when the daily "Secret Rotation" Kuma monitor goes down.
[[repos.hooks]]
id = "secret-rotation-registry-sync"
name = "Secret-rotation registry in sync with secrets.yml"
entry = "uv run --frozen python scripts/secrets_mgmt/secret_rotation.py audit --check"
language = "system"
pass_filenames = false
# The whole `scripts/secrets_mgmt/` tree, not `secret_rotation.py` alone: `load_registry`,
# `sops_names` and every other process boundary the gate depends on moved into
# `rotation_tools.py`, and a per-file list here is a list that goes stale on the next split.
# The rest is the import closure of `audit --check`: `consumers` reaches `k8s_lookups`.
files = "^(scripts/lib/narrow_git\\.py|ansible/vars/secrets\\.yml|ansible/secret_rotation\\.yml|scripts/secrets_mgmt/.*\\.py|scripts/lib/(cli_help|exit_codes|git|k8s_lookups|repo_paths|yaml_fast)\\.py)$"

# The cluster's SOPS files (target slice D3): the cluster file
# ansible/vars/cluster-vars.sops.env, the manifests under ansible/vars/cluster-manifests/ and
# the freshness evidence ansible/vars/cluster-vars.source. Only `cluster_vars.py write`
# writes them, so this runs the checks that need no render or key: every value is ciphertext,
# a file whose content moved also moved its MAC and lastmodified lines since HEAD, each file
# is encrypted to its own creation rule, and .source matches secrets.yml's ciphertext. A
# plaintext value or a hand edit then fails here rather than after a push. From target slice
# PR D3.2c, which commits the first cluster file, CI's full `check` is the backstop for a
# commit made without hooks, which is how the rotation cron commits. While no cluster file
# exists the hook checks nothing and passes, in about 0.1s.
# `files` is the cluster paths plus the entry's import closure, which
# scripts/tests/test_prek_files_cover_entry_imports.py derives and holds this regex to. The
# closure reaches the renderer through `write` and the full `check`, which this hook never
# runs, but a change there must still rerun the hook rather than merge with it skipped.
[[repos.hooks]]
id = "cluster-vars-ciphertext"
name = "Cluster SOPS files are ciphertext written by cluster_vars.py"
entry = "uv run --frozen python scripts/secrets_mgmt/cluster_vars.py check --no-render --base HEAD"
language = "system"
pass_filenames = false
files = "^(ansible/vars/cluster-|ansible/filter_plugins/[^/]+\\.py$|hosts/lib/host_lib\\.py$|scripts/(lib|renderer|secrets_mgmt)/.+\\.py$|scripts/deploy_tools/(chained_timeout|changed_set|kustomizations)\\.py$)"

# Assert uv.lock stays consistent with pyproject.toml. Every unattended production path
# (gitops_deploy, secret-rotation-audit) runs `uv run --frozen`, which installs
# the committed lock AS-IS and never re-resolves — so a pyproject edit merged without a refreshed
# lock reaches the host as an environment missing the dependency it needs, surfacing later as a
# confusing import failure (a deploy rollback+hold, or the Secret Rotation Kuma going stale).
# Every `uv run` hook in this file carries `--frozen` too, so the gate resolves from the
# same lock the deployer does rather than silently regenerating one in the ephemeral checkout;
# what `--frozen` cannot do is notice the drift, which is this hook's job: `uv lock --check`
# (read-only; exits non-zero on drift, never writes) mirrors the prod invariant at commit time.
# Renovate's weekly lockFileMaintenance refreshes the lock but is NOT a same-commit gate, so a
# human pyproject edit is the open escape hatch this closes.
[[repos.hooks]]
id = "uv-lock-check"
name = "uv.lock in sync with pyproject.toml"
entry = "uv lock --check"
language = "system"
pass_filenames = false
files = "^(pyproject\\.toml|uv\\.lock)$"

# Rewrites a role CLAUDE.md's generated `## At a glance` block in the commit that moves one of
# its sources, and fails like a formatter so the author stages the doc and commits again
# (#3698). Before it, every such edit went red in CI on
# test_every_deployed_role_block_matches_what_the_generator_writes_now and cost a fix commit.
# The test stays as the backstop for a commit that skipped prek.
#
# `files` is every input the generator reads: the roles, the inventory, the shared templates
# whose macros declare claims, the bring-up playbooks at ansible/ (`glance_facts.
# SETUP_PLAYBOOKS`), the filter plugin it imports, and the Python under scripts/docs and
# scripts/lib. A role CLAUDE.md matches too, so a hand edit inside the
# markers is reverted in the same commit. None of the docs-refresh cron's three staged paths
# match, so that cron's commit never writes under ansible/roles/. Renovate edits defaults
# without prek, but only an image tag or digest, which the block does not print.
#
# Cost, measured: 0.5s wall for a full run over all three planes.
[[repos.hooks]]
id = "regen-role-glance"
name = "Role At a glance blocks match their sources"
entry = "uv run --frozen python scripts/docs/gen_role_glance.py --fix"
language = "system"
pass_filenames = false
files = "^(cluster/services\\.yml|ansible/(roles|inventory|filter_plugins|templates)/.*|hosts/components/.*|hosts/[^/]+/package\\.yml|hosts/packages/.*|ansible/[^/]+\\.ya?ml|scripts/(docs|lib)/.*\\.py|scripts/renderer/renderer_lib/.*\\.py|scripts/deploy_tools/(chained_timeout|changed_set|kustomizations)\\.py|scripts/deploy_tools/setup_lib/setup_gates\\.py)$"

# Rewrites a stale docs fragment (docs/assets/generated/fragments/) in the commit that moves
# one of its tunables, and fails like a formatter so the author stages the fragment and commits
# again (#3827). Before it, `static-ratchet-tests` ran
# test_every_committed_fragment_matches_what_the_generator_writes_now at commit time, which
# only failed, and the root CLAUDE.md carried a row teaching the hand repair. It sits above
# `static-ratchet-tests` so that test reads the rewritten fragment.
#
# `files` is every input the generator reads: role defaults and templates (the auto-deploy
# coverage walks every k8s role's defaults, which no fragment header lists), the inventory,
# the filter plugin, `ansible/secret_rotation.yml`, the deployer's deploy_changes.py, and the
# Python under scripts/ that reads or renders a fragment. The fragments themselves match too,
# so a hand edit is reverted in the same commit. That makes the hook fire on the docs-refresh
# cron's commit, which stages docs/assets/generated/; build_docs.py regenerates the fragments
# into that directory before the cron commits, so there it writes nothing and exits 0.
#
# Cost, measured: 0.2s wall for a full run.
[[repos.hooks]]
id = "regen-doc-fragments"
name = "Docs fragments match their sources"
entry = "uv run --frozen python scripts/docs/gen_doc_fragments.py --fix"
language = "system"
pass_filenames = false
files = "^(cluster/services\\.yml|ansible/(roles|inventory|filter_plugins|templates)/.*|hosts/components/.*|hosts/[^/]+/package\\.yml|ansible/[^/]+\\.ya?ml|scripts/.*\\.py|docs/assets/generated/fragments/.*)$"

# Lower each ratchet allowlist entry to what its file is today, so a PR that SHRINKS a listed
# module needs no hand edit. An entry has to MATCH its file (the `DECIDED:` marker in
# ansible/tests/_ratchet.py), which made every such PR edit a line by hand: 70 commits touched
# one of the two lists in the 30 days to 2026-09-28, 49 of them only lowering or deleting.
# It only ever tightens -- a grown file's entry is left verbatim, so the ratchet still fails on
# it -- and it writes unconditionally, never running the suite first, which is what keeps it out
# of the #1799 repair deadlock.
#
# `static-ratchet-tests` sits after this hook, and CI's `pytest` job and `uv run pytest` run
# after the commit, so by the time test_no_module_is_longer_than_its_cap_or_its_allowlist_entry
# reads the lists off disk this hook has already rewritten them. Like a formatter, it exits non-zero when it
# rewrote a list, so the author stages what it wrote.
#
# Cost, measured: 4.0s wall for a full run, almost all of it the monkeypatch
# census AST-parsing every test module. The `files` gate keeps it off a docs-only commit,
# and prek SKIPS a hook whose gate matches nothing even with pass_filenames = false
# (verified), so the docs-refresh and eval-run crons never pay it.
[[repos.hooks]]
id = "tighten-ratchet-allowlists"
name = "Ratchet allowlist entries match their files"
entry = "uv run --frozen python scripts/dev/tighten_ratchets.py --tighten"
language = "system"
pass_filenames = false
# Any Python file can be the one that shrank, and the lists themselves so a hand edit that
# raises an entry is pulled back down in the commit that writes it.
files = "(\\.py|^ansible/tests/repo/(module_length|monkeypatch)_allowlist\\.txt)$"

# Lint the fact citations in any CLAUDE.md section this branch touched, against origin/master
# (`ansible/tests/repo/test_every_section_lints_clean.py` holds every section at every commit;
# this names the finding at commit time). `--changed-since` needs a real git history, which `--all-files` on a shallow
# runner may not have for origin/master — see the `land-after-merge`/CI fetch-depth notes.
# CI's prek jobs check out with fetch-depth: 0 and fetch the base branch, so origin/master is
# there on a PR; on a push to master it is HEAD, and the range is empty.
#
# The same run adds `vanished-identifier`: a CLAUDE.md still naming an identifier this branch
# removed from the last non-Markdown file holding it. That commit is usually code-only, so the
# gate fires on any non-Markdown file as well as on a CLAUDE.md. A commit touching only other
# Markdown stays skipped, as the docs-refresh and eval-run crons' commits did before. Measured
# 2026-10-10 on daniel-box, three runs each, against the same branch diff (3 commits plus the
# uncommitted rule): the code
# before the rule took 0.32-0.33s and the code with it 0.37-0.39s. A code-only commit used to
# skip the hook, so it pays the whole ~0.4s. Against a 300-commit range the run took 1.9s.
[[repos.hooks]]
id = "facts-lint-changed"
name = "fact citations in edited CLAUDE.md sections, and identifiers the branch removed"
entry = "uv run --frozen python scripts/dev/fact_status.py lint --changed-since origin/master"
language = "system"
pass_filenames = false
files = "(^|/)CLAUDE\\.md$|^(?!.*\\.md$)"
# The non-Markdown paths the docs-refresh and eval-run crons commit. Neither cron can edit a
# CLAUDE.md, so a finding there would only park its publish, which the docs-refresh contract's
# next-run review says to check before a hook gains one of its staged paths.
exclude = "^(docs/assets/generated/|scripts/dev/pytest_shard_weights\\.json$|evals/history\\.json$)"

# Twelve static test modules that turned more PRs red than any other test the author's own code
# did not break. The first four were 24 of 154 failed CI runs from 2026-08-30 to 2026-10-05
# (#3605). The three census-row modules and the role CLAUDE.md check failed 6 PRs between them
# after that, to 2026-10-09 (#4008). Three of those were the role check's size arm, which the
# operator retired on 2026-10-12 (#2826). The module still runs its existence and tag checks.
# The last four are the other census-row modules: #4894 went red in CI on the suite module's
# glob-census row on 2026-10-13, and #4904 added all four. Each failure names its own repair,
# so finding it at commit time saves a CI round trip and a fix commit. The first four ran in
# 3.4s on daniel-box and 7.5s on daniel-server; all eight measured 10.7-12.6s on daniel-server
# against 8.1-8.8s for the four, back to back on 2026-10-09. On daniel-box on 2026-10-13, back
# to back, the eight measured 14.4-14.9s and all twelve 15.0-15.2s. The whole suite left prek
# on 2026-10-02 because it cost 89-235s, and still runs only in CI and the two crons.
#
# Cost, `prek run static-ratchet-tests --all-files`, back to back against the parent commit on
# 2026-10-18 (#5501), three runs a side. On daniel-box at load average 3.2-4.7, it took
# 9.2-9.4s against 19.0-19.2s. On daniel-server at load average 4.6-6.5, it took 14.3-16.8s
# against 31.5-45.5s; at 7.5-12.8, under other sessions' test runs, 21.4-34.7s against
# 40.5-65.9s. #5501 had measured 27.6s at load 3.3 and 49.0s at load 7.0 there. The census
# detectors over the test modules held most of the cost, re-walking modules that could not
# match; each now skips a module that cannot spell a hit. `--dist loadscope` keeps each module
# on one worker, so the slowest module bounds the hook, and `--dist load` measured no faster on
# daniel-box. The slowest was the render-and-path census, about 6.5s alone with `-n0` on
# daniel-box, so it and the git-and-subprocess census moved to the
# `static-ratchet-tests-test-modules` sibling below (#5599). Measured again on 2026-10-28,
# three rounds interleaved with the parent commit, each run started at a load average of 3.5
# or less. On daniel-server at load average 2.9-3.4, it took 12.1-12.6s against 20.6-21.6s.
# The module length ratchet and the Python census rows now bound it, each about 7.5s alone with
# `-n0` on daniel-box at load average 9.7.
#
# It sits after `tighten-ratchet-allowlists` because the ratchet test reads the lists that hook
# rewrites. `files` is wider than `.py`: a cited
# facts atom is often a YAML key or a `DECIDED:` marker in a template, and the doc fragments
# read role defaults, inventory and the fail2ban template. The shard partition test reads
# ci.yml and pyproject.toml's `testpaths`. test_role_claude_md reads every role's CLAUDE.md
# beside its tasks and templates. The census rows read tracked `.py` files, and four more
# kinds of path:
# - every workflow under .github/workflows/, for the runner-pin and shared-fetch rows;
# - any file under a role's templates/, defaults/ or files/, for the row that refuses a
#   literal uptime-kuma port, because a `.sh` or `.conf` there can dial it;
# - Markdown and JSON under .claude/skills/, .claude/agents/ and evals/cases/, for the rows
#   on the review surfaces' standing-list slug and the agents' inline re-flag list;
# - a compiled module (`.pyc`, `.so`, anything under __pycache__/), for the row that refuses
#   a tracked one.
# The macro row reads every Markdown doc under docs/ and .claude/ and every CLAUDE.md, and only
# the CLAUDE.md files, the skills and the agents are gated. A docs/ page or a .claude/rules/
# file that names a deleted macro goes red in CI instead. Gating all Markdown would charge every docs-only commit the cost above, and
# the edit that usually breaks the row deletes a macro template, which `.j2` already gates.
#
# CI skips it on both `prek run` steps, because its `pytest` job runs the same ten files.
# The `static-ratchet-tests-*` siblings below carry the eleven more #5481 added and six more
# from the 2026-10-25 survey of a week's PR reds.
# That job's checkout is not shallow (`fetch-depth: 0`), so the ratchet's allowlist comparison
# runs there too, against the merge base with origin/master (#4009). docs-refresh
# stages scripts/dev/pytest_shard_weights.json, so this fires on that cron's commit, which
# runs the whole suite green first. eval-run stages nothing this matches. To commit past it, `SKIP=static-ratchet-tests git commit`.
# The shell, `hosts/` and wait-source paths are the scripts the shimmed-path census row reads.
[[repos.hooks]]
id = "static-ratchet-tests"
name = "Static ratchet tests (module length, section lint, shard partition, doc fragments, role CLAUDE.md, census rows)"
entry = "uv run --frozen pytest -q -p no:cacheprovider ansible/tests/repo/test_module_length_ratchet.py ansible/tests/repo/test_every_section_lints_clean.py ansible/tests/repo/test_pytest_shards_partition_the_suite.py scripts/docs/tests/test_gen_doc_fragments.py ansible/tests/repo/test_role_claude_md.py ansible/tests/repo/test_census_rows_python.py ansible/tests/repo/test_census_rows_suite.py ansible/tests/repo/test_census_rows_roles.py ansible/tests/repo/test_census_rows_text.py ansible/tests/repo/test_census_rows_layout.py"
language = "system"
pass_filenames = false
files = "(\\.py$|(^|/)CLAUDE\\.md$|^scripts/dev/pytest_shard_weights\\.json$|^ansible/tests/repo/module_length_allowlist\\.txt$|^pyproject\\.toml$|^\\.github/workflows/[^/]+\\.yml$|^ansible/.*\\.(ya?ml|j2|ini)$|^ansible/roles/[^/]+/[^/]+/(templates|defaults|files)/|^(\\.claude/(skills|agents)|evals/cases)/.*\\.(md|json)$|\\.(pyc|so)$|(^|/)__pycache__/|\\.(sh|bash)$|^hosts/(lib|components)/|^\\.claude/wait-sources/)"

# The two census modules over the pytest-only modules, `_test_module_rules.pytest_only_modules`:
# the git and subprocess rows and the render-and-path rows. They were the slowest two in the
# hook above, about 3.8s and 6.5s alone with `-n0` on daniel-box, and under `--dist loadscope`
# the slowest module bounds a hook (#5599). Their census reads only the tracked modules
# `is_pytest_only` accepts under a test root, so the gate is those paths (a `test_*.py`, a
# `conftest.py`, anything under a `tests/` directory), the import closure the two modules
# reach through scripts/lib and ansible/tests, and pyproject.toml and uv.lock. A commit that
# touches only non-test `.py` outside scripts/lib/ skips both. prek.toml is in the gate
# because test_census_rows_test_modules.py reads this hook's own `files`.
#
# A commit that matches both gates pays both, because prek runs hooks one after another. On
# daniel-server on 2026-10-28 at load average 2.9-3.5, this hook took 11.8-13.7s with `-n 2`
# against 17.0-20.4s with `-n0`, and the two hooks together took 26.0-27.4s, against 20.6-21.6s
# for the single hook before the split. Only a commit that skips this gate gains; parsing each
# test module once across the census rows is the lever left for the rest (#6437).
[[repos.hooks]]
id = "static-ratchet-tests-test-modules"
name = "Census rows over the pytest-only modules (git, subprocess, render and path helpers)"
entry = "uv run --frozen pytest -q -n 2 -p no:cacheprovider scripts/tests/test_census_rows_test_modules.py scripts/tests/test_census_rows_test_renders.py"
language = "system"
pass_filenames = false
files = "^((scripts|ansible/roles|\\.claude/hooks|evals|hosts/components|hosts/lib)/(.*/)?((test_[^/]*|conftest)\\.py|tests/.*\\.py)|(ansible|\\.claude)/tests/.*\\.py|scripts/lib/[^/]+\\.py|prek\\.toml|pyproject\\.toml|uv\\.lock)$"

# Eleven more cheap ratchet and census modules, which failed 15 PR runs across 13 PRs between
# 2026-10-15 and 2026-10-17 (#5481). They sit in sibling hooks rather than in the entry above,
# so that each runs only on a commit that can change its verdict. Widening the gate above to
# `renovate.json` or a planning doc would make those commits pay all twenty-nine modules.
# Each `files` gate is the data the tests read, plus the test module and its import closure
# (scripts/tests/test_prek_files_cover_entry_imports.py), plus pyproject.toml and uv.lock,
# which decide the pytest that runs them. None matches a path the docs-refresh or eval-run
# crons commit.
#
# Cost, measured on daniel-server on 2026-10-17, one module at a time with `-n0`, each
# including its ~0.4s interpreter start: lander deadlines 5.4s, the Renovate pair 3.5-4.0s
# each, import direction 3.6s, checksum annotations 3.5s, tracked Python hazards 3.1s, prek
# files 1.9s, exit codes 1.5s, shell `--help` 1.3s, the owner census 0.8s, probe health 0.4s.
# That is 19s together, well above the issue's 7.5s estimate from recorded weights, because
# each module's fixtures render or walk the tree. As hooks under `prek run <id> --all-files`,
# at load average 3.3: `-python` 4.6s, `-owners` 0.9s, `-lander` 5.2s, `-checksum` 3.5s,
# `-shell` 1.3s, `-renovate` 3.9s, against 27.6s for the hook above in the same run. The
# gates keep a typical commit near budget: a `.py` commit outside scripts/lib/,
# deploy_tools/ and renderer/, outside ansible/tests/_*.py and outside the k8s roles runs
# `-python` and `-owners` alone, about 5.5s on 2026-10-17. Those `-python` figures predate
# xdist_width (below); on 2026-10-22 at load average 3.6-3.9, `-python` took 5.9-6.3s.
#
# `-n0` on the one-module hooks: pyproject.toml's `-n auto` spent longer bringing up xdist
# workers than they take, and each worker repeats the module-scoped render (checksum 3.5s
# against 4.3-5.0s, the Renovate pair 4.1-4.4s against 4.8-5.4s, back to back on 2026-10-17).
# The `-python` hook pins `-n 4` rather than taking pyproject.toml's `-n auto`, because
# ansible/tests/xdist_width.py then sized `auto` at one worker per 15 modules, which put its five
# modules on a single worker (#5917). It sizes `auto` from recorded seconds since #5967, which
# gives these five 3; the pin stays because it is the width measured here. Measured on daniel-server on 2026-10-22, three rounds
# interleaved at load average 4.3-5.4: `-n 4` 6.7-8.5s, `-n auto` 13.3-16.6s. `-n 3`, `-n 5`
# and `-n 4 --dist load` measured no faster than `-n 4`. Under `--dist loadscope` each module
# stays on one worker, so the slowest module, test_prek_files_cover_entry_imports.py at about
# 7s alone with `-n0` at that load, bounds the hook.
# CI skips all of them on both `prek run` steps, as it skips the hook above, because its
# `pytest` job runs the same files. To commit past one, `SKIP=<its id> git commit`.

# Six walks of the tracked Python, so any `.py` can change their verdict. test_exit_codes
# also reads every scripts/*.sh exit contract, and test_prek_files_cover_entry_imports
# reads prek.toml itself.
[[repos.hooks]]
id = "static-ratchet-tests-python"
name = "Static ratchet tests over the tracked Python (import direction, exit codes, prek files, hazards)"
entry = "uv run --frozen pytest -q -n 4 -p no:cacheprovider scripts/tests/test_scripts_import_direction.py scripts/lib/tests/test_exit_codes.py scripts/tests/test_prek_files_cover_entry_imports.py ansible/tests/repo/test_tracked_python_hazards.py"
language = "system"
pass_filenames = false
files = "(\\.py$|^scripts/.*\\.sh$|^(prek\\.toml|pyproject\\.toml|uv\\.lock)$)"

# D2's owner table, and the entry points it names: Python, `ansible/deploy.yml`, and the
# setup-role templates and files that read which tags Flux owns.
[[repos.hooks]]
id = "static-ratchet-tests-owners"
name = "D2 owner table matches the entry points that read tag ownership"
entry = "uv run --frozen pytest -q -n0 -p no:cacheprovider ansible/tests/repo/test_owner_entry_points_census.py"
language = "system"
pass_filenames = false
files = "(\\.py$|^docs/target-architecture/deploy/d2-first-leaf\\.md$|^ansible/deploy\\.yml$|^ansible/roles/setup/[^/]+/(templates|files)/|^(pyproject\\.toml|uv\\.lock)$)"

# The lander deadlines: promote.yml's job timeouts, the land units' TimeoutStartSec in the
# setup roles, the inventory's flux-owned tags, the k8s roles, and the land and render code
# under scripts/. The k8s gate is every role's tasks, templates, defaults and files rather
# than the soak's own inputs: the soak fixture renders every flux-owned role, and
# test_no_published_tag_builds_an_image reads every role's tasks for an image-builder
# include (`images.builder_roles`). So a k8s role edit pays this hook's ~5s. The test module
# reaches the filter plugins, gitops_deploy's deployer_lib, common's host_lib and the
# secrets_mgmt, validate and probe_lib/releases code through pytest's `pythonpath`, so the
# gate covers those too (#5921).
[[repos.hooks]]
id = "static-ratchet-tests-lander"
name = "Lander deadlines cover the soak"
entry = "uv run --frozen pytest -q -n0 -p no:cacheprovider ansible/tests/setup/test_lander_deadlines_cover_the_soak.py"
language = "system"
pass_filenames = false
files = "^(cluster/services\\.yml|\\.github/workflows/promote\\.yml|ansible/inventory/.*|ansible/roles/setup/[^/]+/(templates|defaults)/.*|ansible/roles/k8s/[^/]+/(tasks|templates|defaults|files)/.*|hosts/lib/.*\\.py|ansible/roles/setup/gitops_deploy/files/.*\\.py|ansible/filter_plugins/[^/]+\\.py|scripts/(deploy_tools|renderer|lib|secrets_mgmt|validate)/.*\\.py|scripts/diagnostics/probe_lib/(releases[^/]*|core|host_reader|obs_api|health_checkout)\\.py|ansible/tests/(_[^/]+|setup/[^/]+)\\.py|pyproject\\.toml|uv\\.lock)$"

# Every `checksum/<name>` a CLAUDE.md names, against the k8s roles' rendered manifests. The
# render reaches scripts/validate/k8s_manifests.py and its validate_lib through pytest's
# `pythonpath` (#5921).
[[repos.hooks]]
id = "static-ratchet-tests-checksum"
name = "Documented checksum annotations exist in the manifests"
entry = "uv run --frozen pytest -q -n0 -p no:cacheprovider ansible/tests/k8s/test_checksum_annotations_documented.py"
language = "system"
pass_filenames = false
files = "^(ansible/roles/k8s/.*|CLAUDE\\.md|ansible/(templates|filter_plugins)/.*|ansible/tests/(_[^/]+|k8s/test_checksum_annotations_documented)\\.py|scripts/(deploy_tools|lib|renderer|validate)/.*\\.py|pyproject\\.toml|uv\\.lock)$"

# Every tracked `.sh` and rendered `*.sh.j2` answers `--help`. The test module renders the
# templates through scripts/lib, scripts/validate and the filter plugins, which it reaches
# through pytest's `pythonpath` (#5921).
[[repos.hooks]]
id = "static-ratchet-tests-shell"
name = "Shell entry points answer --help"
entry = "uv run --frozen pytest -q -n0 -p no:cacheprovider ansible/tests/repo/test_shell_entry_points_answer_help.py"
language = "system"
pass_filenames = false
files = "(^scripts/renderer/renderer_lib/jinja_filters\\.py$|\\.sh(\\.j2)?$|^ansible/tests/(_[^/]+|repo/test_shell_entry_points_answer_help)\\.py$|^(ansible/filter_plugins|scripts/(lib|validate))/.*\\.py$|^(pyproject\\.toml|uv\\.lock)$)"

# The two Renovate-rule modules: renovate.json against the autodeploy denylist, which each
# k8s role's `k8s_autodeploy` in its defaults and `lib.k8s_roles` decide, and against who
# owns each pin. Role defaults and the Dockerfile templates hold nearly every pin; a pin a
# custom manager reads from a workflow, a task file or prek.toml reaches CI's `pytest` job
# instead of this hook. The modules reach every filter plugin and scripts/validate through
# pytest's `pythonpath`, so the gate covers those too (#5921).
[[repos.hooks]]
id = "static-ratchet-tests-renovate"
name = "Renovate rules carry the autodeploy denylist marker"
entry = "uv run --frozen pytest -q -n0 -p no:cacheprovider ansible/tests/deploy/test_renovate_per_package_manual_rules_carry_the_denylist_marker.py ansible/tests/deploy/test_renovate_denylist_marker_reaches_the_pr_title.py"
language = "system"
pass_filenames = false
files = "^(cluster/services\\.yml|renovate\\.json|ansible/inventory/.*|ansible/roles/[^/]+/[^/]+/(defaults/.*|templates/Dockerfile[^/]*)|ansible/filter_plugins/[^/]+\\.py|ansible/tests/(_[^/]+|deploy/_autodeploy|deploy/test_renovate_[^/]+)\\.py|scripts/(deploy_tools|lib|renderer|validate)/.*\\.py|scripts/dev/k8s_autodeploy_counts\\.py|pyproject\\.toml|uv\\.lock)$"

# Six more rules that failed PR CI between 2026-10-18 and 2026-10-25: the eval cases'
# CLAUDE.md quotes, the k8s_host_files pairs (deleted with the role's rows in H7.48), the Landings board, the deployer's import
# boundary, the alert conventions and the unit-template census. Each failed 2 to 4 branches
# that changed an input the hook below names, and none ran before CI. Two of them run one node
# rather than the module: the alert module's promtool cases took 4.8-5.2s and the unit module's
# systemd-analyze cases 10.6-11.2s, where the convention node and the census node, the ones
# that failed, took 1.0s and 0.6s.
#
# Cost, one at a time with `-n0` on daniel-box on 2026-10-25 at load average 4.1-4.7, each
# including its interpreter start: eval cases 0.3-0.8s, host-files pairs 0.4-0.5s, Landings
# board 0.4s, deployer imports 0.7-0.8s, alert conventions 1.0s, unit census 0.6s.
#
# `test_cluster_vars_tree.py` failed four branches in the same week and stays out. It takes
# 11-12s because it renders every flux-owned role, so its gate would be every k8s template,
# and its only repair is `cluster_vars.py write`, which is operator-only: catching it at commit
# saves no round trip an agent can act on.

# The eval cases' CLAUDE.md quotes, Pi roles and cited role paths. Any role path counts,
# because a case may cite a file under any role, and deleting or renaming it fails the case.
[[repos.hooks]]
id = "static-ratchet-tests-evals"
name = "Eval cases quote the live CLAUDE.md and cite live role paths"
entry = "uv run --frozen pytest -q -n0 -p no:cacheprovider evals/tests/test_eval_cases.py"
language = "system"
pass_filenames = false
files = "^(evals/(cases|tests)/.*|CLAUDE\\.md|ansible/inventory/host_vars/daniel-pi\\.yml|ansible/roles/.*|scripts/lib/repo_paths\\.py|pyproject\\.toml|uv\\.lock)$"

[[repos.hooks]]
id = "static-ratchet-tests-landing"
name = "Landings board groups by labels the landing ledger writes"
entry = "uv run --frozen pytest -q -n0 -p no:cacheprovider ansible/tests/deploy/test_landing_annotations.py"
language = "system"
pass_filenames = false
files = "^(ansible/roles/k8s/observability/files/dashboards/Infrastructure/landings\\.json|scripts/deploy_tools/land_lib/(ledger|outcome)\\.py|ansible/roles/setup/gitops_deploy/files/gitops_markers\\.py|hosts/lib/[^/]+\\.py|scripts/lib/[^/]+\\.py|ansible/tests/(_[^/]+|deploy/test_landing_annotations)\\.py|pyproject\\.toml|uv\\.lock)$"

# The GitOps deployer imports only the modules it ships beside it and host_lib.
[[repos.hooks]]
id = "static-ratchet-tests-deployer"
name = "GitOps deployer imports only what it ships"
entry = "uv run --frozen pytest -q -n0 -p no:cacheprovider ansible/tests/deploy/test_gitops_deploy_imports.py"
language = "system"
pass_filenames = false
files = "^(ansible/roles/setup/gitops_deploy/files/.*\\.py|hosts/lib/[^/]+\\.py|ansible/tests/(_[^/]+|deploy/_gitops_deploy_allowed|deploy/test_gitops_deploy_imports)\\.py|scripts/lib/[^/]+\\.py|pyproject\\.toml|uv\\.lock)$"

# The alert conventions over the rendered rules. The gate is validate-alert-rules's, whose
# render this node reads, plus the test module.
[[repos.hooks]]
id = "static-ratchet-tests-alerts"
name = "Alert rules meet the alert conventions"
entry = "uv run --frozen pytest -q -n0 -p no:cacheprovider scripts/validate/tests/test_validate_alert_rules.py::test_the_tree_rules_meet_every_convention_is_clean"
language = "system"
pass_filenames = false
files = "^(cluster/services\\.yml|scripts/renderer/(renderer_lib/[^/]+|shadow_diff)\\.py|scripts/deploy_tools/(chained_timeout|changed_set|kustomizations)\\.py|scripts/lib/plain_scalar\\.py|ansible/roles/k8s/observability/(files/rules|tests/rules|files/alertmanager|tests/alertmanager)/.*|ansible/roles/k8s/loki-homelab/(files/rules/.*|templates/(rules-)?configmap\\.yaml\\.j2|templates/config/config\\.alloy\\.j2|defaults/main\\.yml)|ansible/roles/k8s/observability/templates/(prometheus|prometheus-rules|alertmanager|alertmanager-secret)\\.yaml\\.j2|ansible/roles/k8s/observability/defaults/main\\.yml|ansible/inventory/(host_vars/[^/]+|group_vars/all)\\.yml|ansible/filter_plugins/[^/]+\\.py|scripts/validate/alert_.*|scripts/validate/notification_renders\\.py|scripts/validate/k8s_manifests\\.py|scripts/validate/validate_lib/[^/]+\\.py|scripts/validate/tests/test_validate_alert_rules\\.py|scripts/lib/[^/]+\\.py|pyproject\\.toml|uv\\.lock)$"

# The unit-template census: every unit template the validator must find still exists.
[[repos.hooks]]
id = "static-ratchet-tests-units"
name = "Every known unit template is still discovered"
entry = "uv run --frozen pytest -q -n0 -p no:cacheprovider scripts/validate/tests/test_validate_unit_templates.py::test_discover_templates_finds_the_known_set"
language = "system"
pass_filenames = false
files = "^(ansible/roles/.*\\.(service|timer|socket|rules)\\.j2|scripts/validate/(unit_templates|tests/test_validate_unit_templates)\\.py|scripts/validate/validate_lib/[^/]+\\.py|scripts/renderer/renderer_lib/[^/]+\\.py|scripts/deploy_tools/(chained_timeout|changed_set|kustomizations)\\.py|scripts/lib/[^/]+\\.py|ansible/filter_plugins/[^/]+\\.py|pyproject\\.toml|uv\\.lock)$"


# The `hooks` key of .claude/settings.json is rendered from the `# gen-hooks:` block each file
# under .claude/hooks/ carries (scripts/dev/gen_hook_settings.py). `--check` fails on a hook
# file that declares nothing -- the exists-but-unregistered gap the two tests beside it cannot
# see, since test_hook_scripts_executable.py reads only the registered side -- and on a
# settings.json that drifted from the declarations. Run the generator without `--check` and
# commit what it writes. `files` names the generator itself, the way `ruff` names
# pyproject.toml above: a change to the render is a change to what the committed file must be.
[[repos.hooks]]
id = "gen-hook-settings"
name = "Hook registrations match the .claude/hooks/ declarations"
entry = "uv run --frozen python scripts/dev/gen_hook_settings.py --check"
language = "system"
pass_filenames = false
files = "^(\\.claude/hooks/[^/]+\\.(sh|py)|\\.claude/settings\\.json|scripts/dev/gen_hook_settings\\.py|scripts/lib/(cli_help|exit_codes|repo_paths)\\.py)$"

# Lint first-party Python for real bugs only — pyflakes (unused imports/undefined names),
# syntax errors, bare-except, ==None/==True (ruleset in pyproject [tool.ruff.lint]; vendored
# collections excluded there). ruff is pinned in uv.lock (lint group) so the gate is
# reproducible and lockFileMaintenance refreshes it weekly. Runs once when any .py is staged.
#
# `files` names pyproject.toml and uv.lock as well as the code, the way the `ty` hook below
# does: a commit that only enables a rule in [tool.ruff.lint] changes what every .py must
# satisfy, and with `\.py$` alone the PR ran no ruff at all — master's --all-files sweep found
# the 3 errors after the merge, twice, on 2026-09-02 (runs 33632377189 and 33632621818).
#
# uv.lock is here for the same reason one step further out: `uv run --frozen` runs the ruff
# pinned in the lock, so a lock-file maintenance PR decides which ruff version gates every
# .py in the tree and used to run none of it. Renovate #2548 moved ruff 0.16.8 -> 0.16.9 that
# way (#2552); a release that tightens a rule would have passed the PR and turned master red
# on its first full sweep, which is what the Vale binary did in #2536. A CI pull request run now sweeps
# every hook over the whole tree (#5489), so the PR run catches the same escape regardless of `files`;
# `files` still decides what a commit-time run checks.
[[repos.hooks]]
id = "ruff"
name = "Lint first-party Python (ruff)"
entry = "uv run --frozen ruff check"
language = "system"
pass_filenames = false
files = "(\\.py$|^(pyproject\\.toml|uv\\.lock)$)"

# Enforce `ruff format` on first-party Python (config in pyproject.toml [tool.ruff], same
# extend-exclude as the linter so vendored collections are untouched). --check fails the commit
# on an unformatted file instead of rewriting it, so a stray editor save can't sneak past CI.
# `files` covers pyproject.toml and uv.lock for the reasons given on `ruff` above — the
# formatter's output moves with the pinned version just as the linter's ruleset does.
[[repos.hooks]]
id = "ruff-format"
name = "Format first-party Python (ruff format)"
entry = "uv run --frozen ruff format --check"
language = "system"
pass_filenames = false
files = "(\\.py$|^(pyproject\\.toml|uv\\.lock)$)"

# Type-check first-party Python, for the bugs ruff cannot see: it reads one file at a time, so a
# value that crosses a function boundary as the wrong type is invisible to it.
#
# WHAT THIS GATE ACTUALLY CATCHES, since a check is worth exactly its true coverage. It catches a
# `str | None` reaching a `-> str` RETURN and reaching a CALL ARGUMENT, an argument whose
# container type is merely invariant-incompatible (a `frozenset` for a `set`, a `dict[str, str]`
# for a `dict[str, str | None]`), an unnarrowed regex match subscripted, a non-iterable iterated,
# an override whose signature does not match its base, and a submodule reached through a parent
# import. No file is exempt from `invalid-argument-type`.
#
# It does NOT catch an attribute read off an unnarrowed Optional, a wrongly typed assignment, or
# an operator applied to a union — `unresolved-attribute`, `invalid-assignment` and
# `unsupported-operator` are the three rules still off in pyproject [tool.ty.rules], each with
# its measured count and the reason.
#
# `pass_filenames = false` because ty resolves imports across the whole project, and a per-file
# run would report first-party imports as unresolved. `files` covers the config as well as the
# code: a commit that only edits [tool.ty.rules] or bumps the pin changes what the gate checks,
# and would otherwise run no ty at all locally (CI's --all-files sweep is the only backstop).
# Same pattern as the uv.lock hook above.
[[repos.hooks]]
id = "ty"
name = "Type-check first-party Python (ty)"
entry = "uv run --frozen ty check"
language = "system"
pass_filenames = false
files = "(\\.py$|^(pyproject\\.toml|uv\\.lock)$)"

# Fast syntax-only gate: `bash -n` parses (doesn't run) every shell script. Catches the bug
# class of an apostrophe inside a single-quoted `python3 -c '...'` block, which breaks bash's
# own quote parsing and once killed a backup-maintenance watchdog cron for a day. shellcheck
# above would also catch this, but `bash -n` is near-free and independent of it — a belt-and-
# suspenders backstop if the shellcheck-py hook is ever skipped/broken.
# `types = ["shell"]` (identify's shebang-aware detection, same as the shellcheck hook above)
# rather than a `.sh$` files regex — the NUT role's upssched-cmd (files/upssched-cmd, no
# extension: the filename is fixed by upssched's own config) is a real bash script and would be
# silently skipped by an extension-only match.
# NB: `entry` loops over "$@" itself — pre-commit/prek batch every matched filename into ONE
# invocation, and a bare `bash -n "$1" "$2"` silently only checks "$1" ($2+ become that
# script's own positional params, not additional files to parse).
[[repos.hooks]]
id = "bash-syntax-check"
name = "bash -n (shell syntax check)"
entry = "bash -c 'for f in \"$@\"; do bash -n \"$f\" || exit 1; done' --"
language = "system"
types = ["shell"]

# Prose style for the hand-written docs the ADR set introduced. Google's published style
# package plus two homelab rules; see .vale.ini for what is in scope and why.
#
# `files:` repeats .vale.ini's scope rather than trusting it, and the duplication is
# deliberate. Vale exits 0 on a file it has no section for, so a hook that passed every
# Markdown file would read green while checking nothing outside the two configured paths —
# and would ALSO hand it docs/reference/, whose commits come from the docs-refresh cron.
#
# The duplication is safe only because scripts/tests/test_vale_scope.py asserts this regex covers
# every .vale.ini section. It did not, for two commits: docs/deploying.md and
# docs/gitops-pipeline.md entered .vale.ini in PR #418 and never entered this regex, so the
# hook reported "no files to check" and both pages went ungated while reading green. Adding
# a page to .vale.ini alone does not lint it — it has to be in both.
#
# DECIDED: never widen this hook to docs/reference/; the docs-refresh cron commits those paths
# with hooks running, so a strict failure stops every refresh.
# A style error there aborts the commit and alerts on every run until someone fixes the
# generator. ENFORCED by the same test.
#
# The Google package is fetched, not tracked: .vale.ini pins its release URL and .gitignore
# excludes styles/Google/, so a fresh checkout — every new worktree — has the tracked Homelab
# style and nothing else on StylesPath. A bare `vale` there dies with "style 'Google' does
# not exist on StylesPath" on every commit that touches a doc, until someone runs `vale sync`
# by hand in that worktree. `scripts/validate/vale.sh` provisions the package and then execs
# Vale. It is a script rather than a `bash -c` entry because prek splits the file list across
# several CONCURRENT invocations, and an inline guard on the directory raced itself in a fresh
# worktree — read that script's header for the failure and the lock that fixes it (#1189).
# ENFORCED by scripts/validate/tests/test_vale_sync_guard.py.
[[repos.hooks]]
id = "vale"
name = "Prose style (Vale, scoped to ADRs, top-level docs pages and generated fragments)"
entry = "scripts/validate/vale.sh"
language = "system"
# docs/[^/]+\.md is every top-level page and nothing below it, which is what keeps
# docs/archive/ and docs/reference/ out. See .vale.ini for why each is excluded.
#
# DECIDED: the generated fragments under docs/assets/generated/fragments/ are linted here,
# although the docs-refresh cron commits them with hooks running. The operator chose this on
# 2026-09-30 (#3008) so that a generator's prose is gated in CI and not only by a hand
# `vale docs` run, where #3006 was found. The cost is accepted: a Vale error in a fragment
# aborts the cron's commit, and its commit-failure stamp reports DOWN on every run until the
# generator in scripts/docs/gen_doc_fragments.py is fixed. docs/reference/ stays out.
files = "^(docs/adr/.*\\.md|docs/[^/]+\\.md|docs/assets/generated/fragments/[^/]+\\.md)$"

# Build the docs site the way the cron does, and fail on anything --strict rejects.
#
# WHY THIS EXISTS. Generators emit fragments that hand-written pages transclude
# with `--8<--` (scripts/docs/gen_doc_fragments.py). The fragment tests hold the wiring in
# both directions — every include names a fragment, every fragment is included — but neither
# they nor any other gate ever BUILDS the site. So a broken include, a nav entry pointing at
# a deleted page, or a dead internal link reaches master and is caught only by the
# docs-refresh cron, which does not fail loudly: it logs, rebuilds without regenerating, and
# keeps serving the previous site. The page then reads as though its table were simply
# missing.
#
# `files` is deliberately wide — anything the build reads. A fragment lives under
# docs/assets/generated/, which no other docs hook touches, and mkdocs.yml itself changes the
# nav. uv.lock is in it because `--frozen` makes the lock decide which mkdocs and which plugins
# build the site, so a lock bump that moves one is a build change. The build takes ~4s,
# so running it on every lock bump costs less than finding the break on master.
# pass_filenames = false because mkdocs takes a config, not a file list.
#
# The site directory is left in the checkout on purpose and MUST stay ignored. `.gitignore:2`
# denies every root path (`/*`), so `.mkdocs-strict-check/` is covered without an entry of its
# own — which matters more than it looks: the docs-refresh cron commits with these hooks
# running, and `gitops_deploy` reads `git status --porcelain`, where a single UNTRACKED file
# parks the deployer silently. A build directory anywhere git can see would do exactly that.
# Anything that moves this path out from under the root deny needs its own ignore entry first.
[[repos.hooks]]
id = "mkdocs-strict"
name = "Build the docs site (mkdocs --strict)"
entry = "uv run --frozen mkdocs build --strict --site-dir .mkdocs-strict-check"
language = "system"
pass_filenames = false
files = "^(docs/.*|mkdocs\\.yml|scripts/docs/.*\\.py|uv\\.lock)$"