#!/usr/bin/env node // tools/asset-catalog/scripts/lint-docs.mjs // Verifies blocks in /*.md match what // tools/asset-catalog regen-autogen would produce. Exits 1 on drift, 0 if clean. // // Strategy: copy the docs tree to a tmp dir, run regen-autogen against the copy, // then byte-compare every subsystem MD between original and regen output. This // way we never mutate the working tree during a check. // // Usage: node tools/asset-catalog/scripts/lint-docs.mjs // Exit codes: 0 success, 1 drift detected, 2 usage error import { existsSync, readFileSync, writeFileSync, mkdtempSync, mkdirSync, readdirSync, statSync, rmSync, copyFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; function printUsage() { process.stderr.write( 'Usage: lint-docs.mjs \n' + 'Exits 1 on AUTOGEN-block drift; 0 if clean; 2 on usage error.\n', ); } const arg = process.argv[2]; if (arg === '--help' || arg === '-h') { printUsage(); process.exit(0); } if (!arg) { printUsage(); process.exit(2); } if (!existsSync(arg)) { process.stderr.write(`docs-dir not found: ${arg}\n`); process.exit(2); } if (!statSync(arg).isDirectory()) { process.stderr.write(`docs-dir is not a directory: ${arg}\n`); process.exit(2); } const docsDirAbs = resolve(arg); // --------------------------------------------------------------------------- // Recursive directory copy (cross-platform; no `cp` shell-out) // --------------------------------------------------------------------------- function copyDirRecursive(src, dst) { mkdirSync(dst, { recursive: true }); for (const entry of readdirSync(src)) { const sp = join(src, entry); const dp = join(dst, entry); const st = statSync(sp); if (st.isDirectory()) copyDirRecursive(sp, dp); else if (st.isFile()) copyFileSync(sp, dp); } } function listSubsystemMds(dir) { const out = []; for (const e of readdirSync(dir).sort()) { if (e === 'asset-catalog') continue; const p = join(dir, e); const st = statSync(p); if (st.isFile() && p.endsWith('.md') && e !== 'README.md') { out.push(p); } } return out; } const tmp = mkdtempSync(join(tmpdir(), 'lint-docs-')); let exitCode = 0; try { // Copy entire docs tree (need SUBSYSTEM-MAP.json + asset-catalog/ for regen). copyDirRecursive(docsDirAbs, tmp); // Locate the CLI relative to this script (scripts/ is sibling of cli.ts). // The tool dir is the only place `tsx` is installed (per-tool node_modules) // — same Plan 02-04 fix as `pnpm catalog:client`. Invoke `pnpm exec tsx` // from the tool dir so the .bin shim resolves; pass the absolute tmp path // so the CLI doesn't need to know where docs live. const scriptDir = dirname(fileURLToPath(import.meta.url)); const toolDir = resolve(scriptDir, '..'); const isWindows = process.platform === 'win32'; const regen = spawnSync( 'pnpm', ['exec', 'tsx', 'cli.ts', 'regen-autogen', tmp], { cwd: toolDir, encoding: 'utf-8', shell: isWindows, }, ); if (regen.status !== 0) { process.stderr.write(`regen-autogen failed (exit ${regen.status}):\n`); process.stderr.write(regen.stderr ?? ''); process.exit(1); } // Byte-compare each subsystem MD between original and regen tmp. const origMds = listSubsystemMds(docsDirAbs); let drift = 0; for (const mdPath of origMds) { const rel = mdPath.slice(docsDirAbs.length + 1); const tmpPath = join(tmp, rel); if (!existsSync(tmpPath)) { process.stderr.write(`MISSING in regen: ${rel}\n`); drift++; continue; } const orig = readFileSync(mdPath, 'utf-8'); const regenContent = readFileSync(tmpPath, 'utf-8'); if (orig !== regenContent) { process.stderr.write( `DRIFT: ${rel} differs from regen-autogen output\n`, ); drift++; } } if (drift > 0) { process.stderr.write( `\n${drift} MD(s) drifted. Run \`pnpm catalog:client\` and commit.\n`, ); exitCode = 1; } else { process.stdout.write( `OK: all ${origMds.length} subsystem MDs match regen-autogen output\n`, ); exitCode = 0; } } finally { // Best-effort cleanup; don't mask a real exit code with rm errors. try { rmSync(tmp, { recursive: true, force: true }); } catch (_) { /* ignore */ } } process.exit(exitCode);