Skip to content

ci(partof-guard): the closing-keyword gate reads every commit message on the PR - #16371

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-16158-partof-gate-reads-commit-messages
Sep 6, 2026
Merged

ci(partof-guard): the closing-keyword gate reads every commit message on the PR#16371
baozhoutao merged 1 commit into
mainfrom
claude/issue-16158-partof-gate-reads-commit-messages

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #16158

No changeset: skip-changeset. Repo tooling only — one gate script and its wiring
workflow. Nothing is published from any package, so there is nothing for a changeset to
describe. (The label is the PM's to apply; this seat cannot.)

What was wrong

The gate judged one of the two surfaces GitHub's reference parser acts on. It read the
PR body; it never read the commit messages. This repo squash-merges, so the commit that
lands on the default branch is assembled at merge time by concatenating the branch's
commit messages — a text written by nobody and reviewed by nobody. It carries every trailer
its inputs carried, and it can contradict itself where none of its parts did.

The specimen is PR 16247's squash fc3fb7c4619, an ancestor of the default branch: three
bullets concatenated from three commit messages, a closing trailer for card 16121 sitting
inside the first bullet's body, and a third bullet that retracts a claim the first bullet
still makes. The landed message asserts and withdraws the same thing in one text.

Two existing readers both miss it, and for structural reasons rather than by oversight:

  • The body-side rule cannot see it. The contradictory text existed in no body — it was
    manufactured by the assembly — so the body was clean, correctly so.
  • The sweep's H23, which does patrol this surface, binds narrower. H23 reports only the
    Part-of-plus-closing-keyword contradiction, and this specimen declares no Part-of at
    all, so H23 is silent on exactly this shape. H23 is also post-merge and report-only.

What this does

The gate now enforces a second, strictly wider rule: no commit on the PR may carry a
card-relation trailer at all
— no closing keyword, no Part-of, no Refs, bound to any card
number. The PR body is the only carrier of the relation.

Width is what makes it enforceable. "Trailers that would contradict each other once
concatenated" is a property of an assembly that does not exist until the merge button, so it
cannot be judged commit by commit. "No trailer in any commit" is a property of one
commit — and an assembly cannot manufacture what none of its inputs contain.

No fourth parser. The relation extractors are the half-state sweep's, imported and
called at markdown: false, not re-spelled. Two things ride on that: check-closing-keyword-parity
holds three spellings of GitHub's grammar behaviourally equal, and a fourth would be one more
to keep in step; and the markdown: false reading (a commit message is not markdown, so
backticks do not neutralise a keyword there) is a measured contract of that sweep, not
this gate's call to re-make.

The judging path stays HTTP-free. The workflow gathers the commit list and hands it over
as a file path in env:; the script still makes no request.

Why the endpoint and not git log base..head

Recorded here and in the workflow comments because it is a real trade, and it overrides the
route the dispatch suggested. The commits endpoint returns exactly the set GitHub will
squash. The git walk needs the merge base present in order to exclude what is already on the
default branch, and this job checks out at depth 1 — so on a branch that has merged the
default branch back in, a shallow walk cannot perform that exclusion and would report
another author's landed trailers as this PR's. The alternatives are a deepen-until-found
loop (unbounded) or fetch-depth: 0 (a full clone to read a handful of messages). The cost
is one added read scope, pull-requests: read.

Presence semantics: an unread half can never print green

An absent, malformed or empty commit list exits 2 and names which rule judged nothing.
A wiring that forgot the commits has not seen a clean commit history — it has seen no commit
history, and the two must never print the same line. Zero rows is read as a failed gather,
not as a PR with no commits, because every PR has at least one. When a real finding and a
half-wired run coincide, the finding wins the exit code and the unread half is still
named in the output.

Verification

Head sha bbe84648f. Self-test batteries 9 → 15, cases 28 → 66.

Self-test, exit code captured before any pipe:

$ node scripts/check-partof-closing-keyword.mjs --self-test > log 2>&1; echo EXIT=$?
EXIT=0
✓ check-partof-closing-keyword self-test: 66 cases pass.

The three drives of the real gate, (a) clean, (b) the fixture, (c) nothing set:

$ PR_NUMBER=16158 PR_BODY='...' PR_COMMITS_FILE=clean-commits.jsonl node scripts/check-partof-closing-keyword.mjs
EXIT=0
✓ check:partof-closing-keyword: PR #16158 carries no Part-of/closing-keyword contradiction, and its
  1 commit message(s) carry no card-relation trailer.

$ PR_NUMBER=16158 PR_BODY='...' PR_COMMITS_FILE=fixture-commits.jsonl node scripts/check-partof-closing-keyword.mjs
EXIT=1
✗ check:partof-closing-keyword: 1 commit message(s) on PR #16158 carry a
  card-relation trailer. The PR body is the only carrier of the relation.
  commit `fc3fb7c46` ("fix(cli): report `os i18n extract` key counts off the emitted bytes (…") carries ...

$ env -u PR_BODY -u PR_NUMBER -u PR_COMMITS_FILE node scripts/check-partof-closing-keyword.mjs
EXIT=2
check:partof-closing-keyword: NOT WIRED — neither PR_BODY nor PR_NUMBER is set, so this run
was handed no pull request and judged nothing.

A fourth drive, (d) body wired but commits not, exits 2 with PARTLY WIRED. The fixture
row in (b) is not a hand-written fixture: it is the real landed squash message, read out of
this repository with git log -1 --format=%B fc3fb7c4619.

The gate also clears its own PR: run against this branch's commits and this body, it exits 0.

Ablation — three legs, each mutation confirmed on disk before reading

Every leg restored with git checkout HEAD -- <path>, proven by blob-hash equality against
the HEAD blob and an empty git diff HEAD. Direction predicted before running: red.

leg mutation self-test fixture drive
A1 RULE 2 never reports (relations.length === 0>= 0) exit 1, 14 cases red 1 → 0
A2 an empty commit list reads as clean exit 1, 2 cases red 1 (unchanged)
A3 markdown: falsetrue on the closing-keyword read exit 1, 3 cases red 1 (unchanged)

A1 is the load-bearing one: the fixture drive flips to green exactly when the rule is
removed, which is what makes the (b) reading a measurement rather than a coincidence. A2
and A3 leave the fixture at 1 correctly — the fixture's trailer sits in plain prose, so
neither the empty-list path nor code-stripping can hide it; the cases they redden are the
presence-semantics and backtick/fence delegation ones.

A first attempt at A1 used a multi-line anchor, which grep -cF counted as two lines; the
harness aborted the leg as a would-be no-op rather than reporting an unmeasured green, and
the leg was re-run with a unique single-line anchor. Recorded because the abort is the
harness working, not a flake.

Gates

All 47 commands derived by scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
against the real diff were run: 47/47 exit 0. Notable members, given what this change
touches: check-closing-keyword-parity (0 — confirms no fourth parser of the grammar was
introduced and the sweep still finds every spelling registered), check:partof-closing-keyword,
check:pm-dispatch-gates, check-self-test-wired, check-self-test-workflow-commands,
check-step-collectors, check:watch-hint-literal, check:nul-bytes,
check:required-contexts.

Lint is a narrowed scan, declared: eslint --no-inline-config --format json over the
changed script reports 1 file, 0 errors, 0 warnings (count read from the JSON output).
The narrowing excludes nothing, and that is a property of the config rather than an
assumption — eslint.config.mjs states that this repo runs one flat config which "never
enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules)
for ANY file", so this diff cannot move the verdict on any untouched file. The workflow YAML
is outside eslint's population. The repo-wide sweep is CI's run.

Control characters: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over both changed files
returns no hits, beyond check:nul-bytes being green.

Notes for review

  • One thing this seat could not verify locally: gh is not installed in the agent
    container
    , so the gather step's gh api --paginate ... --jq line was validated by
    reading and by YAML parse, not by execution. This PR's own CI run is its first live
    exercise — worth a look at that step's log before this leaves draft.
  • Blast radius. This rule reds any PR whose commits carry a card trailer. On the corpus
    H23 measured, roughly 15% of landed messages carried a closing-keyword binding, so this is
    not a rule that will never fire. It is what the ruling asked for, and the agent protocol
    already requires trailer-free commits; the remedy is never a history rewrite, and the
    finding text says so explicitly.
  • No history rewrite anywhere in the output. The finding cites the contract where it is
    already written (.claude/agents/os-dev.md, quoted verbatim and untranslated) instead of
    restating it, and names the repair the ruling names: the body states the relation once, the
    merger takes the squash message from the body.
  • synchronize was already in the trigger set, so a reworded push re-judges; edited is
    kept for the body half.
  • Governed surfaces were not touched: the diff is one script plus one workflow.

Generated by Claude Code

… on the PR

The gate judged one of the two surfaces GitHub's reference parser acts on.
It read the PR body; it never read the commit messages, and this repo
squash-merges, so the message that lands on the default branch is assembled
at merge time by concatenating them. That text is written by nobody and
reviewed by nobody: it carries every trailer its inputs carried, and it can
contradict itself where none of its parts did.

The specimen is the squash of PR 16247, commit fc3fb7c, an ancestor of
the default branch: three bullets, a closing trailer for a card inside the
first bullet's body, and a third bullet retracting a claim the first still
makes. No body-side rule could have seen it -- the contradictory text
existed in no body -- and the sweep's H23, which patrols this surface after
merge, binds narrower and is silent on it: H23 reports only the Part-of plus
closing-keyword contradiction, and this specimen declares no Part-of at all.

So the gate now enforces a second, strictly wider rule: no commit on the PR
may carry a card-relation trailer at all. Width is what makes it
enforceable -- "trailers that would contradict each other once concatenated"
is a property of an assembly that does not exist until the merge button,
while "no trailer in any commit" is a property of one commit. An assembly
cannot manufacture what none of its inputs contain.

The relation extractors are the sweep's, imported at `markdown: false` and
not re-spelled: a fourth spelling of the closing-keyword grammar would be a
fourth thing for the parity gate to hold in step, and the commit-message
reading is a measured contract of that sweep rather than this gate's call.

The workflow gathers the list and hands it over as a file path, so the
judging path stays HTTP-free by construction. The endpoint is read rather
than `git log base..head` walked: it returns exactly the set GitHub will
squash, while the walk needs a merge base this depth-1 checkout does not
have and would report another author's landed trailers as this PR's.

An absent, malformed or empty commit list exits 2 and says which rule judged
nothing. A wiring that forgot the commits has not seen a clean commit
history, and the two must never print the same line.

Self-test batteries 9 -> 15, cases 28 -> 66, with the real squash message as
the regression fixture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants