Files
msd-core/scripts/changeset/parse.cjs
Cristian Uibar 7f8b5701bf Enforce documentation updates via lint:docs + PR templates (#3651)
* Enforce documentation updates via lint:docs + PR templates (#3213)

New scripts/lint-docs-required.cjs + Docs Required CI workflow fail any
PR whose changeset fragment is typed Added / Changed / Deprecated / Removed
without modifying at least one file under docs/.

Mirrors scripts/changeset/lint.cjs: pure evaluateLint({ changedFiles,
fragments, labels }) returning { ok, reason, triggering } over a frozen
LINT_REASON enum; CLI wrapper reads the PR diff and parses each touched
changeset fragment via the existing parseFragment helper.

Escape hatches:
- no-docs PR label (global)
- per-fragment <!-- docs-exempt: <reason> --> marker, all triggering
  fragments must carry it for the PR to pass

Fixed and Security fragments do not trigger the lint — bug fixes restore
documented behavior, they do not introduce new behavior to document.

PR templates (enhancement.md, feature.md) gain a Documentation checklist
section pointing at the which-doc-to-update matrix. CONTRIBUTING.md adds
a Documentation Updates section codifying that matrix, the English-canonical
language policy for docs/ and the root README, and the two opt-out routes.

Closes #3213

* Address Codex review: fail-closed on malformed fragments and strip docs-exempt marker from rendered release notes (#3213)

Two P2 issues caught by `codex review --base main`:

1) Malformed fragments could silently bypass docs enforcement. parseFragment
   would return ok:false on a triggering Added fragment with bad frontmatter
   and readFragmentsFromDisk dropped it, so evaluateLint saw no triggering
   fragments and passed. The changeset-required lint only checks fragment
   _presence_ not _validity_, so the assumed fallback did not catch it.

   Fix: readFragmentsFromDisk now returns { fragments, malformed }; evaluateLint
   accepts a malformed param and emits a new FAIL_MALFORMED_FRAGMENT verdict
   that outranks every OK path (including the no-docs label) — a parse failure
   must be fixed before docs lint can decide anything else.

2) The per-fragment <!-- docs-exempt: reason --> marker lived in the fragment
   body, so the existing changelog (serializeChangelog) and GitHub release-notes
   (formatBullet) serializers published it verbatim. Worse, both serializers
   append `(#NNNN)` to the body's last line — with the marker as the trailing
   line, the PR suffix attached to the hidden comment instead of the visible
   bullet.

   Fix: parseFragment now extracts the marker into a typed `docsExempt` field
   and strips it from `body`, so all downstream renderers produce clean output
   without remembering to strip. The regex is anchored to its own line (^...$
   with m flag) so inline mentions of the marker syntax in documentation
   (e.g. inside backticks) cannot accidentally exempt a fragment. Bounded
   character class [^\n>] keeps the regex linear-time.

Test additions:
- tests/lint-docs-required.test.cjs: FAIL_MALFORMED_FRAGMENT coverage,
  end-to-end "Added fragment with bad pr → malformed → fail-closed" regression
  test, updated readFragmentsFromDisk return-shape assertions, isExemptFragment
  now checks the typed docsExempt field rather than body content.
- tests/changeset-parse.test.cjs: extractDocsExempt extraction cases (with/
  without reason, case-insensitive, EMPTY_BODY when body is only a marker),
  inline-mention false-positive guard, real-marker-wins-when-also-inline test.
- tests/changeset-new.test.cjs: fragment shape now includes docsExempt: null.

CONTRIBUTING.md updated to clarify the "on its own line" requirement and the
parse-time stripping behavior. The bootstrap fragment cleaned up so its body
no longer contains a literal marker example that would have triggered the
false-positive case.

Full suite: 9696/9696 pass.

* CRLF-safe docs-exempt marker stripping (Codex review pass 2, #3213)

Second `codex review --commit` pass caught a CRLF regression in the
docs-exempt extraction added in the previous commit.

Repro: a Windows-authored fragment

  ---\r\ntype: Added\r\npr: 1\r\n---\r\nFeature.\r\n\r\n<!-- docs-exempt: x -->\r\n

would parse to body `Feature.\r\n\r\n\r` because:

  - The previous trailing-newline slice trimmed only `\n`, leaving `\r`.
  - DOCS_EXEMPT_RE was anchored with `$` only — in multiline mode `$`
    matches before `\n` but does not consume `\r`, so the marker line's
    trailing `\r` was left behind after replace.
  - The cleanup regex stripped trailing `\n` but not `\r`.

Net effect: serializeChangelog emitted

  - Feature.\r
  \r
  \r (#1)

— the `(#1)` PR suffix landed on a blank line instead of attached to
the visible bullet. Same bug surfaces in github-release-notes formatBullet.

Fix:
- DOCS_EXEMPT_RE: add `\r?` before `$` so the regex consumes the CR of a
  CRLF terminator. Switch reason character class from `[^\n>]` to
  `[^\r\n>]` so CRLF-authored reasons don't carry a trailing `\r`.
- extractDocsExempt cleanup: `[ \t\r]+$/gm` strips trailing `\r` on each
  line; `(?:\r?\n){3,}` collapses CRLF triple-blank-lines; `[\r\n]+$`
  strips every trailing line terminator (LF or CR).
- parseFragment trailing-newline slice: CRLF-aware — strips `\r\n` (2
  chars) before falling through to single `\n`.

Tests: two CRLF regression cases in tests/changeset-parse.test.cjs —
Codex's exact repro (end-to-end through serializeChangelog) plus the
no-marker CRLF passthrough case. Full suite: 9698/9698 pass.

* CRLF regression test asserts on parseChangelog IR not rendered text (Codex review pass 3, #3213)

Third `codex review` pass caught that the CRLF regression test added in
the previous commit asserted on serializeChangelog's rendered Markdown
via `out.split('\n')` + `assert.match`. That violates CONTRIBUTING.md's
"Prohibited: Raw Text Matching on Test Outputs" rule and the documented
serializer contract in `serialize.cjs`:

  > tests assert via round-trip (parse(serialize(ir)))
  > rather than by inspecting serialized text

Replace the regex check with the established `parseChangelog(out)`
round-trip and assert on the structured `{ body: 'Feature.', pr: 1 }`
bullet. This is also a stronger regression check than the substring
match: Codex's own probe in the review session confirmed the pre-fix
buggy body shape (`Feature.\r\n\r\n\r`) breaks parseChangelog's bullet
regex entirely (returns `bullets: []`), so the round-trip catches the
exact failure mode end-to-end.

Full suite: 9698/9698 pass.

* Address CodeRabbit findings: anchor link + require non-empty docs-exempt reason (#3213)

CodeRabbit's review on the PR caught two actionable issues, both quick wins.

Anchor link in PR templates pointed to a heading that does not exist. The
CONTRIBUTING.md heading "Documentation Updates — Update the Relevant Docs"
contains an em-dash, which GitHub strips entirely when generating anchor
slugs (it does NOT collapse to a hyphen). The actual anchor is
#documentation-updates-update-the-relevant-docs (single hyphen between every
word), not #documentation-updates--update-the-relevant-docs (double hyphen
where the em-dash was). Both feature.md and enhancement.md fixed.

The docs-exempt marker matched a bare `<!-- docs-exempt -->` with no reason,
which defeats the entire purpose of the escape hatch — the marker exists to
leave an audit trail explaining WHY a PR is exempt. Without a reason it is
a silent bypass.

Fix: DOCS_EXEMPT_RE now requires both the colon AND a non-whitespace first
reason character. Bare `<!-- docs-exempt -->`, empty `<!-- docs-exempt: -->`,
and whitespace-only `<!-- docs-exempt:   -->` are all rejected as if the
marker were not present (`docsExempt: null`). The lint then falls through
to its normal docs-required / no-docs-label checks.

`isExemptFragment` in the lint module tightened too — defense-in-depth: even
if a caller constructs a fragment with `docsExempt: ''` directly, it does
not count as exempt. The predicate now requires `typeof === 'string'` and
non-empty after trim.

Tests:
- changeset-parse.test.cjs: three new explicit-rejection cases (bare marker,
  empty reason, whitespace-only reason). Existing DOCS_EXEMPT_RE shape test
  extended with negative assertions for the same three forms.
- lint-docs-required.test.cjs: prior "empty reason still exempt" test
  inverted — empty/whitespace docsExempt now produces FAIL_DOCS_MISSING.
  isExemptFragment helper test extended with the same negative cases.
- CONTRIBUTING.md: clarified that the reason is required and non-empty.

Skipped CodeRabbit's third finding ("use `npm run lint:docs` in CI workflow
instead of `node scripts/lint-docs-required.cjs`") — the existing
changeset-required.yml uses the direct-node form for the equivalent
changeset lint, so the new docs-required.yml is convention-consistent.
Switching one without the other would create drift, and switching both is
out of scope for #3213.

Bootstrap fragment continues to extract cleanly under the stricter regex
(verified — `docsExempt` field still contains the full bootstrap reason).
Full suite: 9701/9701 pass.
2026-05-16 13:09:54 -04:00

115 lines
5.3 KiB
JavaScript

'use strict';
/**
* Parses a changeset fragment file (text → typed record).
*
* ---
* type: Fixed
* pr: 2975
* ---
* <markdown body>
*
* Returns { ok: true, fragment: { type, pr, body, docsExempt } } on success,
* { ok: false, reason: FRAGMENT_ERROR.X, detail } on failure.
*
* `docsExempt` is `null` when the body contains no docs-exempt marker, or the
* trimmed reason string when the body contains `<!-- docs-exempt: <reason> -->`
* (#3213). The marker is stripped from `body` at parse time so it never bleeds
* into the CHANGELOG.md or GitHub release-notes serializers, which append the
* `(#NNNN)` PR suffix verbatim to the body's last line.
*
* The reason field is a frozen enum so tests assert on stable codes,
* not free-text error messages (CONTRIBUTING.md: "Prohibited: Raw
* Text Matching on Test Outputs").
*/
const FRAGMENT_ERROR = Object.freeze({
MISSING_FRONTMATTER: 'missing_frontmatter',
MISSING_TYPE: 'missing_type',
INVALID_TYPE: 'invalid_type',
MISSING_PR: 'missing_pr',
INVALID_PR: 'invalid_pr',
EMPTY_BODY: 'empty_body',
});
const ALLOWED_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']);
// HTML comment marking a fragment as exempt from the docs-required lint (#3213).
// Form: `<!-- docs-exempt: <reason> -->`. The reason is the *required* human
// audit trail — without it the exemption has no paper-trail value, so a bare
// `<!-- docs-exempt -->` or empty `<!-- docs-exempt: -->` is intentionally
// rejected (the colon and a non-whitespace first reason char are mandatory).
//
// Anchored with `^...$` + `m` flag so the marker only counts when it occupies
// its own line. Inline mentions inside paragraphs (e.g. backtick-wrapped
// syntax examples in documentation) are not matched — they cannot
// accidentally exempt a fragment.
//
// The trailing `\r?` consumes the CR character of a CRLF line terminator,
// which the `$` boundary (multiline mode) does not — so Windows-authored
// fragments produce the same `body` shape as LF-authored ones. The reason
// character class `[^\r\n>]` excludes `\r` for the same reason: a CRLF
// fragment's reason text never carries a trailing `\r`.
//
// Bounded character class `[^\r\n>]` keeps the regex linear-time — no
// catastrophic backtracking on adversarial input. The leading `\S` anchor
// inside the capture group forces at least one non-whitespace character in
// the reason; trailing whitespace before `-->` is consumed by the outer
// `[ \t]*-->` and is not part of the captured reason.
const DOCS_EXEMPT_RE = /^[ \t]*<!--[ \t]*docs-exempt[ \t]*:[ \t]*(\S[^\r\n>]*?)[ \t]*-->[ \t]*\r?$/im;
function extractDocsExempt(body) {
const m = body.match(DOCS_EXEMPT_RE);
if (!m) return { docsExempt: null, body };
const reason = (m[1] || '').trim();
// Strip the marker line and tidy up the surrounding whitespace. The cleanup
// is CRLF-aware so Windows-authored fragments don't leave residual `\r`
// characters that would shift the `(#NNNN)` PR suffix to a blank line in
// the rendered CHANGELOG.md / GitHub release-notes bullet.
const cleaned = body
.replace(DOCS_EXEMPT_RE, '')
.replace(/[ \t\r]+$/gm, '') // strip trailing \r/spaces on each line
.replace(/(?:\r?\n){3,}/g, '\n\n') // collapse 3+ blank lines (CRLF-aware)
.replace(/[\r\n]+$/, ''); // strip every trailing line terminator
return { docsExempt: reason, body: cleaned };
}
function parseFragment(src) {
const fmMatch = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!fmMatch) return { ok: false, reason: FRAGMENT_ERROR.MISSING_FRONTMATTER };
const [, fmBlock, body] = fmMatch;
const fields = {};
for (const line of fmBlock.split(/\r?\n/)) {
const m = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
if (m) fields[m[1]] = m[2].trim();
}
if (!fields.type) return { ok: false, reason: FRAGMENT_ERROR.MISSING_TYPE };
if (!ALLOWED_TYPES.has(fields.type)) {
return { ok: false, reason: FRAGMENT_ERROR.INVALID_TYPE, detail: fields.type };
}
if (!fields.pr) return { ok: false, reason: FRAGMENT_ERROR.MISSING_PR };
const pr = Number(fields.pr);
if (!Number.isInteger(pr) || pr <= 0) {
return { ok: false, reason: FRAGMENT_ERROR.INVALID_PR, detail: fields.pr };
}
// Use trim() only for the emptiness check; preserve the body verbatim
// (including significant leading/trailing whitespace, code blocks, etc.)
// so render → serialize round-trips exactly. Strip the single trailing
// line terminator added by editors so byte-equality holds for typical
// fragments. CRLF-aware: a Windows-authored fragment trims `\r\n` so the
// marker line in extractDocsExempt does not leave residual `\r` characters
// for downstream serializers to attach `(#NNNN)` to (#3213).
if (!body.trim()) return { ok: false, reason: FRAGMENT_ERROR.EMPTY_BODY };
let verbatimBody;
if (body.endsWith('\r\n')) verbatimBody = body.slice(0, -2);
else if (body.endsWith('\n')) verbatimBody = body.slice(0, -1);
else verbatimBody = body;
const { docsExempt, body: visibleBody } = extractDocsExempt(verbatimBody);
if (!visibleBody.trim()) return { ok: false, reason: FRAGMENT_ERROR.EMPTY_BODY };
return { ok: true, fragment: { type: fields.type, pr, body: visibleBody, docsExempt } };
}
module.exports = { parseFragment, extractDocsExempt, FRAGMENT_ERROR, ALLOWED_TYPES, DOCS_EXEMPT_RE };