Files
ersatztv/scripts/lib/branch-rule-classifier.jq
T
timothyandtimothy cf5f42edf9
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 7m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m26s
fix(858,859): a rule the classifier cannot read is not a rule that matches nothing (#897)
#859 was filed as a wrong STATED CAUSE. It was masking a live false-open in the merge gate.

Gitea reports a GLOB branch-protection rule with an EMPTY `branch_name` — the canonical
name lives only in `rule_name`. Measured 2026-08-30 on a scratch repo against 1.27.1.
jq's `//` fires on null and false but NOT on `""`, so `(.branch_name // .rule_name // "")`
resolved every glob rule to the empty string — a name with no metacharacters — and the
glob test, the entire basis of the classifier's undecidable-first ordering, never saw it.

Measured on the predecessor: glob `m*` (not requiring review-verdict/h10) beside plain
`main` (requiring it) resolved to `exact` on `main` and AUTO-GRANTED a scheduled merge,
while Gitea — ordering by Priority then plain-name-ness — may be applying `m*`. That is
#622's hole, reached through the ordering written to close it. Mirror case: a glob alone
resolved to `none` and DENIED about a rule that provably governs the base.

A name is now a non-empty string. Each field resolves to a NAME, a SKIP (absent/null/
empty — fall through), or POISON (present, wrong type — poisons whichever field carries
it). A rule with no usable name is a distinct `unreadable` verdict with its own operator
cause, instead of feeding `none`, whose whole authority is "the full rule list was read
and none matches". The short-circuit is STRUCTURAL: jq binds `as` eagerly, so the flat
form still evaluated `offs`/`nonascii` on the bad name and died before reaching the arm
meant to prevent that.

Also #859: `branch_protections` is fetched ONCE per run, not twice. The round trip is the
smaller half — it is mutable config, so two reads can disagree and the two arms then
decide about different repo states with neither able to notice.

#858: `verdict_script` resolves from `$repo_root`, not `$CLAUDE_PROJECT_DIR`. And the
finding that mattered more — `ETV_HOOK_FIRE_LIB` is `. `-SOURCED, so it is CODE running
before stdin is read and before `decide` exists. A first draft exempted it as "telemetry,
not a predicate"; cold review refuted that by execution: a decoy hook-fire-log.sh in an
env-var-named tree printing an allow and exiting 0 GRANTS THE MERGE, bypassing every
check. Classify a path by how it is CONSUMED, never by what it is called. This hook's copy
is self-located; the other twelve are #891 (high/security), which records the reachable
case — husky launches the prepush hooks by RELATIVE path, so the two roots diverge there.

check-required-contexts.sh gains an array-type gate (a JSON object previously printed
`nomatch`, a positive claim about server config from a body it cannot consume).

Verification: 1377 passed / 2 skipped; 11 declared mutants, 11 detected, disjoint
reddened sets; classifier executed across jq 1.8.2 and 1.6 with identical results; both
env-var tests ship a negative control, because the passing outcome is also what an inert
decoy produces.

Four cold review rounds plus a bounded prose check. Every round found defects the
previous round's fixes introduced — a type conflation that re-opened the auto-grant, a
comment asserting the opposite of the line its own commit changed, and a corrected
sentence whose identical twin survived in the same diff.

Docs: new record `process.hook-resolves-inputs-from-repo-root`; both inline sites cite it
rather than arguing it twice. docs/remote-state-inventory.md's row for the second read
updated. Follow-ups filed: #891 (the other 12 hooks), #895 ("all N tests green" claims).

fixes #858
fixes #859

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-30 11:33:50 +00:00

132 lines
9.4 KiB
Plaintext

# THE governing-branch-protection-rule classifier, as ONE program with two callers (ersatztv#787).
#
# Input : the `GET /repos/{owner}/{repo}/branch_protections` array. Arg `$b`: the branch to resolve.
# Output: {verdict:"exact", rule:<the rule>} | {verdict:"undecidable"} | {verdict:"unreadable"}
# | {verdict:"none"}
#
# It lived inline in `.claude/hooks/pretooluse-merge-consent.sh` until #787 needed the same question
# answered for `main` from a second caller. It was EXTRACTED rather than copied: this is a security
# predicate, and that hook's own history is the argument — the docs-only file enumeration was written
# twice, four rounds of hardening landed on one copy and never reached the other, and the copy with
# real authority ended up strictly weaker than the copy without. Two copies of a security predicate
# drift; one cannot.
#
# DO NOT "simplify" the undecidable-first ordering. Gitea picks the governing rule with
# `GetFirstMatched` over a list sorted by Priority, then by plain-name-ness, so a GLOB rule with a
# better Priority outranks an exactly-named one. Preferring the exact rule would inspect a rule Gitea
# might not be applying. `superset` deliberately over-approximates a glob (literal prefix + `.*` +
# literal suffix) instead of reimplementing gobwas/glob: refusing whenever a glob COULD match is
# sound without knowing the dialect, which is the only claim either caller makes.
def esc: gsub("(?<c>[.+?^${}()|\\[\\]\\\\])"; "\\" + .c);
def offs: [match("[*?\\[\\]{}\\\\]"; "g").offset];
def superset: . as $n | (offs) as $o
| ($n[0:$o[0]] | esc) + ".*" + ($n[($o[-1]+1):] | esc);
def nonascii: explode | any(. > 127);
# A USABLE name is a NON-EMPTY STRING and nothing else, and the two fields are tried in order.
# `.branch_name // .rule_name // ""` got both halves of that wrong (ersatztv#859).
#
# ABSENT/NULL/FALSE became `""` — a VALID name that simply matches nothing — so a rule the program
# could not read at all was counted as a rule that does not govern the base. That fed `none`,
# whose whole authority is the claim "the full rule list was read and none matches", and on the
# merge hook's caller `none` DENIES. A deny is the one arm that must not rest on a rule that was
# never understood.
#
# A NUMBER did NOT take that path, and saying so would be the same overclaim this record keeps
# catching: `//` fires on null and false only, so `{"branch_name":42}` yielded `42`, which then
# made `match` throw. That reached a safe `ask` through the callers' catch-all — a correct
# outcome resting on a crash rather than on a decision, which is why it is classified now.
#
# EMPTY STRING shadowed the fallback, because jq's `//` fires on null and false but NOT on `""`.
# `{"branch_name":"","rule_name":"main"}` therefore resolved to `""` and answered `none` — a
# DENY on the stated grounds that nothing can govern the base, with the governing rule sitting
# right there in the field the program declined to read. So an empty string falls THROUGH to
# the next field rather than winning it.
#
# THIS IS THE SHAPE GITEA ACTUALLY SENDS FOR A GLOB RULE, and it made the undecidable-first
# ordering above a no-op for every one of them. Measured 2026-08-30 against Gitea 1.27.1 on a
# scratch repo, creating one rule of each kind and reading the list back:
#
# {"branch_name":"", "rule_name":"release/*"} <- GLOB rule
# {"branch_name":"main", "rule_name":"main"} <- plain rule
#
# A first probe against this repo saw only its single plain rule and concluded both fields are
# always populated. That was an arbitrary sample generalised into a claim; the positive case had
# to be CONSTRUCTED. Both defects below were then measured on the predecessor:
#
# FALSE-OPEN. Glob `m*` (not requiring `review-verdict/h10`) plus plain `main` (requiring it)
# resolved to `exact` on `main`, so the gate read the plain rule's contexts and AUTO-GRANTED a
# scheduled merge — while Gitea, which orders by Priority then plain-name-ness, may be applying
# `m*`. That is precisely the hole the undecidable-first ordering is written to close, defeated
# because the glob's name was invisible: `""` carries no metacharacter, so the glob test never
# saw it.
# FALSE-DENY. Glob `m*` alone resolved to `none`, denying with "the full rule list was read and
# none matches" about a rule that provably governs the base.
#
# So the fall-through is not defence in depth against a hypothetical future payload; it repairs a
# live misclassification of the payload this server sends today.
# FIELD ORDER IS PRESERVED FROM THE `//` CHAIN — `branch_name` first — and it is unobservable on
# the payloads Gitea 1.27.1 actually sends, which is why it stays rather than being "modernised"
# to prefer the canonical `rule_name`. Measured 2026-08-30, both rule kinds on a scratch repo: a
# PLAIN rule sets both fields to the same value, a GLOB rule sets `branch_name` to `""`. There is
# no payload where both are non-empty AND different, so "first non-empty" selects the canonical
# name under either ordering.
#
# This is a MEASUREMENT, not an appeal to the old code. That chain's order is not evidence the
# order is right — precedent for a style is not evidence the behaviour is correct, and this chain
# was wrong about everything else on this line. If a server ever sends two different non-empty
# names, decide then, with the payload in hand.
# THREE outcomes per field, not two, and collapsing the last two is a regression cold review
# caught in the first draft of this fix. A field either supplies a NAME, or SKIPs (it is simply
# not carrying one: absent, null, or the empty string Gitea sends for a glob rule), or POISONs
# (it is PRESENT holding a type a name cannot have).
#
# The first draft fell through to `rule_name` on anything that was not a usable string, which
# made a malformed field indistinguishable from an unsupplied one. Measured: with `branch_name`
# a number, boolean or array beside `rule_name:"main"`, the predecessor THREW and the gate asked,
# while that draft answered `exact` and AUTO-GRANTED — and with `rule_name:"develop"` it answered
# `none`, denying on "the full rule list was read and none matches" about a field it never read.
# Both directions of the very defect this file is fixing, reintroduced one shape over.
#
# A malformed field is evidence the payload is not what this program thinks it is, so it poisons
# the whole list rather than deferring to its sibling.
def namefield($v): if ($v | type) == "string"
then (if ($v | length) > 0 then {name: $v} else {skip: true} end)
elif $v == null then {skip: true}
else {poison: true} end;
# POISON IS CHECKED ON BOTH FIELDS BEFORE EITHER NAME IS TAKEN, so the invariant above holds
# field-agnostically. The first draft short-circuited on `branch_name` and only consulted
# `rule_name` as a fallback, which made the rule order-dependent: `{"branch_name":42,
# "rule_name":"main"}` poisoned, while `{"branch_name":"main","rule_name":42}` answered `exact`
# off the good field and never looked at the malformed one. Gitea 1.27.1 does not send that
# second shape — a plain rule sets both fields equal, a glob rule sets `branch_name` to `""` —
# so this buys no measured behaviour today. It is fixed because a comment claiming an invariant
# the code does not hold is the failure this whole change is about, and "unreachable" is a
# property of one server version, not of the program.
def rulename: namefield(.branch_name) as $bn | namefield(.rule_name) as $rn
| if ($bn.poison or $rn.poison) then null
elif $bn.name then $bn.name
elif $rn.name then $rn.name
else null end;
. as $rules | $b as $base |
# THE SHORT-CIRCUIT IS STRUCTURAL, not merely first in the `if` chain, and that is load-bearing
# rather than stylistic. jq binds `as` eagerly: written as one flat chain, `$exacts` and
# `$unfoldable` are computed BEFORE any arm is tested, so an unreadable name reached `offs` and
# `nonascii` anyway and the program died with "null cannot be matched" — the crash this arm was
# added to replace, still happening, with the arm sitting unreached above it. Measured while
# writing #859. Nesting the readable-name arms inside the `else` is what actually prevents it.
if ($rules | any(rulename == null)) then {verdict:"unreadable"}
else
($rules | map(select(rulename as $n
| (($n|offs|length) == 0)
and (($n|ascii_downcase) == ($base|ascii_downcase))))) as $exacts |
(($base|nonascii) or ($rules | any(rulename as $n
| ($n|offs|length) == 0 and ($n|nonascii)))) as $unfoldable |
if ($rules | any(rulename as $n
| (($n|offs|length) > 0)
and ($base | test("^" + ($n|superset) + "$")))) then {verdict:"undecidable"}
elif $unfoldable then {verdict:"undecidable"}
elif ($exacts | length) > 1 then {verdict:"undecidable"}
elif ($exacts | length) == 1 then {verdict:"exact", rule:($exacts | first)}
else {verdict:"none"} end
end