diff --git a/scripts/check-empty-changeset.mjs b/scripts/check-empty-changeset.mjs index 27a7a5963d..f925affcfd 100644 --- a/scripts/check-empty-changeset.mjs +++ b/scripts/check-empty-changeset.mjs @@ -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', @@ -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) ───────────────────── diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index 415bdf0f7c..105cd8c428 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -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, summary: string, body: string }} */ @@ -233,8 +253,9 @@ export function parseChangeset(text) { /** @type {Record} */ 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++; @@ -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 }); }