#!/usr/bin/env node /** * verify-docs-flow.mjs — documentation reading-flow and consistency verifier (M2, S-206) * * Asserts: * (i) every docs/*.md file is linked from docs/README.md * (ii) the reviewer CLI matrix is consistent across the four canonical sources * (iii) the Telegram agent list matches the set of agents with helpers on disk * (iv) all repository-relative paths referenced in README.md and docs/ exist * * Exits 0 when all checks pass; exits 1 with a report when any check fails. * * Usage: * node scripts/verify-docs-flow.mjs * pnpm run verify:docs (wired via package.json) */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export const REPO_ROOT = path.resolve(__dirname, ".."); // ── (i) docs/README.md coverage ─────────────────────────────────────────── /** * Check that every *.md file under docs/ is linked from docs/README.md. * Returns an array of error strings (empty = pass). * * @param {string} repoRoot * @returns {Promise} */ export async function checkDocsIndexCoverage(repoRoot) { const docsDir = path.join(repoRoot, "docs"); const readmePath = path.join(docsDir, "README.md"); if (!fs.existsSync(readmePath)) { return ["docs/README.md is missing"]; } const readmeContent = fs.readFileSync(readmePath, "utf8"); const errors = []; // Collect all *.md files under docs/ except README.md itself const mdFiles = fs .readdirSync(docsDir) .filter((f) => f.endsWith(".md") && f !== "README.md"); for (const file of mdFiles) { // Accept both ./FILE.md and FILE.md link forms const linked = readmeContent.includes(`](${file})`) || readmeContent.includes(`](./${file})`); if (!linked) { errors.push(`docs/README.md does not link to docs/${file}`); } } return errors; } // ── (ii) Reviewer matrix consistency ────────────────────────────────────── /** * Canonical reviewer CLI names expected in workflow docs. * Order matters for the consistency check. */ const EXPECTED_REVIEWER_CLIS = ["codex", "claude", "cursor", "opencode", "pi"]; /** * Check that the reviewer CLI matrix is consistent across the four canonical * workflow docs. Returns an array of error strings (empty = pass). * * @param {string} repoRoot * @returns {Promise} */ export async function checkReviewerMatrixConsistency(repoRoot) { const canonical = [ "docs/CREATE-PLAN.md", "docs/IMPLEMENT-PLAN.md", "docs/DO-TASK.md", "docs/REVIEWERS.md", ]; const errors = []; for (const docPath of canonical) { const fullPath = path.join(repoRoot, docPath); if (!fs.existsSync(fullPath)) { errors.push(`${docPath} is missing`); continue; } const content = fs.readFileSync(fullPath, "utf8"); // Each canonical doc must reference all five CLIs for (const cli of EXPECTED_REVIEWER_CLIS) { if (!content.includes(`\`${cli}\``)) { errors.push(`${docPath}: does not mention reviewer CLI \`${cli}\``); } } } return errors; } // ── (iii) Telegram agent coverage ───────────────────────────────────────── /** * Agents that must have helpers on disk in skills/reviewer-runtime/. * Non-Pi agents use the top-level directory; Pi uses the pi/ sub-directory. */ const TELEGRAM_AGENTS = [ { id: "codex", helperPath: "skills/reviewer-runtime/notify-telegram.sh", telegramSectionKeyword: "Codex", }, { id: "claude-code", helperPath: "skills/reviewer-runtime/notify-telegram.sh", telegramSectionKeyword: "Claude Code", }, { id: "opencode", helperPath: "skills/reviewer-runtime/notify-telegram.sh", telegramSectionKeyword: "OpenCode", }, { id: "cursor", helperPath: "skills/reviewer-runtime/notify-telegram.sh", telegramSectionKeyword: "Cursor", }, { id: "pi", helperPath: "skills/reviewer-runtime/pi/notify-telegram.sh", telegramSectionKeyword: "Pi", }, ]; /** * Check that docs/TELEGRAM-NOTIFICATIONS.md documents every agent that has a * notify-telegram.sh helper on disk. Returns an array of error strings. * * @param {string} repoRoot * @returns {Promise} */ export async function checkTelegramAgentCoverage(repoRoot) { const telegramDoc = path.join(repoRoot, "docs", "TELEGRAM-NOTIFICATIONS.md"); const errors = []; if (!fs.existsSync(telegramDoc)) { return ["docs/TELEGRAM-NOTIFICATIONS.md is missing"]; } const content = fs.readFileSync(telegramDoc, "utf8"); for (const agent of TELEGRAM_AGENTS) { const helperFullPath = path.join(repoRoot, agent.helperPath); if (!fs.existsSync(helperFullPath)) { errors.push( `Telegram agent ${agent.id}: helper ${agent.helperPath} does not exist on disk` ); continue; } // The Telegram doc must mention this agent somewhere if (!content.includes(agent.telegramSectionKeyword)) { errors.push( `docs/TELEGRAM-NOTIFICATIONS.md does not document agent: ${agent.telegramSectionKeyword}` ); } } // Pi-specific: must link to PI-COMMON-REVIEWER.md if (!content.includes("PI-COMMON-REVIEWER.md")) { errors.push( "docs/TELEGRAM-NOTIFICATIONS.md must link to docs/PI-COMMON-REVIEWER.md" ); } return errors; } // ── (iv) Repo-relative path references ──────────────────────────────────── /** * Patterns to skip when checking path references — these are URLs and * non-filesystem references that look like repo paths but aren't. */ const PATH_SKIP_PATTERNS = [ /^https?:\/\//, // HTTP URLs /^\/tmp\//, // /tmp paths (runtime artifacts) /^\$\{/, // shell variable expansions /^~\//, // home directory paths /^\.\.?\//, // relative ./ or ../ — checked differently /node_modules/, // node_modules /^[A-Z_]+_[A-Z_]+$/, // environment variable names ]; /** * Extract candidate repository-relative paths from markdown text. * Looks for: * - markdown links: [text](path) * - inline code: `path` * - bare paths starting with known top-level directories * * @param {string} content * @returns {string[]} - candidate relative paths */ function extractRepoPaths(content) { const candidates = new Set(); // Markdown links: [text](path) where path does not start with http/https const linkRe = /\[(?:[^\]]*)\]\(([^)]+)\)/g; let m; while ((m = linkRe.exec(content)) !== null) { const href = m[1].split("#")[0].trim(); // strip anchors if (!href) continue; if (PATH_SKIP_PATTERNS.some((p) => p.test(href))) continue; // Normalize ./ prefix const normalized = href.startsWith("./") ? href.slice(2) : href; if (normalized.startsWith("../")) continue; // skip upward traversals candidates.add(normalized); } return [...candidates]; } /** * Check that all repository-relative paths referenced in README.md and docs/ * actually exist in the repository. Returns an array of error strings. * * @param {string} repoRoot * @returns {Promise} */ export async function checkRepoPathsExist(repoRoot) { const errors = []; const filesToCheck = [ path.join(repoRoot, "README.md"), ...fs .readdirSync(path.join(repoRoot, "docs")) .filter((f) => f.endsWith(".md")) .map((f) => path.join(repoRoot, "docs", f)), ]; for (const filePath of filesToCheck) { if (!fs.existsSync(filePath)) continue; const content = fs.readFileSync(filePath, "utf8"); const relDoc = path.relative(repoRoot, filePath); const candidates = extractRepoPaths(content); for (const candidate of candidates) { // Skip things that obviously aren't repo paths if (!candidate || candidate.length < 3) continue; // Skip paths that look like markdown-only links (heading anchors) if (candidate.startsWith("#")) continue; // For links relative to docs/, resolve relative to docs/ let candidatePath; if (relDoc.startsWith("docs/")) { candidatePath = path.join(repoRoot, "docs", candidate); // If not found there, try relative to repo root if (!fs.existsSync(candidatePath)) { candidatePath = path.join(repoRoot, candidate); } } else { candidatePath = path.join(repoRoot, candidate); } if (!fs.existsSync(candidatePath)) { errors.push(`${relDoc}: broken repo path reference → ${candidate}`); } } } return errors; } // ── Main ─────────────────────────────────────────────────────────────────── async function main() { const repoRoot = REPO_ROOT; const allErrors = []; const checks = [ ["docs/README.md coverage", checkDocsIndexCoverage], ["reviewer matrix consistency", checkReviewerMatrixConsistency], ["Telegram agent coverage", checkTelegramAgentCoverage], ["repo-relative path existence", checkRepoPathsExist], ]; for (const [label, fn] of checks) { const errors = await fn(repoRoot); if (errors.length > 0) { console.error(`\n[FAIL] ${label}:`); for (const e of errors) { console.error(` - ${e}`); } allErrors.push(...errors); } else { console.log(`[pass] ${label}`); } } if (allErrors.length > 0) { console.error(`\ndocs-flow: ${allErrors.length} error(s) found.`); process.exit(1); } else { console.log("\ndocs-flow: all checks passed."); process.exit(0); } } // Run main only when executed directly (not when imported by tests) const isMain = process.argv[1] === fileURLToPath(import.meta.url); if (isMain) { main().catch((err) => { console.error(err); process.exit(1); }); }