Skip to content

Commit 1bd6525

Browse files
fix(scripts): give the objectui changeset digest #6923's leading-blank-line preamble (#7044) (#7162)
`objectui-changeset-digest.mjs` is the fourth carrier of this repo's changeset frontmatter parser. #7004 aligned its entry regex with the three gates; it never got #6923's other half, so it still required the opening fence on line 1. A changeset opening with a single blank line therefore declared nothing here, which makes it read `release-nothing` and drops the commit out of the release digest entirely -- #4731's harm, reached through the parser. Measured with @changesets/parse@0.4.3 (the version this repo resolves): every leading-blank-line form below is a real `major` to changesets, and was `{}` to this file. Second half, and the one that stops the recurrence: #7004's family-agreement assertion in `check-empty-changeset.mjs`'s self-test now covers the fence preamble as well as the entry regex. Per file it asserts the opening fence is tested at the cursor the blank-line skip advanced (never at a literal `lines[0]`), and that the statement immediately above that test is the family's byte-identical skip. Extraction is by position, not by content, so the agreement is a real comparison; a shared-but-stale statement is rejected by name. Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn Co-authored-by: Claude <noreply@anthropic.com>
1 parent 775750e commit 1bd6525

2 files changed

Lines changed: 144 additions & 2 deletions

File tree

scripts/check-empty-changeset.mjs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,6 +1223,16 @@ function selfTest() {
12231223
assert(isEmptyDeclaration('---\n---\n\nbody\n'), 'parser: the canonical empty shape is empty');
12241224
assert(isEmptyDeclaration('\n---\n\n---\n\nbody\n'), 'parser: blank lines around/inside the fence stay empty');
12251225
assert(!isEmptyDeclaration(DECLARING), 'parser: a declaring changeset is not empty');
1226+
// The row above is EMPTY either way — with or without the leading-blank-line
1227+
// skip (#6923) an unread fence yields no packages — so it cannot fail on
1228+
// that skip's removal. This is the direction that can: a DECLARING file
1229+
// opening with a blank line, which @changesets/parse@0.4.3 reads as a real
1230+
// release. Deleting the preamble turns exactly this one red (the gate then
1231+
// rejects a valid changeset as empty-frontmatter), and it is the behavioural
1232+
// half of the family's fence-preamble agreement asserted further down
1233+
// (#7044).
1234+
assert(!isEmptyDeclaration('\n' + DECLARING), 'parser: a DECLARING changeset opening with a blank line is not empty (#6923/#7044)');
1235+
assert(!isEmptyDeclaration('\n\n' + DECLARING), 'parser: two leading blank lines do not hide a declaration (#6923/#7044)');
12261236
assert(
12271237
!isEmptyDeclaration("---\n'@objectstack/cli': patch\n---\n\nbody\n"),
12281238
'parser: single-quoted package names count as a declaration',
@@ -1339,6 +1349,71 @@ function selfTest() {
13391349
`family: ${rel} must skip whole-line YAML comments — without it a colon-bearing comment parses as a package named \`# note\` (#7004)`,
13401350
);
13411351
}
1352+
1353+
// ── … and they agree on WHERE that block may START (#7044) ────────────
1354+
//
1355+
// The second row of the same dialect table, and the standing proof that a
1356+
// ONE-ROW agreement check is not a family agreement check. #6923 taught
1357+
// three of these four to skip leading blank lines before the fence; the
1358+
// fourth (`objectui-changeset-digest.mjs`) still required the fence on
1359+
// line 1 when #7004 came through and aligned the entry regex — so a
1360+
// changeset opening with a single blank line read as release-nothing
1361+
// there and dropped out of the release digest, while
1362+
// @changesets/parse@0.4.3 honoured its `major`. That is #4731's harm
1363+
// (a breaking change vanishing from the record) reached through the one
1364+
// row nothing mechanical was holding. Three alignment passes went over
1365+
// this family and none of them could see it. This block is the mechanism
1366+
// that was missing.
1367+
//
1368+
// The four cannot share the fence TEST itself, and that is by design: the
1369+
// three gates return early on an unfenced file, while the digest falls
1370+
// through and treats the whole text as body. What they must share is the
1371+
// CURSOR — a skip that advances `i`, then a fence tested at `lines[i]`.
1372+
// Both halves are asserted per file, because a preamble sitting dead
1373+
// beside a surviving `lines[0]` test is #7044 again with the fix already
1374+
// in the file.
1375+
const preambles = new Map();
1376+
for (const rel of FAMILY) {
1377+
const src = existsSync(join(REPO_ROOT, rel)) ? readFileSync(join(REPO_ROOT, rel), 'utf8') : '';
1378+
// Comment lines are blanked (not dropped — indices stay meaningful):
1379+
// two of these files QUOTE the old `lines[0]` spelling in their headers
1380+
// while explaining why it was wrong, and a scan that reads prose finds
1381+
// the defect it is hunting inside the account of its own fix.
1382+
const srcLines = src.split('\n').map((l) => (/^\s*(?:\/\/|\/?\*)/.test(l) ? '' : l));
1383+
const fenceAt = srcLines.findIndex((l) => /lines\[[^\]]+\]\?\.trim\(\) [!=]== '---'/.test(l));
1384+
// Anti-vacuous-green (#6983), same discipline as the entry regex above:
1385+
// an extraction that finds nothing must fail here rather than hand the
1386+
// two assertions below an empty set to agree about.
1387+
assert(
1388+
fenceAt > 0,
1389+
`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`,
1390+
);
1391+
if (fenceAt <= 0) continue;
1392+
assert(
1393+
/lines\[i\]\?\.trim\(\) [!=]== '---'/.test(srcLines[fenceAt]),
1394+
`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())})`,
1395+
);
1396+
// The statement immediately above it, extracted by POSITION rather than
1397+
// by content — so the agreement asserted next is a real comparison and
1398+
// not a regex agreeing with itself.
1399+
const above = [...srcLines.slice(0, fenceAt)].reverse().find((l) => l.trim() !== '') ?? '';
1400+
preambles.set(rel, above.trim());
1401+
}
1402+
assert(
1403+
preambles.size === FAMILY.length,
1404+
`family: a fence preamble had to be extracted from all ${FAMILY.length} parsers — got ${preambles.size}`,
1405+
);
1406+
const distinctPreambles = new Set(preambles.values());
1407+
assert(
1408+
distinctPreambles.size === 1,
1409+
`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])}`,
1410+
);
1411+
// And the shared statement must be the SKIP, so this cannot go green on
1412+
// four identical copies of something else sitting in that position.
1413+
assert(
1414+
[...distinctPreambles][0] === "while (i < lines.length && lines[i].trim() === '') i++; // tolerate leading blank lines",
1415+
`family: the shared statement before the fence test must be the leading-blank-line skip itself (#6923) — found ${JSON.stringify([...distinctPreambles][0])}`,
1416+
);
13421417
}
13431418

13441419
// ── Missing input is a failure, never a pass (#4690) ─────────────────────

scripts/objectui-changeset-digest.mjs

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,26 @@ function git(cwd, args, { captureStderr = false } = {}) {
225225
* "release-nothing" and drops the commit from the digest entirely — which is
226226
* #4731's harm exactly, arrived at through the parser instead of a type filter.
227227
*
228+
* WHERE THE FENCE MAY START (#7044). The entry regex was aligned in #7004; the
229+
* OTHER row of the same dialect table was not. This parser required the opening
230+
* fence on line 1 (`lines[0]?.trim() === '---'`) long after #6923 taught the
231+
* three gates to skip leading blank lines first — so a changeset opening with a
232+
* single blank line declared, here, nothing at all. Measured with
233+
* `@changesets/parse@0.4.3` on 2026-08-10, the version this repo resolves:
234+
*
235+
* input | @changesets/parse | this file (before)
236+
* ---------------------------------------|-------------------|-------------------
237+
* `---\n"@object-ui/layout": major\n---` | major | major
238+
* one leading blank line, then the same | major | NOTHING
239+
* two leading blank lines | major | NOTHING
240+
* a leading whitespace-only line | major | NOTHING
241+
*
242+
* changesets honours the bump; this file dropped the commit out of the digest
243+
* and, worse, handed the raw frontmatter text back as the `summary`. So it now
244+
* carries the same preamble the three gates carry, byte for byte —
245+
* `check-empty-changeset.mjs`'s self-test asserts that agreement across all four
246+
* on this row too, which is what stops the fourth carrier drifting again.
247+
*
228248
* @param {string} text
229249
* @returns {{ packages: Record<string, string>, summary: string, body: string }}
230250
*/
@@ -233,8 +253,9 @@ export function parseChangeset(text) {
233253
/** @type {Record<string, string>} */
234254
const packages = {};
235255
let i = 0;
236-
if (lines[0]?.trim() === '---') {
237-
i = 1;
256+
while (i < lines.length && lines[i].trim() === '') i++; // tolerate leading blank lines
257+
if (lines[i]?.trim() === '---') {
258+
i++;
238259
for (; i < lines.length; i++) {
239260
if (lines[i].trim() === '---') {
240261
i++;
@@ -1982,6 +2003,52 @@ function selfTest() {
19822003
pkgsOf('"@object-ui/layout": enormous # keep') === '{}',
19832004
pkgsOf('"@object-ui/layout": enormous # keep'),
19842005
);
2006+
2007+
// ---- #7044: WHERE the fence is allowed to start ------------------------
2008+
//
2009+
// The other row of the same dialect table. #6923 taught the three gates to
2010+
// skip leading blank lines before the fence; this fourth carrier kept
2011+
// requiring it on line 1, and #7004's alignment pass did not touch the row.
2012+
// Same consequence as the block above and by the same mechanism — a silent
2013+
// DROP — but reached without any entry being malformed at all: the entries
2014+
// are perfect, the parser simply never enters the block.
2015+
//
2016+
// Measured with @changesets/parse@0.4.3 (the version this repo resolves) on
2017+
// 2026-08-10: every D-fixture below is read by changesets as a real `major`.
2018+
// So a miss here is a breaking change vanishing from the release record —
2019+
// #4731's stated "single class that must never vanish".
2020+
//
2021+
// Predicted direction on reverse verification: restoring `lines[0]?.trim()
2022+
// === '---'` (with `i = 1`) turns D1-D4 red with `packages` going `{}`, and
2023+
// turns D5 red in the other direction — the raw frontmatter comes back as
2024+
// the summary text. D6/D7 are the controls and stay green in both worlds,
2025+
// which is what makes D1-D5 statements about the preamble rather than about
2026+
// a fixture that parses as nothing either way.
2027+
const CS_MAJOR = '---\n"@object-ui/layout": major\n---\n\nDrop PageNodeRenderer.\n';
2028+
const parsedPkgs = (text) => JSON.stringify(parseChangeset(text).packages);
2029+
check('#7044 D1 one leading blank line before the fence', parsedPkgs('\n' + CS_MAJOR) === '{"@object-ui/layout":"major"}', parsedPkgs('\n' + CS_MAJOR));
2030+
check('#7044 D2 two leading blank lines', parsedPkgs('\n\n' + CS_MAJOR) === '{"@object-ui/layout":"major"}', parsedPkgs('\n\n' + CS_MAJOR));
2031+
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));
2032+
check(
2033+
'#7044 D4 a leading blank line with CRLF endings — the two dialect rows compose',
2034+
parsedPkgs('\r\n---\r\n"@object-ui/layout": major # keep\r\n---\r\n\r\nbody\r\n') === '{"@object-ui/layout":"major"}',
2035+
parsedPkgs('\r\n---\r\n"@object-ui/layout": major # keep\r\n---\r\n\r\nbody\r\n'),
2036+
);
2037+
check(
2038+
'#7044 D5 the SUMMARY is the body, not the frontmatter — the old anchor handed back the raw fence text',
2039+
parseChangeset('\n' + CS_MAJOR).summary === 'Drop PageNodeRenderer.',
2040+
JSON.stringify(parseChangeset('\n' + CS_MAJOR).summary),
2041+
);
2042+
check(
2043+
'#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',
2044+
parsedPkgs(CS_MAJOR) === '{"@object-ui/layout":"major"}' && parseChangeset(CS_MAJOR).summary === 'Drop PageNodeRenderer.',
2045+
`${parsedPkgs(CS_MAJOR)} / ${JSON.stringify(parseChangeset(CS_MAJOR).summary)}`,
2046+
);
2047+
check(
2048+
'#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',
2049+
parsedPkgs('\n"@object-ui/layout": major\n') === '{}' && parseChangeset('\n"@object-ui/layout": major\n').body === '"@object-ui/layout": major',
2050+
`${parsedPkgs('\n"@object-ui/layout": major\n')} / ${JSON.stringify(parseChangeset('\n"@object-ui/layout": major\n').body)}`,
2051+
);
19852052
} finally {
19862053
rmSync(tmp, { recursive: true, force: true });
19872054
}

0 commit comments

Comments
 (0)