Files
fusion/scripts/lib/changeset-schema.mjs
gsxdsm 8317684aff 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.
2026-06-23 23:30:32 -07:00

178 lines
5.0 KiB
JavaScript

/*
* FNXC:Changelog 2026-06-24-14:30:
* Structured changeset body schema. Each changeset body uses labeled fields
* (summary, category, dev) instead of freeform paragraphs. The `summary` is
* the only content that flows into end-user release notes by default. The
* `dev` field is preserved in per-package CHANGELOGs but excluded from
* distilled release notes. Legacy freeform changesets are detected and
* flagged so the linter can warn during the transition period.
*/
/** Maximum character length for the `summary` field. */
export const MAX_SUMMARY_LENGTH = 120;
/** Valid category values, in display order for release notes grouping. */
export const CATEGORIES = [
"feature",
"fix",
"breaking",
"security",
"performance",
"internal",
];
/** Human-readable headings for each category in release notes. */
export const CATEGORY_HEADINGS = {
feature: "New",
fix: "Fixed",
breaking: "Breaking",
security: "Security",
performance: "Performance",
internal: "Internal",
};
/**
* Parse labeled fields from a changeset body.
*
* The body format is:
* summary: One-line user-facing description.
* category: feature
* dev: Optional developer detail (can span multiple lines).
*
* If no labeled fields are found, the entire body is treated as legacy
* content: the first non-empty line becomes `summary`, and `category`
* defaults to `internal` with `legacy: true`.
*
* @param {string} body - The changeset body (after frontmatter).
* @returns {{summary: string, category: string, dev?: string, legacy: boolean} | null}
*/
export function parseChangesetBody(body) {
if (!body || !body.trim()) {
return null;
}
const fields = extractLabeledFields(body);
if (fields.summary !== undefined || fields.category !== undefined || fields.dev !== undefined) {
return {
summary: (fields.summary ?? "").trim(),
category: fields.category ?? "",
dev: fields.dev?.trim() || undefined,
legacy: false,
};
}
// Legacy freeform: first non-empty line is the summary.
const firstLine = body
.split(/\r?\n/)
.map((l) => l.trim())
.find((l) => l.length > 0);
if (!firstLine) {
return null;
}
return {
summary: firstLine,
category: "internal",
legacy: true,
};
}
/**
* Extract `key: value` labeled fields from the changeset body.
* `dev` allows multi-line content until the next labeled field or EOF.
* Returns an empty object if no labeled fields are found.
*/
function extractLabeledFields(body) {
const knownLabels = ["summary", "category", "dev"];
const lines = body.split(/\r?\n/);
const fields = {};
let i = 0;
while (i < lines.length) {
const line = lines[i];
const match = line.match(/^(\w+):\s*(.*)$/);
if (match && knownLabels.includes(match[1])) {
const label = match[1];
const value = match[2];
if (label === "dev") {
// Multi-line: collect subsequent non-labeled lines.
const devLines = [value];
i += 1;
while (i < lines.length) {
const nextLine = lines[i];
const nextMatch = nextLine.match(/^(\w+):\s*(.*)$/);
if (nextMatch && knownLabels.includes(nextMatch[1])) {
break;
}
devLines.push(nextLine);
i += 1;
}
fields.dev = devLines.join("\n").trim();
} else {
fields[label] = value.trim();
i += 1;
}
} else {
i += 1;
}
}
return fields;
}
/**
* Validate a parsed changeset against the schema.
* Returns errors for missing required fields, invalid categories,
* or over-length summaries.
*
* @param {{summary: string, category: string, dev?: string, legacy: boolean}} parsed
* @returns {{valid: boolean, errors: string[]}}
*/
export function validateChangeset(parsed) {
const errors = [];
if (parsed.legacy) {
return { valid: true, errors: [] };
}
if (!parsed.summary) {
errors.push("missing required `summary` field");
} else if (parsed.summary.length > MAX_SUMMARY_LENGTH) {
errors.push(
`\`summary\` exceeds max length (${parsed.summary.length}/${MAX_SUMMARY_LENGTH} chars)`,
);
}
if (!parsed.category) {
errors.push("missing required `category` field");
} else if (!CATEGORIES.includes(parsed.category)) {
errors.push(
`invalid \`category\` value "${parsed.category}"; valid values: ${CATEGORIES.join(", ")}`,
);
}
return { valid: errors.length === 0, errors };
}
/**
* Parse a full changeset markdown file (frontmatter + body).
* Splits on the `---` delimited frontmatter and parses the body.
*
* @param {string} raw - Full file contents.
* @returns {{frontmatter: string, body: string, parsed: object|null}}
*/
export function parseChangesetFile(raw) {
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!fmMatch) {
return { frontmatter: "", body: raw, parsed: parseChangesetBody(raw) };
}
const frontmatter = fmMatch[1];
const body = fmMatch[2];
return { frontmatter, body, parsed: parseChangesetBody(body) };
}