diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index afaae0663b1..2e67cb6c284 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -237,6 +237,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'and one in RC pre-mode': 15, '#6099: the `minor` + prose-annotation family': 17, '#23112: a DECORATED annotation — a pictograph before the word': 16, + '#23118: a bold LEAD LABEL holding a `(breaking …)` parenthetical': 16, 'end-to-end through the shell driver': 5, 'an unwalkable range: REFUSE, loudly (#14178)': 1, 'the INITIAL pin still degrades, and says so': 1, @@ -255,7 +256,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 17; +const SELF_TEST_BATTERY_FLOOR = 18; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -482,13 +483,57 @@ const BREAKING_BLOCK_OPENER = new RegExp( 'iu', ); +/** + * A bold LEAD LABEL that carries the annotation in a parenthetical (#23118): + * ``**`@object-ui/layout` (breaking, declared `minor` per the version policy).**``, + * `**Narrowed (breaking).**`, `- **Tolerant face (breaking for invalid documents).**`. + * The word is not first, so the opener above cannot see it, and the label names + * no word first either, so neither can `BREAKING_EMPHASIS_LABEL`. + * + * A SECOND POSITION, beside the opener and not inside it, with all three parts + * required: + * + * * the line OPENS with `**`, after optional indentation and an optional + * bullet — the label is the line's first token; + * * the `(` sits INSIDE that first bold span: nothing before it may close the + * span, and the span closes on the same line; + * * `breaking` is the parenthetical's FIRST word, so `(non-breaking)` and + * `(not breaking)` stay what they say. + * + * So a `(breaking …)` anywhere else is prose — mid-sentence, after a label that + * already closed (`**Behaviour change** (breaking …`), in a bold span that is not + * the line's first token — and so is a label with no parenthetical. + * + * Judged on EVERY line, not only at a block start: a bullet opens a line without + * opening a block (`- **Strict authoring face.** …` then `- **Tolerant face + * (breaking for invalid documents).** …`), and a wrap column cannot manufacture + * a closed bold span holding the parenthetical the way it can land a bare word + * first on a line. + * + * Measured over objectui's changeset history (every `.changeset/*.md` blob on + * every head and every pull-request head of a clone, objectui `main` at + * `45ae6b89e845`: 6449 blobs), the label opens 41 lines in 39 blobs (18 paths), + * none of them read as breaking before, and each is a lead label. It never sits + * behind `__`, a heading mark, an ordered-list mark or a pictograph, and its + * span never crosses a line, so none of those is admitted. + * + * `markBreaking` STAMPS such an entry. The label names a package or a topic, not + * the class, so it is not a label `OPENS_WITH_BREAKING_LABEL` keeps verbatim, and + * the uniform marker is what keeps the count note's "Each is marked" true. None + * of those 39 blobs opens its summary with the label, so the stamp lands on an + * ordinary first paragraph there. + */ +const BREAKING_LEAD_LABEL = /^[ \t]*(?:[-*+][ \t]+)?\*\*(?:(?!\*\*)[^\n])*?\([ \t]*breaking\b(?:(?!\*\*)[^\n])*\*\*/i; + /** * Did the AUTHOR annotate this change as breaking, in the changeset body? * * THE PRECISION RULE IS POSITIONAL, NOT LEXICAL. "breaking" is an ordinary * English word that appears in changeset prose all the time; what makes an * occurrence a *declaration* is where the author put it — in an emphasis label, - * or at the head of a block. It is never enough that the word merely occurs. + * at the head of a block, or as the first word of a parenthetical inside the + * bold lead label that opens a line (`BREAKING_LEAD_LABEL`, #23118). It is never + * enough that the word merely occurs. * * Measured over `f5bc4c78be76..f995a452d2ca` (65 changesets, 5 mention the word): * @@ -522,6 +567,8 @@ export function hasBreakingAnnotation(body) { atBlockStart = true; continue; } + // #23118: line-initial, unlike the opener below — see `BREAKING_LEAD_LABEL`. + if (BREAKING_LEAD_LABEL.test(line)) return true; // A heading opens a block whether or not a blank line precedes it. if (atBlockStart || /^[ \t]*#{1,6}[ \t]+/.test(line)) { if (BREAKING_BLOCK_OPENER.test(line)) return true; @@ -1933,6 +1980,199 @@ function selfTest() { `breaking=${onlyA.breaking} scaffold=${onlyA.adr0087Scaffold}`, ); + // --- #23118: a bold LEAD LABEL holding a `(breaking …)` parenthetical ----- + // objectui `8a55f0ccd`'s `.changeset/12059-sidebar-no-reorder.md` annotates its + // change in a per-package lead label, and the word is neither first in the + // block nor first in the emphasis run, so the digest read it as not breaking. + // Every positive below is a shape measured in objectui's own changeset + // history (see `BREAKING_LEAD_LABEL`); every negative is a `(breaking …)` + // the rule must leave as prose, most of them verbatim from that same history. + battery('#23118: a bold LEAD LABEL holding a `(breaking …)` parenthetical'); + const label12059 = '**`@object-ui/layout` (breaking, declared `minor` per the version policy).**'; + // `.changeset/12059-sidebar-no-reorder.md` at objectui `8a55f0ccd`, paragraphs trimmed. + const body12059 = + "The console sidebar no longer lets a user drag the app's menu into a private order; the " + + 'Pinned section is now the one place a user orders entries (objectui#12059).\n\n' + + `${label12059} \`NavigationRenderer\` retires \`enableReorder\` and \`onReorder\`.\n\n` + + "**`@object-ui/app-shell`.** The sidebar no longer reads or writes the per-user menu order.\n"; + check( + "#23118 the card's example reads breaking (``**`@object-ui/layout` (breaking, …).**``)", + body12059.includes(`\n\n${label12059} `) && hasBreakingAnnotation(body12059), + ); + check( + '#23118 the rule is LINE-initial: a bulleted label that opens no block reads breaking', + // objectui `6152-object-form-unmirrored-members.md`: the second bullet of a list. + hasBreakingAnnotation( + 'What an author sees change:\n\n' + + '- **Strict authoring face.** A document using any of these keys is no longer refused.\n' + + '- **Tolerant face (breaking for invalid documents).** A value of the wrong type is refused.', + ), + ); + check( + '#23118 the label alone on its line, with more after the parenthetical, and in any case', + hasBreakingAnnotation('a\n\n**Narrowed (breaking).**\n\n`object-grid` x') && + hasBreakingAnnotation("a\n\n**Narrowed (breaking), `@object-ui/types`.** `@objectstack/spec`'s x") && + hasBreakingAnnotation('a\n\n**`@object-ui/i18n` (BREAKING, declared `minor`).** `console.nav` x'), + ); + check( + '#23118 a prose `(breaking …)` outside a lead label does not read breaking', + // objectui `11717-dashboard-widget-chart-measure-arity.md` (mid-sentence) and + // `6152-unmirrored-round4.md` (a wrapped line that opens with the parenthetical). + !hasBreakingAnnotation( + 'a\n\nThis narrows the accepted documents (breaking for a document of either shape). The fix: x', + ) && + !hasBreakingAnnotation( + 'a\n\nA wrong-typed value at either key is now refused at its path, where it used to be kept\n' + + 'unexamined\n(breaking for invalid documents).', + ), + ); + check( + '#23118 a lead label WITHOUT the parenthetical does not read breaking', + !hasBreakingAnnotation('a\n\n**`@object-ui/app-shell`.** The sidebar no longer reads x') && + !hasBreakingAnnotation('a\n\n- **Strict authoring face.** A document using any of these keys x'), + ); + check( + '#23118 a parenthetical AFTER the label has closed is prose (`**…** (breaking …`)', + // objectui `5896-feeditem-single-constructor.md`, verbatim. + !hasBreakingAnnotation( + 'a\n\n**Behaviour change on the console record page** (breaking in the objectui sense,\n' + + "shipped `minor` — objectui's `major` tracks `@objectstack`):", + ), + ); + check( + "#23118 a bold span that is NOT the line's first token does not read breaking", + !hasBreakingAnnotation('a\n\nSee **`@object-ui/layout` (breaking).** below.') && + !hasBreakingAnnotation('a\n\n**Removed.** **`@object-ui/layout` (breaking).** x'), + ); + check( + "#23118 `breaking` must be the parenthetical's FIRST word (`(non-breaking)`, `(not breaking …)`)", + !hasBreakingAnnotation('a\n\n**`@object-ui/layout` (non-breaking).** x') && + !hasBreakingAnnotation('a\n\n**Cleanup (not breaking for any author).** x'), + ); + check( + '#23118 an UNBOLDED label is prose: plain text and a code span stay unread', + // objectui `11168-element-family-inputs.md` and `11674-shortcuts-dialog-wired-only.md`. + !hasBreakingAnnotation( + 'a\n\nNarrowed (breaking for an author who wrote them, which the spec already\nrefuses):', + ) && + !hasBreakingAnnotation( + "a\n\n`@object-ui/i18n` (BREAKING; `minor` under this repository's release model): x", + ), + ); + check( + '#23118 markBreaking STAMPS a summary that opens with a lead label — it is not a breaking label', + markBreaking('**`@object-ui/types` (breaking, hence `minor`).** x') === + '**BREAKING** — **`@object-ui/types` (breaking, hence `minor`).** x' && + markBreaking('**BREAKING (v17)** — x') === '**BREAKING (v17)** — x', + ); + + // Through the real digest, in a repo of its own so the counts above stay + // pinned. (A) is the 12059 shape, (B) the bulleted label, (C) opens its + // SUMMARY with a lead label (no measured specimen; it pins the renderer + // through the real path), (D)-(F) are the prose shapes the rule must leave. + const uiLead = join(tmp, 'objectui-lead-label'); + mkdirSync(join(uiLead, '.changeset'), { recursive: true }); + const gLead = (...args) => git(uiLead, args); + gLead('init', '-q', '-b', 'main'); + gLead('config', 'user.email', 'selftest@objectstack.ai'); + gLead('config', 'user.name', 'self test'); + gLead('config', 'commit.gpgsign', 'false'); + const commitLead = (subject, files) => { + for (const [path, content] of Object.entries(files)) { + mkdirSync(dirname(join(uiLead, path)), { recursive: true }); + writeFileSync(join(uiLead, path), content); + } + gLead('add', '-A'); + gLead('commit', '-q', '-m', subject); + return gLead('rev-parse', 'HEAD').trim(); + }; + const baseLead = commitLead('chore: base', { 'README.md': 'base\n' }); + const shaLeadA = commitLead('feat(layout): the sidebar menu is not user-reorderable (#12059)', { + '.changeset/12059-sidebar-no-reorder.md': "---\n'@object-ui/layout': minor\n---\n\n" + body12059, + 'src/layout.ts': 'a\n', + }); + commitLead('feat(types): the object-form mirror declares its members (#6152)', { + '.changeset/6152-object-form-unmirrored-members.md': + "---\n'@object-ui/types': minor\n---\n\n" + + 'The `object-form` zod mirror declares the members its TypeScript twin declared.\n\n' + + '- **Strict authoring face.** A document using any of these keys is no longer refused.\n' + + '- **Tolerant face (breaking for invalid documents).** A value of the wrong type is refused.\n', + 'src/form.ts': 'b\n', + }); + commitLead('feat(types): a list view retires its legacy chart axes (#6152)', { + '.changeset/lead-label-summary.md': + "---\n'@object-ui/types': minor\n---\n\n" + + '**`@object-ui/types` (breaking for a writer of a retired key, hence `minor`).** The legacy\n' + + 'chart axes are refused at the key.\n', + 'src/chart.ts': 'c\n', + }); + commitLead('feat(dashboard): a widget measure arity (#11717)', { + '.changeset/11717-dashboard-widget-chart-measure-arity.md': + "---\n'@object-ui/plugin-dashboard': patch\n---\n\n" + + 'A chart widget refuses a measure it cannot draw.\n\n' + + 'This narrows the accepted documents (breaking for a document of either shape). The fix: x\n', + 'src/dashboard.ts': 'd\n', + }); + commitLead('refactor(app-shell): one feed item constructor (#5896)', { + '.changeset/5896-feeditem-single-constructor.md': + "---\n'@object-ui/app-shell': patch\n---\n\n" + + 'The record feed builds every item through one constructor.\n\n' + + '**Behaviour change on the console record page** (breaking in the objectui sense,\n' + + "shipped `minor` — objectui's `major` tracks `@objectstack`):\n", + 'src/feed.ts': 'e\n', + }); + commitLead('feat(app-shell): the sidebar keeps no private order', { + '.changeset/app-shell-no-private-order.md': + "---\n'@object-ui/app-shell': patch\n---\n\n" + + '**`@object-ui/app-shell`.** The sidebar no longer reads or writes the per-user menu order.\n', + 'src/sidebar.ts': 'f\n', + }); + const headLead = gLead('rev-parse', 'HEAD').trim(); + const lead = buildDigest({ objectuiRoot: uiLead, frameworkRoot: fwPlain, from: baseLead, to: headLead }); + const leadFlag = (frag) => lead.releasing.find((r) => r.path.includes(frag))?.breaking; + check( + '#23118 exactly the three lead-label annotations are counted, the three prose shapes are not', + lead.releasing.length === 6 && + lead.breaking === 3 && + lead.breakingByAnnotation === 3 && + leadFlag('12059') === true && + leadFlag('6152-object-form') === true && + leadFlag('lead-label-summary') === true, + `releasing=${lead.releasing.length} breaking=${lead.breaking} byAnnotation=${lead.breakingByAnnotation}`, + ); + check( + '#23118 the count line reads 3, by annotation', + lead.body.includes("⚠️ 3 of these carry a breaking change: 3 by the author's own breaking annotation"), + lead.body, + ); + check( + '#23118 the 12059-shaped entry GETS the marker on its line', + lead.body.includes("- **minor** — **BREAKING** — The console sidebar no longer lets a user drag the app's menu"), + lead.body, + ); + check( + '#23118 a summary opening with a lead label is stamped ONCE, the label kept verbatim after it', + lead.body.includes( + '- **minor** — **BREAKING** — **`@object-ui/types` (breaking for a writer of a retired key, hence `minor`).** The legacy', + ) && !lead.body.includes('**BREAKING** — **BREAKING'), + lead.body, + ); + check( + '#23118 the three prose entries are NOT marked', + lead.body.includes('- **patch** — A chart widget refuses a measure it cannot draw.') && + lead.body.includes('- **patch** — The record feed builds every item through one constructor.') && + lead.body.includes('- **patch** — **`@object-ui/app-shell`.** The sidebar no longer reads'), + lead.body, + ); + // The family card's second consequence: a range whose ONLY breaking entry is + // a lead label used to raise no count, and so no ADR-0087 disposition prompt. + const onlyLead = buildDigest({ objectuiRoot: uiLead, frameworkRoot: fwPlain, from: baseLead, to: shaLeadA }); + check( + '#23118 a range whose ONLY breaking entry is a lead label still raises the ADR-0087 question', + onlyLead.breaking === 1 && onlyLead.adr0087Scaffold === true && onlyLead.body.includes(ADR_0087_SCAFFOLD), + `breaking=${onlyLead.breaking} scaffold=${onlyLead.adr0087Scaffold}`, + ); + // --- end-to-end through the shell driver ------------------------------- battery('end-to-end through the shell driver'); const fwRun = join(tmp, 'fw-run');