Skip to content

Commit 8d44d9f

Browse files
committed
fix(scripts): guard the base side of an R row against .changeset/README.md (#7107)
`check-changeset-no-major.mjs` applied `isChangesetFile()` to the HEAD path only. The BASE path -- `fields[1]`, the pre-rename name an `R` row is read at -- was handed straight to `git show`. Git pairs renames by CONTENT rather than by name, so under the `.changeset/*.md` pathspec an `R` row can legitimately arrive as `.changeset/README.md -> .changeset/x.md`; the majors README appears to declare would then be subtracted from the head file's, reporting a brand-new whole-stack major as inherited. Dormant today (the real README carries no frontmatter fence, so `majorPackagesIn` returns `[]`), and the two siblings already carry this guard since #7106. The self-test fixture therefore commits a major-shaped README so the unguarded path really subtracts something: with the guard the row is `introduced`, without it the same row is `exempt`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
1 parent 3e8e669 commit 8d44d9f

1 file changed

Lines changed: 65 additions & 1 deletion

File tree

scripts/check-changeset-no-major.mjs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,17 @@ export function scan({ cwd, base, head = 'HEAD' }) {
403403

404404
// `A` means the path is not at the branch point at all, so there is nothing
405405
// to read and nothing it could already have declared.
406-
const baseText = status === 'A' ? null : showOrNull(from, basePath, cwd);
406+
//
407+
// ...and for `M`/`R` the base side is read ONLY when that path was itself a
408+
// changeset. Git pairs renames by CONTENT, not by name, so under this
409+
// pathspec an `R` row can legitimately arrive as
410+
// `.changeset/README.md -> .changeset/anything.md` (measured, git 2.43.0).
411+
// README is documentation and declares nothing BY DEFINITION — so any major
412+
// it appears to declare is a phantom, and subtracting it here would report a
413+
// genuinely NEW major as exempt, which is this gate's expensive direction.
414+
// For `M` the guard is a no-op, because `basePath` is the path already
415+
// accepted above. Same guard, same reason, as the two siblings (#7106).
416+
const baseText = status === 'A' || !isChangesetFile(basePath) ? null : showOrNull(from, basePath, cwd);
407417
const already = baseText === null ? [] : majorPackagesIn(baseText);
408418
// Per PACKAGE, not per file: adding a second `major` entry to a changeset
409419
// that already declared one is still introducing that second one.
@@ -984,6 +994,16 @@ function selfTest() {
984994
git(['commit', '-q', '-m', 'head', '--allow-empty', '--no-gpg-sign'], dir);
985995
return { dir, base };
986996
};
997+
/**
998+
* The `R` row for `old -> new` in this repo's diff, or null. Both paths are
999+
* regex source, so a caller escapes its dots. Matching the WHOLE row (rather
1000+
* than `/^R\d/` on the output) is what makes an `R` control specific: it pins
1001+
* which two paths git paired, not merely that something was scored a rename.
1002+
*/
1003+
const renameRow = (dir, base, oldPath, newPath) =>
1004+
git(['diff', '--name-status', base, 'HEAD', '--', '.changeset/*.md'], dir)
1005+
.split('\n')
1006+
.find((l) => new RegExp(`^R\\d+\t${oldPath}\t${newPath}$`).test(l)) ?? null;
9871007

9881008
try {
9891009
// A stock of pending majors on the base commit, the shape of the real tree
@@ -1094,6 +1114,50 @@ function selfTest() {
10941114
);
10951115
}
10961116

1117+
// ── #7107: an `R` row whose BASE side is README.md subtracts NOTHING ──────
1118+
//
1119+
// The row above pins README at the HEAD side. This one is the same fact at
1120+
// the BASE side, which is a different code path: `basePath` is read to work
1121+
// out what the file "already declared" at the branch point, and git pairs
1122+
// renames by CONTENT rather than by name — so under the `.changeset/*.md`
1123+
// pathspec an `R` row can legitimately arrive as
1124+
// `.changeset/README.md -> .changeset/x.md` (measured, git 2.43.0; the two
1125+
// siblings pin the same shape as RED 5 / R16 since #7106).
1126+
//
1127+
// Both sides are byte-identical here, which is exactly what makes the case
1128+
// sharp: the head file is a brand-new changeset declaring a major, and the
1129+
// only thing that could excuse it is a major read off README. Delete the
1130+
// `isChangesetFile(basePath)` guard in the scan and `already` becomes
1131+
// `['@objectstack/spec']`, `added` empties, and this row flips from
1132+
// `introduced` to `exempt` — a whole-stack major reported as inherited.
1133+
//
1134+
// DORMANT, said plainly: the real `.changeset/README.md` is boilerplate with
1135+
// no frontmatter fence, so `majorPackagesIn` returns `[]` on it and nothing
1136+
// is subtracted today. The fixture therefore has to COMMIT a major-shaped
1137+
// README to reach the path at all — without that, the case would pass with
1138+
// or without the guard and would certify the hole instead of closing it.
1139+
{
1140+
const long = '\n\nbody long enough for git to score this as a rename rather than an add plus a delete\n';
1141+
const majorReadme = '---\n"@objectstack/spec": major\n---' + long;
1142+
const { dir, base } = makeRepo(
1143+
{ '.changeset/README.md': majorReadme },
1144+
{ '.changeset/README.md': null, '.changeset/was-the-readme.md': majorReadme },
1145+
);
1146+
assert(
1147+
renameRow(dir, base, '\\.changeset/README\\.md', '\\.changeset/was-the-readme\\.md') !== null,
1148+
`#7107 control: git must really pair the new changeset with README.md, or this case is an ordinary \`A\` and says nothing about the base side — got ${JSON.stringify(git(['diff', '--name-status', base, 'HEAD', '--', '.changeset/*.md'], dir))}`,
1149+
);
1150+
const { introduced, exempt } = scan({ cwd: dir, base });
1151+
assert(
1152+
introduced.length === 1 &&
1153+
introduced[0].file === '.changeset/was-the-readme.md' &&
1154+
introduced[0].majors.join() === '@objectstack/spec',
1155+
`#7107: a changeset paired with a major-shaped README.md by rename detection inherits NOTHING — got ${JSON.stringify(introduced)}`,
1156+
);
1157+
assert(exempt.length === 0, `#7107: ... and it is certainly not exempt — got ${JSON.stringify(exempt)}`);
1158+
assert(judge({ introduced, pre: { mode: 'exit' } }).verdict === 'enforce', '#7107: ... so with pre-mode exited that PR is RED');
1159+
}
1160+
10971161
// ── #6129 proper: main drift must not move the verdict ───────────────────
10981162
//
10991163
// The CI shape built for real — a base branch that keeps moving after the PR

0 commit comments

Comments
 (0)