Files
boshu2__agentops/scripts/check-skill-python-ratchet.sh
Bo 5644f2ba71 fix(gates): fail closed on ratchet collection failure and pin first-parent head scope
Repairs the two fresh-Sol ADR-0016 review findings. Turns the RED witnesses
recorded in the previous commit green without touching the governed regex, the
growth guard, the allowlist, or scripts/.skill-python-grandfather.

1. check-skill-python-ratchet.sh no longer swallows the collector contract.

scripts/lib/ratchet.sh gives every chosen collection command an explicit
"|| return 2" because the gate runs in a shell with no ambient strict mode. The
caller then discarded exactly that: ratchet_changed_files_status ... 2>/dev/null
|| true turned a dead Git read into an EMPTY changed set, the governed loop ran
zero times, and the gate printed its green banner over a diff nobody read.
Measured before this change, with a diff-tree shim over a tree carrying a real
unpinned violation: "PASS: skill-python ratchet" / exit 0. The rc is now
preserved and the gate exits 2, with stderr no longer sent to /dev/null so the
library's own reason survives.

2. head scope now HAS the first-parent semantics it declared.

git diff-tree -m --first-parent does not mean "diff only against parent 1":
-m splits a merge into one diff block PER PARENT and emits them all, while
--first-parent constrains history traversal rather than which parent blocks
print. The parent-2 block lists every path already settled on the first-parent
line, so a merge commit re-attributed long-landed files to itself. Sol measured
this on the live integration lineage: at 88c2692f2 the old form emitted 14
governed rows, 12 from the second-parent block, none introduced by that merge.

Head collection becomes one owner, ratchet__head_diff, doing an explicit
two-tree HEAD^1..HEAD diff, with the --root empty-tree diff kept for root
commits (HEAD^1 does not resolve there, and a root commit's contents are exactly
what it introduced). An unreadable parent list fails closed at rc 2 rather than
returning an empty set. The helper replaces all SIX open-coded copies of the old
command -- head scope plus the upstream-without-upstream and auto fallbacks, in
both the name-only and name-status collectors -- so the same defect cannot
survive in a path nobody looked at. diff-tree is retained (not git diff) to
preserve the existing rename-detection posture; only ref selection changes.

The lib header no longer claims -m --first-parent means first-parent.

Verification:
  - tests/scripts/check-skill-python-ratchet.bats  15/15 (N7, N8 were RED)
  - tests/scripts/ratchet-lib.bats                 41/41 (merge case was RED)
  - all six other ratchet-lib consumer suites: unchanged from base
  - go test ./internal/gates/...                   ok (negative-witness closure)
  - shellcheck -S warning on both changed scripts: clean
  - git diff --check: clean
  - live gate sweep over all eight consumers, run identically at the unmodified
    base 0ba51a5f6 and here: byte-identical results. The one pre-existing
    failure (check-atomic-write-ratchet stale-entry rc=1, and its bats case 13)
    reproduces at base with real history and is NOT caused by this change.

Refs: /tmp/agentops-adr0016-go-promotion-review-sol.md (REQUEST_CHANGES,
"Two ratchet corrections are still required"); ADR-0016 section 3.
2026-07-27 23:52:16 -04:00

216 lines
9.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# check-skill-python-ratchet.sh — ADR-0016 shipped-Python ratchet.
#
# WHY: ADR-0016 section 3 fixes the division rule without hedging — "Python
# never ships in skills" (line 97), and "Shipping Python inside a skill is a
# gate failure, not a style nit" (line 123). It called itself a gate failure
# while NO gate existed. A sweep of scripts/ and cli/internal/gates found zero
# enforcement, which is the same disease the repo has already diagnosed
# elsewhere: a rule that exists only as text changes no behavior. This is the
# gate. Interpreter dependencies on the user execution path are what the ADR is
# protecting against — a single static binary IS the distribution story, and
# "deterministic verification" degrades into "deterministic, if your
# interpreter, venv, and PATH cooperate" once Python ships to strangers'
# machines.
#
# WHAT (zero-churn ratchet — never rewrites the existing tree):
# For each ADDED or MODIFIED `skills/*/scripts/**/*.py` in the changed scope
# that is NOT on the grandfather snapshot, FAIL. Grandfathered files are
# exempt UNTIL they are promoted into `ao` (or otherwise removed) — at which
# point their line must be PRUNED from the snapshot (the allowlist only
# shrinks). The surviving grandfathered count is printed on every run so the
# number is visible and can only ratchet down.
#
# SCOPE — the user EXECUTION path only, and deliberately so:
# * `skills/*/scripts/**/*.py` is governed. This is what a skill actually
# invokes on a user's machine, so it is what carries the interpreter
# dependency the ADR forbids.
# * `skills/*/tests/**` is EXEMPT AS A CLASS. Test code never executes on a
# user's machine, so the interpreter-state argument does not reach it. That
# is a recorded amendment to ADR-0016 with its rationale
# (docs/adr/ADR-0016-state-tiers.md, "Amendment 2026-07-25 (tests)"), NOT an
# unstated exception — an unrecorded carve-out would be the same inert-prose
# defect this gate exists to fix.
# * `skills-codex/**` is EXEMPT AS A CLASS: it is a generated projection of
# `skills/**` (regenerated by scripts/regen-all.sh). Governing a projection
# would fail the same violation twice and could not be repaired
# independently of its source. Projections are never authoritative — that is
# ADR-0016's own title.
#
# REPAIR: shared mechanism goes into the `ao` binary as one owner; skill-specific
# parsing and contract logic becomes an `ao` subcommand the skill invokes through
# `sh` glue. ADR-0016's promotion bar and the scratch-tier prototype path exist
# precisely so this rule has no cost. If a file genuinely cannot be promoted,
# that case is made per file as an ADR amendment — not by widening the
# allowlist, which the growth guard rejects.
#
# Usage:
# bash scripts/check-skill-python-ratchet.sh # scope: auto
# bash scripts/check-skill-python-ratchet.sh --scope head # push scope
# bash scripts/check-skill-python-ratchet.sh --scope worktree
#
# Exit codes:
# 0 - pass / not applicable
# 1 - new Python on a skill's execution path, OR a grandfathered entry that no
# longer exists (snapshot must shrink), OR the grandfather snapshot GAINED
# entries vs. its base-ref version (allowlist additions are rejected)
# 2 - usage error / unreadable snapshot / unresolvable base ref / failed
# changed-scope collection (fail-closed: an unread diff is never certified)
# shellcheck disable=SC1007
. "$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/preamble.sh"
cd "$REPO_ROOT" || exit 1
# Shared shrink-only ratchet mechanics (changed-scope collection, base-ref
# snapshot, growth guard, intersection authority). Parse mode `raw` matches the
# snapshot format: one path per line, `#` comments and blanks stripped.
. "$REPO_ROOT/scripts/lib/ratchet.sh"
GRANDFATHER="scripts/.skill-python-grandfather"
SCOPE="auto"
while [[ $# -gt 0 ]]; do
case "$1" in
--scope)
shift
[[ $# -gt 0 ]] || { echo "--scope requires a value" >&2; exit 2; }
SCOPE="$1"
;;
--scope=*) SCOPE="${1#--scope=}" ;;
-h|--help) sed -n '2,56p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
shift
done
case "$SCOPE" in
head|staged|worktree|upstream|auto) ;;
*) echo "Invalid --scope: $SCOPE (want head|staged|worktree|upstream|auto)" >&2; exit 2 ;;
esac
# governed PATH → 0 if the path is Python on a skill's user execution path.
# `skills/<slug>/scripts/**/*.py` at any nesting depth (reverse-engineer keeps
# scripts/binary/*.py), never skills-codex/** and never skills/*/tests/**.
governed() {
# ANCHORED REGEX, not a `==` glob: inside `[[ ]]` a `*` matches `/` too, so
# `skills/*/scripts/*.py` would also match `skills/x/tests/scripts/y.py` and
# silently govern the exempt tests class. `[^/]+` pins the slug to exactly one
# segment; `.+` after `scripts/` keeps arbitrary nesting governed
# (reverse-engineer ships scripts/binary/*.py) — depth is not an escape hatch.
[[ "$1" =~ ^skills/[^/]+/scripts/.+\.py$ ]]
}
# still_exists ENTRY → 0 if the grandfathered path is still in the tree. Used as
# the stale predicate: a promoted/removed file must be pruned from the snapshot.
still_exists() {
# Invoked by NAME through ratchet_stale_entries_by, so shellcheck cannot see
# the call site and reports the body as unreachable (SC2317, info-level).
# shellcheck disable=SC2317
[[ -f "$1" ]]
}
check_grandfather_shrink_only() {
local added rc_=0
added="$(ratchet_assert_shrink_only "$GRANDFATHER" raw)" || rc_=$?
[[ "$rc_" -eq 2 ]] && exit 2
if [[ "$rc_" -ne 0 ]]; then
echo "FAIL: $GRANDFATHER gained new entries — the grandfather list only SHRINKS." >&2
echo " New Python cannot be allowlisted (ADR-0016 section 3). Route the logic" >&2
echo " into an \`ao\` subcommand and invoke it through \`sh\` glue. Added entries:" >&2
while IFS= read -r line; do
echo " $line" >&2
done <<< "$added"
return 1
fi
return 0
}
tmp_changed="$(mktemp -d "${TMPDIR:-/tmp}/skill-python-ratchet.XXXXXX")"
trap 'rm -rf "$tmp_changed"' EXIT
# FAIL-CLOSED COLLECTION. scripts/lib/ratchet.sh gives every chosen collection
# command an explicit `|| return 2` ("refusing to certify an unchecked change
# set") precisely because this gate runs in a shell with no ambient strict mode.
# A `|| true` here would throw that contract away: a dead Git read becomes an
# EMPTY changed set, the loop below runs zero times, and the gate prints its
# green banner over a diff nobody read. Preserve the rc and stop.
collect_rc=0
ratchet_changed_files_status "$SCOPE" > "$tmp_changed/raw" || collect_rc=$?
if [[ "$collect_rc" -ne 0 ]]; then
echo "FAIL: changed-scope collection failed (rc $collect_rc) for scope '$SCOPE' —" >&2
echo " refusing to certify an unchecked change set. An empty change set would" >&2
echo " certify \"no new Python\" over a diff that was never read." >&2
exit 2
fi
shipped=0 # new Python on a skill execution path
stale=0 # grandfathered entries that no longer exist (must prune)
grow=0 # snapshot gained entries (allowlist only shrinks)
ratchet_load_base "$GRANDFATHER" "$(ratchet_base_ref "$SCOPE")" || exit 2
if ! check_grandfather_shrink_only; then
grow=1
fi
while IFS=$'\t' read -r status path rest; do
[[ -n "${status:-}" && -n "${path:-}" ]] || continue
# Renames arrive as "R<score>\told\tnew"; git puts the NEW path in `rest`.
if [[ "$status" == R* && -n "${rest:-}" ]]; then
path="$rest"
status="A"
fi
# Only ADDED or MODIFIED files are governed. Deletions (D) are never a
# violation — removing shipped Python is the direction this gate wants.
case "$status" in
A|M) ;;
*) continue ;;
esac
governed "$path" || continue
# Staged-then-deleted edge: skip anything not present in the working tree.
[[ -f "$path" ]] || continue
ratchet_rc=0
ratchet_is_pinned "$path" "$GRANDFATHER" raw || ratchet_rc=$?
[[ "$ratchet_rc" -eq 2 ]] && exit 2
[[ "$ratchet_rc" -eq 0 ]] && continue
echo "FAIL: $path ships Python on a skill's user execution path (ADR-0016 section 3)." >&2
echo " \"Python never ships in skills\" — interpreter dependencies break" >&2
echo " determinism on user machines. Route this logic into the \`ao\` binary and" >&2
echo " invoke it from the skill through \`sh\` glue; prototype in the scratch tier" >&2
echo " first if it is not yet proven. Adding the path to $GRANDFATHER is" >&2
echo " NOT a repair — the growth guard rejects it." >&2
shipped=$((shipped + 1))
done < "$tmp_changed/raw"
# Shrink direction: a pinned entry whose file is gone was promoted (or deleted)
# and must leave the snapshot, so the count can only ratchet down.
stale_rc=0
stale_list="$(ratchet_stale_entries_by still_exists "$GRANDFATHER" raw)" || stale_rc=$?
[[ "$stale_rc" -eq 2 ]] && exit 2
if [[ -n "$stale_list" ]]; then
echo "FAIL: $GRANDFATHER pins path(s) that no longer exist — prune them so the" >&2
echo " allowlist ratchets down:" >&2
while IFS= read -r line; do
[[ -n "$line" ]] || continue
echo " $line" >&2
stale=$((stale + 1))
done <<< "$stale_list"
fi
# Visible count: the whole point of a ratchet is that the number is public and
# monotone. Print it on pass and fail alike (audit P1.1).
pinned_count="$(ratchet_load_pinned "$GRANDFATHER" raw | grep -c . || true)"
if [[ "$shipped" -gt 0 || "$stale" -gt 0 || "$grow" -gt 0 ]]; then
echo "" >&2
echo "skill-python-ratchet: $shipped new shipped-Python file(s); $stale stale grandfather entry/entries; snapshot-grew=$grow." >&2
echo "skill-python-ratchet: $pinned_count grandfathered file(s) remain on the execution path." >&2
exit 1
fi
echo "PASS: skill-python ratchet — no new Python on any skill's execution path."
echo " $pinned_count grandfathered file(s) remain (ADR-0016 section 3; shrink-only)."
exit 0