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..0580c09a 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -18,15 +18,20 @@ 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") + # 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 -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 "$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 fi echo "OK" 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/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..a5c87d8f 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,61 @@ 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`, 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 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..5813df3d --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,27 @@ +# 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`, 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`. 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 + 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..a8a855de 100644 --- a/scripts/release-changelog.mjs +++ b/scripts/release-changelog.mjs @@ -2,57 +2,205 @@ // 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 { version } = JSON.parse(readFileSync("package.json", "utf8")); -const text = readFileSync(path, "utf8"); +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"]; +const HEADINGS = { + breaking: "Breaking", + added: "Added", + changed: "Changed", + fixed: "Fixed", +}; +// 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(`CHANGELOG.md: ${msg}`); - process.exit(1); + throw new ReleaseError(msg); }; -// 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."); -} -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.", +// Fragment grammar: every non-blank line starts a bullet or continues one +// (indented two or more spaces; nested bullets included). Headings and +// 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 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). +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), ); + 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 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.`); + const offending = body + .split("\n") + .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 two spaces; no headings or " + + `rules) — offending line: '${offending}'.`, + ); + } + fragments.push({ name, category: m[1], body }); + } + return fragments; } -const [header] = headings; -const escaped = version.replace(/[.]/g, "\\."); -if (new RegExp(`^## ${escaped}([^0-9]|$)`, "m").test(text)) { - fail(`a '## ${version}' section already exists.`); +// 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 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}.`); +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}` + - text.slice(header.index + header[0].length), -); -console.log(`CHANGELOG.md: cut Unreleased into '## ${version} — ${date}'.`); +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 new file mode 100644 index 00000000..2146fbb2 --- /dev/null +++ b/test/release-changelog.test.ts @@ -0,0 +1,202 @@ +// 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"); +}); + +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/], + ["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: ' ---'/], + ["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/], +]; + +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"); + }); +} 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(