From 0803d3b2012317b35ed4f08cc325d50b4da88883 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 02:23:55 +0000 Subject: [PATCH 01/13] ci(board-sweep): judge the board before asking what a tag shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `released` calls `graph-check` by path and `graph-check` calls `ready-lint`, so a checkout carrying no `v*` tag took out the two gates the sweep exists for. The tag-less clone is the ordinary case, not an edge one: a web session clones shallow and single-branch and fetches no tags. A gate whose input is a property of the CLONE was sitting upstream of gates whose input is a property of the BOARD. `graph-check` is now a leaf, invoked directly and first. That is the decouple rather than a reorder — a reorder leaves the topology, so the next clone-shaped input reintroduces it. `released` is unchanged: its refs-only arm is correct behaviour for a tag-less clone, and its own internal `graph-check` call stays its own composition, reported once under its own name. Abstention splits into two lanes, which is the substance of the finding: a board-scoped could-not-look still outranks everything (exit 2, the board was not judged), a refusal now outranks a clone-scoped abstention (exit 1, because the board WAS judged), and a coherent board with a clone-scoped abstention is exit 3 — this layer's existing "no verdict here, the caller decides" code. `tests/board-sweep.bats:153` asserted the incumbent contract this reverses, using the missing tag as its abstention. It is re-based on a board-scoped abstention with the change written into the case; the tag-less pair it used to hold is now its own case, asserting exit 1. Refs: CLOUD-921 --- mise-tasks/board-sweep.sh | 87 +++++++++++++++++++++++++++++++++++---- tests/board-sweep.bats | 85 ++++++++++++++++++++++++++++++++++---- 2 files changed, 156 insertions(+), 16 deletions(-) diff --git a/mise-tasks/board-sweep.sh b/mise-tasks/board-sweep.sh index 4d5baff36..abd0353fd 100755 --- a/mise-tasks/board-sweep.sh +++ b/mise-tasks/board-sweep.sh @@ -37,6 +37,39 @@ # faithfully, freshness not at all, so a recovered `status` may be stale. # Recover the structure from the cache; re-read a row before deciding its state. # +# A CLONE-SCOPED ABSTENTION MUST NOT SUPPRESS A BOARD-SCOPED VERDICT +# (CLOUD-921). `released` calls `graph-check` by path and `graph-check` calls +# `ready-lint`, so for its whole life a checkout with no `v*` tag took out the two +# gates this sweep exists for — and the tag-less clone is the ORDINARY case: a web +# session clones shallow and single-branch, and `git fetch origin main` with the +# configured refspec brings no tags. Measured 2026-08-22 over six harvested +# payloads: `released COULD NOT LOOK`, and `graph-check` never ran. The gates were +# fine; the chain was not. +# +# So `graph-check` is a LEAF here, invoked directly and before `released`. That is +# the decouple rather than a reorder, because a reorder leaves the topology — the +# next clone-shaped input reintroduces it. `released` is unchanged (CLOUD-921 §1): +# its refs-only arm is correct behaviour for a tag-less clone. It still gets the +# payload set when a tag exists, and its own internal `graph-check` call stays its +# own composition — it consults the gate only for rows the TAG shipped, and that +# verdict surfaces as `released`'s own `REFUSED ()` lines. One gate name is +# reported by this sweep once, so this is not CLOUD-351's two-sweeps shape. +# +# TWO ABSTENTION LANES, because "could not look" was one word for two facts: +# +# BOARD-SCOPED (`unjudgeable`) — a gate could not decide over the PAYLOAD SET: +# `graph-check`, `done-pr-check` or `spec-ref-check` exiting 2, or a set that +# is empty or not JSON. The board has NOT been judged; that is exit 2 and it +# outranks a refusal, which is CLOUD-251's discipline unchanged. +# +# CLONE-SCOPED (`abstained`) — the input is a property of this CLONE or its +# environment rather than of the board: no `v*` tag for `released`, no +# `--pulls` file and no reachable `gh`. The board WAS judged; one gate had +# nothing to judge with. That is exit 3, and a refusal outranks it. +# +# The distinction is the whole of CLOUD-921: those two were one exit code, so a +# tag-less clone could not tell "coherent, one gate abstained" from "not judged". +# # NO SCHEDULE, deliberately (CLOUD-825 §5). Whether this also runs on a cron, at # Stop, or as a `land` postlude is a trigger question with its own cost — # CLOUD-812 measured 96 idle cron ticks/day flooring at ~2,900 billed min/month. @@ -47,7 +80,11 @@ # With no `--payloads`, the set is `$BOARD_PAYLOADS_DIR/*.json` when that # directory holds any, and stdin otherwise. `-` forces stdin. # -# Exit 0 the board is coherent / 1 dissonance named / 2 could not look. +# Exit 0 the board is coherent / 1 dissonance named / 2 the board was not judged +# (a board-scoped gate could not look) / 3 the board was judged and is coherent, +# and a clone-scoped gate abstained. `3` is this layer's existing "no verdict +# here, the caller decides" code — `checks-green.sh` and `sonar-gate.sh` both +# already publish it. # # Declared mutations (CLOUD-418), one per clause the suite must be able to lose. # `@` delimits each sed script and the rows are `|`-separated, so a script may @@ -57,6 +94,9 @@ #MUTANT gate-two-laundered|s@^\t\tunjudgeable=@\t\trefusals=@|a gate exiting 2 is not laundered into the refusal lane #MUTANT drain-not-invoked|s@^run_gate in-progress-drain@true in-progress-drain@|a landed-but-In-Progress row is named by in-progress-drain #MUTANT released-fed-nothing|s@^\trun_gate released.*@\trun_gate released "$here/released.sh" "$tag" /dev/null) || count=0 refusals=0 unjudgeable=0 +# The clone-scoped lane (CLOUD-921). Only this task's own pre-gather steps write +# it: a gate that RAN and exited 2 has said something about the payload set, which +# is board-scoped by construction, so `run_gate` never touches this counter. +abstained=0 # Pointer-only per non-negotiable rule 4: the gate's name and its verdict. Each # gate's own per-issue lines carry issue keys and rule ids and are passed @@ -182,7 +226,7 @@ run_gate() { # run_gate # The loop names TASKS and the files carry `.sh` (CLOUD-865), so the filename is # built here rather than assumed equal to the task name — the same split # `mutant` makes over `$MUTANT_GATES`. -for gate in released in-progress-drain done-pr-check spec-ref-check; do +for gate in graph-check released in-progress-drain done-pr-check spec-ref-check; do [[ -x "$here/$gate.sh" ]] || { echo "::error:: board-sweep: cannot run $here/$gate.sh. A gate that cannot run is not a pass — the sweep needs it, so this is 'could not look'." >&2 exit 2 @@ -191,7 +235,16 @@ done echo "board-sweep: $count issue(s)" -# --- released -> graph-check -> ready-lint ----------------------------------- +# --- graph-check -> ready-lint ----------------------------------------------- +# +# THE LEAF, AND IT GOES FIRST (CLOUD-921). `graph-check` decides whether the board +# is honestly labelled and calls `ready-lint` per Todo row, so between them they +# are what the sweep exists for. Their input is entirely the payload set — no tag, +# no range, no forge — which is exactly why nothing clone-scoped may sit upstream +# of them. It is invoked here rather than reached through `released`. +run_gate graph-check "$here/graph-check.sh" <<<"$issues" + +# --- released ---------------------------------------------------------------- # # THE REDIRECT THIS TASK EXISTS TO REPLACE. `released ` with no stdin # reports the refs a tag shipped and returns; only the stdin arm reaches @@ -203,9 +256,9 @@ if [[ -z "$tag" ]]; then tag=$(git tag --list 'v[0-9]*' --sort=-version:refname | head -n1) || tag="" fi if [[ -z "$tag" ]]; then - unjudgeable=$((unjudgeable + 1)) - echo " released COULD NOT LOOK" - echo "::error:: board-sweep: this checkout carries no \`v*\` tag, so \`released\` cannot resolve a range and \`graph-check\` behind it is never reached. Fetch tags, or pass --tag." >&2 + abstained=$((abstained + 1)) + echo " released ABSTAINED" + echo "::notice:: board-sweep: this checkout carries no \`v*\` tag, so \`released\` cannot resolve a range and says nothing about which rows a release shipped. Every board-scoped gate above still ran. Fetch tags, or pass --tag." >&2 else run_gate released "$here/released.sh" "$tag" <<<"$issues" fi @@ -233,9 +286,13 @@ elif command -v gh >/dev/null 2>&1; then jq -c '[.[] | {number, state: (.state | ascii_downcase), draft: .isDraft}]') || pulls="" fi if [[ -z "$pulls" ]] || ! jq -e 'type == "array"' <<<"$pulls" >/dev/null 2>&1; then - unjudgeable=$((unjudgeable + 1)) - echo " done-pr-check COULD NOT LOOK" - echo "::error:: board-sweep: no pull-request state to judge Done against. Supply --pulls (a JSON array of {number,state,draft}), or make \`gh\` reachable." >&2 + # THIS TASK'S OWN GATHER STEP FAILED, which is a fact about the environment + # rather than about the board — no `gh`, no `--pulls`. `done-pr-check`'s OWN + # exit 2 (a row naming a PR whose state was piped but absent) stays in the + # board-scoped lane, because that one is a statement about a row. + abstained=$((abstained + 1)) + echo " done-pr-check ABSTAINED" + echo "::notice:: board-sweep: no pull-request state to judge Done against, so that gate was not run. Supply --pulls (a JSON array of {number,state,draft}), or make \`gh\` reachable." >&2 else # Every payload carries the whole list; `done-pr-check` selects by number, so # a per-issue projection here would be a second answer to a question that @@ -253,12 +310,24 @@ fi # `git grep -hoE "CLOUD-[0-9]+'?s? §[0-9]+"` names, not just the active columns. run_gate spec-ref-check "$here/spec-ref-check.sh" <<<"$issues" +# THE ORDER OF THESE THREE IS THE CONTRACT (CLOUD-921). +# +# A board-scoped abstention still outranks everything: nothing below is worth +# reporting about a board that was not judged. if [[ "$unjudgeable" -gt 0 ]]; then echo "board-sweep: $unjudgeable gate(s) could not look — the board has not been judged" >&2 exit 2 fi +# A REFUSAL OUTRANKS A CLONE-SCOPED ABSTENTION, and this is the reversal. The +# board WAS judged here, so reporting "not judged" would withhold a verdict that +# exists — which is what a tag-less clone did to every dissonance the other gates +# found. if [[ "$refusals" -gt 0 ]]; then echo "board-sweep: $refusals gate(s) name dissonance above" >&2 exit 1 fi +if [[ "$abstained" -gt 0 ]]; then + echo "board-sweep: the board is coherent ($count issue(s)); $abstained gate(s) abstained on a property of this clone, not of the board" >&2 + exit 3 +fi echo "board-sweep: every gate ran and none names dissonance ($count issue(s))" diff --git a/tests/board-sweep.bats b/tests/board-sweep.bats index b0a3a19c1..a75b116fd 100644 --- a/tests/board-sweep.bats +++ b/tests/board-sweep.bats @@ -52,7 +52,12 @@ row() { local id=$1 status=${2:-In Progress} local assignee=${3:-\"t@t\"} local att=${4:-'[{"url":"https://github.com/o/r/pull/1"}]'} - printf '{"id":"%s","status":"%s","updatedAt":"2026-08-20T00:00:00.000Z","gitBranchName":"x/%s","assignee":%s,"assigneeId":%s,"description":"a body","relations":{"blockedBy":[],"blocks":[],"relatedTo":[]},"attachments":%s}' \ + # `projectMilestone` is present on every row because `graph-check` reads a set + # in which NO payload carries it as projected-away — `unjudgeable-milestone`, + # exit 2 — whenever the set holds a Todo row. That is its could-not-look arm + # rather than a verdict, and a fixture that tripped it would test the projection + # instead of the clause each case means to isolate (CLOUD-921). + printf '{"id":"%s","status":"%s","updatedAt":"2026-08-20T00:00:00.000Z","gitBranchName":"x/%s","projectMilestone":{"name":"m"},"assignee":%s,"assigneeId":%s,"description":"a body","relations":{"blockedBy":[],"blocks":[],"relatedTo":[]},"attachments":%s}' \ "$id" "$status" "$id" "$assignee" "$assignee" "$att" } @@ -81,7 +86,7 @@ land() { @test "every gate is reached, and the report names each one" { sweep "$(set_of "$(row CLOUD-1)")" local g - for g in released in-progress-drain done-pr-check spec-ref-check; do + for g in graph-check released in-progress-drain done-pr-check spec-ref-check; do [[ "$output" == *"$g"* ]] done } @@ -150,11 +155,18 @@ Closes CLOUD-1" [[ "$output" != *"done-pr-check REFUSED"* ]] } -@test "could not look outranks a refusal, so a half-run sweep is never exit 1" { - # Both at once: `released` cannot resolve a range without a tag, and the - # drain has a landed row to name. The sweep has not judged the board, and - # saying "dissonance found" would imply it had. - git tag -d v0.0.1 +@test "a board-scoped could-not-look outranks a refusal, so a half-run sweep is never exit 1" { + # Both at once: `spec-ref-check` cannot look (its root is gone, so a citation + # resolves against nothing) and the drain has a landed row to name. The BOARD + # has not been judged, and saying "dissonance found" would imply it had. + # + # THIS CASE WAS TAG-LESS UNTIL CLOUD-921, and the change is deliberate rather + # than incidental. It used `released`'s missing tag as the abstention, which + # made the incumbent contract "a property of the CLONE suppresses every verdict + # about the BOARD" — exactly the topology CLOUD-921 reverses. The rank still + # holds; what changed is which abstentions earn it. The tag-less pair is now + # `a refusal outranks a clone-scoped abstention` below. + export SPEC_REF_ROOT="$BATS_TEST_TMPDIR/gone" land "feat: work Closes CLOUD-1" @@ -163,6 +175,65 @@ Closes CLOUD-1" [ "$status" -eq 2 ] } +# --- CLOUD-921: the clone-scoped lane --------------------------------------- +# +# `released` calls `graph-check` by path and `graph-check` calls `ready-lint`, so +# a checkout with no `v*` tag took out the two gates the sweep exists for. The web +# session's clone is tag-less by construction, so that was the ordinary case. + +@test "a tag-less clone still gets a graph-check verdict, and the sweep says so" { + # THE CASE THIS ROW EXISTS FOR, and it is red before the decouple: with + # `graph-check` reachable only through `released`, a missing tag means the gate + # is never invoked and its name appears nowhere in the report. + git tag -d v0.0.1 + sweep "$(set_of "$(row CLOUD-1)")" + [[ "$output" == *"graph-check ok"* ]] + [[ "$output" == *"released ABSTAINED"* ]] + # Judged and coherent, one clone-scoped gate abstained. + [ "$status" -eq 3 ] +} + +@test "an abstention and a not-judged sweep are different exit codes" { + # The two were one code, which is the substance of the finding: a reader could + # not tell "the board is coherent, this clone cannot say what shipped" from + # "nothing was judged". Same fixture, one variable — which lane abstains. + git tag -d v0.0.1 + sweep "$(set_of "$(row CLOUD-1)")" + [ "$status" -eq 3 ] + + # Board-scoped this time: `done-pr-check`'s own exit 2 over a row whose PR + # state was piped and absent. Still 2, and case `a gate exiting 2 is not + # laundered into the refusal lane` above pins that lane on its own. + echo '[]' >"$PULLS" + sweep "$(set_of "$(row CLOUD-1)")" + [ "$status" -eq 2 ] +} + +@test "a refusal outranks a clone-scoped abstention, so reachability buys no weaker verdict" { + # The pair the case above used to assert as exit 2. An incoherent board in a + # tag-less clone must still be REFUSED: the board was judged, so withholding + # the verdict because this clone cannot resolve a range is the defect. + git tag -d v0.0.1 + land "feat: work + +Closes CLOUD-1" + printf 'CLOUD-1\t1\n' >"$EV" + sweep "$(set_of "$(row CLOUD-1)")" + [ "$status" -eq 1 ] + [[ "$output" == *"in-progress-drain"* ]] +} + +@test "a tag-less clone reaches ready-lint, which is graph-check's own leaf" { + # `graph-check` enforces `Todo => ready-lint exits 0`, so a Todo row whose body + # carries no Ready block is the one refusal that can only have come from + # `ready-lint` running. Attribution is the rule name, as elsewhere in this + # suite: that string exists nowhere else in the sweep. + git tag -d v0.0.1 + sweep "$(set_of "$(row CLOUD-1 "Todo" null '[]')")" + [ "$status" -eq 1 ] + [[ "$output" == *"todo-not-ready"* ]] +} + # --- rule 4 ----------------------------------------------------------------- @test "the report carries no issue body" { From 4980dcdfe185ffea65962206bb3cc10016268a8e Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 02:35:06 +0000 Subject: [PATCH 02/13] fix(gate): a blocker outside the piped set is a question nobody asked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `status_of` is a `jq` select over the piped payloads, so an id not in the set came back as the empty string, fell into the resolve loop's catch-all, and turned "I was not given this blocker" into "this blocker has not completed". Measured on `b2f8992` over real payloads for CLOUD-672 and CLOUD-674, whose only blocker had completed the night before: no frontier lines at all, and the board reported as signalling falsely. This is not a badly-chosen closure. Linear does NOT drop `blockedBy` when the blocker completes — CLOUD-661 has been Done since 2026-08-18T23:01:59Z and both dependents still carry the edge — so the active-only closure the workflow prescribes carries an edge to a Done ancestor for every landed blocker. The resolve loop branches three ways: in the set and resolved is the frontier, in the set and open is the existing note at exit 0, and out of the closure is `unjudged` — exit 2, naming the blocker. The discriminator is `in_set` rather than a new sentinel out of `status_of`, because the status-claim scan above already resolves the same ambiguity that way and a sentinel would have to be taught to every reader of that function. THE OPEN CALL, decided with the rejected option recorded: `dangling-blocker` moves into the unjudged family and is keyed to the set. Rejected was keeping it at exit 1 as the anti-vacuity guard — that guard is `unjudgeable-blockedby`, which fires when the KEY is absent, so a caller projecting edges away is caught there and this arm never was it. What decided it is that the same fact was about to be exit 1 in one arm and exit 2 in the other, and that it fires on correct boards routinely. `released` already carries a hand-written `grep -vx 'dangling-blocker'` to undo the id-keying; set-keying makes that filter structurally unnecessary. `ready-lint`'s `(closed)` exemption goes with it, and it is the same false premise: its comment claimed the tracker drops the relation. It does not, so the exemption never fired on the case it was written for. Removing it only narrows what passes, and the case it meant to protect still passes through the ordinary cross-check — asserted, not assumed. Refs: CLOUD-678 --- mise-tasks/board-sweep.sh | 6 +-- mise-tasks/graph-check.sh | 77 ++++++++++++++++++++++++++++++++++++++- mise-tasks/ready-lint.sh | 21 +++++++++-- tests/graph-check.bats | 76 +++++++++++++++++++++++++++++++++++++- tests/ready-lint.bats | 38 +++++++++++++++---- 5 files changed, 199 insertions(+), 19 deletions(-) diff --git a/mise-tasks/board-sweep.sh b/mise-tasks/board-sweep.sh index abd0353fd..36a604d37 100755 --- a/mise-tasks/board-sweep.sh +++ b/mise-tasks/board-sweep.sh @@ -94,9 +94,9 @@ #MUTANT gate-two-laundered|s@^\t\tunjudgeable=@\t\trefusals=@|a gate exiting 2 is not laundered into the refusal lane #MUTANT drain-not-invoked|s@^run_gate in-progress-drain@true in-progress-drain@|a landed-but-In-Progress row is named by in-progress-drain #MUTANT released-fed-nothing|s@^\trun_gate released.*@\trun_gate released "$here/released.sh" "$tag" ready-lint over it exits 0 # todo-unmilestoned Todo => the payload carries a projectMilestone # blockedby-cycle the blockedBy relation is acyclic -# dangling-blocker every blocker is present in the piped set # # The third is CLOUD-375's, and it is a peer of the first two rather than a # frontier note because `Todo` is a column CLAIM of the same kind: the board model @@ -29,9 +28,19 @@ # # unjudgeable-blockedby a payload carries no blockedBy key -> exit 2 # unjudgeable-milestone no payload carries projectMilestone -> exit 2 +# dangling-blocker a blocker is outside the piped set -> exit 2 # excluded (unjudgeable-ready-block) ready-lint could not read it -> exit 2 +# excluded (unjudgeable-blocker …) a blocker of THIS row is outside -> exit 2 +# the piped set, so whether it is +# resolved is a question nobody asked # excluded (blocked-by …) a blocker has not landed -> exit 0 # +# `dangling-blocker` MOVED into this family from the violation list (CLOUD-678). +# Both of the arms above it are the same fact — a blocker outside the closure — +# and one of them was reporting a lying board while the other reported an +# unanswerable question. The decision and the rejected option are recorded beside +# the code rather than here. +# # And one more predicate, CLOUD-234's: prose is not a second authority for a # column, so a status the board decides is checked against the board. # @@ -91,6 +100,12 @@ # untouched, so the ready queue is judged by whoever reads the log. A case # asserting the string but not the status would survive it. #MUTANT todo-refusal-is-a-note|s@^ report "\$id" "todo-not-ready"@ note "$id" "todo-not-ready"@|a Todo issue with no Ready block is refused +# +# CLOUD-678's arm, and the mutation is the rewrite that removes the check while +# passing every other row: returning "resolved" for a blocker nobody piped. The +# `in_set` guard becomes vacuously true, the row reaches the frontier, and the +# question "is this blocker done" is answered by never having been asked. +#MUTANT absent-blocker-reads-as-resolved|s@^ if ! in_set "$to"; then@ if false; then@|a blocker outside the piped set is unjudgeable, not resolved set -euo pipefail lint="$(dirname "$0")/ready-lint.sh" @@ -216,10 +231,36 @@ fi edges=$(jq -r '.[] | .id as $id | .relations.blockedBy[]?.id | "\($id) \(.)"' <<<"$issues" | by_num) +# `dangling-blocker` IS AN UNJUDGED ARM, NOT A VIOLATION (CLOUD-678), and it is +# set-keyed like the two arms above rather than keyed to the dependent. +# +# THE OPEN CALL THIS ROW WAS ASKED TO DECIDE, with the rejected option recorded. +# Rejected: keep it exit 1 on the argument that it is the anti-vacuity guard and +# weakening it lets a caller project edges away. That argument belongs to +# `unjudgeable-blockedby` above, which fires when the KEY is absent — a caller who +# projects the relations away is caught there, and this arm never was that guard. +# +# What decided it is a measurement rather than the balance of arguments. Linear +# does NOT drop `blockedBy` when the blocker completes (CLOUD-661 has been Done +# since 2026-08-18T23:01:59Z and both dependents still carry the edge), so an +# active-only closure — the closure the workflow actually prescribes — carries an +# edge to a Done ancestor for every landed blocker. Measured on `b2f8992`: piping +# `{672, 674}` produced `dangling-blocker` twice over a board that was correct. A +# violation that fires on correct input trains readers to ignore it. +# +# And it makes the two arms agree, which is the part that could not be left: one +# fact — a blocker outside the piped closure — was exit 1 here and about to become +# exit 2 in the frontier loop below. `released`'s `refusal_for` already carries a +# hand-written `grep -vx 'dangling-blocker'` to undo the id-keying; set-keying it +# makes that filter structurally unnecessary rather than merely unused. +out_of_closure="" while read -r from to; do [[ -n "$from" ]] || continue - in_set "$to" || report "$from" "dangling-blocker ($to)" + in_set "$to" || out_of_closure="$out_of_closure $to" done <<<"$edges" +if [[ -n "$out_of_closure" ]]; then + unjudged "graph" "dangling-blocker ($(tr ' ' '\n' <<<"${out_of_closure# }" | sort -u | by_num | tr '\n' ' ' | sed 's/ $//'))" +fi if [[ -n "$edges" ]] && ! tsort <<<"$edges" >/dev/null 2>&1; then cycle=$(tsort <<<"$edges" 2>&1 >/dev/null | grep -oE 'CLOUD-[0-9]+' | by_num | sort -u | tr '\n' ' ' || true) @@ -413,10 +454,32 @@ while read -r id; do continue ;; esac + # THREE ARMS, NOT TWO (CLOUD-678). `status_of` is a `jq` select over the piped + # payloads, so for an id that is not in the set it returns the empty string — + # which fell into this case's catch-all and converted "I was not given this + # blocker" into "this blocker has not completed". Measured on `b2f8992`: two + # Todo rows whose only blocker had completed the night before were withheld + # from the frontier, over a closure that prescribes excluding Done rows. + # + # The discriminator is `in_set`, the file's own predicate, rather than a new + # sentinel from `status_of` — the status-claim scan above already resolves the + # same ambiguity that way (`status-claim-unjudgeable`), and a sentinel would + # have to be taught to every reader of that function to answer one of them. + # + # So an out-of-closure blocker is UNJUDGED and never a note: a silent frontier + # omission is what made this invisible, because `excluded (blocked-by …)` reads + # identically to a legitimate block and an empty frontier reads as "nothing is + # ready" — which CLOUD-607's acceptance treats as success. ok=1 blocking="" + unknown="" while read -r _ to; do [[ -n "$to" ]] || continue + if ! in_set "$to"; then + ok=0 + unknown="$unknown $to" + continue + fi case "$(status_of "$to")" in Done | "In Review") ;; *) ok=0 blocking="$blocking $to" @@ -425,6 +488,16 @@ while read -r id; do done < <(grep -E "^$id " <<<"$edges" || true) if [[ "$ok" = 1 ]]; then frontier+=("$id") + elif [[ -n "$unknown" ]]; then + # Keyed to the ISSUE rather than to `graph`, unlike the arm above: this one + # is why THIS row is off the frontier, so a reader needs the dependent's id + # to act on it. The blockers it could not resolve are named beside it. + # The id-keying that would be wrong above is safe here for a structural + # reason rather than by luck: this loop judges only `Todo` rows, and + # `released`'s `refusal_for` asks only about `In Review` ones, so no line + # from here can reach it. `excluded (unjudgeable-ready-block)` above is + # already id-keyed on the same argument. + unjudged "$id" "excluded (unjudgeable-blocker${unknown}${blocking})" else note "$id" "excluded (blocked-by${blocking})" fi diff --git a/mise-tasks/ready-lint.sh b/mise-tasks/ready-lint.sh index e54ceea37..024edda3b 100755 --- a/mise-tasks/ready-lint.sh +++ b/mise-tasks/ready-lint.sh @@ -534,7 +534,23 @@ if [[ -n "$blockers_start" ]]; then # No blockedBy token — "None.", "no blockers", or pure cross-references — # means nothing is claimed, so there is nothing to hold the board to. # shellcheck disable=SC2013 # word-splitting is the point: ids are bare tokens - for cited in $(sed -E 's/CLOUD-[0-9]+ \(closed\)//g' <<<"$claim" | grep -oE 'CLOUD-[0-9]+' | sort -u); do + # THE `(closed)` EXEMPTION IS GONE, and it was dead code resting on a premise + # this tracker does not have (CLOUD-678). It stripped `CLOUD-N (closed)` from + # the claim before scanning, on the stated reason that "Linear drops the + # relation when the dependency resolves, so demanding one would fail every + # correctly-refined issue whose blocker landed." + # + # MEASURED, and it is the opposite: the relation SURVIVES. CLOUD-661 has been + # Done since 2026-08-18T23:01:59Z and both of its dependents still carry the + # `blockedBy` edge today. So a blocker noted `(closed)` still has a live + # relation, the exemption never fired on the case it was written for, and every + # such citation was already passing through the cross-check below. + # + # Removing it only ever NARROWS what passes — it widened, and on a case that + # cannot occur — and what it bought was a comment documenting behaviour a + # reader would then rely on. `graph-check`'s `dangling-blocker` arm is where + # the measurement is recorded in full. + for cited in $(grep -oE 'CLOUD-[0-9]+' <<<"$claim" | sort -u); do # THE SCAN STILL RUNS, THE CROSS-CHECK DOES NOT (CLOUD-679). Finding the # citation is what makes "the missing key is the SOLE reason" computable at # all: a payload with no key and nothing cited lost nothing, and must stay @@ -545,9 +561,6 @@ if [[ -n "$blockers_start" ]]; then unjudged "$(line_of "$BLOCKERS_LABEL")" continue fi - # A blocker explicitly noted as closed is history, not a live claim: - # Linear drops the relation when the dependency resolves, so demanding - # one would fail every correctly-refined issue whose blocker landed. case " $relations " in *" $cited "*) ;; *) report "$(line_of "$BLOCKERS_LABEL")" "blocker-cited-without-relation ($cited)" ;; diff --git a/tests/graph-check.bats b/tests/graph-check.bats index 078b6de17..975429e28 100644 --- a/tests/graph-check.bats +++ b/tests/graph-check.bats @@ -122,10 +122,82 @@ $READY" 'if .id == $id then .description = $d else . end' "$BOARD" >"$BOARD.2" & } @test "a dangling blocker is reported" { + # CLOUD-678 MOVED THIS ARM FROM exit 1 TO exit 2, and the case is rewritten + # rather than deleted. It asserted that a blocker outside the piped closure is + # a board signalling falsely; measured, it is a closure that cannot answer the + # question — Linear keeps `blockedBy` after the blocker completes, so an + # active-only closure carries such an edge for every landed blocker and this + # fired on correct boards routinely. It is also set-keyed now, like + # `unjudgeable-blockedby`, so `released`'s `refusal_for` cannot see it. issue CLOUD-1 Todo "" "" CLOUD-99 check - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-1 dangling-blocker (CLOUD-99)"* ]] + [ "$status" -eq 2 ] + [[ "$output" == *"graph dangling-blocker (CLOUD-99)"* ]] + [[ "$output" != *"CLOUD-1 dangling-blocker"* ]] +} + +# --- CLOUD-678: a blocker outside the piped set is a question nobody asked ----- +# +# `status_of` is a `jq` select over the payloads, so an id not in the set came back +# as the empty string and fell into the resolve loop's catch-all — turning "I was +# not given this blocker" into "this blocker has not completed". Measured on +# `b2f8992` over real payloads for CLOUD-672 and CLOUD-674, whose only blocker had +# been Done since the night before: no frontier lines at all. + +@test "a Todo issue whose only blocker is Done and piped reaches the frontier" { + # The unchanged half, pinned so the fix cannot buy the arm below by breaking + # the ordinary case. + issue CLOUD-1 Done "" "" + issue CLOUD-2 Todo "" "" CLOUD-1 + check + [ "$status" -eq 0 ] + [[ "$output" == *"frontier CLOUD-2"* ]] +} + +@test "a blocker outside the piped set is unjudgeable, not resolved" { + # THE ARM. Exit 2 and an `unjudgeable`-family line naming the blocker, never a + # silent frontier omission — which is what made this invisible, since + # `excluded (blocked-by …)` reads identically to a legitimate block. + issue CLOUD-2 Todo "" "" CLOUD-1 + check + [ "$status" -eq 2 ] + [[ "$output" == *"CLOUD-2 excluded (unjudgeable-blocker CLOUD-1)"* ]] + [[ "$output" != *"frontier CLOUD-2"* ]] + # NOT the scheduling note: that reads as "this is legitimately blocked", which + # is the wrong remedy. The caller's next action is a re-fetch. + [[ "$output" != *"CLOUD-2 excluded (blocked-by"* ]] +} + +@test "a piped, genuinely open blocker still excludes at exit 0" { + # Unchanged, attribution intact: an unlanded blocker is scheduling, and the + # issue is not claiming otherwise. + issue CLOUD-1 "In Progress" someone "" + issue CLOUD-2 Todo "" "" CLOUD-1 + check + [ "$status" -eq 0 ] + [[ "$output" == *"CLOUD-2 excluded (blocked-by CLOUD-1)"* ]] + [[ "$output" != *"unjudgeable-blocker"* ]] +} + +@test "a set with no edges at all is unchanged by the three-way branch" { + issue CLOUD-1 Todo "" "" + check + [ "$status" -eq 0 ] + [[ "$output" == *"frontier CLOUD-1"* ]] + [[ "$output" != *"unjudgeable-blocker"* ]] + [[ "$output" != *"dangling-blocker"* ]] +} + +@test "one row short of its closure does not withhold the rest of the frontier" { + # The measured shape, minimised: the active-only closure CLOUD-607 prescribes. + # CLOUD-3 is fully judgeable and must still be offered — the exit code says the + # set was short, and the frontier still carries what could be decided. + issue CLOUD-2 Todo "" "" CLOUD-1 + issue CLOUD-3 Todo "" "" + check + [ "$status" -eq 2 ] + [[ "$output" == *"frontier CLOUD-3"* ]] + [[ "$output" != *"frontier CLOUD-2"* ]] } @test "the frontier is unblocked lint-passing Todo issues" { diff --git a/tests/ready-lint.bats b/tests/ready-lint.bats index 5172729e7..fcef071e5 100644 --- a/tests/ready-lint.bats +++ b/tests/ready-lint.bats @@ -73,13 +73,33 @@ block() { [ "$status" -eq 0 ] } -@test "a blocker noted as closed needs no relation" { - # Linear drops the relation once a dependency resolves, so demanding one here - # would fail every correctly-refined issue whose blocker has landed. +@test "a blocker noted as closed still needs its relation" { + # CLOUD-678 INVERTED THIS CASE, on a measurement rather than an argument. It + # read "a blocker noted as closed needs no relation", on the premise that + # "Linear drops the relation once a dependency resolves, so demanding one here + # would fail every correctly-refined issue whose blocker has landed." + # + # The relation SURVIVES: CLOUD-661 has been Done since 2026-08-18T23:01:59Z and + # both dependents still carry the edge. So the exemption never fired on the case + # it was written for, and a `(closed)` blocker with no live relation is the + # ordinary true positive — a §8 claim the board does not carry. local d d=$(block '* **Blockers (§8).** `blockedBy` CLOUD-21 (closed).') payload "$d" lint + [ "$status" -eq 1 ] + [[ "$output" == *"blocker-cited-without-relation (CLOUD-21)"* ]] +} + +@test "a blocker noted as closed passes when the relation is there, which it is" { + # The other half, and the one that makes the inversion safe: the removal + # NARROWS what passes, so the case the exemption was meant to protect has to + # still pass — and it does, through the ordinary cross-check, because the + # tracker keeps the edge. + local d + d=$(block '* **Blockers (§8).** `blockedBy` CLOUD-21 (closed).') + payload "$d" CLOUD-21 + lint [ "$status" -eq 0 ] } @@ -122,15 +142,17 @@ block() { [ "$status" -eq 0 ] } -@test "a closed blocker in Linear's rendered-mention form is exempt" { - # Linear stores mentions as CLOUD-N, so the (closed) - # exemption must survive the markup between the id and the marker — an - # exemption that only matches plain-text fixtures is dead code in production. +@test "a closed blocker in Linear's rendered-mention form is judged like any other" { + # This case existed to prove the `(closed)` exemption survived Linear's mention + # markup. With the exemption gone (CLOUD-678) the markup question stands on its + # own and is the one worth keeping: the id must be recovered from the stored + # form, so the cross-check names CLOUD-21 rather than nothing. local d d=$(block '* **Blockers (§8).** `blockedBy` CLOUD-21 (closed).') payload "$d" lint - [ "$status" -eq 0 ] + [ "$status" -eq 1 ] + [[ "$output" == *"blocker-cited-without-relation (CLOUD-21)"* ]] } @test "a rendered-mention blockedBy claim without a relation is still flagged" { From 20a4cdcd2380a98a0d791a8fa573e1b3a4a972b0 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 02:42:53 +0000 Subject: [PATCH 03/13] ci(ready-cites-check): absent by design is not absent by mistake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The path arm was "zero-judgement: the file is there or it is not" — one bit where the question needs two, collapsing toward refusing a correctly-refined row. A §7 obligation naming the suite its own row exists to WRITE cites a path that is absent by design. Measured over one session's ten-row closure: three refusals, CLOUD-359, CLOUD-361 and CLOUD-920 — every one a §7 test obligation, not one a stale citation. Precision on that arm was zero, and the third row is the row filed to fix it. The cheapest way to pass was to stop naming the file, which is the opposite of what CLOUD-826 wanted. CLOUD-920 §2 left the mechanism to this row. Both cheaper candidates were measured rather than weighed, and both lost: The row's own STATUS ("a row not yet In Progress cannot have written its tests") is wrong for exactly the row that matters — CLOUD-920 was In Progress while its own citation was still prospective, so the rule would refuse the row it exists to fix, at the moment of fixing it. GIT HISTORY as the primary term is blind in the ordinary environment. Measured 2026-08-23 on a web-session clone: it is shallow, and asking --diff-filter=D about a path CLOUD-442 deleted returns nothing — the same answer as a path that never existed. As the discriminator it would silently restore CLOUD-826's defect in every web session, which is worse than the false positive being fixed. So: an explicit `(new)` marker, matched with its path so one marker cannot excuse a whole block, with history kept as the corroborating term. A marked absence is prospective — reported on stderr, exit unmoved, `graph-check`'s note shape. An unmarked absence stays CLOUD-826's refusal. History is asked only to REFUTE a marker — a path deleted in an ancestor was present, so `(new)` is false and it is `stale-cited-path` — never to grant one. The honest limit is gated rather than stated: where history cannot answer, the gate emits a ::notice:: saying the marker was not checked against it, instead of implying it was. Refs: CLOUD-920 --- mise-tasks/ready-cites-check.sh | 120 ++++++++++++++++++++++++++++++-- tests/ready-cites-check.bats | 71 +++++++++++++++++++ 2 files changed, 186 insertions(+), 5 deletions(-) diff --git a/mise-tasks/ready-cites-check.sh b/mise-tasks/ready-cites-check.sh index fbf8e9af3..a90f3ff8b 100755 --- a/mise-tasks/ready-cites-check.sh +++ b/mise-tasks/ready-cites-check.sh @@ -72,12 +72,68 @@ # payload could not be read or the tree could not be resolved — matching # `ready-lint` and `spec-ref-check` so all three compose under one contract. # +# ─── A PATH HAS THREE ANSWERS, NOT TWO (CLOUD-920) ─────────────────────────── +# +# "Zero-judgement: the file is there or it is not" was one bit where the question +# needs two, and it collapsed in the direction that punishes an author for being +# precise. A §7 obligation naming the suite its own row exists to WRITE cites a +# path that is absent BY DESIGN. Measured over one session's ten-row closure: +# three refusals, CLOUD-359, CLOUD-361 and CLOUD-920 — every one a §7 test +# obligation, not one a stale citation. Precision on this arm was zero, and the +# third row is the row filed to fix it. The cheapest way to pass was to stop +# naming the file, which is the opposite of what CLOUD-826 wanted. +# +# So: resolves / refused / PROSPECTIVE, the fourth value CLOUD-251's split needs +# here — not "is", "is not" or "could not look", but "not yet, by design". +# +# ─── WHICH MECHANISM DRAWS THE LINE, AND WHY THE CHEAPER TWO LOST ──────────── +# +# CLOUD-920 §2 named three candidates and made the choice this row's. Decided, and +# the rejected options recorded because a later reader will reach for them again: +# +# REJECTED — the row's own STATUS. "A row not yet In Progress cannot have written +# its tests" is cheap and already in the payload, and it is wrong for exactly the +# row that matters: CLOUD-920 was In Progress while its own citation was still +# prospective, so the rule would refuse the row it exists to fix, at the moment +# it was being fixed. +# +# REJECTED AS THE PRIMARY TERM — git history. `--diff-filter=D` genuinely +# discriminates deleted from never-written, and it is BLIND in the ordinary +# environment. Measured 2026-08-23 on a web-session clone: it is shallow, and +# `tests/memory-guard.bats` — deleted by CLOUD-442 — returns nothing, the same +# answer as a path that never existed. As the primary term it would silently +# restore CLOUD-826's defect in every web session, which is the one outcome +# worse than the false positive being fixed. +# +# CHOSEN — an explicit spelling, with history kept as the corroborating term. +# A citation the block marks `(new)` is prospective; an unmarked absence is +# CLOUD-826's refusal, unchanged. It puts the burden on the author, which is the +# cost, and it keeps the gate a pure function of the payload plus the tree — +# the property every board gate here holds. History is then asked only to +# REFUTE a marker (a path deleted in an ancestor was present, so `(new)` is +# false), never to grant one, so where it cannot look nothing is forgiven that +# would not have been forgiven anyway. +# +# The marker is matched WITH ITS PATH — "`` (new)" — so one `(new)` written +# elsewhere in a block cannot excuse every citation in it. +# +# A prospective citation is reported on stderr and leaves the exit code unmoved, +# which is `graph-check`'s `note` shape. Reported rather than skipped: skipping is +# CLOUD-826's defect restored, and the count is in the summary line either way. +# # The mutation admits `tests/fixtures/` back into the corpus, so a citation that # resolves only against a quotation of itself passes — the vacuity above, restored. #MUTANT fixtures-satisfy-a-citation|s@:!:tests/fixtures/@:!:tests/no-such-dir/@|resolves only in a fixture and nowhere else # And the block-selection mutation: read the FIRST opener rather than the last, so # a superseded clause's stale citations are judged as live obligations. #MUTANT superseded-block-is-judged|s@tail -n1@head -n1@|the last opener is the live block +# CLOUD-920's two arms, and they mutate in opposite directions. The first drops the +# marker requirement, so every absent path becomes prospective — CLOUD-826's defect +# restored, which is the one thing the fix must not buy. +#MUTANT marker-not-required|s@^ if grep -qF -- "\\`$p\\` (new)" <<<"$block"; then@ if true; then@|an unmarked absent path is still refused +# The second removes the anti-forgery term, so a `(new)` marker on a path that was +# DELETED passes — an author's claim believed over the history that refutes it. +#MUTANT marker-outranks-history|s@^ if \[\[ -n "$history" \]\] .*@ if false; then@|a marker on a deleted path is refused, not believed set -uo pipefail # THE ROOT IS `git::repo_root`'S ANSWER, NEVER `--show-toplevel` (CLOUD-824). That @@ -140,8 +196,25 @@ CLAUSE_7='^[[:space:]]*([*-][[:space:]]*)?\*\*[^*]*\((§|clause )7\)|^#{2,6}[[:s findings=0 resolved=0 cited=0 +prospective=0 first_finding=1 reports="" +notes="" + +# CAN HISTORY BE ASKED WHETHER A PATH ONCE EXISTED? (CLOUD-920.) A shallow clone +# cannot answer, and that is not an edge case: a Claude Code web session clones +# shallow, so this is the ordinary environment. Measured 2026-08-23 on such a +# clone — `git log --diff-filter=D -- tests/memory-guard.bats`, a path retired by +# CLOUD-442, returns NOTHING, indistinguishable from a path that never existed. +# +# That measurement is why history is the CORROBORATING term here rather than the +# discriminating one. It is asked only to REFUTE a `(new)` marker, never to grant +# one, so where it cannot look the marker stands on its own and no absence is +# silently forgiven. +history="" +if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null)" = false ]]; then + history=1 +fi report() { reports="${reports} $1"$'\n' @@ -200,23 +273,60 @@ while IFS= read -r key; do done # (2) CITED PATHS, anywhere in the live block. A backticked token carrying a `/` - # and ending in a source extension. Zero-judgement: the file is there or it is - # not. + # and ending in a source extension. THREE OUTCOMES, not two (CLOUD-920). # shellcheck disable=SC2016 # the backticks are literal markdown, not a subshell for p in $(grep -oE '`[A-Za-z0-9_./-]+\.(rs|toml|yml|yaml|bats|md|json|pkl|sh|lock|rego)`' <<<"$block" 2>/dev/null | tr -d '`' | grep -F / | sort -u); do cited=$((cited + 1)) if [[ -e "$p" ]]; then resolved=$((resolved + 1)) - else - report "$key §1 $p absent-cited-path" + continue + fi + # PROSPECTIVE, and only when the block SAYS SO. `(new)` immediately after the + # backticked path is the marker; anything else absent stays CLOUD-826's + # refusal. The marker is matched against the path so a single `(new)` + # elsewhere in the block cannot excuse every citation in it. + if grep -qF -- "\`$p\` (new)" <<<"$block"; then + # THE ANTI-FORGERY TERM. The marker is the author's claim that this file + # does not exist yet; history is the one place that can contradict it. A + # path DELETED in an ancestor was present, so `(new)` is false and the + # citation is the stale obligation CLOUD-826 exists to refuse — marking it + # must not buy a pass. + if [[ -n "$history" ]] && [[ -n "$(git log --format=%h --diff-filter=D -1 -- "$p" 2>/dev/null)" ]]; then + report "$key §1 $p stale-cited-path" + continue + fi + prospective=$((prospective + 1)) + # `note`, not `report`: the exit code is unmoved, and the pointer is still + # emitted so a prospective citation is legible rather than skipped. + notes="${notes} $key §1 $p prospective-cited-path"$'\n' + continue fi + report "$key §1 $p absent-cited-path" done done < <(jq -r '.[] | .id // empty' <<<"$issues" 2>/dev/null || true) +# NOTES BEFORE THE VERDICT, and on stderr either way: a prospective citation is +# information about a correct block, so it must not be able to change the exit +# code, and it must not be buried under a refusal that came after it. +if [[ -n "$notes" ]]; then + printf '%s' "$notes" | sort >&2 + if [[ -z "$history" ]]; then + # THE HONEST LIMIT, stated rather than left to be discovered. In a shallow + # clone the anti-forgery term above cannot fire, so a `(new)` marker on a + # genuinely DELETED path reads as prospective — CLOUD-826's direction. The + # gate says so instead of implying it was checked. + echo "::notice:: ready-cites-check: $prospective prospective citation(s) above, and this clone is SHALLOW — so \`(new)\` could not be checked against history. A marker on a path that was deleted rather than never written is not detectable here; CI runs against a full clone, where it is." >&2 + fi +fi + if [[ "$findings" -ne 0 ]]; then [[ "$first_finding" = 1 ]] && echo "::error:: ready-cites-check: a Ready block cites something the tree does not carry. This checks EXISTENCE, never relevance — whether a test that exists is the right test is not computable (CLOUD-93). A citation resolving only under tests/fixtures/ is refused, because a fixture quoting the citation is not the thing cited:" >&2 printf '%s' "$reports" | sort >&2 echo "::error:: ready-cites-check: $findings of $cited citation(s) resolve nothing" >&2 exit 1 fi -echo "ready-cites-check: $resolved of $cited citation(s) resolve against the tree" +if [[ "$prospective" -gt 0 ]]; then + echo "ready-cites-check: $resolved of $cited citation(s) resolve against the tree; $prospective prospective" +else + echo "ready-cites-check: $resolved of $cited citation(s) resolve against the tree" +fi diff --git a/tests/ready-cites-check.bats b/tests/ready-cites-check.bats index 3999db858..a8fdcf580 100644 --- a/tests/ready-cites-check.bats +++ b/tests/ready-cites-check.bats @@ -154,6 +154,77 @@ at_root() { run env READY_CITES_ROOT="$ROOT" "$GATE" <"$PAYLOAD"; } [[ "$output" == *"tests/no_such_suite.bats absent-cited-path"* ]] } +# --- CLOUD-920: absent by design is not absent by mistake -------------------- +# +# The path arm had one bit where the question needs two, and it collapsed toward +# refusing a correctly-refined row. Measured over one session's ten-row closure: +# three refusals, every one a §7 obligation naming the suite its row exists to +# write, none a stale citation. `(new)` is the marker; history refutes it but never +# grants it, because a shallow clone cannot answer and that is the ordinary case. + +@test "a §7 citation the block marks (new) is prospective, not fatal" { + synthetic + block7 'Cases live in `tests/layer-check.bats` (new): a fixture with a back-edge exits non-zero.' + at_root + [ "$status" -eq 0 ] + [[ "$output" == *"tests/layer-check.bats prospective-cited-path"* ]] + [[ "$output" == *"1 prospective"* ]] +} + +@test "an unmarked absent path is still refused" { + # CLOUD-826's case, and the one the fix must not buy its way out of. Same + # fixture as the prospective case above minus the marker — one variable. + synthetic + block7 'Cases live in `tests/layer-check.bats`: a fixture with a back-edge exits non-zero.' + at_root + [ "$status" -eq 1 ] + [[ "$output" == *"tests/layer-check.bats absent-cited-path"* ]] + [[ "$output" != *"prospective"* ]] +} + +@test "the two absent cases are distinguishable in output" { + # Not merely "one is fatal and one is not": a reader must be able to tell which + # is which from the pointer, since the exit code is a property of the whole run. + synthetic + block7 'Present: `crates/batten/src/git.rs`. Planned: `tests/layer-check.bats` (new). Stale: `tests/gone.bats`.' + at_root + [ "$status" -eq 1 ] + [[ "$output" == *"tests/layer-check.bats prospective-cited-path"* ]] + [[ "$output" == *"tests/gone.bats absent-cited-path"* ]] + [[ "$output" != *"tests/layer-check.bats absent-cited-path"* ]] +} + +@test "a marker on a deleted path is refused, not believed" { + # THE ANTI-FORGERY TERM. `(new)` is the author's claim that the file does not + # exist yet; a path DELETED in an ancestor was present, so the claim is false and + # the citation is exactly CLOUD-826's stale obligation. History is asked only + # here — to refute, never to grant. + synthetic + printf 'old cases +' >"$ROOT/tests/was_here.bats" + git -C "$ROOT" add -A + git -C "$ROOT" -c user.email=t@example.invalid -c user.name=t commit -qm add + git -C "$ROOT" rm -q "$ROOT/tests/was_here.bats" + git -C "$ROOT" -c user.email=t@example.invalid -c user.name=t commit -qm delete + block7 'Cases live in `tests/was_here.bats` (new).' + at_root + [ "$status" -eq 1 ] + [[ "$output" == *"tests/was_here.bats stale-cited-path"* ]] + [[ "$output" != *"prospective"* ]] +} + +@test "a (new) marker elsewhere in the block does not excuse an unrelated citation" { + # The marker is matched WITH its path. Without that, one `(new)` anywhere would + # turn the whole arm off for that row — the "skip absent paths" fix CLOUD-920 + # rules out, reachable by accident. + synthetic + block7 'Planned: `tests/layer-check.bats` (new). Also cites `tests/unrelated.bats` for context.' + at_root + [ "$status" -eq 1 ] + [[ "$output" == *"tests/unrelated.bats absent-cited-path"* ]] + [[ "$output" == *"tests/layer-check.bats prospective-cited-path"* ]] +} + @test "a cited path that exists passes" { synthetic block7 'The subject is `crates/batten/src/git.rs`.' From 2cbf5218d7d1acba463252e56613685d8c3a4b4f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 02:51:31 +0000 Subject: [PATCH 04/13] ci(board-write-record): record the rows a body cites and the caller never passed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tracker auto-links every `CLOUD-nnn` mention in a body into a symmetric `relatedTo` edge, so writing a body modifies every row it cites — rows the author passed as no parameter, named as no relation, and is told about in no response. Measured over one grooming session: 43 edges added, 11 passed, 32 minted by prose, and therefore 32 rows outside that session's scope silently modified. Nothing saw it: `graph-check` reads `relatedTo` for nothing at all, and this recorder had five columns and no relation term. CLOUD-923 §1 says an observed delta needs no new fetch — the pre-set from the `issue-read-check` payload, the post-set from the `save_issue` response. BOTH HALVES ARE FALSE, measured rather than assumed: a `save_issue` response carries no `relations` key at all (only `get_issue(includeRelations: true)` does, and a hook holds no tracker credential), and the read receipt is `key seen read_at body_hash seen_status` — the payload had the relations, the receipt did not keep them. So the column is what IS computable from data in hand: the keys the STORED body cites that the caller passed as no relation, in any direction. That is a prediction rather than an observation and the difference is stated in the code — a cited row that was already related is counted and adds no edge, so it over-counts and never under-counts. Conservative in the direction the row asks for, since the failure it names is a record quieter than the truth. Unforgeable for the same reason the verdict is: the body read is the tracker's response, never the caller's argument. Reported, never refused — CLOUD-923's open call, decided. The auto-linking is not the author's choice and no body can opt out, so a refusal would be a toll with no remedy. The named-paths column is now comma-joined at write time. It was the only variable-width column, and a record whose fifth field can swallow the rest of the line cannot have a sixth: without this, `filed-here-check` reads the new column as a named path and refuses a lap over a path called `0`. The value that gate computes is unchanged — it already comma-joined on read — and the one-way cost of an in-flight record from the previous shape is recorded beside the code. Refs: CLOUD-923 --- mise-tasks/board-write-record.sh | 106 ++++++++++++++++++++++++++++++- mise-tasks/filed-here-check.sh | 20 ++++-- tests/board-write-record.bats | 101 +++++++++++++++++++++++++---- tests/filed-here-check.bats | 38 +++++------ 4 files changed, 228 insertions(+), 37 deletions(-) diff --git a/mise-tasks/board-write-record.sh b/mise-tasks/board-write-record.sh index 8792edf15..211d6c22d 100755 --- a/mise-tasks/board-write-record.sh +++ b/mise-tasks/board-write-record.sh @@ -45,7 +45,8 @@ # # POINTER-ONLY IS LOAD-BEARING HERE (non-negotiable 4), not decorative: the text # this reads is the entire issue body. Five fields reach the file — kind, id, -# updatedAt, verdict, and the diff overlap — and nothing is ever printed. +# updatedAt, verdict, the named paths, and the rows the stored body cites that the +# caller passed as no relation (CLOUD-923) — and nothing is ever printed. # # THE FIFTH FIELD IS PHASE 3 (CLOUD-514), and it is what the first two phases # left out. The `verdict` column prices REFINEMENT: it asks whether the new row @@ -101,6 +102,13 @@ # `self-mutating-row` since CLOUD-480; the class is how a row names a string it # must also contain. #MUTANT overlap-frozen-at-write-time|s@ --n[a]med @ @|A FILE THIS BRANCH HAS NOT TOUCHED IS STILL RECORDED +# +# CLOUD-923's column, and the mutation is the one that passes every other row: read +# the citations from the caller's ARGUMENT instead of the tracker's response, so a +# caller that strips them from what it sends records zero over a body that carries +# eight. Anchored on `^if`, so it cannot match its own `#MUTANT` line and is not the +# self-mutating shape the row above documents. +#MUTANT cites-read-from-the-argument|s@^if \[\[ "$kind" = issue \]\] && \[\[ -n "${description:-}" \]\]; then@if false; then@|a write records the rows its stored body cites set -uo pipefail # @@ -323,12 +331,106 @@ if [[ "$kind" = issue ]]; then overlap=$(printf '%s' "$description" | "$(dirname -- "${BASH_SOURCE[0]}")/board-diff-overlap.sh" --named 2>/dev/null) || overlap=- fi [[ -n "$overlap" ]] || overlap=- + # ONE COLUMN, ONE WHITESPACE-FREE TOKEN (CLOUD-923). `board-diff-overlap + # --named` emits ` ...`, so this column was the only variable-width + # one — and a record whose fifth field can swallow the rest of the line cannot + # have a sixth. `filed-here-check` already comma-joined it on read (`packed`), + # so the value it computes is unchanged; what moves is where the join happens. + # Without this, the cites column below lands inside the named-path list and the + # gate refuses a lap over a path called `0`. + # + # THE ONE-WAY COST, stated rather than papered over with a back-compat claim + # this cannot honour: a record line written by the PREVIOUS shape carries the + # space-separated form, so `filed-here-check` reads its second and later paths + # into the cites column and judges the row on fewer named paths than it named. + # That direction cannot manufacture a refusal — it can only miss one — and the + # window is bounded by the store, which lives under `$GIT_DIR`, is never + # committed, and dies with the container. Writer and reader ship in one commit. + overlap=${overlap// /,} +fi + +# THE CITED-KEYS COLUMN (CLOUD-923). The tracker auto-links every `CLOUD-nnn` +# mention in a body into a symmetric `relatedTo` edge, so writing a body modifies +# every row it cites — rows the caller passed as no parameter, named as no +# relation, and is told about in no response. Measured over one grooming session: +# 43 edges added, 11 passed, 32 minted by prose, and therefore 32 rows outside the +# session's scope silently modified. Nothing saw it: `graph-check` reads +# `relatedTo` for nothing at all, and this recorder had five columns and no +# relation term. +# +# ─── WHAT THIS COLUMN IS, AND WHAT IT IS NOT ───────────────────────────────── +# +# CLOUD-923 §1 says the pre-write set is in the `issue-read-check` payload and the +# post-write set is in the `save_issue` response, so an observed DELTA needs no new +# fetch. BOTH HALVES ARE FALSE, measured 2026-08-23 rather than assumed: +# +# * a `save_issue` response carries no `relations` key at all — only +# `get_issue(includeRelations: true)` does, and a hook holds no tracker +# credential to make that call (`claim-check`'s constraint); +# * `issue-read-check`'s receipt is `key seen read_at body_hash seen_status` — +# five fields, no relation set. The payload had one; the receipt did not keep +# it. +# +# So the observed delta is not computable here without the fetch §1 forbids. What +# IS in hand is the caller's arguments and the body the tracker STORED, and their +# difference is the set of edges prose will mint: **the keys the stored body cites +# that the caller passed as no relation.** +# +# That is a PREDICTION, not an observation, and the difference is stated rather +# than absorbed: a cited row that was already related is counted here and adds no +# edge, so this OVER-counts and never under-counts. Conservative in the direction +# CLOUD-923 §2 asks for — the failure mode it names is the record being quieter +# than the truth, and an upper bound cannot be that. +# +# Unforgeable for the same reason the verdict and the named-paths column are: the +# body read is the tracker's RESPONSE, never the caller's argument. A caller who +# strips citations from what it SENDS still gets them counted from what came back. +# +# Pointer-only per non-negotiable rule 4: a count and the far-end keys, +# comma-joined so the record stays one field per column. Never a line of the body +# that minted them — which is the whole of what this reads. +# +# REPORTED, NEVER REFUSED, and that is CLOUD-923's open call decided. The tracker's +# auto-linking is not the author's choice and no body can opt out of it, so a +# refusal would be a toll with no remedy — the shape `filed-here-check`'s own +# header warns about. This file prints nothing and moves no exit code regardless; +# what changes is that a later reader can see which far-end rows a branch touched. +cites=- +if [[ "$kind" = issue ]] && [[ -n "${description:-}" ]]; then + # The caller's arguments, all three directions: a row passed as a blocker is + # not "minted by prose" however the body also mentions it. + passed=$(printf '%s' "$raw" | jq -r ' + [ .tool_input.relatedTo[]?, .tool_input.blockedBy[]?, .tool_input.blocks[]? ] + | map(select(type == "string")) | unique | .[] + ' 2>/dev/null) || passed="" + # Linear serialises a mention as CLOUD-N; the markup is + # stripped first so the stored and rendered forms are one case, exactly as + # `ready-cites-check` and `graph-check` both do before reading a body. + cited=$(sed -E 's|]*>||g' <<<"$description" | + grep -oE 'CLOUD-[0-9]+' 2>/dev/null | sort -u) || cited="" + minted="" + while IFS= read -r k; do + [[ -n "$k" ]] || continue + # The row's own key is not an edge to anywhere. + [[ "$k" != "$id" ]] || continue + grep -qxF -- "$k" <<<"$passed" && continue + minted="${minted:+$minted,}$k" + done <<<"$(sort -t- -k2,2n <<<"$cited")" + # ZERO IS A COUNT; `-` IS "COULD NOT LOOK". A body the tracker did not return + # leaves the initialiser above standing, so the two are distinguishable in the + # record — CLOUD-251's split, which this column would otherwise collapse in the + # quiet direction. + if [[ -z "$minted" ]]; then + cites=0 + else + cites="$(($(tr -cd , <<<"$minted" | wc -c) + 1)):$minted" + fi fi mkdir -p "$git_dir/batten-receipts" 2>/dev/null || exit 0 # Slashes are the one character a filename cannot carry; the substitution matches # every other branch-keyed receipt here. record="$git_dir/batten-receipts/board-writes.${branch//\//-}" -printf '%s %s %s %s %s\n' "$kind" "$id" "$updated" "$verdict" "$overlap" >>"$record" 2>/dev/null || exit 0 +printf '%s %s %s %s %s %s\n' "$kind" "$id" "$updated" "$verdict" "$overlap" "$cites" >>"$record" 2>/dev/null || exit 0 exit 0 diff --git a/mise-tasks/filed-here-check.sh b/mise-tasks/filed-here-check.sh index 6c2e14a01..5354f3935 100755 --- a/mise-tasks/filed-here-check.sh +++ b/mise-tasks/filed-here-check.sh @@ -214,6 +214,17 @@ report() { # pointer-only: an id, and for the diff refusal one tracked path # `creates` still counts CREATE lines, so the pass line reports how many rows the # branch filed rather than how many times they were linted. # +# THE SIXTH COLUMN IS READ AND NOT JUDGED (CLOUD-923). It holds the rows the stored +# body cites that the caller passed as no relation — the edges the tracker's +# auto-linking mints from prose. It is named here rather than left to fall into +# `overlap`, which is what a trailing field would otherwise do. `_` is this +# file's own spelling for a column it deliberately does not read, as the note above +# on the updatedAt column says: the recorder now +# comma-joins the named-path list so every column is one token, and without that +# this gate would read `0` as a named path and refuse a lap over it. Nothing prices +# the citation set, for the reason CLOUD-923 decided: the tracker's auto-linking is +# not the author's choice, so a toll on it would have no remedy. +# # `_` for the updatedAt column: it is the recorder's forgery-resistant half and # this gate has no use for it, and naming it `_` is what keeps the linter from # reading a deliberate placeholder as a dead variable. @@ -223,7 +234,7 @@ report() { # pointer-only: an id, and for the diff refusal one tracked path # look", and passes. A branch cannot be refused for a question its recorder was # never able to ask. latest="" -while read -r kind id _ verdict overlap; do +while read -r kind id _ verdict overlap _; do [[ -n "$kind" ]] || continue case "$kind" in comment) @@ -249,9 +260,10 @@ while read -r kind id _ verdict overlap; do *) rebuilt="${rebuilt:+$rebuilt }$entry" ;; esac done - # ` ...` from the recorder, comma-joined so one entry stays one - # shell word. Absent or blank is `-`, the same "could not look" the verdict - # column already draws. + # `,...` from the recorder, already comma-joined there since + # CLOUD-923 so one column is one shell word. The join is kept here too: a record + # written before that change carries the space-separated form, and this gate + # reads a store that outlives a single lap. packed=${overlap:--} packed=${packed// /,} latest="${rebuilt:+$rebuilt }$id=${verdict:--}=${packed}" diff --git a/tests/board-write-record.bats b/tests/board-write-record.bats index 71a0d27ca..49fbbd7a6 100644 --- a/tests/board-write-record.bats +++ b/tests/board-write-record.bats @@ -108,7 +108,7 @@ comment_event() { [ "$status" -eq 0 ] [ -f "$(record)" ] run cat "$(record)" - [[ "$output" == "issue CLOUD-999 2026-08-13T00:00:00.000Z ready -" ]] + [[ "$output" == "issue CLOUD-999 2026-08-13T00:00:00.000Z ready - 0" ]] } # THE ROW THIS DESIGN TURNS ON. `ready-lint`'s §8 rule cross-checks prose claiming @@ -123,7 +123,7 @@ comment_event() { run bash -c "'$REC' < $(event mcp__Linear__save_issue "$body" CLOUD-1)" [ "$status" -eq 0 ] run cat "$(record)" - [[ "$output" == *" ready -" ]] + [[ "$output" == *" ready - 0" ]] } @test "an unrefined row records a verdict of unready rather than being refused" { @@ -131,7 +131,7 @@ comment_event() { [ "$status" -eq 0 ] [ -z "$output" ] run cat "$(record)" - [[ "$output" == *" unready -" ]] + [[ "$output" == *" unready - 0" ]] } # --- the diff column (CLOUD-514, phase 3) ------------------------------------- @@ -156,7 +156,7 @@ with_diff() { # a branch whose diff against origin/main touches ONE tracked file run bash -c "'$REC' < $(event mcp__Linear__save_issue 'The bug is in keeper.rs:12.')" [ "$status" -eq 0 ] run cat "$(record)" - [[ "$output" == *" 1 keeper.rs" ]] + [[ "$output" == *" 1,keeper.rs 0" ]] } # THE OTHER DIRECTION (CLOUD-418): a suite that only ever asserts the firing @@ -168,7 +168,7 @@ with_diff() { # a branch whose diff against origin/main touches ONE tracked file run bash -c "'$REC' < $(event mcp__Linear__save_issue 'The bug is in nosuchfile.rs:12.')" [ "$status" -eq 0 ] run cat "$(record)" - [[ "$output" == *" 0" ]] + [[ "$output" == *" 0 0" ]] [[ "$output" != *nosuchfile.rs* ]] } @@ -183,7 +183,7 @@ with_diff() { # a branch whose diff against origin/main touches ONE tracked file run bash -c "'$REC' < $(event mcp__Linear__save_issue 'The bug is in untouched.rs:12.')" [ "$status" -eq 0 ] run cat "$(record)" - [[ "$output" == *" 1 untouched.rs" ]] + [[ "$output" == *" 1,untouched.rs 0" ]] } # POINTER, NEVER PAYLOAD (non-negotiable 4). The recorder reads an entire issue @@ -218,13 +218,13 @@ with_diff() { # a branch whose diff against origin/main touches ONE tracked file run bash -c "'$REC' < $(event mcp__Linear__save_issue 'Just a sentence, no Ready block.')" [ "$status" -eq 0 ] run cat "$(record)" - [[ "$output" == *" unready -" ]] + [[ "$output" == *" unready - 0" ]] run bash -c "'$REC' < $(event mcp__Linear__save_issue '' '' CLOUD-999)" [ "$status" -eq 0 ] [ "$(wc -l <"$(record)")" -eq 2 ] run tail -1 "$(record)" - [[ "$output" == "issue CLOUD-999 2026-08-13T00:00:00.000Z ready -" ]] + [[ "$output" == "issue CLOUD-999 2026-08-13T00:00:00.000Z ready - 0" ]] } # --- the update path asserts nothing about relations (CLOUD-781) -------------- @@ -257,7 +257,12 @@ cites_a_blocker() { run tail -1 "$(record)" # `-` is "could not lint", which `filed-here-check` passes by design. Never # `unready`, which is a verdict about a Ready block nothing could judge. - [[ "$output" == "issue CLOUD-999 2026-08-13T00:00:00.000Z - -" ]] + # + # The sixth column is `1:CLOUD-1` rather than `0` (CLOUD-923): the §8 clause + # cites that row and the groom passed it as no relation, which is the whole of + # what that column counts. The verdict columns are unaffected — the two read + # different things about the same write. + [[ "$output" == "issue CLOUD-999 2026-08-13T00:00:00.000Z - - 1:CLOUD-1" ]] } @test "A CREATE CITING A BLOCKER IT DID NOT PASS IS STILL UNREADY" { @@ -269,7 +274,11 @@ cites_a_blocker() { run bash -c "'$REC' < $(event mcp__Linear__save_issue "$(cites_a_blocker)")" [ "$status" -eq 0 ] run cat "$(record)" - [[ "$output" == *" unready -" ]] + # `1:CLOUD-1` in the sixth column, for CLOUD-923's reason: the §8 clause cites + # that row and this create passed it as no relation. Independent of the verdict — + # the same citation makes the toll fire AND the edge get counted, from the two + # different questions the two columns ask. + [[ "$output" == *" unready - 1:CLOUD-1" ]] } @test "a groom of a genuinely unready body still records unready" { @@ -281,7 +290,7 @@ cites_a_blocker() { run bash -c "'$REC' < $(event mcp__Linear__save_issue 'Still just a sentence, no Ready block.' '' CLOUD-999)" [ "$status" -eq 0 ] run tail -1 "$(record)" - [[ "$output" == *" unready -" ]] + [[ "$output" == *" unready - 0" ]] } # The exception is narrow on purpose: it grants nothing a fresh create would not @@ -312,6 +321,72 @@ cites_a_blocker() { # COMMENTING ON A ROW IS NOT FILING IT. The exception is scoped to rows this # branch CREATED, which is the set `filed-here-check` gates; letting a comment # line qualify would open the update path on every row anyone commented on. +# --- CLOUD-923: the rows a body cites that the caller never passed ------------- +# +# The tracker auto-links every `CLOUD-nnn` mention into a symmetric `relatedTo` +# edge, so writing a body modifies every row it cites — passed as no parameter, +# named as no relation, reported in no response. Measured over one session: 43 +# edges added, 11 passed, 32 minted by prose, so 32 rows outside its scope silently +# modified. This column is the upper bound on that set, and it reads the body the +# TRACKER STORED so a caller cannot strip the citations out of what it sends. + +# A body citing $* — the rows appear only as prose, never as an argument. +cites_rows() { + ready_body + printf 'Evidence: %s carry the measurement.\n' "$*" +} + +@test "a write records the rows its stored body cites" { + # §7 (a). Two of the three cited rows are passed as `blockedBy`, so only the + # third is minted by prose — the argument set is subtracted whichever direction + # it was passed in. + run bash -c "'$REC' < $(event mcp__Linear__save_issue "$(cites_rows CLOUD-1 CLOUD-2 CLOUD-3)" 'CLOUD-1 CLOUD-2')" + [ "$status" -eq 0 ] + run cat "$(record)" + [[ "$output" == *" 1:CLOUD-3" ]] + [[ "$output" != *CLOUD-1* ]] +} + +@test "a write passing exactly the rows it cites records zero" { + # §7 (b). The honest zero, and the reason the column is not simply a citation + # count: an author who declares the edges owes nothing here. + run bash -c "'$REC' < $(event mcp__Linear__save_issue "$(cites_rows CLOUD-1 CLOUD-2)" 'CLOUD-1 CLOUD-2')" + [ "$status" -eq 0 ] + run cat "$(record)" + [[ "$output" == *" 0" ]] +} + +@test "zero and could-not-look are distinguishable in the record" { + # §7 (c). A comment carries no description this can read, so its column is `-`. + # Reading that as "no edges added" is the collapse CLOUD-251 named, and it is the + # direction that would make this record quieter than the truth. + run bash -c "'$REC' < $(comment_event)" + [ "$status" -eq 0 ] + run cat "$(record)" + [[ "$output" == *" -" ]] + [[ "$output" != *" 0" ]] +} + +@test "the row's own key is not counted as an edge to anywhere" { + # A body naming itself is the ordinary shape — every correction section in this + # repository's rows does it — and it is not a relation. + run bash -c "'$REC' < $(event mcp__Linear__save_issue "$(cites_rows CLOUD-999)")" + [ "$status" -eq 0 ] + run cat "$(record)" + [[ "$output" == *" 0" ]] + [[ "$output" != *CLOUD-999\ * ]] || true +} + +@test "POINTER, NEVER PAYLOAD: the citing sentence does not reach the record" { + # The column reads the body, which is the largest thing this hook touches. Only + # the keys and a count may leave it (non-negotiable rule 4). + run bash -c "'$REC' < $(event mcp__Linear__save_issue "$(cites_rows CLOUD-7)")" + run cat "$(record)" + [[ "$output" == *"1:CLOUD-7" ]] + [[ "$output" != *"carry the measurement"* ]] + [[ "$output" != *Evidence* ]] +} + @test "a comment on a row does not make a later update to it recordable" { run bash -c "'$REC' < $(comment_event mcp__Linear__save_comment CLOUD-999)" [ "$(wc -l <"$(record)")" -eq 1 ] @@ -331,7 +406,7 @@ cites_a_blocker() { run bash -c "'$REC' < $(comment_event mcp__Linear__save_comment CLOUD-42)" [ "$status" -eq 0 ] run cat "$(record)" - [[ "$output" == "comment CLOUD-42 2026-08-13T00:00:00.000Z - -" ]] + [[ "$output" == "comment CLOUD-42 2026-08-13T00:00:00.000Z - - -" ]] } # The regression case, stated as a shape rather than a value: whatever a comment @@ -358,7 +433,7 @@ cites_a_blocker() { run bash -c "'$REC' < $(comment_event mcp__Linear__save_comment '' parentId)" [ "$status" -eq 0 ] run cat "$(record)" - [[ "$output" == "comment - 2026-08-13T00:00:00.000Z - -" ]] + [[ "$output" == "comment - 2026-08-13T00:00:00.000Z - - -" ]] } # CLOUD-178 measured the same connector under three names depending on the diff --git a/tests/filed-here-check.bats b/tests/filed-here-check.bats index 87d7db32c..8e009def4 100644 --- a/tests/filed-here-check.bats +++ b/tests/filed-here-check.bats @@ -263,11 +263,13 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } # and every one recorded `ready`. # # The fifth column is the recorder's `board-diff-overlap` reading, `` then -# the overlapping tracked paths. +# the tracked paths the row names, COMMA-JOINED since CLOUD-923 so one column is +# one whitespace-free token. The recorder gained a sixth column then, and a record +# whose fifth field can swallow the rest of the line cannot have a sixth. @test "a row naming a file this branch is changing stops the lap" { changes crates/batten/src/git.rs - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1 crates/batten/src/git.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1,crates/batten/src/git.rs" run "$GATE" [ "$status" -eq 1 ] [[ "$output" == *"CLOUD-900 filed-over-own-diff crates/batten/src/git.rs"* ]] @@ -305,7 +307,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } @test "every overlapping path is named, one pointer per line" { changes a/one.rs b/two.rs - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 2 a/one.rs b/two.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 2,a/one.rs,b/two.rs" run "$GATE" [ "$status" -eq 1 ] [[ "$output" == *"CLOUD-900 filed-over-own-diff a/one.rs"* ]] @@ -316,7 +318,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } # the other, so a row can earn both and must report both. @test "a row that is both unrefined and over the diff reports both" { changes a/one.rs - record "issue CLOUD-900 2026-08-19T00:00:00.000Z unready 1 a/one.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z unready 1,a/one.rs" run "$GATE" [ "$status" -eq 1 ] [[ "$output" == *"CLOUD-900 filed-unrefined"* ]] @@ -327,7 +329,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } # branch filed is groomed, and a row rewritten to be about work elsewhere is the # second of the four remedies. @test "a later reading with no overlap supersedes an earlier one" { - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1 a/one.rs" \ + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1,a/one.rs" \ "issue CLOUD-900 2026-08-19T01:00:00.000Z ready 0" run "$GATE" [ "$status" -eq 0 ] @@ -337,7 +339,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } @test "and a later reading WITH an overlap supersedes a clean one" { changes a/one.rs record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 0" \ - "issue CLOUD-900 2026-08-19T01:00:00.000Z ready 1 a/one.rs" + "issue CLOUD-900 2026-08-19T01:00:00.000Z ready 1,a/one.rs" run "$GATE" [ "$status" -eq 1 ] [[ "$output" == *"filed-over-own-diff a/one.rs"* ]] @@ -362,7 +364,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } # Recorded with no diff at all: the paths are what the body named, not an # intersection. The edit lands afterwards, exactly as it did on the branch this # was found on. - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 2 a/one.rs b/two.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 2,a/one.rs,b/two.rs" changes a/one.rs b/two.rs run "$GATE" [ "$status" -eq 1 ] @@ -372,7 +374,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } # The intersection is real, not a pass-through of the recorded list: a row may # name a dozen files and touch one, and only the one it touches is a pointer. @test "a recorded path the branch does not change is not reported" { - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 2 a/one.rs b/untouched.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 2,a/one.rs,b/untouched.rs" changes a/one.rs run "$GATE" [ "$status" -eq 1 ] @@ -381,7 +383,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } } @test "a row naming only files this branch leaves alone passes" { - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1 b/untouched.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1,b/untouched.rs" changes a/one.rs run "$GATE" [ "$status" -eq 0 ] @@ -392,7 +394,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } # filed AND THEN FIXED on the same branch has its paths in the diff by # construction, so every file-then-fix would need the override. @test "A ROW THE PR CLOSES IS EXEMPT — filing then fixing is the point, not the punt" { - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1 a/one.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1,a/one.rs" changes a/one.rs run bash -c 'printf "Closes CLOUD-900\n" | "$1"' _ "$GATE" [ "$status" -eq 0 ] @@ -400,7 +402,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } } @test "closing a different row does not exempt this one" { - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1 a/one.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1,a/one.rs" changes a/one.rs run bash -c 'printf "Closes CLOUD-901\n" | "$1"' _ "$GATE" [ "$status" -eq 1 ] @@ -411,7 +413,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } # exemption either — the distinction `closing-key-check` already draws, reused by # calling it rather than by copying its match. @test "a body that only refs the row does not exempt it" { - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1 a/one.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1,a/one.rs" changes a/one.rs run bash -c 'printf "Refs CLOUD-900 for context\n" | "$1"' _ "$GATE" [ "$status" -eq 1 ] @@ -422,7 +424,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } # `filed-unrefined`: a Ready block is payable in typing and this is not. @test "the diff refusal names four remedies and none of them is writing more prose" { changes a/one.rs - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1 a/one.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1,a/one.rs" run "$GATE" [[ "$output" == *"Fix it here"* ]] [[ "$output" == *"comment there"* ]] @@ -434,7 +436,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } # POINTER, NEVER PAYLOAD (rule 4): a path is all the recorder ever wrote, so a # path is all this can name. @test "the diff refusal carries the id and one path and nothing else" { - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1 a/one.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1,a/one.rs" run "$GATE" [[ "$output" != *"2026-08-19T00:00:00.000Z"* ]] } @@ -447,7 +449,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } # landing — to need a route that is not a blanket off-switch. @test "the override lets the diff refusal through" { - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1 a/one.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1,a/one.rs" run env BATTEN_FILED_HERE_OVERLAP=1 "$GATE" [ "$status" -eq 0 ] [[ "$output" != *"filed-over-own-diff"* ]] @@ -457,8 +459,8 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } # decision look identical to the branch and completely different to a reviewer. @test "the override records which rows it overrode" { changes a/one.rs b/two.rs - record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1 a/one.rs" \ - "issue CLOUD-901 2026-08-19T00:00:00.000Z ready 1 b/two.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z ready 1,a/one.rs" \ + "issue CLOUD-901 2026-08-19T00:00:00.000Z ready 1,b/two.rs" run env BATTEN_FILED_HERE_OVERLAP=1 "$GATE" [ "$status" -eq 0 ] [[ "$output" == *"BATTEN_FILED_HERE_OVERLAP"* ]] @@ -471,7 +473,7 @@ record() { printf '%s\n' "$@" >>"$RECORD"; } # It is the DIFF override, not a bypass: a row filed unrefined is still refused. @test "the override does not excuse an unrefined row" { - record "issue CLOUD-900 2026-08-19T00:00:00.000Z unready 1 a/one.rs" + record "issue CLOUD-900 2026-08-19T00:00:00.000Z unready 1,a/one.rs" run env BATTEN_FILED_HERE_OVERLAP=1 "$GATE" [ "$status" -eq 1 ] [[ "$output" == *"CLOUD-900 filed-unrefined"* ]] From ee5321c277ab446c87536ebcac346bc4a4b7fb3a Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sun, 23 Aug 2026 03:03:12 +0000 Subject: [PATCH 05/13] refactor(ready-lint): emit the structure the producer already built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ready-lint` is the one program in the tree that turns a Ready block into structure, and for its whole life it handed callers three states and nothing else. Five tasks spawn it and branch on the exit code, so a consumer needing the structure rebuilt it with a second regex over the same body. Two sets now go to stdout: `cites-body`, every issue key the body names with Linear's mention markup stripped, and `cites-blockers`, the keys the §8 span claimed. stdout because every existing consumer already discards it — `graph-check` captures `2>&1 >/dev/null` — so this adds a channel beside the exit code rather than changing one, and a caller reading only the code is byte-identical. `cites-body` is emitted before the `no-ready-block` refusal, and that position is the correctness: it is a property of the body, not of the block. An unrefined row still cites rows and the tracker still mints an edge per citation, so emitting it later would hide the fact for exactly the rows likeliest to carry a stray citation, and a consumer would read that absence as "could not look" over a body it read perfectly well. A line present with no keys is the honest empty set; an absent line means the run never got there. `board-write-record` is the consumer and its own key regex is deleted. It already spawned the gate on that very body for the verdict; it keeps stdout now and reads the set. The case pinning this asserts a property only the producer has — numeric ordering, so `CLOUD-9` precedes `CLOUD-10` where a lexical second scan would not. CLOUD-806 §2 asks for `graph-check`'s three re-derivations to be deleted, and that premise is false. Measured: it derives the key at FIVE sites, and not one rebuilds anything `ready-lint` computes — one reads `tsort`'s output, and four are the status-claim scan, a predicate `ready-lint` does not implement. The duplication is of the regex literal across nine spellings, which the row itself scopes out as CLOUD-761's. Deleting a site there would delete a predicate, not a rebuild. The measurement is recorded beside those sites so a second attempt does not repeat it. `tests/stop-guard.bats` is updated for the comma-joined named-paths column: the recorder's on-disk format had a third consumer that reading the writer's callers does not reveal, and `verify` is what found it. Refs: CLOUD-806 --- mise-tasks/board-write-record.sh | 42 +++++++++++++++++----- mise-tasks/graph-check.sh | 22 ++++++++++++ mise-tasks/ready-lint.sh | 60 ++++++++++++++++++++++++++++++++ tests/board-write-record.bats | 36 +++++++++++++++++++ tests/filed-here-check.bats | 6 +++- tests/ready-lint.bats | 59 +++++++++++++++++++++++++++++++ tests/stop-guard.bats | 4 +-- 7 files changed, 218 insertions(+), 11 deletions(-) diff --git a/mise-tasks/board-write-record.sh b/mise-tasks/board-write-record.sh index 211d6c22d..6d70a6e17 100755 --- a/mise-tasks/board-write-record.sh +++ b/mise-tasks/board-write-record.sh @@ -300,7 +300,12 @@ if [[ "$kind" = issue ]]; then # omits the relations key, `unjudgeable-relations` fires, and `-` is the # honest answer for a groom whose relations nothing here can see. if [[ -n "$payload" ]]; then - printf '%s' "$payload" | "$(dirname -- "${BASH_SOURCE[0]}")/ready-lint.sh" >/dev/null 2>&1 + # STDOUT IS KEPT NOW (CLOUD-806). `ready-lint` emits the structure it already + # built — `cites-body` and `cites-blockers` — before it branches on a verdict, + # so this reads the derived fact instead of rebuilding it with a second regex + # over the same body. stderr still goes nowhere: its pointers are the lint's + # own report, and this file prints nothing. + lint_out=$(printf '%s' "$payload" | "$(dirname -- "${BASH_SOURCE[0]}")/ready-lint.sh" 2>/dev/null) case $? in 0) verdict=ready ;; 1) verdict=unready ;; @@ -388,7 +393,20 @@ fi # # Pointer-only per non-negotiable rule 4: a count and the far-end keys, # comma-joined so the record stays one field per column. Never a line of the body -# that minted them — which is the whole of what this reads. +# that minted them. +# +# THE KEYS COME FROM `ready-lint`, NOT FROM A SECOND SCAN (CLOUD-806). This file +# already spawns that gate on this very body, and that gate is the one program in +# the tree that turns a Ready block into structure — it strips Linear's +# `` mention markup, dedupes, and orders numerically. A second regex here +# would be a second authority over one question, and the two would disagree the +# first time either was touched. +# +# ABSENT IS "COULD NOT LOOK", NEVER EMPTY. The producer emits the line BEFORE it +# branches on a verdict, so a missing line means it exited before reaching the +# emission — an unreadable payload — and this column stays `-`. A line that is +# PRESENT and carries no keys is the honest zero. Collapsing those two is the +# CLOUD-251 shape the overlap column above already takes care to avoid. # # REPORTED, NEVER REFUSED, and that is CLOUD-923's open call decided. The tracker's # auto-linking is not the author's choice and no body can opt out of it, so a @@ -396,18 +414,26 @@ fi # header warns about. This file prints nothing and moves no exit code regardless; # what changes is that a later reader can see which far-end rows a branch touched. cites=- -if [[ "$kind" = issue ]] && [[ -n "${description:-}" ]]; then +# The guard is the EMISSION, not the description: `ready-lint` is the producer, so +# "did it get far enough to emit" is the only thing that decides whether this +# column can be computed at all (CLOUD-806). +emitted="" +cited="" +if [[ -n "${lint_out:-}" ]]; then + while IFS= read -r line; do + [[ "$line" == cites-body* ]] || continue + emitted=1 + cited=$(tr ' ' '\n' <<<"${line#cites-body }" | grep -vx '' || true) + break + done <<<"$lint_out" +fi +if [[ "$kind" = issue ]] && [[ -n "$emitted" ]]; then # The caller's arguments, all three directions: a row passed as a blocker is # not "minted by prose" however the body also mentions it. passed=$(printf '%s' "$raw" | jq -r ' [ .tool_input.relatedTo[]?, .tool_input.blockedBy[]?, .tool_input.blocks[]? ] | map(select(type == "string")) | unique | .[] ' 2>/dev/null) || passed="" - # Linear serialises a mention as CLOUD-N; the markup is - # stripped first so the stored and rendered forms are one case, exactly as - # `ready-cites-check` and `graph-check` both do before reading a body. - cited=$(sed -E 's|]*>||g' <<<"$description" | - grep -oE 'CLOUD-[0-9]+' 2>/dev/null | sort -u) || cited="" minted="" while IFS= read -r k; do [[ -n "$k" ]] || continue diff --git a/mise-tasks/graph-check.sh b/mise-tasks/graph-check.sh index fa9777e78..032aa0eb3 100755 --- a/mise-tasks/graph-check.sh +++ b/mise-tasks/graph-check.sh @@ -51,6 +51,28 @@ # alphabet cannot spell it # unjudgeable-description a payload carries no description key -> exit 2 # +# ─── THIS FILE'S `CLOUD-[0-9]+` SITES ARE NOT RE-DERIVATIONS (CLOUD-806) ───── +# +# CLOUD-806 asked for `ready-lint` to emit the structure it builds and for this +# file's three re-derived issue-key regexes to be deleted. The emission landed and +# has a consumer; the deletion does not apply here, and the measurement is recorded +# so nobody spends a second attempt discovering it. +# +# Measured 2026-08-23 — this file derives the key at FIVE sites, not three, and not +# one of them rebuilds anything `ready-lint` computes: +# +# the cycle report ids out of `tsort`'s OUTPUT. Not a body at all; `ready-lint` +# never sees it and could not emit it. +# the claim scan `CLOUD-N is ` over prose, twice, plus the CAPSPAN +# (four sites) arm. A predicate `ready-lint` does not implement, over the +# whole body rather than the §8 span. +# +# `ready-lint`'s own sites derive §8 blocker citations and deferral citations — +# different predicates over different spans. So the duplication CLOUD-806 names is +# of the REGEX LITERAL across nine spellings, which that row explicitly scopes out +# as CLOUD-761's, and not of the derivation. Deleting a site here would mean +# deleting a predicate, not a rebuild. +# # DECLARED FIELD SET (CLOUD-526), stated because a gate that never writes its # input contract down grows one by accident: `id` and `status` for every issue, # `relations.blockedBy` for the graph, `description` for the §8 claim scan and diff --git a/mise-tasks/ready-lint.sh b/mise-tasks/ready-lint.sh index 024edda3b..227d885fd 100755 --- a/mise-tasks/ready-lint.sh +++ b/mise-tasks/ready-lint.sh @@ -39,6 +39,13 @@ # discriminating case is a `no bump` type: a releasable one collapses to the # same answer either way, so it would pass under the mutation and prove nothing. #MUTANT break-read-off-the-whole-line|s/\[\[ "\$type_token" == \*.!.\* \]\]/grep -qE "!" <<<"$bump_line"/|denying a break +# CLOUD-806's two arms. The first drops the body emission, so a consumer reading +# the derived fact silently gets nothing and cannot tell that from an empty body. +#MUTANT emission-dropped|s@^emit_keys cites-body .*@true@|the body's cited keys are emitted before any verdict +# The second emits it AFTER the no-ready-block refusal, which is where it was first +# written: the fact then exists for refined rows only, and an unrefined row's stray +# citation — the likeliest kind — becomes invisible. +#MUTANT emission-after-the-verdict|s@^emit_keys cites-body @exit 1 # @|an unrefined body still emits its cited keys set -euo pipefail payload=$(cat) @@ -140,6 +147,35 @@ READY_OPENERS='^\*\*Refinement|^#{2,3} +Refinement|^#{2,3} +Ready|^\*\*Definitio # The parent dialect, needed twice: to locate a block, and to exempt it from the # clause floor below. PARENT_OPENER='^#{2,3} +Refinement gate' +# --- THE DERIVED FACT, PART ONE: THE BODY'S KEYS (CLOUD-806) ------------------ +# +# `cites-body` IS EMITTED HERE, BEFORE THE FIRST VERDICT EXIT, and the position is +# the whole of its correctness. It is a property of the BODY, not of the Ready +# block: an unrefined row still cites rows, and the tracker still mints an edge per +# citation from it. Emitting it after the `no-ready-block` refusal below would make +# the fact unavailable for exactly the rows most likely to carry a stray citation, +# and a consumer would read that absence as "could not look" over a body that was +# read perfectly well. +# +# `cites-blockers` cannot come this early — its span does not exist yet — so it is +# emitted at the §8 scan. The two are separate lines for that reason rather than +# for tidiness: a caller can be handed one set and not the other, and an absent +# line means "this run never got far enough to know", per set. +# +# Byte-stable: numeric by issue number, deduped. `by_key_num` and not a bare sort, +# for `graph-check`'s reason — `CLOUD-10` sorts before `CLOUD-9` lexically, so a +# caller diffing two runs could not tell an ordering change from a content one. +by_key_num() { sort -t- -k2,2n; } +emit_keys() { # emit_keys