diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index 67278b5902..1ec7e3e75e 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -70,6 +70,36 @@ // the PR diff, `git grep`, this gate's log, and `--list` -- while rendering as // nothing in a published changelog. It is not hidden from anyone who reviews. // +// ## Which diff rows are judged (#7045) +// +// "Newly ADDS (or newly turns breaking)" is a statement about diff STATUS, and +// the enumeration has to name every status a changeset can arrive under: +// +// A breaking at head -> judged (a new declaration) +// M breaking at head, NOT breaking at base -> judged (turned breaking) +// M breaking at head, already breaking at base -> skipped (inherited stock) +// R breaking at head, NOT breaking at the OLD path-> judged (renamed into it) +// R breaking at head, already breaking there -> skipped (stock, moved) +// +// The last two rows arrived late. Git's rename detection is on by default +// (`diff.renames`, since git 2.9), so a changeset renamed AND turned breaking in +// one commit reports as `R`, and this file passed `--diff-filter=AM` until #7045 +// -- which drops the `R` row wholesale. A declared-breaking changeset arriving +// that way carried no ADR-0087 disposition and was never asked for one, which is +// #6148 again through a door nobody had checked. Measured on git 2.43.0, renaming +// `.changeset/old.md` to `.changeset/new.md` while adding the breaking declaration +// reports `R077.changeset/old.md.changeset/new.md`, and the same diff +// under `AM` prints nothing at all. +// +// The fix is `AMR` plus reading the base side at the OLD path -- field 2 of an +// `R` row, not field 3. It costs nothing: a PURE move of an already-breaking +// stock changeset compares breaking-at-base and stays skipped, so no author is +// asked to re-dispose of somebody else's declaration for renaming a file. +// `check-changeset-no-major.mjs` made the same one-letter correction first +// (#7005 / PR #7048); this gate and `check-empty-changeset.mjs` followed in +// #7045, separately, because each of the three owns its own fixtures and its own +// messages. +// // ## Why the escape hatch is the MAJORITY path, and why that is fine (measured) // // Measured over the last 400 first-parent commits on main: 32 newly-added @@ -1304,14 +1334,24 @@ export function scan({ cwd, base, head = 'HEAD' }) { let pkgsCache = null; const packages = () => (pkgsCache ??= workspacePackagesAt(head, cwd)); - const out = git(['diff', '--name-status', '--diff-filter=AM', from, head, '--', '.changeset/*.md'], cwd); + // `AMR`, not `AM`: see "Which diff rows are judged" in the header (#7045). An + // `R` row is how a declared-breaking changeset used to arrive unseen. + const out = git(['diff', '--name-status', '--diff-filter=AMR', from, head, '--', '.changeset/*.md'], cwd); const problems = []; const judged = []; const skipped = []; for (const line of out.split('\n')) { if (!line.trim()) continue; - const [status, file] = line.split('\t'); + const fields = line.split('\t'); + // `R` is `R\t\t`; `A` and `M` are `\t`. + // One character wide, because the `R` letter carries a similarity score and + // `=== 'R'` against the whole field would never match. + const status = fields[0][0]; + const file = status === 'R' ? fields[2] : fields[1]; + // The path the branch-point side is READ at: the same one for `M`, the + // PRE-RENAME one for `R`. Field 2 either way. + const basePath = fields[1]; if (!file || !isChangesetFile(file)) continue; const headText = showOrNull(head, file, cwd); @@ -1321,11 +1361,23 @@ export function scan({ cwd, base, head = 'HEAD' }) { const decl = breakingDeclaration(parsed); if (!decl.breaking) { skipped.push(file); continue; } - // A changeset that was ALREADY breaking at base is inherited, not introduced. - // Same philosophy as check-empty-changeset: this gate judges what a PR brings, - // never the stock it forked from. - if (status === 'M') { - const baseText = showOrNull(from, file, cwd); + // A changeset that was ALREADY breaking at the branch point is inherited, not + // introduced. Same philosophy as check-empty-changeset: this gate judges what a + // PR brings, never the stock it forked from. + // + // `R` is read at `basePath` -- its pre-rename name -- which is the only thing + // the rename status changes here (#7045). The statuses are spelled out rather + // than written `!== 'A'` so that widening the filter again some day cannot + // silently grant an unlisted status the inheritance exemption; an unknown + // status falls through and gets JUDGED, which is the safe direction. + // + // `isChangesetFile(basePath)` because git pairs renames by CONTENT, not by + // name: an `R` row can arrive as `.changeset/README.md -> .changeset/x.md`, + // and README is documentation that declares nothing. Inheriting "already + // breaking" from it would exempt a genuinely new breaking changeset. For `M` + // the guard is a no-op -- `basePath` is the path already accepted above. + if ((status === 'M' || status === 'R') && isChangesetFile(basePath)) { + const baseText = showOrNull(from, basePath, cwd); if (baseText !== null && breakingDeclaration(parseChangeset(baseText)).breaking) { skipped.push(file); continue; @@ -1776,8 +1828,13 @@ function selfTest() { /** * Build a two-commit repo: base carries the ledger + a breaking changeset in * stock (so the convention assertion is satisfied), head adds `files`. + * + * `baseFiles` puts extra files on the BASE commit, and a `null` in `files` + * deletes one at head. Together they are how a RENAME is expressed (#7045): + * git infers `R` from a delete plus an add of similar content, so the fixture + * has to be able to say both halves. */ - const build = ({ baseIds = ['old-entry-one', 'old-entry-two'], headIds = null, files = {}, pkgs = null }) => { + const build = ({ baseIds = ['old-entry-one', 'old-entry-two'], headIds = null, files = {}, baseFiles = {}, pkgs = null }) => { const dir = mkdtempSync(join(tmpdir(), 'adr0087-')); const w = (rel, text) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); @@ -1795,6 +1852,7 @@ function selfTest() { for (const [name, p] of Object.entries(pkgs ?? { '@objectstack/spec': { dir: 'packages/spec', private: false } })) { w(`${p.dir}/package.json`, JSON.stringify({ name, version: '1.0.0', ...(p.private ? { private: true } : {}) })); } + for (const [rel, text] of Object.entries(baseFiles)) w(rel, text); git(['add', '-A'], dir); git(['commit', '-qm', 'base'], dir); const base = git(['rev-parse', 'HEAD'], dir).trim(); @@ -1803,7 +1861,10 @@ function selfTest() { w(LEDGER_SOURCES[0], REG(headIds)); w(SPEC_CHANGES, SPEC_CHANGES_JSON(headIds)); } - for (const [rel, text] of Object.entries(files)) w(rel, text); + for (const [rel, text] of Object.entries(files)) { + if (text === null) rmSync(join(dir, rel)); + else w(rel, text); + } git(['add', '-A'], dir); // `--allow-empty`: some cases deliberately add nothing at head (the "this PR // touches no changeset" and "inherited changeset" shapes), and an empty commit @@ -2120,6 +2181,94 @@ function selfTest() { green('G6 inherited breaking changeset not re-judged', run(r)); } + // ---- The `R` rows (#7045) ------------------------------------------------- + // + // `--diff-filter=AM` -- what this gate passed until #7045 -- drops an `R` row + // wholesale, so R15 below was GREEN with an undisposed breaking declaration + // sitting in the diff. Both cases carry a CONTROL asserting that git really + // emitted `R`: rename detection is a SIMILARITY score, and a short body degrades + // to add-plus-delete, at which point R15 is an ordinary `A` case that would have + // been caught before the fix too -- green for a reason that is not the fix. + const RENAMEABLE_BODY = + 'a summary line, with a body long enough that git scores the move below as a\n' + + 'rename rather than as an add plus a delete. The `R` status is the entire\n' + + 'subject of these two cases, so the fixture may not be allowed to degrade.\n'; + /** The `R` row for `old -> new` in this repo's diff, or null. */ + const renameRow = (dir, base, oldPath, newPath) => + git(['diff', '--name-status', base, 'HEAD', '--', '.changeset/*.md'], dir) + .split('\n') + .find((l) => new RegExp(`^R\\d+\t${oldPath}\t${newPath}$`).test(l)) ?? null; + + // ---- R15: a changeset RENAMED AND turned breaking in the same commit ------ + // The #7045 shape, and R1's own shape with a `git mv` bolted on: a declaration + // this PR introduces, arriving at a path that did not exist at the branch point, + // saying nothing about the ledger. + { + const r = mk({ + baseFiles: { '.changeset/pending.md': CS({ bumps: [['@objectstack/spec', 'patch']], body: RENAMEABLE_BODY }) }, + files: { + '.changeset/pending.md': null, + '.changeset/pending-renamed.md': CS({ bumps: [['@objectstack/spec', 'major']], body: `**BREAKING** ${RENAMEABLE_BODY}` }), + }, + }); + assert( + renameRow(r.dir, r.base, '\\.changeset/pending\\.md', '\\.changeset/pending-renamed\\.md') !== null, + `R15 control: git must really report this as \`R\`, or the case below is an ordinary \`A\` -- got ${JSON.stringify(git(['diff', '--name-status', r.base, 'HEAD', '--', '.changeset/*.md'], r.dir))}`, + ); + red('R15 the #7045 shape (renamed AND turned breaking, no disposition)', run(r), [ + /pending-renamed/, + /no `adr-0087:` disposition marker/, + /#6148/, + ]); + } + + // ---- G10: a PURE rename of an ALREADY-breaking stock changeset ------------ + // The paired control, and the reason `AMR` costs nothing: the declaration was + // already at the branch point, so moving the file asks nobody to dispose of it + // again. This case can only be green through the `R` path -- were the rename to + // degrade to add-plus-delete the new path would arrive as `A`, and `A` never + // reads the base side at all, so it would be RED. + { + const r = mk({ files: {} }); + git(['mv', '.changeset/stock-breaking.md', '.changeset/stock-breaking-moved.md'], r.dir); + git(['commit', '-qm', 'move the stock changeset'], r.dir); + assert( + renameRow(r.dir, r.base, '\\.changeset/stock-breaking\\.md', '\\.changeset/stock-breaking-moved\\.md') !== null, + 'G10 control: git must really report the move as `R`', + ); + const res = run(r); + green('G10 a pure move of an inherited breaking changeset is not re-judged', res); + assert( + res.skipped.includes('.changeset/stock-breaking-moved.md'), + `G10: ...and it is reported as SKIPPED at its new path, not silently absent -- got ${JSON.stringify(res.skipped)}`, + ); + } + + // ---- R16: an `R` row whose BASE side is README.md inherits NOTHING -------- + // Git pairs renames by CONTENT, not by name, so an `R` row can arrive as + // `.changeset/README.md -> .changeset/x.md` (measured, git 2.43.0). README is + // documentation and declares nothing, so reading "already breaking at base" off + // it would exempt a genuinely new breaking changeset. Delete the + // `isChangesetFile(basePath)` guard in the scan and this case goes green. + { + const BREAKING_README = `# Changesets\n\n**BREAKING** ${RENAMEABLE_BODY}`; + const r = mk({ + baseFiles: { '.changeset/README.md': BREAKING_README }, + files: { + '.changeset/README.md': null, + '.changeset/was-the-readme.md': `---\n'@objectstack/spec': major\n---\n\n${BREAKING_README}`, + }, + }); + assert( + renameRow(r.dir, r.base, '\\.changeset/README\\.md', '\\.changeset/was-the-readme\\.md') !== null, + `R16 control: git must really pair the new changeset with README.md -- got ${JSON.stringify(git(['diff', '--name-status', r.base, 'HEAD', '--', '.changeset/*.md'], r.dir))}`, + ); + red('R16 a breaking changeset paired with README.md by rename detection is still judged', run(r), [ + /was-the-readme/, + /no `adr-0087:` disposition marker/, + ]); + } + // ---- Input assertions (#4690) -------------------------------------------- { const r = mk({ files: {} }); diff --git a/scripts/check-empty-changeset.mjs b/scripts/check-empty-changeset.mjs index 7751df6fab..27a7a5963d 100644 --- a/scripts/check-empty-changeset.mjs +++ b/scripts/check-empty-changeset.mjs @@ -63,13 +63,18 @@ // and a roster of 182 names would be a high-water mark that rots on the first // merge. "Absent-or-non-empty at base" is the same statement with no maintenance. // -// Two diff statuses are judged, and the second one is why the rule is phrased -// about the SET of empty declarations rather than about added files: +// Three diff statuses are judged, and everything after the first row is why the +// rule is phrased about the SET of empty declarations rather than about added +// files ("at base" below always means at the MERGE BASE, see the next section): // -// A added, empty at head -> violation (a new empty file) -// M empty at head, NON-empty at base -> violation (emptied in place) -// M empty at head, already empty at base -> exempt (stock, untouched) -// * non-empty at head -> ok +// A added, empty at head -> violation (a new empty file) +// M empty at head, NON-empty at base -> violation (emptied in place) +// M empty at head, already empty at base -> exempt (stock, untouched) +// R empty at head, NON-empty at the OLD path -> violation (renamed and emptied) +// R empty at head, already empty at that path -> exempt (stock, moved) +// R empty at head, OLD path is README.md -> violation (not inherited from +// documentation; see the scan) +// * non-empty at head -> ok // // Row 2 costs a few lines and removes the obvious bypass: taking a stock // non-empty changeset and deleting its frontmatter entries produces a brand-new @@ -77,6 +82,24 @@ // nothing. Row 3 is what keeps the stock exempt even when a PR edits an existing // empty file's prose, which is a legitimate thing to do and releases nothing new. // +// Rows 4 and 5 are that same argument one status letter along (#7045). Git's +// rename detection is on by default (`diff.renames`, since git 2.9), so emptying +// a stock changeset and `git mv`-ing it in one commit is reported as `R`, and +// `--diff-filter=AM` -- what this file passed until #7045 -- DROPPED that row +// entirely. The gate never saw the file, so row 2's bypass simply reopened under +// a different letter. Measured on git 2.43.0, renaming `.changeset/old.md` to +// `.changeset/new.md` while emptying its frontmatter reports +// +// R077.changeset/old.md.changeset/new.md +// +// and the same diff under `--diff-filter=AM` prints nothing at all. `AMR`, plus +// reading the base side at the OLD path (field 2 of an `R` row, not field 3), +// closes it and costs nothing: row 5 leaves a PURE move of a stock empty changeset +// exempt, so nobody goes red for tidying a filename. `check-changeset-no-major.mjs` +// made the identical one-letter correction first, off the same measurement +// (#7005 / PR #7048); this file and `check-adr-0087-registration.mjs` followed in +// #7045 because each of the three owns its own fixtures and its own messages. +// // ## Where the diff starts (#6129) // // "What the PR introduces" is a claim about ONE SIDE of a fork, so the scan @@ -233,7 +256,7 @@ export function mergeBase(base, head, cwd) { * lived in the CLI could be dropped from it without a single fixture noticing. * * @param {{ cwd: string, base: string, head?: string }} opts - * @returns {{ violations: {file: string, kind: string}[], exempt: string[], ok: string[], base: string }} + * @returns {{ violations: {file: string, kind: string, from?: string}[], exempt: string[], ok: string[], base: string }} * @throws when `base` and `head` have no merge base (#4690: not a pass) */ export function scan({ cwd, base, head = 'HEAD' }) { @@ -244,7 +267,9 @@ export function scan({ cwd, base, head = 'HEAD' }) { 'Refusing to fall back to the raw base, which is the #6129 defect.', ); } - const out = git(['diff', '--name-status', '--diff-filter=AM', from, head, '--', '.changeset/*.md'], cwd); + // `AMR`, not `AM`: an `R` row is where the row-2 bypass reappears under another + // status letter -- see "Three diff statuses are judged" in the header (#7045). + const out = git(['diff', '--name-status', '--diff-filter=AMR', from, head, '--', '.changeset/*.md'], cwd); const violations = []; const exempt = []; @@ -252,7 +277,16 @@ export function scan({ cwd, base, head = 'HEAD' }) { for (const line of out.split('\n')) { if (!line.trim()) continue; - const [status, file] = line.split('\t'); + const fields = line.split('\t'); + // `R` is `R\t\t`; `A` and `M` are `\t`. + // The status is read one character wide because the `R` letter carries a + // similarity score, so `=== 'R'` on the whole field would never match. + const status = fields[0][0]; + const file = status === 'R' ? fields[2] : fields[1]; + // Where the branch-point side is READ. For `A` the path is not there at all + // and `showOrNull` answers null, which is the right answer; for `R` it is the + // PRE-RENAME name, which is the whole reason an `R` row can be judged. + const basePath = fields[1]; if (!file || !isChangesetFile(file)) continue; const headText = showOrNull(head, file, cwd); @@ -267,10 +301,21 @@ export function scan({ cwd, base, head = 'HEAD' }) { continue; } - // Modified. Exempt only if it was ALREADY an empty declaration at base -- - // i.e. this PR did not create the empty declaration, it inherited it. - const baseText = showOrNull(from, file, cwd); + // Modified, or renamed. Exempt only if it was ALREADY an empty declaration at + // the branch point -- i.e. this PR did not create the empty declaration, it + // inherited it. `basePath` is what makes that read survive a rename: for `M` + // it is the same path, for `R` it is the pre-rename one. + // + // ...and it is read ONLY when that path was itself a changeset. Git pairs + // renames by CONTENT, not by name, so an `R` row can legitimately arrive as + // `.changeset/README.md -> .changeset/anything.md` (measured, git 2.43.0). + // README is documentation and declares nothing by definition; inheriting + // "already empty at base" from it would hand out the exemption for free, on + // a head file that really is a brand-new empty changeset. For `M` this guard + // is a no-op, because `basePath` is the path already accepted above. + const baseText = isChangesetFile(basePath) ? showOrNull(from, basePath, cwd) : null; if (baseText !== null && isEmptyDeclaration(baseText)) exempt.push(file); + else if (status === 'R') violations.push({ file, kind: 'renamed-empty', from: basePath }); else violations.push({ file, kind: 'emptied' }); } @@ -283,11 +328,19 @@ const KIND_NOTE = { 'added-empty': 'new file, empty frontmatter -- declares no package', 'added-unfenced': 'new file, no frontmatter block at all -- declares no package', emptied: 'existing changeset emptied by this PR -- declares no package any more', + // #7045. Named apart from `emptied` because the head path is BRAND NEW: telling + // an author "existing changeset emptied" while pointing at a filename that does + // not exist at the branch point sends them looking for the wrong history. The + // note stops at what is true for every `R` row -- what stood at the OLD path is + // named separately, because it is not always a changeset (see the scan). + 'renamed-empty': 'renamed into this path, and empty here -- declares no package', }; function report(violations) { console.error('This PR adds an empty-frontmatter changeset:\n'); - for (const { file, kind } of violations) console.error(` ${file}\n ${KIND_NOTE[kind]}`); + for (const { file, kind, from } of violations) { + console.error(` ${file}\n ${KIND_NOTE[kind]}${from ? `\n (the branch-point path is ${from} -- read the diff there)` : ''}`); + } console.error( [ '', @@ -523,7 +576,7 @@ function selfTest() { // ── GREEN 4: a stock EMPTY changeset whose prose is edited ─────────────── // Still empty at base, so this PR created no new empty declaration. This is - // the row that keeps the exemption honest under `--diff-filter=AM`. + // the row that keeps the exemption honest under `--diff-filter=AMR`. { const { dir, base } = makeRepo( { '.changeset/stock-empty.md': EMPTY }, @@ -548,6 +601,108 @@ function selfTest() { assert(r.violations.length === 0, 'GREEN 5: .changeset/README.md must never be judged as a changeset'); } + // ── The `R` rows (#7045) ───────────────────────────────────────────────── + // + // Rows 4-6 of the header table. `--diff-filter=AM` -- what this file passed + // until #7045 -- drops an `R` row wholesale, so RED 4 below was GREEN with a + // violation sitting in the diff. Every case here carries a CONTROL asserting + // that git really emitted `R`: rename detection is a SIMILARITY score, and a + // short body degrades to add-plus-delete, at which point the case is about an + // ordinary `A` and passes for a reason that has nothing to do with the fix. + // Hence `RENAMEABLE` -- a body long enough to score, in every fixture below. + const RENAMEABLE = + 'feat(spec): a body long enough that git scores the move as a rename rather\n' + + 'than as an add plus a delete -- the whole point of these cases is the `R`\n' + + 'status, and a short body silently turns them into `A` cases instead.\n'; + const RENAMEABLE_DECLARING = `---\n"@objectstack/spec": minor\n---\n\n${RENAMEABLE}`; + const RENAMEABLE_EMPTY = `---\n---\n\n${RENAMEABLE}`; + /** The `R` row for `old -> new`, or null. Fails loudly rather than quietly. */ + const renameRow = (dir, base, oldPath, newPath) => + git(['diff', '--name-status', base, 'HEAD', '--', '.changeset/*.md'], dir) + .split('\n') + .find((l) => new RegExp(`^R\\d+\t${oldPath}\t${newPath}$`).test(l)) ?? null; + + // ── RED 4: a stock non-empty changeset RENAMED AND EMPTIED in one commit ── + // Exactly RED 2 with a `git mv` bolted on -- the same brand-new empty + // declaration, spelled so that `AM` could not see it at all. + { + const { dir, base } = makeRepo( + { '.changeset/was-declaring.md': RENAMEABLE_DECLARING }, + { '.changeset/was-declaring.md': null, '.changeset/now-renamed.md': RENAMEABLE_EMPTY }, + ); + const row = renameRow(dir, base, '\\.changeset/was-declaring\\.md', '\\.changeset/now-renamed\\.md'); + assert(row !== null, `RED 4 control: git must really report this as \`R\`, or the case below is an ordinary \`A\` -- got ${JSON.stringify(git(['diff', '--name-status', base, 'HEAD', '--', '.changeset/*.md'], dir))}`); + const r = scan({ cwd: dir, base }); + assert(r.violations.length === 1, `RED 4: renaming a stock changeset while emptying it must go red (\`--diff-filter=AM\` dropped this row entirely) -- got ${JSON.stringify(r.violations)}`); + assert(r.violations[0]?.file === '.changeset/now-renamed.md', 'RED 4: the violation names the HEAD path (field 3 of the `R` row)'); + assert(r.violations[0]?.kind === 'renamed-empty', 'RED 4: kind must be renamed-empty, not emptied -- the head path is brand new'); + assert(r.violations[0]?.from === '.changeset/was-declaring.md', 'RED 4: ...and it carries the BRANCH-POINT path (field 2), which is where the author has to look'); + } + + // ── GREEN 6: a PURE rename of a stock EMPTY changeset stays exempt ──────── + // The paired control, and the reason `AMR` costs nothing: moving a stock file + // introduces no new empty declaration, so widening the filter must not turn + // tidying a filename into a red. Note this case can ONLY be green through the + // `R` path -- were the rename to degrade to add-plus-delete, the new path + // would arrive as `A` + empty, which is RED 1. + { + const { dir, base } = makeRepo( + { '.changeset/stock-empty.md': RENAMEABLE_EMPTY }, + { '.changeset/stock-empty.md': null, '.changeset/stock-empty-moved.md': RENAMEABLE_EMPTY }, + ); + assert( + renameRow(dir, base, '\\.changeset/stock-empty\\.md', '\\.changeset/stock-empty-moved\\.md') !== null, + 'GREEN 6 control: git must really report this as `R`', + ); + const r = scan({ cwd: dir, base }); + assert(r.violations.length === 0, `GREEN 6: a pure move of a stock empty changeset must not go red -- got ${JSON.stringify(r.violations)}`); + assert(r.exempt.join() === '.changeset/stock-empty-moved.md', `GREEN 6: it is exempt at its NEW path -- got ${JSON.stringify(r.exempt)}`); + } + + // ── GREEN 7: a pure rename of a stock DECLARING changeset is simply ok ──── + { + const { dir, base } = makeRepo( + { '.changeset/stock-declaring.md': RENAMEABLE_DECLARING }, + { '.changeset/stock-declaring.md': null, '.changeset/stock-declaring-moved.md': RENAMEABLE_DECLARING }, + ); + assert( + renameRow(dir, base, '\\.changeset/stock-declaring\\.md', '\\.changeset/stock-declaring-moved\\.md') !== null, + 'GREEN 7 control: git must really report this as `R`', + ); + const r = scan({ cwd: dir, base }); + assert(r.violations.length === 0, 'GREEN 7: moving a changeset that still declares a package is nobody\'s violation'); + assert(r.ok.join() === '.changeset/stock-declaring-moved.md', `GREEN 7: it is simply ok -- got ${JSON.stringify(r.ok)}`); + } + + // ── RED 5: an `R` row whose BASE side is README.md is not "inherited" ───── + // + // The fixture renames `.changeset/README.md` to a changeset filename, and the + // general reason such a row is reachable at all is that git pairs renames by + // CONTENT, not by name -- measured on git 2.43.0, where two identically-bodied + // files paired across unrelated names, and the pathspec kept the pairing + // inside `.changeset/`. Either way README declares nothing BY DEFINITION + // (GREEN 5), so reading "already empty at base" off it would hand the + // exemption out for free on a head file that is a brand-new empty changeset. + // This is the case the `isChangesetFile(basePath)` guard in the scan exists + // for; delete the guard and this row goes green as `exempt`. + { + const README = `# Changesets\n\n${RENAMEABLE}`; + const { dir, base } = makeRepo( + { '.changeset/README.md': README }, + { '.changeset/README.md': null, '.changeset/copied-the-readme.md': README }, + ); + assert( + renameRow(dir, base, '\\.changeset/README\\.md', '\\.changeset/copied-the-readme\\.md') !== null, + 'RED 5 control: git must really pair the new changeset with README.md, or this case is about something else', + ); + const r = scan({ cwd: dir, base }); + assert( + r.violations.length === 1 && r.violations[0]?.file === '.changeset/copied-the-readme.md', + `RED 5: a changeset paired with README.md by rename detection inherits NOTHING -- got ${JSON.stringify(r.violations)}`, + ); + assert(r.exempt.length === 0, 'RED 5: and it is certainly not exempt'); + } + // ── #6129: main drift must not move the verdict, in EITHER direction ───── // // The gate's contract is "same diff, same verdict" -- what a PR introduces @@ -704,6 +859,14 @@ function selfTest() { /git merge-base "refs\/remotes\/origin\/\$BASE_REF" HEAD/.test(yaml), 'consumer: the Check Changeset job must derive its diff base from `git merge-base origin/ HEAD`', ); + // What this pins is the ENDPOINT ($MERGE_BASE), and the `A` is incidental to + // it. Deliberately NOT widened to `AMR` alongside the scan (#7045): the + // workflow's `A` is a route-detection COUNT -- "did this PR write a + // changeset, or does it owe a `skip-changeset` label" -- not a violation + // scan. A rename adds no changeset, so counting `R` there would hand the + // label exemption to a PR that wrote nothing, which is the loosening + // direction. `A` fails CLOSED here and that is the answer this count wants; + // `AMR` in the scan fails closed there. Same family, opposite obligations. assert( /--diff-filter=A "\$MERGE_BASE" HEAD -- '\.changeset\/\*\.md'/.test(yaml), 'consumer: the changeset COUNT must diff from $MERGE_BASE (never the frozen base.sha) -- that count going green on main drift is #6129',