From f6b58004a68404a58163820080cc71b0302a02d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 11:30:52 +0000 Subject: [PATCH] fix(devx): one trailing YAML comment no longer hides a bump from the changeset parsers (#7004) --- scripts/check-adr-0087-registration.mjs | 60 ++++++++++- scripts/check-changeset-no-major.mjs | 108 +++++++++++++++---- scripts/check-empty-changeset.mjs | 136 +++++++++++++++++++++++- scripts/objectui-changeset-digest.mjs | 48 ++++++++- 4 files changed, 318 insertions(+), 34 deletions(-) diff --git a/scripts/check-adr-0087-registration.mjs b/scripts/check-adr-0087-registration.mjs index bf3fd6887f..ab143692a2 100644 --- a/scripts/check-adr-0087-registration.mjs +++ b/scripts/check-adr-0087-registration.mjs @@ -187,10 +187,18 @@ const isChangesetFile = (p) => p.startsWith('.changeset/') && p.endsWith('.md') /** * Split a changeset into its frontmatter bump entries and its body. * - * The entry regex is deliberately the SAME shape `check-changeset-no-major.mjs` - * and `check-empty-changeset.mjs` use. Three gates reading one block must agree on - * what counts as a declaration, or one of them is judging a different file than it - * appears to. + * The entry regex is deliberately the SAME shape `check-changeset-no-major.mjs`, + * `check-empty-changeset.mjs` and `objectui-changeset-digest.mjs` use. Four + * readers of one block must agree on what counts as a declaration, or one of them + * is judging a different file than it appears to. `check-empty-changeset.mjs`'s + * self-test asserts that agreement byte-for-byte across all four (#7004). + * + * This parser's stake in #7004 is signal (1) of `breakingDeclaration` below: a + * `major` carrying a trailing YAML comment (or a quoted bump value) used to read + * as no bump at all, so the frontmatter signal went missing and only signals (2) + * `**BREAKING` and (3) the `!` summary could still carry the declaration. A + * changeset using (1) alone — which the ADR-0087 worklist treats as a full + * declaration — was invisible to this gate. * * @param {string} text * @returns {{ fenced: boolean, bumps: {pkg: string, bump: string}[], body: string }} @@ -205,7 +213,10 @@ export function parseChangeset(text) { let end = -1; for (let j = i + 1; j < lines.length; j++) { if (lines[j].trim() === '---') { end = j; break; } - const m = /^\s*["']?([^"':]+)["']?\s*:\s*([A-Za-z]+)\s*$/.exec(lines[j]); + if (/^\s*#/.test(lines[j])) continue; // a whole-line YAML comment declares nothing + // "": | '': | : + // with an optionally quoted bump value and an optional trailing ` # comment`. + const m = /^\s*["']?([^"':]+)["']?\s*:\s*["']?([A-Za-z]+)["']?(?:\s+#.*)?\s*$/.exec(lines[j]); if (m) bumps.push({ pkg: m[1].trim(), bump: m[2].trim().toLowerCase() }); } if (end < 0) return { fenced: false, bumps: [], body: text }; @@ -2030,6 +2041,45 @@ function selfTest() { assert(breakingDeclaration(parseChangeset(CS({ body: 'feat(spec)!: x\n' }))).breaking, 'P6: a conventional-commit bang is a declaration'); assert(!breakingDeclaration(parseChangeset(CS({ bumps: [['a', 'patch']], body: 'plain\n' }))).breaking, 'P7: a plain patch is not'); + + // ---- P9-P14 (#7004): the shapes the old entry anchor hid from signal (1) --- + // + // This gate's stake in #7004 is the PARTIAL miss: `breakingDeclaration` reads + // three signals, and a `major` wearing a trailing YAML comment (or a quoted + // bump value) used to vanish from signal (1) — leaving (2) `**BREAKING` and + // (3) the `!` summary to carry a declaration they may not carry at all. So + // each fixture below states the bump WITHOUT either of the other two signals: + // a plain body, so `major` is the only thing that can make it breaking. + // + // Predicted direction on reverse verification: restoring the old anchor + // (`([A-Za-z]+)\s*$`) turns P9-P13 red (breaking goes false, signals loses + // `major`) and P14 red in the other direction (a phantom `# note` bump). + const PLAIN = 'a summary line\n\nsome prose that is quite long indeed and explains the change.\n'; + const bumpsOf = (text) => parseChangeset(text).bumps.map((b) => `${b.pkg}=${b.bump}`); + assert( + bumpsOf(`---\n'@objectstack/spec': major # keep\n---\n\n${PLAIN}`).join(',') === '@objectstack/spec=major', + 'P9 (#7004): a trailing YAML comment still yields the `major` bump — changesets reads it as one', + ); + assert( + breakingDeclaration(parseChangeset(`---\n'@objectstack/spec': major # keep\n---\n\n${PLAIN}`)).signals.includes('major'), + 'P10 (#7004): signal (1) fires on a comment-bearing major, with no `**BREAKING` and no `!` in the body to carry it instead', + ); + assert( + breakingDeclaration(parseChangeset(`---\n'@objectstack/spec': "major"\n---\n\n${PLAIN}`)).signals.includes('major'), + 'P11 (#7004): signal (1) fires on a QUOTED bump value', + ); + assert( + breakingDeclaration(parseChangeset(`---\n'@objectstack/spec': 'major' # keep\n---\n\n${PLAIN}`)).signals.includes('major'), + 'P12 (#7004): signal (1) fires on a quoted bump value carrying a comment', + ); + assert( + !breakingDeclaration(parseChangeset(`---\n'@objectstack/spec': minor # keep\n---\n\n${PLAIN}`)).breaking, + 'P13 (#7004): control — the same comment-bearing shape with `minor` is NOT breaking, so P9-P12 are about the bump word and not about the comment merely being tolerated', + ); + assert( + bumpsOf(`---\n# note: major\n'@objectstack/real': patch\n---\n\n${PLAIN}`).join(',') === '@objectstack/real=patch', + 'P14 (#7004): a whole-line comment containing a colon is not a bump — it used to parse as a package named `# note` bumped major, i.e. a phantom breaking declaration', + ); assert(extractIds(" id: 'object-titleFormat-to-nameField',\n").length === 1, 'P8: an id with a capital letter must be extracted'); // ---- I1 (#6566): a bare `import` of this module must NOT run the gate ----- diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs index e05ad2a591..dbdd3c0a60 100644 --- a/scripts/check-changeset-no-major.mjs +++ b/scripts/check-changeset-no-major.mjs @@ -74,7 +74,11 @@ * a leading blank line before `---` | major | caught (see below) * "@objectstack/spec": MAJOR | THROWS invalid type | caught (harmless) * no closing `---` fence | THROWS missing fm | caught (harmless) - * "@objectstack/spec": major # note | major | MISSED (see below) + * "@objectstack/spec": major # note | major | caught (#7004) + * "@objectstack/spec": "major" | major | caught (#7004) + * "@objectstack/spec": 'major' # n | major | caught (#7004) + * # note: major (comment line) | declares NOTHING | ignored (#7004) + * "@objectstack/spec": major# note | THROWS invalid type | missed (harmless) * * Rows marked "harmless" are this file being STRICTER than changesets on a file * changesets refuses outright: the guard names a major in a changeset that could @@ -82,6 +86,12 @@ * about a file that is already broken. The opposite direction is the one that * matters, because it is silent. * + * The last row is the one place a `#` does NOT start a comment: YAML requires + * whitespace before an inline `#`, so `major# note` is the scalar `major# note` + * and changesets throws `invalid version type`. The regex therefore spells the + * comment `(?:\s+#.*)?` rather than `(?:#.*)?` — matching YAML exactly, so this + * file misses only what changesets refuses. + * * LEADING BLANK LINES (fixed in #6923). This parser used to require the fence on * line 1 (`if (lines[0]?.trim() !== '---') return []`), so a changeset opening * with one blank line declared, to this guard, nothing at all — while changesets @@ -91,14 +101,24 @@ * blanks, and all three carry a comment saying the three read the same block — * so this was also the one place that comment was false. It now skips them too. * - * TRAILING YAML COMMENTS are still missed, and that is a KNOWN GAP recorded - * rather than implied: the entry regex ends `([A-Za-z]+)\s*$`, so - * `"@objectstack/spec": major # keep` matches nothing, while changesets reads it - * as a major. All three parsers in this family share the regex and therefore the - * gap, with a different consequence in each, so closing it is a family-wide - * change and not this file's to make alone. Filed as #7004; the fixture below - * pins the CURRENT behaviour so that closing it turns this file red on purpose - * rather than by surprise. + * TRAILING YAML COMMENTS were missed until #7004, together with two more shapes + * the same anchoring hid. The entry regex used to end `([A-Za-z]+)\s*$`, which + * accepts nothing after the bump word, so all of these read as no declaration at + * all while changesets read a real bump: + * + * "@objectstack/spec": major # keep a trailing comment + * "@objectstack/spec": "major" a QUOTED bump value (not in #7004's report) + * "@objectstack/spec": 'major' # keep both at once + * + * And one shape ran the other way — invented rather than hidden. A whole-line + * comment that happens to contain a colon is entry-shaped, so `# note: major` + * parsed as a package literally named `# note` bumped `major`. Measured against + * @changesets/parse@0.4.3, which declares nothing for it. + * + * All four parsers in this family shared the regex and therefore all four gaps, + * with a different consequence in each, so #7004 closed them family-wide in one + * change. Measured after: 19 shapes changesets ACCEPTS now agree, 0 regressions, + * and every surviving difference is on a file changesets throws on. * * ## RESIDUAL: an unreadable `.changeset/` still exits 0 * @@ -143,11 +163,14 @@ const REPO_ROOT = resolve(__dirname, '..'); * A frontmatter line looks like: "@objectstack/spec": major * (single or double quotes, any surrounding whitespace). * - * The entry regex is deliberately the SAME shape `check-empty-changeset.mjs` - * and `check-adr-0087-registration.mjs` use. Three gates reading one block must - * agree on what counts as a declaration, or one of them is judging a different - * file than it appears to. See the dialect table in the header for where they - * agree with `@changesets/parse` and where they do not. + * The entry regex is deliberately the SAME shape `check-empty-changeset.mjs`, + * `check-adr-0087-registration.mjs` and `objectui-changeset-digest.mjs` use. + * Four readers of one block must agree on what counts as a declaration, or one + * of them is judging a different file than it appears to. That agreement is no + * longer only a comment: `check-empty-changeset.mjs`'s self-test extracts the + * regex literal from all four files and asserts they are byte-identical (#7004). + * See the dialect table in the header for where they agree with + * `@changesets/parse` and where they deliberately do not. * * @param {string} text * @returns {string[]} @@ -161,8 +184,10 @@ export function majorPackagesIn(text) { const majors = []; for (let j = i + 1; j < lines.length; j++) { if (lines[j].trim() === '---') break; // end of frontmatter + if (/^\s*#/.test(lines[j])) continue; // a whole-line YAML comment declares nothing // "": | '': | : - const m = /^\s*["']?([^"':]+)["']?\s*:\s*([A-Za-z]+)\s*$/.exec(lines[j]); + // with an optionally quoted bump value and an optional trailing ` # comment`. + const m = /^\s*["']?([^"':]+)["']?\s*:\s*["']?([A-Za-z]+)["']?(?:\s+#.*)?\s*$/.exec(lines[j]); if (m && m[2].toLowerCase() === 'major') majors.push(m[1].trim()); } return majors; @@ -428,15 +453,52 @@ function selfTest() { 'parser: lines that are not `: ` are not declarations', ); - // KNOWN GAP, pinned as current behaviour rather than endorsed. Measured: - // @changesets/parse@0.4.3 reads this as a real major. The entry regex ends - // `([A-Za-z]+)\s*$`, so the trailing comment defeats it — in all three parsers - // of this family, which is why closing it is not this file's change to make - // alone. When it IS closed, this assertion goes red on purpose: flip it, do - // not delete it. + // ── THE FIX (#7004): the shapes the old `([A-Za-z]+)\s*$` anchor hid ────── + // + // This block is #6923's KNOWN-GAP pin, FLIPPED rather than deleted, as the + // note it carried asked. It used to assert `.length === 0` — the gap — with + // the instruction to invert it on the day the family-wide regex was fixed. + // That day is #7004, so the same inputs are asserted to be CAUGHT now. + // + // Predicted direction on reverse verification: restoring the old anchor + // (`([A-Za-z]+)\s*$`) turns exactly these red. Measured with + // @changesets/parse@0.4.3: every one of them DOES release a major, so a miss + // here is a whole-stack major promoted past a guard that printed a tick. + caught('a trailing YAML comment', '---\n"@objectstack/spec": major # keep\n---\n\nbody\n', ['@objectstack/spec']); + caught('a trailing comment after a tab', '---\n"@objectstack/spec": major\t# keep\n---\n\nbody\n', ['@objectstack/spec']); + caught('a trailing comment containing a colon', '---\n"@objectstack/spec": major # note: keep\n---\n\nbody\n', ['@objectstack/spec']); + caught('an empty trailing comment', '---\n"@objectstack/spec": major #\n---\n\nbody\n', ['@objectstack/spec']); + caught('a double-quoted bump value', '---\n"@objectstack/spec": "major"\n---\n\nbody\n', ['@objectstack/spec']); + caught('a single-quoted bump value', '---\n"@objectstack/spec": \'major\'\n---\n\nbody\n', ['@objectstack/spec']); + caught('a quoted bump value AND a comment', '---\n"@objectstack/spec": "major" # keep\n---\n\nbody\n', ['@objectstack/spec']); + caught('a package name containing #, plus a comment', '---\n"@objectstack/a#b": major # keep\n---\n\nbody\n', ['@objectstack/a#b']); + caught('a commented major beside an uncommented minor', '---\n"@objectstack/a": major # keep\n"@objectstack/b": minor\n---\n\nbody\n', [ + '@objectstack/a', + ]); + + // The other direction #7004 measured: a whole-line comment that happens to + // contain a colon is entry-shaped, and used to parse as a package literally + // named `# note`. @changesets/parse declares nothing for it, so neither does + // this. The control below is what keeps this from passing vacuously. + assert( + majorPackagesIn('---\n# note: major\n---\n\nbody\n').length === 0, + 'parser: a whole-line YAML comment is not a declaration, even when it contains a colon (#7004)', + ); + assert( + majorPackagesIn('---\n # note: major\n---\n\nbody\n').length === 0, + 'parser: an INDENTED whole-line comment is not a declaration either (#7004)', + ); + caught('control — a real entry beside a colon-bearing comment line', '---\n# note: major\n"@objectstack/real": major\n---\n\nbody\n', [ + '@objectstack/real', + ]); + + // YAML requires whitespace before an inline `#`, so this one is the scalar + // `major# keep` and @changesets/parse THROWS `invalid version type`. Missing + // it is the harmless direction (a file that can version nothing), and the + // regex spells the comment `(?:\s+#.*)?` precisely to keep it that way. assert( - majorPackagesIn('---\n"@objectstack/spec": major # keep\n---\n\nbody\n').length === 0, - 'parser: KNOWN GAP (#7004) — a trailing YAML comment hides a major from this parser (changesets reads it as a major); flip this when the family-wide regex is fixed, never delete it', + majorPackagesIn('---\n"@objectstack/spec": major# keep\n---\n\nbody\n').length === 0, + 'parser: `major# keep` (no space before #) is not a comment in YAML — changesets throws on it, so missing it is the harmless direction (#7004)', ); // ── The exemption switch, in BOTH directions ────────────────────────────── diff --git a/scripts/check-empty-changeset.mjs b/scripts/check-empty-changeset.mjs index dd097af68a..7751df6fab 100644 --- a/scripts/check-empty-changeset.mjs +++ b/scripts/check-empty-changeset.mjs @@ -128,14 +128,30 @@ const REPO_ROOT = resolve(__dirname, '..'); /** * The bump entries declared in a changeset's YAML frontmatter. * - * The entry regex is deliberately the SAME shape `check-changeset-no-major.mjs` - * uses to find `major` bumps. Two gates reading the same block must agree on - * what counts as a declaration, or one of them is judging a different file than - * it appears to. + * The entry regex is deliberately the SAME shape `check-changeset-no-major.mjs`, + * `check-adr-0087-registration.mjs` and `objectui-changeset-digest.mjs` use. + * Four readers of the same block must agree on what counts as a declaration, or + * one of them is judging a different file than it appears to. That agreement is + * asserted mechanically in this file's self-test (see "the family reads one + * block one way"), not merely claimed here (#7004). * * A file with no opening `---` fence declares nothing either, and is reported as * its own kind so the message can say which of the two shapes it is. * + * ## Why a comment-bearing entry is this gate's FALSE-RED half (#7004) + * + * The regex used to end `([A-Za-z]+)\s*$`, which accepts nothing after the bump + * word. So `"@objectstack/spec": major # keep` — a real major to + * @changesets/parse@0.4.3 — declared nothing here, and a PR that added a + * perfectly valid changeset was rejected as empty-frontmatter under #5471. Same + * anchor, same miss, for a QUOTED bump value (`: "major"`). + * + * The opposite direction was this gate's FALSE-GREEN half, and it is the one + * that mattered more: a whole-line comment containing a colon (`# note: major`) + * is entry-shaped, so a frontmatter block holding only comments parsed as a + * declaration of a package named `# note` — i.e. as NON-empty. That is exactly + * the #4898 input this gate exists to refuse. + * * @param {string} text * @returns {{ fenced: boolean, packages: string[] }} */ @@ -148,8 +164,10 @@ export function declaredBumpsIn(text) { const packages = []; for (let j = i + 1; j < lines.length; j++) { if (lines[j].trim() === '---') break; // end of frontmatter + if (/^\s*#/.test(lines[j])) continue; // a whole-line YAML comment declares nothing // "": | '': | : - const m = /^\s*["']?([^"':]+)["']?\s*:\s*([A-Za-z]+)\s*$/.exec(lines[j]); + // with an optionally quoted bump value and an optional trailing ` # comment`. + const m = /^\s*["']?([^"':]+)["']?\s*:\s*["']?([A-Za-z]+)["']?(?:\s+#.*)?\s*$/.exec(lines[j]); if (m) packages.push(m[1].trim()); } return { fenced: true, packages }; @@ -1052,6 +1070,114 @@ function selfTest() { ); assert(declaredBumpsIn('no fence here\n').fenced === false, 'parser: a fenceless file reports fenced=false'); + // ── THE FIX (#7004): comments and quoted bump values ───────────────────── + // + // This gate's FALSE-RED half. Every fixture here is a changeset that + // @changesets/parse@0.4.3 reads as a real release; before #7004 each one + // declared nothing to this parser, so a PR adding a perfectly valid + // changeset was rejected as empty-frontmatter under #5471. + // + // Predicted direction on reverse verification: restoring the old anchor + // (`([A-Za-z]+)\s*$`) turns exactly these red — `isEmptyDeclaration` goes + // true and the gate rejects a valid file. + const declares = (label, text, expected) => { + const { packages } = declaredBumpsIn(text); + assert( + packages.length === expected.length && expected.every((p) => packages.includes(p)), + `parser: ${label} ⇒ ${JSON.stringify(expected)} — got ${JSON.stringify(packages)}`, + ); + }; + declares('a trailing YAML comment (#7004)', '---\n"@objectstack/spec": minor # keep\n---\n\nbody\n', ['@objectstack/spec']); + declares('a trailing comment after a tab (#7004)', '---\n"@objectstack/spec": minor\t# keep\n---\n\nbody\n', ['@objectstack/spec']); + declares('a trailing comment containing a colon (#7004)', '---\n"@objectstack/spec": minor # note: keep\n---\n\nbody\n', [ + '@objectstack/spec', + ]); + declares('a double-quoted bump value (#7004)', '---\n"@objectstack/spec": "minor"\n---\n\nbody\n', ['@objectstack/spec']); + declares('a single-quoted bump value (#7004)', '---\n"@objectstack/spec": \'minor\'\n---\n\nbody\n', ['@objectstack/spec']); + declares('a quoted bump value AND a comment (#7004)', '---\n"@objectstack/spec": "minor" # keep\n---\n\nbody\n', ['@objectstack/spec']); + assert( + !isEmptyDeclaration('---\n"@objectstack/spec": minor # keep\n---\n\nbody\n'), + 'parser: a comment-bearing declaration is NOT an empty changeset — the #7004 false-RED, stated in this gate own vocabulary', + ); + + // The other direction, and this gate FALSE-GREEN half. A whole-line comment + // containing a colon is entry-shaped; it used to parse as a package named + // `# note`, so a frontmatter block of nothing but comments read as NON-empty + // and sailed through the very check that exists to refuse it (#4898). + assert( + isEmptyDeclaration('---\n# note: minor\n---\n\nbody\n'), + 'parser: a frontmatter holding only a colon-bearing comment IS empty — it declares no package (#7004 false-GREEN half)', + ); + assert( + isEmptyDeclaration('---\n # note: minor\n# another: patch\n---\n\nbody\n'), + 'parser: indented and repeated comment lines are still no declaration (#7004)', + ); + assert( + declaredBumpsIn('---\n# note: minor\n---\n\nbody\n').fenced === true, + 'parser: control — that comment-only fixture IS fenced, so the emptiness above is about the entries and not a missing fence', + ); + declares('control — a real entry beside a colon-bearing comment line', '---\n# note: minor\n"@objectstack/real": patch\n---\n\nbody\n', [ + '@objectstack/real', + ]); + + // YAML needs whitespace before an inline `#`, so this is the scalar + // `minor# keep` and @changesets/parse THROWS on it. Reading it as no + // declaration is agreement with changesets, not a residual gap. + assert( + isEmptyDeclaration('---\n"@objectstack/spec": minor# keep\n---\n\nbody\n'), + 'parser: `minor# keep` (no space before #) is not a YAML comment — changesets throws on it, so declaring nothing here agrees with it (#7004)', + ); + + // ── The family reads one block one way (#7004) ─────────────────────────── + // + // Until now that claim was four prose comments asserting each other. It is + // the load-bearing invariant of this family — the moment two of these + // parsers disagree, one gate is judging a different file than it appears to + // — so it is checked against the actual file text instead. + // + // #7004 is what a comment-only invariant costs: the trailing-comment gap + // reached all four carriers at once, and the fourth + // (`objectui-changeset-digest.mjs`) was not even named in the report, + // because nothing mechanical connected it to the other three. + { + const FAMILY = [ + 'scripts/check-changeset-no-major.mjs', + 'scripts/check-empty-changeset.mjs', + 'scripts/check-adr-0087-registration.mjs', + 'scripts/objectui-changeset-digest.mjs', + ]; + const literals = new Map(); + for (const rel of FAMILY) { + const path = join(REPO_ROOT, rel); + assert(existsSync(path), `family: ${rel} must exist — it is one of the four readers of a changeset frontmatter block`); + const src = existsSync(path) ? readFileSync(path, 'utf8') : ''; + const found = src.match(/\/\^\\s\*\["'\]\?.*?\/\.exec\(/g) ?? []; + // Anti-vacuous-green (#6983): an extraction that stops matching yields + // an empty set, and "all zero literals agree" is a green that judged + // nothing at all. So each file is asserted to have yielded exactly one. + assert(found.length === 1, `family: exactly one entry regex must be extractable from ${rel} — found ${found.length} (the extraction went stale, and the agreement below would compare nothing)`); + if (found.length === 1) literals.set(rel, found[0]); + } + const distinct = new Set(literals.values()); + assert( + distinct.size === 1, + `family: all four changeset frontmatter parsers must use a byte-identical entry regex — found ${distinct.size} distinct spellings: ${JSON.stringify([...literals])}`, + ); + // And the shared spelling must actually be the comment-aware one, so this + // block cannot go green on four identically-STALE copies. + assert( + [...distinct][0]?.includes('(?:\\s+#.*)?'), + 'family: the shared entry regex must carry the `(?:\\s+#.*)?` trailing-comment arm — four identical copies of the OLD anchor would satisfy the agreement check above while re-opening #7004', + ); + for (const rel of FAMILY) { + const src = existsSync(join(REPO_ROOT, rel)) ? readFileSync(join(REPO_ROOT, rel), 'utf8') : ''; + assert( + /\/\^\\s\*#\/\.test\(lines\[[ij]\]\)\) continue;/.test(src), + `family: ${rel} must skip whole-line YAML comments — without it a colon-bearing comment parses as a package named \`# note\` (#7004)`, + ); + } + } + // ── Missing input is a failure, never a pass (#4690) ───────────────────── { const { dir } = makeRepo({}, { 'a.txt': 'x\n' }); diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index 99d079ffd1..415bdf0f7c 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -215,6 +215,16 @@ function git(cwd, args, { captureStderr = false } = {}) { * the two live specimens puts it in the last), so `body` carries the whole text * for `hasBreakingAnnotation` to scan. * + * The entry regex is deliberately the SAME shape the three changeset GATES use + * (`check-changeset-no-major.mjs`, `check-empty-changeset.mjs`, + * `check-adr-0087-registration.mjs`), and `check-empty-changeset.mjs`'s + * self-test asserts all four are byte-identical (#7004). This file is the fourth + * carrier and the one #7004's report did not name; it reads objectui's + * changesets rather than this repo's, so its stake is the release RECORD, not a + * gate: a bump entry the regex cannot see makes the changeset read + * "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. + * * @param {string} text * @returns {{ packages: Record, summary: string, body: string }} */ @@ -230,7 +240,10 @@ export function parseChangeset(text) { i++; break; } - const m = /^\s*["']?([^"':]+)["']?\s*:\s*([A-Za-z]+)\s*$/.exec(lines[i]); + if (/^\s*#/.test(lines[i])) continue; // a whole-line YAML comment declares nothing + // "": | '': | : + // with an optionally quoted bump value and an optional trailing ` # comment`. + const m = /^\s*["']?([^"':]+)["']?\s*:\s*["']?([A-Za-z]+)["']?(?:\s+#.*)?\s*$/.exec(lines[i]); if (m && LEVEL_RANK[m[2].toLowerCase()]) packages[m[1].trim()] = m[2].toLowerCase(); } } @@ -1936,6 +1949,39 @@ function selfTest() { ), `status=${gateGreen.status}\n${gateGreen.stdout}\n${gateGreen.stderr}`, ); + // ---- #7004: the frontmatter shapes the old entry anchor hid ------------ + // + // This file is the FOURTH carrier of the family's entry regex and the one + // #7004's report did not name. Its consequence is neither a false green nor + // a false red but a silent DROP: an entry the regex cannot see makes the + // changeset read `release-nothing`, so the commit leaves the digest — the + // #4731 harm, reached through the parser instead of through a type filter. + // Worst, as ever, for breaking changes, which is the one class that must + // never vanish from a release record. + // + // Predicted direction on reverse verification: restoring the old anchor + // (`([A-Za-z]+)\s*$`) turns C1-C5 red (packages goes `{}`) and C6 red in the + // other direction (a phantom package named `# note`). + const pkgsOf = (block) => JSON.stringify(parseChangeset(`---\n${block}\n---\n\nsummary\n`).packages); + check('#7004 C1 a trailing YAML comment still declares its package', pkgsOf('"@object-ui/layout": major # keep') === '{"@object-ui/layout":"major"}', pkgsOf('"@object-ui/layout": major # keep')); + check('#7004 C2 a trailing comment after a tab', pkgsOf('"@object-ui/layout": minor\t# keep') === '{"@object-ui/layout":"minor"}', pkgsOf('"@object-ui/layout": minor\t# keep')); + check('#7004 C3 a double-quoted bump value', pkgsOf('"@object-ui/layout": "major"') === '{"@object-ui/layout":"major"}', pkgsOf('"@object-ui/layout": "major"')); + check('#7004 C4 a single-quoted bump value with a comment', pkgsOf('"@object-ui/layout": \'minor\' # keep') === '{"@object-ui/layout":"minor"}', pkgsOf('"@object-ui/layout": \'minor\' # keep')); + check( + '#7004 C5 a commented entry beside an uncommented one — BOTH survive', + pkgsOf('"@object-ui/a": major # keep\n"@object-ui/b": patch') === '{"@object-ui/a":"major","@object-ui/b":"patch"}', + pkgsOf('"@object-ui/a": major # keep\n"@object-ui/b": patch'), + ); + check( + '#7004 C6 a whole-line comment containing a colon is NOT a package (it used to parse as one named `# note`)', + pkgsOf('# note: major\n"@object-ui/real": patch') === '{"@object-ui/real":"patch"}', + pkgsOf('# note: major\n"@object-ui/real": patch'), + ); + check( + '#7004 C7 control — the same shape with an unknown bump word still declares nothing, so C1-C6 are about the entry regex and not about LEVEL_RANK being bypassed', + pkgsOf('"@object-ui/layout": enormous # keep') === '{}', + pkgsOf('"@object-ui/layout": enormous # keep'), + ); } finally { rmSync(tmp, { recursive: true, force: true }); }