Commit d588fb2
fix(scripts): the foreign-changeset refusal names both classes, not just the collision (#18162)
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 ed7243d^ --head
ed7243d` — both refs are commits, so the scan reads history, not this
worktree's files.
```
EXIT=1
Diffing ed7243d from 9412320 (merge base with ed7243d^).
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](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 7e58d17 commit d588fb2
1 file changed
Lines changed: 162 additions & 12 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
119 | 119 | | |
120 | 120 | | |
121 | 121 | | |
122 | | - | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
123 | 125 | | |
124 | | - | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
125 | 138 | | |
126 | 139 | | |
127 | 140 | | |
| |||
511 | 524 | | |
512 | 525 | | |
513 | 526 | | |
514 | | - | |
515 | | - | |
516 | | - | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
517 | 531 | | |
518 | 532 | | |
519 | 533 | | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
520 | 563 | | |
521 | 564 | | |
522 | 565 | | |
| |||
530 | 573 | | |
531 | 574 | | |
532 | 575 | | |
533 | | - | |
| 576 | + | |
534 | 577 | | |
535 | 578 | | |
536 | 579 | | |
| |||
540 | 583 | | |
541 | 584 | | |
542 | 585 | | |
543 | | - | |
| 586 | + | |
544 | 587 | | |
545 | 588 | | |
546 | 589 | | |
| |||
553 | 596 | | |
554 | 597 | | |
555 | 598 | | |
556 | | - | |
557 | | - | |
558 | | - | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
559 | 607 | | |
560 | 608 | | |
561 | 609 | | |
562 | 610 | | |
563 | | - | |
| 611 | + | |
564 | 612 | | |
565 | 613 | | |
566 | 614 | | |
| |||
634 | 682 | | |
635 | 683 | | |
636 | 684 | | |
| 685 | + | |
637 | 686 | | |
638 | 687 | | |
639 | 688 | | |
| |||
645 | 694 | | |
646 | 695 | | |
647 | 696 | | |
648 | | - | |
| 697 | + | |
649 | 698 | | |
650 | 699 | | |
651 | 700 | | |
| |||
1348 | 1397 | | |
1349 | 1398 | | |
1350 | 1399 | | |
| 1400 | + | |
| 1401 | + | |
| 1402 | + | |
| 1403 | + | |
| 1404 | + | |
| 1405 | + | |
| 1406 | + | |
| 1407 | + | |
| 1408 | + | |
| 1409 | + | |
| 1410 | + | |
| 1411 | + | |
| 1412 | + | |
| 1413 | + | |
| 1414 | + | |
| 1415 | + | |
| 1416 | + | |
| 1417 | + | |
| 1418 | + | |
| 1419 | + | |
| 1420 | + | |
| 1421 | + | |
| 1422 | + | |
| 1423 | + | |
| 1424 | + | |
| 1425 | + | |
| 1426 | + | |
| 1427 | + | |
| 1428 | + | |
| 1429 | + | |
| 1430 | + | |
| 1431 | + | |
| 1432 | + | |
| 1433 | + | |
| 1434 | + | |
| 1435 | + | |
| 1436 | + | |
| 1437 | + | |
| 1438 | + | |
| 1439 | + | |
| 1440 | + | |
| 1441 | + | |
| 1442 | + | |
| 1443 | + | |
| 1444 | + | |
| 1445 | + | |
| 1446 | + | |
| 1447 | + | |
| 1448 | + | |
| 1449 | + | |
| 1450 | + | |
| 1451 | + | |
| 1452 | + | |
| 1453 | + | |
| 1454 | + | |
| 1455 | + | |
| 1456 | + | |
| 1457 | + | |
| 1458 | + | |
| 1459 | + | |
| 1460 | + | |
| 1461 | + | |
| 1462 | + | |
| 1463 | + | |
| 1464 | + | |
| 1465 | + | |
| 1466 | + | |
| 1467 | + | |
| 1468 | + | |
| 1469 | + | |
| 1470 | + | |
| 1471 | + | |
| 1472 | + | |
| 1473 | + | |
| 1474 | + | |
| 1475 | + | |
| 1476 | + | |
| 1477 | + | |
| 1478 | + | |
| 1479 | + | |
| 1480 | + | |
| 1481 | + | |
| 1482 | + | |
| 1483 | + | |
| 1484 | + | |
| 1485 | + | |
| 1486 | + | |
| 1487 | + | |
| 1488 | + | |
| 1489 | + | |
| 1490 | + | |
| 1491 | + | |
| 1492 | + | |
| 1493 | + | |
| 1494 | + | |
| 1495 | + | |
| 1496 | + | |
| 1497 | + | |
| 1498 | + | |
| 1499 | + | |
| 1500 | + | |
1351 | 1501 | | |
1352 | 1502 | | |
1353 | 1503 | | |
| |||
0 commit comments