diff --git a/package.json b/package.json index b10f910..5ee1b88 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@typescript-eslint/parser": "^8.65.0", "conventional-changelog": "^8.1.0", "conventional-changelog-conventionalcommits": "^10.2.1", + "conventional-recommended-bump": "^12.1.0", "eslint": "^10.8.0", "jest": "^30.4.2", "magic-codemods": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99c50f0..40a10d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: conventional-changelog-conventionalcommits: specifier: ^10.2.1 version: 10.2.1 + conventional-recommended-bump: + specifier: ^12.1.0 + version: 12.1.0 eslint: specifier: ^10.8.0 version: 10.8.0(supports-color@8.1.1) @@ -1100,6 +1103,11 @@ packages: engines: {node: '>=22'} hasBin: true + conventional-recommended-bump@12.1.0: + resolution: {integrity: sha512-HTNG3kEZXOOfKwrEx54ljURU9bG4nVPQzXMyCSeUcA/PE8EpNpQNujSrbXNBI8QZyN35zM+pVw+FuQk4KqHSHg==} + engines: {node: '>=22'} + hasBin: true + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -3109,6 +3117,14 @@ snapshots: '@simple-libs/stream-utils': 2.0.0 argue-cli: 3.1.0 + conventional-recommended-bump@12.1.0: + dependencies: + '@conventional-changelog/git-client': 3.1.0(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.1) + argue-cli: 3.1.0 + conventional-changelog-preset-loader: 6.0.1 + conventional-commits-filter: 6.0.1 + conventional-commits-parser: 7.1.1 + convert-source-map@2.0.0: {} cross-spawn@7.0.6: diff --git a/tools/changelog-check.mjs b/tools/changelog-check.mjs index 028e2a4..508f906 100644 --- a/tools/changelog-check.mjs +++ b/tools/changelog-check.mjs @@ -1,29 +1,71 @@ -// Positive control for tools/changelog-preset.mjs. +// Control for tools/changelog-preset.mjs. // -// The preset decides which commit types reach the changelog, so the risk it -// carries is silent omission: mark a type `hidden` and its commits vanish with -// no error anywhere. A breaking change vanishing that way is the one failure -// this repo cannot ship, and CONTRIBUTING.md promises it won't. +// The preset decides which commit types reach the changelog and which of them +// can move the version, so the risk it carries is silent omission: mark a type +// hidden and its commits vanish with no error anywhere. A breaking change +// vanishing that way is the one failure this package cannot ship, and +// CONTRIBUTING.md promises it won't. // -// So this builds a throwaway repo with one commit of every type, renders it -// through the real preset, and asserts on what comes back. The `ci!:` commit is -// the case worth having: `ci` is hidden, and its breaking note still has to -// surface. +// POLICY below is written out longhand on purpose. Deriving it from the +// preset's own type list would make every assertion a tautology — hide a +// section and the check would just change its mind about what it expected. So +// the policy is stated here, the preset is asserted to match it, and the +// rendered output is asserted to match it too. Changing what reaches the +// changelog means changing both files, deliberately. +// +// Three things get checked: +// +// 1. the type list agrees with the policy +// 2. a synthetic history of every type renders the way the policy says, +// including the `ci!:` case — `ci` is hidden and its breaking note still +// has to surface +// 3. the recommended bump respects `effect`, so a release of nothing but +// `build:` and `chore:` commits cannot come out a minor +// +// Then it renders the same history through a sabotaged type list and fails if +// *that* passes. An assertion that cannot fail is worth nothing. import { execFileSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Bumper } from "conventional-recommended-bump"; + +import preset, { TYPES } from "./changelog-preset.mjs"; const here = import.meta.dirname; const presetPath = join(here, "changelog-preset.mjs"); -const cliPath = join( - here, - "..", - "node_modules", - ".bin", - "conventional-changelog", -); +// Resolved rather than assumed to sit at ../node_modules/.bin: pnpm hoists, and +// in a workspace the binary can land in the root store instead of the package. +// The package exports no `./package.json`, so resolve its entry point and walk +// across to the CLI beside it. +const cliEntry = fileURLToPath(import.meta.resolve("conventional-changelog")); +const cliPath = join(dirname(cliEntry), "cli", "index.js"); + +/** + * What each type is for. `bump` can raise the version, `changelog` renders + * without raising it, `hidden` does neither. + * + * @type {Record} + */ +const POLICY = { + feat: "bump", + feature: "bump", + fix: "bump", + perf: "bump", + revert: "bump", + build: "changelog", + refactor: "changelog", + chore: "changelog", + docs: "changelog", + ci: "hidden", + style: "hidden", + test: "hidden", +}; +// One commit per type, plus three breaking ones spread across a bump type, a +// changelog type and a hidden type. const COMMITS = [ "feat: add a thing", "fix: correct a thing", @@ -39,44 +81,119 @@ const COMMITS = [ "ci!: require node 24\n\nBREAKING CHANGE: node 22 is no longer supported", ]; -const repo = mkdtempSync(join(tmpdir(), "safe-jsx-changelog-")); -/** @param {string[]} args */ -const git = (...args) => - execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: "pipe" }); +// Subject fragment -> the type it was committed under. Matching on subjects +// rather than section headings keeps the check working in a repo that renames +// its sections. +const SUBJECTS = { + feat: "add a thing", + fix: "correct a thing", + build: "shrink the tarball", + refactor: "rewrite the internals", + chore: "bump something", + docs: "update the readme", + ci: "tweak the workflow", + style: "reformat", + test: "add cases", +}; -let output; +const NOTES = { + "a bump type (perf!)": "the slow path is gone", + "a changelog type (chore!)": "the legacy loader is gone", + "a HIDDEN type (ci!)": "node 22 is no longer supported", +}; -try { - git("init", "--quiet", "--initial-branch", "main"); - git("config", "user.email", "check@example.com"); - git("config", "user.name", "changelog check"); - writeFileSync( - join(repo, "package.json"), - JSON.stringify({ name: "changelog-check", version: "1.0.0" }), - ); - git("add", "."); - - for (const message of COMMITS) { - git( - "commit", - "--quiet", - "--allow-empty", - "--cleanup=verbatim", - "-m", - message, +/** + * Builds a throwaway repo out of `commits` and hands it to `run`. Async on + * purpose: `bumpFor` below is, and a synchronous `finally` would delete the + * repo out from under it. + * + * `tag` says where v1.0.0 goes, and the two callers need different answers. + * The renderer wants it last, so `--release-count 0` has a released section to + * render — a chunk whose version matches the last tag comes out empty. The + * bumper wants it first, so the commits are the range being measured. + * + * @template T + * @param {string[]} commits + * @param {"before" | "after"} tag + * @param {(repo: string) => T | Promise} run + * @returns {Promise} + */ +const inThrowawayRepo = async (commits, tag, run) => { + const repo = mkdtempSync(join(tmpdir(), "safe-jsx-changelog-")); + /** @param {string[]} args */ + const git = (...args) => + execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: "pipe" }); + + try { + git("init", "--quiet", "--initial-branch", "main"); + git("config", "user.email", "check@example.com"); + git("config", "user.name", "changelog check"); + writeFileSync( + join(repo, "package.json"), + JSON.stringify({ name: "changelog-check", version: "1.0.0" }), ); - } + git("add", "."); + git("commit", "--quiet", "-m", "chore: initial"); + if (tag === "before") git("tag", "-a", "v1.0.0", "-m", "v1.0.0"); + + for (const message of commits) { + git( + "commit", + "--quiet", + "--allow-empty", + "--cleanup=verbatim", + "-m", + message, + ); + } + + if (tag === "after") git("tag", "-a", "v1.0.0", "-m", "v1.0.0"); - git("tag", "-a", "v1.0.0", "-m", "v1.0.0"); + return await run(repo); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}; - output = execFileSync( - cliPath, - ["--config", presetPath, "--release-count", "0", "--stdout"], - { cwd: repo, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, +/** + * Renders COMMITS through a preset module and returns the changelog text. + * + * @param {string} configPath + * @returns {Promise} + */ +const render = (configPath) => + inThrowawayRepo(COMMITS, "after", (repo) => + execFileSync( + process.execPath, + [cliPath, "--config", configPath, "--release-count", "0", "--stdout"], + { cwd: repo, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, + ), ); -} finally { - rmSync(repo, { recursive: true, force: true }); -} + +/** + * The release type conventional-recommended-bump lands on for `commits`, read + * through the real preset. `null` means "nothing here warrants a release". + * + * @param {string[]} commits + * @returns {Promise} + */ +const bumpFor = (commits) => + inThrowawayRepo(commits, "before", async (repo) => { + const bumper = new Bumper(repo); + bumper.loadPreset({ name: "conventionalcommits", types: TYPES }); + // Same double cast .release-it.js needs: the preset publishes + // `createPreset(config?): {}`, so its own return type doesn't describe the + // `whatBump` it demonstrably returns. `bump()` is typed as a union whose + // empty arm has no `releaseType`, which is the `null` case here. + const { whatBump } = + /** @type {{ whatBump: Parameters[0] }} */ ( + /** @type {unknown} */ (await preset) + ); + const recommendation = /** @type {{ releaseType?: string }} */ ( + await bumper.bump(whatBump) + ); + return recommendation.releaseType ?? null; + }); /** @type {string[]} */ const failures = []; @@ -88,49 +205,129 @@ const expect = (label, condition) => { if (!condition) failures.push(label); }; -// Breaking changes render whatever type they hang off, hidden ones included. -// The preset prefixes the heading with a warning sign, so match on the words. -expect("BREAKING CHANGES heading", /^### .*BREAKING CHANGES$/m.test(output)); -expect( - "breaking note on a bump type (perf!)", - output.includes("the slow path is gone"), -); +// 1. The type list says what the policy says. +for (const [type, effect] of Object.entries(POLICY)) { + const entry = TYPES.find((candidate) => candidate.type === type); + expect(`${type} is in the type list`, entry !== undefined); + expect(`${type} is "${effect}"`, entry?.effect === effect); +} +for (const entry of TYPES) { + expect(`${entry.type} is covered by the policy`, entry.type in POLICY); +} + +/** + * 2. The rendered output says what the policy says. + * + * @param {string} output + * @returns {string[]} + */ +const assessRendering = (output) => { + /** @type {string[]} */ + const found = []; + /** + * @param {string} label + * @param {boolean} condition + */ + const check = (label, condition) => { + if (!condition) found.push(label); + }; + + // Breaking changes render whatever type they hang off, hidden ones included. + // The preset prefixes the heading with a warning sign, so match on the words. + check("BREAKING CHANGES heading", /^### .*BREAKING CHANGES$/m.test(output)); + for (const [label, text] of Object.entries(NOTES)) { + check(`breaking note on ${label}`, output.includes(text)); + } + + for (const [type, subject] of Object.entries(SUBJECTS)) { + const shouldRender = POLICY[type] !== "hidden"; + check( + shouldRender ? `${type} renders` : `${type} stays hidden`, + output.includes(subject) === shouldRender, + ); + } + + return found; +}; + +failures.push(...assessRendering(await render(presetPath))); + +// 3. `effect` decides the bump, not just the rendering. The middle case is the +// one that matters: those four types all render, and none of them may push a +// release past a patch. +const bumps = { + breaking: await bumpFor(["feat!: drop the old API"]), + feature: await bumpFor(["feat: add a thing"]), + fix: await bumpFor(["fix: correct a thing"]), + changelogOnly: await bumpFor([ + "build: shrink the tarball", + "refactor: rewrite the internals", + "chore(deps): bump something", + "docs: update the readme", + ]), + hiddenOnly: await bumpFor(["ci: tweak the workflow", "style: reformat"]), +}; + +expect("a breaking feat is a major", bumps.breaking === "major"); +expect("a feat is a minor", bumps.feature === "minor"); +expect("a fix is a patch", bumps.fix === "patch"); expect( - "breaking note on a changelog type (chore!)", - output.includes("the legacy loader is gone"), + `build/refactor/chore/docs alone do not bump (got ${bumps.changelogOnly})`, + bumps.changelogOnly === null, ); expect( - "breaking note on a HIDDEN type (ci!)", - output.includes("node 22 is no longer supported"), + `ci/style alone do not bump (got ${bumps.hiddenOnly})`, + bumps.hiddenOnly === null, ); -// The types that can change the published artifact. -expect("Features section", output.includes("### Features")); -expect("Bug Fixes section", output.includes("### Bug Fixes")); -expect("Build System section", output.includes("shrink the tarball")); -expect("Code Refactoring section", output.includes("rewrite the internals")); -expect("Chores section", output.includes("bump something")); -expect("Documentation section", output.includes("update the readme")); - -// The types that cannot. A non-breaking commit of each stays out. -expect("ci stays hidden", !output.includes("tweak the workflow")); -expect("style stays hidden", !output.includes("reformat")); -expect("test stays hidden", !output.includes("add cases")); - if (failures.length > 0) { console.error("changelog preset check FAILED:"); for (const failure of failures) console.error(` - ${failure}`); - console.error("\n--- rendered output ---\n"); - console.error(output); process.exit(1); } -// `--print` is for looking at the control by hand, which is the only way to -// judge the section names and ordering. The assertions above cannot. -if (process.argv.includes("--print")) { - console.log(output); +// Negative control. Same commits, same assertions, a type list with every +// visible type forced to `hidden`. POLICY doesn't move, so hiding a section has +// to show up as a failure. +const sabotaged = TYPES.map((entry) => + entry.effect === "hidden" ? entry : { ...entry, effect: "hidden" }, +); +// Written next to the real preset rather than in the temp dir so the bare +// `conventional-changelog-conventionalcommits` specifier still resolves. +const sabotagedPath = join( + here, + `.changelog-preset-sabotaged.${process.pid}.mjs`, +); +writeFileSync( + sabotagedPath, + 'import createPreset from "conventional-changelog-conventionalcommits";\n' + + `export default createPreset({ types: ${JSON.stringify(sabotaged)} });\n`, +); + +let sabotagedFailures; +try { + sabotagedFailures = assessRendering(await render(sabotagedPath)); +} finally { + rmSync(sabotagedPath, { force: true }); +} + +if (sabotagedFailures.length === 0) { + console.error( + "negative control FAILED: hiding every visible type changed nothing, so the assertions above are not testing anything.", + ); + process.exit(1); } console.log( - `changelog preset check passed (${COMMITS.length} synthetic commits, 3 breaking)`, + `changelog preset check passed (${COMMITS.length} synthetic commits, 3 breaking; bumps: feat!=${bumps.breaking}, feat=${bumps.feature}, fix=${bumps.fix}, changelog-only=${bumps.changelogOnly}, hidden-only=${bumps.hiddenOnly})`, ); +console.log( + `negative control passed (hiding the visible types breaks ${sabotagedFailures.length} assertions: ${sabotagedFailures.join(", ")})`, +); + +// `--print` is for looking at the control by hand, which is the only way to +// judge the section names and ordering. The assertions above cannot. +if (process.argv.includes("--print")) { + console.log("\n--- rendered through the real preset ---\n"); + console.log(await render(presetPath)); +}