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:
3
.github/workflows/pr-checks.yml
vendored
3
.github/workflows/pr-checks.yml
vendored
@@ -44,6 +44,9 @@ jobs:
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Changeset format
|
||||
run: pnpm check:changesets
|
||||
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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",
|
||||
|
||||
186
scripts/__tests__/check-changeset-format.test.mjs
Normal file
186
scripts/__tests__/check-changeset-format.test.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
104
scripts/check-changeset-format.mjs
Normal file
104
scripts/check-changeset-format.mjs
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user