Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only `docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep over every tracked file outside the detector, for the detector's own phrasings plus the attribution and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer proved") added residuals in the same files. Every site was classified with #812's three dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and traps stay; the attribution of who found them and the round in which they were found go. The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus `scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as the records' own exemption, and what keeps the record's depth measurement true), and `--all` lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812 argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff` sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill the withdrawn parity test ran on. The population oracle is restated over segments with the new arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures exemption, list only markdown, drop the symlink filter, test the mode per row instead of per path) each redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++` filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no notice — fixed, with a positive control and its own mutant. Code is unchanged by construction, measured per file type against `origin/main`: Python modules are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs (string literals) of three test modules; workflows differ only in `#` lines inside `run:` block scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated exceptions: the detector and its test, 26 vitest titles that carried review-round or severity labels or a reviewer attribution (call sites whose title changed — every changed title line walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed. Web: lint, typecheck, 1319 tests green. Closes #876. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
165 lines
11 KiB
Bash
Executable File
165 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Compare the LIVE required status checks on a branch against the committed snapshot
|
|
# (`.gitea/required-status-contexts.json`). ersatztv#787.
|
|
#
|
|
# WHY A SCRIPT AND NOT SIX LINES IN THE HOOK. `scripts/check-review-verdict.sh` was extracted from
|
|
# `pretooluse-merge-consent.sh` in #629 for exactly this reason: while the classification lived in
|
|
# the hook it had no tests, and three false-opens survived in it. This file is the same species —
|
|
# a pure classifier over a JSON payload its CALLER fetched — so it is testable in isolation and
|
|
# reads no remote state itself (`docs/remote-state-inventory.md` grades it `N/A` for that reason).
|
|
#
|
|
# WHAT IT IS FOR. `scripts/tests/test_ci_dropped_step_guard.py` derives its marked-job SCOPE from
|
|
# that snapshot, because `pr-checks.yml::script-tests` checks out with `persist-credentials: false`
|
|
# and cannot ask the server. That makes the snapshot the one hand-maintained input in the chain, so
|
|
# it needs a reconciliation wherever a credential DOES exist. This is that reconciliation.
|
|
#
|
|
# Input : the `GET /repos/{owner}/{repo}/branch_protections` array, on stdin.
|
|
# Output: exactly one class word on stdout.
|
|
# match — the live contexts for the branch equal the snapshot, as SETS
|
|
# drift — they were both read and they differ (the finding)
|
|
# nomatch — no rule governs the branch at all (protection removed)
|
|
# undecidable — a glob rule could govern the branch, so which rule applies is not derivable here
|
|
# unreadable — the payload, or a field this decision consumes, is not the shape it must be.
|
|
# Includes a rule whose NAME is unusable — either both `branch_name` and `rule_name`
|
|
# supply no name (absent, null or empty), or one of them is PRESENT holding a
|
|
# non-string, which poisons the rule however good its sibling is. The classifier
|
|
# answers `unreadable` for both, and a list with such a member supports no finding
|
|
# about which rule governs the branch (ersatztv#859).
|
|
# Exit : 0 having printed a class; 2 on a USAGE error (never a class, so a caller cannot mistake
|
|
# a broken invocation for a finding).
|
|
set -euo pipefail
|
|
|
|
branch=main
|
|
snapshot=""
|
|
snapshot_given=no
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
# `[ $# -ge 2 ]` before the shift, or a trailing `--branch` makes `shift 2` fail and `set -e`
|
|
# exits 1 with no diagnostic — a status this script's own contract reserves for nothing, from a
|
|
# path its usage arm was written to cover.
|
|
--branch) [ $# -ge 2 ] || { echo "--branch needs a value" >&2; exit 2; }; branch=$2; shift 2 ;;
|
|
--snapshot) [ $# -ge 2 ] || { echo "--snapshot needs a value" >&2; exit 2; }; snapshot=$2; snapshot_given=yes; shift 2 ;;
|
|
*) echo "usage: $0 [--branch NAME] [--snapshot PATH] < branch_protections.json" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
[ -n "$branch" ] || { echo "--branch may not be empty" >&2; exit 2; }
|
|
# An EXPLICIT empty --snapshot is a usage error, not a request for the default: falling back would
|
|
# silently read a different file than the caller named.
|
|
if [ -z "$snapshot" ] && [ "$snapshot_given" = yes ]; then
|
|
echo "--snapshot may not be empty" >&2; exit 2
|
|
fi
|
|
if [ -z "$snapshot" ]; then
|
|
snapshot="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.gitea/required-status-contexts.json"
|
|
fi
|
|
[ -r "$snapshot" ] || { echo "snapshot not readable: $snapshot" >&2; exit 2; }
|
|
command -v jq >/dev/null 2>&1 || { echo "jq is required" >&2; exit 2; }
|
|
|
|
payload=$(cat)
|
|
|
|
# The snapshot is validated to the SAME depth it is consumed at. A `contexts` that is present but
|
|
# holds a non-string would otherwise compare unequal against a well-formed live list and be reported
|
|
# as `drift` — a confident finding derived from a payload that was never understood, which is the
|
|
# shape this repo has fixed twice in the merge hook (a `// []` default that fired on `false`, and an
|
|
# object-typed validation that never checked its members).
|
|
snap=$(jq -c 'if (.contexts | type) == "array" and (all(.contexts[]; type == "string"))
|
|
then (.contexts | sort | unique) else "BAD" end' "$snapshot" 2>/dev/null || true)
|
|
if [ -z "$snap" ] || [ "$snap" = '"BAD"' ] || [ "$snap" = "null" ]; then
|
|
echo unreadable; exit 0
|
|
fi
|
|
|
|
# THE SNAPSHOT NAMES ITS OWN BRANCH, and a mismatch against `--branch` is a USAGE error rather than a
|
|
# class: comparing one branch's live contexts against another branch's mirror would produce a
|
|
# confident `match` or `drift` about a pair that was never meant to be compared. Exiting 2 is the
|
|
# only report that cannot be mistaken for a finding.
|
|
# REQUIRED, not merely cross-checked when present. An absent, empty or non-string `branch` would
|
|
# otherwise let a contexts list be compared against ANY `--branch`, which is the same defect the
|
|
# mismatch check exists to prevent, reached by omission instead of by disagreement.
|
|
snap_branch=$(jq -r 'if (.branch | type) == "string" then .branch else "" end' "$snapshot" 2>/dev/null || true)
|
|
if [ -z "$snap_branch" ]; then
|
|
echo "snapshot names no branch (a non-empty string \`branch\` is required): $snapshot" >&2; exit 2
|
|
fi
|
|
if [ "$snap_branch" != "$branch" ]; then
|
|
echo "snapshot describes branch '$snap_branch' but --branch is '$branch'" >&2; exit 2
|
|
fi
|
|
|
|
# WHICH RULE GOVERNS THE BRANCH is not decided here. That question — Gitea's Priority ordering, its
|
|
# gobwas/glob dialect, case folding, non-ASCII names — is answered by the ONE classifier both this
|
|
# script and `pretooluse-merge-consent.sh` load. Reimplementing it here would be the second copy of a
|
|
# security predicate that the merge hook's own history argues against, and it would be the copy
|
|
# without authority, which is historically the one that goes stale.
|
|
classifier="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/branch-rule-classifier.jq"
|
|
[ -r "$classifier" ] || { echo "classifier not readable: $classifier" >&2; exit 2; }
|
|
|
|
# THE PAYLOAD MUST BE AN ARRAY to produce a finding, the same gate the merge hook applies
|
|
# before its own call. Without it a JSON OBJECT reached the classifier, matched no rule, and this
|
|
# script printed `nomatch` — "no rule governs the branch at all (protection removed)", a positive
|
|
# claim about the server's configuration derived from a payload that is not the shape it must be.
|
|
# `{}` produced exactly that, measured; removing the gate also turns `{"a":{...}}` into a confident
|
|
# `drift`. Pre-existing rather than introduced by ersatztv#859, and fixed here because that issue is
|
|
# precisely about arms asserting findings they did not establish; `unreadable` already means "no
|
|
# comparison was made", which is the truth for a non-array body.
|
|
#
|
|
# "To produce a finding" rather than "before the classifier sees it", and the distinction is the
|
|
# `ci.jq-version-contract` gap (ersatztv#643): on jq 1.6 — the version `scripts/jq-preflight.sh`
|
|
# pins for `script-tests` — `jq -e` over EMPTY input exits 0, so an empty 200 body is not diverted
|
|
# here and does reach the classifier. Measured on both 1.6 and 1.8.2: the class word is `unreadable`
|
|
# either way, because the classifier then produces nothing and the catch-all answers. The outcome is
|
|
# version-independent; only the mechanism differs, so the comment states the outcome.
|
|
if ! printf '%s' "$payload" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
|
printf 'unreadable\n'; exit 0
|
|
fi
|
|
|
|
verdict=$(printf '%s' "$payload" | jq --arg b "$branch" -c -f "$classifier" 2>/dev/null || true)
|
|
case $(printf '%s' "$verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in
|
|
none) printf 'nomatch\n'; exit 0 ;;
|
|
undecidable) printf 'undecidable\n'; exit 0 ;;
|
|
exact) : ;;
|
|
# EVERYTHING ELSE, deliberately as ONE arm. The classifier's declared `unreadable` verdict (a rule
|
|
# whose name this program could not use, ersatztv#859) and a jq crash both land here, and they are
|
|
# NOT given separate arms — because this script's contract publishes exactly five class words and
|
|
# `unreadable` already means precisely "no comparison was made", which is true of both.
|
|
#
|
|
# A separate `unreadable)` arm printing the same word is a measured no-op (removing it
|
|
# left the suite green), and the merge hook's own
|
|
# comment condemns exactly that — an arm no observation can distinguish is a comment with syntax.
|
|
# The hook can afford two arms because it has two distinct REASON STRINGS to hand an operator;
|
|
# here there is one output word, so a second arm would be decoration.
|
|
#
|
|
# The mechanism is still pinned, one level down, where it IS observable:
|
|
# `test_check_required_contexts.py::test_the_CLASSIFIER_ITSELF_returns_the_declared_unreadable_verdict`
|
|
# asserts the classifier exits 0 with `{"verdict":"unreadable"}` rather than crashing, which this
|
|
# arm cannot distinguish and therefore must not claim to.
|
|
*) printf 'unreadable\n'; exit 0 ;;
|
|
esac
|
|
|
|
# The rule's own shape is validated to the depth it is CONSUMED at, not merely as an object. An
|
|
# `enable_status_check` that arrived as the STRING "true", or a contexts list holding a non-string,
|
|
# would otherwise compare unequal against a well-formed snapshot and be reported as `drift` — a
|
|
# confident finding derived from a payload that was never understood, which is the one-level-down
|
|
# swallow the merge hook has had to fix twice.
|
|
#
|
|
# `null`/absent contexts is NOT a read failure: it legitimately means "none required", so against a
|
|
# non-empty snapshot it is the FINDING that every required context was removed.
|
|
class=$(printf '%s' "$verdict" | jq -r --argjson snap "$snap" '
|
|
.rule as $rule
|
|
# The EFFECTIVE required set, not the raw list. `enable_status_check: false` means Gitea requires
|
|
# nothing on this branch whatever `status_check_contexts` still holds, so type-checking that flag
|
|
# and then ignoring its VALUE certifies disabled protection as a current mirror — reporting `match`
|
|
# on a branch that is not gating anything at all.
|
|
| if ($rule.enable_status_check | type) != "boolean" then "unreadable"
|
|
elif ($rule.enable_status_check | not) then (if ($snap | length) == 0 then "match" else "drift" end)
|
|
elif (($rule | has("status_check_contexts")) | not) or ($rule.status_check_contexts == null)
|
|
then (if ($snap | length) == 0 then "match" else "drift" end)
|
|
elif ($rule.status_check_contexts | type) != "array"
|
|
or any($rule.status_check_contexts[]; type != "string") then "unreadable"
|
|
else (if ($rule.status_check_contexts | sort | unique) == $snap then "match" else "drift" end)
|
|
end' 2>/dev/null || true)
|
|
|
|
case "$class" in
|
|
match|drift|nomatch|undecidable|unreadable) printf '%s\n' "$class" ;;
|
|
# jq threw, or produced a word this contract does not define. Either way the comparison did NOT
|
|
# happen, and saying so is the only safe report — `unreadable` is the class that means exactly
|
|
# "no comparison was made", never a silent success.
|
|
*) printf 'unreadable\n' ;;
|
|
esac
|