214 lines
6.4 KiB
TypeScript
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();
|