Skip to content

A subject that was built and then cut to nothing reached no rule at all, and said nothing (#186) - #190

Merged
fdaviddpt merged 1 commit into
mainfrom
fix/186
Aug 18, 2026
Merged

A subject that was built and then cut to nothing reached no rule at all, and said nothing (#186)#190
fdaviddpt merged 1 commit into
mainfrom
fix/186

Conversation

@fdaviddpt

@fdaviddpt fdaviddpt commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

scripts/pre-tool-hook.sh builds a subject full_command from the tool_input keys, then cuts it at the first ; & | " or -- to get cmd — the command words. A command that begins with one of those bytes has no command words at all: {"command":"; git push"} leaves cmd empty while full_command was non-empty the whole time. The hook printed {} and exited before the layer loop that consults any rule.

It was a short-circuit on the wrong variable, not a policy

cmd is the subject of exactly one consumer: the substring arm of the tool matcher. Three others read something else and were skipped by a test about a variable that is none of their business.

  • The regex arm matches full_command, deliberately — that is what makes cd x && git push reachable at all, and it is what README.md's "What a tool rule is tested against" table has always promised. So a ~match rule would have matched and never ran, and because this is the only dimension that can refuse a call, a mode: block regex rule failed open on any command starting with a cut byte. Indexed, validated, counted by every diagnostic, reading as enforced, never run.
  • The vocabulary pass lifts path tokens out of command (the whole command) and never touched cmd, so ; cat src/Billing/x.php bound nothing while true; cat src/Billing/x.php bound Billing the whole time. This one is not in the issue and is what settled the design: the question stops being "should the two matching arms agree" and becomes "why is a test about the substring arm's subject gating three consumers that do not read it".
  • The per-row refusal notices need no subject at all.

The line is deleted. The substring arm needs no exit of its own: index("", term) is 0, and rebuild-tsv.sh:280 refuses a row with an empty match: column, so no row's term can be empty.

The three judgment calls the issue named

1. Should the regex arm run when the substring arm has nothing? Yes, and it is narrower than it sounds. A regex rule already matches the whole command on every other call — ~git push already fires on echo "git push" today, which is the price the README already documents for being able to anchor past a chain operator. The only calls whose verdict changes are those whose command words are empty, and on those the rule now does exactly what it does everywhere else. This is not new policy; it is the removal of an inconsistency.

2. Does this state get its own report? No, and that is a decision rather than an omission. After the fix nothing is unreached: every row is read, the regex ones are evaluated and can fire, and a substring row that does not match is an ordinary non-match — the same one true; git push has always produced silently. A notice here would fire on that entire class and would call rules unreachable that are working exactly as #7 intends. The #182 census is untouched and still gated on the whole subject; section E of the new suite drives the two states apart in one tree.

3. Is the cut itself the bug? No. Cutting at the first ; & | " is what stops a substring rule about git push firing on echo "git push" (#7). Narrowing it to keep the first command word would fix this case by reopening that one. Section C pins the cut as kept rather than quietly narrowed: git push still does not fire on ; git push, exactly as it has never fired on true; git push.

Tests

tests/test-cut-to-nothing.sh, 35 assertions across 6 sections. Every must-not-fire assertion sits beside a must-fire control in the same fixture, same tree, same hook — {} is what a dead tree, an unread layer and a hook that died all produce, and this issue is about not being able to tell those apart. Section B carries a negative control (; ls -la, a cut-to-nothing command the block rule does not describe) so it cannot pass by blocking everything. Every index is written by rebuild-tsv.sh from real frontmatter.

Red before the fix: PASS: 26 FAIL: 9, every failure a {} where a rule body was expected, every positive control green. The reviewer reproduced that count independently against 19f6c46. Green after: PASS: 35 FAIL: 0 on one-true-awk 20200816, GNU Awk 5.4.1 and mawk (PATH-shimmed, darwin 24.3.0). Full suite green, 2813 assertions, 0 skipped, exit 0.

Review

Two spawns against the committed diff. The auditor returned five classes, 0 findings, 0 could-not-check, and confirmed by reading .github/workflows/tests.yml that run-all.sh runs on ubuntu-latest, macos-latest and windows-latest — so the new suite is covered on all three legs by CI, which no local run can establish.

The reviewer found one real defect: the changelog fragment claimed 2817 full-suite assertions where the measured figure is 2813 (the first count used grep -c 'PASS:', which also counts each suite's own summary line). Corrected and amended.

Recorded rather than smoothed over: the reviewer's final message ended "beyond the one changelog number mismatch reported above" and that message contained no such statement — the item survived only because it was re-derivable. That is the partial form of the empty-return failure and it is in the report.

Not in this PR

scripts/jit-dry-run.sh:629,652 and scripts/jit-doctor.sh:553 cite line numbers in pre-tool-hook.sh that were already wrong on merged main (verified by reading them out of git show origin/main:), and this diff shifts them ~35 lines further. That is the class rather than the instance — a cross-file line-number citation convention in a repository whose files move every PR — and it wants its own issue and its own sweep.

Closes #186

Verified by the maintainer

The premise I briefed was wrong, and the author is right. I framed this as "the two matching arms disagree about a cut-to-nothing subject" and handed over the evidence for it. The author found a third consumer that exit was skipping — the vocabulary pass, which lifts path tokens out of the whole command and never read cmd at all, so ; cat src/Billing/x.php bound nothing while true; cat src/Billing/x.php bound the whole time. That reframes the defect from a disagreement between two arms into one short-circuit on a variable that is only one consumer's business, which is also why the fix is a deletion rather than a reconciliation. Nothing in my brief pointed there.

Red re-run, mine, against merged main at 19f6c46 with the fix absent: PASS: 26 FAIL: 9, every failure a {} where a rule body was expected. That is the same count the author measured and the same count their reviewer reproduced independently — three derivations, one number.

I accept the two refusals in this diff, and they are the harder half of it. Not adding a notice is right: after the deletion nothing is unreached, and a notice on this class would call working rules unreachable — it would fire on the ordinary non-match that true; git push has always produced. Keeping the cut is right for the same reason it was introduced in #7, and section C pins it as kept rather than quietly narrowed, which is the assertion I would have asked for if it were not already there.

The reviewer went partially dark again — its final message referred to "the one changelog number mismatch reported above" and contained no such statement. The item survived only because it was re-derivable by re-counting the suite log. That is the third reviewer in this session to name findings it did not state, and the author recorded it as partial rather than rounding it to clean, which is the correct call.

Filed rather than carried: #191 — the stale cross-file line-number citations in jit-dry-run.sh and jit-doctor.sh. All three were already wrong on merged main before this diff, verified by reading them out of git show origin/main:; this diff shifts them further. It is the convention that needs deciding, not the four numbers.

…ll, and said nothing (#186)

pre-tool-hook.sh builds `full_command` from the tool_input keys, then cuts it at
the first `;` `&` `|` or `"` to get `cmd` -- the command WORDS. A command that
BEGINS with one of those bytes has no command words at all, and the hook answered
`{}` and exited before the layer loop that consults any rule.

That exit was a short-circuit on the wrong variable. `cmd` is the subject of
exactly one consumer: the substring arm of the tool matcher. Three others read
something else and were skipped by a test about a variable that is none of their
business -- the regex arm, which matches the WHOLE command deliberately so that
`cd x && git push` is reachable; the vocabulary pass, which lifts path tokens out
of `command` and never touched `cmd`; and the per-row refusal notices, which need
no subject. So a `mode: block` regex rule failed OPEN on a command beginning with
one of the cut bytes: indexed, counted by every report, reading as enforced, never
run. This is the only dimension that can refuse a call.

The line is gone. The substring arm needs no exit of its own -- index("", term) is
0, and rebuild-tsv.sh refuses a row with an empty match column.

THE CUT IS UNCHANGED. A substring rule still does not see past it: a rule about
git push does not fire on a leading-semicolon command, exactly as it has never
fired on `true; git push`. That cut is what keeps a substring rule off an echo of
the same words (#7), and narrowing it to keep the first command word would fix
this case by reopening that one. Section C pins it as kept rather than quietly
narrowed.

NO NEW NOTICE, and that is a decision. After this change no row is unreached:
every row is read, the regex ones are evaluated and can fire, and a substring row
that does not match is an ordinary non-match -- the same one `true; git push` has
always produced silently. A notice here would fire on that whole class and would
call rules unreachable that are working as #7 intends. The #182 census is untouched
and still gated on the whole subject; section E drives both states apart.

tests/test-cut-to-nothing.sh, 35 assertions across 6 sections, every must-not-fire
assertion beside a must-fire control in the same tree, same hook. Red before the
fix: PASS 26 FAIL 9, every failure a bare {} where a rule body was expected, every
positive control green. Green after on one-true-awk 20200816, GNU Awk 5.4.1 and
mawk. Full suite green, 2813 assertions, 0 skipped, exit 0.

README.md gains the paragraph the table at "What a tool rule is tested against"
was already promising, and the stale comment at the vocabulary gate -- which said
this shape never reaches it -- is corrected.

Closes #186

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fdaviddpt
fdaviddpt merged commit 98386f1 into main Aug 18, 2026
5 checks passed
@fdaviddpt
fdaviddpt deleted the fix/186 branch August 18, 2026 13:15
fdaviddpt pushed a commit that referenced this pull request Aug 18, 2026
…ching (#191)

A `<file>.sh:NNN` pointer is exact the day it is written and wrong on the next
PR that inserts a line above it. It rots silently: a rotted citation reads
exactly like a live one, so the reader follows it and lands on a plausible
comment rather than on an error.

Counted on main at 98386f1, outside the assembled changelog: nine citations of
that shape. Six pointed at the wrong thing, one had drifted off the block it
named, two were still right. #191 names three of the six; the sweep added here
found the other three. #185 added one of them three hours before #190 moved it.

Every citation in an enforced file now points at something greppable -- a
function name, a distinctive literal, or the issue number, which additionally
says why rather than where.

tests/test-line-citations.sh has three outcomes rather than two. It FAILS on a
citation in a tracked scripts or tests shell file; it REPORTS WITHOUT FAILING
over every other tracked file; and it names the assembled changelog as
deliberately not swept, because that file is generated and a finding in it is
unactionable. The advisory half is advisory rather than enforced for one stated
reason: its only remaining finding lives in an entry held by PR #192, and
reddening a file this change may not edit is how a check gets disabled in its
first week. Widening it is one awk pattern.

The false-positive surface was costed before the check was written: over all 96
tracked basenames and the whole tree, 10 hits and 0 false. A bash diagnostic, a
shellcheck line, an awk error and a longer basename are driven as controls that
must not match, alongside a planted citation that must.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fdaviddpt pushed a commit that referenced this pull request Aug 18, 2026
- CLAUDE.md read as attributing the move to #191 itself, because the issue
  citation sat immediately after "the PR that moved it". #185 added it and #190
  moved it three hours later; the sentence now says so.
- scripts/jit-dry-run.sh: "which #7 is the reason for" -> "which exists because
  of #7". Accurate either way, clunky one way.

A third finding was argued down rather than applied. The reviewer recounted the
audit as 3 right / 1 drifted / 5 wrong and read the tooling.md citation as
correct, on the strength of that entry own sentence "The truncation is
pre-tool-hook.sh:127-144 and it is correct" -- where "it" is the truncation
BEHAVIOUR being deliberate, not the line range being accurate. The same range
in scripts/jit-dry-run.sh was judged wrong in the same review, which is the
contradiction: at 98386f1 those lines are the #182 subagent_type comment, and
the truncation is at 162-185. The count stands at 6 wrong / 1 drifted / 2 right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fdaviddpt added a commit that referenced this pull request Aug 18, 2026
…ses new ones (#191) (#197)

* Cross-file line-number citations rotted silently, and nothing was watching (#191)

A `<file>.sh:NNN` pointer is exact the day it is written and wrong on the next
PR that inserts a line above it. It rots silently: a rotted citation reads
exactly like a live one, so the reader follows it and lands on a plausible
comment rather than on an error.

Counted on main at 98386f1, outside the assembled changelog: nine citations of
that shape. Six pointed at the wrong thing, one had drifted off the block it
named, two were still right. #191 names three of the six; the sweep added here
found the other three. #185 added one of them three hours before #190 moved it.

Every citation in an enforced file now points at something greppable -- a
function name, a distinctive literal, or the issue number, which additionally
says why rather than where.

tests/test-line-citations.sh has three outcomes rather than two. It FAILS on a
citation in a tracked scripts or tests shell file; it REPORTS WITHOUT FAILING
over every other tracked file; and it names the assembled changelog as
deliberately not swept, because that file is generated and a finding in it is
unactionable. The advisory half is advisory rather than enforced for one stated
reason: its only remaining finding lives in an entry held by PR #192, and
reddening a file this change may not edit is how a check gets disabled in its
first week. Widening it is one awk pattern.

The false-positive surface was costed before the check was written: over all 96
tracked basenames and the whole tree, 10 hits and 0 false. A bash diagnostic, a
shellcheck line, an awk error and a longer basename are driven as controls that
must not match, alongside a planted citation that must.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Address the audit: an advisory sweep that could report coverage it never had (#191)

Four findings from the self-review, all in tests/test-line-citations.sh unless
noted:

- The advisory sweep had no floor guard on its file count, so a broken selector
  would print `Clean across 0 file(s)` and exit 0 -- byte-identical, to
  run-all.sh which reads only the exit code, to a genuinely clean tree. It now
  takes the same `< 10 -> exit 2` floor the enforced sweep already had.
- The enforced selector was `[^/]*`, so a shell file landing in a subdirectory
  one day would fall silently into the report-only bucket and stay there. Now
  `.*`, which closes the shape rather than half of it.
- A tracked path that is not a readable file was skipped in silence in both
  loops, while the header above claimed a count the sweep never reached. Both
  are counted now; an unreadable path in the enforced set fails the suite.
- The needle accepts an optional directory prefix, and no enforced file
  exercises that branch -- every real citation was bare-basename -- so a dead
  prefix branch would have read as clean. It has its own positive control now.

scripts/jit-dry-run.sh named `jit_log_name()` and `jit_row_id()` as being "in
pre-tool-hook.sh". They are defined in common.sh and only called there, which
is an anchor wrong in the same way the line numbers were. Reworded to what is
true: pre-tool-hook.sh routes every refused row through them.

The 96-basename and 10-hit figures are now pinned to 98386f1 in all three
places that carry them, rather than stated in the present tense. They were
already stale by two the moment this branch added a file, which is the exact
failure mode the change is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Two prose findings from the re-review (#191)

- CLAUDE.md read as attributing the move to #191 itself, because the issue
  citation sat immediately after "the PR that moved it". #185 added it and #190
  moved it three hours later; the sentence now says so.
- scripts/jit-dry-run.sh: "which #7 is the reason for" -> "which exists because
  of #7". Accurate either way, clunky one way.

A third finding was argued down rather than applied. The reviewer recounted the
audit as 3 right / 1 drifted / 5 wrong and read the tooling.md citation as
correct, on the strength of that entry own sentence "The truncation is
pre-tool-hook.sh:127-144 and it is correct" -- where "it" is the truncation
BEHAVIOUR being deliberate, not the line range being accurate. The same range
in scripts/jit-dry-run.sh was judged wrong in the same review, which is the
contradiction: at 98386f1 those lines are the #182 subagent_type comment, and
the truncation is at 162-185. The count stands at 6 wrong / 1 drifted / 2 right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* README said "Two suites are the exception" and this change made it three (#191)

The test section enumerates the suites that are about this repository rather
than the hooks, and the enumeration was a count. Adding tests/test-line-citations.sh
made the sentence wrong in the quietest possible way -- a reader counting two
and finding three has no reason to think anything is missing.

Named rather than counted where it can be: the third one is called out by name
and by what it refuses, and the "assembler they are about" clause now says
which two it means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Rebase onto 5b46095: two citations #194 wrote while this branch was open (#191)

CI caught these, and the check that caught them is the one this branch adds.
Neither is a defect in this diff -- they are in tests/test-session-markers.sh,
section J, which #194 (issue #177) added to main after this branch was cut.

- `session-start-hook.sh:32 parses the payload with jit_json_fields +
  jit_session_key` -- the line number was ALREADY WRONG on the day it was
  written: line 32 is mid-sentence in the LC_ALL paragraph, and the parse claim
  is three lines earlier. The comment names its own functions, so the number
  was carrying nothing the prose did not already carry. Dropped.
- `the comment at session-start-hook.sh:25-29` -- accurate today, and a pointer
  to a comment rather than to code, which is the shape most likely to move.
  This one wanted more than a rename: it is a claim about TWO hooks agreeing,
  so it now names the pair of functions the claim is about and says which half
  of the claim this leg proves.

The counts pinned to 98386f1 are re-pinned to 5b46095 in all three places that
carry them, and the second measurement is kept beside the first rather than
replacing it. Nine citations at 98386f1, eleven at 5b46095; 96 basenames / 10
hits / 0 false, then 98 / 12 / 0. Pinning was already the right call and the
rebase is what proved it: the pin went stale in under a day and SAID SO,
where a present-tense number would have read true and been wrong.

That interval is now the argument the suite header leads with. The citations
this check first went red on in CI were not the ones it was written for, they
were written by somebody else while it was in review, and one of the two was
born broken. A cleanup would not have caught either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* The advisory half said it was blocked on #192, which has landed (#191)

A reason that has been spent reads exactly like a reason that still holds --
which is the defect class this whole branch is about, in the file that exists
to refuse it. The suite printed "advisory only while #192 holds one of the
files it would flag" on every run, and #192 merged at eb9cf71.

Replaced with what is true: widening is a scope decision nobody has taken, not
a blocked one. Deliberately NOT widened here. It is unrequested scope on a pull
request already in review, and the question it settles -- whether every future
doc, template, example and jit-context entry is bound by this rule, in every
installed project's contributor path -- is a design decision this branch was
not briefed to make. Filed for the maintainer with the exact cost instead: two
awk alternations in the selectors, one line in the entry currently flagged,
and zero measured false positives on that surface across two commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Florian DAVID <fdavid@digital-village.fr>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A subject that was built and then cut to nothing reaches no rule at all, and says nothing

1 participant