diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e7da9e21..49317b01 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -86,6 +86,27 @@ repos: language: system pass_filenames: false always_run: true + - id: directory-depth-cap + name: Report paths over the directory-depth cap + # Repo-wide, and deliberately --warn for now: 47 paths under + # periodic_background/{hosts,digital_wellbeing} are over the cap and + # can only come down by extracting those two units, which is blocked + # on a manual guard repoint. Running it warn-only keeps the number + # visible on every commit without blocking unrelated work. Drop + # --warn once both units are out; that is the last step of the + # extraction, not a separate decision. + entry: bash meta/scripts/check_directory_depth.sh --warn --all + language: system + pass_filenames: false + always_run: true + - id: repo-size-cap + name: Enforce the source-file ceiling + # Counts tracked files only, excluding vendored and hook-generated + # trees. Prints the count on every run so the ceiling is auditable. + entry: bash meta/scripts/check_repo_size.sh + language: system + pass_filenames: false + always_run: true - id: ci-baseline-green name: Refuse to build on a red CI baseline # Checks the last COMPLETED run of each required workflow on main. diff --git a/docs/superpowers/evidence/wire-structure-gates-2026-08-23.json b/docs/superpowers/evidence/wire-structure-gates-2026-08-23.json new file mode 100644 index 00000000..0a098733 --- /dev/null +++ b/docs/superpowers/evidence/wire-structure-gates-2026-08-23.json @@ -0,0 +1,57 @@ +{ + "intent": "Wire the two repo-structure gates into pre-commit: the directory-depth cap (warn-only for now) and a new source-file ceiling, so structural drift is visible on every commit.", + "scope": [ + "meta/scripts/check_directory_depth.sh - structural exemptions plus a --warn mode", + "meta/scripts/check_repo_size.sh - new source-file ceiling gate", + ".pre-commit-config.yaml - two new always_run hooks", + "Non-goal: bringing the remaining 47 over-cap paths under the cap. They sit under periodic_background/{hosts,digital_wellbeing}, whose extraction is blocked on a manual sudo guard repoint.", + "Non-goal: syncing meta/.pre-commit-config.yaml, which is a separate standalone config already 219 lines divergent at HEAD." + ], + "changes": [ + "Exempted the 57 structurally-deep paths: whole projects with their own layout (C/, dwm/, artgate/), one module-split tool (misc/tools/), prose trees (docs/), three one-off leaves, and five externally-dictated directory segments (systemd, hooks, workflows, patches, fixtures).", + "Added --warn to check_directory_depth.sh: reports violations and exits 0. The hook uses it because the 47 remaining paths cannot be fixed without the blocked extraction; enforcement turns on by deleting the flag.", + "Guarded the --all argument order: '--all --warn' previously ignored --warn silently and exited 1, which would have blocked every commit. It now errors with a note that options precede --all.", + "Added check_repo_size.sh, counting tracked files via git ls-files (never find - the tree holds ~30k untracked extraction leftovers), excluding third_party/, docs/superpowers/ and .hippo/. Ceiling 1200 against a current 1049; the count is always printed so the threshold is auditable from a CI log." + ], + "verification": [ + { + "command": "./meta/scripts/check_directory_depth.sh --all", + "result": "pass", + "evidence": "EXIT=1 with exactly 47 violations, all under periodic_background/{hosts,digital_wellbeing}. Confirms the arithmetic 104 = 57 structural + 42 digital_wellbeing + 5 hosts, and that no unintended path was swept up by the new exemptions." + }, + { + "command": "./meta/scripts/check_directory_depth.sh --warn --all", + "result": "pass", + "evidence": "EXIT=0. Also checked: single clean file EXIT=0; single over-cap file EXIT=1; --help EXIT=0; '--all --warn' now errors loudly instead of silently exiting 1." + }, + { + "command": "./meta/scripts/check_repo_size.sh; ./meta/scripts/check_repo_size.sh --count", + "result": "pass", + "evidence": "'check_repo_size: 1049 source files (ceiling 1200)' EXIT=0; --count prints 1049 EXIT=0; unknown flag EXIT=1." + }, + { + "command": "MAX_SOURCE_FILES=10 variant of check_repo_size.sh", + "result": "pass", + "evidence": "EXIT=1 with 'over the 10 ceiling' plus the extract-a-unit hint. Confirms the failure branch fires rather than being dead code." + }, + { + "command": "pre-commit run directory-depth-cap --all-files; pre-commit run repo-size-cap --all-files", + "result": "pass", + "evidence": "Both report Passed through the real pre-commit harness, not just direct invocation." + }, + { + "command": "pre-commit run --files meta/scripts/check_repo_size.sh meta/scripts/check_directory_depth.sh .pre-commit-config.yaml", + "result": "pass", + "evidence": "No hook failures; shfmt and shellcheck clean on both scripts." + } + ], + "risks": [ + "The depth gate is warn-only, so it cannot yet block a NEW over-cap path. This is deliberate and reversible by deleting --warn from the hook entry once hosts and digital_wellbeing are extracted.", + "The prefix exemptions are broad by design (e.g. all of linux_configuration/docs/). A future deep code tree under an exempted prefix would go unpoliced; the exemptions are individually commented so each can be narrowed.", + "The 1200 ceiling leaves 151 files of headroom. Pruning docs/superpowers is tracked separately and would not change this count, since that tree is excluded from it." + ], + "rollback": [ + "git revert the commit. Both gates are additive: the scripts are standalone and the hooks are new entries, so reverting restores the previous behaviour exactly.", + "After rollback, confirm 'pre-commit run --all-files' still passes and that no hook references the removed check_repo_size.sh." + ] +} diff --git a/meta/scripts/check_directory_depth.sh b/meta/scripts/check_directory_depth.sh index 53b67ce1..7f01b485 100755 --- a/meta/scripts/check_directory_depth.sh +++ b/meta/scripts/check_directory_depth.sh @@ -12,17 +12,34 @@ # third_party/ vendored - its shape is upstream's business # docs/superpowers/ hook-generated bookkeeping, not hand-placed # .github/skills/ layout fixed by the agent harness +# C/ a C project: its own build layout, not taxonomy +# artgate/ a Flask app; routes/ and styles/ are framework shape +# misc/tools/ one tool split across modules to satisfy the line cap +# docs/ prose trees; chapter folders are not code taxonomy +# dwm/ an upstream-shaped suckless tree +# utils/data/ data files beside the script that reads them +# zsh/scratchpad/ a single scratch file +# run_all/ the aggregate runner's own fixtures # -# Exempt, by path segment: any file under a `lib/` or `tests/` directory. -# Those two names are structural, not taxonomic - a library split out to -# satisfy the 250-line cap, and the test suite that the shell-coverage -# ratchet requires to sit beside it. Capping them would force one gate to -# fight another. +# Exempt, by path segment: `lib/` and `tests/` are structural, not taxonomic +# - a library split out to satisfy the 250-line cap, and the test suite the +# shell-coverage ratchet requires beside it. Capping them would force one +# gate to fight another. The same argument covers the directories whose name +# is dictated by an external format rather than chosen by us: systemd unit +# dirs, pacman/git hook dirs, GitHub workflow dirs, patch sets and test +# fixtures. Each is matched as a whole segment, so `libvirt/` or `binutils/` +# is unaffected. # # check_directory_depth.sh --all # every tracked file # check_directory_depth.sh ... # only the named files +# check_directory_depth.sh --all --warn # report but always exit 0 # # Exits 0 when everything is within the cap, 1 when anything is over. +# With --warn it reports violations and still exits 0; the hook runs in that +# mode because 47 paths under `periodic_background/{hosts,digital_wellbeing}` +# are over the cap and can only be fixed by extracting those two units, which +# is blocked on a manual guard repoint. Drop --warn from the hook once they +# are out; the cap is then enforced for real. # ============================================================================ set -euo pipefail @@ -35,6 +52,19 @@ readonly EXCLUDED_PREFIXES=( third_party/ docs/superpowers/ .github/skills/ + # Whole projects that carry their own conventional layout. + linux_configuration/C/ + linux_configuration/dwm/ + python_pkg/artgate/ + # A single tool split into modules to satisfy the 250-line cap. + linux_configuration/misc/tools/ + # Prose trees: a chapter folder is not code taxonomy. + linux_configuration/docs/ + # One-off leaves, listed explicitly rather than exempting the segment + # name everywhere: `data` and `scratchpad` are too generic for that. + linux_configuration/utils/data/ + linux_configuration/zsh/scratchpad/ + meta/scripts/run_all/ ) # Directory names that never count as taxonomy. Matched as whole path @@ -42,12 +72,21 @@ readonly EXCLUDED_PREFIXES=( readonly EXEMPT_SEGMENTS=( lib tests + # Names dictated by an external format, not chosen as a category. + systemd + hooks + workflows + patches + fixtures ) +WARN_ONLY=0 + usage() { - echo "Usage: $SCRIPT_NAME [--all | ...]" + echo "Usage: $SCRIPT_NAME [--warn] [--all | ...]" echo "Options:" echo " --all Check every tracked file" + echo " --warn Report violations but always exit 0" echo " -h, --help Show this help" echo echo "Files must sit at most $MAX_DEPTH directories below the repo root." @@ -115,6 +154,11 @@ main() { fi if [[ "$1" == "--all" ]]; then + if [[ $# -gt 1 ]]; then + echo "Error: --all takes no further arguments (got: ${*:2})" >&2 + echo "Note: options must precede --all, e.g. --warn --all" >&2 + exit 1 + fi mapfile -t targets < <(git ls-files) else targets=("$@") @@ -135,6 +179,11 @@ main() { printf '%s\n' "$violations" | while IFS=$'\t' read -r depth path; do printf ' %3d %s (+%d)\n' "$depth" "$path" "$((depth - MAX_DEPTH))" >&2 done + + if ((WARN_ONLY)); then + echo "check_directory_depth: --warn given, not failing" >&2 + return 0 + fi return 1 } @@ -143,6 +192,10 @@ while [[ $# -gt 0 ]]; do -h | --help) usage ;; + --warn) + WARN_ONLY=1 + shift + ;; *) break ;; diff --git a/meta/scripts/check_repo_size.sh b/meta/scripts/check_repo_size.sh new file mode 100755 index 00000000..2e5c2b8b --- /dev/null +++ b/meta/scripts/check_repo_size.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash + +# ============================================================================ +# Enforce a ceiling on the number of source files in the repo. +# +# The companion to the directory-depth cap. Depth stops any one subtree from +# growing its own taxonomy; this stops the repo as a whole from quietly +# becoming the place everything lands. When the count reaches the ceiling the +# answer is to extract a unit into its own repository - not to raise the +# number because the commit is inconvenient. +# +# "Source" means tracked files that a human wrote and maintains here. Three +# categories are therefore excluded from the count: +# +# third_party/ vendored; upstream decides how many files it has +# docs/superpowers/ hook-generated evidence/contract bookkeeping, which +# grows once per commit and would dominate the number +# .hippo/ agent-generated store, same argument +# +# Counting is done with `git ls-files`, never `find`: the working tree also +# holds untracked extraction leftovers running to tens of thousands of files, +# and a `find`-based count would be meaningless. +# +# The count is always printed, so the threshold can be audited from any CI log +# rather than being taken on trust. +# +# check_repo_size.sh # count and enforce +# check_repo_size.sh --count # print the number only, always exit 0 +# +# Exits 0 when at or under the ceiling, 1 when over. +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME + +# Set from the 1049 files counted when this gate was written, plus room to +# work. Extract a unit rather than raising it; see the header. +readonly MAX_SOURCE_FILES=1200 + +readonly EXCLUDED_PREFIXES=( + third_party/ + docs/superpowers/ + .hippo/ +) + +COUNT_ONLY=0 + +usage() { + echo "Usage: $SCRIPT_NAME [--count]" + echo "Options:" + echo " --count Print the source-file count and exit 0" + echo " -h, --help Show this help" + echo + echo "Fails when tracked source files exceed $MAX_SOURCE_FILES." + exit 0 +} + +# Tracked files minus the generated and vendored trees. Printed one per line. +source_files() { + local -a filter=() + local prefix + for prefix in "${EXCLUDED_PREFIXES[@]}"; do + filter+=(-e "^${prefix}") + done + # grep exits 1 when it filters everything out; that is not an error here. + git ls-files | grep -v "${filter[@]}" || true +} + +main() { + local count + count="$(source_files | wc -l)" + + if ((COUNT_ONLY)); then + echo "$count" + return 0 + fi + + if ((count > MAX_SOURCE_FILES)); then + echo "check_repo_size: $count source files, over the $MAX_SOURCE_FILES ceiling" >&2 + echo "Extract a unit into its own repo rather than raising the cap." >&2 + return 1 + fi + + echo "check_repo_size: $count source files (ceiling $MAX_SOURCE_FILES)" + return 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + ;; + --count) + COUNT_ONLY=1 + shift + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +main