From 805218dd7dd34ac165d1affc5e5252ace74c44e8 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:49:07 +0100 Subject: [PATCH 1/4] fix(governance): ratchet only new anonymous exemptions --- .github/workflows/governance-reusable.yml | 13 ++++- scripts/check-exemption-ratchet.sh | 70 ++++++++++++++++++++--- scripts/tests/exemption-ratchet-test.sh | 52 ++++++++++++++++- 3 files changed, 123 insertions(+), 12 deletions(-) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 6ed47ae5..8d072708 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -1377,13 +1377,22 @@ jobs: - name: Exemption ratchet run: | set -euo pipefail + # Bootstrap rule: standards must exercise the script from the pull + # request being tested. Consumers must use the trusted copy from + # standards@main and cannot substitute a same-named local file. + # This mirrors the sibling debt-ratchet job below. + if [ "${{ github.repository }}" = "hyperpolymath/standards" ]; then + cp scripts/check-exemption-ratchet.sh \ + scripts/count-ledger-entries.sh "$RUNNER_TEMP/" + else + cp .standards-checkout/scripts/check-exemption-ratchet.sh \ + .standards-checkout/scripts/count-ledger-entries.sh "$RUNNER_TEMP/" + fi # Stage the script OUT of the scanned tree and delete the checkout, # so the ratchet only ever reads the CALLER's ledgers — standards has # ledgers of its own and they are not this repository's. # Both files: the ratchet calls count-ledger-entries.sh as a sibling, # and it has no fallback if the counter is missing — deliberately. - cp .standards-checkout/scripts/check-exemption-ratchet.sh \ - .standards-checkout/scripts/count-ledger-entries.sh "$RUNNER_TEMP/" rm -rf .standards-checkout bash "$RUNNER_TEMP/check-exemption-ratchet.sh" \ "${{ github.event.pull_request.base.sha }}" diff --git a/scripts/check-exemption-ratchet.sh b/scripts/check-exemption-ratchet.sh index bc88238d..ae4afdfc 100755 --- a/scripts/check-exemption-ratchet.sh +++ b/scripts/check-exemption-ratchet.sh @@ -27,9 +27,11 @@ # 1. NO SILENT GROWTH. An exemption ledger may lose entries freely. # Gaining entries requires an explicit, reviewed # declaration in the commit message. -# 2. NO ANONYMOUS ENTRIES. Every .hypatia-baseline.json entry must carry a -# `note` or a `tracking_issue`. "What is this?" -# must be answerable without archaeology. +# 2. NO NEW ANONYMOUS DEBT. Every new or changed .hypatia-baseline.json +# entry must carry a `note` or a `tracking_issue`. +# Legacy anonymous entries are tolerated only +# while identical as complete JSON values; +# touching one requires documenting it. # 3. NO WILDCARD LEDGERS. A `**` pattern in a banned-language ledger # silently absorbs files added later, which turns # a migration ledger into a permanent blind spot. @@ -148,13 +150,65 @@ for path in "${LEDGERS[@]}"; do fi done -# 2. No anonymous baseline entries. +# 2. No new or changed anonymous baseline entries. +# +# This is deliberately a ratchet, not an instantaneous cleanliness gate. A +# repository may already have anonymous legacy entries when it adopts the +# shared workflow. Failing every unrelated PR until all of that historic debt +# is documented makes the gate impossible to introduce and encourages blanket +# bypasses. Instead, compare complete JSON values as a multiset: +# +# * an unchanged anonymous value is grandfathered; +# * adding an anonymous value fails; +# * changing any field on an anonymous value fails (the old value vanished +# and a new undocumented value appeared); +# * adding a note/tracking_issue, or deleting an entry, is debt reduction and +# passes; +# * duplicate values are counted, so appending a second identical anonymous +# entry cannot hide behind one grandfathered copy. if [ -f .hypatia-baseline.json ]; then - anon="$(jq '[.[] | select((has("note")|not) and (has("tracking_issue")|not))] | length' \ - .hypatia-baseline.json 2>/dev/null || echo 0)" + base_baseline="$(mktemp)" + trap 'rm -f "$base_baseline"' EXIT + if git cat-file -e "${BASE_REF}:.hypatia-baseline.json" 2>/dev/null; then + git show "${BASE_REF}:.hypatia-baseline.json" > "$base_baseline" + else + printf '[]\n' > "$base_baseline" + fi + + anon_delta="$(jq -n \ + --slurpfile before "$base_baseline" \ + --slurpfile after .hypatia-baseline.json ' + def anonymous: + (has("note") | not) and (has("tracking_issue") | not); + + ($before[0]) as $base + | ($after[0]) as $head + | if (($base | type) != "array" or ($head | type) != "array") then + error(".hypatia-baseline.json must contain a JSON array at base and HEAD") + else + ([ $base[] | select(anonymous) ] + | group_by(.) + | map({entry: .[0], count: length})) as $base_groups + | ([ $head[] | select(anonymous) ] + | group_by(.) + | map({entry: .[0], count: length})) as $head_groups + | [ $head_groups[] as $new + | (($base_groups + | map(select(.entry == $new.entry) | .count) + | first) // 0) as $old_count + | select($new.count > $old_count) + | {entry: $new.entry, added: ($new.count - $old_count)} ] + end + ')" + rm -f "$base_baseline" + trap - EXIT + + anon="$(printf '%s\n' "$anon_delta" | jq '[.[].added] | add // 0')" if [ "${anon:-0}" -gt 0 ]; then - note "ANONYMOUS .hypatia-baseline.json: ${anon} entr(y|ies) carry neither a note nor a tracking_issue" - note " Every exemption must say what it is. Add \`note\` explaining" + note "ANONYMOUS .hypatia-baseline.json: ${anon} new or changed entr(y|ies) carry neither a note nor a tracking_issue" + printf '%s\n' "$anon_delta" | jq -r \ + '.[] | " \(.added)x \(.entry | tojson)"' + note " Every exemption touched now must say what it is. Add \`note\` explaining" note " what the finding actually is, or \`tracking_issue\` naming the" note " work that discharges it." fail=1 diff --git a/scripts/tests/exemption-ratchet-test.sh b/scripts/tests/exemption-ratchet-test.sh index 90b66fca..ac8def13 100755 --- a/scripts/tests/exemption-ratchet-test.sh +++ b/scripts/tests/exemption-ratchet-test.sh @@ -14,7 +14,22 @@ SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/check-exemption-ratchet.sh" WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT cd "$WORK"; git init -q .; git config user.email t@example.com; git config user.name T -printf '[{"severity":"high","rule_module":"m","type":"t","file":"a.rs","note":"why"}]\n' > .hypatia-baseline.json +# Reproduce the adoption case that exposed the defect: a repository already +# has many anonymous legacy entries. Unrelated work must not be blocked merely +# because those values predate the ratchet; only a new or changed anonymous +# value is a violation. +jq -n '[range(0; 44) | { + severity: "medium", + rule_module: "legacy", + type: ("legacy-" + tostring), + file: ("legacy-" + tostring + ".rs") +}] + [{ + severity: "high", + rule_module: "m", + type: "t", + file: "a.rs", + note: "why" +}]' > .hypatia-baseline.json printf 'cicd_rules/banned_language_file:src/A.res\n' > .hypatia-ignore git add -A; git commit -q -m base BASE="$(git rev-parse HEAD)" @@ -27,7 +42,7 @@ expect() { # expect