diff --git a/scripts/check-role-word.mjs b/scripts/check-role-word.mjs index a74a8840b2..6a36b40a0f 100644 --- a/scripts/check-role-word.mjs +++ b/scripts/check-role-word.mjs @@ -122,6 +122,253 @@ function countMatches(text) { return m ? m.length : 0; } +// ── The vendor-wire fence exemption (#10533) ───────────────────────────────── +// +// Maintainer ruling, 2026-08-21 — 「其他接受」 — accepting option B: the ratchet +// gains a narrowly-scoped exemption for upstream-owned vocabulary appearing +// inside fenced code blocks marked as vendor wire payloads; prose remains fully +// ratcheted (ADR-0090 D3's actual target). +// +// This is NOT an amendment to D3. D3's own Word ban paragraph already carves the +// boundary out in its own words: +// +// "Single documented exception: the better-auth boundary — `sys_member.role` +// is third-party schema we do not own; it remains…" +// +// D3 reserves the word in "identifiers, UI copy, and documentation", and what it +// is aimed at is ObjectStack PROSE reaching for the word where `permission_set` / +// `position` / `business_unit` is meant. A JSON body quoting a third party's +// literal wire key is not that and never was — but a per-file EXACT ratchet has +// no way to say so. It can only say "frozen count", and because it is per-file +// and exact, NO file anywhere has spare budget: the corpus cannot gain a single +// new occurrence, in any file, by any author who is not the maintainer. +// +// Measured, not hypothetical. `POST /api/v1/auth/organization/add-member` reads +// `body.role` (`readRole()` in packages/plugins/plugin-auth/src/organization-add-member.ts) +// and — unlike `userId` / `organizationId` / `teamId` — carries no snake_case +// alias, so the wire name is that word and nothing else. #10050 had to document a +// REQUIRED parameter without naming it. Adding it to the `http` fence that page +// already has moved content/docs/permissions/authentication.mdx 4 → 5 and this +// gate refused: `role-word count grew 4 → 5`. +// +// ## What bounds the exemption is the MARKING, not the fence +// +// Keying on "is a fenced block" would exempt every code block in the corpus — +// materially broader than what was accepted, and it would take the ratchet's +// teeth out of every example in every page. A block is exempt only when it is +// CLAIMED, one block at a time, by a marker line written directly above it. So +// the exemption's entire surface is greppable (`git grep os:vendor-wire`), every +// use of it is a reviewable line in a diff rather than a property of a file's +// shape, and the count of blocks it covers is printed on every run (see +// `exemptClause`) instead of having to be rediscovered. +// +// The marker also names WHICH upstream it claims, and that vendor must be one +// this repo has actually decided about (VENDOR_BOUNDARIES). D3 documents exactly +// one, so a second boundary is a decision — taken by editing this file under +// review, never by an author typing a new word into a docs page. That is the +// difference between an exemption with a floor and an exemption that widens +// itself. +// +// ## Deliberately NOT extended to inline code spans +// +// A backticked word in a sentence is prose with backticks around it; inline code +// is everywhere, and exempting it would leave the ratchet with nothing. A prose +// mention still costs a baselined occurrence — including the route path +// `/organization/update-member-role` written into a sentence, which the card +// names as a second-order bite. The remedy there is the same one every author +// has: put the wire shape in a marked fence, where a URL belongs anyway. + +/** + * The claim token. Spelled ONCE — every matcher, message and self-test derives + * from it, so renaming it cannot leave a diagnostic pointing at a marker that + * nothing recognises any more. + * + * It deliberately contains no form of the reserved word. A marker that matched + * WORD would contribute one occurrence per marked block and defeat itself, which + * is why the `` spelling floated on the card is not the one + * used here: `\brole\b` matches inside `role-word` (the hyphen is a word + * boundary), so every marker would have paid for itself. + */ +const VENDOR_WIRE_TOKEN = 'os:vendor-wire'; + +/** + * The upstreams whose wire vocabulary this repo has decided to carry literally. + * ADR-0090 D3 documents exactly one. Adding an entry is a deliberate, reviewable + * edit to this gate — which is the point: it keeps the exemption's blast radius + * in source, where review can see it, rather than in author discipline. + */ +const VENDOR_BOUNDARIES = new Set(['better-auth']); + +// Comment syntax per EXTENSION, not per root. MDX has no HTML comments — +// fumadocs-mdx fails the build outright on `` ("Unexpected character +// `!`… to create a comment in MDX, use `{/* text */}`") — while the MDX +// expression form in a plain `.md` file renders as literal text. The marker +// follows each format's own syntax, exactly as the `os:check` convention already +// does; the reference implementation is +// packages/spec/scripts/check-skill-examples.ts. +// +// Line comments, not a JSDoc block, on purpose: the MDX spelling ENDS in the +// two characters that close a block comment, so quoting it inside one truncates +// the comment mid-sentence and the file stops parsing. The reference above is +// written this way for the same reason. +// +// Keyed by extension rather than by root because EXTENSIONS admits both kinds +// under either ROOT: a `.md` file added under content/docs must take the `.md` +// spelling, and a root-keyed table would hand it the one that breaks. +const MARKER_SYNTAX = { + '.md': { open: '' }, + '.mdx': { open: '{/*', close: '*/}' }, +}; + +/** `.mdx` or `.md`, for a path this gate walked (EXTENSIONS admits only those). */ +function extensionOf(file) { + return file.endsWith('.mdx') ? '.mdx' : '.md'; +} + +/** The marker an author writes, rendered for one extension. */ +function markerFor(ext, vendor) { + const s = MARKER_SYNTAX[ext]; + return `${s.open} ${VENDOR_WIRE_TOKEN} ${vendor} ${s.close}`; +} + +/** + * Does this line ATTEMPT the claim? Deliberately generous — any comment carrying + * the token, in either format, naming any vendor word at all. + * + * Generosity is the fail-safe direction. A line this recognises but + * `vendorWireClaim()` refuses becomes a LOUD orphan; a line neither recognises is + * a marker that silently checks nothing while reading as intentional, which is + * strictly the worse failure. `check-skill-examples.ts` reached the same split + * for `os:check`, and for the same reason. + */ +function looksLikeVendorWireClaim(line) { + const t = line.trim(); + if (!t.includes(VENDOR_WIRE_TOKEN)) return false; + return (t.startsWith('')) + || (t.startsWith('{/*') && t.endsWith('*/}')); +} + +/** + * The vendor this line claims, or null if it opts nothing in. All three + * conditions are required, and each one is a way the exemption could otherwise + * widen by accident: the exact comment syntax for THIS extension, exactly the + * token plus exactly one vendor word, and that vendor declared in + * VENDOR_BOUNDARIES. + * + * @param {string} line + * @param {string} ext + * @returns {string | null} + */ +function vendorWireClaim(line, ext) { + const syntax = MARKER_SYNTAX[ext]; + if (!syntax) return null; + const t = line.trim(); + if (!t.startsWith(syntax.open) || !t.endsWith(syntax.close)) return null; + const inner = t.slice(syntax.open.length, t.length - syntax.close.length).trim(); + const parts = inner.split(/\s+/); + if (parts.length !== 2 || parts[0] !== VENDOR_WIRE_TOKEN) return null; + return VENDOR_BOUNDARIES.has(parts[1]) ? parts[1] : null; +} + +/** + * An opening code fence, CommonMark-shaped: up to three spaces of indent (58 of + * them in today's corpus, inside list items), a run of three or more backticks, + * and an info string that may not itself contain a backtick. + */ +const FENCE_OPEN = /^ {0,3}(`{3,})([^`]*)$/; + +/** + * Every vendor-wire exemption in one file, plus the two ways a marker can be + * present and mean nothing. + * + * The backtick RUN LENGTH is tracked, not just "a fence": today's corpus holds + * four ```` fences that wrap ``` examples, and a closer that ignored length + * would end such a block on its own contents — scoping the exemption to a + * fragment of what the author claimed, or past it. + * + * @param {string} text + * @param {string} ext + * @returns {{blocks: number, occurrences: number, orphans: number[], unterminated: number[]}} + */ +function analyzeVendorWire(text, ext) { + const lines = text.split('\n'); + const inFence = new Array(lines.length).fill(false); + const claimed = new Set(); + const orphans = []; + const unterminated = []; + let blocks = 0; + let occurrences = 0; + + for (let i = 0; i < lines.length; i++) { + const open = FENCE_OPEN.exec(lines[i]); + if (!open) continue; + const run = open[1].length; + const closeFence = new RegExp(`^ {0,3}\`{${run},}[ \\t]*$`); + let end = i + 1; + while (end < lines.length && !closeFence.test(lines[end])) end++; + const vendor = i > 0 && !inFence[i - 1] ? vendorWireClaim(lines[i - 1], ext) : null; + if (vendor) { + if (end >= lines.length) { + // An unclosed fence runs to the end of the document (CommonMark), so + // honouring this marker would exempt the whole remainder of the file + // from one line — the silent widening this whole design exists to + // prevent. Refused instead, and named. + unterminated.push(i + 1); + } else { + claimed.add(i - 1); + blocks += 1; + for (let b = i + 1; b < end; b++) occurrences += countMatches(lines[b]); + } + } + for (let s = i; s < Math.min(end + 1, lines.length); s++) inFence[s] = true; + i = end; + } + + for (let i = 0; i < lines.length; i++) { + // Top level only. A marker shown INSIDE a fence is example text, and this + // gate's own convention has to be documentable — in these very roots — + // without the documentation tripping it. + if (inFence[i] || claimed.has(i)) continue; + if (looksLikeVendorWireClaim(lines[i])) orphans.push(i + 1); + } + + return { blocks, occurrences, orphans, unterminated }; +} + +/** + * A marker that opts nothing in is an ERROR, not a no-op. It reads as + * intentional — someone believed the block below it was covered — while the + * block is still counted, so the author meets a "count grew" verdict that names + * neither the marker nor the reason it did not take. + * + * @param {string} file + * @param {number} line + * @param {string} ext + * @returns {string} + */ +function orphanMarkerMessage(file, line, ext) { + return ( + `${file}:${line}: a ${VENDOR_WIRE_TOKEN} marker that opts NOTHING in. It must be the line ` + + 'IMMEDIATELY above an opening code fence (no blank line between), spelled for this file ' + + `type — ${markerFor(ext, '')} — and name exactly one declared vendor boundary ` + + `(${[...VENDOR_BOUNDARIES].join(', ')}). A placed-but-inert marker is worse than no marker: ` + + 'it reads as intentional while the block it claims is still fully counted.' + ); +} + +/** + * @param {string} file + * @param {number} line + * @returns {string} + */ +function unterminatedFenceMessage(file, line) { + return ( + `${file}:${line}: a ${VENDOR_WIRE_TOKEN} marker claims a code fence that is never closed. ` + + 'An unclosed fence runs to the end of the document, so honouring it would exempt the whole ' + + 'rest of the file from a single marker. Close the fence.' + ); +} + // ── The ratchet-remedy authority convention (#8435) ────────────────────────── // // This gate's second remedy is `--update`, which expands the baseline. That is @@ -183,9 +430,13 @@ function ratchetRemedyCarriesAuthority(message) { function newUseMessage(file, count) { return ( `${file}: NEW use of the reserved word "role" (${count} occurrence(s)). ` - + 'ADR-0090 D3: use permission_set / position / business_unit. That is the fix, and the ' - + `only one of the two you can take on your own. ${RATCHET_AUTHORITY_MARKER}, NOT a co-equal ` - + 'option: for a genuine boundary (better-auth, ARIA, quoted history), add it to ' + + 'ADR-0090 D3: use permission_set / position / business_unit. That is the fix. ' + + `If instead this is a third party's literal WIRE key, put it in a fenced code block and ` + + `mark that block \`${markerFor(extensionOf(file), '')}\` on the line directly above ` + + `the opening fence (declared boundaries: ${[...VENDOR_BOUNDARIES].join(', ')}); prose stays ` + + `ratcheted either way. Those two are the paths you can take on your own. ` + + `${RATCHET_AUTHORITY_MARKER}, NOT a co-equal third: for a genuine boundary that is NOT a ` + + 'fenced wire payload (ARIA, quoted history), add it to ' + `${BASELINE_PATH} by running \`node scripts/check-role-word.mjs --update\`. The gated thing ` + 'is that ACT, not the file — `--update` rewrites the whole baseline from the current tree, ' + 'so it admits your occurrence and re-baselines every other file in one stroke. The baseline ' @@ -194,6 +445,33 @@ function newUseMessage(file, count) { ); } +/** + * The count-GREW verdict, named and pure for the same reason as the message + * above — and newly so. It used to be built inline, which is exactly why the + * defect this gate was carded for was invisible from here: the author of a + * vendor wire payload met "New occurrences are banned" with no reachable remedy + * named at all, and the only one that existed was the maintainer's. + * + * It deliberately does NOT offer the baseline path. `RATCHET_EXPANSION_OFFER` + * keys on that offer, so a message that named it would need the authority + * marker; the point of this one is that the author now HAS a path of their own. + * + * @param {string} file + * @param {number} allowed + * @param {number} count + * @returns {string} + */ +function grewMessage(file, allowed, count) { + return ( + `${file}: role-word count grew ${allowed} → ${count}. New occurrences are banned ` + + '(ADR-0090 D3): use permission_set / position / business_unit. If the new occurrence is a ' + + `third party's literal WIRE key, put it in a fenced code block and mark that block ` + + `\`${markerFor(extensionOf(file), '')}\` on the line directly above the opening ` + + `fence (declared boundaries: ${[...VENDOR_BOUNDARIES].join(', ')}). Prose — including a ` + + 'route path written into a sentence — stays ratcheted; put the wire shape in the fence.' + ); +} + // ── What a PASSING run tells the reader (#9910) ──────────────────────── // // The green line used to be, in full: @@ -268,6 +546,29 @@ function scanClause(scanned) { return `${total} ${kinds} file(s) read across ${scanned.length} root(s) — ${perRoot}`; } +/** + * What the scan did NOT count, as one clause, shared by both success paths for + * the reason `scanClause` is (#10533). + * + * The #9910 principle this follows: a run reports what it READ, because a number + * derived only from the ledger cannot tell a reader whether the population moved + * underneath it. The vendor-wire exemption creates a second such blind spot — a + * suppressed occurrence is invisible in every ledger number and in the file + * count alike — so the volume is stated outright on every run. It is the answer + * to "how far has this widened?", printed rather than rediscovered, and a zero + * here is as informative as a large number: it says no block in the corpus + * claims the boundary at all. + * + * @param {{blocks: number, occurrences: number}} exempt + * @returns {string} + */ +function exemptClause(exempt) { + return ( + `${exempt.blocks} ${VENDOR_WIRE_TOKEN} block(s) suppressed ${exempt.occurrences} ` + + 'occurrence(s)' + ); +} + /** * The GREEN body, named and pure so the self-test can assert on the sentence an * author actually reads — the counts are interpolated, so reading this file's @@ -276,14 +577,16 @@ function scanClause(scanned) { * @param {{root: string, files: number}[]} scanned per-ROOT counts, in ROOTS order * @param {Record} ledger files still carrying the word (== the * baseline on any run that reaches this line) + * @param {{blocks: number, occurrences: number}} exempt vendor-wire suppressions * @returns {string} */ -function successSummary(scanned, ledger) { +function successSummary(scanned, ledger, exempt) { const fileCount = Object.keys(ledger).length; const occurrences = Object.values(ledger).reduce((n, c) => n + c, 0); return ( 'check-role-word: OK, no new occurrences of the reserved word.\n' + ` Scanned: ${scanClause(scanned)}.\n` + + ` Exempt: ${exemptClause(exempt)}.\n` + ` Ledger: ${fileCount} baselined file(s) still carrying it ` + `(${occurrences} occurrence(s)) in ${BASELINE_PATH}.` ); @@ -303,12 +606,13 @@ function successSummary(scanned, ledger) { * * @param {{root: string, files: number}[]} scanned per-ROOT counts, in ROOTS order * @param {Record} ledger the freshly written baseline + * @param {{blocks: number, occurrences: number}} exempt vendor-wire suppressions * @returns {string} */ -function updateSummary(scanned, ledger) { +function updateSummary(scanned, ledger, exempt) { return ( `role-word baseline updated: ${Object.keys(ledger).length} file(s) baselined ` - + `from ${scanClause(scanned)}.` + + `from ${scanClause(scanned)}, with ${exemptClause(exempt)}.` ); } @@ -464,9 +768,14 @@ function selfTest() { const SCANNED = [{ root: 'content/docs', files: 179 }, { root: 'skills', files: 36 }]; const DEAD_SCAN = [{ root: 'content/docs', files: 0 }, { root: 'skills', files: 0 }]; const PAID_OFF = {}; + // Synthetic for the same reason the counts above are: closed fixtures, never a + // reading of the tree. `NO_EXEMPT` is what today's corpus renders (no block + // claims the boundary); `SOME_EXEMPT` is the state a future one reaches. + const NO_EXEMPT = { blocks: 0, occurrences: 0 }; + const SOME_EXEMPT = { blocks: 2, occurrences: 3 }; - const greenPaid = successSummary(SCANNED, PAID_OFF); - const greenDead = successSummary(DEAD_SCAN, PAID_OFF); + const greenPaid = successSummary(SCANNED, PAID_OFF, NO_EXEMPT); + const greenDead = successSummary(DEAD_SCAN, PAID_OFF, NO_EXEMPT); // (1) THE property this card exists for, pinned as a property and not as // text: once the debt is paid — the state this ratchet is BUILT to reach — a @@ -490,7 +799,7 @@ function selfTest() { // AWAY is refused before this line renders, so what this fixture stands for // now is the residual case: a root that exists and contributed nothing. const oneRootGone = successSummary( - [{ root: 'content/docs', files: 179 }, { root: 'skills', files: 0 }], PAID_OFF); + [{ root: 'content/docs', files: 179 }, { root: 'skills', files: 0 }], PAID_OFF, NO_EXEMPT); expect('#9910 — a root that contributed NOTHING is still named, with its zero (a root can ' + 'exist and read empty; dropping it from the line hides that behind the other root\'s total)', /\bskills 0\b/.test(oneRootGone) && oneRootGone !== greenPaid); @@ -500,7 +809,7 @@ function selfTest() { // tree it just read. expect('#9910 — the --update confirmation states its input volume too, so re-baselining ' + 'over a dead scan cannot read like a debt fully paid', - updateSummary(DEAD_SCAN, PAID_OFF) !== updateSummary(SCANNED, PAID_OFF)); + updateSummary(DEAD_SCAN, PAID_OFF, NO_EXEMPT) !== updateSummary(SCANNED, PAID_OFF, NO_EXEMPT)); // ── A missing ROOT is REFUSED, per root (#9932) ─────────────────────────── // @@ -634,6 +943,254 @@ function selfTest() { expect('the declared form is NOT a ROOTS entry', !ROOTS.some((r) => ROOT_DIR_WATCH_HINTS.includes(r))); + // ── The vendor-wire fence exemption (#10533) ─────────────────────────────── + // + // Maintainer ruling, 2026-08-21: 「其他接受」 — option B, with a self-test + // covering the exemption in all three directions plus a positive control. + // Those four are labelled (B1)…(B4) below and are the load-bearing set; the + // legs after them each close one way the MARKING could stop bounding the + // exemption, which is the only thing standing between "vendor wire payloads" + // and "every fenced block in the corpus". + // + // Fixtures differ from each other in exactly ONE line wherever possible, so a + // failure names the property and not the fixture. + const VW_MDX = markerFor('.mdx', 'better-auth'); + const VW_MD = markerFor('.md', 'better-auth'); + /** Counted occurrences — what the ratchet actually compares against. */ + const vwCount = (text, ext) => countMatches(text) - analyzeVendorWire(text, ext).occurrences; + + const FENCED = [ + '# Attaching an existing user', + '', + VW_MDX, + '```http', + 'POST /api/v1/auth/organization/add-member', + '{ "userId": "usr_01HZX", "role": "member" }', + '```', + '', + ].join('\n'); + const UNMARKED = FENCED.replace(`${VW_MDX}\n`, ''); + const PROSE = FENCED.replace( + '# Attaching an existing user', + '# Attaching an existing user\n\nEvery member carries a role, said the prose.', + ); + + // (B4) THE POSITIVE CONTROL, and it comes first because every leg below is + // vacuous without it: the fixtures really do contain the reserved word. A + // fixture that had lost it would make (B1) pass by saying nothing at all — + // zero counted occurrences because there were none to count — and that is + // precisely the shape of green this gate exists to refuse elsewhere. + expect('#10533 (B4, positive control) — the fixtures CARRY the reserved word, so a zero from ' + + '(B1) is the exemption working and not an empty fixture', + countMatches(FENCED) === 1 && countMatches(UNMARKED) === 1 && countMatches(PROSE) === 2); + + // (B1) The new behaviour. + expect('#10533 (B1) — a fenced vendor-wire block PASSES: its occurrence is not counted', + vwCount(FENCED, '.mdx') === 0); + // (B2) D3's actual target, untouched. + expect('#10533 (B2) — a PROSE occurrence still FAILS, in the very same file whose fenced block ' + + 'is exempt (the exemption suppresses a block, never a file)', + vwCount(PROSE, '.mdx') === 1); + // (B3) The leg that carries the design. Without it the exemption keys on "is a + // fence" and silently widens to every code block in the corpus — materially + // broader than what was accepted. The ruling says "marked as vendor wire + // payloads"; the marking is what bounds it. + expect('#10533 (B3) — an UNMARKED fence still FAILS (the exemption keys on the MARKING, never ' + + 'on being a code block)', + vwCount(UNMARKED, '.mdx') === 1); + + // The tally the green line publishes has to be real, or the "how far has this + // widened?" answer printed on every run is decorative. + const fencedReport = analyzeVendorWire(FENCED, '.mdx'); + expect('#10533 — the exemption REPORTS itself: one claimed block, one suppressed occurrence', + fencedReport.blocks === 1 && fencedReport.occurrences === 1 + && fencedReport.orphans.length === 0 && fencedReport.unterminated.length === 0); + + // A marker that matched WORD would add one occurrence per marked block and + // defeat itself — the reason the `` spelling floated on + // the card is not the one implemented (`\brole\b` matches inside `role-word`). + expect('#10533 — the marker token itself contains no form of the reserved word (a marker that ' + + 'did would pay for every block it exempts)', + countMatches(VENDOR_WIRE_TOKEN) === 0 && countMatches(VW_MDX) === 0 + && countMatches(VW_MD) === 0); + + // ── Every way the MARKING could stop bounding the exemption ──────────────── + // + // Each fixture is a marker an author might plausibly write. Each must (a) opt + // NOTHING in — the block stays counted — and (b) be reported as an orphan, not + // ignored. (b) is the half that matters: a marker that silently checks nothing + // reads as intentional, and its author meets a "count grew" verdict naming + // neither the marker nor the reason it did not take. + const nearMisses = [ + ['a blank line between marker and fence', FENCED.replace(`${VW_MDX}\n`, `${VW_MDX}\n\n`)], + ['the .md spelling in an .mdx file', FENCED.replace(VW_MDX, VW_MD)], + ['a vendor nobody declared', FENCED.replace(VW_MDX, markerFor('.mdx', 'acme-corp'))], + ['the bare token, naming no vendor', FENCED.replace(VW_MDX, `{/* ${VENDOR_WIRE_TOKEN} */}`)], + ['a marker above PROSE rather than a fence', + FENCED.replace(VW_MDX, `${VW_MDX}\nNot a fence.`)], + ]; + for (const [label, text] of nearMisses) { + const report = analyzeVendorWire(text, '.mdx'); + expect(`#10533 — ${label} opts NOTHING in (the block stays counted)`, + vwCount(text, '.mdx') === 1 && report.blocks === 0); + expect(`#10533 — ${label} is reported as an ORPHAN, not ignored (a placed-but-inert marker ` + + 'reads as intentional)', + report.orphans.length === 1); + } + + // The `.md` half. PR #10038 is this file's standing reminder that the skills + // root is a real population and not an afterthought. + const FENCED_MD = FENCED.replace(VW_MDX, VW_MD); + expect('#10533 — the .md spelling opts in for a .md file (both roots admit both extensions, so ' + + 'the syntax is keyed by EXTENSION, not by root)', + vwCount(FENCED_MD, '.md') === 0 && analyzeVendorWire(FENCED_MD, '.md').blocks === 1); + expect('#10533 — and the .mdx spelling in a .md file opts nothing in, as an orphan (it would ' + + 'render as literal text there)', + vwCount(FENCED, '.md') === 1 && analyzeVendorWire(FENCED, '.md').orphans.length === 1); + + // Backtick RUN LENGTH. Today's corpus holds four ```` fences wrapping ``` + // examples; a closer that ignored length would end this block on its own + // contents and leave the last payload line counted. + const NESTED = [ + VW_MDX, + '````md', + '```http', + 'POST /api/v1/auth/organization/add-member', + '```', + '{ "role": "member" }', + '````', + '', + ].join('\n'); + expect('#10533 — a ```` block that wraps ``` examples is exempt to its OWN closing fence (a ' + + 'length-blind closer would end it on the inner fence and count the rest)', + countMatches(NESTED) === 1 && vwCount(NESTED, '.mdx') === 0); + + // An unclosed fence runs to end of document (CommonMark), so honouring a + // marker on one would hand a single line the whole rest of the file. + const UNCLOSED = [VW_MDX, '```http', '{ "role": "member" }', '', 'Prose with a role.', ''].join('\n'); + const unclosedReport = analyzeVendorWire(UNCLOSED, '.mdx'); + expect('#10533 — a marker on a fence that is never closed exempts NOTHING and is reported (it ' + + 'would otherwise exempt the whole remainder of the file from one line)', + unclosedReport.unterminated.length === 1 && unclosedReport.blocks === 0 + && vwCount(UNCLOSED, '.mdx') === 2); + + // This convention has to be documentable IN the roots it governs: a marker + // shown inside a fence is example text, not a claim. + const ILLUSTRATED = ['```md', VW_MDX, '```json', '{ "role": "member" }', '```', ''].join('\n'); + const illustratedReport = analyzeVendorWire(ILLUSTRATED, '.mdx'); + expect('#10533 — a marker shown INSIDE a fence is example text: it claims nothing and is not ' + + 'an orphan (this gate\'s own convention must be documentable in content/docs and skills)', + illustratedReport.blocks === 0 && illustratedReport.orphans.length === 0 + && vwCount(ILLUSTRATED, '.mdx') === 1); + + // ── The diagnostics name the path that now exists ────────────────────────── + // + // The defect was reachability, not just capacity: an author hitting the + // ratchet with a vendor payload had no discoverable remedy, and the only one + // named was the maintainer's. Derived from the constants, so renaming the + // token cannot leave a message pointing at a marker nothing recognises. + const grew = grewMessage('content/docs/example.mdx', 4, 5); + const newUse = newUseMessage('content/docs/example.mdx', 2); + expect('#10533 — BOTH refusal messages name the vendor-wire marker (the count-GREW one is how ' + + 'this defect was actually met, and it used to name no author-takeable remedy at all)', + grew.includes(VENDOR_WIRE_TOKEN) && newUse.includes(VENDOR_WIRE_TOKEN)); + expect('#10533 — each message renders the marker in the spelling for the file it names', + grew.includes(markerFor('.mdx', '')) && newUse.includes(markerFor('.mdx', ''))); + expect('#10533 — a .md file is told the .md spelling', + grewMessage('skills/objectstack-api/SKILL.md', 1, 2).includes(markerFor('.md', ''))); + // The #8435 convention, held across the rewrite: the marker path is the + // AUTHOR's, so the count-GREW message must not drag the maintainer-only label + // onto itself by offering the baseline; the NEW-use message still offers the + // baseline and still carries the label. + expect('#8435 + #10533 — the count-GREW message offers no baseline expansion, so it needs no ' + + 'maintainer-only marker (the remedy it names is the author\'s own)', + !RATCHET_EXPANSION_OFFER.test(grew) && ratchetRemedyCarriesAuthority(grew)); + expect('#8435 + #10533 — the NEW-use message still offers the baseline path AND still marks it ' + + 'maintainer-only, now as a THIRD option rather than the second', + RATCHET_EXPANSION_OFFER.test(newUse) && ratchetRemedyCarriesAuthority(newUse)); + + // The green line publishes the exemption volume, for the #9910 reason: a + // suppressed occurrence is invisible in every ledger number and in the file + // count alike, so "how far has this widened?" is printed, not rediscovered. + expect('#10533 — the GREEN body states the exemption volume, and renders DIFFERENTLY when the ' + + 'exemption moves (a corpus that quietly grew claims cannot print the same green)', + successSummary(SCANNED, PAID_OFF, NO_EXEMPT) + !== successSummary(SCANNED, PAID_OFF, SOME_EXEMPT)); + expect('#10533 — the --update confirmation states it too (it re-baselines from counts the ' + + 'exemption already reduced)', + updateSummary(SCANNED, PAID_OFF, NO_EXEMPT) !== updateSummary(SCANNED, PAID_OFF, SOME_EXEMPT)); + expect('#10533 — a corpus claiming NOTHING says so outright, rather than omitting the clause', + successSummary(SCANNED, PAID_OFF, NO_EXEMPT).includes(exemptClause(NO_EXEMPT))); + + // ── The exemption at the PROGRAM level ───────────────────────────────────── + // + // Everything above drives a predicate. A predicate the program never consults + // would satisfy all of it — the same "declaration that silently self-cancels" + // shape the #9932 legs below were written against — so these build real trees + // and read a child process's real exit status, never a pipe's. + // + // Both fixture files live in the SAME root on purpose: it is the extension, + // not the root, that selects the marker syntax, and a tree that put each + // spelling in its "own" root would pass just as well under a root-keyed table. + const vwSandbox = mkdtempSync(join(tmpdir(), 'check-role-word-vendorwire-')); + try { + const [root] = ROOTS; + const MDX = `${root}/members.mdx`; + const MD = `${root}/members.md`; + const buildTree = (name, files) => { + const dir = join(vwSandbox, name); + for (const r of ROOTS) mkdirSync(join(dir, r), { recursive: true }); + for (const [rel, body] of Object.entries(files)) writeFileSync(join(dir, rel), body); + return dir; + }; + + // (B1) at program level, in both spellings at once. No baseline file exists + // in these trees, so ANY counted occurrence is a NEW use and exit 1 — which + // makes exit 0 here exactly the claim "nothing was counted". + const passing = runIn(buildTree('pass', { [MDX]: FENCED, [MD]: FENCED_MD })); + expect('#10533 (B1, program) — a tree whose only occurrences sit in MARKED vendor-wire ' + + 'fences is GREEN, in both marker spellings, with no baseline entry for either file', + passing.status === 0); + expect('#10533 (B1, program) — and the run PUBLISHES what it suppressed, so the exemption ' + + 'cannot widen unobserved', + passing.out.includes(exemptClause({ blocks: 2, occurrences: 2 }))); + + // (B2) at program level. + const prose = runIn(buildTree('prose', { [MDX]: PROSE, [MD]: FENCED_MD })); + expect('#10533 (B2, program) — one PROSE occurrence in a file whose fenced block is exempt ' + + 'still fails, and is reported as a NEW use', + prose.status === 1 && prose.out.includes('NEW use of the reserved word')); + + // (B3) at program level — the leg that carries the design. + const unmarked = runIn(buildTree('unmarked', { [MDX]: UNMARKED, [MD]: FENCED_MD })); + expect('#10533 (B3, program) — the SAME fenced payload with its marker removed still fails ' + + '(so the green above came from the marking, not from the fence)', + unmarked.status === 1 && unmarked.out.includes('NEW use of the reserved word')); + + // An inert marker is refused as its own class of problem, naming itself — + // not left to surface as a bare count the author cannot connect to it. + const orphanTree = buildTree('orphan', { [MDX]: FENCED.replace(`${VW_MDX}\n`, `${VW_MDX}\n\n`) }); + const orphan = runIn(orphanTree); + expect('#10533 — a marker that opts nothing in fails as a MARKER problem, naming the token', + orphan.status === 1 && orphan.out.includes('vendor-wire marker problem') + && orphan.out.includes(VENDOR_WIRE_TOKEN)); + + // And that refusal precedes the write, pinned as byte-identity rather than + // as an exit code — the #9932 discipline, for the same reason: re-baselining + // from counts whose author misunderstood them freezes the misunderstanding + // into a ledger that is shrink-only. + mkdirSync(join(orphanTree, dirname(BASELINE_PATH)), { recursive: true }); + const vwLedgerPath = join(orphanTree, BASELINE_PATH); + const vwLedgerBefore = '{\n "pinned": 11\n}\n'; + writeFileSync(vwLedgerPath, vwLedgerBefore); + const orphanUpdate = runIn(orphanTree, ['--update']); + expect('#10533 — `--update` over a tree holding an inert marker refuses BEFORE writing, ' + + 'leaving the baseline byte-identical', + orphanUpdate.status === 1 && readFileSync(vwLedgerPath, 'utf8') === vwLedgerBefore); + } finally { + rmSync(vwSandbox, { recursive: true, force: true }); + } + if (failures.length) { for (const f of failures) console.error(` x self-test: ${f}`); console.error(`\ncheck-role-word --self-test: ${failures.length} failure(s).\n`); @@ -647,7 +1204,15 @@ function selfTest() { + 'subtree spelling dispatch-gates derives from, and declares nothing this gate does not ' + 'walk. A configured ROOT that does not exist is REFUSED — per root, before the scan and ' + 'before `--update` writes anything — proven by running this gate inside built trees, not ' - + 'by a predicate no caller has to reach.', + + 'by a predicate no caller has to reach. The vendor-wire exemption is pinned in all three ' + + 'directions over a fixture PROVEN to carry the reserved word: a marked fence passes, a ' + + 'prose occurrence in the same file still fails, and an UNMARKED fence still fails — so the ' + + 'exemption keys on the marking and not on being a code block. Every near-miss marker (a ' + + 'blank line, the other format\'s spelling, an undeclared vendor, the bare token, a marker ' + + 'above prose) opts nothing in AND is refused as an orphan, an unclosed claimed fence ' + + 'exempts nothing, and both refusal messages name the marker in the spelling for the file ' + + 'they name — all of it also driven through a real child process, so a predicate the ' + + 'program never consulted could not pass it.', ); process.exit(0); } @@ -680,18 +1245,43 @@ for (const root of ROOTS) { } const current = {}; +/* Vendor-wire exemptions, tallied as the scan runs — the same pass, not a second + * one — so the volume can be printed on every run (#10533). An exemption nobody + * can see is an exemption that widens unobserved, which is the failure mode the + * marking was chosen to prevent in the first place. */ +const exempt = { blocks: 0, occurrences: 0 }; +const markerProblems = []; for (const f of files.sort()) { const rel = relative('.', f).replace(/\\/g, '/'); - // File/dir names are URLs — a `role-*` slug is UI copy (counts once). + // File/dir names are URLs — a `role-*` slug is UI copy (counts once). A path + // cannot be inside a fence, so the exemption never reaches this half. const nameHits = countMatches(rel); - const bodyHits = countMatches(readFileSync(f, 'utf8')); + const text = readFileSync(f, 'utf8'); + const ext = extensionOf(rel); + const vendorWire = analyzeVendorWire(text, ext); + exempt.blocks += vendorWire.blocks; + exempt.occurrences += vendorWire.occurrences; + for (const n of vendorWire.orphans) markerProblems.push(orphanMarkerMessage(rel, n, ext)); + for (const n of vendorWire.unterminated) markerProblems.push(unterminatedFenceMessage(rel, n)); + const bodyHits = countMatches(text) - vendorWire.occurrences; const total = nameHits + bodyHits; if (total > 0) current[rel] = total; } +/* Refused BEFORE the `--update` write, for the reason the missing-root probe is: + * a marker that opts nothing in means the counts just taken are not the counts + * its author believes were taken, and re-baselining from them would freeze that + * misunderstanding into the ledger — silently, and in the one direction this + * shrink-only ratchet cannot walk back. */ +if (markerProblems.length) { + console.error(`check-role-word: ${markerProblems.length} vendor-wire marker problem(s)\n`); + for (const e of markerProblems) console.error(' • ' + e); + process.exit(1); +} + if (update) { writeFileSync(BASELINE_PATH, JSON.stringify(current, null, 2) + '\n'); - console.log(updateSummary(scanned, current)); + console.log(updateSummary(scanned, current, exempt)); process.exit(0); } @@ -705,7 +1295,7 @@ for (const [file, count] of Object.entries(current)) { if (allowed === undefined) { errors.push(newUseMessage(file, count)); } else if (count > allowed) { - errors.push(`${file}: role-word count grew ${allowed} → ${count}. New occurrences are banned (ADR-0090 D3).`); + errors.push(grewMessage(file, allowed, count)); } } for (const [file, allowed] of Object.entries(baseline)) { @@ -722,4 +1312,4 @@ if (errors.length) { for (const e of errors) console.error(' • ' + e); process.exit(1); } -console.log(successSummary(scanned, current)); +console.log(successSummary(scanned, current, exempt));