Skip to content

fix(scripts): the foreign-changeset refusal names both classes, not just the collision - #18162

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-18160-foreign-changeset-remedy
Sep 14, 2026
Merged

claude[bot] merged 1 commit into
mainfrom
claude/issue-18160-foreign-changeset-remedy

Conversation

@claude

@claude claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #18160

Ruling D on #17712 (maintainer, 2026-09-14), implemented as ruled: text only. --diff-filter=MD, --no-renames, the merge-base derivation, the changeset-release/main exemption and the job-level skip-changeset exemption are byte-for-byte unchanged. No label (B refused), no diff-shape allowance (C refused). Clause-②: no.

What changed

scanForeign() refused every foreign M/D row with one remedy — rename yours; restore theirs from base. That is right for the collision the gate was built for, and wrong for a PR that changed behaviour a pending release note describes and corrected that note in the same stroke: following it restores a sentence the same PR has just made false.

The refusal now names two classes with opposite remedies, and routes the second to the confirmation path the report already described instead of to the restore.

One source, two renderings: FOREIGN_TWO_CLASS_LINES is indented into the human body, and FOREIGN_TWO_CLASS_TEXT (the same array joined) is what the ::error annotation carries.

Acceptance

1 — the refusal names both classes

Printed body (excerpt, from the run in row 3 below):

Two things produce this refusal and their remedies are OPPOSITE, so read which one you are before you act.
  COLLISION -- you and another PR drew the same changeset filename, and yours overwrote theirs. Remedy: rename yours; restore theirs from base.
  DELIBERATE CORRECTION -- your change may have made this PENDING release note false, and you rewrote it in the same stroke. Remedy: do NOT restore it -- say so on the PR and get it confirmed; restoring it from the base would put the false sentence back.

The numbered procedure below it is now labelled Concretely, for the COLLISION class:, and the closing paragraph is the class-2 route: step 1 is named as the one thing not to do, and the author is sent to say so on the PR and get it confirmed. The gate stays red either way — that is what puts the decision in front of a person.

2 — annotation and body pinned equal

The self-test does not compare each rendering against its own constant (that passes straight through the drift). It reads the block back out of the rendered body, normalises it to one line, and requires equality with the annotation's text:

D two-class: the body block and the annotation text are EQUAL once normalised -- neither rendering may drift from the other

Ablation, from the committed state, proving the pin can fail: revert the annotation to Remedy: ${FOREIGN_REMEDY}. alone.

injected: 1  removed: 0
MUTATED-EXIT=1
  - D two-class: the annotation a reviewer reads on the diff carries the WHOLE two-class text, not the collision half
head_blob=ad1f15d2fb5597c2f4805a09c9c06afa6db78846
now      =ad1f15d2fb5597c2f4805a09c9c06afa6db78846
git diff HEAD empty: YES

3 — ed7243d52 driven through the gate, from real commits

node scripts/check-empty-changeset.mjs --base ed7243d52^ --head ed7243d52 — both refs are commits, so the scan reads history, not this worktree's files.

EXIT=1
Diffing ed7243d52 from 941232040 (merge base with ed7243d52^).
This PR changes a changeset it did not add:

   .changeset/aggregate-field-type-compatibility.md
     present on the merge base and CHANGED by this PR -- this is somebody else's release note

Still exit 1 — this card does not make it pass — and the text now names the second class. That commit is the load-bearing instance: at ed7243d52^ the note reads "Booleans are refused for sum / avg / min / max by the ruling's 'every other pair: refused'", and at ed7243d52 the same PR rewrites it to "Booleans (boolean, toggle) are NOT in the 'every other pair' default". Restoring theirs from base would republish the first sentence.

4 — self-test green, new battery registered, roster floor moved

node scripts/check-empty-changeset.mjs --self-test   EXIT=0
✓ check-empty-changeset --self-test: 159 assertions over real temp git repos (real scan() path)

New battery D (#18160): the refusal names BOTH classes, body and annotation pinned equal at 12 cases; SELF_TEST_BATTERY_FLOOR 22 to 23. A battery that never ran stays distinguishable from one that passed — ablation raising that battery's floor to 13, from the committed state:

MUTATED-EXIT=1
  - self-test battery "D (#18160): the refusal names BOTH classes, body and annotation pinned equal" registered 12 case(s), below its pinned floor of 13
head_blob=ad1f15d2fb5597c2f4805a09c9c06afa6db78846
now      =ad1f15d2fb5597c2f4805a09c9c06afa6db78846
git diff HEAD empty: YES

Both ablations restored with git checkout HEAD -- path and verified by blob hash against HEAD, not by an exit code.

5 — no net change to the number of PRs the gate refuses (a reading, not an assertion)

Replayed the whole 30-day changeset-touching population of origin/main through both implementations in one process — the post-change scanForeign from this branch and the pre-change one from a detached worktree at origin/main (0ffb4963e is the commit that last touched the script) — judging each commit at base = commit^, head = commit:

population        2020
identical verdict 2020
divergent verdict 0
refused (new gate) 28
refused (old gate) 28

The reading fires rather than reading zero against nothing: 28 commits are refused on both sides, and the refused rows (status letter plus path) are equal commit by commit, not merely the counts.

Whole-CLI leg, exit codes rather than the scan's return value, over the 28 refused commits plus 12 changeset-touching controls:

40 SAME    (new gate vs pre-change gate)
28 new=1   12 new=0

The 12 zeros are the control that the harness is not simply printing 1.

A correction to the card's census

The card's window reading does not reproduce, and the direction is against the card's own modesty. node scripts/pm/git-history.mjs count --days=30 --path=.changeset on origin/main at 394ccf549 answers 2020 commits (exit 0, floor 2026-08-08), not 429. The card's 429 matches a 7-day window, which answers 446 today.

Re-deriving the foreign population over the real 30 days: 28 commits carry a foreign M/D on a .changeset/*.md, of which 24 are not Version Packages. The class the old remedy misrouted is therefore materially larger than the five the card names, and it includes a recurring shape the card does not — commits whose entire subject is correcting a stale pending changeset (docs(changeset): the STACK_* codes ARE in ERROR_CODE_LEDGER now — correct the stale sentence …, docs(changeset): correct the share-link refusal log-volume sentence, and others). Nothing here re-opens the ruling; it only means D lands on more traffic than it was scoped against.

Release surface

skip-changeset. The diff is one file, repo-root scripts/check-empty-changeset.mjs. Measured rather than assumed: the root manifest is private: true, and of the 70 non-private package manifests under packages/, every files[] entry is package-local (dist, README.md, CHANGELOG.md, plus spec's generated artefacts) — none escapes its own directory and none names a scripts path, so no tarball can carry this file.

Gates

Derived from the real diff with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (34 commands; the tool took the change set from the merge base itself), then run and reconciled with --ran. Results are in the report comment on #18160.


Generated by Claude Code

…ust the collision

`scanForeign()` refused every foreign `M`/`D` with one remedy -- "rename yours;
restore theirs from base". That is right for the collision the gate was built
for, and wrong for a PR that changed behaviour a PENDING release note describes
and corrected that note in the same stroke: following it restores a sentence the
same PR has just made false.

The refusal now names both classes and routes the second to the existing human
confirmation path instead of to the restore. One source, two renderings: the
human body indents the block, the `::error` annotation joins it into a line, and
the self-test reads the block back out of the rendered body and requires it to
equal the annotation's text byte for byte, so neither rendering can drift.

Text only -- `--diff-filter=MD`, `--no-renames`, the merge-base derivation and
both exemptions are untouched, and the new battery pins the deliberate-correction
shape as STILL refused.

Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Co-authored-by: Claude <noreply@anthropic.com>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 14, 2026
@claude
claude Bot marked this pull request as ready for review September 14, 2026 09:11
@claude
claude Bot enabled auto-merge September 14, 2026 09:12
@claude
claude Bot added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit d588fb2 Sep 14, 2026
37 checks passed
@claude
claude Bot deleted the claude/issue-18160-foreign-changeset-remedy branch September 14, 2026 09:38
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

Development

Successfully merging this pull request may close these issues.

fix(scripts): the foreign-changeset refusal prescribes restoring a release note the same PR made false — name the second class (ruling D on #17712)

1 participant