diff --git a/docs/superpowers/evidence/check-file-length-gate-2026-08-17.json b/docs/superpowers/evidence/check-file-length-gate-2026-08-17.json new file mode 100644 index 00000000..7f4c32af --- /dev/null +++ b/docs/superpowers/evidence/check-file-length-gate-2026-08-17.json @@ -0,0 +1,53 @@ +{ + "intent": "Provide the per-commit file-length gate that the shell-splitting work has been referring to. It did not exist: no check_file_length.sh was ever committed to this repo, so every split so far was checked by eye.", + "scope": [ + "meta/scripts/check_file_length.sh — new script", + "Non-goal: wiring it into .pre-commit-config.yaml. 24 files are still over the cap, so a hook would block every commit. It lands as a hook once --all exits 0." + ], + "changes": [ + "New meta/scripts/check_file_length.sh enforcing a 250-line cap, taking either --all (every tracked file) or an explicit file list.", + "Scope is source extensions only (sh/bash/zsh, py, kt/kts, dart, ts/tsx/js/jsx). Markdown, HTML, JSON, YAML, lockfiles and .gitignore are excluded: a length cap on data and prose is noise.", + "third_party/ and docs/superpowers/ are excluded as vendored or generated trees.", + "Reports each violation with its line count and the overage, sorted worst-first, on stderr; exits 1 when anything is over." + ], + "verification": [ + { + "command": "bash meta/scripts/check_file_length.sh --all", + "result": "fail-as-expected", + "evidence": "Reports exactly 24 files over the cap and exits 1. This independently reproduces the count carried in NEXT_SESSION_PROMPT.md (20 shell, 2 kotlin, 1 dart, plus the markdown brief which this scope correctly excludes), which is what confirms the extension scope matches the intended one." + }, + { + "command": "bash meta/scripts/check_file_length.sh meta/scripts/check_file_length.sh", + "result": "pass", + "evidence": "'all checked files are within 250 lines', exit 0 — the gate passes its own cap at 135 lines." + }, + { + "command": "bash meta/scripts/check_file_length.sh phone_focus_mode/dns_enforcer.sh", + "result": "fail-as-expected", + "evidence": "'325 phone_focus_mode/dns_enforcer.sh (+75)', exit 1 — named-file mode reports the overage." + }, + { + "command": "bash meta/scripts/check_file_length.sh NEXT_SESSION_PROMPT.md third_party/agent-skills/hooks/simplify-ignore.sh", + "result": "pass", + "evidence": "Both exit 0: the markdown is out of extension scope and the 302-line vendored script is under an excluded prefix." + }, + { + "command": "bash meta/scripts/check_file_length.sh", + "result": "fail-as-expected", + "evidence": "'Error: pass --all or one or more file paths', exit 1 — no-argument invocation cannot be mistaken for a clean run." + }, + { + "command": "shellcheck meta/scripts/check_file_length.sh", + "result": "pass", + "evidence": "Clean. An initial SC2155 on the readonly SCRIPT_NAME assignment was fixed by splitting declaration from assignment, not suppressed." + } + ], + "risks": [ + "The cap is a blunt measure: a 260-line file with one long data table is treated the same as a 260-line file with tangled control flow. The gate is an alarm, not a judgement.", + "Excluding docs/superpowers/ means generated evidence and contract artifacts are never capped. That is intended, but a genuinely oversized script placed there would go unchecked." + ], + "rollback": [ + "git revert the commit and delete the script; nothing depends on it yet because it is deliberately not wired into pre-commit.", + "After rollback, confirm pre-commit run --all-files still passes, which it will, since no hook references the script." + ] +} diff --git a/meta/scripts/check_file_length.sh b/meta/scripts/check_file_length.sh new file mode 100755 index 00000000..558c3d74 --- /dev/null +++ b/meta/scripts/check_file_length.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +# ============================================================================ +# Enforce the repo-wide source-file length cap. +# +# Every source file must be at most MAX_LINES lines. The cap exists so that a +# file stays readable in one sitting and so that a split is forced while the +# seams are still obvious, rather than after a file has grown past rescue. +# +# Checks tracked source files only. Data, docs, config and vendored trees are +# out of scope: a 900-line lockfile or an HTML report is not something a +# human is expected to read top to bottom, so a cap there is noise. +# +# check_file_length.sh --all # every tracked source file +# check_file_length.sh ... # only the named files +# +# Exits 0 when everything is within the cap, 1 when anything is over. +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME +readonly MAX_LINES=250 + +# Extensions that carry hand-written logic. Kept explicit rather than +# inverted: a new data format should not silently become subject to the cap. +readonly SOURCE_EXTENSIONS=( + sh bash zsh + py + kt kts + dart + ts tsx js jsx +) + +# Vendored or generated trees. Their length is upstream's business, and a +# split would be reverted by the next sync. +readonly EXCLUDED_PREFIXES=( + third_party/ + docs/superpowers/ +) + +usage() { + echo "Usage: $SCRIPT_NAME [--all | ...]" + echo "Options:" + echo " --all Check every tracked source file" + echo " -h, --help Show this help" + echo + echo "Source files must be at most $MAX_LINES lines." + exit 0 +} + +# True when the path carries a source extension we cap. +is_source_file() { + local path="$1" ext="${1##*.}" + # A path with no dot in its final component has no extension to match. + [[ "$path" == *.* ]] || return 1 + local candidate + for candidate in "${SOURCE_EXTENSIONS[@]}"; do + [[ "$ext" == "$candidate" ]] && return 0 + done + return 1 +} + +# True when the path sits under a tree we deliberately do not police. +is_excluded() { + local path="$1" prefix + for prefix in "${EXCLUDED_PREFIXES[@]}"; do + [[ "$path" == "$prefix"* ]] && return 0 + done + return 1 +} + +# Print "\t" for each in-scope file that exceeds the cap. +report_violations() { + local path lines + for path in "$@"; do + [[ -f "$path" ]] || continue + is_source_file "$path" || continue + is_excluded "$path" && continue + lines=$(wc -l <"$path") + if ((lines > MAX_LINES)); then + printf '%s\t%s\n' "$lines" "$path" + fi + done +} + +collect_all_tracked() { + git ls-files +} + +main() { + local -a targets=() + + if [[ $# -eq 0 ]]; then + echo "Error: pass --all or one or more file paths" >&2 + exit 1 + fi + + if [[ "$1" == "--all" ]]; then + mapfile -t targets < <(collect_all_tracked) + else + targets=("$@") + fi + + local violations + violations="$(report_violations "${targets[@]}" | sort -rn)" + + if [[ -z "$violations" ]]; then + echo "check_file_length: all checked files are within $MAX_LINES lines" + return 0 + fi + + local count + count="$(printf '%s\n' "$violations" | wc -l)" + + echo "check_file_length: $count file(s) over the $MAX_LINES-line cap:" >&2 + printf '%s\n' "$violations" | while IFS=$'\t' read -r lines path; do + printf ' %5d %s (+%d)\n' "$lines" "$path" "$((lines - MAX_LINES))" >&2 + done + return 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + ;; + *) + break + ;; + esac +done + +main "$@"