#!/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/.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 = ''; const END_MARKER = ''; /** * The shallowest heading depth a fragment BODY may open. * * `##` and `###` are structural in the rendered document: the generator emits * `## ` and `### . ` 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, };