* test(#2359): failing-first regression for the 1.4.0 Cursor commands citation The `## [1.4.0]` CHANGELOG entry for the `.cursor/commands/` surface carries the trailing reference (#803) — the Cline PR, cited correctly by the entry two lines below. The Cursor surface shipped in #805 (feat(#785)). Adds four behavioral cases to the owning module's test file, asserting through parseChangelog (the same parser cmdExtract/cmdVerify/cmdRender use) rather than raw text matching: 1. the Cursor bullet cites 805 - FAILS before the fix 2. the adjacent Cline bullet still cites 803 - guards a global s/803/805/ 3. exactly one Cursor bullet exists - guards drop/duplicate 4. the two bullets cite different PRs - the defect as an invariant Folded into tests/changeset-serialize.test.cjs rather than a new bug-2359-*.test.cjs file, per scripts/lint-regression-test-names.cjs. Refs #2359 * fix(#2359): CHANGELOG 1.4.0 Cursor commands entry cites #805, not #803 The `## [1.4.0]` entry for `gsd install --cursor` writing `.cursor/commands/gsd-<name>.md` carried the trailing reference (#803). That PR is `feat(#787): elevate Cline` — cited correctly by the entry two lines below. The Cursor slash-command surface shipped in #805 (`feat(#785): write .cursor/commands/ Cursor 1.6 slash-command surface`). Two adjacent bullets therefore claimed one PR, and only the second was right. The trailing (#NNNN) is machine-parsed by parseChangelog and consumed by `changeset extract`, so the wrong number is live data, not only prose — and it had already propagated to a human reporter (#2341, quoted there as "#785 / #803"). Single-occurrence, line-anchored edit. The Cline entry's (#803) is correct and is deliberately untouched; the regression test added in the preceding commit asserts both, so a global s/803/805/ fails. Prose is unchanged, including the "both surfaces are written on every install" clause the issue explicitly certifies as accurate. Fixes #2359 * chore(#2359): changeset fragment for the Cursor commands citation fix * test(#2359): assert bullet cardinality before reading it Review findings, one root cause: each of the four cases re-derived the bullet list, re-filtered by marker, then destructured `const [x] = ...` and read `.pr` unguarded. A reworded or deleted entry therefore died with TypeError: Cannot read properties of undefined (reading 'pr') instead of naming what was missing. The repetition was also duplicated logic across all four cases. Adds locateBullet(version, marker, label), which asserts exactly one match before returning, and folds the two cardinality guards into one case covering both entries. Failure mode verified: a marker matching nothing now raises AssertionError "expected exactly one bogus bullet in the 1.4.0 section, found 0". Refs #2359 * chore(#2359): backfill changeset pr number (#3252) --------- Co-authored-by: sim <sim@local>
246 lines
11 KiB
JavaScript
246 lines
11 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 path = require('node:path');
|
|
|
|
const { serializeChangelog, parseChangelog } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'serialize.cjs'));
|
|
|
|
// Round-trip property: serialize(IR) → parse(text) → IR equals original.
|
|
// Tests assert on the parsed IR shape, not the serialized text contents.
|
|
|
|
describe('changeset serialize: IR → markdown round-trip (#2975)', () => {
|
|
test('a single-section IR round-trips through serialize → parse', () => {
|
|
const ir = {
|
|
releaseHeader: { version: '1.0.0', date: '2026-01-01' },
|
|
sections: [
|
|
{ type: 'Fixed', bullets: [{ pr: 1, body: 'fix something.' }] },
|
|
],
|
|
priorChangelog: null,
|
|
};
|
|
const text = serializeChangelog(ir);
|
|
const back = parseChangelog(text);
|
|
|
|
assert.equal(back.releases[0].version, '1.0.0');
|
|
assert.equal(back.releases[0].date, '2026-01-01');
|
|
assert.equal(back.releases[0].sections.length, 1);
|
|
assert.equal(back.releases[0].sections[0].type, 'Fixed');
|
|
assert.equal(back.releases[0].sections[0].bullets.length, 1);
|
|
assert.equal(back.releases[0].sections[0].bullets[0].pr, 1);
|
|
});
|
|
});
|
|
|
|
describe('changeset serialize: multi-line bullet parsing (#3496)', () => {
|
|
// Regression: parseChangelog silently dropped bullets whose text wrapped
|
|
// across multiple indented continuation lines. The PR number (#NNNN) appears
|
|
// on the final continuation line, not the opening `-` line, so the
|
|
// single-line bullet regex never matched. Every such bullet returned 0
|
|
// entries for its section even though the markdown was well-formed.
|
|
test('parses a multi-line bullet whose (# pr) trailer is on a continuation line', () => {
|
|
const text = [
|
|
'## [1.41.0] - 2026-05-07',
|
|
'',
|
|
'### Feature',
|
|
'',
|
|
'- **Short title** — first line of a long description',
|
|
' that wraps onto a second line and terminates with. (#2792)',
|
|
'- Single-line bullet with pr. (#2800)',
|
|
].join('\n');
|
|
const result = parseChangelog(text);
|
|
assert.equal(result.releases.length, 1);
|
|
const section = result.releases[0].sections[0];
|
|
assert.equal(section.type, 'Feature');
|
|
assert.equal(section.bullets.length, 2, 'both bullets (multi-line and single-line) must be captured');
|
|
assert.equal(section.bullets[0].pr, 2792, 'multi-line bullet pr');
|
|
assert.equal(section.bullets[1].pr, 2800, 'single-line bullet pr');
|
|
});
|
|
|
|
test('parses a bullet with (# pr) on the opening line even when followed by multi-line bullets', () => {
|
|
const text = [
|
|
'## [1.42.1] - 2026-05-15',
|
|
'',
|
|
'### Fixed',
|
|
'',
|
|
'- Simple fix. (#3261)',
|
|
'- **Complex fix** — first line of',
|
|
' a multi-line description. (#3287)',
|
|
].join('\n');
|
|
const result = parseChangelog(text);
|
|
const section = result.releases[0].sections[0];
|
|
assert.equal(section.bullets.length, 2);
|
|
assert.equal(section.bullets[0].pr, 3261);
|
|
assert.equal(section.bullets[1].pr, 3287);
|
|
});
|
|
|
|
test('a multi-line bullet with linked release header is parsed correctly', () => {
|
|
const text = [
|
|
'## [1.42.1](https://github.com/open-gsd/gsd-core/compare/v1.41.0...v1.42.1) - 2026-05-15',
|
|
'',
|
|
'### Fixed',
|
|
'',
|
|
'- **Multi-line with linked header** — description spans',
|
|
' multiple lines and version header has an inline URL. (#3287)',
|
|
].join('\n');
|
|
const result = parseChangelog(text);
|
|
assert.equal(result.releases[0].version, '1.42.1');
|
|
assert.equal(result.releases[0].sections[0].bullets.length, 1);
|
|
assert.equal(result.releases[0].sections[0].bullets[0].pr, 3287);
|
|
});
|
|
|
|
test('preserves bullets without (# pr) trailer as { body, pr: null } instead of dropping them', () => {
|
|
// Regression guard for Codex finding: bullets that lack a trailing
|
|
// (# NNNN) were silently discarded. They must be stored with pr: null
|
|
// so callers (e.g. cmdExtract) can render them without silent loss.
|
|
const text = [
|
|
'## [1.43.0] - 2026-05-20',
|
|
'',
|
|
'### Fixed',
|
|
'',
|
|
'- Fix with a PR reference. (#4000)',
|
|
'- Documented fix without a PR reference.',
|
|
'- **Multi-line fix without trailer** — first line of description',
|
|
' that continues on a second line but has no (# NNN) at the end.',
|
|
].join('\n');
|
|
const result = parseChangelog(text);
|
|
const section = result.releases[0].sections[0];
|
|
assert.equal(section.bullets.length, 3, 'all three bullets must be captured');
|
|
assert.equal(section.bullets[0].pr, 4000, 'PR bullet preserved');
|
|
assert.equal(section.bullets[1].pr, null, 'no-PR bullet has pr: null');
|
|
assert.ok(section.bullets[1].body.includes('Documented fix'), 'no-PR bullet body preserved');
|
|
assert.equal(section.bullets[2].pr, null, 'multi-line no-PR bullet has pr: null');
|
|
assert.ok(section.bullets[2].body.includes('Multi-line fix without trailer'), 'multi-line no-PR body preserved');
|
|
});
|
|
});
|
|
|
|
describe('changeset serialize: multi-section + prior content (#2975)', () => {
|
|
const { serializeChangelog, parseChangelog } = require(require('node:path').join(__dirname, '..', 'scripts', 'changeset', 'serialize.cjs'));
|
|
|
|
test('round-trips an IR with three section types and multiple bullets per section', () => {
|
|
const ir = {
|
|
releaseHeader: { version: '1.42.0', date: '2026-05-01' },
|
|
sections: [
|
|
{ type: 'Added', bullets: [{ pr: 1, body: 'add A' }, { pr: 2, body: 'add B' }] },
|
|
{ type: 'Changed', bullets: [{ pr: 3, body: 'change C' }] },
|
|
{ type: 'Fixed', bullets: [{ pr: 4, body: 'fix D' }, { pr: 5, body: 'fix E' }] },
|
|
],
|
|
priorChangelog: null,
|
|
};
|
|
const back = parseChangelog(serializeChangelog(ir));
|
|
assert.equal(back.releases.length, 1);
|
|
assert.deepEqual(
|
|
back.releases[0].sections.map((s) => ({ type: s.type, prs: s.bullets.map((b) => b.pr) })),
|
|
[
|
|
{ type: 'Added', prs: [1, 2] },
|
|
{ type: 'Changed', prs: [3] },
|
|
{ type: 'Fixed', prs: [4, 5] },
|
|
],
|
|
);
|
|
});
|
|
|
|
test('prior CHANGELOG content survives serialize → parse as a separate release block', () => {
|
|
const priorText = '## [0.9.0] - 2025-12-01\n\n### Fixed\n\n- old fix (#100)\n';
|
|
const ir = {
|
|
releaseHeader: { version: '1.0.0', date: '2026-01-01' },
|
|
sections: [{ type: 'Added', bullets: [{ pr: 200, body: 'new feature' }] }],
|
|
priorChangelog: priorText,
|
|
};
|
|
const back = parseChangelog(serializeChangelog(ir));
|
|
assert.equal(back.releases.length, 2);
|
|
assert.equal(back.releases[0].version, '1.0.0');
|
|
assert.equal(back.releases[1].version, '0.9.0');
|
|
assert.equal(back.releases[1].sections[0].bullets[0].pr, 100);
|
|
});
|
|
});
|
|
|
|
// ─── #3001: multi-paragraph body round-trip ────────────────────────────────
|
|
|
|
describe('#3001: multi-paragraph changeset body round-trips without truncation', () => {
|
|
test('a body containing \\n\\n retains content from both paragraphs AND its PR number', () => {
|
|
const ir = {
|
|
releaseHeader: { version: '1.42.0', date: '2026-08-05' },
|
|
sections: [{
|
|
type: 'Fixed',
|
|
bullets: [{
|
|
pr: 2595,
|
|
body: 'First paragraph, load-bearing.\n\nSecond paragraph, also load-bearing.',
|
|
}],
|
|
}],
|
|
priorChangelog: null,
|
|
};
|
|
const text = serializeChangelog(ir);
|
|
const back = parseChangelog(text);
|
|
const bullet = back.releases[0].sections[0].bullets[0];
|
|
// PR trailer must survive (was lost pre-fix because the trailer was on the dropped side).
|
|
assert.equal(bullet.pr, 2595,
|
|
`PR number must survive round-trip; got: ${bullet.pr}`);
|
|
// Content from BOTH paragraphs must be present (was truncated pre-fix).
|
|
assert.ok(bullet.body.includes('First paragraph'),
|
|
`first paragraph must survive; got: ${JSON.stringify(bullet.body)}`);
|
|
assert.ok(bullet.body.includes('Second paragraph'),
|
|
`second paragraph must survive; got: ${JSON.stringify(bullet.body)}`);
|
|
});
|
|
});
|
|
|
|
describe('changeset serialize: shipped CHANGELOG citation integrity (#2359)', () => {
|
|
// Regression: the `## [1.4.0]` Cursor commands-surface entry carried the
|
|
// trailing reference (#803) — the *Cline* PR, cited correctly by the entry two
|
|
// lines below it. The Cursor surface actually shipped in #805. Two adjacent
|
|
// bullets therefore claimed one PR, and only the second was right.
|
|
//
|
|
// This is not cosmetic: the trailing (#NNNN) is machine-parsed here and
|
|
// consumed by `cmdExtract`, so a wrong number propagates into extracted
|
|
// release-note ranges. It had already propagated to a human reporter (#2341,
|
|
// quoted there as "#785 / #803").
|
|
//
|
|
// The obvious bad fix is a global s/(#803)/(#805)/, which repairs the Cursor
|
|
// entry and silently corrupts the Cline one. The Cline assertion below is the
|
|
// guard for exactly that, and is expected to pass both before and after.
|
|
const CHANGELOG = fs.readFileSync(path.join(__dirname, '..', 'CHANGELOG.md'), 'utf8');
|
|
const CURSOR_MARKER = /`\.cursor\/commands\/gsd-<name>\.md`/;
|
|
const CLINE_MARKER = /Elevate the Cline runtime to hook parity/;
|
|
|
|
function bulletsOf(version) {
|
|
const release = parseChangelog(CHANGELOG).releases.find((r) => r.version === version);
|
|
assert.ok(release, `CHANGELOG.md must still carry a \`## [${version}]\` release section`);
|
|
return release.sections.flatMap((s) => s.bullets);
|
|
}
|
|
|
|
// Asserts cardinality before returning, so a reworded or deleted entry fails
|
|
// with a readable message instead of a TypeError on an undefined bullet.
|
|
function locateBullet(version, marker, label) {
|
|
const matches = bulletsOf(version).filter((b) => marker.test(b.body));
|
|
assert.equal(matches.length, 1,
|
|
`expected exactly one ${label} bullet in the ${version} section, found ${matches.length}`);
|
|
return matches[0];
|
|
}
|
|
|
|
test('the 1.4.0 Cursor commands-surface bullet cites PR 805', () => {
|
|
const cursor = locateBullet('1.4.0', CURSOR_MARKER, 'Cursor commands-surface');
|
|
assert.equal(cursor.pr, 805,
|
|
'the .cursor/commands/ surface shipped in PR #805 (feat(#785)), not #803 (the Cline PR)');
|
|
});
|
|
|
|
test('the adjacent 1.4.0 Cline bullet still cites PR 803', () => {
|
|
const cline = locateBullet('1.4.0', CLINE_MARKER, 'Cline hook-parity');
|
|
assert.equal(cline.pr, 803,
|
|
"the Cline entry's #803 is correct and must survive the Cursor correction");
|
|
});
|
|
|
|
test('both 1.4.0 entries survive the correction exactly once each', () => {
|
|
const bullets = bulletsOf('1.4.0');
|
|
assert.equal(bullets.filter((b) => CURSOR_MARKER.test(b.body)).length, 1,
|
|
'the Cursor entry must be corrected in place — neither dropped nor duplicated');
|
|
assert.equal(bullets.filter((b) => CLINE_MARKER.test(b.body)).length, 1,
|
|
'the Cline entry must be left alone — neither dropped nor duplicated');
|
|
});
|
|
|
|
test('the adjacent Cursor and Cline bullets do not cite the same PR', () => {
|
|
const cursor = locateBullet('1.4.0', CURSOR_MARKER, 'Cursor commands-surface');
|
|
const cline = locateBullet('1.4.0', CLINE_MARKER, 'Cline hook-parity');
|
|
assert.notEqual(cursor.pr, cline.pr,
|
|
'two adjacent entries describing different runtimes cannot share one PR');
|
|
});
|
|
});
|