* fix(#2488): strip leading terminators so changeset bullets survive re-parse A fragment body beginning with a line terminator rendered as an empty `- ` bullet followed by an orphaned paragraph. `parseChangelog` treats a non-indented line as terminating a bullet, so `github-release-notes.cjs` silently dropped the entry when re-parsing CHANGELOG.md to build the GitHub Release body. Two independent causes, both in scripts/changeset/parse.cjs: 1. `extractDocsExempt` stripped trailing terminators but not leading ones. `DOCS_EXEMPT_RE` is `^...$` under /m, so removing a first-line `<!-- docs-exempt -->` marker left the `\n` that `$` does not consume. 2. `parseFragment` preserved the post-frontmatter body verbatim, so a blank line between the closing `---` and the first content line produced the same leading `\n` with no marker involved. 8 of 256 pending fragments were affected, split 4/4 across the two causes — including the OpenCode MCP binding, the pi extension, and the EoS adapters, all of which would have vanished from the v1.8.0 release notes. Regression tests cover both causes in LF and CRLF form, plus an end-to-end serializeChangelog -> parseChangelog round-trip that pins the user-visible defect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#2488): regenerate golden install fixtures for parse.cjs scripts/ ships in the npm package and the installer, so the golden install-parity fixtures record a content hash for every shipped file. Editing scripts/changeset/parse.cjs drifts that hash and fails all 18 per-runtime parity tests. Regenerated via `npm run gen:golden`. The diff is exactly one line per fixture — the scripts/changeset/parse.cjs hash — with no unrelated drift. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
289 lines
14 KiB
JavaScript
289 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 path = require('node:path');
|
|
|
|
const { parseFragment, extractDocsExempt, FRAGMENT_ERROR, DOCS_EXEMPT_RE } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'parse.cjs'));
|
|
|
|
describe('changeset parse: fragment file → typed record (#2975)', () => {
|
|
test('returns { ok: true, fragment } for a well-formed fragment', () => {
|
|
const src = '---\ntype: Fixed\npr: 2975\n---\nfix the thing.\n';
|
|
const result = parseFragment(src);
|
|
assert.equal(result.ok, true);
|
|
assert.deepEqual(result.fragment, {
|
|
type: 'Fixed',
|
|
pr: 2975,
|
|
body: 'fix the thing.',
|
|
docsExempt: null,
|
|
});
|
|
});
|
|
|
|
test('preserves verbatim body content (e.g. code blocks) — does not trim significant whitespace', () => {
|
|
const src = '---\ntype: Fixed\npr: 1\n---\n```js\nlet x = 1;\n```\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.fragment.body, '```js\nlet x = 1;\n```');
|
|
});
|
|
|
|
test('strips a blank line between the frontmatter close and the body content (no marker involved)', () => {
|
|
// Same downstream failure mode as a first-line docs-exempt marker, but
|
|
// triggered purely by authoring whitespace (a blank line right after the
|
|
// closing `---`) with no docs-exempt marker present at all — a distinct
|
|
// root cause than extractDocsExempt's marker-stripping path, discovered
|
|
// via the .changeset/ fleet scan while regression-testing #1929.
|
|
const src = '---\ntype: Changed\npr: 2036\n---\n\n**Bold text describing the fix.** More detail follows.\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.fragment.docsExempt, null);
|
|
assert.doesNotMatch(r.fragment.body, /^[\r\n]/);
|
|
assert.match(r.fragment.body, /^\*\*Bold text describing the fix\.\*\*/);
|
|
});
|
|
|
|
test('CRLF variant: blank line between frontmatter close and body content is stripped identically', () => {
|
|
const src = '---\r\ntype: Changed\r\npr: 2036\r\n---\r\n\r\n**Bold text describing the fix.** More detail follows.\r\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, true);
|
|
assert.doesNotMatch(r.fragment.body, /^[\r\n]/);
|
|
assert.doesNotMatch(r.fragment.body, /[\r]/);
|
|
const lfSrc = '---\ntype: Changed\npr: 2036\n---\n\n**Bold text describing the fix.** More detail follows.\n';
|
|
assert.equal(r.fragment.body, parseFragment(lfSrc).fragment.body);
|
|
});
|
|
|
|
test('end-to-end: leading-blank-line fragment survives serializeChangelog → parseChangelog round-trip', () => {
|
|
const src = '---\ntype: Changed\npr: 2036\n---\n\n**Bold text describing the fix.** More detail follows.\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, true);
|
|
|
|
const { serializeChangelog, parseChangelog } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'serialize.cjs'));
|
|
const out = serializeChangelog({
|
|
releaseHeader: { version: '1.0.0', date: '2026-01-01' },
|
|
sections: [{ type: 'Changed', bullets: [{ pr: r.fragment.pr, body: r.fragment.body }] }],
|
|
priorChangelog: null,
|
|
});
|
|
const parsed = parseChangelog(out);
|
|
assert.equal(parsed.releases.length, 1);
|
|
assert.deepEqual(parsed.releases[0].sections, [
|
|
{ type: 'Changed', bullets: [{ body: '**Bold text describing the fix.** More detail follows.', pr: 2036 }] },
|
|
]);
|
|
});
|
|
|
|
test('exposes a frozen FRAGMENT_ERROR enum with the documented codes', () => {
|
|
assert.deepEqual(
|
|
Object.keys(FRAGMENT_ERROR).sort(),
|
|
['EMPTY_BODY', 'INVALID_PR', 'INVALID_TYPE', 'MISSING_FRONTMATTER', 'MISSING_PR', 'MISSING_TYPE'],
|
|
);
|
|
});
|
|
|
|
for (const [label, src, expectedReason] of [
|
|
['fails MISSING_FRONTMATTER when no frontmatter block present',
|
|
'just a body, no frontmatter\n', 'MISSING_FRONTMATTER'],
|
|
['fails MISSING_TYPE when frontmatter omits type:',
|
|
'---\npr: 2975\n---\nfix.\n', 'MISSING_TYPE'],
|
|
['fails INVALID_TYPE for a type not in the Keep-a-Changelog set',
|
|
'---\ntype: Refactored\npr: 2975\n---\nfix.\n', 'INVALID_TYPE'],
|
|
['fails MISSING_PR when frontmatter omits pr:',
|
|
'---\ntype: Fixed\n---\nfix.\n', 'MISSING_PR'],
|
|
['fails INVALID_PR when pr: is not a positive integer',
|
|
'---\ntype: Fixed\npr: 0\n---\nfix.\n', 'INVALID_PR'],
|
|
['fails EMPTY_BODY when the body is whitespace-only',
|
|
'---\ntype: Fixed\npr: 2975\n---\n \n', 'EMPTY_BODY'],
|
|
]) {
|
|
test(label, () => {
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.reason, FRAGMENT_ERROR[expectedReason]);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('changeset parse: docs-exempt extraction (#3213)', () => {
|
|
test('extractDocsExempt returns { docsExempt: null, body } when no marker present', () => {
|
|
const out = extractDocsExempt('plain body text');
|
|
assert.deepEqual(out, { docsExempt: null, body: 'plain body text' });
|
|
});
|
|
|
|
test('extractDocsExempt captures the reason and strips the marker from body', () => {
|
|
const out = extractDocsExempt('feature note.\n\n<!-- docs-exempt: internal-only -->');
|
|
assert.equal(out.docsExempt, 'internal-only');
|
|
assert.doesNotMatch(out.body, /docs-exempt/);
|
|
assert.match(out.body, /feature note\./);
|
|
});
|
|
|
|
test('extractDocsExempt REJECTS bare marker without colon — reason is required (CodeRabbit finding)', () => {
|
|
// A bare `<!-- docs-exempt -->` provides no audit trail; intentionally
|
|
// not extracted so the lint requires either docs/ updates or a marker
|
|
// with a real reason.
|
|
const out = extractDocsExempt('body\n<!-- docs-exempt -->');
|
|
assert.equal(out.docsExempt, null);
|
|
assert.match(out.body, /docs-exempt/); // unchanged — bare marker stays in body
|
|
});
|
|
|
|
test('extractDocsExempt REJECTS marker with empty reason (<!-- docs-exempt: -->)', () => {
|
|
const out = extractDocsExempt('body\n<!-- docs-exempt: -->');
|
|
assert.equal(out.docsExempt, null);
|
|
});
|
|
|
|
test('extractDocsExempt REJECTS marker with whitespace-only reason', () => {
|
|
const out = extractDocsExempt('body\n<!-- docs-exempt: -->');
|
|
assert.equal(out.docsExempt, null);
|
|
});
|
|
|
|
test('extractDocsExempt is case-insensitive on the marker token', () => {
|
|
const out = extractDocsExempt('body\n<!-- DOCS-EXEMPT: shouty reason -->');
|
|
assert.equal(out.docsExempt, 'shouty reason');
|
|
});
|
|
|
|
test('parseFragment surfaces docsExempt on the fragment record', () => {
|
|
const src = '---\ntype: Added\npr: 3213\n---\nbootstrap.\n\n<!-- docs-exempt: bootstrap -->\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.fragment.docsExempt, 'bootstrap');
|
|
// Marker must not appear in the rendered body. CHANGELOG and GitHub
|
|
// release-notes serializers append `(#NNNN)` to the body's last line;
|
|
// a trailing comment line would attach the suffix to the wrong content.
|
|
assert.doesNotMatch(r.fragment.body, /docs-exempt/);
|
|
assert.match(r.fragment.body, /bootstrap\./);
|
|
});
|
|
|
|
test('parseFragment fails EMPTY_BODY when the body is only a docs-exempt marker', () => {
|
|
const src = '---\ntype: Added\npr: 1\n---\n<!-- docs-exempt: nothing else -->\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.reason, FRAGMENT_ERROR.EMPTY_BODY);
|
|
});
|
|
|
|
test('DOCS_EXEMPT_RE is exposed and matches the documented shape (colon + non-empty reason required)', () => {
|
|
assert.ok(DOCS_EXEMPT_RE instanceof RegExp);
|
|
assert.match('<!-- docs-exempt: x -->', DOCS_EXEMPT_RE);
|
|
assert.match('<!-- docs-exempt: bootstrap reason with spaces -->', DOCS_EXEMPT_RE);
|
|
assert.doesNotMatch('docs-exempt: not in a comment', DOCS_EXEMPT_RE);
|
|
assert.doesNotMatch('<!-- docs-exempt -->', DOCS_EXEMPT_RE); // no colon
|
|
assert.doesNotMatch('<!-- docs-exempt: -->', DOCS_EXEMPT_RE); // empty reason
|
|
assert.doesNotMatch('<!-- docs-exempt: -->', DOCS_EXEMPT_RE); // whitespace-only reason
|
|
});
|
|
|
|
test('inline mention inside backticks does NOT count as a marker (false-positive guard)', () => {
|
|
// Fragment body documents the marker syntax inline as part of release notes.
|
|
// Without the line-anchor, the regex would mis-identify this as an actual
|
|
// exemption and strip release-note content.
|
|
const src =
|
|
'---\ntype: Added\npr: 3213\n---\n' +
|
|
'New escape hatch: `<!-- docs-exempt: <reason> -->` on its own line at the end of a fragment body exempts that fragment from docs lint.\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.fragment.docsExempt, null);
|
|
// The literal syntax example must remain in the rendered body — it is
|
|
// legitimate release-note content explaining the new feature.
|
|
assert.match(r.fragment.body, /docs-exempt/);
|
|
});
|
|
|
|
test('CRLF-authored fragments: marker is stripped cleanly without residual \\r (Codex finding)', () => {
|
|
// Codex's exact repro from the second review pass:
|
|
// Feature.\r\n\r\n<!-- docs-exempt: x -->\r\n
|
|
// Before the fix this parsed to body `Feature.\r\n\r\n\r`, which made
|
|
// serializeChangelog emit `- Feature.\r\n\r\n\r (#1)` — the PR suffix
|
|
// landed on a blank line instead of attached to the visible bullet.
|
|
const src = '---\r\ntype: Added\r\npr: 1\r\n---\r\nFeature.\r\n\r\n<!-- docs-exempt: x -->\r\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.fragment.docsExempt, 'x');
|
|
assert.doesNotMatch(r.fragment.body, /[\r]/); // no residual CR characters
|
|
assert.doesNotMatch(r.fragment.body, /docs-exempt/);
|
|
// End-to-end: round-trip through serialize → parse to assert on the
|
|
// structured changelog IR, not rendered text (CONTRIBUTING.md:
|
|
// "Prohibited: Raw Text Matching on Test Outputs"). The buggy pre-fix
|
|
// body shape (`Feature.\r\n\r\n\r`) breaks `parseChangelog`'s bullet
|
|
// regex — it returns an empty `bullets: []` — so this round-trip is
|
|
// a stronger regression check than a substring match.
|
|
const { serializeChangelog, parseChangelog } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'serialize.cjs'));
|
|
const out = serializeChangelog({
|
|
releaseHeader: { version: '1.0.0', date: '2026-01-01' },
|
|
sections: [{ type: 'Added', bullets: [{ pr: r.fragment.pr, body: r.fragment.body }] }],
|
|
priorChangelog: null,
|
|
});
|
|
const parsed = parseChangelog(out);
|
|
assert.equal(parsed.releases.length, 1);
|
|
assert.deepEqual(parsed.releases[0].sections, [
|
|
{ type: 'Added', bullets: [{ body: 'Feature.', pr: 1 }] },
|
|
]);
|
|
});
|
|
|
|
test('CRLF-authored fragment without marker: no stripping needed, body unchanged in semantics', () => {
|
|
const src = '---\r\ntype: Fixed\r\npr: 5\r\n---\r\nbug fix.\r\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.fragment.docsExempt, null);
|
|
assert.match(r.fragment.body, /bug fix\./);
|
|
});
|
|
|
|
test('marker as the FIRST line of the body does not leave a leading line terminator (root-cause fix)', () => {
|
|
// Before the fix: DOCS_EXEMPT_RE's `$` (multiline mode) does not consume
|
|
// the `\n` terminating the marker's own line, so when the marker is the
|
|
// first line, `body` was left starting with `\n`. serializeChangelog then
|
|
// emits an empty `- ` bullet followed by an orphaned paragraph, and
|
|
// parseChangelog's continuation check (requires leading `\s`) treats the
|
|
// non-indented paragraph as terminating the bullet — dropping the
|
|
// content on re-parse.
|
|
const src = '---\ntype: Fixed\npr: 1929\n---\n<!-- docs-exempt: first-line marker -->\nBold text describing the fix.\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.fragment.docsExempt, 'first-line marker');
|
|
assert.doesNotMatch(r.fragment.body, /^[\r\n]/);
|
|
assert.match(r.fragment.body, /Bold text describing the fix\./);
|
|
});
|
|
|
|
test('CRLF-authored fragment with a FIRST-line marker: same leading-terminator fix applies', () => {
|
|
const src = '---\r\ntype: Fixed\r\npr: 1929\r\n---\r\n<!-- docs-exempt: first-line marker -->\r\nBold text describing the fix.\r\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.fragment.docsExempt, 'first-line marker');
|
|
assert.doesNotMatch(r.fragment.body, /^[\r\n]/);
|
|
assert.doesNotMatch(r.fragment.body, /[\r]/);
|
|
// LF- and CRLF-authored variants of the same fragment must produce the
|
|
// identical body — the recurring CRLF bug class (#1658/#1668/#2206/
|
|
// #2449/#2450) means these two must never diverge.
|
|
const lfSrc = '---\ntype: Fixed\npr: 1929\n---\n<!-- docs-exempt: first-line marker -->\nBold text describing the fix.\n';
|
|
const lfResult = parseFragment(lfSrc);
|
|
assert.equal(r.fragment.body, lfResult.fragment.body);
|
|
});
|
|
|
|
test('end-to-end: FIRST-line marker fragment survives serializeChangelog → parseChangelog round-trip (#1929 pin)', () => {
|
|
// This is the assertion that pins the user-visible defect: a dropped
|
|
// bullet in GitHub release notes (scripts/changeset/github-release-notes.cjs),
|
|
// built by feeding the CHANGELOG.md text through parseChangelog.
|
|
const src = '---\ntype: Fixed\npr: 1929\n---\n<!-- docs-exempt: first-line marker -->\nBold text describing the fix.\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, true);
|
|
|
|
const { serializeChangelog, parseChangelog } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'serialize.cjs'));
|
|
const out = serializeChangelog({
|
|
releaseHeader: { version: '1.0.0', date: '2026-01-01' },
|
|
sections: [{ type: 'Fixed', bullets: [{ pr: r.fragment.pr, body: r.fragment.body }] }],
|
|
priorChangelog: null,
|
|
});
|
|
const parsed = parseChangelog(out);
|
|
assert.equal(parsed.releases.length, 1);
|
|
assert.deepEqual(parsed.releases[0].sections, [
|
|
{ type: 'Fixed', bullets: [{ body: 'Bold text describing the fix.', pr: 1929 }] },
|
|
]);
|
|
});
|
|
|
|
test('marker on its own line trailing a fragment body still wins (real-marker positive case)', () => {
|
|
const src =
|
|
'---\ntype: Added\npr: 3213\n---\n' +
|
|
'New escape hatch: `<!-- docs-exempt: <reason> -->` documents the syntax.\n' +
|
|
'\n' +
|
|
'<!-- docs-exempt: bootstrap of the lint itself -->\n';
|
|
const r = parseFragment(src);
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.fragment.docsExempt, 'bootstrap of the lint itself');
|
|
// The trailing real-marker line is stripped — the "bootstrap" reason
|
|
// should not appear anywhere in the rendered body.
|
|
assert.doesNotMatch(r.fragment.body, /bootstrap of the lint itself/);
|
|
// … but the inline syntax example is preserved.
|
|
assert.match(r.fragment.body, /docs-exempt: <reason>/);
|
|
});
|
|
});
|