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
#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>
132 lines
9.4 KiB
Plaintext
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
|