335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
#!/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:
|
|
*
|
|
* <!-- begin:auto-docs -->
|
|
* ## Documentation tree
|
|
*
|
|
* - [<dir-name>](./relative/path.md) — first-paragraph description
|
|
* - ...
|
|
* <!-- end:auto-docs -->
|
|
*
|
|
* 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` (`<dirname>.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<typeof readdirSync>;
|
|
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>): string[] {
|
|
let entries: ReturnType<typeof readdirSync>;
|
|
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<string>();
|
|
|
|
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("<!-- begin:auto-docs -->")) 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 = "<!-- begin:auto-spec-link -->";
|
|
const SPEC_MARKER_END = "<!-- end:auto-spec-link -->";
|
|
|
|
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<void> {
|
|
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/<shortName>/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();
|