Files
msd-core/scripts/gen-features.cjs
Tom Boucher 308c17505c fix(#3840): reject a malformed feature order instead of coercing it (#3851)
* fix(#3840): reject a malformed feature `order` instead of coercing it

`scripts/gen-features.cjs` was the one field validated by coercion rather than
by shape. `Number('')` is 0, and `0x10`, `0b11`, `0o17`, `1e3`, `1.` and `.5`
all coerce to finite numbers, so a fragment declaring a bare `order:` sorted to
position 0 -- ahead of every real feature, in both the body and the generated
table of contents -- with zero violations, a clean `--check` and `--write`
exiting 0. That is a fail-open in a gate whose entire contract is a typed
violation rather than a silent guess.

`order` is now shape-checked against an optionally-signed decimal literal
before coercion, mirroring how ID_RE guards `id`. The finite check stays: the
regex alone would admit a literal long enough to overflow to Infinity.

Surfaced by re-running the feature-implementation directive's design and QA
steps against the code merged in #3845, which shipped without them.

Refs #3840

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3840): backfill changeset PR number

Refs #3840

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 08:36:33 -04:00

837 lines
32 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* Generates `docs/FEATURES.md` — BOTH its table of contents and every feature
* section body — from per-feature fragments under `docs/features/`.
*
* WHY THIS EXISTS (#3840). `docs/FEATURES.md` was hand-maintained, and every
* feature PR had to write into TWO shared mutable cells in it: the `### N.`
* heading (whose integer was hand-allocated at authoring time, so concurrent
* PRs all picked the same next number) and the hand-maintained table of
* contents (which collides even between PRs that picked DIFFERENT numbers).
* #3831 was renumbered 165 -> 166 -> 167 -> 168 across successive rebases, the
* last collision landing mid-verification; because every rebase invalidates the
* sha-keyed pass marker, each collision also cost a full remote matrix run.
*
* The fix is the pattern this repo already uses twice for exactly this problem
* (`.changeset/` for CHANGELOG.md, `tests/emitted-drift-acks/` for #2914): one
* file per contribution, consolidated by a generator. A contributor adds ONE
* new file under `docs/features/` and touches no shared file, so there is
* nothing to collide on. `docs/FEATURES.md` itself becomes a DERIVED artifact:
* when two branches both regenerate it the conflict is resolved by re-running
* `--write`, not by hand-renumbering and re-editing a TOC.
*
* NUMBER ALLOCATION. `id` is declared in the fragment's own frontmatter and is
* FROZEN once merged, so inbound `#N-slug` anchors from other docs keep
* resolving. It does NOT have to be contiguous or maximal — the corpus already
* skips 58, 113 and 131 and carries the non-integer ids `6.5`, `27a` and `27b`.
* The only rule is uniqueness, which `--check` enforces with a typed violation
* rather than leaving a human to notice. Because any unique id is legal, an
* author can use their issue number and never revisit the choice after a
* rebase: the number-chase is gone, not merely serialized.
*
* GROUPS ARE DERIVED TOO. A fragment names its `group` (the `##` heading) and
* groups are ordered by their lowest-ordered member, so there is no shared
* group list to edit either. Optional per-group prose lives in
* `docs/features/_groups/<slug>.md`, which a feature-adding PR never touches.
*
* Invariants enforced (see CONTRIBUTING.md "Adding a feature to
* `docs/FEATURES.md`"):
* 1. Every fragment declares `id`, `title` and `group` in its frontmatter.
* 2. Ids are unique across the corpus, and so are the anchors they generate.
* 3. A fragment body never opens a heading at depth <= 3 — `##` would forge a
* group and `###` would forge a sibling section, both silently.
* 4. Every `_groups/` note names a group that some fragment is a member of.
* 5. Every inbound `FEATURES.md#anchor` link from the repo's own markdown
* resolves to a heading this generator emits.
* 6. The committed `docs/FEATURES.md` equals the generated one.
*
* Invariant 5 is what makes the number freeze REAL rather than a promise. This
* repo has no link checker, so broken anchors shipped silently: the migration
* found `FEATURES.md#runtime-identity` (never a heading) and
* `#143-spec-phase-edge-completeness-probe` (off by one) already live on `next`.
* Since a fragment can now change its own `id` in a one-line edit, an unchecked
* anchor would be a much easier thing to break than it was to break by hand.
*
* Usage:
* node scripts/gen-features.cjs # print the generated region to stdout
* node scripts/gen-features.cjs --write # rewrite the region in docs/FEATURES.md
* node scripts/gen-features.cjs --check # exit 1 if stale or invalid
* node scripts/gen-features.cjs --json # --check semantics; JSON report on stdout
* node scripts/gen-features.cjs --write --force # write despite violations
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.resolve(__dirname, '..');
const FEATURES_DIR = path.join(ROOT, 'docs', 'features');
const GROUP_NOTES_DIR = path.join(FEATURES_DIR, '_groups');
const FEATURES_PATH = path.join(ROOT, 'docs', 'FEATURES.md');
const START_MARKER =
'<!-- FEATURES:START — generated by scripts/gen-features.cjs; do not edit by hand -->';
const END_MARKER = '<!-- FEATURES:END -->';
/**
* The shallowest heading depth a fragment BODY may open.
*
* `##` and `###` are structural in the rendered document: the generator emits
* `## <group>` and `### <id>. <title>` itself, so a body line at either depth
* would forge a group or a sibling section that has no fragment, no id and no
* TOC entry — and would do it silently, because markdown renders it fine.
* `####` and deeper nest INSIDE a section and are always legal.
*/
const MIN_BODY_HEADING_DEPTH = 4;
/**
* Stable reason codes for every violation this gate can emit.
*
* Tests assert `assert.equal(v.reason, REASON.X)` over the `--json`
* `violations` array rather than regex-matching stderr prose — see
* CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs".
*/
const REASON = Object.freeze({
FILENAME_INVALID: 'filename_invalid',
FRONTMATTER_MISSING: 'frontmatter_missing',
FIELD_MISSING: 'field_missing',
FIELD_UNKNOWN: 'field_unknown',
ID_INVALID: 'id_invalid',
ID_DUPLICATE: 'id_duplicate',
ORDER_INVALID: 'order_invalid',
ANCHOR_DUPLICATE: 'anchor_duplicate',
BODY_EMPTY: 'body_empty',
BODY_HEADING_TOO_SHALLOW: 'body_heading_too_shallow',
GROUP_NOTE_ORPHAN: 'group_note_orphan',
GROUP_NOTE_DUPLICATE: 'group_note_duplicate',
INBOUND_ANCHOR_UNRESOLVED: 'inbound_anchor_unresolved',
BODY_FORGES_REGION_MARKER: 'body_forges_region_marker',
DIRENT_NOT_REGULAR_FILE: 'dirent_not_regular_file',
DIRENT_UNREADABLE: 'dirent_unreadable',
});
/**
* Substrings a fragment body may never contain.
*
* `spliceIntoFeatures` finds the region boundaries by scanning `docs/FEATURES.md`
* for these markers. A fragment that PLANTS one gets it rendered verbatim into
* the generated region, where it becomes an earlier (or later) match than the
* real boundary on the NEXT run — so a subsequent `--write` splices against the
* forged boundary and silently freezes everything past it as "hand-authored",
* a corruption that survives deleting the offending fragment. Fragments arrive
* through fork PRs, so this is attacker-reachable input, not a typo class.
*
* Defence is in depth: this rejects the forgery at the source, and
* `spliceIntoFeatures` independently anchors on the LAST end marker so a
* marker that reaches the document some other way still cannot shrink the
* region it governs.
*/
const FORBIDDEN_BODY_SUBSTRINGS = Object.freeze(['<!-- FEATURES:START', '<!-- FEATURES:END']);
/** Which forbidden marker (if any) `body` contains. */
function forgedRegionMarker(body) {
return FORBIDDEN_BODY_SUBSTRINGS.find((marker) => String(body).includes(marker)) || null;
}
/**
* Directories the inbound-anchor scan walks, relative to the repo root.
*
* Deliberately does NOT include `.planning/`, `node_modules/`, or anything
* outside version control. Locale subtrees under `docs/` ARE walked, but their
* links are filtered by RESOLVED TARGET (below), so `docs/ja-JP/x.md` pointing
* at its own sibling `docs/ja-JP/FEATURES.md` is correctly ignored — those
* translations carry different section counts and are not in this gate's scope.
*/
const LINK_SCAN_DIRS = Object.freeze(['docs']);
const LINK_SCAN_ROOT_FILES = Object.freeze(['README.md', 'CONTRIBUTING.md', 'CONTEXT.md']);
/** `[text](path/FEATURES.md#anchor)` — the only inbound form this gate checks. */
const INBOUND_LINK_RE = /\(([^()\s]*FEATURES\.md)#([A-Za-z0-9._-]+)\)/g;
/** Frontmatter fields a feature fragment may declare. */
const FRAGMENT_FIELDS = Object.freeze(['id', 'title', 'group', 'order']);
/** Frontmatter fields a group-note fragment may declare. */
const GROUP_NOTE_FIELDS = Object.freeze(['group']);
/**
* A legal feature id: an integer, optionally followed by a single lowercase
* letter (`27a`) or a single decimal part (`6.5`). All three shapes are LIVE in
* the corpus today — freezing the migrated numbers required accepting them, and
* the letter/decimal forms are exactly how a feature gets inserted between two
* already-published numbers without renumbering either.
*/
const ID_RE = /^(?:0|[1-9][0-9]*)(?:\.[0-9]+|[a-z])?$/;
/**
* A legal explicit `order`: an optionally-signed decimal literal.
*
* `order` was the only field validated by COERCION rather than by shape, and
* `Number()` is far more liberal than a docs ordering field has reason to be:
* `Number('')` and `Number(' ')` are 0, and `0x10`, `0b11`, `0o17`, `1e3`, `1.`
* and `.5` all coerce to finite numbers. A fragment declaring `order:` with
* nothing after it therefore sorted to position 0 — ahead of every real
* feature — with no violation and exit 0, in a gate whose whole contract is a
* typed violation rather than a silent guess. Shape first, then coerce,
* mirroring how ID_RE guards `id`.
*/
const ORDER_RE = /^[+-]?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/;
/** The fragment filename shape: a kebab slug, mirroring `docs/adr/`'s rule. */
const FRAGMENT_FILENAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
const FRONTMATTER_KEY_RE = /^[a-z][a-z0-9_-]*$/;
/**
* GitHub's heading-anchor algorithm, as applied to the FULL rendered heading
* text (`27b. Existing Codebase Onboarding` -> `27b-existing-codebase-onboarding`).
*
* Deriving the anchor from the same string that is rendered is the whole point:
* a hand-written TOC could disagree with its heading and nothing would notice,
* which is how `docs/FEATURES.md` came to be missing TOC entries for `6.5`,
* `27a` and every section from 163 up. Here the two cannot diverge.
*
* Reproduces GitHub's rule: lowercase, drop everything that is not a word
* character, a space or a hyphen, then map spaces to hyphens. Note the drops
* happen BEFORE the space->hyphen mapping, which is why `Token Count & Git`
* yields the double hyphen in `#152-statusline-token-count--git-segment`.
*/
function slugify(headingText) {
return String(headingText)
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s/g, '-');
}
/**
* Render a frontmatter value.
*
* JSON-quoted only when a bare form would not survive the round trip: an empty
* value, one with significant leading/trailing whitespace, one that would be
* mistaken for a quoted value, or one carrying a newline (which would forge a
* second frontmatter line). Everything else stays bare so the common case
* reads as ordinary YAML.
*/
function renderScalar(value) {
const s = String(value);
if (s === '' || s !== s.trim() || s.startsWith('"') || /[\r\n]/.test(s)) return JSON.stringify(s);
return s;
}
/** Inverse of `renderScalar`. */
function parseScalar(raw) {
if (raw.startsWith('"')) {
try {
const parsed = JSON.parse(raw);
if (typeof parsed === 'string') return parsed;
} catch {
// Fall through: an unparseable quoted-looking value is taken literally
// rather than crashing the whole corpus scan on one bad fragment.
}
}
return raw;
}
/**
* Serialize `{key: string}` into a fragment's `---`-delimited frontmatter block
* plus body. The exact inverse of `parseFrontmatter`; the pair is property
* tested for bijectivity.
*/
function renderFrontmatter(data, body) {
const lines = ['---'];
for (const [key, value] of Object.entries(data)) lines.push(`${key}: ${renderScalar(value)}`);
lines.push('---', '');
return `${lines.join('\n')}\n${body}`;
}
/**
* Split fragment text into `{data, body}`.
*
* Returns `data: null` when the document does not open with a `---` fence — the
* caller turns that into a typed FRONTMATTER_MISSING violation rather than
* guessing at a body-only fragment.
*/
function parseFrontmatter(text) {
const normalized = String(text).replace(/\r\n/g, '\n');
if (!normalized.startsWith('---\n')) return { data: null, body: normalized };
const end = normalized.indexOf('\n---\n', 3);
if (end === -1) return { data: null, body: normalized };
const data = {};
for (const line of normalized.slice(4, end + 1).split('\n')) {
if (line.trim() === '') continue;
const sep = line.indexOf(':');
if (sep === -1) continue;
const key = line.slice(0, sep).trim();
if (!FRONTMATTER_KEY_RE.test(key)) continue;
data[key] = parseScalar(line.slice(sep + 1).trim());
}
// `+5` clears "\n---\n"; the blank line renderFrontmatter emits after the
// closing fence is consumed here so body text starts at its first real line.
let body = normalized.slice(end + 5);
if (body.startsWith('\n')) body = body.slice(1);
return { data, body };
}
/**
* Line numbers (1-based, relative to `body`) of every heading opened at a depth
* shallower than `MIN_BODY_HEADING_DEPTH`, ignoring fenced code blocks.
*
* Fence tracking is not decoration: several migrated bodies embed shell and
* markdown samples, and a `## ` inside a fenced sample is content, not a
* forged group heading. Both ``` and ~~~ fences are honored, and a fence only
* closes on a marker of the same character at least as long as the opener —
* the CommonMark rule, so a ```` ``` ```` inside a ```` ```` ```` block does
* not end it.
*/
function shallowBodyHeadings(body) {
const hits = [];
let fenceChar = null;
let fenceLen = 0;
String(body)
.split('\n')
.forEach((line, i) => {
const fence = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
if (fence) {
const [char, len] = [fence[1][0], fence[1].length];
if (fenceChar === null) {
fenceChar = char;
fenceLen = len;
return;
}
if (char === fenceChar && len >= fenceLen && line.slice(fence[0].length).trim() === '') {
fenceChar = null;
fenceLen = 0;
}
return;
}
if (fenceChar !== null) return;
const heading = /^(#{1,6})\s/.exec(line);
if (heading && heading[1].length < MIN_BODY_HEADING_DEPTH) {
hits.push({ line: i + 1, depth: heading[1].length });
}
});
return hits;
}
/**
* Default sort key for a fragment that declares no explicit `order`: the id's
* numeric part. `27a` and `6.5` therefore land where a reader expects without
* anyone writing an `order`; only a fragment whose frozen position CONTRADICTS
* its number (`27b` precedes `27a` in the published document) needs one.
*/
function defaultOrder(id) {
return Number.parseFloat(id);
}
/** Every `*.md` directly under a directory, sorted, tolerating a missing dir. */
function markdownFilesIn(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.name.endsWith('.md'))
.map((e) => ({ name: e.name, regular: e.isFile() && !e.isSymbolicLink() }))
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}
/**
* Whether a `docs/features/` entry may be READ at all.
*
* `readdirSync(..., {withFileTypes:true})` reports dirent types WITHOUT
* following symlinks (it is `lstat`-shaped), so `isFile()` is already false for
* a symlink — but relying on that alone reads as an accident. This states the
* rule outright: only a regular file is a fragment.
*
* The threat is concrete. A fork PR can commit `docs/features/evil.md` as a
* symlink to any path the process can read (`~/.ssh/id_rsa`, a CI secret file,
* anything outside the repo). The generator INLINES a fragment's bytes into the
* committed `docs/FEATURES.md`, so following one link would exfiltrate that
* file's contents into a public document on the next `regen:derived`. Refusing
* non-regular entries is what keeps the fragment corpus to files a reviewer can
* actually see in the diff.
*/
function refuseNonRegular(entry, rel, add) {
if (entry.regular) return false;
add(REASON.DIRENT_NOT_REGULAR_FILE, rel, {});
return true;
}
/**
* Read every fragment and group note off disk into the intermediate
* representation the renderer and the validator both consume.
*
* `--json` exposes this IR's violations, and the test suite asserts against the
* IR rather than against rendered markdown text (CONTRIBUTING.md forbids
* grepping generated output): the parse result is the contract, the markdown is
* one projection of it.
*/
function readCorpus() {
const violations = [];
const add = (reason, file, detail) => violations.push({ reason, file, ...detail });
const fragments = [];
for (const entry of markdownFilesIn(FEATURES_DIR)) {
const name = entry.name;
const rel = path.posix.join('docs/features', name);
if (!FRAGMENT_FILENAME_RE.test(name)) {
add(REASON.FILENAME_INVALID, rel, {});
continue;
}
if (refuseNonRegular(entry, rel, add)) continue;
let text;
try {
text = fs.readFileSync(path.join(FEATURES_DIR, name), 'utf8');
} catch {
add(REASON.DIRENT_UNREADABLE, rel, {});
continue;
}
const { data, body } = parseFrontmatter(text);
if (data === null) {
add(REASON.FRONTMATTER_MISSING, rel, {});
continue;
}
for (const key of Object.keys(data)) {
if (!FRAGMENT_FIELDS.includes(key)) add(REASON.FIELD_UNKNOWN, rel, { field: key });
}
let missing = false;
for (const field of ['id', 'title', 'group']) {
if (!data[field]) {
add(REASON.FIELD_MISSING, rel, { field });
missing = true;
}
}
if (missing) continue;
if (!ID_RE.test(data.id)) {
add(REASON.ID_INVALID, rel, { id: data.id });
continue;
}
let order = defaultOrder(data.id);
if (data.order !== undefined) {
order = Number(data.order);
// Both guards are load-bearing: the regex rejects the shapes `Number`
// would silently accept, and `isFinite` still catches a well-shaped
// literal long enough to overflow to Infinity.
if (!ORDER_RE.test(data.order) || !Number.isFinite(order)) {
add(REASON.ORDER_INVALID, rel, { order: data.order });
continue;
}
}
const trimmed = body.replace(/\s+$/, '');
if (trimmed === '') {
add(REASON.BODY_EMPTY, rel, {});
continue;
}
for (const hit of shallowBodyHeadings(trimmed)) {
add(REASON.BODY_HEADING_TOO_SHALLOW, rel, { line: hit.line, depth: hit.depth });
}
const forged = forgedRegionMarker(trimmed);
if (forged !== null) {
add(REASON.BODY_FORGES_REGION_MARKER, rel, { marker: forged });
continue;
}
fragments.push({
file: rel,
id: data.id,
title: data.title,
group: data.group,
order,
explicitOrder: data.order !== undefined,
body: trimmed,
anchor: slugify(`${data.id}. ${data.title}`),
});
}
const notes = new Map();
for (const entry of markdownFilesIn(GROUP_NOTES_DIR)) {
const name = entry.name;
const rel = path.posix.join('docs/features/_groups', name);
if (refuseNonRegular(entry, rel, add)) continue;
let text;
try {
text = fs.readFileSync(path.join(GROUP_NOTES_DIR, name), 'utf8');
} catch {
add(REASON.DIRENT_UNREADABLE, rel, {});
continue;
}
const { data, body } = parseFrontmatter(text);
if (data === null) {
add(REASON.FRONTMATTER_MISSING, rel, {});
continue;
}
for (const key of Object.keys(data)) {
if (!GROUP_NOTE_FIELDS.includes(key)) add(REASON.FIELD_UNKNOWN, rel, { field: key });
}
if (!data.group) {
add(REASON.FIELD_MISSING, rel, { field: 'group' });
continue;
}
if (notes.has(data.group)) {
add(REASON.GROUP_NOTE_DUPLICATE, rel, { group: data.group });
continue;
}
const trimmed = body.replace(/\s+$/, '');
if (trimmed === '') {
add(REASON.BODY_EMPTY, rel, {});
continue;
}
const forgedNote = forgedRegionMarker(trimmed);
if (forgedNote !== null) {
add(REASON.BODY_FORGES_REGION_MARKER, rel, { marker: forgedNote });
continue;
}
notes.set(data.group, { file: rel, group: data.group, body: trimmed });
}
return { fragments, notes, violations };
}
/** Every `*.md` under `dir`, recursively, as repo-relative POSIX paths. */
function markdownFilesUnder(dir) {
const found = [];
const walk = (abs) => {
let entries;
try {
entries = fs.readdirSync(abs, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const joined = path.join(abs, e.name);
if (e.isDirectory()) walk(joined);
else if (e.isFile() && e.name.endsWith('.md')) {
found.push(path.relative(ROOT, joined).split(path.sep).join('/'));
}
}
};
walk(path.join(ROOT, dir));
return found.sort();
}
/**
* Every inbound `docs/FEATURES.md#anchor` reference in the repo's own markdown,
* with the anchors this generator actually emits subtracted.
*
* Links are matched by RESOLVED TARGET, not by textual prefix: a link is only
* checked when the path it names resolves to `docs/FEATURES.md` itself. That is
* what keeps the locale trees out of scope without a hardcoded skip list —
* `docs/ja-JP/README.md` linking `FEATURES.md#…` resolves to
* `docs/ja-JP/FEATURES.md` and is left alone.
*/
function checkInboundAnchors(emittedAnchors) {
const violations = [];
const files = [
...LINK_SCAN_DIRS.flatMap((d) => markdownFilesUnder(d)),
...LINK_SCAN_ROOT_FILES.filter((f) => fs.existsSync(path.join(ROOT, f))),
];
for (const rel of files) {
let text;
try {
text = fs.readFileSync(path.join(ROOT, rel), 'utf8');
} catch {
violations.push({ reason: REASON.DIRENT_UNREADABLE, file: rel });
continue;
}
const dir = path.posix.dirname(rel);
for (const m of text.matchAll(INBOUND_LINK_RE)) {
const target = path.posix.normalize(path.posix.join(dir, m[1]));
if (target !== 'docs/FEATURES.md') continue;
if (!emittedAnchors.has(m[2])) {
violations.push({ reason: REASON.INBOUND_ANCHOR_UNRESOLVED, file: rel, anchor: m[2] });
}
}
}
return violations;
}
/**
* Corpus-wide checks that need every fragment in hand, plus the assembled
* group/section ordering the renderer walks.
*
* Sections sort by `order`, ties broken by id string, so the arrangement is a
* total order that does not depend on readdir sequence. Groups sort by their
* lowest-ordered member — which is what removes the last shared list: nobody
* has to edit a group registry to add a release bucket.
*/
function buildCorpus() {
const { fragments, notes, violations } = readCorpus();
const byId = new Map();
for (const f of fragments) {
const prior = byId.get(f.id);
if (prior) violations.push({ reason: REASON.ID_DUPLICATE, file: f.file, id: f.id, first: prior.file });
else byId.set(f.id, f);
}
const byAnchor = new Map();
for (const f of fragments) {
const prior = byAnchor.get(f.anchor);
if (prior && prior.id !== f.id) {
violations.push({ reason: REASON.ANCHOR_DUPLICATE, file: f.file, anchor: f.anchor, first: prior.file });
} else if (!prior) {
byAnchor.set(f.anchor, f);
}
}
const grouped = new Map();
for (const f of fragments) {
if (!grouped.has(f.group)) grouped.set(f.group, []);
grouped.get(f.group).push(f);
}
for (const note of notes.values()) {
if (!grouped.has(note.group)) {
violations.push({ reason: REASON.GROUP_NOTE_ORPHAN, file: note.file, group: note.group });
}
}
const bySection = (a, b) => a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
const groups = [...grouped.entries()]
.map(([title, sections]) => {
sections.sort(bySection);
const note = notes.get(title);
return {
title,
anchor: slugify(title),
order: sections[0].order,
note: note ? note.body : null,
sections,
};
})
.sort((a, b) => a.order - b.order || (a.title < b.title ? -1 : a.title > b.title ? 1 : 0));
const emitted = new Set([...groups.map((g) => g.anchor), ...fragments.map((f) => f.anchor)]);
violations.push(...checkInboundAnchors(emitted));
return { fragments, groups, notes, anchors: emitted, violations };
}
/** Human one-liners for the `--check` stderr report, keyed off the typed reason. */
function describeViolation(v) {
switch (v.reason) {
case REASON.FILENAME_INVALID:
return `${v.file}: filename must be a kebab-case slug, e.g. runtime-identity.md`;
case REASON.FRONTMATTER_MISSING:
return `${v.file}: missing the '---' frontmatter block`;
case REASON.FIELD_MISSING:
return `${v.file}: frontmatter is missing required field '${v.field}'`;
case REASON.FIELD_UNKNOWN:
return `${v.file}: unknown frontmatter field '${v.field}'`;
case REASON.ID_INVALID:
return `${v.file}: id '${v.id}' is not an integer, integer+letter (27a) or decimal (6.5)`;
case REASON.ID_DUPLICATE:
return `${v.file}: id '${v.id}' is already used by ${v.first} — pick another (any unique id is legal)`;
case REASON.ORDER_INVALID:
return `${v.file}: order '${v.order}' is not a decimal number (an optionally-signed integer or decimal)`;
case REASON.ANCHOR_DUPLICATE:
return `${v.file}: anchor '#${v.anchor}' collides with ${v.first}`;
case REASON.BODY_EMPTY:
return `${v.file}: body is empty`;
case REASON.BODY_HEADING_TOO_SHALLOW:
return `${v.file}: body line ${v.line} opens an h${v.depth}; use h${MIN_BODY_HEADING_DEPTH} or deeper inside a feature`;
case REASON.GROUP_NOTE_ORPHAN:
return `${v.file}: names group '${v.group}', which no fragment belongs to`;
case REASON.GROUP_NOTE_DUPLICATE:
return `${v.file}: a second note for group '${v.group}'`;
case REASON.INBOUND_ANCHOR_UNRESOLVED:
return `${v.file}: links docs/FEATURES.md#${v.anchor}, which no heading provides`;
case REASON.BODY_FORGES_REGION_MARKER:
return `${v.file}: body contains the generated-region marker '${v.marker}'`;
case REASON.DIRENT_NOT_REGULAR_FILE:
return `${v.file}: not a regular file (a symlink is never followed)`;
case REASON.DIRENT_UNREADABLE:
return `${v.file}: unreadable`;
default:
return `${v.file}: ${v.reason}`;
}
}
/** Render the generated region: the table of contents, then every section. */
function renderFeatures(corpus) {
const out = [START_MARKER, '', '## Table of Contents', ''];
for (const g of corpus.groups) {
out.push(`- [${g.title}](#${g.anchor})`);
for (const s of g.sections) out.push(` - [${s.title}](#${s.anchor})`);
}
for (const g of corpus.groups) {
out.push('', '---', '', `## ${g.title}`, '');
if (g.note) out.push(g.note, '');
g.sections.forEach((s, i) => {
if (i > 0) out.push('---', '');
out.push(`### ${s.id}. ${s.title}`, '', s.body, '');
});
}
out.push(
'---',
'',
'_Generated by `scripts/gen-features.cjs` — add a fragment under `docs/features/` and run `--write`._',
'',
END_MARKER,
);
return out.join('\n');
}
/**
* Replace the generated region of `doc` with `region`.
*
* The end boundary is the LAST occurrence, not the first. With `indexOf`, a
* forged `<!-- FEATURES:END -->` anywhere inside the region would become the de
* facto boundary and everything after it would be preserved as if hand-authored
* — permanently, since the next run splices against the same forged marker.
* `lastIndexOf` makes the true trailing marker win, so the generated region can
* only ever GROW to swallow a forgery, never shrink to be governed by one.
* `FORBIDDEN_BODY_SUBSTRINGS` rejects the forgery upstream; this is the second
* layer, and it also covers a marker that reached the document by hand.
*/
function spliceIntoFeatures(doc, region) {
const start = doc.indexOf(START_MARKER);
const end = doc.lastIndexOf(END_MARKER);
if (start === -1 || end === -1) {
throw new ExitError(
1,
`docs/FEATURES.md is missing the generated-region markers.\nExpected:\n ${START_MARKER}\n ${END_MARKER}\n`,
);
}
return doc.slice(0, start) + region + doc.slice(end + END_MARKER.length);
}
/**
* Parse CLI flags. FAIL-CLOSED on an unrecognized flag: falling through to the
* no-flags "print the region" behavior would mask a typo (`--wirte`) as a clean
* run. Mirrors `scripts/gen-adr-index.cjs`.
*/
function parseArgs(argv) {
const opts = { write: false, check: false, json: false, force: false };
for (const arg of argv) {
if (arg === '--write') opts.write = true;
else if (arg === '--check') opts.check = true;
else if (arg === '--json') opts.json = true;
else if (arg === '--force') opts.force = true;
else {
throw new ExitError(
1,
`unknown flag: ${arg}\nRecognized flags: --write, --check, --json, --force.`,
);
}
}
return opts;
}
function main() {
const { write, check, json, force } = parseArgs(process.argv.slice(2));
const corpus = buildCorpus();
const { violations } = corpus;
const region = renderFeatures(corpus);
if (json) {
// `--json` implies `--check` semantics but always computes BOTH facts
// (violations AND staleness) rather than short-circuiting, so a consumer
// gets the complete picture in one shot.
const doc = fs.readFileSync(FEATURES_PATH, 'utf8');
const stale = spliceIntoFeatures(doc, region) !== doc;
const ok = violations.length === 0 && !stale;
process.stdout.write(
JSON.stringify({
ok,
featureCount: corpus.fragments.length,
groupCount: corpus.groups.length,
indexStale: stale,
violations,
}) + '\n',
);
return ok ? 0 : 1;
}
// FAIL-CLOSED. `--write` used to render the region regardless, warning only
// on stderr and exiting 0 — so a `--write && git commit` chain would happily
// commit a docs/FEATURES.md carrying two colliding `### 7.` sections. Every
// other gate in this repo refuses rather than degrades, and a generator that
// emits a known-corrupt artifact is worse than one that emits none: the
// corruption is what gets reviewed. `--force` remains for the deliberate
// "write it anyway so I can see what it looks like" case, and says so.
if (violations.length > 0 && !(write && force)) {
process.stderr.write(
`docs/features/ has ${violations.length} fragment violation(s).\n` +
'See CONTRIBUTING.md "Adding a feature to `docs/FEATURES.md`" for the contract.\n' +
(write ? 'Refusing to write a corrupt docs/FEATURES.md; pass --force to override.\n' : '') +
'\n',
);
for (const v of violations) process.stderr.write(` ✗ ${describeViolation(v)}\n`);
process.stderr.write('\n');
throw new ExitError(1);
}
// `--write` takes precedence over a co-supplied `--check`, matching
// gen-adr-index.cjs: the flags are documented to be used one at a time.
if (write) {
const doc = fs.readFileSync(FEATURES_PATH, 'utf8');
fs.writeFileSync(FEATURES_PATH, spliceIntoFeatures(doc, region));
process.stdout.write(
`Wrote ${corpus.fragments.length} features in ${corpus.groups.length} groups into ${FEATURES_PATH}.\n`,
);
if (violations.length > 0) {
// Only reachable under --force; the guard above rejects otherwise.
process.stderr.write(
`\n${violations.length} violation(s) written anyway under --force — --check will fail:\n\n`,
);
for (const v of violations) process.stderr.write(` ✗ ${describeViolation(v)}\n`);
}
} else if (check) {
const doc = fs.readFileSync(FEATURES_PATH, 'utf8');
if (spliceIntoFeatures(doc, region) !== doc) {
process.stderr.write(
'docs/FEATURES.md is stale. Run:\n node scripts/gen-features.cjs --write\n\n',
);
throw new ExitError(1);
}
process.stdout.write(
`docs/FEATURES.md is up to date (${corpus.fragments.length} features, ${corpus.groups.length} groups).\n`,
);
} else {
process.stdout.write(region + '\n');
}
}
// Guarded: the test suite requires this module for its pure parser/renderer, so
// loading it must not also run the generator.
if (require.main === module) runMain(main);
module.exports = {
REASON,
MIN_BODY_HEADING_DEPTH,
FRAGMENT_FIELDS,
START_MARKER,
END_MARKER,
slugify,
parseFrontmatter,
renderFrontmatter,
shallowBodyHeadings,
forgedRegionMarker,
FORBIDDEN_BODY_SUBSTRINGS,
defaultOrder,
checkInboundAnchors,
buildCorpus,
renderFeatures,
spliceIntoFeatures,
describeViolation,
};