#!/usr/bin/env node /** * Fase 17.4.3 — CHANGELOG.md linter. * * Validates three things and exits non-zero on the first failure: * * 1. Keep-a-Changelog header line is present. * 2. Every release heading matches `## [vX.Y.Z] — YYYY-MM-DD` or the * placeholder `## [Unreleased]`. Pre-release / build-metadata * suffixes are allowed on the version (e.g. `v0.0.0-beta`, * `v1.0.0-rc.0`). * 3. Every bullet inside a release section starts with one of the * three prefixes `[new] / [fix] / [tweak]`. Bullets that introduce * a sub-grouping header (lines ending in `:`) are exempted so the * area-by-area structure of the beta backfill can use plain bullets * as section titles. * * Wired into `just check` and runnable standalone: * node scripts/check-changelog.mjs [path] * * Exit codes: * 0 — file passes all three checks * 1 — usage error (missing file) * 2 — validation failure (one or more issues printed to stderr) */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const repoRoot = resolve(__filename, '..', '..'); const target = resolve(process.argv[2] ?? resolve(repoRoot, 'CHANGELOG.md')); let raw; try { raw = readFileSync(target, 'utf8'); } catch (err) { console.error(`check-changelog: cannot read ${target}: ${err.message}`); process.exit(1); } const lines = raw.split('\n'); const errors = []; // Check 1 — Keep-a-Changelog header. if (!raw.includes('Keep a Changelog')) { errors.push('Missing Keep-a-Changelog reference in the header.'); } // Walk through lines. Track when we are inside a release section. const RELEASE_HEADING = /^## \[(.+?)\](?:\s+—\s+(.+))?$/; const VERSION_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; const DATE_RE = /^\d{4}-\d{2}-\d{2}/; const BULLET = /^[-*]\s+(.*)$/; const ALLOWED_PREFIXES = ['[new]', '[fix]', '[tweak]']; let insideRelease = false; let currentRelease = null; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const m = line.match(RELEASE_HEADING); if (m) { const [, label, rest] = m; currentRelease = label; insideRelease = true; if (label === 'Unreleased') { // no further checks continue; } if (!VERSION_RE.test(label)) { errors.push( `Line ${i + 1}: release label "${label}" does not match vX.Y.Z (semver-with-optional-prerelease).` ); } if (!rest || !DATE_RE.test(rest)) { errors.push( `Line ${i + 1}: release heading "${label}" is missing the "— YYYY-MM-DD" date segment.` ); } continue; } if (!insideRelease) continue; const bm = line.match(BULLET); if (!bm) continue; const body = bm[1].trim(); if (!body) continue; // Sub-grouping bullets end with ":" and carry no prefix (e.g. "- Auth + identity:"). if (body.endsWith(':')) continue; const ok = ALLOWED_PREFIXES.some((p) => body.toLowerCase().startsWith(p)); if (!ok) { errors.push( `Line ${i + 1} (in "${currentRelease}"): bullet missing [new]/[fix]/[tweak] prefix — "${body.slice(0, 80)}".` ); } } if (errors.length > 0) { for (const e of errors) console.error(`check-changelog: ${e}`); process.exit(2); } console.log(`check-changelog: ${target} OK`);