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
174 lines
11 KiB
Bash
Executable File
174 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Make the jq version a job's shell gates run under OBSERVABLE, and any drift LOUD.
|
|
#
|
|
# ersatztv#648. Every shell gate in this repo is authored and tested on a developer Mac shipping
|
|
# jq 1.8.x. The CI runner ships jq 1.6. Nothing pinned or checked that, and until ersatztv#631 the one
|
|
# thing that could have noticed (scripts/tests/) never ran on the runner. Three independent divergences
|
|
# surfaced in a single day:
|
|
#
|
|
# ersatztv#643 `jq -e` over EMPTY input -> exit 4 on 1.8, exit 0 on 1.6 (a transport failure
|
|
# passed the docs-only pagination guard)
|
|
# ersatztv#647 contains("<NUL>") -> false on 1.8, TRUE for every string on 1.6
|
|
# (the H10 verdict classifier was entirely inert)
|
|
# ersatztv#647 parse-error exit code -> 5 on 1.8, 4 on 1.6 — same as "no output"
|
|
# (garbage API response read as "no comments")
|
|
#
|
|
# All three are fixed with version-stable constructs, but patching constructs one at a time does not
|
|
# scale: the failures share one shape — a shell gate's behaviour is a function of its interpreter's
|
|
# version, and that version was an UNTESTED AXIS. This script makes the axis explicit.
|
|
#
|
|
# WHY A FLOOR AND NOT A PIN EVERYWHERE. The obvious fix — bake a pinned jq into the CI toolchain image
|
|
# (docker/ci/Dockerfile) — provably does NOT cover the gate that actually broke. `.gitea/workflows/
|
|
# review-verdict.yml` is `runs-on: small`, carries no toolchain-image pin, and per `ci.small-lane-git-only`
|
|
# the small lane is git-only. It therefore gets the HOST's jq 1.6 no matter what the image contains.
|
|
# That was checked, not assumed (ersatztv#648's first Done-when box).
|
|
#
|
|
# So the contract is the other way round: 1.6 is the FLOOR every gate must work on, and it is the
|
|
# runner's own jq that provides the 1.6 coverage `scripts/tests/` runs under.
|
|
#
|
|
# TWO MODES, deliberately asymmetric:
|
|
#
|
|
# (no --expect) Print the version and assert it is >= MIN_VERSION. Used by jobs on the merge
|
|
# path, including review-verdict.yml. There is NO upper bound here on purpose:
|
|
# review-verdict.yml writes `review-verdict/h10`, a REQUIRED status check on
|
|
# `main`, so a hard pin there would turn any jq upgrade on the runner into a
|
|
# repo-wide merge deadlock. Observability without a deadlock risk.
|
|
#
|
|
# --expect X.Y Additionally assert the version is exactly X.Y, and FAIL if not. Used by the
|
|
# `script-tests` job. This is the tripwire: `scripts/tests/` currently exercises
|
|
# the 1.6 path only because the runner happens to ship 1.6. If the runner were
|
|
# upgraded, that coverage would vanish SILENTLY and the whole class of bug above
|
|
# would go untested again. Going red forces a human to decide — re-pin, or add a
|
|
# real 1.6 matrix leg — rather than letting the coverage evaporate unnoticed.
|
|
#
|
|
# Usage: jq-preflight.sh [--expect <major.minor>]
|
|
|
|
set -euo pipefail
|
|
|
|
# The lowest jq every shell gate in this repo must run correctly on. Do not raise this without
|
|
# confirming the CI runner has actually been upgraded first — the runner, not the dev Mac, is the
|
|
# binding constraint.
|
|
MIN_VERSION="1.6"
|
|
|
|
expect=""
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--expect)
|
|
# `shift 2` with a missing value fails under `set -e` and exits 1 with NOTHING on either
|
|
# stream — a CI step dying with an empty log is exactly the diagnostic hole this script exists
|
|
# to remove. Check explicitly instead.
|
|
if [ "$#" -lt 2 ] || [ -z "${2:-}" ]; then
|
|
echo "jq-preflight: --expect requires a <major.minor> value" >&2
|
|
exit 2
|
|
fi
|
|
expect="$2"; shift 2 ;;
|
|
*) echo "jq-preflight: unknown argument '$1'" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
echo "jq-preflight: jq is not on PATH. The shell gates in scripts/ and .gitea/workflows/ shell out to jq; without it they fail as a pile of opaque assertion errors instead of one clear message." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Take jq's EXIT STATUS seriously, and keep stderr OUT of the parse input.
|
|
#
|
|
# This was `raw=$(jq --version 2>&1 || true)`, which did neither — and that combination turned the
|
|
# guard fail-OPEN on the case it most needs to catch. A jq that cannot start (the canonical one is a
|
|
# glibc mismatch after a base-image change) exits 127 and writes something like
|
|
# `jq: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.34' not found` to stderr. Folded into `raw`,
|
|
# that string contains `2.34`, which the version pattern happily matched — so the preflight printed
|
|
# "parsed 2.34", certified the floor, and exited 0 on a jq that cannot run at all. The strip-based
|
|
# parse this replaced failed CLOSED there, so it was a regression introduced by the fix.
|
|
# `$?` inside an `if ! cmd; then` block is the NEGATED status (0), not jq's, so capture it explicitly.
|
|
set +e
|
|
raw=$(jq --version 2>/dev/null)
|
|
jq_rc=$?
|
|
set -e
|
|
if [ "$jq_rc" -ne 0 ]; then
|
|
echo "jq-preflight: 'jq --version' failed (exit ${jq_rc}). jq is on PATH but cannot run — a broken build or a missing shared library. Failing closed rather than certifying a version it did not report." >&2
|
|
exit 1
|
|
fi
|
|
# `jq --version` prints e.g. `jq-1.6`, `jq-1.7.1`, or on some builds `jq-1.8.2-dirty`.
|
|
# Parse with an explicit regex rather than by stripping around the first `-` and `.`.
|
|
#
|
|
# The strip approach had a hole that defeated the whole point of this script. It assumed the format
|
|
# is exactly `jq-X.Y`, so a build printing anything else — `jq version 1.6` (a distro wrapper),
|
|
# `JQ-1.6`, `jq-1.-6` — left ONE of major/minor empty. The old sanity check was
|
|
# `case "$major$minor" in *[!a-9]*|"")`, and on `jq version 1.6` that concatenation is "6": non-empty
|
|
# and all-digits, so the guard PASSED. The floor comparison then ran `[ "" -lt 1 ]`, which exits 2
|
|
# with "integer expression expected" — and `set -e` exempts a failing command in an `if` condition,
|
|
# so the whole conditional read false and the script exited 0 having asserted NOTHING, after printing
|
|
# a plausible-looking "parsed" line.
|
|
#
|
|
# That is the silently-untested-axis failure this script was written to eliminate, reproduced inside
|
|
# the script itself. Require a real `<digits>.<digits>` match, and fail closed when there isn't one.
|
|
# ANCHORED to the leading `jq` token, not "first digits.digits anywhere in the string".
|
|
#
|
|
# An unanchored match takes whatever number comes first, wherever it is. That accepted a leading
|
|
# warning line or a date prefix as the version — `2026.07.26 jq-1.6` parsed as 2026.07, which sails
|
|
# over the floor. Anchoring keeps every legitimate form (`jq-1.6`, `jq version 1.6`, `jq-1.7.1`,
|
|
# `jq-1.6-dirty`, `jq-1.6 (Debian 1.6-2.1)`) and rejects the rest, which then fails closed below.
|
|
# FIRST LINE ONLY, and bounded everywhere. Both bounds are load-bearing; this is the third narrowing of
|
|
# this one predicate and each earlier narrowing failed for a variant of the same reason.
|
|
#
|
|
# * First line only. `[[:space:]]` matches NEWLINES, so an "anchored" pattern still scanned the
|
|
# whole output: `jq\n2.34: cannot load` matched `jq`, crossed the newline as separator, and
|
|
# parsed 2.34 — the fail-open narrowed but not closed. `[[:blank:]]` (space/tab
|
|
# only) plus a first-line slice confines the match to the line that can actually carry a version.
|
|
# * Bounded digit runs. This is the original fail-open mechanism resurrected. The regex guaranteed the
|
|
# operands were digits but not that they fit in `test`'s integer range, so a 23-digit major made
|
|
# `[ "$major" -lt "$min_major" ]` error with "integer expression expected" — and `set -e` exempts
|
|
# a failing command in an `if` condition, so the conditional read false and THE FLOOR WAS NEVER
|
|
# ASSERTED, exit 0. Exactly what the empty-string case did before the bound. `{1,9}` keeps every
|
|
# operand inside a 32-bit integer, so the comparison can no longer error.
|
|
# * Bounded separator runs, so the pattern cannot be walked across arbitrary filler.
|
|
first=${raw%%$'\n'*}
|
|
first=${first%$'\r'}
|
|
# The separator is one of the two forms real jq actually emits — `jq-1.6` or `jq version 1.6` — not
|
|
# "any run of dashes and blanks". A permissive class let the pattern be walked across filler:
|
|
# `jq -- 2.34 (real jq-1.6)` parsed as 2.34, and `jq<TAB><TAB>9.9` as 9.9. A blank separator now
|
|
# REQUIRES the literal word `version`, which is the only context a real build puts one in.
|
|
#
|
|
# The trailing `([^0-9]|$)` is what actually bounds the digit runs. `{1,9}` alone does not: the regex
|
|
# is unanchored at the end, so `jq-1.99999999999999999999999` simply matched the first 9 digits of
|
|
# the minor and compared THAT — a mis-parse that passes the floor. Requiring a non-digit (or
|
|
# end-of-string) after the minor makes an over-long run fail to match at all, so it fails closed.
|
|
if [[ "$first" =~ ^[[:blank:]]*[Jj][Qq](-v?|[[:blank:]]+version[[:blank:]]+v?)([0-9]{1,9})\.([0-9]{1,9})([^0-9]|$) ]]; then
|
|
major="${BASH_REMATCH[2]}"
|
|
minor="${BASH_REMATCH[3]}"
|
|
else
|
|
echo "jq-preflight: could not parse a major.minor version out of '${first}'. Refusing to assert a floor against an unparsed version — that would silently pass." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# THIS LINE IS THE POINT of the no-arg mode: the jq version CI actually used is in the job log, so a
|
|
# future divergence can be diagnosed from the log alone rather than by guessing at the runner image.
|
|
# `$first`, not `$raw`: a multi-line `--version` would split this across lines, breaking the single
|
|
# grep-able log line that is the entire point of the no-arg mode.
|
|
echo "jq-preflight: jq version in use = ${first} (parsed ${major}.${minor}; floor ${MIN_VERSION})"
|
|
|
|
min_major=${MIN_VERSION%%.*}
|
|
min_minor=${MIN_VERSION#*.}
|
|
if [ "$major" -lt "$min_major" ] || { [ "$major" -eq "$min_major" ] && [ "$minor" -lt "$min_minor" ]; }; then
|
|
echo "jq-preflight: jq ${major}.${minor} is BELOW the supported floor ${MIN_VERSION}. The gates in scripts/ and .gitea/workflows/ are written against ${MIN_VERSION}+ semantics and will misbehave silently on older builds." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [ -n "$expect" ]; then
|
|
if [ "${major}.${minor}" != "$expect" ]; then
|
|
echo "jq-preflight: expected jq ${expect}, found ${major}.${minor}." >&2
|
|
echo "" >&2
|
|
echo "This is a TRIPWIRE, not a defect in your change (ersatztv#648). scripts/tests/ was pinned to" >&2
|
|
echo "jq ${expect} because that is what this runner shipped; it now reports ${major}.${minor}. The ${expect}" >&2
|
|
echo "coverage the suite assumed has therefore just disappeared, silently — and jq 1.7 altered NUL" >&2
|
|
echo "handling, exit codes, @base64d and number precision, every one of which a gate here depends on." >&2
|
|
echo "" >&2
|
|
echo "Decide explicitly, then update the --expect value in .gitea/workflows/pr-checks.yml:" >&2
|
|
echo " * re-pin to the new version after re-reading docs/ci-cd.md -> 'The jq contract', or" >&2
|
|
echo " * add a real matrix leg that runs the suite under ${MIN_VERSION} as well." >&2
|
|
exit 1
|
|
fi
|
|
echo "jq-preflight: version matches the expected pin (${expect})."
|
|
fi
|