fix(648): an explicit jq version contract + one shared PR-file enumeration #658

Merged
timothy merged 7 commits from fix/648-649-jq-gates into main 2026-07-26 22:46:05 +02:00
Owner

fixes #648

Lands the jq version contract, and the shared PR-file enumeration that #649 needs. #649 stays open: its enforced half is a deliberate follow-up, explained below.

#648 — the decision, with the small-lane constraint checked rather than assumed

The issue offered three options and declined to pick. Option 1 (bake a pinned jq into docker/ci/Dockerfile) is ruled out by measurement: .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 — so it gets the host's jq 1.6 regardless of what the toolchain image contains. That is the exact workflow where the #647 bug was live, so the pin would have missed it entirely.

So the contract is inverted: jq 1.6 is the floor, and it is the runner's own jq that provides the 1.6 coverage scripts/tests/ runs under.

scripts/jq-preflight.sh has two deliberately asymmetric modes:

Mode Used by Why
no args — print version, assert floor merge-path jobs Observability with no deadlock risk
--expect 1.6 — additionally pin script-tests only Tripwire: the 1.6 coverage exists only because the runner ships 1.6

The asymmetry is load-bearing. review-verdict.yml writes review-verdict/h10, the branch-protection-required status. A hard pin there would turn any jq bump on the runner — a base-image bump, a host reimage, nothing this repo controls — into a repo-wide merge deadlock. A required merge gate must not fail because an upstream package manager did its job.

Conversely the pin on script-tests matters precisely because that job is the suite's only 1.6 coverage: if the runner's jq silently changed, that coverage would evaporate with no signal.

#649 (partial) — one enumeration instead of two

The enforced review-verdict.yml guard had drifted strictly weaker than the advisory merge-consent hook: four rounds of #643 hardening landed on the copy whose failures produce a human prompt, and never reached the copy that writes a required status. Its fail-closed behaviour on a garbage response was also incidental — n came back empty, [ "$n" -lt 50 ] errored to false, the loop ran to MAX_PAGES. The right answer, reached through a bash arithmetic error any refactor could have flipped.

scripts/pr-changed-files.sh is now the single implementation. Shared mechanism, not policy — the two docs-only allow-lists differ on purpose (the hook's is broader because it falls through to a human prompt; the workflow's is narrower because a match posts a green status with nobody in the loop). Sharing the enumeration fixes the drift; sharing the classification would erase an intended difference.

Note the issue's asymmetry table was already partly stale when filed: bb1809fb had since ported two of the eight guards. The real gap was six, and all six are in the shared script.

Why the enforced half is a separate PR

review-verdict.yml will check out the PR's base ref, never the head, so a PR cannot rewrite the gate that judges it. The base is main — which does not yet contain these scripts. Wiring it in this PR would make the job exit 127 on its own PR and block the merge gate through the combined status. Scripts land first; the follow-up points the enforced gate at them.

Verification

Mutation-tested, not just green. Each of the six guards was individually broken and the suite re-run; every mutation turned exactly its own test red, and the file restored byte-identical:

Mutation Tests that went red
CR/LF rejection removed 3
.. rejection removed 1
closed .status allow-list removed 3
previous_filename checked only on renamed 1
terminate on SHORT page 1
head-sha binding disabled 1

scripts/tests: 259 passed.

Five rounds on one predicate

The version parser in scripts/jq-preflight.sh took four revisions, and rounds 2, 3 and 4 each found a defect in the previous round's fix. They share one signature, which is worth naming because it is the thing to look for next time:

The guard appears to assert something, but a crafted input makes the assertion silently not happen — usually because a shell builtin errors inside an if condition, which set -e exempts.

Round Defect Why the tests missed it
1 jq version 1.6 left the major empty; the sanity check saw "6" and passed; [ "" -lt 1 ] errored → conditional false → floor never asserted, exit 0 No malformed-version case existed
2 jq --version 2>&1 || true folded stderr in and dropped the exit status, so a glibc error containing 2.34 passed the floor — a regression; round 1 failed closed here The shim always exited 0 and never wrote stderr
3 [[:space:]] matches newlines, so the "anchor" still scanned the whole output (jq\n2.34: cannot load → 2.34); and an over-long digit run resurrected round 1's exact mechanism No multi-line or out-of-range case
4 {1,9} alone didn't bound anything — unanchored at the end, an over-long minor just matched its first 9 digits and compared that —

| 5 | (predicate clean) — but two of round 4's changes were unpinned: reverting the first-line slice and [[:blank:]] together left the suite green, and the CR test was vacuous twice over | The 4 filler cases were all killed by the separator alone; and splitlines() splits on \r while subprocess(text=True) rewrites \r→\n, so the CR assertion was unfalsifiable |

Round 5 could not break the predicate: 28,930 real runs across 14,465 crafted --version strings on bash 3.2.57 and 5.3.15, zero fail-opens. It also settled the highest-risk portability question — {1,9} interval quantifiers are honoured inside [[ =~ ]] on bash 3.2, so round 4's bound is not void on the authoring Macs.

Round 4's fix: first line only, [[:blank:]] not [[:space:]], bounded digit runs with a trailing non-digit boundary, and a separator restricted to the two forms real jq emits (jq-1.6 / jq version 1.6).

Verified against a 20-case matrix — every legitimate form parses to the right numbers (jq-1.6, jq version 1.6, jq-1.7.1, jq-1.6-dirty, jq-1.10 compared numerically, jq-1.6 (Debian 1.6-2.1), jq-v1.6, JQ-1.6, jq-1.6.0, CRLF) and every constructed attack fails closed. Four mutations, each reddening exactly its own tests. The real jq 1.8.2 reports cleanly end-to-end.

None of these are reachable from a real jq build. They are fixed anyway because the guard's own stated invariant — never assert a floor against something it did not parse — was still violable four revisions in, and the follow-up PR moves this code into the branch-protection-required check, where "unreachable in practice" stops being a good enough reason.

Deviation from a Done-when box, stated rather than glossed: the box asked to prove loudness by temporarily asserting a version the runner lacks and watching the job go red. I did it as a unit test instead (test_jq_preflight.py shims jq on PATH), which is strictly better — it re-runs on every PR rather than living in one CI run's history — but it is not literally what the box says. The floor/pin logic is proven; what a real CI run still adds is confirming the version line actually appears in the job log, which is observable on this PR's own run.

A vacuous test was found and fixed during development: test_missing_jq_is_loud passed while PATH still exposed the Mac's real /usr/bin/jq, so the negative case was never negative. PATH is now narrowed to the shim dir alone.

Transition window is safe: the hook resolves the script relative to its own location and runs from the shared checkout, which will not have it until this merges. Verified by hiding the script and re-running: the exemption is withheld (fail-closed) and the gate falls through to a prompt. Costs an extra prompt, never a silent pass.

Docs

docs/ci-cd.md → "The jq contract"; new records ci.jq-version-contract and ci.shared-pr-file-enumeration; ci.script-tests-job now points at the contract instead of restating it. Both records carry a landing note about the two-PR split, removed by the follow-up.

fixes #648 Lands the jq version contract, and the shared PR-file enumeration that #649 needs. #649 stays open: its enforced half is a deliberate follow-up, explained below. ## #648 — the decision, with the small-lane constraint checked rather than assumed The issue offered three options and declined to pick. Option 1 (bake a pinned jq into `docker/ci/Dockerfile`) is **ruled out by measurement**: `.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 — so it gets the **host's** jq 1.6 regardless of what the toolchain image contains. That is the exact workflow where the #647 bug was live, so the pin would have missed it entirely. So the contract is inverted: **jq 1.6 is the floor**, and it is the runner's own jq that provides the 1.6 coverage `scripts/tests/` runs under. `scripts/jq-preflight.sh` has two deliberately asymmetric modes: | Mode | Used by | Why | |---|---|---| | no args — print version, assert floor | merge-path jobs | Observability with **no** deadlock risk | | `--expect 1.6` — additionally pin | `script-tests` only | Tripwire: the 1.6 coverage exists only because the runner ships 1.6 | The asymmetry is load-bearing. `review-verdict.yml` writes `review-verdict/h10`, the branch-protection-**required** status. A hard pin there would turn any jq bump on the runner — a base-image bump, a host reimage, nothing this repo controls — into a repo-wide merge deadlock. A required merge gate must not fail because an upstream package manager did its job. Conversely the pin on `script-tests` matters precisely because that job is the suite's only 1.6 coverage: if the runner's jq silently changed, that coverage would evaporate with no signal. ## #649 (partial) — one enumeration instead of two The enforced `review-verdict.yml` guard had drifted **strictly weaker** than the advisory merge-consent hook: four rounds of #643 hardening landed on the copy whose failures produce a human prompt, and never reached the copy that writes a required status. Its fail-closed behaviour on a garbage response was also *incidental* — `n` came back empty, `[ "$n" -lt 50 ]` errored to false, the loop ran to `MAX_PAGES`. The right answer, reached through a bash arithmetic error any refactor could have flipped. `scripts/pr-changed-files.sh` is now the single implementation. **Shared mechanism, not policy** — the two docs-only allow-lists differ on purpose (the hook's is broader because it falls through to a human prompt; the workflow's is narrower because a match posts a green status with nobody in the loop). Sharing the enumeration fixes the drift; sharing the classification would erase an intended difference. Note the issue's asymmetry table was already **partly stale** when filed: `bb1809fb` had since ported two of the eight guards. The real gap was six, and all six are in the shared script. ## Why the enforced half is a separate PR `review-verdict.yml` will check out the PR's **base** ref, never the head, so a PR cannot rewrite the gate that judges it. The base is `main` — which does not yet contain these scripts. Wiring it in this PR would make the job exit 127 on its own PR and block the merge gate through the combined status. Scripts land first; the follow-up points the enforced gate at them. ## Verification **Mutation-tested, not just green.** Each of the six guards was individually broken and the suite re-run; every mutation turned *exactly its own test* red, and the file restored byte-identical: | Mutation | Tests that went red | |---|---| | CR/LF rejection removed | 3 | | `..` rejection removed | 1 | | closed `.status` allow-list removed | 3 | | `previous_filename` checked only on `renamed` | 1 | | terminate on SHORT page | 1 | | head-sha binding disabled | 1 | `scripts/tests`: **259 passed**. ### Five rounds on one predicate The version parser in `scripts/jq-preflight.sh` took **four revisions**, and rounds 2, 3 and 4 each found a defect in the previous round's fix. They share one signature, which is worth naming because it is the thing to look for next time: > *The guard appears to assert something, but a crafted input makes the assertion silently not happen* — usually because a shell builtin errors inside an `if` condition, which `set -e` exempts. | Round | Defect | Why the tests missed it | |---|---|---| | 1 | `jq version 1.6` left the major empty; the sanity check saw `"6"` and passed; `[ "" -lt 1 ]` errored → conditional false → **floor never asserted, exit 0** | No malformed-version case existed | | 2 | `jq --version 2>&1 \|\| true` folded stderr in and dropped the exit status, so a glibc error containing `2.34` **passed the floor** — a regression; round 1 failed *closed* here | The shim always exited 0 and never wrote stderr | | 3 | `[[:space:]]` matches newlines, so the "anchor" still scanned the whole output (`jq\n2.34: cannot load` → 2.34); and an over-long digit run **resurrected round 1's exact mechanism** | No multi-line or out-of-range case | | 4 | `{1,9}` alone didn't bound anything — unanchored at the end, an over-long minor just matched its first 9 digits and compared *that* | — | | 5 | *(predicate clean)* — but two of round 4's changes were **unpinned**: reverting the first-line slice and `[[:blank:]]` together left the suite green, and the CR test was vacuous twice over | The 4 filler cases were all killed by the separator alone; and `splitlines()` splits on `\r` while `subprocess(text=True)` rewrites `\r`→`\n`, so the CR assertion was unfalsifiable | Round 5 could not break the predicate: **28,930 real runs** across 14,465 crafted `--version` strings on bash 3.2.57 and 5.3.15, zero fail-opens. It also settled the highest-risk portability question — `{1,9}` interval quantifiers **are** honoured inside `[[ =~ ]]` on bash 3.2, so round 4's bound is not void on the authoring Macs. Round 4's fix: first line only, `[[:blank:]]` not `[[:space:]]`, bounded digit runs **with a trailing non-digit boundary**, and a separator restricted to the two forms real jq emits (`jq-1.6` / `jq version 1.6`). Verified against a 20-case matrix — every legitimate form parses to the right numbers (`jq-1.6`, `jq version 1.6`, `jq-1.7.1`, `jq-1.6-dirty`, `jq-1.10` compared *numerically*, `jq-1.6 (Debian 1.6-2.1)`, `jq-v1.6`, `JQ-1.6`, `jq-1.6.0`, CRLF) and every constructed attack fails closed. Four mutations, each reddening exactly its own tests. The real jq 1.8.2 reports cleanly end-to-end. **None of these are reachable from a real jq build.** They are fixed anyway because the guard's own stated invariant — never assert a floor against something it did not parse — was still violable four revisions in, and the follow-up PR moves this code into the branch-protection-required check, where "unreachable in practice" stops being a good enough reason. **Deviation from a Done-when box, stated rather than glossed:** the box asked to prove loudness by temporarily asserting a version the runner lacks and watching the job go red. I did it as a unit test instead (`test_jq_preflight.py` shims `jq` on PATH), which is strictly better — it re-runs on every PR rather than living in one CI run's history — but it is not literally what the box says. The floor/pin logic is proven; what a real CI run still adds is confirming the version line actually appears in the job log, which is observable on this PR's own run. **A vacuous test was found and fixed during development:** `test_missing_jq_is_loud` passed while `PATH` still exposed the Mac's real `/usr/bin/jq`, so the negative case was never negative. PATH is now narrowed to the shim dir alone. **Transition window is safe:** the hook resolves the script relative to its own location and runs from the shared checkout, which will not have it until this merges. Verified by hiding the script and re-running: the exemption is *withheld* (fail-closed) and the gate falls through to a prompt. Costs an extra prompt, never a silent pass. ## Docs `docs/ci-cd.md` → "The jq contract"; new records `ci.jq-version-contract` and `ci.shared-pr-file-enumeration`; `ci.script-tests-job` now points at the contract instead of restating it. Both records carry a landing note about the two-PR split, removed by the follow-up.
timothy added 7 commits 2026-07-26 22:22:57 +02:00
#649 — the enforced review-verdict.yml guard had drifted strictly WEAKER than the
advisory merge-consent hook: four rounds of #643 hardening landed on the copy whose
failures produce a human prompt, and never reached the copy that writes the
branch-protection-required review-verdict/h10 status. Its fail-closed behaviour on a
garbage response was also incidental (an empty `n` erroring a bash conditional to
false), not designed.

Extract scripts/pr-changed-files.sh as the single implementation both call. Shared
MECHANISM, not policy: the two docs-only allow-lists differ deliberately and stay
separate. review-verdict.yml now checks out the BASE ref, never the PR head, so a PR
cannot rewrite the gate that judges it.

#648 — baking jq into docker/ci/Dockerfile provably cannot cover the gate that broke:
review-verdict.yml is runs-on:small with no toolchain pin, so it gets the host's jq 1.6
(checked, not assumed). Add scripts/jq-preflight.sh: floor+observable everywhere, and a
--expect tripwire on script-tests only — pinning the required merge check would deadlock
every merge on a jq bump.

Verified by mutation: six guards individually broken, each turning exactly its own test
red, then restored byte-identical.

fixes #648
fixes #649
Adds docs/ci-cd.md "The jq contract" (1.6 floor, the three divergent constructs,
and the deliberate pin-vs-floor asymmetry with its merge-deadlock reason), plus two
decision records: ci.jq-version-contract and ci.shared-pr-file-enumeration.
ci.script-tests-job stops restating the three jq rules and points at the new record.

Also corrects the script-tests preflight description: it is now two steps (git
presence, then jq VERSION via scripts/jq-preflight.sh --expect 1.6), not one.

A literal NUL byte had crept into the ci-cd.md paragraph describing jq 1.6's NUL
truncation — which git treats as a binary file. Replaced with the literal text.

Decisions-Edit: yes
Splits the review-verdict.yml rewiring out of this PR. That workflow checks out the
PR's BASE ref — deliberately, so a PR cannot rewrite the gate that judges it — and the
base is main, which does not yet contain scripts/pr-changed-files.sh or
scripts/jq-preflight.sh. Wiring it here would make the job exit 127 on its own PR and
block the merge gate through the combined status, which reads red jobs as blocking.

So this PR lands the scripts, their tests, the hook rewiring and the script-tests jq
pin; the follow-up points review-verdict.yml at them once they exist on main.

The two tests that asserted on review-verdict.yml are scoped accordingly, each carrying
the reason. test_review_verdict_never_pins_a_jq_version is asserted NOW rather than in
the follow-up, so the no-pin constraint on the required check is already enforced when
the wiring lands.

Decisions-Edit: yes
Two real defects, and three docs claims that were simply wrong.

jq-preflight.sh parsed the version by stripping around the first `-` and `.`, which
assumed the format is exactly `jq-X.Y`. A build printing `jq version 1.6` left major
empty; the sanity check concatenated major+minor into "6", which is non-empty and
all-digits, so it PASSED. The floor comparison then ran `[ "" -lt 1 ]`, which errors —
and `set -e` exempts a failing command in an `if` condition, so the conditional read
false and the script exited 0 having asserted nothing, after printing a plausible
"parsed" line. The silently-untested-axis failure this script exists to eliminate,
reproduced inside the script itself. Now parsed by explicit regex, failing closed with a
diagnosis when there is no <digits>.<digits> match. Also: `--expect` with no value exited
1 with empty output on both streams.

The hook's exit-status check was pinned by nothing: mutating `if files=$(...)` into
`files=$(...) || true; files_complete=yes` left the ENTIRE suite green. It survived only
by redundancy — the script writes stdout once, right before exit 0, so failures also
happen to yield empty stdout and `[ -n "$files" ]` catches it. Safe by accident, which is
the exact criticism this branch levels at the old code. Four tests now pin it, with a
stub that FAILS while emitting a docs-only list (the one case redundancy cannot absorb)
plus a positive control proving the harness can see the difference. Verified: the
mutation now turns exactly those tests red.

Docs corrections. The record claimed the --expect pin was safe because script-tests is
"advisory, not a required check" — false. The merge-consent hook reads the COMBINED
status (ci.advisory-red-blocks-the-merge-gate, #598), so firing the tripwire blocks every
non-docs-only merge until someone re-pins. Kept anyway, for a stated reason, but no
longer described as free. The record also asserted in the present tense that
review-verdict.yml checks out the base ref; it has no checkout step at all, so that is
now a future-tense requirement on the follow-up. And the documented .status allow-list
named GitHub's `removed`, which the code rejects.

The drift-guard regex anchored on `?limit=`, so a re-inlined copy written
`files?page=1&limit=50` would have walked past it.

Decisions-Edit: yes
Round 3, and it found that round 2's fix was a REGRESSION on the case that matters most.

`raw=$(jq --version 2>&1 || true)` did two wrong things at once: folded stderr into the
parse input and discarded the exit status. Combined with a pattern that matched the first
<digits>.<digits> ANYWHERE, a jq broken by a glibc mismatch — which exits 127 and writes
"version `GLIBC_2.34' not found" to stderr — parsed as version 2.34 and PASSED the floor.
The strip-based parse this replaced failed CLOSED there. So the fix for a fail-open bug
introduced a worse fail-open bug, in the one script whose entire purpose is to refuse to
certify a version it did not parse.

Same mechanism, second symptom: an unanchored match let a prefix outrank the real version.
`2026.07.26 jq-1.6` parsed as 2026.07; a leading warning line carrying any number won too.

Now: jq's exit status is captured explicitly (`$?` inside `if ! cmd` is the NEGATED status,
so that needed care too), stderr is kept out of the parse, and the pattern is anchored to
the leading `jq` token. Every legitimate form still parses — `jq-1.6`, `jq version 1.6`,
`jq-1.7.1`, `jq-1.6-dirty`, `jq-1.6 (Debian 1.6-2.1)`, `jq-1.10` (numeric compare, so the
two-digit minor is not read lexically).

The tests could not have caught any of this: the shim always exited 0 and never wrote to
stderr, so every case it could express was clean. It now takes stderr and an exit code, and
the four new cases turn red under the exact mutation.

Also: the drift guard now strips comment lines before matching. A future comment citing
`pulls/$pr/files?limit=100` as an example of what not to do would otherwise have reddened
script-tests — which, per this branch's own correction, blocks merges.

And the record no longer over-corrects: the combined-status read is guarded by
`if [ "$mwcs" != "true" ]`, so a red script-tests blocks the hook-mediated merge path, not
literally every merge.

Decisions-Edit: yes
Round 4 found the round-1 MECHANISM alive in round 3's regex. The pattern guaranteed the
operands were digits but not that they fit `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. That is precisely what the empty string did in round 1: same
shape, third occurrence, same predicate.

Bounding the runs with {1,9} was not sufficient on its own. The pattern 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 rather than an error that skips it. The
trailing `([^0-9]|$)` is what actually closes it.

Second hole: `[[:space:]]` matches NEWLINES, so round 3's "anchor" still scanned the whole
output. `jq\n2.34: cannot load shared library` matched `jq`, crossed the newline as
separator, and parsed 2.34. Now the first line only, with `[[:blank:]]`.

Third: the separator class `[-[:blank:]]{1,4}` could be walked across filler —
`jq -- 2.34 (real jq-1.6)` parsed as 2.34, `jq<TAB><TAB>9.9` as 9.9. It is now one of the two
forms real jq emits: `jq-1.6` or `jq version 1.6` (a blank separator REQUIRES the literal
word `version`).

Verified across a 20-case matrix: every legitimate form still parses to the right numbers
(jq-1.6, jq version 1.6, jq-1.7.1, jq-1.6-dirty, jq-1.10 numerically, jq-1.6 (Debian 1.6-2.1),
jq-v1.6, JQ-1.6, jq-1.6.0, CRLF), and every constructed attack fails closed. Four mutations,
each reddening exactly its own tests. The real jq 1.8.2 on this machine still reports cleanly.

Also: the log line now interpolates the first line, so a multi-line --version cannot split the
single grep-able line the no-arg mode exists to emit.

None of these are reachable from a real jq build. They are recorded and fixed because the
guard's own stated invariant — never assert a floor against something it did not parse — was
still violable three rounds in, and the follow-up PR moves this exact code into the
branch-protection-required check.
test(648): close the mutation gaps round 5 found — two tests passed for the wrong reason
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Review verdict / Set review-verdict status (pull_request) Successful in 31s
PR Gates / Script tests (pytest) (pull_request) Successful in 35s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m59s
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
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m24s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ b255b7f
b255b7ffdc
Round 5 could not break the predicate itself: 28,930 real runs of the script across
14,465 crafted --version strings on bash 3.2.57 and 5.3.15 produced zero fail-opens, and
`{1,9}` is honoured on bash 3.2, so round 4's bound is not void on the authoring Macs.

What it did find is that two of round 4's changes were unpinned, and the tests that
looked like they covered them did not.

Reverting BOTH the first-line slice and `[[:blank:]]`→`[[:space:]]` together left the whole
suite green. The four filler cases are all killed by the SEPARATOR restriction alone, so
they attributed the fix to the wrong layer. Added three cases carrying the literal word
`version` (`jq\nversion\n9.9` and friends), which satisfy the separator rule and can only be
stopped by confining the parse to line one with a newline-free blank class.

The CR-strip test was worse: vacuous through two independent mechanisms. `str.splitlines()`
also splits on `\r`, so a per-line view dropped the stray CR; and `subprocess.run(text=True)`
translates `\r` to `\n` outright, so even a raw-string check on stdout was unfalsifiable.
The mutant demonstrably emits `... = jq-1.6<CR> (parsed 1.6; ...)` at the byte level while
the test reported green. Added `run_bytes()` and a bytes comparison.

Both gaps are now mutation-verified: reverting either change reddens exactly its own test.

Also records the operational edge this parser acquires in the follow-up: it is strictly
fail-closed by design, so once the floor mode gates the required check, a jq wrapper that
prints a banner line would deadlock merges. The fix there is to widen the accepted forms,
never to relax fail-closed.

Decisions-Edit: yes
Author
Owner

Review-verdict: MERGEABLE @ b255b7f

Five adversarial review rounds on the version predicate; rounds 2-4 each broke the previous fix, round 5 could not break it (28,930 runs, bash 3.2 + 5.3) and its two test-quality gaps are closed and mutation-verified. Runner log confirms jq-1.6 and the observable version line. 292 script tests; 16 guards individually mutation-tested.

Review-verdict: MERGEABLE @ b255b7f Five adversarial review rounds on the version predicate; rounds 2-4 each broke the previous fix, round 5 could not break it (28,930 runs, bash 3.2 + 5.3) and its two test-quality gaps are closed and mutation-verified. Runner log confirms jq-1.6 and the observable version line. 292 script tests; 16 guards individually mutation-tested.
timothy merged commit 8103e34fff into main 2026-07-26 22:46:05 +02:00
timothy deleted branch fix/648-649-jq-gates 2026-07-26 22:46:06 +02:00
Sign in to join this conversation.