#!/usr/bin/env bun /** * docs-check — verify every non-excluded `.ts(x)` implementation file in a * package's src/ tree has a co-located sibling `.md` meeting CLAUDE.md rules: * * - Required headings (H2 or H3): Purpose, Features, Usage (or Basic Example * + Advanced Example), API Reference, Implementation Notes * - >=200 words excluding fenced code blocks * * Usage: bun scripts/docs-check.ts [] (defaults to cwd) * Exits 0 on full pass, 1 on any violation. */ import { readdirSync, readFileSync, statSync } from "node:fs"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; type Violation = { file: string; reason: string }; const EXCLUDE_FILE = [ /\.test\.tsx?$/, /\.spec\.tsx?$/, /\.stories\.tsx?$/, /\.bench\.ts$/, /\.d\.ts$/, ]; const REQUIRED_HEADINGS: { label: string; test: (h: string[]) => boolean }[] = [ { label: "Purpose", test: (h) => h.some((x) => /^purpose\b/.test(x)) }, { label: "Features", test: (h) => h.some((x) => /^features\b/.test(x)) }, { label: "Usage (basic + advanced)", test: (h) => { const hasBasic = h.some((x) => /basic\s+example/.test(x)); const hasAdvanced = h.some((x) => /advanced\s+example/.test(x)); const hasUsage = h.some((x) => /^usage\b/.test(x)); return (hasBasic && hasAdvanced) || hasUsage; }, }, { label: "API Reference", test: (h) => h.some((x) => /api\s+reference/.test(x)), }, { label: "Implementation Notes", test: (h) => h.some((x) => /implementation\s+notes/.test(x)), }, ]; function walk(dir: string, out: string[] = []): string[] { let entries: string[]; try { entries = readdirSync(dir); } catch { return out; } for (const name of entries) { if (name === "node_modules" || name === "dist" || name === "coverage") continue; const p = join(dir, name); const st = statSync(p); if (st.isDirectory()) walk(p, out); else out.push(p); } return out; } function isExcluded(file: string, srcRoot: string): boolean { const name = basename(file); if (EXCLUDE_FILE.some((rx) => rx.test(name))) return true; const rel = relative(srcRoot, file); const parts = rel.split(sep); if (parts[0] === "constants") return true; if (parts.some((p) => p === "__benchmarks__" || p === "__fixtures__")) return true; if (name === "index.ts" || name === "index.tsx") return true; if (/\.types\.tsx?$/.test(name)) { if (parts[0] === "types" && parts.length === 2) return false; return true; } // *.schemas.ts — co-located Zod schemas are pure data-shape definitions, // semantically equivalent to *.types.ts. Documented via the sibling feature // module's .md (e.g. decay.md covers decay.ts AND decay.schemas.ts). if (/\.schemas\.tsx?$/.test(name)) return true; return false; } function stripCodeBlocks(md: string): string { return md.replace(/```[\s\S]*?```/g, "").replace(/~~~[\s\S]*?~~~/g, ""); } function extractHeadings(md: string): string[] { const out: string[] = []; for (const line of md.split(/\r?\n/)) { const m = /^(#{2,3})\s+(.+?)\s*#*\s*$/.exec(line); if (m) out.push(m[2].toLowerCase().trim()); } return out; } function wordCount(text: string): number { const matches = text.match(/\b[\p{L}\p{N}][\p{L}\p{N}'_-]*\b/gu); return matches ? matches.length : 0; } function validateMarkdown(mdPath: string): string[] { const raw = readFileSync(mdPath, "utf8"); const stripped = stripCodeBlocks(raw); const words = wordCount(stripped); const headings = extractHeadings(raw); const issues: string[] = []; for (const req of REQUIRED_HEADINGS) { if (!req.test(headings)) issues.push(`missing section: ${req.label}`); } if (words < 200) issues.push(`word count ${words} < 200 (excl. code blocks)`); return issues; } function main(): number { const pkgDir = resolve(process.argv[2] ?? "."); const srcRoot = join(pkgDir, "src"); let files: string[]; try { statSync(srcRoot); files = walk(srcRoot); } catch { console.log(`[docs-check] no src/ at ${pkgDir} - skipping`); return 0; } const candidates = files.filter( (f) => /\.tsx?$/.test(f) && !/\.d\.ts$/.test(f) && !isExcluded(f, srcRoot) ); const violations: Violation[] = []; let passed = 0; for (const ts of candidates) { const md = ts.replace(/\.tsx?$/, ".md"); const rel = relative(pkgDir, ts); try { statSync(md); } catch { violations.push({ file: rel, reason: "missing sibling .md" }); console.log(` MISS ${rel} - missing sibling .md`); continue; } const issues = validateMarkdown(md); if (issues.length > 0) { for (const issue of issues) { violations.push({ file: rel, reason: issue }); console.log(` FAIL ${rel} - ${issue}`); } } else { passed++; } } const total = candidates.length; const pct = total === 0 ? 100 : Math.round((passed / total) * 100); const pkgName = basename(dirname(srcRoot)) || basename(pkgDir); console.log( `[${pkgName}] Docs coverage: ${passed}/${total} files (${pct}%)` ); return violations.length === 0 ? 0 : 1; } process.exit(main());