diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index dd7e3dff3e..5edec3f778 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -44,6 +44,9 @@ jobs: - name: Lint run: pnpm lint + - name: Changeset format + run: pnpm check:changesets + typecheck: name: Typecheck runs-on: ubuntu-latest diff --git a/package.json b/package.json index 9fcec03216..63dafe3abe 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,11 @@ "type": "module", "packageManager": "pnpm@10.33.0", "scripts": { - "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs", - "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs", + "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs", + "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs", "check:line-count": "node scripts/check-file-line-count.mjs", - "test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @runfusion/fusion test:ci-shape", + "check:changesets": "node scripts/check-changeset-format.mjs", + "test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @runfusion/fusion test:ci-shape", "smoke:boot": "node scripts/boot-smoke.mjs", "local": "node scripts/start-local.mjs", "dev": "node scripts/dev-with-memory.mjs", diff --git a/scripts/__tests__/check-changeset-format.test.mjs b/scripts/__tests__/check-changeset-format.test.mjs new file mode 100644 index 0000000000..0f62c9bc0b --- /dev/null +++ b/scripts/__tests__/check-changeset-format.test.mjs @@ -0,0 +1,186 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { scanChangesets } from "../check-changeset-format.mjs"; +import { + mkdirSync, + writeFileSync, + rmSync, + existsSync, +} from "node:fs"; +import { join } from "node:path"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; + +function createTempChangesetDir(changesets) { + const dir = mkdtempSync(join(tmpdir(), "changeset-lint-test-")); + for (const [name, content] of Object.entries(changesets)) { + writeFileSync(join(dir, name), content); + } + return dir; +} + +const validStructured = `--- +"@runfusion/fusion": minor +--- + +summary: Add a new dashboard widget. +category: feature +dev: Uses the widget framework. +`; + +const validMinimal = `--- +"@runfusion/fusion": patch +--- + +summary: Fix a typo. +category: fix +`; + +const legacyFreeform = `--- +"@runfusion/fusion": patch +--- + +Fix ntfy test notifications to honor unsaved Settings form config so users can test before saving. +`; + +const missingCategory = `--- +"@runfusion/fusion": minor +--- + +summary: Add something. +`; + +const invalidCategory = `--- +"@runfusion/fusion": minor +--- + +summary: Add something. +category: enhancement +`; + +const overLengthSummary = `--- +"@runfusion/fusion": minor +--- + +summary: ${"a".repeat(121)} +category: feature +`; + +const emptyBody = `--- +"@runfusion/fusion": minor +--- + +`; + +test("valid structured changeset passes with no errors", () => { + const dir = createTempChangesetDir({ "valid.md": validStructured }); + try { + const { errors, warnings } = scanChangesets(dir); + assert.equal(errors.length, 0); + assert.equal(warnings.length, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("legacy freeform changeset passes with warning in transition mode", () => { + const dir = createTempChangesetDir({ "legacy.md": legacyFreeform }); + try { + const { errors, warnings } = scanChangesets(dir); + assert.equal(errors.length, 0); + assert.equal(warnings.length, 1); + assert.ok(warnings[0].includes("legacy")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("missing category fails with error", () => { + const dir = createTempChangesetDir({ "no-cat.md": missingCategory }); + try { + const { errors } = scanChangesets(dir); + assert.equal(errors.length, 1); + assert.ok(errors[0].includes("missing required `category`")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("invalid category value fails with error", () => { + const dir = createTempChangesetDir({ "bad-cat.md": invalidCategory }); + try { + const { errors } = scanChangesets(dir); + assert.equal(errors.length, 1); + assert.ok(errors[0].includes("invalid")); + assert.ok(errors[0].includes("enhancement")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("over-length summary fails with error", () => { + const dir = createTempChangesetDir({ "long.md": overLengthSummary }); + try { + const { errors } = scanChangesets(dir); + assert.equal(errors.length, 1); + assert.ok(errors[0].includes("exceeds max length")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("empty body fails with error", () => { + const dir = createTempChangesetDir({ "empty.md": emptyBody }); + try { + const { errors } = scanChangesets(dir); + assert.equal(errors.length, 1); + assert.ok(errors[0].includes("empty or unparseable")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("empty directory passes with no errors or warnings", () => { + const dir = mkdtempSync(join(tmpdir(), "changeset-lint-test-")); + try { + const { errors, warnings } = scanChangesets(dir); + assert.equal(errors.length, 0); + assert.equal(warnings.length, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("nonexistent directory passes with no errors or warnings", () => { + const { errors, warnings } = scanChangesets("/nonexistent/path"); + assert.equal(errors.length, 0); + assert.equal(warnings.length, 0); +}); + +test("mixed valid and invalid changesets report all errors", () => { + const dir = createTempChangesetDir({ + "valid.md": validStructured, + "no-cat.md": missingCategory, + "bad-cat.md": invalidCategory, + "legacy.md": legacyFreeform, + }); + try { + const { errors, warnings } = scanChangesets(dir); + assert.equal(errors.length, 2); + assert.equal(warnings.length, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("valid minimal structured changeset (no dev) passes", () => { + const dir = createTempChangesetDir({ "minimal.md": validMinimal }); + try { + const { errors, warnings } = scanChangesets(dir); + assert.equal(errors.length, 0); + assert.equal(warnings.length, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/check-changeset-format.mjs b/scripts/check-changeset-format.mjs new file mode 100644 index 0000000000..8b1a1c8a2b --- /dev/null +++ b/scripts/check-changeset-format.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/* + * FNXC:Changelog 2026-06-24-15:00: + * Changeset format linter. Validates that all .changeset/*.md files follow + * the structured schema (summary, category, dev labeled fields). During the + * transition period, legacy freeform changesets produce warnings (exit 0). + * Structurally invalid changesets (partial fields, bad category, over-length + * summary) always produce errors (exit 1). Use --strict to fail on legacy + * changesets. + */ + +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parseChangesetFile, validateChangeset, CATEGORIES } from "./lib/changeset-schema.mjs"; + +const STRICT = process.argv.includes("--strict"); +const CHANGESET_DIR = ".changeset"; + +/** + * Scan all .changeset/*.md files (excluding README.md) and return findings. + * @returns {{errors: string[], warnings: string[]}} + */ +export function scanChangesets(dir = CHANGESET_DIR) { + const errors = []; + const warnings = []; + + if (!existsSync(dir)) { + return { errors, warnings }; + } + + const files = readdirSync(dir).filter( + (f) => f.endsWith(".md") && f !== "README.md", + ); + + for (const file of files) { + const filePath = join(dir, file); + const raw = readFileSync(filePath, "utf8"); + const { parsed } = parseChangesetFile(raw); + + if (!parsed) { + errors.push(`${file}: empty or unparseable body`); + continue; + } + + if (parsed.legacy && !STRICT) { + warnings.push( + `${file}: legacy freeform format (no labeled fields). Expected: summary, category, dev.`, + ); + continue; + } + + if (parsed.legacy && STRICT) { + errors.push( + `${file}: legacy freeform format not allowed in --strict mode. Migrate to labeled fields (summary, category, dev).`, + ); + continue; + } + + const validation = validateChangeset(parsed); + if (!validation.valid) { + for (const err of validation.errors) { + errors.push(`${file}: ${err}`); + } + } + } + + return { errors, warnings }; +} + +export function main() { + const { errors, warnings } = scanChangesets(); + + for (const w of warnings) { + console.warn(` WARN ${w}`); + } + + if (errors.length > 0) { + for (const e of errors) { + console.error(` FAIL ${e}`); + } + console.error( + `\nChangeset format check failed. Valid categories: ${CATEGORIES.join(", ")}.`, + ); + console.error( + "Expected body format:\n summary: One-line user-facing description.\n category: \n dev: Optional developer detail.", + ); + return 1; + } + + if (warnings.length > 0) { + console.warn( + `\nChangeset format check passed with ${warnings.length} legacy warning(s).`, + ); + } + + return 0; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + process.exitCode = main(); +} diff --git a/scripts/lib/changeset-schema.mjs b/scripts/lib/changeset-schema.mjs index 827ca656a8..50cadf3d37 100644 --- a/scripts/lib/changeset-schema.mjs +++ b/scripts/lib/changeset-schema.mjs @@ -56,7 +56,7 @@ export function parseChangesetBody(body) { if (fields.summary !== undefined || fields.category !== undefined || fields.dev !== undefined) { return { summary: (fields.summary ?? "").trim(), - category: fields.category ?? "internal", + category: fields.category ?? "", dev: fields.dev?.trim() || undefined, legacy: false, };