feat(fase-17): CHANGELOG.md backfill + Keep-a-Changelog linter
Adds repo-root CHANGELOG.md in Keep-a-Changelog format with a single [v0.0.0-beta] section that catalogues every shipped feature across Fases 0–16 (58 bullets, [new]/[fix]/[tweak] prefixes, grouped by area). The companion linter at scripts/check-changelog.mjs enforces (a) the Keep-a-Changelog header, (b) the `## [vX.Y.Z] — YYYY-MM-DD` heading shape on every release, and (c) the three-prefix rule on every bullet. Wired into `just check` so a malformed CHANGELOG fails the check gate without blocking commit hooks. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
109
scripts/check-changelog.mjs
Executable file
109
scripts/check-changelog.mjs
Executable file
@@ -0,0 +1,109 @@
|
||||
#!/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`);
|
||||
Reference in New Issue
Block a user