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.
178 lines
5.0 KiB
JavaScript
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) };
|
|
}
|