Files
get-shit-done/scripts/changeset/serialize.cjs
Tom Boucher 9d5db87249 feat(#2975): adopt changeset-fragment workflow to eliminate CHANGELOG conflicts (#2978)
* feat(#2975): adopt changeset-fragment workflow to eliminate CHANGELOG conflicts

Two PRs that both edit `### Fixed` in CHANGELOG.md always conflict on merge.
Recently bit on #2960/#2972 in the same session — fix-the-conflict-and-rebase
tax. Replace the shared-file model with per-PR fragment files that never
share lines.

Implementation built TDD per #2975, vertical slices with structured-IR
assertions throughout:

  scripts/changeset/parse.cjs       - fragment text → typed record + frozen
                                      FRAGMENT_ERROR enum (8 tests)
  scripts/changeset/render.cjs      - fragments → structured IR with
                                      Keep-a-Changelog section ordering
                                      (2 tests)
  scripts/changeset/serialize.cjs   - IR ↔ markdown round-trip pair
                                      (parse(serialize(ir)) === ir,
                                      3 tests)
  scripts/changeset/cli.cjs         - file-I/O wrapper with --json mode;
                                      reads .changeset/, folds into
                                      CHANGELOG.md, deletes consumed
                                      fragments. Idempotent. (1 test)
  scripts/changeset/lint.cjs        - pure verdict (changedFiles, labels)
                                      → { ok, reason } via LINT_REASON
                                      enum. Honors `no-changelog` label.
                                      (5 tests)
  scripts/changeset/new.cjs         - fragment scaffolder with random
                                      adjective-noun-noun filename. Tests
                                      assert via parseFragment round-trip.
                                      (3 tests)

Total: 22 tests, all assertions on typed structured fields. No regex on
text, no String#includes on file content. Lint clean across 356 test files.

Supporting:

  .changeset/README.md              - format spec + workflow docs
  .changeset/eager-hawks-rally.md   - dogfood fragment for THIS PR (will
                                      be the first thing the new release
                                      tool consumes)
  .github/workflows/changeset-required.yml
                                    - CI: every PR runs lint.cjs
  package.json                      - npm run changeset, changelog:render,
                                      lint:changeset
  CONTRIBUTING.md                   - new "CHANGELOG Entries — Drop a
                                      Fragment" section between PR
                                      Guidelines and Testing Standards

Closes #2975

* fix(#2975): address CodeRabbit findings on changeset workflow

7 valid findings (4 Major, 3 Minor); all addressed:

scripts/changeset/parse.cjs
  - Preserve fragment body verbatim. Previously body.trim() ate
    intentional leading whitespace (code blocks, etc.); now trim() is
    used only for the emptiness check, and a single trailing newline
    is stripped (the editor-added one) so well-formed fragments
    round-trip byte-for-byte. Added a regression test asserting a
    code-block-leading body is preserved.

scripts/changeset/cli.cjs
  - Validate flag values during argument parsing. parseArgs now returns
    { ok, opts | error }; rejects `--repo` etc. with no following value
    or with another flag as the value. main() surfaces the error
    message before exiting 2.
  - Handle post-write fragment-deletion failures. After CHANGELOG.md
    is written, any unlink failure is captured into a structured
    deleteFailures list with reason 'fail_fragment_delete'; cmdRender
    returns exitCode=1 with the partial-failure detail instead of
    leaving the changelog updated and fragments behind (which would
    cause double-consumption on rerun).

scripts/changeset/lint.cjs
  - Treat CHANGELOG.md as a linted user-facing path. Direct edits to
    CHANGELOG.md (the bypass route around the new workflow) now fail
    the lint with FAIL_MISSING_FRAGMENT. Added a regression test for
    that case.
  - Use cp.execFileSync instead of cp.execSync for the git diff call.
    Eliminates the shell-interpolation surface on GITHUB_BASE_REF;
    git's own arg parser remains the validator.

scripts/changeset/new.cjs
  - Atomic fragment creation. existsSync() + writeFileSync was racy
    under concurrent invocations. Now writeFileSync uses { flag: 'wx' }
    which fails EEXIST on collision; the random-name retry loop
    catches EEXIST and re-rolls. Throws explicitly after 16 attempts
    rather than silently overwriting.

.changeset/README.md
  - Add language tag `md` to the format example fence (markdownlint
    MD040).

All 25 changeset tests pass; lint clean (356 test files, 0 violations).

* fix(#2975): sanitize --type and validate flag values in new.cjs (CR fixes)

Two CR findings on scripts/changeset/new.cjs:

1. (Minor) `type` was embedded in frontmatter without sanitization. A
   newline in the value (e.g. `--type 'Fixed\ntype: Added'`) would
   corrupt the fragment. scaffoldFragment now validates `type` against
   the Keep-a-Changelog ALLOWED_TYPES set BEFORE writing — same set
   parse.cjs uses on consume. Throws with a typed error referencing
   the allowed values; tests cover the newline case + 4 other
   non-allowed values.

2. (Minor) `--repo` (and other value-taking flags) without a value
   silently set opts.repo to undefined, which produced a cryptic
   ERR_INVALID_ARG_TYPE deep inside path.join. parseArgs now mirrors
   the cli.cjs convention: returns { ok, opts | error }, validates
   that the next token exists and is not itself another flag, and
   surfaces a precise "missing value for --repo" message before exit.
   Added 3 tests: missing-trailing-value, flag-as-value, well-formed.

29 tests pass across the changeset suite (4 new regression tests).
2026-05-01 18:12:20 -04:00

75 lines
2.1 KiB
JavaScript

'use strict';
/**
* Markdown serializer + parser for the changelog IR. The two are inverses
* over the well-formed subset; tests assert via round-trip (parse(serialize(ir)))
* rather than by inspecting serialized text — see CONTRIBUTING.md
* "Prohibited: Raw Text Matching on Test Outputs".
*
* Serialized form (Keep a Changelog):
*
* ## [1.42.0] - 2026-05-01
*
* ### Fixed
*
* - body of the bullet (#NNNN)
*
* <priorChangelog appended verbatim>
*/
function serializeChangelog(ir) {
const lines = [];
const { version, date } = ir.releaseHeader;
lines.push(`## [${version}] - ${date}`);
lines.push('');
for (const section of ir.sections) {
lines.push(`### ${section.type}`);
lines.push('');
for (const b of section.bullets) {
lines.push(`- ${b.body} (#${b.pr})`);
}
lines.push('');
}
let out = lines.join('\n');
if (ir.priorChangelog) {
out += '\n' + ir.priorChangelog;
}
return out;
}
/**
* Inverse parser: extracts the structured releases from a CHANGELOG.md
* text. Returns { releases: [{ version, date, sections: [{ type, bullets:
* [{ pr, body }] }] }] }. Tolerates the actual repo's CHANGELOG dialect.
*/
function parseChangelog(text) {
const releases = [];
const lines = text.split(/\r?\n/);
let cur = null;
let curSection = null;
for (const line of lines) {
const releaseMatch = line.match(/^##\s+\[([^\]]+)\](?:\s*-\s*(\S+))?/);
if (releaseMatch) {
cur = { version: releaseMatch[1], date: releaseMatch[2] || null, sections: [] };
curSection = null;
releases.push(cur);
continue;
}
if (!cur) continue;
const sectionMatch = line.match(/^###\s+(.+?)\s*$/);
if (sectionMatch) {
curSection = { type: sectionMatch[1], bullets: [] };
cur.sections.push(curSection);
continue;
}
if (!curSection) continue;
const bulletMatch = line.match(/^-\s+(.*?)\s*\(#(\d+)\)\s*$/);
if (bulletMatch) {
curSection.bullets.push({ body: bulletMatch[1], pr: Number(bulletMatch[2]) });
}
}
return { releases };
}
module.exports = { serializeChangelog, parseChangelog };