/** * README sync validation — ensures README.md reflects current code surfaces. * * Checks CLI commands, environment variables, and skills against README content. * Used by tests (CI/pre-commit) and the Stop hook (blocks session if stale). */ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { palPkg } from "./paths"; interface SyncResult { ok: boolean; issues: string[]; } /** Files that, when changed, should trigger a README check. */ export const WATCHED_PATHS = [ "src/cli/index.ts", "src/hooks/lib/paths.ts", "src/hooks/lib/inference.ts", "src/tools/youtube-analyze.ts", "assets/skills", "assets/agents", ]; /** * A flag spelling of a command the README documents under its canonical name, * or the `cli` prefix the dispatcher consumes before a command is ever named. */ function isAliasOrInternalRoute(cmd: string): boolean { return ["--help", "-h", "help", "-v", "--version", "cli"].includes(cmd); } /** Extract CLI command names from the switch statement in index.ts */ function extractCliCommands(): string[] { const pkg = palPkg(); const cliPath = resolve(pkg, "src", "cli", "index.ts"); if (!existsSync(cliPath)) return []; const content = readFileSync(cliPath, "utf-8"); const matches = content.matchAll(/case\s+"([^"]+)":/g); const commands: string[] = []; for (const match of matches) { const cmd = match[1]; if (isAliasOrInternalRoute(cmd)) continue; commands.push(cmd); } return [...new Set(commands)]; } /** Extract PAL_* env var names from paths.ts + API keys from source */ function extractEnvVars(): string[] { const pkg = palPkg(); const vars: Set = new Set(); // PAL_* from paths.ts const pathsFile = resolve(pkg, "src", "hooks", "lib", "paths.ts"); if (existsSync(pathsFile)) { const content = readFileSync(pathsFile, "utf-8"); for (const match of content.matchAll(/process\.env\.(PAL_\w+)/g)) { vars.add(match[1]); } } // PAL_ANTHROPIC_API_KEY from inference.ts const inferenceFile = resolve(pkg, "src", "hooks", "lib", "inference.ts"); if (existsSync(inferenceFile)) { const content = readFileSync(inferenceFile, "utf-8"); if (content.includes("PAL_ANTHROPIC_API_KEY")) { vars.add("PAL_ANTHROPIC_API_KEY"); } } // PAL_GEMINI_API_KEY from youtube-analyze.ts const youtubeFile = resolve(pkg, "src", "tools", "youtube-analyze.ts"); if (existsSync(youtubeFile)) { const content = readFileSync(youtubeFile, "utf-8"); if (content.includes("PAL_GEMINI_API_KEY")) { vars.add("PAL_GEMINI_API_KEY"); } } return [...vars]; } /** * Names of the skills PAL ships — one directory per skill, each holding a * SKILL.md. Reading the directory rather than loose `.md` files matters: the * folder-per-skill layout is what the runtime loads, and matching on files * silently yields nothing, which makes every skill check pass vacuously. */ export function shippedSkillNames(): string[] { const skillsDir = resolve(palPkg(), "assets", "skills"); if (!existsSync(skillsDir)) return []; return readdirSync(skillsDir, { withFileTypes: true }) .filter((e) => e.isDirectory() && existsSync(resolve(skillsDir, e.name, "SKILL.md"))) .map((e) => e.name) .sort(); } /** * Skill names the README's "## Skills" table claims PAL ships. Scoped to that * one section: other tables list commands and agents in the same row shape, and * matching them would report every command as a retired skill. */ function documentedSkillNames(readme: string): string[] { const section = /^## Skills$([\s\S]*?)(?=^## )/m.exec(readme)?.[1] ?? ""; return Array.from(section.matchAll(/^\|\s*`([a-z0-9-]+)`\s*\|/gm), (m) => m[1]); } /** Validate that README.md documents all code surfaces. */ export function validateReadmeSync(): SyncResult { const pkg = palPkg(); const readmePath = resolve(pkg, "README.md"); if (!existsSync(readmePath)) { return { ok: false, issues: ["README.md not found"] }; } const readme = readFileSync(readmePath, "utf-8"); const issues: string[] = []; // Check CLI commands for (const cmd of extractCliCommands()) { if (!readme.includes(`pal cli ${cmd}`)) { issues.push(`CLI command "${cmd}" exists in code but not documented in README`); } } // Check environment variables for (const envVar of extractEnvVars()) { if (!readme.includes(envVar)) { issues.push( `Environment variable "${envVar}" used in code but not documented in README` ); } } // Check skills — every shipped skill has a row, and no row outlives its skill const shipped = shippedSkillNames(); const undocumentedSkills = shipped.filter((name) => !readme.includes(name)); if (undocumentedSkills.length > 0) { issues.push(`Skills not documented in README: ${undocumentedSkills.join(", ")}`); } const retired = documentedSkillNames(readme).filter((name) => !shipped.includes(name)); if (retired.length > 0) { issues.push(`README documents skills that no longer ship: ${retired.join(", ")}`); } return { ok: issues.length === 0, issues }; }