#!/usr/bin/env bun /** * generate-package-module-tree — for every workspace package, inject a * recursive "Documentation tree" section into the package README and into * every parent module .md that has descendant .mds. * * The section is enclosed in: * * * ## Documentation tree * * - [](./relative/path.md) — first-paragraph description * - ... * * * On rerun, the markers are found and the body is replaced in place. If * markers don't exist, the block is inserted before the first `## ` heading * (or appended at the end if none). * * The walk descends from each package's `src/` directory. For each direct * subdirectory it finds the canonical `.md` (`.md` ⟶ `README.md` ⟶ * single-md fallback). That .md becomes the child link. Recursion continues * inside the subdirectory: any sub-subdirectories with their own canonical * .mds become children of the previously discovered .md, and so on to leaves. * * Usage: * bun scripts/generate-package-module-tree.ts # rewrite * bun scripts/generate-package-module-tree.ts --check # exit 1 on drift * bun scripts/generate-package-module-tree.ts --verbose */ import { existsSync, readdirSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; import { CONFIG } from "./lib/config.js"; import { discoverPackageDirs } from "./lib/discover-packages.js"; import { findCanonicalMd, firstBodyParagraph, isDirectorySafe, relForLink, replaceMarkedSection, tryRead, } from "./lib/doc-tree.js"; const ROOT = CONFIG.paths.root; const APPS_ROOT = join(ROOT, "apps"); const OPENSPEC_SPECS = join(ROOT, "openspec", "specs"); interface ModuleNode { /** Display name (the directory name). */ name: string; /** Absolute path to the canonical `.md`. */ mdPath: string; /** First-paragraph description (already truncated). */ description: string; /** Recursive children — modules whose canonical .md lives under this one's dir. */ children: ModuleNode[]; } function listSubdirs(dir: string): string[] { let entries: ReturnType; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return []; } return entries .filter((e) => e.isDirectory()) .map((e) => e.name) .filter( (n) => !n.startsWith(".") && n !== "node_modules" && n !== "dist" ) .sort(); } function listDirectMds(dir: string, exclude: ReadonlySet): string[] { let entries: ReturnType; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return []; } const out: string[] = []; for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.endsWith(".md")) continue; if (entry.name.endsWith(".types.md")) continue; if (entry.name === "README.md") continue; if (exclude.has(entry.name)) continue; out.push(entry.name); } out.sort(); return out; } function buildModuleTree(dir: string): ModuleNode[] { const out: ModuleNode[] = []; const claimed = new Set(); for (const sub of listSubdirs(dir)) { const subDir = join(dir, sub); const md = findCanonicalMd(subDir, sub); if (!md) { // Subdir without a canonical doc — attach its leaves at this level. const grand = buildModuleTree(subDir); for (const node of grand) out.push(node); continue; } const content = tryRead(md) ?? ""; const description = firstBodyParagraph(content); const children = buildModuleTree(subDir); out.push({ name: sub, mdPath: md, description, children }); } // Pick up flat `.md` files directly in this directory (covers packages // like `@suaveplan/types` whose `src/*.md` are concept-per-file). for (const fileName of listDirectMds(dir, claimed)) { const mdPath = join(dir, fileName); const content = tryRead(mdPath) ?? ""; const description = firstBodyParagraph(content); const displayName = fileName.replace(/\.md$/, ""); out.push({ name: displayName, mdPath, description, children: [] }); } return out; } function renderTree( nodes: readonly ModuleNode[], from: string, depth = 0 ): string { if (nodes.length === 0) return ""; const indent = " ".repeat(depth); const lines: string[] = []; for (const node of nodes) { const link = relForLink(from, node.mdPath); const desc = node.description ? ` — ${node.description}` : ""; lines.push(`${indent}- [${node.name}](${link})${desc}`); if (node.children.length > 0) { lines.push(renderTree(node.children, from, depth + 1)); } } return lines.filter((l) => l !== "").join("\n"); } interface Stats { updated: number; unchanged: number; skipped: number; missingReadme: number; } function processFile( from: string, children: readonly ModuleNode[], heading: string, options: { check: boolean; verbose: boolean } ): "updated" | "unchanged" | "skipped" { const content = tryRead(from); if (content === undefined) return "skipped"; if (children.length === 0) { // Leaf — strip any existing auto block but otherwise leave alone. const stripped = replaceMarkedSection(content, "_(no submodules)_"); if (stripped.next === content) return "unchanged"; // Only write the strip-to-stub if markers existed. If markers didn't // exist (leaf with no auto section yet), skip — nothing to inject. if (!content.includes("")) return "unchanged"; if (!options.check) writeFileSync(from, stripped.next); if (options.verbose) console.log(` stripped leaf: ${from}`); return "updated"; } const body = `## ${heading}\n\n${renderTree(children, from)}`; const result = replaceMarkedSection(content, body); if (!result.changed) return "unchanged"; if (!options.check) writeFileSync(from, result.next); if (options.verbose) console.log(` updated: ${from}`); return "updated"; } const SPEC_MARKER_BEGIN = ""; const SPEC_MARKER_END = ""; function ensureSpecFooter( readmePath: string, specPath: string, options: { check: boolean; verbose: boolean }, stats: Stats ): void { const content = tryRead(readmePath); if (content === undefined) return; const link = relForLink(readmePath, specPath); const block = [ SPEC_MARKER_BEGIN, "## OpenSpec", "", `Canonical capability spec: [\`${link}\`](${link}).`, SPEC_MARKER_END, ].join("\n"); const beginIdx = content.indexOf(SPEC_MARKER_BEGIN); const endIdx = content.indexOf(SPEC_MARKER_END); let next: string; if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) { next = `${content.slice(0, beginIdx)}${block}${content.slice(endIdx + SPEC_MARKER_END.length)}`; } else { const trimmed = content.replace(/\s+$/u, ""); next = `${trimmed}\n\n${block}\n`; } if (next === content) { stats.unchanged++; return; } if (!options.check) writeFileSync(readmePath, next); stats.updated++; if (options.verbose) console.log(` spec-link: ${readmePath}`); } function visitTree( nodes: readonly ModuleNode[], options: { check: boolean; verbose: boolean }, stats: Stats ): void { for (const node of nodes) { const result = processFile( node.mdPath, node.children, "Submodules", options ); if (result === "updated") stats.updated++; else if (result === "unchanged") stats.unchanged++; else stats.skipped++; if (node.children.length > 0) visitTree(node.children, options, stats); } } interface CliOptions { check: boolean; verbose: boolean; } function parseArgs(argv: readonly string[]): CliOptions { let check = false; let verbose = false; for (const arg of argv) { if (arg === "--check") check = true; else if (arg === "--verbose" || arg === "-v") verbose = true; else throw new Error(`Unknown flag: ${arg}`); } return { check, verbose }; } async function main(): Promise { let options: CliOptions; try { options = parseArgs(process.argv.slice(2)); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } const pkgDirs = await discoverPackageDirs(); const stats: Stats = { updated: 0, unchanged: 0, skipped: 0, missingReadme: 0, }; const targets: string[] = [...pkgDirs]; if (isDirectorySafe(APPS_ROOT)) { for (const entry of readdirSync(APPS_ROOT, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (entry.name.startsWith(".")) continue; const appDir = join(APPS_ROOT, entry.name); if (tryRead(join(appDir, "package.json"))) targets.push(appDir); } } for (const pkgDir of targets) { const readmePath = join(pkgDir, "README.md"); const original = tryRead(readmePath); if (original === undefined) { stats.missingReadme++; continue; } const srcDir = join(pkgDir, "src"); const roots = isDirectorySafe(srcDir) ? buildModuleTree(srcDir) : []; const pkgResult = processFile( readmePath, roots, "Documentation tree", options ); if (pkgResult === "updated") stats.updated++; else if (pkgResult === "unchanged") stats.unchanged++; else stats.skipped++; // OpenSpec spec link — if `openspec/specs//spec.md` exists // and isn't already referenced in the README, append a small footer // section between auto-spec markers so the spec joins the doc tree. const shortName = basename(pkgDir); const specPath = join(OPENSPEC_SPECS, shortName, "spec.md"); if (existsSync(specPath)) { ensureSpecFooter(readmePath, specPath, options, stats); } visitTree(roots, options, stats); if (options.verbose) { console.log( ` ${basename(pkgDir)}: ${roots.length} root module(s)` ); } } console.log(""); console.log(`✏️ Updated: ${stats.updated}`); console.log(`⏭️ Unchanged: ${stats.unchanged}`); console.log(`⚠️ Missing README: ${stats.missingReadme}`); if (stats.skipped > 0) { console.log(`⚠️ Skipped: ${stats.skipped}`); } if (options.check && stats.updated > 0) { console.error(""); console.error("Drift detected — run without --check to rewrite."); process.exit(1); } process.exit(0); } void main();