Skip to content

Commit f55efdf

Browse files
committed
fix(scripts): the foreign-changeset refusal names both classes, not just 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>
1 parent 394ccf5 commit f55efdf

1 file changed

Lines changed: 162 additions & 12 deletions

File tree

scripts/check-empty-changeset.mjs

Lines changed: 162 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,22 @@
119119
// ## The foreign changeset rule (#17712)
120120
//
121121
// A PR may not MODIFY or DELETE a `.changeset/*.md` that exists on the merge
122-
// base and was not added by this PR. Refused by name, with one remedy:
122+
// base and was not added by this PR. Refused by name, and the refusal names TWO
123+
// classes because the diff shape cannot tell them apart and their remedies are
124+
// opposite (#18160, ruling D on #17712, 2026-09-14):
123125
//
124-
// rename yours; restore theirs from base
126+
// COLLISION -> rename yours; restore theirs from base
127+
// DELIBERATE CORRECTION -> do NOT restore it; get it confirmed on the PR
128+
//
129+
// The second class is a PR that changed behaviour a PENDING release note
130+
// describes and corrected that note in the same stroke. It is refused exactly as
131+
// before -- ruling D moved no verdict, `--diff-filter=MD`, `--no-renames`, the
132+
// merge-base derivation and both exemptions are untouched -- but the single
133+
// remedy sent that author to restore a sentence their own PR had just falsified.
134+
// The measured instance is `ed7243d52` (boolean support for `sum` / `avg` /
135+
// `min` / `max`, rewriting `.changeset/aggregate-field-type-compatibility.md`,
136+
// which had said booleans were refused). Where the two classes are rendered and
137+
// how they are held equal is at `FOREIGN_TWO_CLASS_LINES`.
125138
//
126139
// Ruled 2026-09-13 (director seat, decision batch #130 item 3) on #17712, as
127140
// option A'. The census the ruling rests on, taken on `origin/main` at
@@ -511,12 +524,42 @@ function report(violations) {
511524
}
512525

513526
/**
514-
* The one remedy the #17712 ruling names, verbatim. A constant because the
515-
* self-test asserts the rendered report carries it: a refusal that names the
516-
* offending file but not the way out sends an author to read this script.
527+
* The remedy for the COLLISION class, verbatim as the #17712 ruling names it.
528+
* A constant because the self-test asserts the rendered report carries it: a
529+
* refusal that names the offending file but not the way out sends an author to
530+
* read this script.
517531
*/
518532
export const FOREIGN_REMEDY = 'rename yours; restore theirs from base';
519533

534+
/**
535+
* The remedy for the DELIBERATE-CORRECTION class (#18160, ruling D on #17712).
536+
* It is the OPPOSITE act, and that is the whole point of naming two classes: a
537+
* PR that changed behaviour a PENDING release note describes and corrected that
538+
* note in the same stroke is refused CORRECTLY -- this text changes no verdict
539+
* -- but following `FOREIGN_REMEDY` there restores a sentence the same PR has
540+
* just made false. Measured instance: `ed7243d52` lands boolean support for
541+
* `sum` / `avg` / `min` / `max` and rewrites
542+
* `.changeset/aggregate-field-type-compatibility.md`, whose base text stated
543+
* booleans were refused for exactly those four aggregates.
544+
*/
545+
export const FOREIGN_CORRECTION_REMEDY = 'do NOT restore it -- say so on the PR and get it confirmed';
546+
547+
// ONE source for the two-class remedy, rendered TWICE: as indented lines in the
548+
// human body, and joined into the single line a `::error` annotation has to be.
549+
// A reviewer who reads only the annotation on the diff and an author who reads
550+
// only the job log must be told the same thing, so the two renderings are held
551+
// equal by construction (one source) AND by assertion -- the self-test reads the
552+
// block back OUT of the rendered body, normalises it to one line, and requires
553+
// it to equal `FOREIGN_TWO_CLASS_TEXT` byte for byte (#18160 acceptance 2).
554+
const FOREIGN_TWO_CLASS_LINES = Object.freeze([
555+
'Two things produce this refusal and their remedies are OPPOSITE, so read which one you are before you act.',
556+
`COLLISION -- you and another PR drew the same changeset filename, and yours overwrote theirs. Remedy: ${FOREIGN_REMEDY}.`,
557+
`DELIBERATE CORRECTION -- your change may have made this PENDING release note false, and you rewrote it in the same stroke. Remedy: ${FOREIGN_CORRECTION_REMEDY}; restoring it from the base would put the false sentence back.`,
558+
]);
559+
560+
/** The one-line rendering of {@link FOREIGN_TWO_CLASS_LINES}, for annotations. */
561+
export const FOREIGN_TWO_CLASS_TEXT = FOREIGN_TWO_CLASS_LINES.join(' ');
562+
520563
const FOREIGN_NOTE = {
521564
M: 'present on the merge base and CHANGED by this PR -- this is somebody else\'s release note',
522565
D: 'present on the merge base and DELETED by this PR -- this is somebody else\'s release note',
@@ -530,7 +573,7 @@ function reportForeign(rows) {
530573
console.error(
531574
[
532575
'',
533-
`Remedy: ${FOREIGN_REMEDY}.`,
576+
...FOREIGN_TWO_CLASS_LINES.map((line, i) => (i === 0 ? line : ` ${line}`)),
534577
'',
535578
'A changeset filename carries no meaning, so a collision looks like nothing: the',
536579
'default word-pair names were designed for one human running the CLI at a time, and',
@@ -540,7 +583,7 @@ function reportForeign(rows) {
540583
'replaced by yours, its own CI never re-runs, and the loss surfaces at release time in',
541584
'the generated CHANGELOG, with the authoring PR long merged (#17712).',
542585
'',
543-
'Concretely:',
586+
'Concretely, for the COLLISION class:',
544587
'',
545588
' 1. Restore their file exactly as it stands on the merge base:',
546589
' git checkout <merge-base> -- <the file named above>',
@@ -553,14 +596,19 @@ function reportForeign(rows) {
553596
'only thing that consumes them, and it runs on the release PR, which this gate never',
554597
'judges.',
555598
'',
556-
'If you are deliberately correcting somebody else\'s release note, that is a decision',
557-
'about a release, not a refactor -- say so on the PR and get it confirmed, rather than',
558-
'routing around this gate.',
599+
'For the DELIBERATE CORRECTION class there is no second command to run, and step 1',
600+
'above is the one thing not to do: the note you rewrote describes behaviour THIS PR',
601+
'changed, so restoring it from the base republishes a sentence that is now false, and',
602+
'no label and no diff shape makes that safe. Correcting a pending release note is a',
603+
'decision about a release rather than a refactor -- say so on the PR, naming the note',
604+
'and what changed under it, and get it confirmed. That is the existing human path;',
605+
'this gate stays red either way, and staying red is what puts the decision in front of',
606+
'a person instead of routing around it.',
559607
].join('\n'),
560608
);
561609
for (const { file } of rows) {
562610
console.error(
563-
`::error file=${file}::${file} exists on the merge base and was not added by this PR, so changing or deleting it silently replaces somebody else's release note (#17712). Remedy: ${FOREIGN_REMEDY}.`,
611+
`::error file=${file}::${file} exists on the merge base and was not added by this PR, so changing or deleting it silently replaces somebody else's release note (#17712). ${FOREIGN_TWO_CLASS_TEXT}`,
564612
);
565613
}
566614
}
@@ -634,6 +682,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
634682
'#6129: main drift must not move the verdict, in EITHER direction': 6,
635683
'#6129, the other half: a base branch that DELETES': 2,
636684
"A' (#17712): a changeset the PR did not add is neither modified nor deleted": 29,
685+
'D (#18160): the refusal names BOTH classes, body and annotation pinned equal': 12,
637686
'#4690, one step later: no merge base at all is a failure': 1,
638687
'The consumer: this gate\'s own CI step (#6129)': 23,
639688
'The second consumer: where THIS SELF-TEST runs (#6509)': 12,
@@ -645,7 +694,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
645694

646695
// DELETING an entry silences that battery's floor exactly as effectively as
647696
// zeroing it, so the roster's own size is pinned too.
648-
const SELF_TEST_BATTERY_FLOOR = 22;
697+
const SELF_TEST_BATTERY_FLOOR = 23;
649698

650699
// The key an assertion is filed under when no battery is open. It is not a
651700
// declared battery, so it reds by the same set difference rather than silently
@@ -1348,6 +1397,107 @@ function selfTest() {
13481397
}
13491398
}
13501399

1400+
// ── D (#18160): the refusal names BOTH classes, and cannot drift ─────────
1401+
//
1402+
// Ruling D on #17712 changed the refusal TEXT and nothing else: the same
1403+
// diffs are refused before and after, so every case here asserts about what
1404+
// is PRINTED, plus one fixture that pins the strength it does not move.
1405+
//
1406+
// The equality case is the load-bearing one. Body and annotation are two
1407+
// renderings of one source, and a later author editing only the one they
1408+
// happened to be reading is exactly the drift acceptance 2 forbids -- so the
1409+
// two-class block is read back OUT of the rendered body, normalised to a
1410+
// single line, and required to equal the annotation's text byte for byte. A
1411+
// case that asserted each rendering against its own constant would pass
1412+
// through that drift without a word.
1413+
battery('D (#18160): the refusal names BOTH classes, body and annotation pinned equal');
1414+
{
1415+
const THEIRS = '.changeset/plain-donkeys-repeat.md';
1416+
const MINE = '.changeset/18160-foreign-changeset-remedy.md';
1417+
const OTHER_DECLARING = '---\n"@objectstack/cli": minor\n---\n\nfeat(cli): somebody else\n';
1418+
const render = (rows) => {
1419+
const captured = [];
1420+
const realError = console.error;
1421+
console.error = (...args) => captured.push(args.join(' '));
1422+
try {
1423+
reportForeign(rows);
1424+
} finally {
1425+
console.error = realError;
1426+
}
1427+
return captured;
1428+
};
1429+
1430+
const captured = render([{ file: THEIRS, status: 'M' }]);
1431+
const text = captured.join('\n');
1432+
1433+
assert(
1434+
text.includes(FOREIGN_REMEDY),
1435+
'D two-class: the body still carries the COLLISION remedy verbatim -- ruling D removed nothing',
1436+
);
1437+
assert(
1438+
text.includes(FOREIGN_CORRECTION_REMEDY),
1439+
'D two-class: the body carries the DELIBERATE CORRECTION remedy -- the class the single remedy misrouted',
1440+
);
1441+
assert(
1442+
/your change may have made this PENDING release note false/.test(text),
1443+
'D two-class: the body names the second class in the ruling\'s own terms, not as a generic caveat',
1444+
);
1445+
assert(
1446+
text.includes('there is no second command to run') && text.includes('get it confirmed'),
1447+
'D two-class: the second class is ROUTED to the confirmation path rather than to the restore',
1448+
);
1449+
1450+
// The equality pin, taken against the REAL rendered body.
1451+
{
1452+
const lines = text.split('\n');
1453+
const start = lines.findIndex((line) => line.trim() === FOREIGN_TWO_CLASS_LINES[0]);
1454+
assert(start !== -1, 'D two-class: the body really contains the two-class block (the pin below is not vacuous)');
1455+
const normalised = lines
1456+
.slice(start, start + FOREIGN_TWO_CLASS_LINES.length)
1457+
.map((line) => line.trim())
1458+
.join(' ');
1459+
assert(
1460+
normalised === FOREIGN_TWO_CLASS_TEXT,
1461+
'D two-class: the body block and the annotation text are EQUAL once normalised -- neither rendering may drift from the other',
1462+
);
1463+
}
1464+
1465+
const annotations = captured.filter((line) => line.startsWith('::error file='));
1466+
assert(annotations.length === 1, 'D two-class: one annotation per refused file, unchanged');
1467+
assert(
1468+
annotations[0].includes(FOREIGN_TWO_CLASS_TEXT),
1469+
'D two-class: the annotation a reviewer reads on the diff carries the WHOLE two-class text, not the collision half',
1470+
);
1471+
assert(
1472+
annotations[0].includes(THEIRS),
1473+
'D two-class: the annotation still names the file -- CONTROL that the line above is the real annotation',
1474+
);
1475+
1476+
// Strength, unchanged. The `ed7243d52` shape in miniature: the PR adds its
1477+
// OWN changeset (so `skip-changeset` is not available to it) and rewrites a
1478+
// foreign one in the same commit. Ruling D does NOT make this pass.
1479+
{
1480+
const { dir, base } = makeRepo(
1481+
{ '.changeset/README.md': '# Changesets\n', [THEIRS]: OTHER_DECLARING },
1482+
{ [THEIRS]: DECLARING, [MINE]: DECLARING, 'packages/spec/src/data/table.ts': 'export const v = 2;\n' },
1483+
);
1484+
const shape = git(['diff', '--name-status', '--no-renames', base, 'HEAD', '--', '.changeset/*.md'], dir);
1485+
assert(
1486+
/^A\t\.changeset\/18160-foreign-changeset-remedy\.md$/m.test(shape),
1487+
'D strength: CONTROL -- the fixture really adds a changeset of its own, so this PR could not take the skip-changeset exemption',
1488+
);
1489+
assert(
1490+
/^M\t\.changeset\/plain-donkeys-repeat\.md$/m.test(shape),
1491+
'D strength: CONTROL -- and it really rewrites a foreign one in the same commit',
1492+
);
1493+
const r = scanForeign({ cwd: dir, base });
1494+
assert(
1495+
r.foreign.length === 1 && r.foreign[0]?.file === THEIRS && r.foreign[0]?.status === 'M',
1496+
'D strength: the deliberate-correction shape is STILL refused, and refused by name -- ruling D moved the text, never the verdict',
1497+
);
1498+
}
1499+
}
1500+
13511501
// ── #4690, one step later: no merge base at all is a failure ─────────────
13521502
// Falling back to the raw base here would restore exactly the bug above, so
13531503
// the scan throws and the CLI turns that into exit 1.

0 commit comments

Comments
 (0)