From bdcd6035ef346b9d090caa1a0d5946641df0bb24 Mon Sep 17 00:00:00 2001 From: seekskyworld Date: Sun, 30 Aug 2026 16:20:36 +0800 Subject: [PATCH] ci: validate decision document metadata Signed-off-by: seekskyworld --- package.json | 3 +- scripts/check-docs-contract.mjs | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 scripts/check-docs-contract.mjs diff --git a/package.json b/package.json index 4f02ddbb..5b4297ad 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,9 @@ }, "scripts": { "prepublishOnly": "bun run check && bun run test", - "check": "bun run check:config-contract && bun run check:discipline && bun run format:check && bun run lint && bun run typecheck", + "check": "bun run check:config-contract && bun run check:docs-contract && bun run check:discipline && bun run format:check && bun run lint && bun run typecheck", "check:config-contract": "node scripts/check-config-contract.mjs", + "check:docs-contract": "node scripts/check-docs-contract.mjs", "check:discipline": "node scripts/check-discipline-ledger.mjs", "prepare": "node scripts/prepare-effect-tsgo.mjs", "format": "biome format --write .", diff --git a/scripts/check-docs-contract.mjs b/scripts/check-docs-contract.mjs new file mode 100644 index 00000000..53cd3e01 --- /dev/null +++ b/scripts/check-docs-contract.mjs @@ -0,0 +1,60 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +const root = resolve("docs"); +const requiredDecisionFields = [ + "decision-status", + "created", + "last-reviewed", + "applies-to", + "owner", + "related-issues", + "related-prs", + "supersedes", +]; + +function markdownFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return markdownFiles(path); + return entry.isFile() && entry.name.endsWith(".md") ? [path] : []; + }); +} + +function frontmatter(text) { + const match = text.match(/^---\n([\s\S]*?)\n---\n/); + if (!match) return null; + return new Map( + match[1] + .split("\n") + .map((line) => line.match(/^([\w-]+):\s*(.*)$/)) + .filter(Boolean) + .map(([, key, value]) => [key, value.trim()]), + ); +} + +const files = markdownFiles(root); +const decisions = files.filter( + (file) => + file.includes(`${join("docs", "decisions")}${"/"}`) && + !["README.md", "TEMPLATE.md"].includes(file.split("/").pop()), +); +const errors = []; + +for (const file of decisions) { + const metadata = frontmatter(readFileSync(file, "utf8")); + if (!metadata) { + errors.push(`${relative(process.cwd(), file)}: missing YAML frontmatter`); + continue; + } + for (const field of requiredDecisionFields) { + if (!metadata.get(field)) errors.push(`${relative(process.cwd(), file)}: missing ${field}`); + } +} + +if (errors.length) { + console.error(errors.join("\n")); + process.exit(1); +} + +console.log(`docs contract (${decisions.length} decision records)`);