Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions scripts/check-empty-changeset.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1223,6 +1223,16 @@ function selfTest() {
assert(isEmptyDeclaration('---\n---\n\nbody\n'), 'parser: the canonical empty shape is empty');
assert(isEmptyDeclaration('\n---\n\n---\n\nbody\n'), 'parser: blank lines around/inside the fence stay empty');
assert(!isEmptyDeclaration(DECLARING), 'parser: a declaring changeset is not empty');
// The row above is EMPTY either way — with or without the leading-blank-line
// skip (#6923) an unread fence yields no packages — so it cannot fail on
// that skip's removal. This is the direction that can: a DECLARING file
// opening with a blank line, which @changesets/parse@0.4.3 reads as a real
// release. Deleting the preamble turns exactly this one red (the gate then
// rejects a valid changeset as empty-frontmatter), and it is the behavioural
// half of the family's fence-preamble agreement asserted further down
// (#7044).
assert(!isEmptyDeclaration('\n' + DECLARING), 'parser: a DECLARING changeset opening with a blank line is not empty (#6923/#7044)');
assert(!isEmptyDeclaration('\n\n' + DECLARING), 'parser: two leading blank lines do not hide a declaration (#6923/#7044)');
assert(
!isEmptyDeclaration("---\n'@objectstack/cli': patch\n---\n\nbody\n"),
'parser: single-quoted package names count as a declaration',
Expand Down Expand Up @@ -1339,6 +1349,71 @@ function selfTest() {
`family: ${rel} must skip whole-line YAML comments — without it a colon-bearing comment parses as a package named \`# note\` (#7004)`,
);
}

// ── … and they agree on WHERE that block may START (#7044) ────────────
//
// The second row of the same dialect table, and the standing proof that a
// ONE-ROW agreement check is not a family agreement check. #6923 taught
// three of these four to skip leading blank lines before the fence; the
// fourth (`objectui-changeset-digest.mjs`) still required the fence on
// line 1 when #7004 came through and aligned the entry regex — so a
// changeset opening with a single blank line read as release-nothing
// there and dropped out of the release digest, while
// @changesets/parse@0.4.3 honoured its `major`. That is #4731's harm
// (a breaking change vanishing from the record) reached through the one
// row nothing mechanical was holding. Three alignment passes went over
// this family and none of them could see it. This block is the mechanism
// that was missing.
//
// The four cannot share the fence TEST itself, and that is by design: the
// three gates return early on an unfenced file, while the digest falls
// through and treats the whole text as body. What they must share is the
// CURSOR — a skip that advances `i`, then a fence tested at `lines[i]`.
// Both halves are asserted per file, because a preamble sitting dead
// beside a surviving `lines[0]` test is #7044 again with the fix already
// in the file.
const preambles = new Map();
for (const rel of FAMILY) {
const src = existsSync(join(REPO_ROOT, rel)) ? readFileSync(join(REPO_ROOT, rel), 'utf8') : '';
// Comment lines are blanked (not dropped — indices stay meaningful):
// two of these files QUOTE the old `lines[0]` spelling in their headers
// while explaining why it was wrong, and a scan that reads prose finds
// the defect it is hunting inside the account of its own fix.
const srcLines = src.split('\n').map((l) => (/^\s*(?:\/\/|\/?\*)/.test(l) ? '' : l));
const fenceAt = srcLines.findIndex((l) => /lines\[[^\]]+\]\?\.trim\(\) [!=]== '---'/.test(l));
// Anti-vacuous-green (#6983), same discipline as the entry regex above:
// an extraction that finds nothing must fail here rather than hand the
// two assertions below an empty set to agree about.
assert(
fenceAt > 0,
`family: an opening-fence test must be extractable from ${rel} — found none, so the extraction went stale and the preamble agreement below would judge nothing`,
);
if (fenceAt <= 0) continue;
assert(
/lines\[i\]\?\.trim\(\) [!=]== '---'/.test(srcLines[fenceAt]),
`family: ${rel} must test the opening fence at the cursor the blank-line skip advanced, never at a literal line index — \`lines[0]\` IS #7044: the changeset opens with one blank line, the fence is on line 2, and the entire block reads as absent (found: ${JSON.stringify(srcLines[fenceAt].trim())})`,
);
// The statement immediately above it, extracted by POSITION rather than
// by content — so the agreement asserted next is a real comparison and
// not a regex agreeing with itself.
const above = [...srcLines.slice(0, fenceAt)].reverse().find((l) => l.trim() !== '') ?? '';
preambles.set(rel, above.trim());
}
assert(
preambles.size === FAMILY.length,
`family: a fence preamble had to be extracted from all ${FAMILY.length} parsers — got ${preambles.size}`,
);
const distinctPreambles = new Set(preambles.values());
assert(
distinctPreambles.size === 1,
`family: all four parsers must carry a byte-identical leading-blank-line preamble immediately before their fence test — found ${distinctPreambles.size} distinct spellings: ${JSON.stringify([...preambles])}`,
);
// And the shared statement must be the SKIP, so this cannot go green on
// four identical copies of something else sitting in that position.
assert(
[...distinctPreambles][0] === "while (i < lines.length && lines[i].trim() === '') i++; // tolerate leading blank lines",
`family: the shared statement before the fence test must be the leading-blank-line skip itself (#6923) — found ${JSON.stringify([...distinctPreambles][0])}`,
);
}

// ── Missing input is a failure, never a pass (#4690) ─────────────────────
Expand Down
71 changes: 69 additions & 2 deletions scripts/objectui-changeset-digest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,26 @@ function git(cwd, args, { captureStderr = false } = {}) {
* "release-nothing" and drops the commit from the digest entirely — which is
* #4731's harm exactly, arrived at through the parser instead of a type filter.
*
* WHERE THE FENCE MAY START (#7044). The entry regex was aligned in #7004; the
* OTHER row of the same dialect table was not. This parser required the opening
* fence on line 1 (`lines[0]?.trim() === '---'`) long after #6923 taught the
* three gates to skip leading blank lines first — so a changeset opening with a
* single blank line declared, here, nothing at all. Measured with
* `@changesets/parse@0.4.3` on 2026-08-10, the version this repo resolves:
*
* input | @changesets/parse | this file (before)
* ---------------------------------------|-------------------|-------------------
* `---\n"@object-ui/layout": major\n---` | major | major
* one leading blank line, then the same | major | NOTHING
* two leading blank lines | major | NOTHING
* a leading whitespace-only line | major | NOTHING
*
* changesets honours the bump; this file dropped the commit out of the digest
* and, worse, handed the raw frontmatter text back as the `summary`. So it now
* carries the same preamble the three gates carry, byte for byte —
* `check-empty-changeset.mjs`'s self-test asserts that agreement across all four
* on this row too, which is what stops the fourth carrier drifting again.
*
* @param {string} text
* @returns {{ packages: Record<string, string>, summary: string, body: string }}
*/
Expand All @@ -233,8 +253,9 @@ export function parseChangeset(text) {
/** @type {Record<string, string>} */
const packages = {};
let i = 0;
if (lines[0]?.trim() === '---') {
i = 1;
while (i < lines.length && lines[i].trim() === '') i++; // tolerate leading blank lines
if (lines[i]?.trim() === '---') {
i++;
for (; i < lines.length; i++) {
if (lines[i].trim() === '---') {
i++;
Expand Down Expand Up @@ -1982,6 +2003,52 @@ function selfTest() {
pkgsOf('"@object-ui/layout": enormous # keep') === '{}',
pkgsOf('"@object-ui/layout": enormous # keep'),
);

// ---- #7044: WHERE the fence is allowed to start ------------------------
//
// The other row of the same dialect table. #6923 taught the three gates to
// skip leading blank lines before the fence; this fourth carrier kept
// requiring it on line 1, and #7004's alignment pass did not touch the row.
// Same consequence as the block above and by the same mechanism — a silent
// DROP — but reached without any entry being malformed at all: the entries
// are perfect, the parser simply never enters the block.
//
// Measured with @changesets/parse@0.4.3 (the version this repo resolves) on
// 2026-08-10: every D-fixture below is read by changesets as a real `major`.
// So a miss here is a breaking change vanishing from the release record —
// #4731's stated "single class that must never vanish".
//
// Predicted direction on reverse verification: restoring `lines[0]?.trim()
// === '---'` (with `i = 1`) turns D1-D4 red with `packages` going `{}`, and
// turns D5 red in the other direction — the raw frontmatter comes back as
// the summary text. D6/D7 are the controls and stay green in both worlds,
// which is what makes D1-D5 statements about the preamble rather than about
// a fixture that parses as nothing either way.
const CS_MAJOR = '---\n"@object-ui/layout": major\n---\n\nDrop PageNodeRenderer.\n';
const parsedPkgs = (text) => JSON.stringify(parseChangeset(text).packages);
check('#7044 D1 one leading blank line before the fence', parsedPkgs('\n' + CS_MAJOR) === '{"@object-ui/layout":"major"}', parsedPkgs('\n' + CS_MAJOR));
check('#7044 D2 two leading blank lines', parsedPkgs('\n\n' + CS_MAJOR) === '{"@object-ui/layout":"major"}', parsedPkgs('\n\n' + CS_MAJOR));
check('#7044 D3 a leading WHITESPACE-ONLY line (the preamble trims, it does not test for empty)', parsedPkgs(' \n' + CS_MAJOR) === '{"@object-ui/layout":"major"}', parsedPkgs(' \n' + CS_MAJOR));
check(
'#7044 D4 a leading blank line with CRLF endings — the two dialect rows compose',
parsedPkgs('\r\n---\r\n"@object-ui/layout": major # keep\r\n---\r\n\r\nbody\r\n') === '{"@object-ui/layout":"major"}',
parsedPkgs('\r\n---\r\n"@object-ui/layout": major # keep\r\n---\r\n\r\nbody\r\n'),
);
check(
'#7044 D5 the SUMMARY is the body, not the frontmatter — the old anchor handed back the raw fence text',
parseChangeset('\n' + CS_MAJOR).summary === 'Drop PageNodeRenderer.',
JSON.stringify(parseChangeset('\n' + CS_MAJOR).summary),
);
check(
'#7044 D6 control — the identical text WITHOUT the leading blank line parses the same, so D1-D5 are about the blank line and nothing else',
parsedPkgs(CS_MAJOR) === '{"@object-ui/layout":"major"}' && parseChangeset(CS_MAJOR).summary === 'Drop PageNodeRenderer.',
`${parsedPkgs(CS_MAJOR)} / ${JSON.stringify(parseChangeset(CS_MAJOR).summary)}`,
);
check(
'#7044 D7 control — skipping blanks did NOT make the parser fence-less: a leading blank line over an UNFENCED file still declares nothing, and its body survives whole',
parsedPkgs('\n"@object-ui/layout": major\n') === '{}' && parseChangeset('\n"@object-ui/layout": major\n').body === '"@object-ui/layout": major',
`${parsedPkgs('\n"@object-ui/layout": major\n')} / ${JSON.stringify(parseChangeset('\n"@object-ui/layout": major\n').body)}`,
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
Expand Down
Loading