Skip to content

feat(gates): give the closing-keyword parity gate a --body pre-flight mode - #17552

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-17533-closing-keyword-body-mode
Sep 10, 2026
Merged

os-litant merged 2 commits into
mainfrom
claude/issue-17533-closing-keyword-body-mode

Conversation

@os-litant

Copy link
Copy Markdown
Contributor

Fixes #17533

scripts/check-closing-keyword-parity.mjs gains a third mode, --body PATH (a
lone - reads stdin), beside --list and --self-test. One file, +159/-1.

⚠️ Every card number quoted in the transcripts below is masked to #NNNNN.
Pasting the real ones into a PR body would arm exactly the auto-close this
instrument exists to warn about — code fences do not protect a body from
GitHub's parser — so the masking is not tidiness, it is the demonstration.

The gap it addresses

This repository ships one closing-keyword grammar in three spellings and grades
them for agreement. Every spelling is private to its own file: two live inside
actions/github-script blocks in workflows that deliberately never check the
repo out, and the third is a module-private function in the PM lane's hot
script. So no runnable instrument could read a PR body before the PR
existed — the only enforcement that reads one is the blocking gate that fires
after the body is already written.

This gate, however, already builds all three parsers as real RegExps from the
shipped bytes (loadParsers → parserFrom). It was one input away from being
the body reader the card asks for.

Usage and exit register

node scripts/check-closing-keyword-parity.mjs --body PATH    # a lone "-" reads stdin

  0  No registered parser binds anything in the body.
  2  At least one parser binds. NOT a failure: it is the report, and the
     caller decides.
  1  The instrument did not run: no path given, an unreadable input, or a
     parser that could not be extracted.

Why 2 and not 1 for a hit. 1 is already this gate's "the parsers disagree
/ extraction failed" — #4690's rule, a harness that could not find its subject
has verified nothing. A body that legitimately declares a close must never
report through the same code as a broken instrument, so the hit code is
distinct. Callers must not collapse "non-zero" into one meaning.

It decides nothing and blocks nothing. It is not wired into CI as a body
check and no workflow calls it with --body; its self-test cases ride the
existing Closing-keyword parser parity step in lint.yml, which already runs
--self-test and the sweep.

Design: one grammar, read, never transcribed

judgeBody(parsers, body) is the only new exported function. It runs
body.matchAll(parser.make()) for every parser without a problem — i.e. it
runs whatever loadParsers() returns and adds nothing. No new regex literal
lands anywhere in this diff.
A fourth spelling transcribed into a body checker
is precisely the defect this gate's own sweep exists to catch, and the sweep
would go red on one; the sweep verdict below shows it did not have to.

The answer is per parser and deliberately not collapsed. The three differ on
purpose in reference scope (qualified-only / same-repo / bare-only — the
header's REFERENCE SCOPE paragraph), so which parser binds is the answer an
author needs, not a boolean.

Zone-2 readings (measured on this branch)

Reading Result
the argv switch (:487-499 before the edit) switches on process.argv[2]: --list, --self-test, else run() — as quoted
^export function / ^function in the parity script repoRoot, workflowScript, parserFrom, loadParsers, sweep, judge, list, run, selfTest
grep -n "^export.*closingKeywordRe" scripts/pm/check-half-states.mjs 0 lines — module-private, unchanged by this PR
grep -c closingKeywordRe scripts/pm/check-half-states.mjs 7, not 0 — the dispatch predicted 0 for both greps. 7 counts occurrences (definition at :1786 plus six call sites and comment references); the export count is the 0. partOfRe likewise: 7 occurrences, 0 exports. The card's claim ("module-private") holds; only the arithmetic in the dispatch prompt was off
self-test, before / after 24 assertions → 30 assertions, exit 0 both times
sweep, before / after OK (3 parsers agree … sweep found 5 file(s) … all registered), exit 0 both times — identical, so the edit registered as no fourth parser
CI wiring for the self-test already present: .github/workflows/lint.yml step Closing-keyword parser parity runs --self-test and the sweep. git grep -n closing-keyword-parity -- package.json returns nothing, and the workflow's own comment says the node invocation is deliberate (GATE INVOCATION IDIOM). ⇒ ⛔ no second wiring added; package.json and lint.yml are untouched

The scope demonstration

A body built from the card's own examples, run through the new mode (numbers
masked, per the warning at the top):

$ node scripts/check-closing-keyword-parity.mjs --body demo-body.md
cross-repo-issue-closer      (qualified)   Fixes: objectstack-ai/objectui#NNN
duplicate-fix-guard          (same-repo)   Fixes: objectstack-ai/objectui#NNN
duplicate-fix-guard          (same-repo)   close #NNNNN
h7-partof-closing-keyword    (bare)        close #NNNNN

check-closing-keyword-parity --body: 4 closing declaration(s) in demo-body.md,
bound by 3 of 3 registered parser(s). …
EXIT=2

The body's three lines were a qualified colon reference, the sentence
Merging this must not VERB #NNNNN (VERB standing in for a real closing verb), and Part of #NNNNN. All three
predictions hold: the qualified reference binds in the two qualified-capable
parsers; the negation sentence binds in the same-repo and bare parsers and is
invisible to the cross-repo closer; Part of binds nowhere. No parser reads
negation
— that is the whole finding, and the mode shows it before the PR
exists rather than after.

All three exit legs, measured:

Leg Command Exit
a body that MUST hit --body demo-body.md 2
a body that MUST NOT hit --body clean-body.md (only Part of and a bare reference with no verb) 0
stdin printf … | --body - 2
no path --body 1 (usage + No body was read, so nothing was verified.)
unreadable input --body missing.md 1 (cannot read … ENOENT)
a parser that cannot be extracted run in a throwaway checkout holding copies of the three parser files with one workflow's job id renamed — no tracked file mutated 1, naming [duplicate-fix-guard] … no job \duplicate-fix-guard` with steps -- renamed? The parser cannot be located.`

Ablation (two-legged, from the committed state)

Mutation: bodyMode reads an empty string instead of the input — deliberately
judge()-independent, so every parser-vs-parser assertion stays green and only
the new cases can catch it.

HEAD blob: b5be506397adcd5c96e51d46cf8f91aee8ffa25e
on-disk BEFORE: anchor=1 marker=0     on-disk AFTER: anchor=0 marker=1
mutated blob:   349431f171aba6a216d484c7263bee070cbc578e  (differs from HEAD: yes)

MUTATED leg 1 — the hit body:  EXIT=0   ← the silent failure the mutation simulates
MUTATED leg 2 — the self-test: EXIT=1
  ✗ check-closing-keyword-parity --self-test -- 2 failure(s)
    • X11: `--body FILE` exits 2 on the negation body, got 0
    • X11: the exit-2 report names the parser that binds, got: … OK (no registered
      parser binds a closing declaration …)

RESTORE:  restored blob b5be506397adcd5c96e51d46cf8f91aee8ffa25e == HEAD blob
          git diff HEAD: empty   git status --porcelain: empty
RESTORED: hit body EXIT=2, self-test EXIT=0 (30 assertions)

The mutation script carried an EXIT/INT/TERM trap that restores the file, with absolute
paths, the substitution was proved on disk by grep counts of both the deleted
anchor and the injected marker (not by the editor's exit code), and the restore
was proved by blob-hash equality plus an empty git diff HEAD — not by an exit
code. Nothing permanent was added: the ablation lives only in this transcript.

Note the deliberate asymmetry: case X12 (a body that declares no close must
exit 0) stays green under this mutation, because an empty body legitimately
exits 0. That is correct — X11 is the leg that can fail, which is why the
two-legged proof needs the hit case and not only the clean one.

The ablation found a real defect in the new self-test itself, which the
second commit repairs. The two end-to-end cases originally spawned
join(root, SELF), and root comes from repoRoot() — the cwd. Launched from
a sibling checkout the battery spawned that tree's copy of the script and
graded a file nobody had edited, reporting got 3 (the dependency-prerequisite
exit of an uninstalled sibling) instead of the exit register under test. The
child is now fileURLToPath(import.meta.url), so the process under test is the
process that is running. Measured both ways: from a sibling checkout's cwd the
battery reported got 3 before the repair and ✓ … 30 assertions after.

Self-test cases added (+6 assertions, 24 → 30)

Driven from the existing REFS / KEYWORDS fixtures rather than invented
spellings, so a case here cannot drift away from what the same file asserts
about the same three parsers:

  • X8 the negation body binds in both bare-scoped parsers and not the qualified one
  • X9 a Part of body binds nothing (judge()'s rule 4b asserts the same string from the parser side)
  • X10 the colon spelling on a qualified reference binds in the two qualified-capable parsers
  • X11 end to end through a real process over a real file: exit 2, and the report names the parser that binds
  • X12 end to end through stdin: exit 0 on a body that declares no close

The one judgement, on the four axes

The card offered three routes: export closingKeywordRe() (and partOfRe()),
give this gate a body mode, or write a standalone body-sweep script. Real
business need
is measured, not speculative: the negation trap fired on a real
PR in this repo and armed a genuine auto-close, and the dispatch loop was
already issuing an instruction naming an instrument that could not read a body —
the demand existed before the capability. Long-term soundness favours the
body mode: this gate already owns the extraction of all three parsers, so
reading them is free and the grammar stays single, whereas exporting one
private function publishes one of three scopes — an answer narrower than the
question — from the lane's hottest script, and a standalone script is a third
file to keep in parity. Not letting AI write it wrong is the axis that
decides it: a body mode leaves nowhere to put a fourth regex and the gate's own
sweep goes red if anyone tries, while an exported grammar is the lenient-consumer
shape — N call sites free to wrap it in their own tolerance — and the mode's
refusal to collapse the per-parser scope split keeps the author reading the
truth instead of a boolean. Startup-stage focus is satisfied in the cheapest
form available: one exported function, one argv branch, no new file, no new
package script, no new CI wiring, no published surface. The axes do not
conflict here; all four point the same way. The one cost worth naming is that a
gate whose stated job was parser-vs-parser agreement now answers a second
question — mitigated because both answers come from the same loaded parsers, so
there is no second source of truth to keep in sync.

Verification

Gate families derived from the FINAL diff by
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
(no paths — the script derives its own change set), every family run, every exit
code captured before any pipe, reconciled with --ran:

dispatch-gates: change set derived from git — 1 path(s) vs merge base e526556ae
  · scripts/check-closing-keyword-parity.mjs
✓ dispatch-gates --ran: 31 derived famil(ies) accounted for — 31 run,
  0 NOT-MEASURED (a DERIVED zero — all 31 recorded an exit code and none of them is 3).

All 31 exited 0. Verdict lines from the load-bearing ones:

Command Exit Verdict line
node scripts/check-closing-keyword-parity.mjs 0 OK (3 parsers agree on all 9 keywords and both measured separators; sweep found 5 file(s) carrying the grammar across 8334 tracked file(s), all registered).
node scripts/check-closing-keyword-parity.mjs --self-test 0 ✓ … 30 assertions, 5 mutations of the shipped parsers each driven to red.
pnpm check:pm-dispatch-gates 0 ✓ dispatch-gates self-test: 1678 cases pass.
node scripts/check-self-test-wired.mjs 0 ✓ … every one of the 203 script(s) CI runs that ship a --self-test has that self-test run by CI.
node scripts/check-scripts-symbol-anchors.mjs 0 ✅ … 3168 anchors across 246 scripts resolve …
pnpm check:cross-package-test-inputs 0 OK: 28 package(s) read outside themselves, all declared …
pnpm check:nul-bytes 0 check-nul-bytes: OK (scanned 8327 text file(s) … no raw ASCII control bytes).

Beyond the derived set:

  • Repo-wide lint, not narrowed: npx eslint . --no-inline-config --format json → exit 0, 6589 files linted, 0 errors, 0 warnings, taken at 25a74a35. No scope claim is being made, because none was needed.
  • Path face: node scripts/pm/check-governed-merges.mjs --branch claude/issue-17533-closing-keyword-body-mode → exit 0, ✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list.
  • Control-byte self-scan on the edited file beyond the gate: grep -naP over the ASCII control range returned nothing (exit 1).
  • Not applicable: the 14 families dispatch-gates reports as applying "once this card's changeset exists" do not apply — see below.

Changeset: none, skip-changeset applied. Nothing published moves. The root
package is private: true, and no published package's files[] escapes its own
directory, so a repo-root scripts/ file cannot be shipped by any package;
git grep -l judgeBody returns exactly one tracked file, the script itself.

Acceptance notes

  • noted, not filed: the shipped self-test harness pattern of spawning
    join(repoRoot(), SELF) rather than the running file is a general hazard
    for any gate whose self-test re-invokes itself; this PR corrects only the
    instance it introduced, in the file it introduced it in. A tree-wide sweep for
    the pattern has no carrier PR and no owner today — successor: none — and it is
    an observation rather than a reproducible defect elsewhere, so it is noted
    here and not filed.
  • noted, not filed: docs/audits/2026-09-self-test-shape-census.md still
    records this script's self-test dispatch as NONE / DEFEATED (exit 0, printed 0 byte(s)). The selfTestReachedVerdict handshake in the entrypoint has since
    closed that; the audit is a dated census, not a live ledger, so a stale row in
    it is neither a defect nor a contract violation. Not filed.
  • The body mode is an author-facing pre-flight instrument only. Wiring it into
    CI as a body check would be a policy decision (what a hit should do to a
    PR), and this PR deliberately takes none.

This body, run through the mode it adds

The last step before opening this PR was to point the new mode at this body.
Measured before this section was appended (the number is masked here per the
policy at the top — unmasking it inside a transcript would plant a second copy
of the same declaration in the body):

$ node scripts/check-closing-keyword-parity.mjs --body pr-body.md
duplicate-fix-guard          (same-repo)   Fixes #NNNNN
h7-partof-closing-keyword    (bare)        Fixes #NNNNN

check-closing-keyword-parity --body: 2 closing declaration(s) in pr-body.md,
bound by 2 of 3 registered parser(s). …
EXIT=2

Exactly one distinct declaration — the Fixes line at the top of this body,
seen by the two parsers scoped to the bare form and invisible to the cross-repo
closer, which is correct for a same-repo reference. Every other card number in
this body binds nowhere. Re-run on the final text with this section appended,
the reading is unchanged — still 2 hits and one distinct card number, because
the masked quotation carries no digits for a parser to bind.

Clause-②: no


Generated by Claude Code

There was no runnable instrument a PR author could point at a body to
prove its closing keywords before the PR exists. The one grammar this
repo grades for agreement is spelled three times and every spelling is
private to its own file, so the only enforcement that reads a body is
the blocking gate that fires after the body is written.

This gate already builds the three shipped parsers as real RegExps from
the shipped bytes. `--body <FILE|->` runs exactly those over one body
and prints every hit with the parser id and its reference scope. No
fourth spelling is introduced -- a transcribed grammar is the defect the
sweep exists to catch, and it would go red on one.

Exit register, documented in the header: 0 when no registered parser
binds anything, 2 when at least one binds (the report, not a failure),
1 when the instrument did not run -- no path, an unreadable input, or a
parser that could not be extracted.

The case it exists for is a sentence written to prevent a close that
arms one, because no parser reads negation. Six new self-test cases
cover it: the negation body, a `Part of` body, the colon spelling on a
qualified reference, and two end-to-end drives of the mode through a
real process (a file and stdin) that grade the exit code.

Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK
Co-authored-by: Claude <noreply@anthropic.com>
…cwd's copy

The two end-to-end cases spawned `join(root, SELF)`, and `root` comes from
`repoRoot()` — the cwd. Launched from a sibling checkout the battery
therefore spawned THAT tree's copy of the script and graded a file nobody
had edited: measured, it reported `got 3` (the dependency-prerequisite
exit of an uninstalled sibling) instead of the exit register under test.

A harness that verified the wrong subject has verified nothing. The child
is now `fileURLToPath(import.meta.url)`, so the process under test is the
process that is running.

Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK
Co-authored-by: Claude <noreply@anthropic.com>
@os-litant os-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 10, 2026 — with Claude
@os-litant
os-litant marked this pull request as ready for review September 10, 2026 22:37
@os-litant
os-litant added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 66cbfa6 Sep 10, 2026
36 checks passed
@os-litant
os-litant deleted the claude/issue-17533-closing-keyword-body-mode branch September 10, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants