179 lines
5.5 KiB
TypeScript
179 lines
5.5 KiB
TypeScript
#!/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 [<package-dir>] (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());
|