import { existsSync as __existsSyncForDocs } from "node:fs"; import { resolve as __resolveForDocs } from "node:path"; // Module-level docs context, initialized by initDocsContext() before any // other function in this file is called. The repo root + submodule list // are passed via the context provided to registerDocsCommand. let REPO_ROOT = ""; let SUBMODULES: readonly string[] = []; export function initDocsContext(opts: { repoRoot: string; submodules: readonly string[] }): void { REPO_ROOT = opts.repoRoot; SUBMODULES = opts.submodules; } function submodulePath(name: string): string { return __resolveForDocs(REPO_ROOT, name); } function isSubmoduleInitialized(name: string): boolean { return __existsSyncForDocs(__resolveForDocs(REPO_ROOT, name, ".git")); } import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; import { resolveBinName } from "../core/config.ts"; import { readDocStatusFromText } from "./docs-frontmatter.ts"; /** * Regenerates index READMEs in docs/audits/ and docs/issues/ directories. * Idempotent: safe to run on every commit. Reads the first ~30 lines of * each dated file to extract title and (for issues) status. * * Targets: the host root, every initialized git submodule, and first-level * in-tree packages that keep `docs/audits` or `docs/issues` without being * git submodules. Marker rules are unchanged. * * Preserves hand-written preambles. The command updates only the section * between `` and `` markers. If a * README exists without markers, the command refuses to overwrite it and * reports it as `needs-markers` so the human can insert them explicitly. * New READMEs (no file yet) are created with a minimal default preamble. */ export interface IndexOpts { dryRun?: boolean; repo?: string; } export type IndexStatus = "unchanged" | "updated" | "needs-markers" | "created"; export interface IndexResult { path: string; // the README being regenerated status: IndexStatus; before: string; after: string; // what would be written (identical to before if status=unchanged or needs-markers) } interface DatedEntry { file: string; // e.g. "2026-04-11_merge-dup-key.md" date: string; // YYYY-MM-DD title: string; // first H1 or filename-derived status?: string; // for issues } const DATED_FILE_PATTERN = /^(\d{4}-\d{2}-\d{2})_([a-z0-9][a-z0-9_-]*)\.md$/i; function extractTitle(content: string, fallbackSlug: string): string { const h1Match = content.match(/^#\s+(.+)$/m); if (h1Match) return h1Match[1]!.trim(); // Fallback: slug → Title Case return fallbackSlug.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } export function extractStatus(content: string): string | undefined { return readDocStatusFromText(content, "issue") ?? undefined; } function readEntries(dir: string, includeStatus: boolean): DatedEntry[] { if (!existsSync(dir)) return []; const entries: DatedEntry[] = []; for (const f of readdirSync(dir)) { if (f === "README.md") continue; const m = f.match(DATED_FILE_PATTERN); if (!m) continue; const [, date, slug] = m; let content = ""; try { content = readFileSync(join(dir, f), "utf8"); } catch { continue; } entries.push({ file: f, date: date!, title: extractTitle(content, slug!), status: includeStatus ? extractStatus(content) : undefined, }); } // Newest first entries.sort((a, b) => b.date.localeCompare(a.date)); return entries; } function renderAuditsTable(entries: DatedEntry[]): string { if (entries.length === 0) return "_No audits recorded._\n"; const lines = ["| Date | File | Description |", "|------|------|-------------|"]; for (const e of entries) { lines.push(`| ${e.date} | [${e.file}](${e.file}) | ${e.title} |`); } return `${lines.join("\n")}\n`; } function renderIssuesTable(entries: DatedEntry[]): string { if (entries.length === 0) return "_No issues recorded._\n"; const lines = [ "| Date | File | Status | Description |", "|------|------|--------|-------------|", ]; for (const e of entries) { const status = e.status ?? "unknown"; lines.push(`| ${e.date} | [${e.file}](${e.file}) | ${status} | ${e.title} |`); } return `${lines.join("\n")}\n`; } const BEGIN_MARKER = ""; const END_MARKER = ""; function defaultPreamble(kind: "audits" | "issues"): string { const regen = `The table below is regenerated by \`${resolveBinName()} docs index\`; do not hand-edit it. Add prose above the \`BEGIN INDEX\` marker instead.`; return kind === "audits" ? [ "# Audit Documents", "", "Immutable, date-stamped reports. File names follow `YYYY-MM-DD_.md`.", "", regen, "", ].join("\n") : [ "# Issues", "", "Date-stamped post-mortems and investigations. File names follow `YYYY-MM-DD_.md`.", "Each file carries lifecycle status in leading YAML frontmatter.", "", regen, "", ].join("\n"); } /** Replace ONLY the section between the markers. Never touch content outside them. */ function spliceBetweenMarkers(existing: string, body: string): string { const beginIdx = existing.indexOf(BEGIN_MARKER); const endIdx = existing.indexOf(END_MARKER); if (beginIdx < 0 || endIdx < 0 || endIdx < beginIdx) { // Caller should have checked hasMarkers() first; return unchanged as a safe fallback. return existing; } const before = existing.slice(0, beginIdx + BEGIN_MARKER.length); const after = existing.slice(endIdx); return `${before}\n${body}${after}`; } function hasMarkers(existing: string): boolean { return existing.includes(BEGIN_MARKER) && existing.includes(END_MARKER); } function buildNewReadme(kind: "audits" | "issues", body: string): string { return `${defaultPreamble(kind)}\n${BEGIN_MARKER}\n${body}${END_MARKER}\n`; } const SKIP_NESTED_DIR_NAMES = new Set(["node_modules", "dist", "target"]); function hasIndexableDocs(path: string): boolean { return existsSync(join(path, "docs", "audits")) || existsSync(join(path, "docs", "issues")); } /** Root + initialized submodules + first-level in-tree packages with docs indexes. */ export function collectIndexTargets(): { name: string; path: string }[] { const byPath = new Map(); const add = (name: string, path: string) => { byPath.set(path, { name, path }); }; add("(root)", REPO_ROOT); for (const name of SUBMODULES) { if (!isSubmoduleInitialized(name)) continue; add(name, submodulePath(name)); } if (!existsSync(REPO_ROOT)) return [...byPath.values()]; for (const ent of readdirSync(REPO_ROOT, { withFileTypes: true })) { if (!ent.isDirectory() && !ent.isSymbolicLink()) continue; if (ent.name.startsWith(".")) continue; if (SKIP_NESTED_DIR_NAMES.has(ent.name)) continue; if (isSubmoduleInitialized(ent.name)) continue; const child = join(REPO_ROOT, ent.name); if (!hasIndexableDocs(child)) continue; add(ent.name, child); } return [...byPath.values()]; } export async function runIndex(opts: IndexOpts): Promise { const targets = collectIndexTargets(); const filter = opts.repo === "." ? "(root)" : opts.repo; const filtered = filter ? targets.filter((t) => t.name === filter) : targets; const results: IndexResult[] = []; for (const { name: _name, path } of filtered) { for (const kind of ["audits", "issues"] as const) { const dir = join(path, "docs", kind); if (!existsSync(dir)) continue; const entries = readEntries(dir, kind === "issues"); const body = kind === "audits" ? renderAuditsTable(entries) : renderIssuesTable(entries); const readmePath = join(dir, "README.md"); const exists = existsSync(readmePath); const before = exists ? readFileSync(readmePath, "utf8") : ""; const displayPath = relative(REPO_ROOT, readmePath); let status: IndexStatus; let after: string; if (!exists) { after = buildNewReadme(kind, body); status = "created"; } else if (!hasMarkers(before)) { // Hands off: the human-written README has no markers. Don't touch it. after = before; status = "needs-markers"; } else { after = spliceBetweenMarkers(before, body); status = before === after ? "unchanged" : "updated"; } results.push({ path: displayPath, status, before, after }); if (!opts.dryRun && (status === "updated" || status === "created")) { writeFileSync(readmePath, after, "utf8"); } } } return results; }