diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c3b4acb02..389b79d534 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -341,7 +341,7 @@ Write unit tests in `tests/unit/` covering at minimum: - [ ] Error responses route through `buildErrorBody()` / `sanitizeErrorMessage()` — no raw stack traces in response bodies (see [`docs/security/ERROR_SANITIZATION.md`](./docs/security/ERROR_SANITIZATION.md)) - [ ] Shell commands (`exec` / `spawn`) pass runtime values via `env`, not via string interpolation - [ ] All inputs validated with Zod schemas -- [ ] CHANGELOG updated (if user-facing change) +- [ ] Changelog **fragment** added under `changelog.d/{features|fixes|maintenance}/-.md` for user-facing changes (see [`changelog.d/README.md`](./changelog.d/README.md)) — do **not** edit `CHANGELOG.md` directly; fragments are aggregated at release time and never conflict between PRs - [ ] Documentation updated (if applicable) - [ ] No new CodeQL / Secret-Scanning alerts opened, or each one dismissed with technical justification referencing the relevant `docs/security/` doc - [ ] Routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) classified as `isLocalOnlyPath()` in `src/server/authz/routeGuard.ts` — see [Hard Rule #15](docs/security/ROUTE_GUARD_TIERS.md) diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000000..1e383a0a1d --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,42 @@ +# changelog.d/ — changelog fragments + +**A PR never edits `CHANGELOG.md` directly during the cycle.** Instead it adds ONE new +file here — its changelog entry as a *fragment*. Two PRs never touch the same file, so +changelog merge conflicts (the "CHANGELOG-eat" cascade that forced a re-sync push + full +CI re-run after every sibling merge) are structurally impossible. + +## Convention + +| Directory | Aggregates under | +| -------------- | ----------------------- | +| `features/` | `### ✨ New Features` | +| `fixes/` | `### 🐛 Bug Fixes` | +| `maintenance/` | `### 📝 Maintenance` | + +- **Filename**: `-.md` (e.g. `fixes/6700-dockerfile-better-sqlite3.md`). + The PR number prefix keeps aggregation order deterministic. +- **Content**: the exact bullet line(s) that should land in `CHANGELOG.md`, starting with + `- `. Multi-line (continuation) bullets are fine. Keep the repo's credit format: + `(#PR — thanks @user)`. +- One fragment per PR (rarely more, e.g. a PR that both fixes and adds). + +## Example + +`changelog.d/fixes/6496-cloudflare-relay-worker-syntax.md`: + +```markdown +- **fix(providers):** Cloudflare relay Worker deploys use Service Worker syntax with `body_part` metadata ([#6496](https://github.com/diegosouzapw/OmniRoute/pull/6496)) — thanks @SeaXen +``` + +## Aggregation + +The release captain (or `/generate-release`) folds all fragments into `CHANGELOG.md` and +deletes them: + +```bash +node scripts/release/aggregate-changelog.mjs # write + delete fragments +node scripts/release/aggregate-changelog.mjs --dry-run # preview only +``` + +Fragment well-formedness is enforced by `npm run check:changelog-integrity` (the same +gate that guards against CHANGELOG-eat for legacy direct edits). diff --git a/changelog.d/features/.gitkeep b/changelog.d/features/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/changelog.d/features/6783-changelog-fragments.md b/changelog.d/features/6783-changelog-fragments.md new file mode 100644 index 0000000000..0980c2967a --- /dev/null +++ b/changelog.d/features/6783-changelog-fragments.md @@ -0,0 +1 @@ +- **Changelog fragments (`changelog.d/`)**: PRs now add their changelog entry as a new fragment file (`changelog.d/{features|fixes|maintenance}/-.md`) instead of editing `CHANGELOG.md` — two PRs never touch the same file, structurally eliminating the CHANGELOG-eat merge conflicts that forced a re-sync push + full CI re-run after every sibling merge (O(N²) CI runs in a merge-storm). `scripts/release/aggregate-changelog.mjs` (npm run changelog:aggregate) folds fragments into the living section at release reconciliation, and `check:changelog-integrity` now also validates fragment well-formedness. Regression guard: `tests/unit/changelog-fragments.test.ts`. diff --git a/changelog.d/fixes/.gitkeep b/changelog.d/fixes/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/changelog.d/maintenance/.gitkeep b/changelog.d/maintenance/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/package.json b/package.json index c898b34204..92d759d7eb 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,7 @@ "check:test-masking": "node scripts/check/check-test-masking.mjs", "check:test-runner-api": "node scripts/check/check-test-runner-api.mjs", "check:changelog-integrity": "node scripts/check/check-changelog-integrity.mjs", + "changelog:aggregate": "node scripts/release/aggregate-changelog.mjs", "check:agent-skills-sync": "node --import tsx/esm scripts/skills/generate-agent-skills.mjs", "check:build-scope": "node scripts/check/check-build-scope.mjs", "check:error-helper": "node scripts/check/check-error-helper.mjs", diff --git a/scripts/check/check-changelog-integrity.mjs b/scripts/check/check-changelog-integrity.mjs index b07c3f99f4..edf3ac4442 100644 --- a/scripts/check/check-changelog-integrity.mjs +++ b/scripts/check/check-changelog-integrity.mjs @@ -26,12 +26,15 @@ // env ALLOW_CHANGELOG_REMOVALS=1 report-only (never fails) import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const CHANGELOG = "CHANGELOG.md"; +const FRAGMENTS_DIR = "changelog.d"; +const FRAGMENT_SECTIONS = ["features", "fixes", "maintenance"]; +const FRAGMENT_SKIP = new Set(["README.md", ".gitkeep"]); /** Extract the set of bullet lines (trimmed) from a CHANGELOG text. */ export function extractBullets(text) { @@ -56,6 +59,49 @@ export function findLostBullets(baseText, headText) { return lost; } +/** + * Validate changelog FRAGMENTS (changelog.d/
/*.md — see changelog.d/README.md). + * A fragment must be a well-formed markdown bullet ("- ...") with no merge-conflict + * markers, and must live in a known section dir. Returns [{file, error}]. Pure over + * the filesystem — unit-tested via a tmp root. + */ +export function findInvalidFragments(root = ROOT) { + const invalid = []; + const base = join(root, FRAGMENTS_DIR); + if (!existsSync(base)) return invalid; + const entries = readdirSync(base, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + if (!FRAGMENT_SKIP.has(entry.name)) { + invalid.push({ + file: `${FRAGMENTS_DIR}/${entry.name}`, + error: `fragments live in a section dir (${FRAGMENT_SECTIONS.join("|")}), not at changelog.d root`, + }); + } + continue; + } + if (!FRAGMENT_SECTIONS.includes(entry.name)) { + invalid.push({ + file: `${FRAGMENTS_DIR}/${entry.name}/`, + error: `unknown section dir (expected ${FRAGMENT_SECTIONS.join("|")})`, + }); + continue; + } + for (const f of readdirSync(join(base, entry.name))) { + if (FRAGMENT_SKIP.has(f) || !f.endsWith(".md")) continue; + const file = `${FRAGMENTS_DIR}/${entry.name}/${f}`; + const text = readFileSync(join(base, entry.name, f), "utf8"); + const firstContent = text.split("\n").find((l) => l.trim().length > 0); + if (!firstContent) invalid.push({ file, error: "empty fragment" }); + else if (!firstContent.trimStart().startsWith("- ")) + invalid.push({ file, error: 'fragment must start with a markdown bullet ("- ")' }); + else if (/^(<{7}|={7}|>{7})/m.test(text)) + invalid.push({ file, error: "fragment contains merge-conflict markers" }); + } + } + return invalid; +} + function git(args) { return execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); } @@ -77,6 +123,16 @@ function resolveBaseRef() { } function main() { + // Fragment well-formedness first (changelog.d/ — the fragments pattern makes the + // eat-guard below structurally unnecessary for PRs that stop editing CHANGELOG.md). + const invalidFragments = findInvalidFragments(); + if (invalidFragments.length > 0) { + console.error(`[changelog-integrity] ${invalidFragments.length} invalid changelog fragment(s):`); + for (const { file, error } of invalidFragments) console.error(` ✗ ${file}: ${error}`); + console.error("\nSee changelog.d/README.md for the fragment convention."); + return 1; + } + const baseRef = resolveBaseRef(); if (!baseRef) { console.log("[changelog-integrity] SKIP — could not resolve a base ref (offline/fresh clone)."); diff --git a/scripts/release/aggregate-changelog.mjs b/scripts/release/aggregate-changelog.mjs new file mode 100644 index 0000000000..7d490045f6 --- /dev/null +++ b/scripts/release/aggregate-changelog.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// scripts/release/aggregate-changelog.mjs +// +// Changelog FRAGMENTS aggregator (towncrier/changesets pattern, adopted 2026-07-09). +// +// Why: during a release cycle every PR used to edit the same few lines at the top of +// CHANGELOG.md (its bullet). In a merge-storm each merge conflicted every sibling +// (CHANGELOG-eat / DIRTY cascade), forcing a re-sync push + full CI re-run per PR per +// merge — O(N²) CI runs for N queued PRs. With fragments, a PR adds ONE NEW FILE under +// changelog.d/
/ instead, so two PRs never touch the same file: no conflicts, +// no eat, no re-sync. This script is the single place fragments become CHANGELOG.md +// bullets — run by the release captain (or /generate-release) at reconciliation, and +// safe to run mid-cycle whenever a consolidated view is wanted. +// +// Convention: +// changelog.d/features/-.md → appended to "### ✨ New Features" +// changelog.d/fixes/-.md → appended to "### 🐛 Bug Fixes" +// changelog.d/maintenance/-.md → appended to "### 📝 Maintenance" +// File content = the exact bullet line(s), starting with "- " (continuation lines +// allowed). Credit format stays the repo norm: "(#PR — thanks @user)". +// +// Usage: +// node scripts/release/aggregate-changelog.mjs [--dry-run] +// --dry-run print the would-be CHANGELOG.md to stdout and list fragments; +// touch nothing. +// +// On a real run, aggregated fragment files are DELETED (leaving README.md and the +// .gitkeep placeholders) — the caller commits both the CHANGELOG.md update and the +// deletions in one commit. + +import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const FRAGMENTS_DIR = "changelog.d"; + +/** Section subdir → the CHANGELOG heading its bullets are appended under. */ +export const SECTIONS = Object.freeze({ + features: "### ✨ New Features", + fixes: "### 🐛 Bug Fixes", + maintenance: "### 📝 Maintenance", +}); + +const SKIP_FILES = new Set(["README.md", ".gitkeep"]); + +/** + * Validate one fragment's text. Returns null when OK, or a human-readable error. + * Pure — unit-tested. + */ +export function validateFragmentText(text) { + const body = String(text || "").replace(/^/, ""); + const lines = body.split("\n"); + const firstContent = lines.find((l) => l.trim().length > 0); + if (!firstContent) return "empty fragment"; + if (!firstContent.trimStart().startsWith("- ")) { + return 'fragment must start with a markdown bullet ("- ")'; + } + if (/^(<{7}|={7}|>{7})/m.test(body)) return "fragment contains merge-conflict markers"; + return null; +} + +/** + * Collect fragments from /changelog.d, sorted by filename per section for a + * deterministic output order. Returns { features: [...], fixes: [...], + * maintenance: [...], invalid: [{file, error}] } where each valid entry is + * { file, text } (text trimmed of trailing whitespace). + */ +export function collectFragments(root) { + const out = { features: [], fixes: [], maintenance: [], invalid: [] }; + const base = join(root, FRAGMENTS_DIR); + if (!existsSync(base)) return out; + for (const section of Object.keys(SECTIONS)) { + const dir = join(base, section); + if (!existsSync(dir)) continue; + const files = readdirSync(dir) + .filter((f) => f.endsWith(".md") && !SKIP_FILES.has(f)) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + for (const f of files) { + const file = join(dir, f); + const text = readFileSync(file, "utf8").replace(/\s+$/, ""); + const error = validateFragmentText(text); + if (error) out.invalid.push({ file: relative(root, file), error }); + else out[section].push({ file: relative(root, file), text }); + } + } + return out; +} + +/** + * Append bullets at the END of a living-section heading's bullet block (before the + * next "##"/"###" heading). Operates on the FIRST occurrence of the heading — in this + * repo's CHANGELOG the living cycle section always appears first. Pure — unit-tested. + * Throws when a needed heading is missing (the release captain adds the heading; the + * script never invents structure). + */ +export function insertBullets(changelogText, bulletsBySection) { + let lines = changelogText.split("\n"); + for (const [section, heading] of Object.entries(SECTIONS)) { + const bullets = (bulletsBySection[section] || []).map((b) => b.text ?? b); + if (bullets.length === 0) continue; + const headIdx = lines.findIndex((l) => l.trim() === heading); + if (headIdx === -1) { + throw new Error( + `heading "${heading}" not found in CHANGELOG.md — add it to the living section before aggregating ${section} fragments` + ); + } + // End of this section's block: last non-empty line before the next heading. + let nextHead = lines.length; + for (let i = headIdx + 1; i < lines.length; i++) { + if (/^##/.test(lines[i])) { + nextHead = i; + break; + } + } + let insertAt = nextHead; + while (insertAt > headIdx + 1 && lines[insertAt - 1].trim() === "") insertAt--; + const block = bullets.flatMap((b) => b.split("\n")); + lines = [...lines.slice(0, insertAt), ...block, ...lines.slice(insertAt)]; + } + return lines.join("\n"); +} + +/** + * Aggregate fragments into CHANGELOG.md. Returns a summary object. When dryRun is + * true nothing is written or deleted. + */ +export function aggregate({ root = ROOT, dryRun = false } = {}) { + const collected = collectFragments(root); + if (collected.invalid.length > 0) { + const detail = collected.invalid.map((i) => ` ✗ ${i.file}: ${i.error}`).join("\n"); + throw new Error(`invalid changelog fragments:\n${detail}`); + } + const total = collected.features.length + collected.fixes.length + collected.maintenance.length; + const changelogPath = join(root, "CHANGELOG.md"); + const before = readFileSync(changelogPath, "utf8"); + const after = total === 0 ? before : insertBullets(before, collected); + if (!dryRun && total > 0) { + writeFileSync(changelogPath, after); + for (const section of Object.keys(SECTIONS)) { + for (const { file } of collected[section]) unlinkSync(join(root, file)); + } + } + return { total, collected, changed: total > 0, after }; +} + +function main() { + const dryRun = process.argv.includes("--dry-run"); + const result = aggregate({ dryRun }); + if (result.total === 0) { + console.log("[aggregate-changelog] no fragments to aggregate — nothing to do."); + return 0; + } + for (const section of Object.keys(SECTIONS)) { + for (const { file } of result.collected[section]) { + console.log(`[aggregate-changelog] ${dryRun ? "would aggregate" : "aggregated"} ${file}`); + } + } + console.log( + `[aggregate-changelog] ${result.total} fragment(s) → CHANGELOG.md${dryRun ? " (dry-run, nothing written)" : " (fragments deleted — commit CHANGELOG.md + deletions together)"}` + ); + return 0; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + process.exit(main()); +} diff --git a/tests/unit/changelog-fragments.test.ts b/tests/unit/changelog-fragments.test.ts new file mode 100644 index 0000000000..6be69c0a4d --- /dev/null +++ b/tests/unit/changelog-fragments.test.ts @@ -0,0 +1,184 @@ +// Guards the changelog FRAGMENTS pipeline (changelog.d/ → CHANGELOG.md), adopted +// 2026-07-09 to kill the CHANGELOG-eat / DIRTY merge-storm cascade: a PR adds ONE new +// file under changelog.d/
/ instead of editing CHANGELOG.md, so sibling PRs +// never conflict. Covers the aggregator (scripts/release/aggregate-changelog.mjs) and +// the fragment validation wired into the merge-integrity gate +// (scripts/check/check-changelog-integrity.mjs::findInvalidFragments). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const { SECTIONS, validateFragmentText, collectFragments, insertBullets, aggregate } = + await import("../../scripts/release/aggregate-changelog.mjs"); +const { findInvalidFragments } = await import("../../scripts/check/check-changelog-integrity.mjs"); + +const CHANGELOG_FIXTURE = `# Changelog + +## [Unreleased] + +## [3.8.47] — TBD + +_Living section — bullets land here as PRs merge._ + +### ✨ New Features + +- **existing feature**: already here (#1 — thanks @a) + +### 🐛 Bug Fixes + +- **fix(x):** existing fix (#2 — thanks @b) + +### 📝 Maintenance + +- chore: existing maintenance (#3) + +## [3.8.46] - 2026-07-04 + +### ✨ New Features + +- **old feature**: shipped (#0) +`; + +function makeRoot({ fragments = {} } = {}) { + const root = mkdtempSync(join(tmpdir(), "chfrag-")); + writeFileSync(join(root, "CHANGELOG.md"), CHANGELOG_FIXTURE); + mkdirSync(join(root, "changelog.d"), { recursive: true }); + for (const [rel, text] of Object.entries(fragments)) { + const abs = join(root, "changelog.d", rel); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync(abs, text); + } + return root; +} + +test("validateFragmentText accepts a bullet and rejects garbage", () => { + assert.equal(validateFragmentText("- **fix:** ok (#9 — thanks @x)"), null); + assert.equal(validateFragmentText("- multi\n continuation line"), null); + assert.match(validateFragmentText(""), /empty/); + assert.match(validateFragmentText("not a bullet"), /must start/); + assert.match(validateFragmentText("- ok\n<<<<<<< HEAD"), /conflict markers/); +}); + +test("collectFragments reads sections sorted and flags invalid files", () => { + const root = makeRoot({ + fragments: { + "fixes/6700-b.md": "- fix B (#6700)", + "fixes/6496-a.md": "- fix A (#6496)", + "features/6728-chaos.md": "- feat chaos (#6728)", + "features/bad.md": "no bullet here", + }, + }); + const c = collectFragments(root); + assert.deepEqual( + c.fixes.map((f) => f.text), + ["- fix A (#6496)", "- fix B (#6700)"] + ); + assert.equal(c.features.length, 1); + assert.equal(c.invalid.length, 1); + assert.match(c.invalid[0].file, /bad\.md/); + rmSync(root, { recursive: true, force: true }); +}); + +test("insertBullets appends at the END of each living section", () => { + const out = insertBullets(CHANGELOG_FIXTURE, { + features: [{ text: "- NEW feature bullet (#10)" }], + fixes: [{ text: "- NEW fix bullet (#11)" }], + maintenance: [{ text: "- NEW maintenance bullet (#12)" }], + }); + const lines = out.split("\n"); + const featIdx = lines.indexOf("- NEW feature bullet (#10)"); + const bugHeadIdx = lines.indexOf("### 🐛 Bug Fixes"); + const fixIdx = lines.indexOf("- NEW fix bullet (#11)"); + const maintHeadIdx = lines.indexOf("### 📝 Maintenance"); + const maintIdx = lines.indexOf("- NEW maintenance bullet (#12)"); + // Each new bullet lands after its own existing bullets, before the next heading. + assert.ok(featIdx > lines.indexOf("- **existing feature**: already here (#1 — thanks @a)")); + assert.ok(featIdx < bugHeadIdx, "feature bullet must stay inside the features section"); + assert.ok(fixIdx > bugHeadIdx && fixIdx < maintHeadIdx); + assert.ok(maintIdx > maintHeadIdx && maintIdx < lines.indexOf("## [3.8.46] - 2026-07-04")); + // Only the FIRST (living) occurrence of a heading is touched — the shipped 3.8.46 + // section is byte-identical. + assert.ok(out.includes("## [3.8.46] - 2026-07-04\n\n### ✨ New Features\n\n- **old feature**: shipped (#0)")); + // No existing bullet lost. + for (const existing of ["#1 — thanks @a", "existing fix (#2", "existing maintenance (#3"]) { + assert.ok(out.includes(existing)); + } +}); + +test("insertBullets throws when a needed heading is missing", () => { + const noMaint = CHANGELOG_FIXTURE.replace("### 📝 Maintenance\n\n- chore: existing maintenance (#3)\n", ""); + assert.throws( + () => insertBullets(noMaint, { maintenance: [{ text: "- x" }] }), + /📝 Maintenance.*not found/s + ); +}); + +test("aggregate dry-run touches nothing; real run writes and deletes fragments", () => { + const root = makeRoot({ + fragments: { "fixes/6800-real.md": "- real aggregated fix (#6800 — thanks @c)" }, + }); + const dry = aggregate({ root, dryRun: true }); + assert.equal(dry.total, 1); + assert.ok(!readFileSync(join(root, "CHANGELOG.md"), "utf8").includes("#6800")); + assert.ok(existsSync(join(root, "changelog.d/fixes/6800-real.md"))); + + const real = aggregate({ root }); + assert.equal(real.total, 1); + const after = readFileSync(join(root, "CHANGELOG.md"), "utf8"); + assert.ok(after.includes("- real aggregated fix (#6800 — thanks @c)")); + assert.ok(!existsSync(join(root, "changelog.d/fixes/6800-real.md")), "fragment must be deleted"); + + // Idempotence: nothing left → second run is a no-op. + const again = aggregate({ root }); + assert.equal(again.total, 0); + assert.equal(readFileSync(join(root, "CHANGELOG.md"), "utf8"), after); + rmSync(root, { recursive: true, force: true }); +}); + +test("aggregate refuses invalid fragments loudly", () => { + const root = makeRoot({ fragments: { "features/oops.md": "forgot the dash" } }); + assert.throws(() => aggregate({ root }), /invalid changelog fragments/); + rmSync(root, { recursive: true, force: true }); +}); + +test("gate findInvalidFragments: clean tree passes, bad placement/content fail", () => { + const clean = makeRoot({ fragments: { "maintenance/1-ok.md": "- ok (#1)" } }); + assert.deepEqual(findInvalidFragments(clean), []); + rmSync(clean, { recursive: true, force: true }); + + const dirty = makeRoot({ + fragments: { + "stray.md": "- misplaced at root", + "unknown-section/2-x.md": "- wrong dir", + "fixes/3-bad.md": "missing dash", + }, + }); + const invalid = findInvalidFragments(dirty); + const files = invalid.map((i) => i.file).sort(); + assert.equal(invalid.length, 3); + assert.ok(files.some((f) => f.includes("stray.md"))); + assert.ok(files.some((f) => f.includes("unknown-section"))); + assert.ok(files.some((f) => f.includes("3-bad.md"))); + rmSync(dirty, { recursive: true, force: true }); +}); + +test("gate skips README.md and .gitkeep; absent changelog.d is fine", () => { + const root = makeRoot(); + writeFileSync(join(root, "changelog.d/README.md"), "# docs, not a fragment"); + mkdirSync(join(root, "changelog.d/fixes"), { recursive: true }); + writeFileSync(join(root, "changelog.d/fixes/.gitkeep"), ""); + assert.deepEqual(findInvalidFragments(root), []); + rmSync(root, { recursive: true, force: true }); + + const bare = mkdtempSync(join(tmpdir(), "chfrag-bare-")); + assert.deepEqual(findInvalidFragments(bare), []); + rmSync(bare, { recursive: true, force: true }); +}); + +test("SECTIONS maps every dir to a real living-section heading in the fixture", () => { + for (const heading of Object.values(SECTIONS)) { + assert.ok(CHANGELOG_FIXTURE.includes(heading), `fixture must contain ${heading}`); + } +});