From 018547507c7b7d42c12743195cc492af0c607d3f Mon Sep 17 00:00:00 2001 From: Anarchid Date: Mon, 24 Aug 2026 16:03:31 +0300 Subject: [PATCH 1/5] chore(changelog): land entries as per-change fragments in changelog.d/ Concurrent PRs editing the shared '## Unreleased' section of CHANGELOG.md conflict whenever one PR outlives another merge (e.g. #115, whose only textual conflict against main is CHANGELOG.md). Entries now land as uniquely-named fragment files (changelog.d/..md), which git merges without conflict; the npm-version hook folds them into the release section and deletes them. Direct '## Unreleased' edits remain supported and are merged at the same point, so in-flight PRs need no rework. Tag-time publish guard and github-release job are unchanged. Co-Authored-By: Claude Fable 5 --- .github/PULL_REQUEST_TEMPLATE.md | 5 +- .github/workflows/changelog.yml | 8 +- CHANGELOG.md | 4 +- CONTRIBUTING.md | 63 +++++++---- changelog.d/README.md | 22 ++++ changelog.d/changelog-fragments.changed.md | 5 + package.json | 2 +- scripts/release-changelog.mjs | 116 +++++++++++++++++++-- 8 files changed, 184 insertions(+), 41 deletions(-) create mode 100644 changelog.d/README.md create mode 100644 changelog.d/changelog-fragments.changed.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3316dc4a..2f9efbfe 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,8 +21,9 @@ --- -- [ ] `CHANGELOG.md` updated under `## Unreleased` — or this change is - internal-only / test-only / docs-only (apply the `no-changelog` label). +- [ ] Changelog fragment added — `changelog.d/..md` + (see `changelog.d/README.md`) — or this change is internal-only / + test-only / docs-only (apply the `no-changelog` label). diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index c8be4729..8569b0a8 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -18,15 +18,17 @@ jobs: with: fetch-depth: 0 - - name: Require CHANGELOG.md update when src/ changes + - name: Require changelog fragment when src/ changes run: | base="${{ github.event.pull_request.base.sha }}" head="${{ github.event.pull_request.head.sha }}" changed=$(git diff --name-only "$base...$head") echo "Changed files:" echo "$changed" - if echo "$changed" | grep -q '^src/' && ! echo "$changed" | grep -qx 'CHANGELOG.md'; then - echo "::error::This PR touches src/ but not CHANGELOG.md. Add an entry under 'Unreleased' (see CONTRIBUTING.md), or apply the 'no-changelog' label if the change is internal-only." + if echo "$changed" | grep -q '^src/' \ + && ! echo "$changed" | grep -Eq '^changelog\.d/[^/]+\.(breaking|added|changed|fixed)\.md$' \ + && ! echo "$changed" | grep -qx 'CHANGELOG.md'; then + echo "::error::This PR touches src/ but carries no changelog entry. Add a fragment changelog.d/..md (see CONTRIBUTING.md), or apply the 'no-changelog' label if the change is internal-only." exit 1 fi echo "OK" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b417dff..173513ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ Notable changes to `@animalabs/agent-framework`, loosely following [Keep a Changelog](https://keepachangelog.com/). Entries land with the change -that causes them — see [CONTRIBUTING.md](CONTRIBUTING.md#changelog). +that causes them, as fragment files in [`changelog.d/`](changelog.d/) that are +folded into a version section at release time — see +[CONTRIBUTING.md](CONTRIBUTING.md#changelog). Releases up to and including 0.7.3 predate this file; for their contents see `git log` and the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8772f80b..be714a1c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ plus, when applicable, **Not verified**, **Out of scope**, and - **Tests accompany behavior changes.** Review scrutinizes test substance, not mere presence — a test that can't fail on the unfixed code will be called out. -- **Changelog entry** under `## Unreleased` for anything behavior-affecting +- **Changelog fragment** in `changelog.d/` for anything behavior-affecting (see below). Conventional-commit-style titles (`feat(modules): …`, `fix(streaming): …`) are @@ -85,38 +85,55 @@ that don't fail on unfixed code, or with claims the branch itself disproves. ## Changelog -`CHANGELOG.md` keeps a standing `## Unreleased` section with -`### Breaking` / `### Added` / `### Changed` / `### Fixed` subsections -(loosely [Keep a Changelog](https://keepachangelog.com/)). - -- **The entry lands with the change** — same commit, or at least the same +Changelog entries land as **fragment files** in +[`changelog.d/`](changelog.d/) — one file per change — and are folded into +`CHANGELOG.md` (loosely [Keep a Changelog](https://keepachangelog.com/)) at +release time. One file per change is what keeps concurrent work from +conflicting: when every PR edited the same `## Unreleased` section, any PR +that outlived another merge hit a conflict in `CHANGELOG.md`; distinct files +never do. + +- **Format:** `changelog.d/..md`, + containing one or more markdown bullets (`- …`), written exactly as they + should appear in `CHANGELOG.md` (continuation lines indent two spaces). + The slug just has to be unique among pending fragments — the PR number or + branch name works (`115-tune-out.added.md`). The release script refuses + unrecognized category suffixes rather than silently stranding an entry. +- **The fragment lands with the change** — same commit, or at least the same PR. This binds direct pushes to `main` just as much as PRs. On PRs, CI - enforces it softly: touching `src/` without touching `CHANGELOG.md` fails - the `changelog` check unless the `no-changelog` label is applied. + enforces it softly: touching `src/` without adding a fragment (or editing + `CHANGELOG.md`) fails the `changelog` check unless the `no-changelog` + label is applied. - **What needs an entry:** anything a module developer, host, or downstream consumer would notice — behavior, event/trace surfaces, tool namespacing, config schema, agent state machine, public exports, defaults. Internal refactors, test-only, and docs-only changes don't. -- **Breaking entries are audience-scoped.** Name the audience in the heading - (`### Breaking (module authors only)`) and cover: **who needs to act**, +- **Breaking entries are audience-scoped.** Open the bullet by naming who + needs to act (`- **Module authors:** …`) and cover: **who needs to act**, **migration**, and **unchanged** (what readers might fear broke but didn't). Because connectome-host and other hosts pin this package by range, a breaking change here surfaces in their next install — spell out the minimum sibling versions it requires. -- **Keep one `## Unreleased` heading.** Add entries under the existing one; - don't open a second. Only the first is cut at release time, so entries - filed under a later heading are silently never released — the release - script refuses to run if it finds more than one. +- **Editing `## Unreleased` in `CHANGELOG.md` directly still works** and is + merged with the fragments at release time — it remains the right place to + restructure pending entries, and the escape hatch for anything the + fragment format can't express (e.g. an audience-qualified + `### Breaking (module authors only)` heading, which `breaking` fragments + will then join). Keep one `## Unreleased` heading — the release script + refuses more than one, since only the first is ever cut. - **Releases** (maintainers): `npm version ` does the - whole cut — the `version` hook retitles `Unreleased` to - `## X.Y.Z — YYYY-MM-DD` (keeping a fresh `Unreleased` above it, and - refusing to release when there are no entries), then npm commits and tags. - `git push --follow-tags` triggers CI, which refuses a tag with no matching - changelog section, publishes `@animalabs/agent-framework` to npm, and - creates the GitHub release with that section as its notes. The two release - jobs are independent: some consumers run github-clone checkouts, so - release notes must exist even when npm publish fails. Version bumps are a - maintainer release-time action, not part of feature PRs. + whole cut — the `version` hook folds the pending fragments plus any + entries filed directly under `Unreleased` into `## X.Y.Z — YYYY-MM-DD` + (subsections emitted in `### Breaking` / `### Added` / `### Changed` / + `### Fixed` order), deletes the consumed fragments, keeps a fresh empty + `Unreleased` above, and refuses to release when there is nothing to + release; npm then commits and tags. `git push --follow-tags` triggers CI, + which refuses a tag with no matching changelog section, publishes + `@animalabs/agent-framework` to npm, and creates the GitHub release with + that section as its notes. The two release jobs are independent: some + consumers run github-clone checkouts, so release notes must exist even + when npm publish fails. Version bumps are a maintainer release-time + action, not part of feature PRs. ## Building and testing diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 00000000..77034789 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,22 @@ +# Pending changelog fragments + +One file per change, so concurrent branches never conflict the way shared +`CHANGELOG.md` edits do. At release time `npm version` folds every fragment +here into the new version section of `CHANGELOG.md` and deletes it. + +**Name:** `..md` — the slug just has to +be unique among pending fragments; the PR number or branch name works +(`115-tune-out.added.md`). + +**Content:** one or more markdown bullets, exactly as they should appear in +`CHANGELOG.md`: + +```markdown +- `tune_out` diverts a channel's traffic to a subconscious summarizer + instead of unsubscribing (#77). Continuation lines indent two spaces. +``` + +Breaking fragments open by naming who needs to act: +`- **Module authors:** …`. + +See [CONTRIBUTING.md](../CONTRIBUTING.md#changelog) for what needs an entry. diff --git a/changelog.d/changelog-fragments.changed.md b/changelog.d/changelog-fragments.changed.md new file mode 100644 index 00000000..9f255b2f --- /dev/null +++ b/changelog.d/changelog-fragments.changed.md @@ -0,0 +1,5 @@ +- Changelog entries now land as per-change fragment files in `changelog.d/` + (`..md`), folded into the version + section at release time — concurrent PRs no longer conflict in + `CHANGELOG.md`. Editing `## Unreleased` directly still works and is merged + at the same point. diff --git a/package.json b/package.json index 7d8cd3df..6c0114c0 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "typecheck": "tsc --noEmit", "pretest": "mkdir -p dist/test/fixtures && cp test/fixtures/*.mjs dist/test/fixtures/", "test": "node --test --test-force-exit dist/test/*.test.js", - "version": "node scripts/release-changelog.mjs && git add CHANGELOG.md", + "version": "node scripts/release-changelog.mjs && git add CHANGELOG.md changelog.d", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/scripts/release-changelog.mjs b/scripts/release-changelog.mjs index 6c299bcf..07bf77e7 100644 --- a/scripts/release-changelog.mjs +++ b/scripts/release-changelog.mjs @@ -2,26 +2,68 @@ // package.json already carries the new version, and files staged here are // included in the release commit that `npm version` then creates and tags. // -// Cuts the standing `## Unreleased` section into `## X.Y.Z — YYYY-MM-DD` and +// Folds the pending fragments in changelog.d/ (one file per change, +// `..md`) together with anything filed +// directly under the standing `## Unreleased` section into a new +// `## X.Y.Z — YYYY-MM-DD` section, deletes the consumed fragments, and // leaves a fresh empty `## Unreleased` above it. Refuses to release when -// there is nothing to release, or when the file's shape is ambiguous. -import { readFileSync, writeFileSync } from "node:fs"; +// there is nothing to release, or when the input's shape is ambiguous. +import { + existsSync, + readdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; const path = "CHANGELOG.md"; +const FRAGMENT_DIR = "changelog.d"; +// Canonical subsection order; a fragment's category must be one of these. +const CATEGORIES = ["breaking", "added", "changed", "fixed"]; +const HEADINGS = { + breaking: "Breaking", + added: "Added", + changed: "Changed", + fixed: "Fixed", +}; + const { version } = JSON.parse(readFileSync("package.json", "utf8")); const text = readFileSync(path, "utf8"); const fail = (msg) => { - console.error(`CHANGELOG.md: ${msg}`); + console.error(`release-changelog: ${msg}`); process.exit(1); }; +const fragments = []; +if (existsSync(FRAGMENT_DIR)) { + for (const name of readdirSync(FRAGMENT_DIR).sort()) { + if (name === "README.md" || !name.endsWith(".md")) continue; + const m = name.match(/\.(breaking|added|changed|fixed)\.md$/); + if (!m) { + fail( + `${FRAGMENT_DIR}/${name}: unrecognized category — name fragments ` + + `'.<${CATEGORIES.join("|")}>.md' so the entry is not silently stranded.`, + ); + } + const body = readFileSync(join(FRAGMENT_DIR, name), "utf8").trim(); + if (!body) fail(`${FRAGMENT_DIR}/${name}: empty fragment.`); + if (!/^[-*] /.test(body)) { + fail( + `${FRAGMENT_DIR}/${name}: a fragment is one or more markdown bullets ('- …').`, + ); + } + fragments.push({ name, category: m[1], body }); + } +} + // Exactly one Unreleased heading. A second one silently strands entries: // only the first is ever cut, so anything filed under a later heading is // never released and never reaches the GitHub release notes. const headings = [...text.matchAll(/^## Unreleased[ \t]*$/gm)]; if (headings.length === 0) { - fail("no '## Unreleased' section — add one before releasing."); + fail("no '## Unreleased' section in CHANGELOG.md — add one before releasing."); } if (headings.length > 1) { const lines = headings.map((m) => text.slice(0, m.index).split("\n").length); @@ -39,9 +81,57 @@ if (new RegExp(`^## ${escaped}([^0-9]|$)`, "m").test(text)) { const afterHeader = text.slice(header.index + header[0].length); const nextSection = afterHeader.search(/^## /m); -const body = nextSection === -1 ? afterHeader : afterHeader.slice(0, nextSection); -if (!/^[ \t]*[-*] /m.test(body)) { - fail(`'## Unreleased' has no entries — nothing to release as ${version}.`); +const oldBody = nextSection === -1 ? afterHeader : afterHeader.slice(0, nextSection); +const rest = nextSection === -1 ? "" : afterHeader.slice(nextSection); + +// Merge fragment bullets into the Unreleased body's subsection structure. +// Directly-filed entries are kept; a fragment joins the first subsection +// whose title starts with its category (so audience-qualified headings like +// '### Breaking (module authors only)' still attract 'breaking' fragments), +// or a new canonical subsection. Output is emitted in canonical order. +function mergeFragments(body, frags) { + const preamble = []; + const parts = []; + let current = null; + for (const line of body.split("\n")) { + const h = line.match(/^###\s+(.*)$/); + if (h) { + current = { title: h[1].trim(), lines: [] }; + parts.push(current); + } else if (current) { + current.lines.push(line); + } else { + preamble.push(line); + } + } + for (const f of frags) { + let part = parts.find((p) => p.title.toLowerCase().startsWith(f.category)); + if (!part) { + part = { title: HEADINGS[f.category], lines: [] }; + parts.push(part); + } + part.lines.push("", ...f.body.split("\n")); + } + const rank = (t) => { + const i = CATEGORIES.findIndex((c) => t.toLowerCase().startsWith(c)); + return i === -1 ? CATEGORIES.length : i; + }; + const chunks = []; + const pre = preamble.join("\n").trim(); + if (pre) chunks.push(pre); + for (const p of [...parts].sort((a, b) => rank(a.title) - rank(b.title))) { + const content = p.lines.join("\n").replace(/\n{3,}/g, "\n\n").trim(); + chunks.push(content ? `### ${p.title}\n\n${content}` : `### ${p.title}`); + } + return chunks.join("\n\n"); +} + +const merged = mergeFragments(oldBody, fragments); +if (!/^[ \t]*[-*] /m.test(merged)) { + fail( + `nothing to release as ${version} — no fragments in ${FRAGMENT_DIR}/ ` + + "and no entries under '## Unreleased'.", + ); } // Spliced by index rather than string-replaced: `text.replace("## Unreleased", …)` @@ -52,7 +142,11 @@ const date = new Date().toISOString().slice(0, 10); writeFileSync( path, text.slice(0, header.index) + - `## Unreleased\n\n## ${version} — ${date}` + - text.slice(header.index + header[0].length), + `## Unreleased\n\n## ${version} — ${date}\n\n${merged}\n\n` + + rest, +); +for (const f of fragments) unlinkSync(join(FRAGMENT_DIR, f.name)); +console.log( + `CHANGELOG.md: released '## ${version} — ${date}' from ${fragments.length} ` + + `fragment(s) plus the Unreleased section.`, ); -console.log(`CHANGELOG.md: cut Unreleased into '## ${version} — ${date}'.`); From 7fc6a1405668b701ce20183263882c08285e73d0 Mon Sep 17 00:00:00 2001 From: Anarchid Date: Mon, 24 Aug 2026 16:29:27 +0300 Subject: [PATCH 2/5] fix(changelog): validate every fragment line; count only added fragments in CI Two findings from Greptile's review of the membrane companion PR (antra-tess/membrane#49), applicable wave-wide since the code is shared: a fragment whose first line was a bullet could smuggle top-level prose or a rogue '## ' heading (a fake section boundary) into the released changelog, and the CI check's path-only grep let a deleted or renamed fragment satisfy the entry requirement. Co-Authored-By: Claude Fable 5 --- .github/workflows/changelog.yml | 5 ++++- scripts/release-changelog.mjs | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 8569b0a8..0580c09a 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -23,10 +23,13 @@ jobs: base="${{ github.event.pull_request.base.sha }}" head="${{ github.event.pull_request.head.sha }}" changed=$(git diff --name-only "$base...$head") + # Fragments must be *added* — a deleted or renamed fragment also + # appears in --name-only and must not satisfy the check. + added=$(git diff --name-only --diff-filter=A "$base...$head") echo "Changed files:" echo "$changed" if echo "$changed" | grep -q '^src/' \ - && ! echo "$changed" | grep -Eq '^changelog\.d/[^/]+\.(breaking|added|changed|fixed)\.md$' \ + && ! echo "$added" | grep -Eq '^changelog\.d/[^/]+\.(breaking|added|changed|fixed)\.md$' \ && ! echo "$changed" | grep -qx 'CHANGELOG.md'; then echo "::error::This PR touches src/ but carries no changelog entry. Add a fragment changelog.d/..md (see CONTRIBUTING.md), or apply the 'no-changelog' label if the change is internal-only." exit 1 diff --git a/scripts/release-changelog.mjs b/scripts/release-changelog.mjs index 07bf77e7..bc1c8be2 100644 --- a/scripts/release-changelog.mjs +++ b/scripts/release-changelog.mjs @@ -49,9 +49,20 @@ if (existsSync(FRAGMENT_DIR)) { } const body = readFileSync(join(FRAGMENT_DIR, name), "utf8").trim(); if (!body) fail(`${FRAGMENT_DIR}/${name}: empty fragment.`); - if (!/^[-*] /.test(body)) { + // Every line must start a bullet or continue one (indented). A line of + // top-level prose — or worse, a '## ' heading — would splice a fake + // section boundary into the released changelog. + const badLine = body + .split("\n") + .find((l) => l.trim() !== "" && !/^[-*] /.test(l) && !/^\s/.test(l)); + if (!/^[-*] /.test(body) || badLine !== undefined) { fail( - `${FRAGMENT_DIR}/${name}: a fragment is one or more markdown bullets ('- …').`, + `${FRAGMENT_DIR}/${name}: a fragment is one or more markdown bullets ` + + `('- …', continuation lines indented)` + + (badLine !== undefined && /^[-*] /.test(body) + ? ` — offending line: '${badLine}'` + : "") + + ".", ); } fragments.push({ name, category: m[1], body }); From d94b4d18cdf74ed90b9e42628383e38e9e380275 Mon Sep 17 00:00:00 2001 From: Anarchid Date: Mon, 24 Aug 2026 16:58:12 +0300 Subject: [PATCH 3/5] fix(changelog): fail-closed fragment scan, strict fragment grammar, regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex QA pass on the membrane companion (antra-tess/membrane#49) found that a slug taken verbatim from a 'fix/foo' branch name — which the docs invited — created changelog.d/fix/foo.fixed.md, and the scanner skipped the 'fix' directory without a word: a silent release-note data-loss path that PR CI catches but direct pushes bypass. The scanner now aborts on any entry that is not README.md or a well-formed fragment file, and the docs prescribe filesystem-safe slugs ('/' -> '-'). The continuation-line check also treated any whitespace-prefixed line as valid, so an indented '## ' heading still passed. Continuations must now be indented two or more spaces, and headings/rules are refused at any indentation. test/release-changelog.test.ts runs the real script in a throwaway directory: fragment-only and mixed assembly, canonical ordering, audience-qualified headings, deletion + README preservation, and fourteen refusal cases asserting nothing is modified on failure. Co-Authored-By: Claude Fable 5 --- CONTRIBUTING.md | 18 ++-- changelog.d/README.md | 13 ++- scripts/release-changelog.mjs | 51 ++++++--- test/release-changelog.test.ts | 189 +++++++++++++++++++++++++++++++++ 4 files changed, 247 insertions(+), 24 deletions(-) create mode 100644 test/release-changelog.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be714a1c..a5c87d8f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,12 +93,18 @@ conflicting: when every PR edited the same `## Unreleased` section, any PR that outlived another merge hit a conflict in `CHANGELOG.md`; distinct files never do. -- **Format:** `changelog.d/..md`, - containing one or more markdown bullets (`- …`), written exactly as they - should appear in `CHANGELOG.md` (continuation lines indent two spaces). - The slug just has to be unique among pending fragments — the PR number or - branch name works (`115-tune-out.added.md`). The release script refuses - unrecognized category suffixes rather than silently stranding an entry. +- **Format:** `changelog.d/..md`, a flat + file directly in `changelog.d/`, containing one or more markdown bullets + (`- …`) written exactly as they should appear in `CHANGELOG.md`: + continuation lines indent two spaces, nested bullets are fine, headings + and horizontal rules are refused (even indented — a heading inside a + fragment would corrupt the section structure). The slug just has to be + unique among pending fragments and filesystem-safe — the PR number works, + and so does the branch name with `/` replaced by `-` + (`115-tune-out.added.md`, `fix-locus-routing.fixed.md`). The release + script scans the directory fail-closed: a subdirectory, an unrecognized + category suffix, or any other stray file aborts the release rather than + silently stranding an entry. - **The fragment lands with the change** — same commit, or at least the same PR. This binds direct pushes to `main` just as much as PRs. On PRs, CI enforces it softly: touching `src/` without adding a fragment (or editing diff --git a/changelog.d/README.md b/changelog.d/README.md index 77034789..5813df3d 100644 --- a/changelog.d/README.md +++ b/changelog.d/README.md @@ -4,12 +4,17 @@ One file per change, so concurrent branches never conflict the way shared `CHANGELOG.md` edits do. At release time `npm version` folds every fragment here into the new version section of `CHANGELOG.md` and deletes it. -**Name:** `..md` — the slug just has to -be unique among pending fragments; the PR number or branch name works -(`115-tune-out.added.md`). +**Name:** `..md`, as a flat file directly +in this directory. The slug just has to be unique among pending fragments and +filesystem-safe: the PR number works, and so does the branch name with `/` +replaced by `-` (`115-tune-out.added.md`, `fix-locus-routing.fixed.md`). The +release script refuses subdirectories and any other stray file here, so a +misplaced entry fails the release loudly instead of being left out. **Content:** one or more markdown bullets, exactly as they should appear in -`CHANGELOG.md`: +`CHANGELOG.md`. Continuation lines indent two spaces (nested bullets are +fine); headings and horizontal rules are refused even when indented, since a +heading inside a fragment would corrupt the section structure: ```markdown - `tune_out` diverts a channel's traffic to a subconscious summarizer diff --git a/scripts/release-changelog.mjs b/scripts/release-changelog.mjs index bc1c8be2..afe95f67 100644 --- a/scripts/release-changelog.mjs +++ b/scripts/release-changelog.mjs @@ -36,33 +36,56 @@ const fail = (msg) => { process.exit(1); }; +// Fragment grammar: every non-blank line starts a bullet or continues one +// (indented two or more spaces; nested bullets included). Headings and +// horizontal rules are refused even when indented — a '## ' line would +// splice a fake section boundary into the released changelog, and an +// indented one still renders as a heading inside the entry. +const isBulletStart = (l) => /^[-*] /.test(l); +const isContinuation = (l) => /^ {2,}\S/.test(l); +const isBlockConstruct = (l) => + /^\s*#{1,6}(\s|$)/.test(l) || /^\s*([-=*_])\1{2,}\s*$/.test(l); + +// The directory is scanned fail-closed: anything that is not README.md or +// a well-formed fragment file aborts the release, so an entry can never be +// silently left out (e.g. a fragment created under a 'fix/' subdirectory +// because the slug was taken verbatim from a branch name). const fragments = []; if (existsSync(FRAGMENT_DIR)) { - for (const name of readdirSync(FRAGMENT_DIR).sort()) { - if (name === "README.md" || !name.endsWith(".md")) continue; + const entries = readdirSync(FRAGMENT_DIR, { withFileTypes: true }).sort( + (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0), + ); + for (const entry of entries) { + const { name } = entry; + if (name === "README.md") continue; + if (!entry.isFile()) { + fail( + `${FRAGMENT_DIR}/${name}: not a file. Fragments are flat files directly ` + + `in ${FRAGMENT_DIR}/ — a slug cannot contain '/'; use the PR number, ` + + "or the branch name with '/' replaced by '-'.", + ); + } const m = name.match(/\.(breaking|added|changed|fixed)\.md$/); if (!m) { fail( - `${FRAGMENT_DIR}/${name}: unrecognized category — name fragments ` + + `${FRAGMENT_DIR}/${name}: unrecognized file — name fragments ` + `'.<${CATEGORIES.join("|")}>.md' so the entry is not silently stranded.`, ); } const body = readFileSync(join(FRAGMENT_DIR, name), "utf8").trim(); if (!body) fail(`${FRAGMENT_DIR}/${name}: empty fragment.`); - // Every line must start a bullet or continue one (indented). A line of - // top-level prose — or worse, a '## ' heading — would splice a fake - // section boundary into the released changelog. - const badLine = body + const offending = body .split("\n") - .find((l) => l.trim() !== "" && !/^[-*] /.test(l) && !/^\s/.test(l)); - if (!/^[-*] /.test(body) || badLine !== undefined) { + .find( + (l) => + l.trim() !== "" && + (!(isBulletStart(l) || isContinuation(l)) || isBlockConstruct(l)), + ); + if (offending !== undefined) { fail( `${FRAGMENT_DIR}/${name}: a fragment is one or more markdown bullets ` + - `('- …', continuation lines indented)` + - (badLine !== undefined && /^[-*] /.test(body) - ? ` — offending line: '${badLine}'` - : "") + - ".", + "('- …'; continuation lines indented two spaces; no headings or " + + `rules) — offending line: '${offending}'.`, ); } fragments.push({ name, category: m[1], body }); diff --git a/test/release-changelog.test.ts b/test/release-changelog.test.ts new file mode 100644 index 00000000..2b66e458 --- /dev/null +++ b/test/release-changelog.test.ts @@ -0,0 +1,189 @@ +// Black-box coverage of scripts/release-changelog.mjs: each case runs the +// real script in a throwaway directory, so assembly, validation, and the +// deletion of consumed fragments are all exercised exactly as `npm version` +// would run them. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT = fileURLToPath( + new URL("../../scripts/release-changelog.mjs", import.meta.url), +); + +const BASE_CHANGELOG = [ + "# Changelog", + "", + "Intro that mentions ## Unreleased inline, which must not count as a heading.", + "", + "## Unreleased", + "", + "## 1.0.0 — 2026-01-01", + "", + "### Fixed", + "", + "- Old entry.", + "", +].join("\n"); + +interface Fixture { + version?: string; + changelog?: string; + fragments?: Record; +} + +function setup(f: Fixture): string { + const dir = mkdtempSync(join(tmpdir(), "release-changelog-")); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "fixture", version: f.version ?? "1.1.0" }), + ); + writeFileSync(join(dir, "CHANGELOG.md"), f.changelog ?? BASE_CHANGELOG); + mkdirSync(join(dir, "changelog.d")); + writeFileSync(join(dir, "changelog.d", "README.md"), "# Pending fragments\n"); + for (const [name, body] of Object.entries(f.fragments ?? {})) { + const path = join(dir, "changelog.d", name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, body); + } + return dir; +} + +function run(dir: string): { status: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync(process.execPath, [SCRIPT], { + cwd: dir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { status: 0, stdout, stderr: "" }; + } catch (e) { + const err = e as { status: number; stdout?: string; stderr?: string }; + return { status: err.status, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } +} + +function section(text: string, version: string): string { + const lines = text.split("\n"); + const start = lines.findIndex((l) => l.startsWith(`## ${version} — `)); + assert.notEqual(start, -1, `no '## ${version}' section in:\n${text}`); + const rest = lines.slice(start + 1); + const end = rest.findIndex((l) => l.startsWith("## ")); + return rest.slice(0, end === -1 ? undefined : end).join("\n").trim(); +} + +const changelog = (dir: string) => readFileSync(join(dir, "CHANGELOG.md"), "utf8"); +const pending = (dir: string) => readdirSync(join(dir, "changelog.d")).sort(); + +test("folds fragments into a versioned section in canonical order and deletes them", () => { + const dir = setup({ + fragments: { + "z-later.fixed.md": "- Fixed thing.\n", + "a-first.added.md": "- Added thing,\n continued on an indented line.\n", + "m.breaking.md": "- **Module authors:** breaking thing.\n", + }, + }); + const r = run(dir); + assert.equal(r.status, 0, r.stderr); + const text = changelog(dir); + assert.equal( + section(text, "1.1.0"), + [ + "### Breaking", + "", + "- **Module authors:** breaking thing.", + "", + "### Added", + "", + "- Added thing,", + " continued on an indented line.", + "", + "### Fixed", + "", + "- Fixed thing.", + ].join("\n"), + ); + assert.match(text, /^## Unreleased\n\n## 1\.1\.0 — \d{4}-\d{2}-\d{2}\n/m, "fresh empty Unreleased above the cut"); + assert.ok(text.startsWith("# Changelog\n\nIntro that mentions"), "file header preserved"); + assert.equal(section(text, "1.0.0"), "### Fixed\n\n- Old entry.", "older section untouched"); + assert.deepEqual(pending(dir), ["README.md"], "consumed fragments deleted, README kept"); +}); + +test("merges fragments into directly-filed Unreleased entries, reordering subsections canonically", () => { + const dir = setup({ + changelog: BASE_CHANGELOG.replace( + "## Unreleased\n", + "## Unreleased\n\n### Fixed\n\n- Manual fix.\n\n### Added\n\n- Manual add.\n", + ), + fragments: { "x.fixed.md": "- Fragment fix.\n" }, + }); + const r = run(dir); + assert.equal(r.status, 0, r.stderr); + assert.equal( + section(changelog(dir), "1.1.0"), + "### Added\n\n- Manual add.\n\n### Fixed\n\n- Manual fix.\n\n- Fragment fix.", + ); +}); + +test("breaking fragments join an audience-qualified Breaking heading", () => { + const dir = setup({ + changelog: BASE_CHANGELOG.replace( + "## Unreleased\n", + "## Unreleased\n\n### Fixed\n\n- Manual fix.\n\n### Breaking (module authors only)\n\n- Manual break.\n", + ), + fragments: { "b.breaking.md": "- Fragment break.\n" }, + }); + const r = run(dir); + assert.equal(r.status, 0, r.stderr); + assert.equal( + section(changelog(dir), "1.1.0"), + "### Breaking (module authors only)\n\n- Manual break.\n\n- Fragment break.\n\n### Fixed\n\n- Manual fix.", + ); +}); + +test("accepts nested bullets and multi-line continuations", () => { + const dir = setup({ + fragments: { "n.fixed.md": "- one\n - nested\n more text\n- two\n" }, + }); + const r = run(dir); + assert.equal(r.status, 0, r.stderr); + assert.equal(section(changelog(dir), "1.1.0"), "### Fixed\n\n- one\n - nested\n more text\n- two"); +}); + +const refusals: Array<[string, Fixture, RegExp]> = [ + ["nothing to release", {}, /nothing to release as 1\.1\.0/], + ["duplicate version section", { version: "1.0.0", fragments: { "a.fixed.md": "- x\n" } }, /'## 1\.0\.0' section already exists/], + ["unrecognized category suffix", { fragments: { "oops.md": "- x\n" } }, /changelog\.d\/oops\.md: unrecognized file/], + ["stray non-markdown file", { fragments: { "notes.txt": "hi\n", "a.fixed.md": "- x\n" } }, /changelog\.d\/notes\.txt: unrecognized file/], + ["empty fragment", { fragments: { "e.fixed.md": "\n" } }, /changelog\.d\/e\.fixed\.md: empty fragment/], + ["fragment nested under a branch-name directory", { fragments: { "fix/foo.fixed.md": "- x\n", "a.fixed.md": "- y\n" } }, /changelog\.d\/fix: not a file/], + ["plain prose", { fragments: { "p.fixed.md": "prose only\n" } }, /offending line: 'prose only'/], + ["prose after a valid bullet", { fragments: { "p.fixed.md": "- ok\nrogue prose\n" } }, /offending line: 'rogue prose'/], + ["top-level heading after a bullet", { fragments: { "h.fixed.md": "- ok\n\n## 9.9.9 — fake\n" } }, /offending line: '## 9\.9\.9 — fake'/], + ["indented ATX heading", { fragments: { "h.fixed.md": "- ok\n ## 8.8.8 — injected\n - beneath\n" } }, /offending line: ' ## 8\.8\.8 — injected'/], + ["indented setext underline / rule", { fragments: { "s.fixed.md": "- ok\n ---\n" } }, /offending line: ' ---'/], + ["tab-indented continuation", { fragments: { "t.fixed.md": "- ok\n\tcontinued\n" } }, /offending line: '\tcontinued'/], + ["no Unreleased heading", { changelog: "# Changelog\n\n## 1.0.0 — 2026-01-01\n\n- x\n", fragments: { "a.fixed.md": "- x\n" } }, /no '## Unreleased' section/], + ["two Unreleased headings", { changelog: BASE_CHANGELOG + "\n## Unreleased\n\n- stranded\n", fragments: { "a.fixed.md": "- x\n" } }, /2 '## Unreleased' headings/], +]; + +for (const [name, fixture, message] of refusals) { + test(`refuses ${name} without touching anything`, () => { + const dir = setup(fixture); + const before = { changelog: changelog(dir), pending: pending(dir) }; + const r = run(dir); + assert.equal(r.status, 1, `expected refusal, got exit ${r.status}:\n${r.stdout}${r.stderr}`); + assert.match(r.stderr, message); + assert.equal(changelog(dir), before.changelog, "CHANGELOG.md must be untouched"); + assert.deepEqual(pending(dir), before.pending, "no fragment may be deleted on refusal"); + }); +} From c2fa58aaefea44dc070668fd932f4dfb1e89ccbe Mon Sep 17 00:00:00 2001 From: Anarchid Date: Mon, 24 Aug 2026 17:15:17 +0300 Subject: [PATCH 4/5] fix(changelog): report refusals via exitCode; refuse in-item headings and spaced rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second Codex QA round on the membrane companion (antra-tess/membrane#49, head f72e00a): - Refusals used console.error + process.exit(1). Where stderr is a pipe (asynchronous on macOS), the exit can pre-empt the write, so callers — including the new tests — saw exit 1 with empty stderr. Refusals now throw a ReleaseError caught at the entry point, which prints and sets process.exitCode = 1 so the process drains stdio on its own. - The block-construct check missed thematic breaks with interior whitespace ('- - -') and headings used as item content ('- ## x'). Both are now refused; content after the bullet marker is inspected. Six regression cases added, plus one guarding against over-refusal of bullets that merely resemble rules or headings. - CI runs the suite on both OSes and on Node 20 and 24 (was: ubuntu only, Node 20), so platform-specific stdio behaviour is actually exercised. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 10 ++- scripts/release-changelog.mjs | 140 +++++++++++++++++++-------------- test/release-changelog.test.ts | 13 +++ 3 files changed, 100 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0709571e..f2864203 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: jobs: build-and-test: - name: Build & Test (${{ matrix.os }}) + name: Build & Test (${{ matrix.os }}, node ${{ matrix.node }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -19,8 +19,11 @@ jobs: # node_modules tree records only that machine's native binaries, so a # lock made on macOS breaks Linux installs and vice versa. Each runner # catches the lock the other's platform produced. Tests run on ubuntu. + # Node: the engines floor and current LTS — stdio flushing semantics + # around process exit differ between versions and platforms. matrix: os: [ubuntu-latest, macos-latest] + node: [20, 24] steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -30,7 +33,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: - node-version: 20 + node-version: ${{ matrix.node }} # Root lockfiles are deliberately gitignored in this repo, so plain # npm install (matching publish.yml). No lock to guard here. @@ -43,6 +46,7 @@ jobs: - name: Build run: npm run build + # Tests run on both platforms: process/stdio behaviour differs (pipes + # are asynchronous on macOS), and ubuntu-only runs have missed that. - name: Test - if: matrix.os == 'ubuntu-latest' run: npm test diff --git a/scripts/release-changelog.mjs b/scripts/release-changelog.mjs index afe95f67..a8a855de 100644 --- a/scripts/release-changelog.mjs +++ b/scripts/release-changelog.mjs @@ -17,7 +17,7 @@ import { } from "node:fs"; import { join } from "node:path"; -const path = "CHANGELOG.md"; +const CHANGELOG = "CHANGELOG.md"; const FRAGMENT_DIR = "changelog.d"; // Canonical subsection order; a fragment's category must be one of these. const CATEGORIES = ["breaking", "added", "changed", "fixed"]; @@ -28,30 +28,35 @@ const HEADINGS = { fixed: "Fixed", }; -const { version } = JSON.parse(readFileSync("package.json", "utf8")); -const text = readFileSync(path, "utf8"); - +// Refusals throw and are reported at the entry point, which then lets the +// process end on its own with exitCode 1. process.exit() would race the +// stderr write when stderr is a pipe (asynchronous on macOS), leaving the +// caller a bare failure status with no reason attached. +class ReleaseError extends Error {} const fail = (msg) => { - console.error(`release-changelog: ${msg}`); - process.exit(1); + throw new ReleaseError(msg); }; // Fragment grammar: every non-blank line starts a bullet or continues one // (indented two or more spaces; nested bullets included). Headings and -// horizontal rules are refused even when indented — a '## ' line would -// splice a fake section boundary into the released changelog, and an -// indented one still renders as a heading inside the entry. +// thematic breaks are refused wherever they appear — at top level a '## ' +// line would splice a fake section boundary into the released changelog, +// and as item content ('- ## x', ' ## x') they still render as headings. const isBulletStart = (l) => /^[-*] /.test(l); const isContinuation = (l) => /^ {2,}\S/.test(l); -const isBlockConstruct = (l) => - /^\s*#{1,6}(\s|$)/.test(l) || /^\s*([-=*_])\1{2,}\s*$/.test(l); +const itemContent = (l) => l.replace(/^\s*(?:[-*]\s+)?/, ""); +const isHeading = (s) => /^#{1,6}(\s|$)/.test(s); +// Thematic breaks may carry interior whitespace ('- - -', '* * *'). +const isRule = (s) => /^([-*_=])(\s*\1){2,}\s*$/.test(s); +const isBlockConstruct = (l) => isRule(l.trim()) || isHeading(itemContent(l)); // The directory is scanned fail-closed: anything that is not README.md or // a well-formed fragment file aborts the release, so an entry can never be // silently left out (e.g. a fragment created under a 'fix/' subdirectory // because the slug was taken verbatim from a branch name). -const fragments = []; -if (existsSync(FRAGMENT_DIR)) { +function collectFragments() { + const fragments = []; + if (!existsSync(FRAGMENT_DIR)) return fragments; const entries = readdirSync(FRAGMENT_DIR, { withFileTypes: true }).sort( (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0), ); @@ -90,34 +95,9 @@ if (existsSync(FRAGMENT_DIR)) { } fragments.push({ name, category: m[1], body }); } + return fragments; } -// Exactly one Unreleased heading. A second one silently strands entries: -// only the first is ever cut, so anything filed under a later heading is -// never released and never reaches the GitHub release notes. -const headings = [...text.matchAll(/^## Unreleased[ \t]*$/gm)]; -if (headings.length === 0) { - fail("no '## Unreleased' section in CHANGELOG.md — add one before releasing."); -} -if (headings.length > 1) { - const lines = headings.map((m) => text.slice(0, m.index).split("\n").length); - fail( - `${headings.length} '## Unreleased' headings (lines ${lines.join(", ")}). ` + - "Only the first is released; fold them into one before releasing.", - ); -} -const [header] = headings; - -const escaped = version.replace(/[.]/g, "\\."); -if (new RegExp(`^## ${escaped}([^0-9]|$)`, "m").test(text)) { - fail(`a '## ${version}' section already exists.`); -} - -const afterHeader = text.slice(header.index + header[0].length); -const nextSection = afterHeader.search(/^## /m); -const oldBody = nextSection === -1 ? afterHeader : afterHeader.slice(0, nextSection); -const rest = nextSection === -1 ? "" : afterHeader.slice(nextSection); - // Merge fragment bullets into the Unreleased body's subsection structure. // Directly-filed entries are kept; a fragment joins the first subsection // whose title starts with its category (so audience-qualified headings like @@ -160,27 +140,67 @@ function mergeFragments(body, frags) { return chunks.join("\n\n"); } -const merged = mergeFragments(oldBody, fragments); -if (!/^[ \t]*[-*] /m.test(merged)) { - fail( - `nothing to release as ${version} — no fragments in ${FRAGMENT_DIR}/ ` + - "and no entries under '## Unreleased'.", +function main() { + const { version } = JSON.parse(readFileSync("package.json", "utf8")); + const text = readFileSync(CHANGELOG, "utf8"); + const fragments = collectFragments(); + + // Exactly one Unreleased heading. A second one silently strands entries: + // only the first is ever cut, so anything filed under a later heading is + // never released and never reaches the GitHub release notes. + const headings = [...text.matchAll(/^## Unreleased[ \t]*$/gm)]; + if (headings.length === 0) { + fail(`no '## Unreleased' section in ${CHANGELOG} — add one before releasing.`); + } + if (headings.length > 1) { + const lines = headings.map((m) => text.slice(0, m.index).split("\n").length); + fail( + `${headings.length} '## Unreleased' headings (lines ${lines.join(", ")}). ` + + "Only the first is released; fold them into one before releasing.", + ); + } + const [header] = headings; + + const escaped = version.replace(/[.]/g, "\\."); + if (new RegExp(`^## ${escaped}([^0-9]|$)`, "m").test(text)) { + fail(`a '## ${version}' section already exists.`); + } + + const afterHeader = text.slice(header.index + header[0].length); + const nextSection = afterHeader.search(/^## /m); + const oldBody = nextSection === -1 ? afterHeader : afterHeader.slice(0, nextSection); + const rest = nextSection === -1 ? "" : afterHeader.slice(nextSection); + + const merged = mergeFragments(oldBody, fragments); + if (!/^[ \t]*[-*] /m.test(merged)) { + fail( + `nothing to release as ${version} — no fragments in ${FRAGMENT_DIR}/ ` + + "and no entries under '## Unreleased'.", + ); + } + + // Spliced by index rather than string-replaced: `text.replace("## Unreleased", …)` + // would hit the first *substring* occurrence, which is not necessarily the + // heading the regex matched (an inline mention of `## Unreleased` in prose + // comes first) and would inject the version heading into the wrong place. + const date = new Date().toISOString().slice(0, 10); + writeFileSync( + CHANGELOG, + text.slice(0, header.index) + + `## Unreleased\n\n## ${version} — ${date}\n\n${merged}\n\n` + + rest, + ); + for (const f of fragments) unlinkSync(join(FRAGMENT_DIR, f.name)); + console.log( + `${CHANGELOG}: released '## ${version} — ${date}' from ${fragments.length} ` + + "fragment(s) plus the Unreleased section.", ); } -// Spliced by index rather than string-replaced: `text.replace("## Unreleased", …)` -// would hit the first *substring* occurrence, which is not necessarily the -// heading the regex matched (an inline mention of `## Unreleased` in prose -// comes first) and would inject the version heading into the wrong place. -const date = new Date().toISOString().slice(0, 10); -writeFileSync( - path, - text.slice(0, header.index) + - `## Unreleased\n\n## ${version} — ${date}\n\n${merged}\n\n` + - rest, -); -for (const f of fragments) unlinkSync(join(FRAGMENT_DIR, f.name)); -console.log( - `CHANGELOG.md: released '## ${version} — ${date}' from ${fragments.length} ` + - `fragment(s) plus the Unreleased section.`, -); +try { + main(); +} catch (e) { + if (!(e instanceof ReleaseError)) throw e; + console.error(`release-changelog: ${e.message}`); + process.exitCode = 1; +} diff --git a/test/release-changelog.test.ts b/test/release-changelog.test.ts index 2b66e458..2146fbb2 100644 --- a/test/release-changelog.test.ts +++ b/test/release-changelog.test.ts @@ -159,6 +159,14 @@ test("accepts nested bullets and multi-line continuations", () => { assert.equal(section(changelog(dir), "1.1.0"), "### Fixed\n\n- one\n - nested\n more text\n- two"); }); +test("accepts bullet content that merely resembles headings or rules", () => { + const body = "- **Module authors:** bold opener.\n- -1 is now the sentinel.\n- #123 is referenced inline.\n- ***emphasis*** then text.\n"; + const dir = setup({ fragments: { "r.fixed.md": body } }); + const r = run(dir); + assert.equal(r.status, 0, r.stderr); + assert.equal(section(changelog(dir), "1.1.0"), `### Fixed\n\n${body.trim()}`); +}); + const refusals: Array<[string, Fixture, RegExp]> = [ ["nothing to release", {}, /nothing to release as 1\.1\.0/], ["duplicate version section", { version: "1.0.0", fragments: { "a.fixed.md": "- x\n" } }, /'## 1\.0\.0' section already exists/], @@ -171,6 +179,11 @@ const refusals: Array<[string, Fixture, RegExp]> = [ ["top-level heading after a bullet", { fragments: { "h.fixed.md": "- ok\n\n## 9.9.9 — fake\n" } }, /offending line: '## 9\.9\.9 — fake'/], ["indented ATX heading", { fragments: { "h.fixed.md": "- ok\n ## 8.8.8 — injected\n - beneath\n" } }, /offending line: ' ## 8\.8\.8 — injected'/], ["indented setext underline / rule", { fragments: { "s.fixed.md": "- ok\n ---\n" } }, /offending line: ' ---'/], + ["spaced thematic break shaped like a bullet", { fragments: { "s.fixed.md": "- ok\n- - -\n" } }, /offending line: '- - -'/], + ["asterisk thematic break with spaces", { fragments: { "s.fixed.md": "- ok\n* * *\n" } }, /offending line: '\* \* \*'/], + ["rule as bullet content", { fragments: { "s.fixed.md": "- ---\n" } }, /offending line: '- ---'/], + ["heading as bullet content", { fragments: { "h.fixed.md": "- ## 9.9.9 — embedded heading\n" } }, /offending line: '- ## 9\.9\.9 — embedded heading'/], + ["heading as nested bullet content", { fragments: { "h.fixed.md": "- ok\n - ### nested heading\n" } }, /offending line: ' - ### nested heading'/], ["tab-indented continuation", { fragments: { "t.fixed.md": "- ok\n\tcontinued\n" } }, /offending line: '\tcontinued'/], ["no Unreleased heading", { changelog: "# Changelog\n\n## 1.0.0 — 2026-01-01\n\n- x\n", fragments: { "a.fixed.md": "- x\n" } }, /no '## Unreleased' section/], ["two Unreleased headings", { changelog: BASE_CHANGELOG + "\n## Unreleased\n\n- stranded\n", fragments: { "a.fixed.md": "- x\n" } }, /2 '## Unreleased' headings/], From c754ec0be50c08ff0d952a50b0dc9973c13a47cb Mon Sep 17 00:00:00 2001 From: Anarchid Date: Mon, 24 Aug 2026 17:22:54 +0300 Subject: [PATCH 5/5] test(workspace): keep MountWatcher re-attach cases Linux-only (#130) Widening CI to macOS ran these for the first time there: three root-liveness re-attach cases fail on macOS with Node 24 (and pass with Node 20, and on Linux). They were written around Linux inode-reuse semantics; the file already keeps a sibling case Linux-only for a macOS watcher reason. Skipped on non-Linux with the same pattern; the underlying behaviour difference is tracked in #130. Co-Authored-By: Claude Fable 5 --- test/workspace-watcher.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/test/workspace-watcher.test.ts b/test/workspace-watcher.test.ts index ee8e71bd..fa831278 100644 --- a/test/workspace-watcher.test.ts +++ b/test/workspace-watcher.test.ts @@ -203,7 +203,13 @@ describe('MountWatcher lifecycle', () => { await watcher.stop(); }); - it('read-write mount recreates a deleted root and re-attaches', async () => { + // Root-liveness re-attach cases are authoritative on Linux (written around + // inode-reuse semantics there). Under Node 24 on macOS the native watcher + // delivers a different event stream — root not recreated, duplicate + // reattach, read-only root re-attached — tracked in #130. + it('read-write mount recreates a deleted root and re-attaches', { + skip: process.platform !== 'linux', + }, async () => { let readyCount = 0; let reattachCount = 0; const { watcher } = collectWithNative({ @@ -258,7 +264,10 @@ describe('MountWatcher lifecycle', () => { } }); - it('fires onReattach exactly once for one remove and recreate cycle', async () => { + // Linux-only for the same reason as above (#130). + it('fires onReattach exactly once for one remove and recreate cycle', { + skip: process.platform !== 'linux', + }, async () => { let readyCount = 0; let reattachCount = 0; const { watcher } = collectWithNative({ @@ -307,7 +316,10 @@ describe('MountWatcher lifecycle', () => { } }); - it('keeps a deleted read-only root detached and stops cleanly', async () => { + // Linux-only for the same reason as above (#130). + it('keeps a deleted read-only root detached and stops cleanly', { + skip: process.platform !== 'linux', + }, async () => { let ready = false; let reattachCount = 0; const watcher = new MountWatcher(