validate.mjs
Source file: qa/validate.mjs. Copy it from the repository, or use the copy button on the code block.
#!/usr/bin/env node/** * Static QA for the guide. Zero dependencies. * * node qa/validate.mjs validate the whole guide * node qa/validate.mjs <dir> validate another folder (e.g. your own .claude/ tree) * * Checks: internal links + anchors, required section files, checklists, SKILL.md * frontmatter, subagent frontmatter, JSON validity, MCP client configs, Python syntax. * Exits 1 if any error is found. */import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";import { join, dirname, resolve, relative, basename, extname, sep } from "node:path";import { spawnSync } from "node:child_process";import { fileURLToPath } from "node:url";
const GUIDE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");const ROOT = resolve(process.argv[2] ?? GUIDE_ROOT);const IS_GUIDE = ROOT === GUIDE_ROOT;const IGNORE_DIRS = new Set(["node_modules", "build", ".venv", ".git", "__pycache__", "dist", ".astro"]);// Generated by scripts/sync-docs.mjs; validated by the site build (starlight-links-validator) instead.const IGNORE_PATHS = new Set([join(ROOT, "src", "content", "docs")]);
const errors = [];const warnings = [];const stats = { md: 0, links: 0, skills: 0, agents: 0, json: 0, checklists: 0, py: 0 };const rel = (p) => relative(ROOT, p).split(sep).join("/");const err = (file, msg) => errors.push(`${rel(file)}: ${msg}`);const warn = (file, msg) => warnings.push(`${rel(file)}: ${msg}`);
function walk(dir, out = []) { for (const entry of readdirSync(dir)) { const full = join(dir, entry); if (IGNORE_DIRS.has(entry) || IGNORE_PATHS.has(full)) continue; if (statSync(full).isDirectory()) walk(full, out); else out.push(full); } return out;}
// ---------- Markdown helpers ----------
/** Remove fenced code blocks (``` and ````) and inline code so their contents aren't parsed as links/headings. */function stripCode(md) { const lines = md.split(/\r?\n/); const out = []; let fence = null; for (const line of lines) { const m = line.match(/^\s*(`{3,}|~{3,})/); if (m) { if (!fence) fence = m[1]; else if (m[1][0] === fence[0] && m[1].length >= fence.length) fence = null; out.push(""); continue; } out.push(fence ? "" : line.replace(/`+[^`]*`+/g, "``")); } return out.join("\n");}
/** GitHub-style heading slug. */function slugify(text) { return text .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") .replace(/`/g, "") .trim() .toLowerCase() .replace(/[^\p{L}\p{N}\s_-]/gu, "") .replace(/\s/g, "-");}
const anchorCache = new Map();function anchorsFor(file) { if (anchorCache.has(file)) return anchorCache.get(file); const raw = readFileSync(file, "utf8"); // Headings are parsed from text with fences removed but inline code kept (it contributes to the slug). const lines = raw.split(/\r?\n/); const seen = new Map(); const anchors = new Set(); let fence = null; for (const line of lines) { const f = line.match(/^\s*(`{3,}|~{3,})/); if (f) { if (!fence) fence = f[1]; else if (f[1][0] === fence[0] && f[1].length >= fence.length) fence = null; continue; } if (fence) continue; const h = line.match(/^#{1,6}\s+(.+?)\s*#*\s*$/); if (!h) continue; const base = slugify(h[1]); const n = seen.get(base) ?? 0; anchors.add(n === 0 ? base : `${base}-${n}`); seen.set(base, n + 1); } anchorCache.set(file, anchors); return anchors;}
function checkLinks(file) { const md = stripCode(readFileSync(file, "utf8")); const linkRe = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; for (const m of md.matchAll(linkRe)) { const target = m[1]; if (/^(https?:|mailto:|tel:)/i.test(target)) continue; stats.links++; if (target.includes("\\")) { err(file, `link uses backslashes: ${target}`); continue; } const [pathPart, anchor] = target.split("#"); const dest = pathPart ? resolve(dirname(file), decodeURIComponent(pathPart)) : file; if (!existsSync(dest)) { err(file, `broken link: ${target}`); continue; } if (anchor && extname(dest).toLowerCase() === ".md" && !anchorsFor(dest).has(anchor.toLowerCase())) { err(file, `broken anchor: ${target}`); } }}
// ---------- Frontmatter (minimal YAML subset) ----------
function parseFrontmatter(text) { const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!m) return null; const lines = m[1].split(/\r?\n/); const data = {}; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (/^\s*(#|$)/.test(line) || /^\s/.test(line)) continue; const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); if (!kv) throw new Error(`cannot parse frontmatter line: "${line}"`); const [, key, restRaw] = kv; let rest = restRaw.trim(); const block = []; while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === "")) { block.push(lines[++i]); } if (/^[>|][-+]?$/.test(rest)) { const parts = block.map((l) => l.trim()); data[key] = rest.startsWith(">") ? parts.filter(Boolean).join(" ") : parts.join("\n").trim(); } else if (rest === "" && block.some((l) => /^\s+[A-Za-z0-9_-]+:/.test(l))) { data[key] = { __map: true }; } else if (rest === "" && block.some((l) => /^\s+-\s/.test(l))) { data[key] = block.filter((l) => /^\s+-\s/.test(l)).map((l) => l.replace(/^\s+-\s+/, "").trim()); } else { if (!/^["']/.test(rest)) rest = rest.replace(/\s+#.*$/, ""); rest = rest.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1"); data[key] = rest; } } return { data, body: m[2] };}
// ---------- Validators ----------
const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
function checkSkill(file) { stats.skills++; let fm; try { fm = parseFrontmatter(readFileSync(file, "utf8")); } catch (e) { return err(file, `invalid frontmatter: ${e.message}`); } if (!fm) return err(file, "SKILL.md must start with YAML frontmatter (---)"); const { name, description } = fm.data; const folder = basename(dirname(file));
if (!name) err(file, "missing `name`"); else { if (name.length > 64) err(file, `name is ${name.length} chars (max 64)`); if (!NAME_RE.test(name)) err(file, `name "${name}" must be lowercase letters, digits and single hyphens`); if (/anthropic|claude/i.test(name)) err(file, `name "${name}" contains a reserved word (anthropic/claude)`); if (name !== folder) err(file, `name "${name}" must match folder name "${folder}"`); } if (!description || typeof description !== "string") err(file, "missing `description`"); else { if (description.length > 1024) err(file, `description is ${description.length} chars (max 1024)`); if (/<[a-zA-Z][^>]*>/.test(description)) err(file, "description must not contain XML/HTML tags"); if (/^(I |I'm |You can )/.test(description)) warn(file, "description should be written in third person"); if (!/\buse (when|whenever|for|this|after|before)\b/i.test(description)) { warn(file, 'description should say WHEN to use it (e.g. "Use when…")'); } } const bodyLines = fm.body.split(/\r?\n/).length; if (bodyLines > 500) err(file, `body is ${bodyLines} lines (keep SKILL.md under 500)`); if (/\]\([^)]*\\[^)]*\)/.test(fm.body)) err(file, "use forward slashes in file references");}
function checkAgent(file) { stats.agents++; let fm; try { fm = parseFrontmatter(readFileSync(file, "utf8")); } catch (e) { return err(file, `invalid frontmatter: ${e.message}`); } if (!fm) return err(file, "subagent file must start with YAML frontmatter (---)"); const { name, description, readonly, is_background } = fm.data; if (!name) err(file, "missing `name`"); else if (!NAME_RE.test(name)) err(file, `name "${name}" must be lowercase letters, digits and hyphens`); if (!description) err(file, "missing `description`"); else if (description.length > 1024) warn(file, "description is very long; keep routing text focused"); for (const [k, v] of Object.entries({ readonly, is_background })) { if (v !== undefined && !["true", "false"].includes(v)) err(file, `\`${k}\` must be true or false`); } if (fm.body.trim().length < 50) err(file, "system prompt (body) is empty or too short");}
function checkJson(file) { stats.json++; let data; try { data = JSON.parse(readFileSync(file, "utf8")); } catch (e) { return err(file, `invalid JSON: ${e.message}`); } const isMcpConfig = /mcp|claude_desktop|claude-desktop/i.test(basename(file)) && basename(file) !== "package.json"; if (!isMcpConfig) return; if (!data.mcpServers || typeof data.mcpServers !== "object") return err(file, "MCP config must have an `mcpServers` object"); for (const [server, cfg] of Object.entries(data.mcpServers)) { if (!cfg.command && !cfg.url) err(file, `server "${server}" needs \`command\` (stdio) or \`url\` (remote)`); if (cfg.args && !Array.isArray(cfg.args)) err(file, `server "${server}": \`args\` must be an array`); if (JSON.stringify(cfg).match(/(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|xox[bp]-[A-Za-z0-9-]{10,})/)) { err(file, `server "${server}" appears to contain a hard-coded secret`); } }}
function checkChecklist(file) { stats.checklists++; const count = (readFileSync(file, "utf8").match(/^\s*- \[ \] /gm) ?? []).length; if (count < 5) err(file, `checklist has ${count} checkbox items (expected at least 5)`);}
function checkPython(files) { if (!files.length) return; const py = ["python", "python3"].find((c) => spawnSync(c, ["--version"]).status === 0); if (!py) return warnings.push("python not found; skipped Python syntax checks"); for (const f of files) { stats.py++; const r = spawnSync(py, ["-c", "import ast,sys; ast.parse(open(sys.argv[1],encoding='utf-8').read())", f]); if (r.status !== 0) err(f, `Python syntax error: ${r.stderr.toString().trim().split("\n").pop()}`); }}
// ---------- Guide structure ----------
const REQUIRED = { ".": ["README.md", "DECISIONS.md", "MASTER-CHECKLIST.md"], "00-foundations": ["01-choosing-the-right-tool.md", "02-platform-matrix.md", "03-glossary.md", "04-build-lifecycle.md"], "01-skills": ["01-concepts.md", "02-step-by-step.md", "03-writing-guide.md", "04-testing-and-evaluation.md", "05-distribution.md", "checklist.md", "templates/README.md"], "02-agents": ["01-concepts.md", "02-subagent-step-by-step.md", "03-sdk-agent-step-by-step.md", "04-design-patterns.md", "05-testing-and-evaluation.md", "checklist.md", "templates/README.md"], "03-mcp": ["01-concepts.md", "02-build-typescript.md", "03-build-python.md", "04-connect-to-clients.md", "05-tool-design.md", "06-testing-and-debugging.md", "07-security-and-production.md", "checklist.md", "templates/README.md"],};
function checkStructure() { for (const [dir, files] of Object.entries(REQUIRED)) { for (const f of files) { const p = join(ROOT, dir, f); if (!existsSync(p)) errors.push(`missing required file: ${dir}/${f}`); } } const matrix = join(ROOT, "00-foundations", "02-platform-matrix.md"); if (existsSync(matrix)) { const m = readFileSync(matrix, "utf8").match(/Last verified:\s*(\d{4}-\d{2}-\d{2})/); if (!m) errors.push("platform matrix is missing its 'Last verified: YYYY-MM-DD' line"); else { const ageDays = (Date.now() - Date.parse(m[1])) / 86_400_000; if (ageDays > 90) warnings.push(`platform matrix last verified ${Math.floor(ageDays)} days ago; re-check official sources`); } } if (existsSync(join(ROOT, "DECISIONS.md")) && !/^## \d{4}-\d{2}-\d{2}/m.test(readFileSync(join(ROOT, "DECISIONS.md"), "utf8"))) { errors.push("DECISIONS.md has no dated entries (## YYYY-MM-DD — title)"); }}
// ---------- Run ----------
const files = walk(ROOT);const isAgentFile = (f) => { const p = rel(f); if (!p.endsWith(".md") || /README\.md$|eval-template\.md$/.test(p)) return false; return /(^|\/)(\.claude|\.cursor|\.codex)\/agents\//.test(p) || /^02-agents\/templates\//.test(p);};
for (const f of files) { const name = basename(f); const ext = extname(f).toLowerCase(); if (ext === ".md") { stats.md++; checkLinks(f); if (name === "SKILL.md") checkSkill(f); else if (isAgentFile(f)) checkAgent(f); if (name === "checklist.md" || name === "MASTER-CHECKLIST.md") checkChecklist(f); } else if (ext === ".json") { checkJson(f); }}checkPython(files.filter((f) => extname(f) === ".py"));if (IS_GUIDE) checkStructure();
console.log( `QA scanned ${stats.md} markdown files, ${stats.links} internal links, ${stats.skills} skills, ` + `${stats.agents} subagents, ${stats.json} JSON files, ${stats.py} Python files, ${stats.checklists} checklists.`,);for (const w of warnings) console.log(` WARN ${w}`);for (const e of errors) console.log(` FAIL ${e}`);if (errors.length) { console.log(`\nQA FAILED with ${errors.length} error(s).`); process.exit(1);}console.log(`\nQA PASSED${warnings.length ? ` with ${warnings.length} warning(s)` : ""}.`);