Initial Commit
This commit is contained in:
213
scripts/hoist-readme-titles.ts
Normal file
213
scripts/hoist-readme-titles.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user