mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
5644f2ba71
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: at88c2692f2the 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 base0ba51a5f6and 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.
216 lines
9.8 KiB
Bash
Executable File
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
|