feat(policy): gate tool substitution, name shell programs .sh, and fix what that rename broke - #638
Conversation
CLOUD-864 A Bash call substitutes for the structured-tool surface in 34 of 38 calls — Read/Grep/Glob used zero times, and `run-shape-guard` already named this root cause for a different symptom
Why The owner observed an agent reaching for The measurementSource:
Every Bash call classified by whether a first-class tool does that exact job:
34 of 38 had a substitute. 4 did not. Rows overlap — one call can carry two forms; 34/38 is the deduplicated count of calls with at least one substitute. The classifier is ~20 lines of One candidate finding did NOT hold, and is recorded because the check is what makes the rest credible. The observation included "writing files with bash stream redirection instead of write tools". False in this transcript: the only two redirects to a file were The hypothesis this refutes — mine, an hour earlierThe first RCA in this session concluded "Serena was adopted as infrastructure, never as an instrument" and rested on a cost argument: all 21 The wider measurement kills that support. The root cause — this repo already wrote it down, for a different symptom
That was mined from foreground It has never been generalised to tool choice, and it is the same default: the shell as universal interface. Serena is not a separate problem. It is the most expensive casualty of a general default, because it is the only displaced tool with no text approximation at all — Where a name-resolving instrument actually reachesAn earlier revision of this body called First: the language list is under-configured, not a constraint. Serena ships Second, and this is the sharper finding: widening
So the coverage table, corrected:
This is CLOUD-310's extensionless trap recurring in a second tool. CLOUD-310 measured The routes for the shell 65k are an upstream The rest still holds:
Three surfaces, three failuresThe
And prose cannot hold this surface, demonstrated live. On exiting plan mode this session, the host injected a mode directive reading: "Do your work through the Bash tool wherever it can accomplish the job: read files with cat, head, or sed -n, search with grep and find … rather than using the dedicated Read, Edit, or Write tools." A grep of the transcript confirms it was absent for all 38 measured calls, so it does not explain the history — but it establishes the durability argument outright: a host directive can mandate the anti-pattern mid-session and override any prose surface this repo owns. A Budget constraint on the fix
The correction to CLOUD-844 §2CLOUD-844 owns the instrument taxonomy for That is too strong, and it is the sentence to correct. The general question is undecidable. A narrow instance is exactly as decidable as the
AGENTS.md holds that a wrongly-refusing gate is a defect. A wrongly-declined gate is the same shape: it leaves the rule as prose and books the refusal as a verdict. CLOUD-844 stays the authority on the three-row taxonomy; this row is the binding half it declined, and it must not restate the taxonomy. Refinement — Ready Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
Filed from a self-measurement whose first pass reached the wrong root cause; the correction is in the body because the wrong version was one edit from being filed. CLOUD-865 143 tracked files carry a shell shebang and no `.sh`/`.bash`, so every instrument must reimplement shebang sniffing or go silently blind — hk does, ast-grep and Serena do not
Why 143 tracked files begin with a shell shebang and do not end in
Zero files under The defect is that correctness depends on every tool independently sniffing contentA file extension is the portable selector: every tool understands it, at zero cost, before opening the file. A shebang is a content selector: it works only for tools that read the first line and map it to a language. This tree relies entirely on the second, so each instrument either implements shebang detection or covers nothing — and when it covers nothing it says so by exiting 0. Measured, three tools, three answers:
hk's reach was re-measured this session with The tree documents the dependency as a justification rather than a risk, which is the clearest evidence that it was never weighed as one.
Read forward, that is an argument for keeping the naming: upstream happens to sniff, so no glob is needed. Read as a dependency, it is the finding — this repository's shell coverage rests on a selector detail of one vendored builtin, unpinned by anything, and nothing states what happens to a tool that lacks it. Two tools already lacked it. The real shape is a per-tool tax. hk pays it by shipping Why "leave it as it is" is not a neutral optionThe first revision of this body offered "accept shell as a text-tool surface" as a legitimate third route. It is not, and that framing was the error the owner named. Accepting it means accepting:
The convention itself is the defect. Naming a bash program What fixing it meansRename the 137 real programs (136 under The rename's cost is entirely in references, not content: every The other half of the same selector, already owned: the batch above contains no Not in scope: the instrument taxonomy (CLOUD-844), the substitution guard (CLOUD-864 — its preset already handles shell correctly by construction, falling through to the Refinement — Ready Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
Revision note: filed 2026-08-21 as a three-route decision that treated the naming as a neutral choice, with the measured claim that no instrument reached these files. Both were wrong — hk reaches all 146 via CLOUD-897 `sonar-gate` in `final` is a race the repo normally loses, so the analyzer's verdict has never actually blocked a land
Why CLOUD-441 wired the external analyzer into
Measured 2026-08-22 from the check-runs API, the last three merged PRs:
Every one landed with a The bounded retry cannot save it. The verdict is therefore a function of CI duration, not of the analysis. #638 (300+ files plus the Demoted to advisory in #638 so that branch could land: Not this issue's
This row is only the PR-side determinism. It is worth separating because fixing the race without clearing the red would start blocking every PR on a pre-existing condition, and clearing the red without fixing the race would leave the next regression equally invisible. What reading the findings actually costs, since CLOUD-528 says they cannot be readPartly wrong, and the correction is reusable. Check-run annotations are reachable and the GitHub MCP Two traps, both of which look like data:
Recorded as Refinement — Ready Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
Filed from #638, which did not cause this and was refused by it. |
|
Important Review skippedToo many files! This PR contains 336 files, which is 36 over the limit of 300. To get a review, reduce the PR to 300 files or fewer by splitting it into smaller PRs or changing its base branch. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (3)
📒 Files selected for processing (336)
You can disable this status message by setting the Comment |
1a1be30 to
d2ac730
Compare
…d broke Refs: CLOUD-865 The external analyzer failed #638 with a D Security Rating on New Code. The findings were not anything this branch wrote: it selects shell files by EXTENSION, so until `mise-tasks/*` were renamed to `.sh` it had never read one of those 136 programs. The rename turned it on and 1379 pre-existing `[` tests became new code at once. Nothing was newly wrong; a light came on. That is the fourth instrument this tree has caught selecting by extension and covering nothing while exiting 0. CLOUD-310 measured the first, CLOUD-864 the second, CLOUD-382 owns the `.bats` half, and CLOUD-865 predicted a third would arrive the same way — this is the measurement rather than the prediction. THE CODEMOD IS SHELLCHECK'S OWN. SC2292 ships a populated `fix:`, so `shellcheck -o require-double-brackets --format=diff` emits the rewrite and `git apply` takes it — 1379 lines across 139 files, no hand-written sed. `install.sh` is untouched and stays that way: it is the tree's only `#!/bin/sh` program, `[[` is a bash builtin, and SC2292 is Bash/Ksh-scoped so shellcheck skips it by construction rather than by an exclusion somebody maintains. SEMANTICS CHECKED RATHER THAN ASSUMED. `[[` suppresses word splitting, makes `=` a pattern match, and changes `-a`/`-o`. Across the patch: zero `-a`/`-o`, and four `=`/`!=` with a glob-looking right side, every one of them quoted (`[[ "$2" = "*" ]]`, three `${value#*[^0-9]}`), so all four stay literal. AND IT BROKE 37 MUTANTS, WHICH IS THE HALF WORTH READING. A `#MUTANT` declaration is a sed script matching a source line LITERALLY, so rewriting the line silently unaims it — a third of the enforced set would have reported `unappliable-mutation`. Baselined against HEAD first (2 already broken, both outside `$MUTANT_GATES`), re-aimed exactly the 37 the codemod broke, and re-measured back to 2. Only the MATCH half was rewritten: `bot-issue`'s `closes-on-a-bare-key` deliberately INJECTS a `[ -n "$key" ]` as its corruption, and rewriting that would change what the mutant proves. The rule ships with its mechanism (non-negotiable rule 2): `.shellcheckrc` gains `enable=require-double-brackets`, so the existing shellcheck step refuses the next `[` at pre-commit instead of a CI run later. No new task, no new gate wiring, no second selector.
d2ac730 to
c3da83c
Compare
…every skill
Three surfaces, one reach. `.serena/project.yml` gains the languages the tree
actually has servers for and a one-line `initial_prompt`; `skills/serena/` gains
the dispositional half; `skill-check` gains the coverage it claimed.
`bash` is declared BEFORE it can match anything, deliberately. `Language.BASH`
is `FilenameMatcher(".sh", ".bash")` — extension-only — so it covers zero files
while `mise-tasks/*` are extensionless, and starts covering ~29k lines the
moment the rename lands. Declaring it afterwards would mean doing that rename
without the instrument it buys.
`initial_prompt` is ONE line because lines bind, not tokens: the set is now
198/199 with 238 tokens spare, and a second line sits exactly on the ceiling and
fails the next unrelated change. The detail lives in the skill, which loads on
trigger and costs the budget nothing.
`skill-check` checked one skill while its hk step globbed `skills/**`, so a
second skill fired the step and was never read. The loop now covers every skill
for the two predicates that generalise — budget and one-set-of-bytes — and keeps
the verb and exit-table checks on the batten skill, whose authority is `batten
spec` and `--help` and which mean nothing over a skill that describes something
else.
Discovery is a filesystem glob, not `git ls-files`. The first version asked git
and passed with the new skill's vendor symlink deleted: a skill is untracked
until staged, so the gate skipped the one most likely to be wrong. That is the
silent-exemption defect the loop exists to remove, reproduced inside the fix for
it, and `tests/skill-check.bats` now pins it — the untracked case fails against
a git-sourced discovery.
Refs: CLOUD-864
…choice has a gate Measured over one session's transcript: 34 of 38 Bash calls had a first-class tool that does the job, and Read/Grep/Glob were used zero times — 15 `head -N`, 13 `grep`, 12 `ls`, 5 `cat`, 5 `sed -n`, 2 `find -name`. The rule existed as prose in three places and as a gate in none, which non-negotiable rule 2 calls half a change. WHY THIS KIND, AND WHY NO EXISTING ONE WOULD DO. What separates a substitution from an ordinary filter is not the program but what surrounds it: `grep pat crates/` answers a question `Grep` answers better, while `git ls-files | grep crates/batten` filters another command's output and no tool replaces it. Same program, same operand, opposite verdicts, and only the position tells them apart. `matching_shape_rows` iterates every segment with no index or terminator in scope, so a `shape` row carrying these programs refuses the filter too — the wrongly-refusing gate AGENTS.md calls a defect, and the false-positive class CLOUD-199 measured gets a guard bypassed. `pipeline` is already defined over the operators between segments, which is exactly the fact in question. So: two predicate families on one kind. `verdict` + `filters` is the discard family; `substitutes` is this one. `permits` gains the column and `requires` loses `verdict`/`filters`, which is a CONDITIONAL requirement rather than a relaxation — the same move `Receipt`'s `pattern` already makes — enforced in `validate_pipeline_tables`, where the sibling columns are in scope. A row must declare one family whole; half a family matches and decides nothing. The predicate reads the UPSTREAM terminator (`parsed[index - 1]`), where the discard predicate reads the following one. Both derive from a parse already in hand, which is why this is two columns rather than a new kind. Three exclusions, each a case the gate must not refuse: an absolute path (`cat /tmp/verify.log` — the shape `verdict-not-discarded` MANDATES, so refusing it would put the two rows in contradiction), a bare pattern with no separator or extension (`grep -c CLOUD`), and stdin. A path reached through a shell variable is invisible and stated as such on the row rather than guessed at. Verified against the compiled binary, not a fixture: `sed -n '1,40p' AGENTS.md` denies naming `Read(offset, limit)`; the five allow shapes above pass. `pipeline_shapes.rs` is 17/17, and the thirteen pre-existing discard cases still pass — the new row refuses nothing the other family relied on. Refs: CLOUD-864
143 tracked files began with a shell shebang and ended in neither `.sh` nor
`.bash`; 137 of them were programs and 6 were fixtures. This renames the 137 and
adds the rule that keeps it true.
WHY IT WAS A DEFECT AND NOT A CONVENTION. An extension is the PORTABLE selector:
every tool honours it, before opening the file, at no cost. A shebang is a
CONTENT selector, so it works only for tools that read the first line — and one
that does not covers nothing while exiting 0. Measured on this tree, three
instruments, three answers: hk's shellcheck reached all of them (`hk check --all
--step shellcheck -v` batches 146 files) because its builtin selector sniffs
shebangs; ast-grep pointed at `mise-tasks/` scanned nothing and exited 0
(CLOUD-310 defect 1); Serena's bash LSP matched none, because
`FilenameMatcher(".sh", ".bash")` is extension-only and `add_extensions` is
wired for PHP and Perl alone. Two of three silently blind, independently,
neither announcing it. `hk.pkl` documented the dependency as a JUSTIFICATION for
the naming rather than as the risk it is.
THE GATE IS A POLICY PRESET, NOT ANOTHER BASH TASK. Adding a 138th shell program
to police the 137 would be the thing the retirement campaign exists to stop
(CLOUD-843/312/807). `shell-hygiene/shebang-names-its-language.rego` decides
over `input.tree.lines`, and its allows are the load-bearing half: `bats`,
`python3` and a file with no shebang all stay clean, so it is a rule about SHELL
rather than about shebangs.
That needed one engine column. `documents` got a glob spelling in CLOUD-850 and
`lines` — which landed in the same batch — did not, so the unparsed half of the
fact model was reachable only by naming every path. A row enumerating 137
programs is a list that goes stale the next time somebody adds one: silently,
green. `line_sources` is the symmetric sibling, and both resolve through one
`select_declared` so the two spellings cannot drift.
THE DECLARATION IS THE EXCLUSION. Six of the 143 are fixtures — probes for this
exact class, which a rename would delete as test data. No glob in the row selects
them, because `Selector` sets `literal_separator(true)`. A row reads the paths it
names and no others.
WHAT THE TEXTUAL PASS COULD NOT SEE, and the strongest argument the rename was
worth doing: 23 sibling-task paths are ASSEMBLED AT RUNTIME —
`"$(dirname "$0")/checks-green"`, `"$here/claimed-keys"`,
`"$TASKS/reference-check"`. The string `mise-tasks/checks-green` never appears in
those files, so no scan over source text finds them, and they fail at call time
rather than parse time. `ntia-check` was the first to break and it broke on
exactly this shape. A tree that named its files consistently would not have had
them.
Two fixtures were rewritten by the bulk pass and reverted, both deliberate
literals: this preset's own deny case (which the rename turned into a PASSING
one — green, testing nothing) and `board-diff-overlap`'s 124-path replay of the
tree as it stood when three real issues were filed. The rego fixture now says so
in a comment, so the next tree-wide rename cannot silently defuse it.
Renamed by `git mv`; 788 references rewritten with Serena's `replace_in_files`
under dry-run and `expected_count` guards. `mise run <task>` is unaffected:
`Task::is_match` strips one extension from both the stored and the typed name.
Refs: CLOUD-864
Closes CLOUD-865
Five more spellings of the same defect, each found by running something rather
than by scanning anything. A task that resolves a sibling by concatenating a
directory with a basename never contains the string being renamed, so no pass
over source text finds it:
"$(cd "$(dirname "$0")" && pwd)/man-pages" derived-check
"$(cd "$(dirname "$0")" && pwd)/render/cli" reference-check
"$(cd "$(dirname "$0")" && pwd)/checksums" release-assets-check
"$(cd "$(dirname "$0")" && pwd)/dist" sbom-binary
"$(cd "$(dirname "$0")" && pwd)/sbom" sbom-check
"$TASKS/sbom", "$TASKS/render/cli", "$TASKS/sbom-binary"
The running total is 31 constructed paths across five spellings — `$(dirname
"$0")/x`, `$here/x`, `$(cd … && pwd)/x`, `$TASKS/x`, and the `.git/hooks`
symlink. The earlier sweeps each matched one shape and missed the next.
TWO OF THESE SURFACED ONLY BECAUSE THE GATES REFUSE TO PASS WHEN THEY CANNOT
RUN. `derived-check` and `reference-check` both say it in as many words — "a
gate that checks nothing must not report green" — so a task whose helper had
moved failed loudly instead of reporting a clean tree. A gate that treated an
unrunnable helper as nothing to report would have hidden both.
And the rebase during this work landed a NEW extensionless task from main,
`coderabbit-config-check`, which the rule in the previous commit would have
failed on. That is CLOUD-843's measured growth arriving inside the change that
stops it, which is the best evidence the gate is worth having: the convention
was still accreting while it was being retired.
Refs: CLOUD-864, CLOUD-865
…not see
Refs: CLOUD-865, CLOUD-864
`mise run verify` over the renamed tree was red in eight suites. Every failure
was the same class: a reference to a task that no textual pass could see, and in
five of the six spellings the broken reference FAILS OPEN — `[ -x "$p" ]` then
`exit 0`, or a glob that matches nothing, so the gate keeps running and keeps
reporting clean.
16 constructed siblings in 9 files `$(dirname -- "${BASH_SOURCE[0]}")/x`
2 two-step siblings `here=$(cd "$(dirname "$0")" && pwd)`
2 suffix globs `"$(dirname "$0")"/*-guard`
2 ready-lint anchors `-check` required at the closing backtick
1 mutant gate resolution task name vs file name
20 bats fixtures the suite CREATES the file it stubs
Six of the sixteen were in the Stop hook, so four rules ran over nothing.
So the rule ships with the class it created: `sibling-resolves`, a second module
in the `shell-hygiene` preset, decides a constructed sibling against
`input.tree.tracked` across all three single-line spellings plus the two-step
form, and excludes a parent-directory variable and a git-dir path by test. The
row is renamed `shell-hygiene` because it now carries two rules and a finding
names its own.
`board-diff-overlap`'s third arm now tries `<name>.sh` as well as `<name>`: a
task is written up as `land` and stored as `land.sh`, and the arm resolved
nothing the day the tree grew extensions. The frozen replay fixture keeps its
extensionless paths and its assertion is restored to match — a historical replay
that tracks the present measures nothing.
`repo_relative_path` gets two exclusions, both defects this row committed against
its own author within the hour it landed: a redirection (`2>/dev/null`) and a
regex alternation (`.bats|basename`) each read as a path, so the refusal was
right in verdict and wrong in every pointer it gave. The operand scan now stops
at the first redirection.
Enables `regorus/regex`, which resolves to the `regex 1.12.3` this workspace
already depends on directly — a lockfile edge, no new package, and none of the
names `evaluator-closure-check` refuses. The alternative was a character-index
walk hand-written in Rego to extract one capture.
… pointer Refs: CLOUD-864 It was a closure local to `each_shape_renders_its_own_cause`, and the two new substitution regressions referenced it from file scope. `cross-check` caught it before CI did, which is the split working: a foreign-target type-check costs nothing here and a matrix leg costs minutes. A free function rather than a second closure, because both families now assert the same property for the same reason — the discard family that three shapes render three causes, the substitution family that the cause names the operand a caller can act on.
…d broke Refs: CLOUD-865 The external analyzer failed #638 with a D Security Rating on New Code. The findings were not anything this branch wrote: it selects shell files by EXTENSION, so until `mise-tasks/*` were renamed to `.sh` it had never read one of those 136 programs. The rename turned it on and 1379 pre-existing `[` tests became new code at once. Nothing was newly wrong; a light came on. That is the fourth instrument this tree has caught selecting by extension and covering nothing while exiting 0. CLOUD-310 measured the first, CLOUD-864 the second, CLOUD-382 owns the `.bats` half, and CLOUD-865 predicted a third would arrive the same way — this is the measurement rather than the prediction. THE CODEMOD IS SHELLCHECK'S OWN. SC2292 ships a populated `fix:`, so `shellcheck -o require-double-brackets --format=diff` emits the rewrite and `git apply` takes it — 1379 lines across 139 files, no hand-written sed. `install.sh` is untouched and stays that way: it is the tree's only `#!/bin/sh` program, `[[` is a bash builtin, and SC2292 is Bash/Ksh-scoped so shellcheck skips it by construction rather than by an exclusion somebody maintains. SEMANTICS CHECKED RATHER THAN ASSUMED. `[[` suppresses word splitting, makes `=` a pattern match, and changes `-a`/`-o`. Across the patch: zero `-a`/`-o`, and four `=`/`!=` with a glob-looking right side, every one of them quoted (`[[ "$2" = "*" ]]`, three `${value#*[^0-9]}`), so all four stay literal. AND IT BROKE 37 MUTANTS, WHICH IS THE HALF WORTH READING. A `#MUTANT` declaration is a sed script matching a source line LITERALLY, so rewriting the line silently unaims it — a third of the enforced set would have reported `unappliable-mutation`. Baselined against HEAD first (2 already broken, both outside `$MUTANT_GATES`), re-aimed exactly the 37 the codemod broke, and re-measured back to 2. Only the MATCH half was rewritten: `bot-issue`'s `closes-on-a-bare-key` deliberately INJECTS a `[ -n "$key" ]` as its corruption, and rewriting that would change what the mutant proves. The rule ships with its mechanism (non-negotiable rule 2): `.shellcheckrc` gains `enable=require-double-brackets`, so the existing shellcheck step refuses the next `[` at pre-commit instead of a CI run later. No new task, no new gate wiring, no second selector.
…itutes column broke Refs: CLOUD-865 Two findings, and the first one is the campaign's own thesis arriving on schedule. MAIN LANDED TWO NEW EXTENSIONLESS SHELL PROGRAMS while this branch was open — `board-sweep` and `ci-slow-needed` — so `board-sweep` shelled out to `mise-tasks/released`, got nothing, and took its could-not-look branch. Five bats cases red. The convention regrows the moment nothing refuses it, which is the whole argument for gating it rather than renaming once. THE GATE CAUGHT BOTH, LOCALLY, BEFORE CI. `batten enforce` over this tree: shell-hygiene shebang-names-its-language x2 shell-hygiene sibling-resolves x6 Two shebangs with no extension, and six constructed siblings naming files the tree no longer carries — `$here/released`, `$here/in-progress-drain`, `$here/done-pr-check`, `$here/spec-ref-check`, and the loop that probes them by name. That is CLOUD-865's "shown able to fail" satisfied by a live case rather than a fixture, and `sibling-resolves` earning its place on the first foreign change it saw: every one of those references is guarded by a test that exits 0, so without the rule they would have gone quiet rather than red. `ci-slow-needed`'s probe list is the subtler half. It asserts that a change to `mise-tasks/land` and `.claude/hooks/git-hook` must NOT be treated as inert to the hk slow tier — but both paths stopped existing, so the probe was asking about files the tree does not have and would have passed on a list that had quietly stopped covering anything. AND THE RUST TEST. `a_kind_only_accepts_its_own_scopes` builds a minimal row per (kind, scope) from `requires()` and asserts it validates, with an explicit arm for each "one of" that a flat column list cannot express — Receipt's pattern, Document's pattern, Policy's module and tree-scoped documents. `substitutes` added a sixth: a pipeline row carries either the discard pair or the substitution list, so neither is in `requires()` and the generic row had neither. Given the same treatment, in the same idiom, beside the other five.
…casualties Refs: CLOUD-864, CLOUD-865 THE ROW WAS WRONGLY REFUSING, which AGENTS.md calls a defect rather than a strict reading. `sed` reads a file two ways and only one has a first-class equivalent: `sed -n '1,40p' f` prints a range, which is `Read(offset, limit)` and the most-measured shape in the corpus, while `sed 's/a/b/' f` TRANSFORMS the stream, which no tool does at all. The row denied both and told the caller "a first-class tool answers this directly" — false for the second, and a gate whose stated reason does not hold teaches the wrong lesson. A `substitutes` entry may now be qualified: `sed:-n` selects only when the flag is present. It mirrors the `requires_flag` the `[[verb]]` table already carries for exactly this distinction on `sed -i`, rather than inventing a second vocabulary. Bundled and value-carrying spellings still select (`-ne`), and a long option that merely contains the letter does not (`--posix`) — a `contains` over the whole token would have read that as the flag. The other eight entries read a file or list paths in every mode they have and stay unqualified. TWO CORRECT ROWS NOW OVERLAP, and the tests had to say which one they mean. `sed -n 1p <memory>` is a READ to the verb table — the property `mediated_verbs.rs` exists to pin — and a substitution deny to the new row. An assertion on the aggregate exit code would report one rule's arrival as the other's regression, so those cases assert `assert_not_refused_as_a_write` instead: every protected-path refusal carries its `redirect` and those all end in `_memory`, so the absence of that token is the verb table's verdict on its own. `rm <ordinary>` stays the strong assertion, since neither row touches it. AND THREE MORE THINGS THE RENAME BROKE, none reachable by a textual pass: - `serena-mcp.sh` names the server it fronts by stripping `-mcp` from its own basename. That basename now ends in `.sh`, so nothing stripped and every ledger record would have read `serena-mcp.sh`. The extension comes off first. - `tests/land-lock.bats` builds `$(dirname "$LOCK")/task-registry` at run time. `sibling-resolves` does not read `.bats` — `line_sources` is `mise-tasks/**`, `.claude/hooks/**`, `*.sh` — so the rule that exists for this shape was one directory away from it and only executing the suite found it. - `board-sweep`'s probe loop and `ci-slow-needed`'s inert list, both landed on main after the rename, in the commit before this one.
Refs: CLOUD-864, CLOUD-865 All three were found by pointing the rules at this tree rather than at the fixtures written for them, and all three refuse work that is correct — which AGENTS.md calls a defect rather than a strict reading. A REGEX IS NOT A PATH, and the exclusion had to be widened twice before it held. `grep`, `rg` and `sed` take their pattern as the first non-flag operand, so the "first operand that looks like a path" scan reaches the PATTERN first and names it as the target: right in verdict, wrong in every pointer it gives, and a pointer is the only thing a caller can act on. Three live misfires, each escaping the previous fix — `2>/dev/null` (a redirection), then `a|b|%.bats|c` (an alternation, with `.bats|c` read as an extension), then `\)/[A-Za-z0-9_][A-Za-z0-9._-]*`, which carries no `|` at all and reaches the scan through a `/` inside a character class. The test is now over the metacharacter SET rather than one member: a backslash, an anchor, a group or a quantifier brace never appears in a tracked path, while `*` and `?` deliberately still do — `ls mise-tasks/*.sh` is a glob aimed at the tree and `Glob` is what answers it, so excluding those would have opened the hole the row exists to close. A SIBLING MAY LIVE IN A SUBDIRECTORY. `sibling-resolves` captured a name up to the next separator, so `"$(cd "$(dirname "$0")" && pwd)/render/cli.sh"` resolved `mise-tasks/render` — a directory, which `input.tree.tracked` has no entry for because it carries files. A correct reference reported missing. The capture crosses `/` now, with both directions cased: the real reference passes, and the same line with nothing behind it is still a finding. AND THE CRATE CARRIED A CONSUMER TOKEN. A doc comment reached for a long-option example whose name is in the consumer's own table, which `the_crate_bakes_in_no_ consumer_vocabulary` refuses under non-negotiable rule 1. Reworded to an example that names nothing outside the crate. The gate was right and the prose was lazy.
Refs: CLOUD-865 `suite-bench` and `suite-bench-check`, landed on main during this branch's last rebase. That is the THIRD pair in one session — `board-sweep` and `ci-slow-needed` arrived the same way two rebases ago — and the count is the argument rather than an inconvenience: a convention with no gate regrows as fast as anyone writes a new task, and every one of those authors was doing the ordinary thing correctly by the tree they cloned. Caught by `shellcheck` this time rather than by an execution failure, because `.shellcheckrc` now enables SC2292 and the new files carry `[ ]` tests: the gate that landed one commit ago refused main's next contribution to the same class before any suite ran. That is the mechanism working in the direction it was built for. The same four steps as the other two pairs, in order: rename, re-point the references (`tests/suite-bench-check.bats`'s `GATE` and its `# subject:` line — `mise.toml` and `hk.pkl` name TASKS and need none), run shellcheck's own codemod over the two files, re-check the mutant declarations. The suite passes 8/8 and no tracked path under `mise-tasks/` is extensionless. AND FOUR DECLARED PREDICATE MOVES. Four rules carry a `glob` naming their own check's file — `claim-not-raced`, `evaluator-closure-io-free`, `release-attestation-precondition`, `release-tracking-check` — so the rename moved four predicates, and `config-lint` refuses an undeclared move against `origin/main`. That is the gate working as written rather than a false positive: its own doc says the kind is "reported as a change, never as a ranking", because whether one glob is narrower than another is a judgement the module refuses to make while "the predicate moved at all" is a byte comparison. So the moves are declared rather than argued away. Each glob names the same file it always did, under the name that file now has; no rule's reach changes. The declaration travels as a trailer because that is the source this gate can read — there is no claim receipt in a fresh container to cross-check a groomed body against, and rewriting CLOUD-865's Ready block mid-work is the shape `claim-check` refuses outright. Weakens: rule-predicate-changed rule[claim-not-raced].glob Weakens: rule-predicate-changed rule[evaluator-closure-io-free].glob Weakens: rule-predicate-changed rule[release-attestation-precondition].glob Weakens: rule-predicate-changed rule[release-tracking-check].glob
Refs: CLOUD-864 CI refused the branch on `perf`: `wired` went 9.13ms -> 17.71ms p50, 1.94x against a 1.30x gate. `wired` is `batten hook` as `.claude/settings.json` invokes it — every tool call, and the one path with a p95 budget. `Policy::from_resolved` handed EVERY policy row to `policy::load`, which reads, compiles and smoke-queries each module by design (CLOUD-647, so a broken module is a config error rather than a denied call). Two of those rows are now `scope = "tree"` and cannot fire on a mediated call at all, so the hook paid full rego compilation per invocation for a verdict nothing could read. The function's own doc already promised otherwise — "the tree engine's rules are simply absent here rather than skipped per-call" — and the `shapes` field beside it filters exactly that way. Only `bundles` did not. This closes the gap the design already described rather than adding a case to it. MEASURED, paired against the merge base on one machine, and the reading is bigger than the regression: path base head ratio noop 3.12 ms 3.17 ms 1.02 check 4.27 ms 4.05 ms 0.95 hook 3.56 ms 3.82 ms 1.07 passthrough 3.39 ms 3.31 ms 0.98 wired 60.26 ms 12.00 ms 0.20 `wired` is FIVE TIMES FASTER than the merge base, which is the part worth reading twice: this was not my regression being undone. Trunk has been compiling tree-scoped modules on every mediated call for as long as those rows have existed, and the cost grew with each one enabled. Two new modules pushed it past the threshold and made a standing defect visible. (Absolutes here run ~6x the CI runner's — the container is loaded — which is exactly why the gate decides on a ratio.) What this gives up, stated rather than absorbed: a broken TREE-scoped module is no longer a config error at hook time. It still is under `batten check` and `enforce`, which is where a tree rule is evaluated and where `verify` and CI both reach it, so the module is refused before it can matter — one surface over.
Refs: CLOUD-865
The external analyzer's seven `failure`-level findings on new code, each the
same idiom: `case X in <bad>) refuse ;; esac`, where every other value falls
through to the success path.
Falling through is the right behaviour in all seven and none changes — an
unmatched `case` already does nothing. What changes is that the decision is now
written down. That is this repository's own posture applied to its own scripts:
an unhandled value falling out of a validation `case` reads identically whether
it was decided or forgotten, which is the shape every "could not look is not a
pass" rule here exists to refuse.
filed-here-check three, and the two the analyzer did not flag are the
same idiom in the same function — leaving them would
have been arbitrary
reclaim-census digits fall through to the print
ready-guard a non-matching adjacent pair slides the window on
ci-lease-precondition every branch that is not a bot branch IS judged, which
is the default and the reason the exemption is a short
named list
board-move-guard an epoch falls through to the age bounds
release-tracking-check a third subcommand is neither error nor evidence
land-lock-check a parsable epoch falls through to the holder check
Not enabled as a shellcheck check: 67 sites remain tree-wide and
`add-default-case` ships no applicable fix — its `--list-optional` `fix:` line
is an illustration, not a generated patch, so `--format=diff` emits nothing.
Converting those by hand is its own change with its own review, and doing it
inside a PR this size would bury it.
Refs: CLOUD-865 Measured with `gh` after the analyzer refused #638 and three hypotheses about its content turned out to be guesses. `final` completed 25-29 seconds BEFORE Sonar even started on each of the last three merged PRs. `sonar-gate` therefore read *absent*, which is a pass by design, and every one of them landed with a `failure` Sonar check-run on its head. The verdict this gate hands a branch is a function of how long that branch's own CI takes, not of what the analyzer found. `main` sits at C Security Rating on roughly every other trunk commit, and nothing surfaces it: no push-to-`main` workflow exists to read the verdict, and on PRs the race swallows it. #638 is the first branch slow enough — 300+ files plus the `perf` job — for Sonar to answer first. It did not make the analyzer red; it made it audible. The memory also records the two things that cost the most time getting there, because both look like data and are not: * A private project answers an unauthenticated caller with `Project doesn't exist` — a denial wearing a 404 — so an empty `api/issues/search` from there is not a zero. * Check-run annotations ARE reachable (`gh api …/annotations`, which the GitHub MCP tool does not expose) but are capped at 50 and truncated silently. Truncation is provable by arithmetic rather than trust: one run showed 50 of which 42 were a single rule, and clearing that rule left 50 again rather than 8. A security-rated issue can sit wholly outside the window, which is exactly what happened here. No gate or task changes: the race and the standing red are trunk's, and a branch that patched either in passing would be fixing the repository from inside a rename.
Refs: CLOUD-897 CLOUD-441 put `sonar-gate` in `final` so a failing quality gate could block a land. It never blocked one. `sonar-gate` reads the analyzer's check-run by name and ABSENT is a pass by design, and `final` has been finishing before the analyzer starts: #648 final done 04:56:37Z analyzer started 04:57:06Z (+29s) #647 final done 04:46:24Z analyzer started 04:46:52Z (+28s) #646 final done 04:14:51Z analyzer started 04:15:16Z (+25s) All three landed with a `failure` analysis on their head. The bounded retry never ran either: it retries on exit 3 (pending), and absent returns 0 on the first look. The verdict a branch received was a function of how long its own CI took, which is not a verdict. Removed rather than softened. A step that cannot decide has no business in the one job branch protection requires, and leaving it there as an advisory would keep a red annotation on every run while deciding nothing — the sensor-only shape non-negotiable rule 2 refuses. Dropped from `verify:gated` in the same change, which is what keeps `ci-local-parity` true: verify ran it because CI ran it. `mise run sonar-gate` still exists and still works; nothing in the landing path reads it. CLOUD-897 owns restoring it, and only alongside CLOUD-528: arming a deterministic gate while `main` sits at C Security Rating would refuse every PR in the repository. `ci-local-parity` and `actionlint` green.
c757a33 to
5c510fa
Compare
|
|
/fast-forward |
|
The premise this PR demoted This PR's body says the gate refused it for
Across the last 14 merged PRs (#631–#650), not one merged head carried a PR-scoped analysis. That, and Generated by Claude Code |
…bly wrong `workflow/sonar-gate-race` landed in 5c510fa teaching two things that do not hold. Its table of "final finished before the analyzer started" is three rows of main's BRANCH-scoped analysis posting onto the identical SHA after the fast-forward — the PR analysis lands within ~20s of the push. And its first instruction, check `main` and treat a Sonar refusal as not yours if trunk is red, would have waved through the one real finding this repo has seen: #638's D was `pullRequest=638`, its own new code, while trunk's C is `branch=main` and cannot enter a PR verdict. Replaced by `mem:workflow/sonar-scope`, which keeps everything the old file got right — the 404-as-denial, the silent 50-annotation cap and its arithmetic proof, annotations being invisible to the MCP tool — and adds what reading `details_url` shows. Refs: CLOUD-897 Refs: CLOUD-528




Closes CLOUD-864. Closes CLOUD-865. Refs CLOUD-897.
One PR, five commits, because the three pieces are one change: the rename is what makes Serena reach shell, the engine row is what makes reaching for the wrong instrument fail, and the last two commits are the rename's aftermath — which turned out to be the largest part.
Why
Measured over this session's own transcript: 34 of 38 Bash calls had a first-class tool substitute, and
Read/Grep/Globwere used zero times. 15head -N, 13grep, 12ls, 5cat, 5sed -n, 2find -name.The cause is not Serena-specific and not a cost problem —
Read/Grep/Globare eager and free. It is the shell-as-terminal default this repository already named inmise-tasks/run-shape-guard.sh, for a different symptom family, and which CLOUD-199 generalised once already. This is its second extension.1. Make the instrument reachable —
c560f8d.serena/project.ymllanguages:was[rust]. Now[rust, bash, yaml, toml, markdown].bashgoes in before it can match anything, deliberately:Language.BASHisFilenameMatcher(".sh", ".bash")(solidlsp/ls_config.py:474), extension-only, so it covers zero files whilemise-tasks/*are extensionless — and starts covering ~29k lines the moment the rename below lands. Declaring it afterwards would mean doing that rename without the instrument it buys.initial_promptwas''; upstream documents it as the only content always given on activation. Now one line — a budget fact, not a style choice.[budget.instructions]counts it against the same ceiling as AGENTS.md, and lines bind, not tokens: 197/199 before, 198/199 after. A second line sits exactly on the ceiling.skills/serena/SKILL.md+ vendor symlink is the dispositional half, triggering on navigating code — not on Serena being broken, which is the triggermem:serena-setupalready owns and the reason nothing fired.skill-checkwas a coverage claim with no coverage.hk.pklinvoked it with no arguments, so it defaulted toskills/batten/SKILL.mdwhile globbingskills/**. A second skill fired the step and was never read. Now it loops over every skill for the two predicates that generalise.The bug inside that fix, kept in the record: the first loop asked
git ls-files. Deleting the new skill's symlink and re-running reported clean — a skill is untracked until staged, so the gate skipped the one most likely to be wrong. Discovery is now a filesystem glob, because the harness loads a skill for being there.2. The engine row —
3c739feRuleKind::Pipelinegainssubstitutes, plus the upstream-terminator read. No existing kind could express this:matching_shape_rowsiterates every segment with no index, so ashaperow overgrepwould denygit ls-files | grep crates/batten— ordinary work.sed -n '1,40p' AGENTS.mdgit ls-files | grep crates/battenmise run verify >/tmp/verify.log 2>&1verdict-not-discardedmandatesClause (b) of the spec — fire only where a name-resolving instrument covers the path — is dropped, not deferred. The
Read/Grep/Globfloor is correct on 100% of the tree with no language server at all, so there was nothing for an attach-state fact to gate. The fact model is untouched.3. The rename and its gate —
ff3b79c138 programs renamed to
.sh, 788 reference sites updated.shell-hygieneis the first tree-scoped preset, deciding overinput.tree.linesbecause the thing it judges is a file's first line. 138 findings → 0.4. What the rename broke, and the gate for it —
61619e6,1a1be30verifywent red in eight suites, ~70 cases. Every failure was a reference no textual pass can see, and in five of six spellings the broken reference fails open —[ -x "$p" ] || exit 0, or a glob that matches nothing.stop-guard.shcarried six, so the Stop hook ran four rules over nothing and exited 0.$(dirname -- "${BASH_SOURCE[0]}")/xhere=$(cd "$(dirname "$0")" && pwd)then"$here/x""$(dirname "$0")"/*-guard— a suffix globsrc="mise-tasks/$gate"— task name vs file nameready-lint/board-diff-overlapregex anchorsSo the rule ships with the class it created:
sibling-resolves, a second module inshell-hygiene, resolves a constructed sibling againstinput.tree.trackedacross all four spellings, against the judged file's own directory. Excluded by test, each a real line here: a$(git rev-parse --git-dir)/…path, a variable holding the parent directory, anddirname "$file".repo_relative_pathalso gained two exclusions — both defects this row committed against its own author within the hour it landed.tail -40 batten.toml 2>/dev/nullrefused naming2>/dev/null;grep -E 'a|b.bats' …refused naming the pattern. Right in verdict, wrong in every pointer they gave.Dependency change
Enables
regorus/regex.sibling-resolvesreads raw lines, and without the regex builtins the only way to extract a capture in Rego is a hand-rolled character walk — a parser in a policy language.regorus/regexisdep:regexat1.12.3and this workspace already depends onregex = "1"directly, so it adds a lockfile edge and no package, and none of the namesevaluator-closure-checkrefuses.regexperforms no I/O, sono_evaluator_feature_admits_iois unchanged. Argument recorded inCargo.tomlbeside the existingast/httpones.Fixtures that must NOT be renamed
tests/fixtures/board-diff-overlap/tracked-at-filing.txtis a frozengit ls-filesreplay; its assertion was wrongly rewritten by the bulk pass and is restored. The rego deny fixturetools/deploymust stay a literal or the deny test becomes a pass. Both carry comments saying so.Known limit, stated rather than discovered later
Serena's bash coverage is not verifiable from this session. The server reads
.serena/project.ymlat activation and does not re-read it, soget_symbols_overviewover a renamed.shstill answersActive languages: ['rust']. The extension matcher makes the coverage a certainty; the observation belongs to the next session, as does CLOUD-670's cold-index cost. Recorded on CLOUD-864 rather than left for a reader to trip over..batsalso stays dark: 145 files,#!/usr/bin/env batsis not a shell interpreter, and this bundle does not claim otherwise. That is CLOUD-382's.Generated by Claude Code