Skip to content

validate.mjs

Source file: qa/validate.mjs. Copy it from the repository, or use the copy button on the code block.

validate.mjs
#!/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)` : ""}.`);