feat(changelog): U2 — changeset format linter + CI wiring

Add scripts/check-changeset-format.mjs:
- Validates structured changeset schema across .changeset/*.md
- Legacy changesets warn (exit 0) in transition mode
- Structured changesets with missing/invalid fields error (exit 1)
- --strict flag fails on legacy changesets

Wire into package.json (check:changesets, pretest, test:gate) and
pr-checks.yml lint job. Fix parser to not default category on
structured changesets missing the field.

10 linter tests + 18 schema tests all passing.
This commit is contained in:
gsxdsm
2026-06-23 23:30:32 -07:00
parent 5676d95fd0
commit 8317684aff
5 changed files with 298 additions and 4 deletions

View File

@@ -44,6 +44,9 @@ jobs:
- name: Lint
run: pnpm lint
- name: Changeset format
run: pnpm check:changesets
typecheck:
name: Typecheck
runs-on: ubuntu-latest

View File

@@ -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",

View File

@@ -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 });
}
});

View File

@@ -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: <one of: " +
CATEGORIES.join(", ") + ">\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();
}

View File

@@ -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,
};