* 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>
837 lines
32 KiB
JavaScript
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,
|
|
};
|