Files
suaveplan/scripts/hoist-readme-titles.ts
2026-05-27 23:47:53 +01:00

214 lines
6.4 KiB
TypeScript

#!/usr/bin/env bun
/**
* hoist-readme-titles — for every workspace package, ensure README.md's
* first line follows the convention:
*
* # @suaveplan/<name> - <description>
*
* The `<description>` is sourced from each package's `package.json`
* `description` field. The script is idempotent: rerunning with no upstream
* changes results in zero file writes.
*
* Usage:
* bun scripts/hoist-readme-titles.ts # rewrite where needed
* bun scripts/hoist-readme-titles.ts --check # exit 1 if any drift
* bun scripts/hoist-readme-titles.ts --verbose # log every package
*
* Exit codes:
* 0 — every package's README first line matches its package.json description.
* 1 — drift detected (with `--check`) or write failures occurred.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { discoverPackageDirs } from "./lib/discover-packages.js";
const PKG_PREFIX = "@suaveplan/";
const TITLE_RE = /^#\s+`?@suaveplan\/([\w-]+)`?(?:\s+[-—]\s+(.*?))?\s*$/u;
interface Report {
updated: string[];
unchanged: string[];
missingDescription: string[];
missingReadme: string[];
malformedTitle: string[];
}
function normalize(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function buildTitle(name: string, description: string): string {
return `# @suaveplan/${name} - ${description}`;
}
interface ProcessResult {
next: string;
changed: boolean;
malformed: boolean;
}
function rewriteFirstLine(
contents: string,
name: string,
description: string
): ProcessResult {
const newlineIndex = contents.indexOf("\n");
const firstLine =
newlineIndex === -1 ? contents : contents.slice(0, newlineIndex);
const rest = newlineIndex === -1 ? "" : contents.slice(newlineIndex);
const match = firstLine.match(TITLE_RE);
if (!match) {
return { next: contents, changed: false, malformed: true };
}
const parsedName = match[1];
if (parsedName !== name) {
return { next: contents, changed: false, malformed: true };
}
const existingDescription = (match[2] ?? "").trim();
const desired = normalize(description);
if (existingDescription === desired) {
return { next: contents, changed: false, malformed: false };
}
const nextLine = buildTitle(name, desired);
return { next: nextLine + rest, changed: true, malformed: false };
}
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 report: Report = {
updated: [],
unchanged: [],
missingDescription: [],
missingReadme: [],
malformedTitle: [],
};
for (const dir of pkgDirs) {
const pkgJsonPath = join(dir, "package.json");
const readmePath = join(dir, "README.md");
let pkgJson: { name?: string; description?: string };
try {
pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as {
name?: string;
description?: string;
};
} catch {
continue;
}
if (!pkgJson.name?.startsWith(PKG_PREFIX)) continue;
const shortName = pkgJson.name.slice(PKG_PREFIX.length);
const description = (pkgJson.description ?? "").trim();
if (!description) {
report.missingDescription.push(shortName);
if (options.verbose) {
console.log(
` missing description: @suaveplan/${shortName} (${dir})`
);
}
continue;
}
let readme: string;
try {
readme = readFileSync(readmePath, "utf-8");
} catch {
report.missingReadme.push(shortName);
if (options.verbose) {
console.log(` missing README: @suaveplan/${shortName}`);
}
continue;
}
const result = rewriteFirstLine(readme, shortName, description);
if (result.malformed) {
report.malformedTitle.push(shortName);
if (options.verbose) {
console.log(
` malformed title: @suaveplan/${shortName} (expected "# @suaveplan/${shortName}" on line 1)`
);
}
continue;
}
if (result.changed) {
if (!options.check) writeFileSync(readmePath, result.next);
report.updated.push(shortName);
if (options.verbose) {
console.log(` updated: @suaveplan/${shortName}`);
}
} else {
report.unchanged.push(shortName);
}
}
console.log("");
console.log(`✏️ Updated: ${report.updated.length}`);
console.log(`⏭️ Already current: ${report.unchanged.length}`);
if (report.missingDescription.length > 0) {
console.log(
`⚠️ Missing description: ${report.missingDescription.length}`
);
}
if (report.missingReadme.length > 0) {
console.log(`⚠️ Missing README: ${report.missingReadme.length}`);
}
if (report.malformedTitle.length > 0) {
console.log(`❌ Malformed title: ${report.malformedTitle.length}`);
for (const name of report.malformedTitle) {
console.log(` - @suaveplan/${name}`);
}
}
if (options.check) {
if (
report.updated.length > 0 ||
report.malformedTitle.length > 0 ||
report.missingReadme.length > 0
) {
console.error("");
console.error(
"Drift, malformed titles, or missing READMEs detected — run without --check to rewrite (malformed titles need manual fixing)."
);
process.exit(1);
}
}
// Default mode: diagnostic warnings only. Pipeline steps continue.
process.exit(0);
}
void main();