* 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.
375 lines
14 KiB
JavaScript
375 lines
14 KiB
JavaScript
'use strict';
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const {
|
|
evaluateLint,
|
|
readFragmentsFromDisk,
|
|
LINT_REASON,
|
|
OPT_OUT_LABEL,
|
|
TRIGGERING_TYPES,
|
|
isFragmentPath,
|
|
isDocsFile,
|
|
isExemptFragment,
|
|
} = require(path.join(__dirname, '..', 'scripts', 'lint-docs-required.cjs'));
|
|
|
|
// evaluateLint is pure over the resolved inputs (changedFiles, fragments,
|
|
// labels, malformed). Tests assert on the structured verdict:
|
|
// { ok, reason: LINT_REASON.X, triggering: string[], malformed? }.
|
|
|
|
describe('docs-required lint: pure verdict (#3213)', () => {
|
|
test('LINT_REASON enum exposes the documented codes', () => {
|
|
assert.deepEqual(
|
|
Object.keys(LINT_REASON).sort(),
|
|
[
|
|
'FAIL_DOCS_MISSING',
|
|
'FAIL_MALFORMED_FRAGMENT',
|
|
'OK_DOCS_UPDATED',
|
|
'OK_FRAGMENTS_EXEMPT',
|
|
'OK_NO_TRIGGERING_FRAGMENTS',
|
|
'OK_OPT_OUT_LABEL',
|
|
].sort(),
|
|
);
|
|
});
|
|
|
|
test('TRIGGERING_TYPES covers the four user-facing non-fix types', () => {
|
|
assert.deepEqual(
|
|
[...TRIGGERING_TYPES].sort(),
|
|
['Added', 'Changed', 'Deprecated', 'Removed'].sort(),
|
|
);
|
|
});
|
|
|
|
test('OPT_OUT_LABEL is no-docs (matches CONTRIBUTING)', () => {
|
|
assert.equal(OPT_OUT_LABEL, 'no-docs');
|
|
});
|
|
|
|
test('OK_NO_TRIGGERING_FRAGMENTS when no fragments touched at all', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['bin/install.js'],
|
|
fragments: [],
|
|
labels: [],
|
|
});
|
|
assert.equal(verdict.ok, true);
|
|
assert.equal(verdict.reason, LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS);
|
|
assert.deepEqual(verdict.triggering, []);
|
|
});
|
|
|
|
test('OK_NO_TRIGGERING_FRAGMENTS for Fixed-only fragments (bug-class)', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['bin/install.js', '.changeset/silly-bears-dance.md'],
|
|
fragments: [
|
|
{ path: '.changeset/silly-bears-dance.md', type: 'Fixed', body: 'fix typo', docsExempt: null },
|
|
],
|
|
labels: [],
|
|
});
|
|
assert.deepEqual(verdict, {
|
|
ok: true,
|
|
reason: LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS,
|
|
triggering: [],
|
|
});
|
|
});
|
|
|
|
test('OK_NO_TRIGGERING_FRAGMENTS for Security-only fragments', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: [],
|
|
fragments: [{ path: '.changeset/a.md', type: 'Security', body: 'cve', docsExempt: null }],
|
|
labels: [],
|
|
});
|
|
assert.equal(verdict.ok, true);
|
|
assert.equal(verdict.reason, LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS);
|
|
});
|
|
|
|
test('OK_DOCS_UPDATED when Added fragment ships alongside a docs/ change', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['.changeset/a.md', 'docs/COMMANDS.md'],
|
|
fragments: [{ path: '.changeset/a.md', type: 'Added', body: 'new cmd', docsExempt: null }],
|
|
labels: [],
|
|
});
|
|
assert.equal(verdict.ok, true);
|
|
assert.equal(verdict.reason, LINT_REASON.OK_DOCS_UPDATED);
|
|
assert.deepEqual(verdict.triggering, ['.changeset/a.md']);
|
|
});
|
|
|
|
test('OK_DOCS_UPDATED for nested docs/ paths (docs/adr/, docs/agents/)', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['.changeset/a.md', 'docs/adr/0099-new.md'],
|
|
fragments: [{ path: '.changeset/a.md', type: 'Changed', body: '...', docsExempt: null }],
|
|
labels: [],
|
|
});
|
|
assert.equal(verdict.reason, LINT_REASON.OK_DOCS_UPDATED);
|
|
});
|
|
|
|
for (const type of ['Added', 'Changed', 'Deprecated', 'Removed']) {
|
|
test(`FAIL_DOCS_MISSING when ${type} fragment has no docs/ change and no escape hatch`, () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['.changeset/a.md', 'bin/install.js'],
|
|
fragments: [{ path: '.changeset/a.md', type, body: '...', docsExempt: null }],
|
|
labels: [],
|
|
});
|
|
assert.equal(verdict.ok, false);
|
|
assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING);
|
|
assert.deepEqual(verdict.triggering, ['.changeset/a.md']);
|
|
});
|
|
}
|
|
|
|
test('OK_OPT_OUT_LABEL when no-docs label present overrides triggering fragments', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['.changeset/a.md', 'bin/install.js'],
|
|
fragments: [{ path: '.changeset/a.md', type: 'Added', body: '...', docsExempt: null }],
|
|
labels: ['no-docs'],
|
|
});
|
|
assert.equal(verdict.ok, true);
|
|
assert.equal(verdict.reason, LINT_REASON.OK_OPT_OUT_LABEL);
|
|
});
|
|
|
|
test('per-fragment docsExempt reason exempts that fragment', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['.changeset/a.md', 'bin/install.js'],
|
|
fragments: [
|
|
{ path: '.changeset/a.md', type: 'Added', body: 'foo', docsExempt: 'internal-only' },
|
|
],
|
|
labels: [],
|
|
});
|
|
assert.equal(verdict.ok, true);
|
|
assert.equal(verdict.reason, LINT_REASON.OK_FRAGMENTS_EXEMPT);
|
|
assert.deepEqual(verdict.triggering, ['.changeset/a.md']);
|
|
});
|
|
|
|
test('docsExempt empty string does NOT exempt — defense-in-depth (CodeRabbit finding)', () => {
|
|
// parse.cjs no longer produces empty-string docsExempt (the marker regex
|
|
// requires a non-empty reason). evaluateLint defends against any caller
|
|
// that constructs a fragment with `docsExempt: ''` directly — empty or
|
|
// whitespace-only reasons are not a valid audit trail.
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['.changeset/a.md', 'bin/install.js'],
|
|
fragments: [
|
|
{ path: '.changeset/a.md', type: 'Added', body: 'foo', docsExempt: '' },
|
|
],
|
|
labels: [],
|
|
});
|
|
assert.equal(verdict.ok, false);
|
|
assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING);
|
|
});
|
|
|
|
test('docsExempt whitespace-only does NOT exempt — defense-in-depth', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['.changeset/a.md', 'bin/install.js'],
|
|
fragments: [
|
|
{ path: '.changeset/a.md', type: 'Added', body: 'foo', docsExempt: ' \t' },
|
|
],
|
|
labels: [],
|
|
});
|
|
assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING);
|
|
});
|
|
|
|
test('partial exemption fails — one un-marked triggering fragment is enough to require docs', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['.changeset/a.md', '.changeset/b.md', 'bin/install.js'],
|
|
fragments: [
|
|
{ path: '.changeset/a.md', type: 'Added', body: 'foo', docsExempt: 'x' },
|
|
{ path: '.changeset/b.md', type: 'Changed', body: 'no marker here', docsExempt: null },
|
|
],
|
|
labels: [],
|
|
});
|
|
assert.equal(verdict.ok, false);
|
|
assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING);
|
|
assert.deepEqual(verdict.triggering.sort(), ['.changeset/a.md', '.changeset/b.md']);
|
|
});
|
|
|
|
test('mixed Fixed + Added with no docs still fails — Added triggers', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['.changeset/a.md', '.changeset/b.md'],
|
|
fragments: [
|
|
{ path: '.changeset/a.md', type: 'Fixed', body: '...', docsExempt: null },
|
|
{ path: '.changeset/b.md', type: 'Added', body: '...', docsExempt: null },
|
|
],
|
|
labels: [],
|
|
});
|
|
assert.equal(verdict.ok, false);
|
|
assert.equal(verdict.reason, LINT_REASON.FAIL_DOCS_MISSING);
|
|
assert.deepEqual(verdict.triggering, ['.changeset/b.md']);
|
|
});
|
|
});
|
|
|
|
describe('docs-required lint: malformed fragments fail closed (#3213, Codex finding)', () => {
|
|
test('FAIL_MALFORMED_FRAGMENT when a touched fragment failed to parse', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['.changeset/bad.md'],
|
|
fragments: [],
|
|
labels: [],
|
|
malformed: [{ path: '.changeset/bad.md', reason: 'missing_frontmatter' }],
|
|
});
|
|
assert.equal(verdict.ok, false);
|
|
assert.equal(verdict.reason, LINT_REASON.FAIL_MALFORMED_FRAGMENT);
|
|
assert.deepEqual(verdict.malformed, [{ path: '.changeset/bad.md', reason: 'missing_frontmatter' }]);
|
|
});
|
|
|
|
test('FAIL_MALFORMED_FRAGMENT outranks OK_DOCS_UPDATED — malformed must be fixed first', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['.changeset/bad.md', '.changeset/ok.md', 'docs/USER-GUIDE.md'],
|
|
fragments: [{ path: '.changeset/ok.md', type: 'Added', body: 'fine', docsExempt: null }],
|
|
labels: ['no-docs'],
|
|
malformed: [{ path: '.changeset/bad.md', reason: 'invalid_type', detail: 'Bogus' }],
|
|
});
|
|
assert.equal(verdict.ok, false);
|
|
assert.equal(verdict.reason, LINT_REASON.FAIL_MALFORMED_FRAGMENT);
|
|
});
|
|
|
|
test('no-docs label cannot bypass FAIL_MALFORMED_FRAGMENT', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: ['.changeset/bad.md'],
|
|
fragments: [],
|
|
labels: ['no-docs'],
|
|
malformed: [{ path: '.changeset/bad.md', reason: 'missing_pr' }],
|
|
});
|
|
assert.equal(verdict.reason, LINT_REASON.FAIL_MALFORMED_FRAGMENT);
|
|
});
|
|
|
|
test('malformed defaults to [] when omitted — back-compat with simple test inputs', () => {
|
|
const verdict = evaluateLint({
|
|
changedFiles: [],
|
|
fragments: [],
|
|
labels: [],
|
|
});
|
|
assert.equal(verdict.ok, true);
|
|
assert.equal(verdict.reason, LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS);
|
|
});
|
|
});
|
|
|
|
describe('docs-required lint: helpers', () => {
|
|
test('isFragmentPath accepts .changeset/<slug>.md, rejects README', () => {
|
|
assert.equal(isFragmentPath('.changeset/foo.md'), true);
|
|
assert.equal(isFragmentPath('.changeset/silly-bears-dance.md'), true);
|
|
assert.equal(isFragmentPath('.changeset/README.md'), false);
|
|
assert.equal(isFragmentPath('.changeset/nested/foo.md'), false);
|
|
assert.equal(isFragmentPath('docs/COMMANDS.md'), false);
|
|
assert.equal(isFragmentPath('bin/install.js'), false);
|
|
});
|
|
|
|
test('isDocsFile matches docs/ prefix only', () => {
|
|
assert.equal(isDocsFile('docs/COMMANDS.md'), true);
|
|
assert.equal(isDocsFile('docs/adr/0001-foo.md'), true);
|
|
assert.equal(isDocsFile('docs/agents/triage-labels.md'), true);
|
|
assert.equal(isDocsFile('docs'), false); // exact 'docs' without slash is not a file under docs/
|
|
assert.equal(isDocsFile('CONTRIBUTING.md'), false);
|
|
assert.equal(isDocsFile('README.md'), false);
|
|
});
|
|
|
|
test('isExemptFragment checks docsExempt is a non-empty string, not body content', () => {
|
|
assert.equal(isExemptFragment({ docsExempt: 'reason' }), true);
|
|
assert.equal(isExemptFragment({ docsExempt: 'a' }), true);
|
|
// Empty/whitespace-only reason → no audit trail → not exempt.
|
|
assert.equal(isExemptFragment({ docsExempt: '' }), false);
|
|
assert.equal(isExemptFragment({ docsExempt: ' \t' }), false);
|
|
assert.equal(isExemptFragment({ docsExempt: null }), false);
|
|
assert.equal(isExemptFragment({ docsExempt: undefined }), false);
|
|
assert.equal(isExemptFragment({}), false);
|
|
// Body content is irrelevant — parse.cjs extracts the marker into docsExempt.
|
|
assert.equal(
|
|
isExemptFragment({ body: '<!-- docs-exempt: x -->', docsExempt: null }),
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('docs-required lint: readFragmentsFromDisk', () => {
|
|
function withTempRepo(fn) {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-docs-lint-'));
|
|
try {
|
|
fs.mkdirSync(path.join(tmp, '.changeset'), { recursive: true });
|
|
fn(tmp);
|
|
} finally {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test('returns { fragments, malformed } shape', () => {
|
|
withTempRepo((tmp) => {
|
|
const out = readFragmentsFromDisk([], tmp);
|
|
assert.ok('fragments' in out, 'has fragments');
|
|
assert.ok('malformed' in out, 'has malformed');
|
|
assert.deepEqual(out.fragments, []);
|
|
assert.deepEqual(out.malformed, []);
|
|
});
|
|
});
|
|
|
|
test('parses valid fragments and skips non-fragment paths', () => {
|
|
withTempRepo((tmp) => {
|
|
fs.writeFileSync(
|
|
path.join(tmp, '.changeset', 'a.md'),
|
|
'---\ntype: Added\npr: 1\n---\nnew feature\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmp, '.changeset', 'b.md'),
|
|
'---\ntype: Fixed\npr: 2\n---\nbug fix\n',
|
|
);
|
|
const { fragments, malformed } = readFragmentsFromDisk(
|
|
['.changeset/a.md', '.changeset/b.md', 'bin/x.js'],
|
|
tmp,
|
|
);
|
|
assert.equal(fragments.length, 2);
|
|
assert.equal(fragments[0].path, '.changeset/a.md');
|
|
assert.equal(fragments[0].type, 'Added');
|
|
assert.equal(fragments[0].docsExempt, null);
|
|
assert.equal(fragments[1].type, 'Fixed');
|
|
assert.deepEqual(malformed, []);
|
|
});
|
|
});
|
|
|
|
test('skips deleted fragments (path in diff but file gone)', () => {
|
|
withTempRepo((tmp) => {
|
|
const { fragments, malformed } = readFragmentsFromDisk(['.changeset/deleted.md'], tmp);
|
|
assert.deepEqual(fragments, []);
|
|
assert.deepEqual(malformed, []);
|
|
});
|
|
});
|
|
|
|
test('routes malformed fragments to the malformed list with typed reason', () => {
|
|
withTempRepo((tmp) => {
|
|
fs.writeFileSync(path.join(tmp, '.changeset', 'bad.md'), 'no frontmatter here\n');
|
|
const { fragments, malformed } = readFragmentsFromDisk(['.changeset/bad.md'], tmp);
|
|
assert.deepEqual(fragments, []);
|
|
assert.equal(malformed.length, 1);
|
|
assert.equal(malformed[0].path, '.changeset/bad.md');
|
|
assert.equal(malformed[0].reason, 'missing_frontmatter');
|
|
});
|
|
});
|
|
|
|
test('Added fragment with bad pr surfaces as malformed (Codex finding regression test)', () => {
|
|
withTempRepo((tmp) => {
|
|
fs.writeFileSync(
|
|
path.join(tmp, '.changeset', 'a.md'),
|
|
'---\ntype: Added\n---\nbody but no pr field\n',
|
|
);
|
|
const { fragments, malformed } = readFragmentsFromDisk(['.changeset/a.md'], tmp);
|
|
assert.deepEqual(fragments, []);
|
|
assert.equal(malformed.length, 1);
|
|
assert.equal(malformed[0].reason, 'missing_pr');
|
|
// End-to-end: feed straight into evaluateLint and confirm fail-closed.
|
|
const verdict = evaluateLint({ changedFiles: ['.changeset/a.md'], fragments, malformed, labels: [] });
|
|
assert.equal(verdict.reason, LINT_REASON.FAIL_MALFORMED_FRAGMENT);
|
|
});
|
|
});
|
|
|
|
test('extracts docs-exempt marker into typed field and strips it from body', () => {
|
|
withTempRepo((tmp) => {
|
|
fs.writeFileSync(
|
|
path.join(tmp, '.changeset', 'a.md'),
|
|
'---\ntype: Added\npr: 3\n---\nnew thing\n\n<!-- docs-exempt: internal-only -->\n',
|
|
);
|
|
const { fragments } = readFragmentsFromDisk(['.changeset/a.md'], tmp);
|
|
assert.equal(fragments.length, 1);
|
|
assert.equal(fragments[0].docsExempt, 'internal-only');
|
|
// The marker no longer appears in the rendered body — renderers append
|
|
// `(#NNNN)` to body's last line, so the marker would otherwise leak into
|
|
// CHANGELOG.md / GitHub release notes.
|
|
assert.doesNotMatch(fragments[0].body, /docs-exempt/);
|
|
});
|
|
});
|
|
});
|