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:
2026-05-19 01:11:57 +02:00
parent 427fe33c9c
commit cb223a0c29
3 changed files with 207 additions and 1 deletions

109
scripts/check-changelog.mjs Executable file
View 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`);