#!/usr/bin/env node /** * scripts/docs-completeness.mjs — release documentation gate (TASK-0135, stage 11). * * Zero-dependency Node script. Exit 0 = all checks pass, exit 1 = at least * one check failed (stdout shows the full report either way). * * Checks: * 1. READMEs — every workspace package directory (repo root + apps/*, * packages/*, tests/* that contain a package.json) ships a non-trivial * README.md (at least one `# ` heading, >= 40 bytes). * 2. OpenAPI + API-doc coverage — docs/api/openapi.yaml exists and parses * as a real spec (top-level `paths:` with a sane path count), and every * spec path group is either documented by a docs/api/*.md page (the doc * references at least one of the group's paths, parameter tokens * normalized) or explicitly listed in KNOWN_UNDOCUMENTED_GROUPS below. * The 18 core product groups are locked: they must stay documented, and * any NEW undocumented group fails the gate (no silent drift). * 3. ROADMAP — the Phase 15 section exists with at least 5 Definition-of- * Done checkboxes. Default mode: the boxes are present and NOT all * ticked (Phase 15 still in flight). With --require-phase-15-ticked: * every box in the section must be ticked (release-complete gate). * 4. README links — the root README.md links to docs/development/ * admin-guide.md and docs/support-matrix.md. * * Usage: * node scripts/docs-completeness.mjs * node scripts/docs-completeness.mjs --require-phase-15-ticked */ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const requirePhase15Ticked = process.argv.includes("--require-phase-15-ticked"); // --- helpers --------------------------------------------------------------- let failures = 0; const results = []; function pass(msg) { results.push(` [PASS] ${msg}`); } function fail(msg) { failures += 1; results.push(` [FAIL] ${msg}`); } function read(p) { return readFileSync(join(ROOT, p), "utf8"); } function listDir(p) { const abs = join(ROOT, p); if (!existsSync(abs)) return []; return readdirSync(abs, { withFileTypes: true }) .filter((e) => e.isDirectory() && !e.name.startsWith(".") && !e.name.includes("node_modules")) .map((e) => join(p, e.name)); } function listFiles(p) { const abs = join(ROOT, p); if (!existsSync(abs)) return []; return readdirSync(abs, { withFileTypes: true }) .filter((e) => e.isFile() && !e.name.startsWith(".")) .map((e) => join(p, e.name)); } // Normalize a path so `:pageId` (docs style) and `{pageId}` (OpenAPI style) // compare equal: any segment that starts with `:` or `{` becomes `:P`. function normalizePath(p) { return p .split("/") .map((s) => (/^[{:]/.test(s) ? ":P" : s)) .join("/"); } // Candidate paths inside a markdown doc: runs of path-like tokens (the docs // write endpoints as headings `### GET \`/pages/:pageId/blocks\`` or inline). // Only characters that appear in real paths are allowed — no `"` in the // class, so this literal never fights the shell or the editor. const PATH_TOKEN = /(?:^|[\s`'"(])((?:\/[A-Za-z0-9_\-:.{}]+)+)/g; // --- 1. READMEs ------------------------------------------------------------ console.log("== 1. README completeness =="); { const packageDirs = [""].concat( ...["apps", "packages", "tests"].map((base) => listDir(base).filter((d) => existsSync(join(ROOT, d, "package.json"))) ) ); const missing = []; const thin = []; for (const d of packageDirs) { const rel = d ? `${d}/README.md` : "README.md"; if (!existsSync(join(ROOT, d, "README.md"))) { missing.push(rel); continue; } const text = read(rel); if (text.length < 40 || !/^#\s+/m.test(text)) thin.push(rel); } if (missing.length) fail(`missing README.md: ${missing.join(", ")}`); if (thin.length) fail(`README.md too thin (needs a # heading, >= 40 bytes): ${thin.join(", ")}`); if (!missing.length && !thin.length) pass(`all ${packageDirs.length} package dirs ship a real README.md`); } // --- 2. OpenAPI + API-doc coverage ----------------------------------------- console.log("== 2. OpenAPI + API-doc coverage =="); // Groups whose paths are deliberately NOT given their own docs/api page: // debug/docs/health — internal & ops surface (documented in the code // headers + deployment runbook, not the public API docs); // notifications/share/shares — real gaps on the docket (tracked in the // TASK-0135 report); they stay LOCKED here: if a group later stops being // documented it is a regression, and if a NEW group appears without docs // the gate fails — so this list can only shrink as docs land. (templates // was removed 2026-10-12: it is now documented in docs/api/pages.md.) const KNOWN_UNDOCUMENTED_GROUPS = new Set([ "debug", "docs", "health", "notifications", "share", "shares", ]); // The core product groups that MUST stay documented (locked coverage). const CORE_DOCUMENTED_GROUPS = [ "ai", "auth", "automations", "blocks", "comments", "databases", "files", "forms", "oauth", "orgs", "pages", "public", "records", "search", "sessions", "users", "webhooks", "workspaces", ]; { const specRel = "docs/api/openapi.yaml"; if (!existsSync(join(ROOT, specRel))) { fail(`missing ${specRel}`); } else { const spec = read(specRel); if (!/^paths:\s*$/m.test(spec)) { fail(`${specRel}: no top-level "paths:" key — not a valid OpenAPI document`); } else { const specPaths = [...spec.matchAll(/^ (\/\S+):/gm)].map((m) => m[1]); if (specPaths.length < 50) { fail(`${specRel}: only ${specPaths.length} top-level paths — spec looks truncated (expected >= 50)`); } else { pass(`${specRel}: ${specPaths.length} top-level paths under "paths:"`); } // Group the spec paths (first path segment). const byGroup = new Map(); for (const p of specPaths) { const g = p.replace(/^\//, "").split("/")[0]; if (!byGroup.has(g)) byGroup.set(g, new Set()); byGroup.get(g).add(normalizePath(p)); } const docFiles = listFiles("docs/api") .filter((f) => f.endsWith(".md")) .map((f) => ({ file: f, text: read(f) })); const docPaths = docFiles.map(({ file, text }) => { const found = new Set(); for (const m of text.matchAll(PATH_TOKEN)) found.add(normalizePath(m[1])); return { file, found }; }); const documented = (group) => docPaths.some(({ found }) => [...byGroup.get(group)].some((p) => found.has(p))); const uncovered = [...byGroup.keys()].filter((g) => !documented(g)); const undocumented = uncovered.filter((g) => !KNOWN_UNDOCUMENTED_GROUPS.has(g)); const unlisted = uncovered.filter((g) => KNOWN_UNDOCUMENTED_GROUPS.has(g) && documented(g)); if (undocumented.length) { fail(`spec groups with NO docs/api/*.md page (and not in KNOWN_UNDOCUMENTED_GROUPS): ${undocumented.join(", ")}`); } else { pass(`every spec path group is documented or explicitly tracked (${uncovered.length} tracked gaps: ${uncovered.join(", ")})`); } const coreUndocumented = CORE_DOCUMENTED_GROUPS.filter((g) => !documented(g)); if (coreUndocumented.length) { fail(`core product groups lost their API docs: ${coreUndocumented.join(", ")}`); } else { pass(`all ${CORE_DOCUMENTED_GROUPS.length} core product groups documented in docs/api/`); } // Stale tracking entries: a group no longer in the spec (or now // documented) should be removed from the list. const stale = [...KNOWN_UNDOCUMENTED_GROUPS].filter( (g) => !byGroup.has(g) || documented(g) ); if (stale.length) { fail(`KNOWN_UNDOCUMENTED_GROUPS is stale (documented or no longer in the spec — remove the entry): ${stale.join(", ")}`); } else { pass(`KNOWN_UNDOCUMENTED_GROUPS matches the spec (no stale entries)`); } if (unlisted.length) { fail(`tracked as undocumented but actually documented (remove from the list): ${unlisted.join(", ")}`); } } } } // --- 3. ROADMAP Phase 15 ---------------------------------------------------- console.log("== 3. ROADMAP Phase 15 Definition of Done =="); { const roadmap = read("ROADMAP.md"); const lines = roadmap.split(/\r?\n/); let start = lines.findIndex((l) => /^##\s+Phase 15\b/.test(l)); if (start < 0) { fail("ROADMAP.md: no '## Phase 15' section"); } else { let end = lines.length; for (let i = start + 1; i < lines.length; i++) { if (/^##\s+/.test(lines[i])) { end = i; break; } } const section = lines.slice(start, end); const boxes = section.filter((l) => /^\s*- \[[ xX]\]/.test(l)); const ticked = boxes.filter((l) => /^\s*- \[[xX]\]/.test(l)); if (boxes.length < 5) { fail(`ROADMAP.md Phase 15: only ${boxes.length} Definition-of-Done checkboxes (need >= 5)`); } else { pass(`ROADMAP.md Phase 15 section has ${boxes.length} Definition-of-Done checkboxes (ticked: ${ticked.length}/${boxes.length})`); } if (requirePhase15Ticked) { if (boxes.length >= 5 && ticked.length === boxes.length) { pass(`--require-phase-15-ticked: all ${boxes.length} boxes ticked`); } else { fail(`--require-phase-15-ticked: ${boxes.length - ticked.length} of ${boxes.length} boxes still unticked`); } } else if (ticked.length === boxes.length && boxes.length > 0) { fail("ROADMAP.md Phase 15 boxes are ALL ticked — run in default mode only while Phase 15 is in flight (use --require-phase-15-ticked to assert completion)"); } else { pass(`ROADMAP.md Phase 15 boxes present and not fully ticked (in flight)`); } } } // --- 4. README links --------------------------------------------------------- console.log("== 4. README links =="); { const readme = read("README.md"); const wanted = [ "docs/development/admin-guide.md", "docs/support-matrix.md", ]; const broken = wanted.filter((link) => !readme.includes(link)); const missingTargets = wanted.filter((link) => !existsSync(join(ROOT, link))); if (broken.length) fail(`README.md does not link to: ${broken.join(", ")}`); else pass("README.md links to admin-guide and support-matrix"); if (missingTargets.length) fail(`linked target missing on disk: ${missingTargets.join(", ")}`); else pass("linked doc targets exist on disk"); } // --- summary ----------------------------------------------------------------- console.log(""); for (const r of results) console.log(r); console.log(""); if (failures) { console.log(`docs-completeness: FAIL (${failures} check${failures === 1 ? "" : "s"} failed)`); process.exit(1); } else { console.log("docs-completeness: PASS (all checks green)"); process.exit(0); }