From 4fc5f0451348fd5e631af4069f3f0ddbb0b01aed Mon Sep 17 00:00:00 2001 From: Vadim Zolotokrylin <1125014+zolotokrylin@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:32:42 +0800 Subject: [PATCH 1/4] docs(rules): configure the rules audit per repo for org-wide reuse --- rules.config.yml | 33 +++++ scripts/check-rules.mjs | 268 ++++++++++++++++++++++++---------------- 2 files changed, 193 insertions(+), 108 deletions(-) create mode 100644 rules.config.yml diff --git a/rules.config.yml b/rules.config.yml new file mode 100644 index 0000000..0d9aac3 --- /dev/null +++ b/rules.config.yml @@ -0,0 +1,33 @@ +# Per-repo settings for the shared rules audit (scripts/check-rules.mjs). +# The script is identical across repos; only this file differs. See +# holdex/wizard#1614. + +idPrefix: DEV +rulesDir: docs/rules +indexFile: docs/rules/README.md + +# Rule filenames: "id" means the file is the id (DEV-010.md); "id-slug" means +# the id is a prefix followed by a slug (HR-010-onboard.md). +filenameStyle: id + +# Whether the body repeats the frontmatter title as its H2 heading. +titleAsH2: false + +# Body section headings, at the levels this repo uses. +headingProblem: "## Problem" +headingSolution: "## Solution" +headingAcceptance: "### Acceptance Criteria" + +# Cross-rule link style: "inline" is `](./DEV-010.md)`; "reference" is a +# `[hr-010]: ./HR-010-...md` definition. +linkStyle: inline + +requiredFrontmatter: + - id + +problemMaxLength: 250 +requireAcceptance: true +noDashes: true +depsPointLower: false +checkDocsSpine: true +checkRootReadme: true diff --git a/scripts/check-rules.mjs b/scripts/check-rules.mjs index 772b412..1c50e17 100644 --- a/scripts/check-rules.mjs +++ b/scripts/check-rules.mjs @@ -1,34 +1,82 @@ #!/usr/bin/env node -// Audits the developer rules system (docs/rules/DEV-*.md) against the authoring -// rules those files themselves define, plus the docs-tree reachability that -// DEV-337 requires. Mechanical checks only; wording quality is still a human -// review. Exits non-zero (listing every failure) if any rule breaks structure, -// frontmatter, linking, or index invariants, or a doc is orphaned, so the -// pre-push hook can block the push. Run directly with `npm run check:rules`. +// Audits a rules system (docs/rules/-*.md) against the shared authoring +// standard those files define. The script is identical across every Holdex repo +// that adopts the standard; all repo-specific layout lives in rules.config.yml +// beside this file's repo root (see holdex/wizard#1614: one standard, per-repo +// adjustable settings, run locally on pre-push with CI as the safety net). +// Mechanical checks only; wording quality is still a human review. Exits +// non-zero (listing every failure) so the pre-push hook can block the push. Run +// directly with `npm run check:rules`. import { existsSync, readFileSync, readdirSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); + +// Minimal YAML reader for this config's shape only: top-level `key: value` +// scalars and `key:` blocks whose members are `- item` list entries. Scalars +// coerce to boolean, integer, or (quote-stripped) string; an empty value is +// null. This avoids a runtime dependency so the pre-push hook needs only node. +function loadConfig(path) { + const cfg = {}; + let listKey = null; + for (const raw of readFileSync(path, "utf8").split("\n")) { + const line = raw.replace(/\s+#.*$/, "").replace(/^#.*$/, ""); + if (!line.trim()) continue; + const item = line.match(/^\s+-\s+(.*)$/); + if (item && listKey) { + cfg[listKey].push(coerce(item[1])); + continue; + } + const kv = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/); + if (!kv) continue; + const [, key, value] = kv; + if (value === "") { + cfg[key] = []; + listKey = key; + } else { + cfg[key] = coerce(value); + listKey = null; + } + } + return cfg; +} +function coerce(v) { + const s = v.trim(); + if (s === "true") return true; + if (s === "false") return false; + if (s === "null" || s === "") return null; + if (/^-?\d+$/.test(s)) return Number(s); + return s.replace(/^["']|["']$/g, ""); +} + +const cfg = loadConfig(join(REPO_ROOT, "rules.config.yml")); +const RULES_DIR = join(REPO_ROOT, cfg.rulesDir); +const INDEX_FILE = join(REPO_ROOT, cfg.indexFile); const DOCS_DIR = join(REPO_ROOT, "docs"); -const RULES_DIR = join(DOCS_DIR, "rules"); -const PROBLEM_MAX = 250; // DEV-050 +const P = cfg.idPrefix; +const idRe = new RegExp(`${P}-\\d+`); +const fileRe = + cfg.filenameStyle === "id-slug" + ? new RegExp(`^${P}-\\d+-[a-z0-9-]+\\.md$`) + : new RegExp(`^${P}-\\d+\\.md$`); const errors = []; const fail = (file, msg) => errors.push(`${file}: ${msg}`); +const num = (id) => Number(id.replace(`${P}-`, "")); const files = readdirSync(RULES_DIR) - .filter((f) => /^DEV-\d+\.md$/.test(f)) + .filter((f) => fileRe.test(f)) .sort(); -const ids = new Map(); // id -> filename +const ids = new Map(); // id -> repo-relative path const deps = new Map(); // id -> [ids] for (const file of files) { - // Report every rule failure against its repo-relative path, so the message - // names the offending rule and doubles as a clickable link to it. - const ref = `docs/rules/${file}`; + // Report every failure against its repo-relative path, so the message names + // the offending rule and doubles as a clickable link to it. + const ref = `${cfg.rulesDir}/${file}`; const text = readFileSync(join(RULES_DIR, file), "utf8"); const fmMatch = text.match(/^---\n([\s\S]*?)\n---/); if (!fmMatch) { @@ -37,16 +85,21 @@ for (const file of files) { } const fm = fmMatch[1]; - const idMatch = fm.match(/^id:\s*(DEV-\d+)\s*$/m); + const idMatch = fm.match(new RegExp(`^id:\\s*"?(${P}-\\d+)"?\\s*$`, "m")); if (!idMatch) { fail(ref, "frontmatter has no id"); continue; } const id = idMatch[1]; - const base = file.replace(/\.md$/, ""); - if (id !== base) fail(ref, `id ${id} does not match filename`); + const base = + cfg.filenameStyle === "id-slug" ? file.match(new RegExp(`^(${P}-\\d+)-`))[1] : file.replace(/\.md$/, ""); + if (id !== base) fail(ref, `id ${id} does not match filename ${cfg.filenameStyle === "id-slug" ? "prefix " : ""}${base}`); ids.set(id, ref); + for (const field of cfg.requiredFrontmatter) { + if (!new RegExp(`^${field}:\\s*\\S`, "m").test(fm)) fail(ref, `frontmatter missing \`${field}\``); + } + const depMatch = fm.match(/^depends_on:\s*\[(.*?)\]\s*$/m); deps.set( id, @@ -58,125 +111,124 @@ for (const file of files) { : [], ); - // DEV-020: body opens Problem, Solution, then nested Acceptance Criteria. - if (!/^## Problem$/m.test(text)) fail(ref, "missing `## Problem`"); - if (!/^## Solution$/m.test(text)) fail(ref, "missing `## Solution`"); - if (!/^### Acceptance Criteria$/m.test(text)) - fail(ref, "missing `### Acceptance Criteria` (must nest under `###`)"); - - // DEV-050: Problem paragraph length. - const problem = text.match(/## Problem\s*\n+([\s\S]*?)\n\n/); - if (problem) { - const len = problem[1].replace(/\s+/g, " ").trim().length; - if (len > PROBLEM_MAX) fail(ref, `Problem is ${len} chars (max ${PROBLEM_MAX})`); + if (cfg.titleAsH2) { + const titleMatch = fm.match(/^title:\s*"(.+)"\s*$/m); + if (titleMatch && !text.includes(`\n## ${titleMatch[1]}\n`)) fail(ref, "body H2 does not repeat the frontmatter title"); + } + const hasHeading = (h) => new RegExp(`^${h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m").test(text); + if (!hasHeading(cfg.headingProblem)) fail(ref, `missing \`${cfg.headingProblem}\``); + if (!hasHeading(cfg.headingSolution)) fail(ref, `missing \`${cfg.headingSolution}\``); + if (cfg.requireAcceptance && !hasHeading(cfg.headingAcceptance)) + fail(ref, `missing \`${cfg.headingAcceptance}\``); + + if (cfg.problemMaxLength) { + const problem = text.match(new RegExp(`${cfg.headingProblem}\\s*\\n+([\\s\\S]*?)\\n\\n`)); + if (problem) { + const len = problem[1].replace(/\s+/g, " ").trim().length; + if (len > cfg.problemMaxLength) fail(ref, `Problem is ${len} chars (max ${cfg.problemMaxLength})`); + } } - // Writing convention: no em/en dashes. - if (/[—–]/.test(text)) fail(ref, "contains an em/en dash"); + if (cfg.noDashes && /[—–]/.test(text)) fail(ref, "contains an em/en dash"); } -// DEV-040: depends_on entries resolve. +// depends_on entries resolve, optionally point to a lower id, and form no cycles. for (const [id, list] of deps) { for (const dep of list) { if (!ids.has(dep)) fail(ids.get(id), `depends_on ${dep} does not resolve`); + else if (cfg.depsPointLower && num(dep) >= num(id)) fail(ids.get(id), `depends_on ${dep} does not point to a lower id`); } } - -// DEV-040 sanity: no dependency cycles. const inCycle = (node, seen) => seen.has(node) || (deps.get(node) || []).some((d) => inCycle(d, new Set([...seen, node]))); for (const id of deps.keys()) { if (inCycle(id, new Set())) fail(ids.get(id), `is part of a dependency cycle`); } -// DEV-040: every prose rule link resolves. +// Cross-rule links resolve, in whichever link style this repo uses. for (const file of files) { const text = readFileSync(join(RULES_DIR, file), "utf8"); - for (const m of text.matchAll(/\]\(\.\/(DEV-\d+)\.md\)/g)) { - if (!ids.has(m[1])) fail(`docs/rules/${file}`, `links ${m[1]} which does not exist`); + if (cfg.linkStyle === "inline") { + for (const m of text.matchAll(new RegExp(`\\]\\(\\.\\/(${P}-\\d+)\\.md\\)`, "g"))) + if (!ids.has(m[1])) fail(`${cfg.rulesDir}/${file}`, `links ${m[1]} which does not exist`); + } else { + for (const m of text.matchAll(new RegExp(`^\\[${P.toLowerCase()}-\\d+\\]:\\s*(\\S+)\\s*$`, "gm"))) { + const target = m[1].split("#")[0]; + if (target.endsWith(".md") && !existsSync(resolve(dirname(join(RULES_DIR, file)), target))) + fail(`${cfg.rulesDir}/${file}`, `link def points to missing ${target}`); + } } } -// Index: README lists every rule, and links no rule that does not exist. -const readme = readFileSync(join(RULES_DIR, "README.md"), "utf8"); +// Index lists every rule, and links no rule that does not exist. +const index = readFileSync(INDEX_FILE, "utf8"); for (const id of ids.keys()) { - if (!readme.includes(`[${id}]`)) fail("docs/rules/README.md", `does not list ${id}`); -} -for (const m of new Set([...readme.matchAll(/\[(DEV-\d+)\]/g)].map((x) => x[1]))) { - if (!ids.has(m)) fail("docs/rules/README.md", `links ${m} which does not exist`); + if (!index.includes(`[${id}]`)) fail(cfg.indexFile, `does not list ${id}`); } - -// DEV-337: docs/README.md indexes the tree, and every doc is reachable from the -// root README through the index SPINE only. From a README, a spine link is -// either a sibling .md in the same directory or an immediate subdirectory's -// README.md. Deeper links are cross-references, not index structure: they are -// ignored here, so a grandchild can never be reached by a shortcut from an -// ancestor, only through its own directory's README. This enforces that each -// doc is indexed by its nearest README and parents link child indexes, not -// files. -const rel = (p) => p.slice(REPO_ROOT.length + 1); - -const walkMarkdown = (dir) => { - const out = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const p = join(dir, entry.name); - if (entry.isDirectory()) out.push(...walkMarkdown(p)); - else if (entry.name.endsWith(".md")) out.push(p); - } - return out; -}; - -if (!existsSync(join(DOCS_DIR, "README.md"))) { - fail("docs/README.md", "missing docs index"); +for (const m of new Set([...index.matchAll(new RegExp(`\\[(${P}-\\d+)\\]`, "g"))].map((x) => x[1]))) { + if (!ids.has(m)) fail(cfg.indexFile, `links ${m} which does not exist`); } -const rootReadme = join(REPO_ROOT, "README.md"); -const reachable = new Set(); -const queue = [rootReadme]; -while (queue.length) { - const file = queue.shift(); - if (reachable.has(file)) continue; - reachable.add(file); - if (!existsSync(file)) continue; - const dir = dirname(file); - for (const m of readFileSync(file, "utf8").matchAll(/\]\(([^)\s]+)\)/g)) { - const target = m[1].split("#")[0]; - if (!target.endsWith(".md") || /^[a-z]+:/i.test(target)) continue; - const abs = resolve(dir, target); - const sameDir = dirname(abs) === dir; - const childIndex = basename(abs) === "README.md" && dirname(dirname(abs)) === dir; - if ((sameDir || childIndex) && !reachable.has(abs)) queue.push(abs); +// Optional: every doc is reachable from the root README through the index spine +// (a sibling .md or an immediate subdirectory's README.md). Deeper links are +// cross-references, not index structure, so a grandchild is only reached through +// its own directory's README. +if (cfg.checkDocsSpine) { + const rel = (p) => p.slice(REPO_ROOT.length + 1); + const walk = (dir) => { + const out = []; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) out.push(...walk(p)); + else if (e.name.endsWith(".md")) out.push(p); + } + return out; + }; + if (!existsSync(join(DOCS_DIR, "README.md"))) fail("docs/README.md", "missing docs index"); + const reachable = new Set(); + const queue = [join(REPO_ROOT, "README.md")]; + while (queue.length) { + const file = queue.shift(); + if (reachable.has(file)) continue; + reachable.add(file); + if (!existsSync(file)) continue; + const dir = dirname(file); + for (const m of readFileSync(file, "utf8").matchAll(/\]\(([^)\s]+)\)/g)) { + const target = m[1].split("#")[0]; + if (!target.endsWith(".md") || /^[a-z]+:/i.test(target)) continue; + const abs = resolve(dir, target); + const sameDir = dirname(abs) === dir; + const childIndex = basename(abs) === "README.md" && dirname(dirname(abs)) === dir; + if ((sameDir || childIndex) && !reachable.has(abs)) queue.push(abs); + } } + for (const md of walk(DOCS_DIR)) + if (!reachable.has(md)) fail(rel(md), "is not indexed by its directory's README (unreachable through the index spine)"); } -for (const md of walkMarkdown(DOCS_DIR)) { - if (!reachable.has(md)) - fail(rel(md), "is not indexed by its directory's README (unreachable through the index spine)"); -} - -// DEV-338: the root README opens with a title + description and documents -// Local, Stage/Preview, and Production. Its link to the docs index is DEV-337's -// concern (already enforced by the spine reachability check above), not -// re-checked here. -if (!existsSync(rootReadme)) { - fail("README.md", "repository has no root README"); -} else { - const readme = readFileSync(rootReadme, "utf8"); - const h1 = readme.match(/^#\s+\S.*$/m); - if (!h1) fail("README.md", "has no H1 title"); - else { - const afterH1 = readme.slice(readme.indexOf(h1[0]) + h1[0].length); - const description = afterH1.split(/^##\s/m)[0].replace(/^\s*(#.*)?$/gm, "").trim(); - if (!description) fail("README.md", "has no description before the first section"); - } - if (!/^##\s+(setup|installation|getting started)\b/im.test(readme)) - fail("README.md", "has no Setup / Installation section"); - for (const [label, re] of [ - ["Local", /^###\s+.*\blocal\b/im], - ["Stage/Preview", /^###\s+.*\b(stage|preview)\b/im], - ["Production", /^###\s+.*\bproduction\b/im], - ]) { - if (!re.test(readme)) fail("README.md", `Setup is missing a ${label} subsection`); +// Optional: the root README opens with a title + description and documents +// Local, Stage/Preview, and Production setup. +if (cfg.checkRootReadme) { + const rootReadme = join(REPO_ROOT, "README.md"); + if (!existsSync(rootReadme)) { + fail("README.md", "repository has no root README"); + } else { + const readme = readFileSync(rootReadme, "utf8"); + const h1 = readme.match(/^#\s+\S.*$/m); + if (!h1) fail("README.md", "has no H1 title"); + else { + const afterH1 = readme.slice(readme.indexOf(h1[0]) + h1[0].length); + const description = afterH1.split(/^##\s/m)[0].replace(/^\s*(#.*)?$/gm, "").trim(); + if (!description) fail("README.md", "has no description before the first section"); + } + if (!/^##\s+(setup|installation|getting started)\b/im.test(readme)) + fail("README.md", "has no Setup / Installation section"); + for (const [label, re] of [ + ["Local", /^###\s+.*\blocal\b/im], + ["Stage/Preview", /^###\s+.*\b(stage|preview)\b/im], + ["Production", /^###\s+.*\bproduction\b/im], + ]) + if (!re.test(readme)) fail("README.md", `Setup is missing a ${label} subsection`); } } From d6e075ad3a61b180e24b14eb8530269c4f1ad03a Mon Sep 17 00:00:00 2001 From: Vadim Zolotokrylin <1125014+zolotokrylin@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:56:20 +0800 Subject: [PATCH 2/4] docs(rules): reduce the audit config to a single id prefix --- rules.config.yml | 34 +---- scripts/check-rules.mjs | 271 +++++++++++++++++----------------------- 2 files changed, 115 insertions(+), 190 deletions(-) diff --git a/rules.config.yml b/rules.config.yml index 0d9aac3..3bfa495 100644 --- a/rules.config.yml +++ b/rules.config.yml @@ -1,33 +1,5 @@ -# Per-repo settings for the shared rules audit (scripts/check-rules.mjs). -# The script is identical across repos; only this file differs. See -# holdex/wizard#1614. +# Per-repo setting for the shared rules audit (scripts/check-rules.mjs). +# The script is identical across every repo; only the id prefix differs. +# See holdex/wizard#1614. idPrefix: DEV -rulesDir: docs/rules -indexFile: docs/rules/README.md - -# Rule filenames: "id" means the file is the id (DEV-010.md); "id-slug" means -# the id is a prefix followed by a slug (HR-010-onboard.md). -filenameStyle: id - -# Whether the body repeats the frontmatter title as its H2 heading. -titleAsH2: false - -# Body section headings, at the levels this repo uses. -headingProblem: "## Problem" -headingSolution: "## Solution" -headingAcceptance: "### Acceptance Criteria" - -# Cross-rule link style: "inline" is `](./DEV-010.md)`; "reference" is a -# `[hr-010]: ./HR-010-...md` definition. -linkStyle: inline - -requiredFrontmatter: - - id - -problemMaxLength: 250 -requireAcceptance: true -noDashes: true -depsPointLower: false -checkDocsSpine: true -checkRootReadme: true diff --git a/scripts/check-rules.mjs b/scripts/check-rules.mjs index 1c50e17..f930a3d 100644 --- a/scripts/check-rules.mjs +++ b/scripts/check-rules.mjs @@ -1,82 +1,41 @@ #!/usr/bin/env node // Audits a rules system (docs/rules/-*.md) against the shared authoring -// standard those files define. The script is identical across every Holdex repo -// that adopts the standard; all repo-specific layout lives in rules.config.yml -// beside this file's repo root (see holdex/wizard#1614: one standard, per-repo -// adjustable settings, run locally on pre-push with CI as the safety net). -// Mechanical checks only; wording quality is still a human review. Exits -// non-zero (listing every failure) so the pre-push hook can block the push. Run -// directly with `npm run check:rules`. +// standard those files define, plus the docs-tree reachability the standard +// requires. The script is identical across every Holdex repo that adopts the +// standard; the only per-repo setting is the id prefix in rules.config.yml (see +// holdex/wizard#1614). Mechanical checks only; wording quality is still a human +// review. Exits non-zero (listing every failure) if any rule breaks structure, +// frontmatter, linking, or index invariants, or a doc is orphaned, so the +// pre-push hook can block the push. Run directly with `npm run check:rules`. import { existsSync, readFileSync, readdirSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); - -// Minimal YAML reader for this config's shape only: top-level `key: value` -// scalars and `key:` blocks whose members are `- item` list entries. Scalars -// coerce to boolean, integer, or (quote-stripped) string; an empty value is -// null. This avoids a runtime dependency so the pre-push hook needs only node. -function loadConfig(path) { - const cfg = {}; - let listKey = null; - for (const raw of readFileSync(path, "utf8").split("\n")) { - const line = raw.replace(/\s+#.*$/, "").replace(/^#.*$/, ""); - if (!line.trim()) continue; - const item = line.match(/^\s+-\s+(.*)$/); - if (item && listKey) { - cfg[listKey].push(coerce(item[1])); - continue; - } - const kv = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/); - if (!kv) continue; - const [, key, value] = kv; - if (value === "") { - cfg[key] = []; - listKey = key; - } else { - cfg[key] = coerce(value); - listKey = null; - } - } - return cfg; -} -function coerce(v) { - const s = v.trim(); - if (s === "true") return true; - if (s === "false") return false; - if (s === "null" || s === "") return null; - if (/^-?\d+$/.test(s)) return Number(s); - return s.replace(/^["']|["']$/g, ""); -} - -const cfg = loadConfig(join(REPO_ROOT, "rules.config.yml")); -const RULES_DIR = join(REPO_ROOT, cfg.rulesDir); -const INDEX_FILE = join(REPO_ROOT, cfg.indexFile); const DOCS_DIR = join(REPO_ROOT, "docs"); -const P = cfg.idPrefix; -const idRe = new RegExp(`${P}-\\d+`); -const fileRe = - cfg.filenameStyle === "id-slug" - ? new RegExp(`^${P}-\\d+-[a-z0-9-]+\\.md$`) - : new RegExp(`^${P}-\\d+\\.md$`); +const RULES_DIR = join(DOCS_DIR, "rules"); +const PROBLEM_MAX = 250; // DEV-050 + +// The only per-repo setting: the rule id prefix (e.g. DEV, HR). Everything else +// is the fixed org standard, identical in every repo. +const P = readFileSync(join(REPO_ROOT, "rules.config.yml"), "utf8") + .match(/^idPrefix:\s*"?([A-Za-z]+)"?\s*$/m)[1]; const errors = []; const fail = (file, msg) => errors.push(`${file}: ${msg}`); -const num = (id) => Number(id.replace(`${P}-`, "")); const files = readdirSync(RULES_DIR) - .filter((f) => fileRe.test(f)) + .filter((f) => new RegExp(`^${P}-\\d+\\.md$`).test(f)) .sort(); -const ids = new Map(); // id -> repo-relative path +const ids = new Map(); // id -> filename const deps = new Map(); // id -> [ids] for (const file of files) { - // Report every failure against its repo-relative path, so the message names - // the offending rule and doubles as a clickable link to it. - const ref = `${cfg.rulesDir}/${file}`; + // Report every rule failure against its repo-relative path, so the message + // names the offending rule and doubles as a clickable link to it. + const ref = `docs/rules/${file}`; const text = readFileSync(join(RULES_DIR, file), "utf8"); const fmMatch = text.match(/^---\n([\s\S]*?)\n---/); if (!fmMatch) { @@ -85,21 +44,16 @@ for (const file of files) { } const fm = fmMatch[1]; - const idMatch = fm.match(new RegExp(`^id:\\s*"?(${P}-\\d+)"?\\s*$`, "m")); + const idMatch = fm.match(new RegExp(`^id:\\s*(${P}-\\d+)\\s*$`, "m")); if (!idMatch) { fail(ref, "frontmatter has no id"); continue; } const id = idMatch[1]; - const base = - cfg.filenameStyle === "id-slug" ? file.match(new RegExp(`^(${P}-\\d+)-`))[1] : file.replace(/\.md$/, ""); - if (id !== base) fail(ref, `id ${id} does not match filename ${cfg.filenameStyle === "id-slug" ? "prefix " : ""}${base}`); + const base = file.replace(/\.md$/, ""); + if (id !== base) fail(ref, `id ${id} does not match filename`); ids.set(id, ref); - for (const field of cfg.requiredFrontmatter) { - if (!new RegExp(`^${field}:\\s*\\S`, "m").test(fm)) fail(ref, `frontmatter missing \`${field}\``); - } - const depMatch = fm.match(/^depends_on:\s*\[(.*?)\]\s*$/m); deps.set( id, @@ -111,124 +65,123 @@ for (const file of files) { : [], ); - if (cfg.titleAsH2) { - const titleMatch = fm.match(/^title:\s*"(.+)"\s*$/m); - if (titleMatch && !text.includes(`\n## ${titleMatch[1]}\n`)) fail(ref, "body H2 does not repeat the frontmatter title"); - } - const hasHeading = (h) => new RegExp(`^${h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m").test(text); - if (!hasHeading(cfg.headingProblem)) fail(ref, `missing \`${cfg.headingProblem}\``); - if (!hasHeading(cfg.headingSolution)) fail(ref, `missing \`${cfg.headingSolution}\``); - if (cfg.requireAcceptance && !hasHeading(cfg.headingAcceptance)) - fail(ref, `missing \`${cfg.headingAcceptance}\``); - - if (cfg.problemMaxLength) { - const problem = text.match(new RegExp(`${cfg.headingProblem}\\s*\\n+([\\s\\S]*?)\\n\\n`)); - if (problem) { - const len = problem[1].replace(/\s+/g, " ").trim().length; - if (len > cfg.problemMaxLength) fail(ref, `Problem is ${len} chars (max ${cfg.problemMaxLength})`); - } + // Body opens Problem, Solution, then nested Acceptance Criteria. + if (!/^## Problem$/m.test(text)) fail(ref, "missing `## Problem`"); + if (!/^## Solution$/m.test(text)) fail(ref, "missing `## Solution`"); + if (!/^### Acceptance Criteria$/m.test(text)) + fail(ref, "missing `### Acceptance Criteria` (must nest under `###`)"); + + // Problem paragraph length. + const problem = text.match(/## Problem\s*\n+([\s\S]*?)\n\n/); + if (problem) { + const len = problem[1].replace(/\s+/g, " ").trim().length; + if (len > PROBLEM_MAX) fail(ref, `Problem is ${len} chars (max ${PROBLEM_MAX})`); } - if (cfg.noDashes && /[—–]/.test(text)) fail(ref, "contains an em/en dash"); + // Writing convention: no em/en dashes. + if (/[—–]/.test(text)) fail(ref, "contains an em/en dash"); } -// depends_on entries resolve, optionally point to a lower id, and form no cycles. +// depends_on entries resolve. for (const [id, list] of deps) { for (const dep of list) { if (!ids.has(dep)) fail(ids.get(id), `depends_on ${dep} does not resolve`); - else if (cfg.depsPointLower && num(dep) >= num(id)) fail(ids.get(id), `depends_on ${dep} does not point to a lower id`); } } + +// No dependency cycles. const inCycle = (node, seen) => seen.has(node) || (deps.get(node) || []).some((d) => inCycle(d, new Set([...seen, node]))); for (const id of deps.keys()) { if (inCycle(id, new Set())) fail(ids.get(id), `is part of a dependency cycle`); } -// Cross-rule links resolve, in whichever link style this repo uses. +// Every prose rule link resolves. for (const file of files) { const text = readFileSync(join(RULES_DIR, file), "utf8"); - if (cfg.linkStyle === "inline") { - for (const m of text.matchAll(new RegExp(`\\]\\(\\.\\/(${P}-\\d+)\\.md\\)`, "g"))) - if (!ids.has(m[1])) fail(`${cfg.rulesDir}/${file}`, `links ${m[1]} which does not exist`); - } else { - for (const m of text.matchAll(new RegExp(`^\\[${P.toLowerCase()}-\\d+\\]:\\s*(\\S+)\\s*$`, "gm"))) { - const target = m[1].split("#")[0]; - if (target.endsWith(".md") && !existsSync(resolve(dirname(join(RULES_DIR, file)), target))) - fail(`${cfg.rulesDir}/${file}`, `link def points to missing ${target}`); - } + for (const m of text.matchAll(new RegExp(`\\]\\(\\.\\/(${P}-\\d+)\\.md\\)`, "g"))) { + if (!ids.has(m[1])) fail(`docs/rules/${file}`, `links ${m[1]} which does not exist`); } } -// Index lists every rule, and links no rule that does not exist. -const index = readFileSync(INDEX_FILE, "utf8"); +// Index: README lists every rule, and links no rule that does not exist. +const readme = readFileSync(join(RULES_DIR, "README.md"), "utf8"); for (const id of ids.keys()) { - if (!index.includes(`[${id}]`)) fail(cfg.indexFile, `does not list ${id}`); + if (!readme.includes(`[${id}]`)) fail("docs/rules/README.md", `does not list ${id}`); +} +for (const m of new Set([...readme.matchAll(new RegExp(`\\[(${P}-\\d+)\\]`, "g"))].map((x) => x[1]))) { + if (!ids.has(m)) fail("docs/rules/README.md", `links ${m} which does not exist`); } -for (const m of new Set([...index.matchAll(new RegExp(`\\[(${P}-\\d+)\\]`, "g"))].map((x) => x[1]))) { - if (!ids.has(m)) fail(cfg.indexFile, `links ${m} which does not exist`); + +// docs README indexes the tree, and every doc is reachable from the root README +// through the index SPINE only. From a README, a spine link is either a sibling +// .md in the same directory or an immediate subdirectory's README.md. Deeper +// links are cross-references, not index structure: they are ignored here, so a +// grandchild can never be reached by a shortcut from an ancestor, only through +// its own directory's README. This enforces that each doc is indexed by its +// nearest README and parents link child indexes, not files. +const rel = (p) => p.slice(REPO_ROOT.length + 1); + +const walkMarkdown = (dir) => { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkMarkdown(p)); + else if (entry.name.endsWith(".md")) out.push(p); + } + return out; +}; + +if (!existsSync(join(DOCS_DIR, "README.md"))) { + fail("docs/README.md", "missing docs index"); } -// Optional: every doc is reachable from the root README through the index spine -// (a sibling .md or an immediate subdirectory's README.md). Deeper links are -// cross-references, not index structure, so a grandchild is only reached through -// its own directory's README. -if (cfg.checkDocsSpine) { - const rel = (p) => p.slice(REPO_ROOT.length + 1); - const walk = (dir) => { - const out = []; - for (const e of readdirSync(dir, { withFileTypes: true })) { - const p = join(dir, e.name); - if (e.isDirectory()) out.push(...walk(p)); - else if (e.name.endsWith(".md")) out.push(p); - } - return out; - }; - if (!existsSync(join(DOCS_DIR, "README.md"))) fail("docs/README.md", "missing docs index"); - const reachable = new Set(); - const queue = [join(REPO_ROOT, "README.md")]; - while (queue.length) { - const file = queue.shift(); - if (reachable.has(file)) continue; - reachable.add(file); - if (!existsSync(file)) continue; - const dir = dirname(file); - for (const m of readFileSync(file, "utf8").matchAll(/\]\(([^)\s]+)\)/g)) { - const target = m[1].split("#")[0]; - if (!target.endsWith(".md") || /^[a-z]+:/i.test(target)) continue; - const abs = resolve(dir, target); - const sameDir = dirname(abs) === dir; - const childIndex = basename(abs) === "README.md" && dirname(dirname(abs)) === dir; - if ((sameDir || childIndex) && !reachable.has(abs)) queue.push(abs); - } +const rootReadme = join(REPO_ROOT, "README.md"); +const reachable = new Set(); +const queue = [rootReadme]; +while (queue.length) { + const file = queue.shift(); + if (reachable.has(file)) continue; + reachable.add(file); + if (!existsSync(file)) continue; + const dir = dirname(file); + for (const m of readFileSync(file, "utf8").matchAll(/\]\(([^)\s]+)\)/g)) { + const target = m[1].split("#")[0]; + if (!target.endsWith(".md") || /^[a-z]+:/i.test(target)) continue; + const abs = resolve(dir, target); + const sameDir = dirname(abs) === dir; + const childIndex = basename(abs) === "README.md" && dirname(dirname(abs)) === dir; + if ((sameDir || childIndex) && !reachable.has(abs)) queue.push(abs); } - for (const md of walk(DOCS_DIR)) - if (!reachable.has(md)) fail(rel(md), "is not indexed by its directory's README (unreachable through the index spine)"); } -// Optional: the root README opens with a title + description and documents -// Local, Stage/Preview, and Production setup. -if (cfg.checkRootReadme) { - const rootReadme = join(REPO_ROOT, "README.md"); - if (!existsSync(rootReadme)) { - fail("README.md", "repository has no root README"); - } else { - const readme = readFileSync(rootReadme, "utf8"); - const h1 = readme.match(/^#\s+\S.*$/m); - if (!h1) fail("README.md", "has no H1 title"); - else { - const afterH1 = readme.slice(readme.indexOf(h1[0]) + h1[0].length); - const description = afterH1.split(/^##\s/m)[0].replace(/^\s*(#.*)?$/gm, "").trim(); - if (!description) fail("README.md", "has no description before the first section"); - } - if (!/^##\s+(setup|installation|getting started)\b/im.test(readme)) - fail("README.md", "has no Setup / Installation section"); - for (const [label, re] of [ - ["Local", /^###\s+.*\blocal\b/im], - ["Stage/Preview", /^###\s+.*\b(stage|preview)\b/im], - ["Production", /^###\s+.*\bproduction\b/im], - ]) - if (!re.test(readme)) fail("README.md", `Setup is missing a ${label} subsection`); +for (const md of walkMarkdown(DOCS_DIR)) { + if (!reachable.has(md)) + fail(rel(md), "is not indexed by its directory's README (unreachable through the index spine)"); +} + +// The root README opens with a title + description and documents Local, +// Stage/Preview, and Production. Its link to the docs index is the spine +// reachability check's concern above, not re-checked here. +if (!existsSync(rootReadme)) { + fail("README.md", "repository has no root README"); +} else { + const readme = readFileSync(rootReadme, "utf8"); + const h1 = readme.match(/^#\s+\S.*$/m); + if (!h1) fail("README.md", "has no H1 title"); + else { + const afterH1 = readme.slice(readme.indexOf(h1[0]) + h1[0].length); + const description = afterH1.split(/^##\s/m)[0].replace(/^\s*(#.*)?$/gm, "").trim(); + if (!description) fail("README.md", "has no description before the first section"); + } + if (!/^##\s+(setup|installation|getting started)\b/im.test(readme)) + fail("README.md", "has no Setup / Installation section"); + for (const [label, re] of [ + ["Local", /^###\s+.*\blocal\b/im], + ["Stage/Preview", /^###\s+.*\b(stage|preview)\b/im], + ["Production", /^###\s+.*\bproduction\b/im], + ]) { + if (!re.test(readme)) fail("README.md", `Setup is missing a ${label} subsection`); } } From 34e5acf75c9313e539d65ce96e9411a9d692aaff Mon Sep 17 00:00:00 2001 From: Vadim Zolotokrylin <1125014+zolotokrylin@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:00:24 +0800 Subject: [PATCH 3/4] docs(rules): follow reference-style links when indexing the docs spine --- scripts/check-rules.mjs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/check-rules.mjs b/scripts/check-rules.mjs index f930a3d..2c5f9fb 100644 --- a/scripts/check-rules.mjs +++ b/scripts/check-rules.mjs @@ -145,8 +145,16 @@ while (queue.length) { reachable.add(file); if (!existsSync(file)) continue; const dir = dirname(file); - for (const m of readFileSync(file, "utf8").matchAll(/\]\(([^)\s]+)\)/g)) { - const target = m[1].split("#")[0]; + const body = readFileSync(file, "utf8"); + // Follow both inline links `](target)` and reference-link definitions + // `[label]: target`, so a repo may use either link style and still index its + // tree through the same spine. + const targets = [ + ...[...body.matchAll(/\]\(([^)\s]+)\)/g)].map((m) => m[1]), + ...[...body.matchAll(/^\s*\[[^\]]+\]:\s*(\S+)/gm)].map((m) => m[1]), + ]; + for (const raw of targets) { + const target = raw.split("#")[0]; if (!target.endsWith(".md") || /^[a-z]+:/i.test(target)) continue; const abs = resolve(dir, target); const sameDir = dirname(abs) === dir; From 50358079589c5c33bdc22da9fe33a6d8004c9488 Mon Sep 17 00:00:00 2001 From: Vadim Zolotokrylin <1125014+zolotokrylin@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:20:50 +0800 Subject: [PATCH 4/4] docs(rules): cite the DEV rules each check enforces --- scripts/check-rules.mjs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/scripts/check-rules.mjs b/scripts/check-rules.mjs index 2c5f9fb..6ee4e6f 100644 --- a/scripts/check-rules.mjs +++ b/scripts/check-rules.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -// Audits a rules system (docs/rules/-*.md) against the shared authoring -// standard those files define, plus the docs-tree reachability the standard +// Audits a rules system (docs/rules/-*.md) against the authoring rules +// those files themselves define, plus the docs-tree reachability that DEV-337 // requires. The script is identical across every Holdex repo that adopts the // standard; the only per-repo setting is the id prefix in rules.config.yml (see // holdex/wizard#1614). Mechanical checks only; wording quality is still a human @@ -65,13 +65,13 @@ for (const file of files) { : [], ); - // Body opens Problem, Solution, then nested Acceptance Criteria. + // DEV-020: body opens Problem, Solution, then nested Acceptance Criteria. if (!/^## Problem$/m.test(text)) fail(ref, "missing `## Problem`"); if (!/^## Solution$/m.test(text)) fail(ref, "missing `## Solution`"); if (!/^### Acceptance Criteria$/m.test(text)) fail(ref, "missing `### Acceptance Criteria` (must nest under `###`)"); - // Problem paragraph length. + // DEV-050: Problem paragraph length. const problem = text.match(/## Problem\s*\n+([\s\S]*?)\n\n/); if (problem) { const len = problem[1].replace(/\s+/g, " ").trim().length; @@ -82,21 +82,21 @@ for (const file of files) { if (/[—–]/.test(text)) fail(ref, "contains an em/en dash"); } -// depends_on entries resolve. +// DEV-040: depends_on entries resolve. for (const [id, list] of deps) { for (const dep of list) { if (!ids.has(dep)) fail(ids.get(id), `depends_on ${dep} does not resolve`); } } -// No dependency cycles. +// DEV-040 sanity: no dependency cycles. const inCycle = (node, seen) => seen.has(node) || (deps.get(node) || []).some((d) => inCycle(d, new Set([...seen, node]))); for (const id of deps.keys()) { if (inCycle(id, new Set())) fail(ids.get(id), `is part of a dependency cycle`); } -// Every prose rule link resolves. +// DEV-040: every prose rule link resolves. for (const file of files) { const text = readFileSync(join(RULES_DIR, file), "utf8"); for (const m of text.matchAll(new RegExp(`\\]\\(\\.\\/(${P}-\\d+)\\.md\\)`, "g"))) { @@ -113,8 +113,8 @@ for (const m of new Set([...readme.matchAll(new RegExp(`\\[(${P}-\\d+)\\]`, "g") if (!ids.has(m)) fail("docs/rules/README.md", `links ${m} which does not exist`); } -// docs README indexes the tree, and every doc is reachable from the root README -// through the index SPINE only. From a README, a spine link is either a sibling +// DEV-337: docs README indexes the tree, and every doc is reachable from the +// root README through the index SPINE only. From a README, a spine link is a sibling // .md in the same directory or an immediate subdirectory's README.md. Deeper // links are cross-references, not index structure: they are ignored here, so a // grandchild can never be reached by a shortcut from an ancestor, only through @@ -168,9 +168,9 @@ for (const md of walkMarkdown(DOCS_DIR)) { fail(rel(md), "is not indexed by its directory's README (unreachable through the index spine)"); } -// The root README opens with a title + description and documents Local, -// Stage/Preview, and Production. Its link to the docs index is the spine -// reachability check's concern above, not re-checked here. +// DEV-338: the root README opens with a title + description and documents Local, +// Stage/Preview, and Production. Its link to the docs index is DEV-337's concern +// (the spine reachability check above), not re-checked here. if (!existsSync(rootReadme)) { fail("README.md", "repository has no root README"); } else {