chore(gates): retire the external pre-commit hook contract
Why: .pre-commit-hooks.yaml and its release-tag gate served external consumers that do not exist. No repo on the Gitea instance pins these hooks, and the README names apm as the only supported install path. The mechanism was also already failing: skill-size-check.sh changed after v2.0.1 with no tag cut, and the gate cannot fire through Gitea's merge button. (Simplification audit finding 36.) Implementation Notes: - Delete .pre-commit-hooks.yaml, scripts/check-release-needed.sh, tests/test-check-release-needed.sh and tests/test-vale-hooks-consumer.sh, and remove the check-release-needed pre-push hook. The repo: local skill-size-check and vale-audit-prefilter-* hooks are unchanged. - ADR-0014 is amended, not retired: its runtime decision to bundle Vale inside factory-audit stands. The amendment keeps the entry[0]-only constraint (LESSONS.md:101,105) in case the export returns. ADR-0025 gets a pointer. - test-vale-wrap.sh: drop case 33 (the cross-manifest drift check) and case 28's hook-scope half, which read the published manifest. Case 32 now also requires each hook to select every tracked file of its class, which keeps case 33's one-plugin-narrowing guard, with a mutation test. - test-skill-size-check.sh and test-adr0020-contract.sh now assert the hook contract and verbose: true on .pre-commit-config.yaml only. - gates.md: pre-push count goes from 9 to 8 authored hooks (11 to 10 reported), and the Release table, the External consumers section and the two-manifest scope table are removed. README and script/test comments no longer describe the export as live. The resolver comment is edited identically in both copies. - The v1.0.0/v2.0.0/v2.0.1 tags are left in place; they are inert. ADR: 0014 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,242 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Hard-fails only when pushing to main: if any file covered by .pre-commit-hooks.yaml
|
||||
# (the external git-hook/CI contract, see ADR-0014) changed since the last tag,
|
||||
# a release must be cut before landing on main, or external consumers pinning
|
||||
# `rev: <tag>` silently miss the change. Pre-commit sets PRE_COMMIT_REMOTE_BRANCH
|
||||
# for pre-push hooks; on every other branch (feature work mid-review) this is a
|
||||
# silent no-op — pushing WIP commits there must not be blocked on cutting a
|
||||
# premature tag (see ADR-0014's repo: local vs pinned self-reference decision).
|
||||
#
|
||||
# Known gap: this only fires on a local `git push` through pre-commit's pre-push
|
||||
# hook. A PR merged via Gitea's merge button (server-side, no local push) or a
|
||||
# CI runner invoking `pre-commit run --hook-stage pre-push` directly does not set
|
||||
# PRE_COMMIT_REMOTE_BRANCH and will not trigger this check — closing that
|
||||
# requires a server-side CI job, which this repo does not have yet.
|
||||
|
||||
TARGET_BRANCH="refs/heads/main"
|
||||
|
||||
if [[ "${PRE_COMMIT_REMOTE_BRANCH:-}" != "$TARGET_BRANCH" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# What is actually being pushed, which is only HEAD for the common
|
||||
# `git push <remote> <current-branch>` case. pre-commit's pre-push hook-impl
|
||||
# exports the local sha of each pushed ref as PRE_COMMIT_TO_REF; a
|
||||
# `git push <remote> topic:main` from a different checkout would otherwise be
|
||||
# gated on the wrong tip — a false negative when HEAD is behind the pushed ref
|
||||
# (unreleased changes sail through), a false positive when it is ahead.
|
||||
# PRE_COMMIT_FROM_REF, the *remote's* current tip, is deliberately not used
|
||||
# anywhere here: the baseline is the last release tag, not what the remote
|
||||
# already has. Diffing from the remote tip would let an untagged
|
||||
# release-relevant commit already on main excuse the next push from cutting a
|
||||
# tag, which is precisely the drift this gate exists to catch.
|
||||
PUSHED_REF="${PRE_COMMIT_TO_REF:-HEAD}"
|
||||
|
||||
# pre-commit passes an all-zeros sha (40 hex zeros under sha1, 64 under sha256)
|
||||
# as the "to" ref when the push deletes a branch. Nothing is being shipped, and
|
||||
# every rev-taking command below would fail on an unresolvable sha, so bail out
|
||||
# rather than turning a branch deletion into a confusing "could not diff".
|
||||
if [[ "$PUSHED_REF" =~ ^0+$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
HOOKS_MANIFEST=".pre-commit-hooks.yaml"
|
||||
|
||||
if [[ ! -f "$HOOKS_MANIFEST" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only vX.Y.Z release tags count as a baseline — an incidental checkpoint or
|
||||
# experiment tag reachable from the pushed ref must not shift the diff baseline.
|
||||
# The tag is resolved from $PUSHED_REF, not HEAD, for the same reason the diff
|
||||
# is: a tag reachable only from HEAD is not part of the history being pushed.
|
||||
# --match is a shell glob, not a regex: its trailing `*`s match any suffix, so
|
||||
# without --exclude a pre-release/checkpoint tag like v1.2.3-checkpoint or
|
||||
# v1.2.3-rc1 also satisfies 'v[0-9]*.[0-9]*.[0-9]*' and could be picked over the
|
||||
# true last release tag. --exclude is glob syntax too, so '*-*' is what actually
|
||||
# rules out any tag carrying a hyphenated suffix, leaving only bare vMAJOR.MINOR.PATCH.
|
||||
LAST_TAG="$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' --exclude '*-*' "$PUSHED_REF" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$LAST_TAG" ]]; then
|
||||
echo "FAIL: no release tag exists yet, but .pre-commit-hooks.yaml already exposes hooks to external consumers." >&2
|
||||
echo " Fix: cut the first release tag (e.g. v1.0.0) before this lands on main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Derive release-relevant paths from .pre-commit-hooks.yaml's own entry: lines
|
||||
# instead of hand-maintaining a parallel list — the manifest is the single
|
||||
# source of truth for what external consumers actually pull at a pinned rev,
|
||||
# so a hook added/removed/renamed there can't silently drift out of sync here.
|
||||
# Everything is derived from tokens[0], the hook's script: pre-commit prefixes
|
||||
# only entry[0] with the hook-repo clone path, so any later token that looks
|
||||
# like a path resolves against the *consuming* repo and can never name a file
|
||||
# this repo ships. A hook's bundled data therefore has to be self-located
|
||||
# relative to the script — vale-wrap.sh reads its own
|
||||
# <script-dir>/../assets/vale/.vale.ini plus the sibling styles/ tree — which
|
||||
# makes <script-dir>/../assets release-relevant alongside the script itself.
|
||||
# The ../ is normalised by stripping a path component rather than with
|
||||
# `realpath -m`, which is a GNU-only extension. Two guards keep the derivation
|
||||
# from inventing paths: a bundle root of "." is skipped, because a script in a
|
||||
# top-level directory (scripts/skill-size-check.sh) would derive the repo's own
|
||||
# shared assets/, which no hook owns and whose churn must not demand a release;
|
||||
# and the assets/ directory is added only where it is known to exist, since a
|
||||
# hook that bundles nothing must not contribute a pathspec matching nothing.
|
||||
RELEASE_PATHS=("$HOOKS_MANIFEST")
|
||||
|
||||
add_release_path() {
|
||||
local candidate="$1" existing
|
||||
for existing in "${RELEASE_PATHS[@]}"; do
|
||||
[[ "$existing" == "$candidate" ]] && return 0
|
||||
done
|
||||
RELEASE_PATHS+=("$candidate")
|
||||
}
|
||||
|
||||
# Emits one "<hook id><TAB><entry value>" line per hook so a rejected entry can
|
||||
# name the hook a human has to go fix. The id sits on its own line above its
|
||||
# entry: in YAML, so it is carried forward and then cleared; a hook that somehow
|
||||
# has no id still reports something printable rather than an empty name. Kept in
|
||||
# bash rather than awk: matching `[[:space:]]` inside a bracket expression is
|
||||
# reliable in bash's own globs but not in the BWK awk macOS ships. `read -r` with
|
||||
# a single variable is the trimmer — it strips leading and trailing whitespace
|
||||
# while preserving anything in between, so a multi-token entry survives intact
|
||||
# for the error message to quote back.
|
||||
manifest_entries() {
|
||||
local line id="" value
|
||||
while IFS= read -r line; do
|
||||
# Drop the indentation and the optional list dash, so that `- id: x` and
|
||||
# ` entry: y` both reduce to the same bare "key: value" shape.
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
if [[ "$line" == -* ]]; then
|
||||
line="${line#-}"
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
fi
|
||||
case "$line" in
|
||||
id:*)
|
||||
read -r id <<< "${line#id:}"
|
||||
;;
|
||||
entry:*)
|
||||
read -r value <<< "${line#entry:}"
|
||||
printf '%s\t%s\n' "${id:-(unnamed hook)}" "$value"
|
||||
id=""
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# A hook's script is legitimate if it exists in the working tree *or* at
|
||||
# $LAST_TAG — the same union the pathspec itself spans. Checking per-scope
|
||||
# instead would reject exactly the case this gate exists to flag: a script
|
||||
# deleted since the tag while its entry survives (see the no -e filtering note
|
||||
# further down) is a real deletion to report, not a malformed manifest.
|
||||
entry_path_exists() {
|
||||
local candidate="$1"
|
||||
[[ -e "$candidate" ]] && return 0
|
||||
git cat-file -e "$LAST_TAG:$candidate" 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# $1 selects where the "does this hook bundle an assets/ tree?" guard looks:
|
||||
# "worktree" probes the filesystem, anything else is a rev whose tree is probed
|
||||
# with git plumbing. Reading entry lines from stdin keeps one derivation for
|
||||
# both the tagged manifest and the current one.
|
||||
collect_release_paths() {
|
||||
local scope="$1" line hook_id entry bundle_root where
|
||||
local -a tokens
|
||||
if [[ "$scope" == "worktree" ]]; then
|
||||
where="the working tree's $HOOKS_MANIFEST"
|
||||
else
|
||||
where="$HOOKS_MANIFEST at $scope"
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
hook_id="${line%%$'\t'*}"
|
||||
entry="${line#*$'\t'}"
|
||||
read -ra tokens <<< "$entry"
|
||||
[[ ${#tokens[@]} -eq 0 ]] && continue
|
||||
# ADR-0014 binds every entry to a bare script path and nothing else, because
|
||||
# pre-commit rewrites only entry[0] into the hook-repo clone. That is a
|
||||
# constraint nothing else enforces, and the sibling .pre-commit-config.yaml
|
||||
# already ships the multi-token `bash <script>` shape one copy-paste away —
|
||||
# so an entry like `bash scripts/foo.sh` would add "bash" as a pathspec that
|
||||
# matches nothing and derive a bundle root of ".", dropping that hook's
|
||||
# entire surface out of the gate silently. Both malformed shapes below fail
|
||||
# loudly instead: silent degradation here is the same class of defect as the
|
||||
# --config token already recorded in LESSONS.md.
|
||||
if [[ ${#tokens[@]} -gt 1 ]]; then
|
||||
echo "FAIL: hook '$hook_id' in $where has a multi-token entry: $entry" >&2
|
||||
echo " Why: pre-commit rewrites only entry[0] into the hook-repo clone, so every later" >&2
|
||||
echo " token resolves against the *consuming* repo and can never name a file this" >&2
|
||||
echo " repo ships — and this gate would derive its release paths from '${tokens[0]}'." >&2
|
||||
echo " Fix: make the entry a bare script path and have the script self-locate anything" >&2
|
||||
echo " else from \${BASH_SOURCE[0]} (see ADR-0014, 'Consequences')." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! entry_path_exists "${tokens[0]}"; then
|
||||
echo "FAIL: hook '$hook_id' in $where names a path that exists neither in the working tree nor at $LAST_TAG: ${tokens[0]}" >&2
|
||||
echo " Why: this gate derives its release-relevant pathspec from that path, so a name" >&2
|
||||
echo " that resolves to no file silently drops the hook's whole surface from the diff." >&2
|
||||
echo " Fix: point the entry at a script path this repo actually ships (see ADR-0014," >&2
|
||||
echo " 'Consequences'); a bare command name is not a valid entry here." >&2
|
||||
exit 1
|
||||
fi
|
||||
add_release_path "${tokens[0]}"
|
||||
bundle_root="$(dirname "$(dirname "${tokens[0]}")")"
|
||||
[[ "$bundle_root" == "." ]] && continue
|
||||
if [[ "$scope" == "worktree" ]]; then
|
||||
[[ -d "$bundle_root/assets" ]] && add_release_path "$bundle_root/assets"
|
||||
else
|
||||
git cat-file -e "$scope:$bundle_root/assets" 2>/dev/null && add_release_path "$bundle_root/assets"
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# The worktree alone is not enough: a path is release-relevant if it was part of
|
||||
# the contract at $LAST_TAG *or* is part of it now, so both trees have to be
|
||||
# derived and unioned. Deriving only from the worktree meant that deleting a
|
||||
# hook's entire assets/ tree made the `-d` guard drop the path from the pathspec
|
||||
# altogether, and the deletion — which breaks every consumer at the next rev —
|
||||
# diffed clean. The two manifests can genuinely disagree (an entry added,
|
||||
# removed, or renamed since the tag), and the union is the conservative side of
|
||||
# that disagreement: a path the tag exposed and HEAD no longer does is a removal
|
||||
# consumers must be told about, and a path only HEAD exposes is new contract
|
||||
# surface they cannot reach without a new tag. The union never over-fires on its
|
||||
# own, either — any manifest edit that makes the two disagree already changes
|
||||
# $HOOKS_MANIFEST, which is itself a release-relevant path.
|
||||
collect_release_paths worktree < <(manifest_entries < "$HOOKS_MANIFEST")
|
||||
|
||||
# A missing manifest at the tag is legitimate (the manifest was added since) but
|
||||
# is indistinguishable from an unreadable tagged tree by its exit status alone,
|
||||
# so the tag's root tree is verified separately. An absent tree object — a
|
||||
# shallow clone, a truncated fetch — fails closed exactly like a `git diff`
|
||||
# failure does, rather than silently degrading to worktree-only derivation.
|
||||
if MANIFEST_AT_TAG="$(git cat-file -p "$LAST_TAG:$HOOKS_MANIFEST" 2>/dev/null)"; then
|
||||
collect_release_paths "$LAST_TAG" < <(printf '%s\n' "$MANIFEST_AT_TAG" | manifest_entries)
|
||||
elif ! git cat-file -e "$LAST_TAG^{tree}" 2>/dev/null; then
|
||||
echo "FAIL: could not read the tree at $LAST_TAG to determine which paths that release exposed." >&2
|
||||
echo " Fix: ensure full tag history is available (e.g. git fetch --unshallow) and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# No -e/existence filtering on the pathspec: a path deleted since $LAST_TAG is
|
||||
# exactly the case that must be caught (external consumers pinning the old tag
|
||||
# would hit a missing file), and `git diff` reports deletions fine without it
|
||||
# existing at the pushed ref. A git failure (e.g. a shallow clone missing
|
||||
# $LAST_TAG's history) must fail closed, not be swallowed into an empty,
|
||||
# falsely-clean diff.
|
||||
if ! CHANGED="$(git diff --name-only "$LAST_TAG".."$PUSHED_REF" -- "${RELEASE_PATHS[@]}")"; then
|
||||
echo "FAIL: could not diff $LAST_TAG..$PUSHED_REF to check for release-relevant changes (see git error above)." >&2
|
||||
echo " Fix: ensure full tag history is available (e.g. git fetch --unshallow) and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$CHANGED" ]]; then
|
||||
echo "FAIL: files covered by .pre-commit-hooks.yaml changed since $LAST_TAG:" >&2
|
||||
echo "$CHANGED" | sed 's/^/ /' >&2
|
||||
echo " Fix: cut a new release tag — external consumers pinning rev: $LAST_TAG would miss this change." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -79,9 +79,9 @@ set -euo pipefail
|
||||
# all, so this script checks HEAD — the pushed ref only if checked out. The
|
||||
# script cannot recover either case: the ref list is gone by the time it
|
||||
# runs. Push refs one at a time to be sure each is checked.
|
||||
# - A PR merged via Gitea's merge button runs no local hook at all (the same
|
||||
# gap check-release-needed has). Closing it requires a server-side CI job,
|
||||
# which this repo does not have yet.
|
||||
# - A PR merged via Gitea's merge button runs no local hook at all.
|
||||
# Closing it requires a server-side CI job, which this repo does not have
|
||||
# yet.
|
||||
|
||||
# Byte-wise regex matching and messages: path bytes are matched against
|
||||
# SKILL_PATH_RE below and must not depend on the caller's locale.
|
||||
|
||||
@@ -109,10 +109,9 @@ FAIL=0
|
||||
|
||||
# ZERO ARGUMENTS IS A USAGE ERROR, exit 2 — not a clean run.
|
||||
#
|
||||
# This hook is `pass_filenames: true` in both .pre-commit-config.yaml and
|
||||
# .pre-commit-hooks.yaml, and pre-commit skips a filename-passing hook entirely
|
||||
# when nothing matches its `files:` pattern, so it never invokes this script
|
||||
# with an empty argument list. Every no-argument invocation therefore comes from
|
||||
# This hook is `pass_filenames: true` in .pre-commit-config.yaml, and
|
||||
# pre-commit skips a filename-passing hook entirely when nothing matches its
|
||||
# `files:` pattern, so it never invokes this script with an empty argument list. Every no-argument invocation therefore comes from
|
||||
# somewhere else — a hand-run command, a wrapper, or a `files:` pattern edited
|
||||
# into matching nothing — and printing nothing and exiting 0 made all three
|
||||
# indistinguishable from a clean corpus. A mis-scoped pattern would have
|
||||
@@ -236,8 +235,8 @@ def info(msg):
|
||||
# plugins/kyberforge/.apm/skills/factory-audit/scripts/lib-boundary-resolver.sh
|
||||
# The block between these markers must stay byte-identical in both. It is copied
|
||||
# rather than imported because a cache-installed plugin's scripts cannot read
|
||||
# files outside their own plugin directory, and this repo-root hook resolves via
|
||||
# .pre-commit-hooks.yaml, where entry[0] is the only token pre-commit rewrites --
|
||||
# files outside their own plugin directory, and this repo-root hook is kept fit for
|
||||
# a published hook manifest (retired; ADR-0014), where only entry[0] is rewritten --
|
||||
# so no single file is reachable by both (the same constraint that duplicates the
|
||||
# ADR-0020 constants). Edit one copy, then paste it over the other.
|
||||
#
|
||||
|
||||
Reference in New Issue
Block a user