Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

203
scripts/analyze-doc-tree.ts Normal file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env bun
/**
* analyze-doc-tree — walk the documentation tree from the root README
* and report every `.md` file on disk that is NOT reachable through
* the relative-link chain.
*
* Algorithm:
* 1. Start at `<repo>/README.md`. Read it; follow every relative `.md`
* link target. Recurse into each linked file the same way. Record
* the visited set.
* 2. Walk the filesystem for every `.md` under the repo (skipping
* node_modules, dist, build, coverage, .turbo, .vite, openspec/archive
* by default; see `--ignore`).
* 3. The difference is the orphan list.
*
* Usage:
* bun scripts/analyze-doc-tree.ts # warn-only
* bun scripts/analyze-doc-tree.ts --strict # exit 1 on orphans
* bun scripts/analyze-doc-tree.ts --ignore openspec # add ignore segments
* bun scripts/analyze-doc-tree.ts --quiet # counts only
*/
import { existsSync, realpathSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { CONFIG } from "./lib/config.js";
import { extractRelativeMdLinks, tryRead, walkMd } from "./lib/doc-tree.js";
const ROOT = CONFIG.paths.root;
const DEFAULT_IGNORE_SEGMENTS: readonly string[] = [
"openspec/archive",
"openspec/changes",
"openspec/templates",
"openspec/specs",
"openspec/cross-cutting-designs",
"docs",
"docker",
"ops",
"assets",
"apps/docs/docs",
".claude",
".cursor",
];
/**
* File-name suffixes that are excluded from the orphan check. These files
* exist on disk but are NOT part of the doc tree (sibling type-docs that
* pair with `.types.ts` files, bundled agent skill manifests, etc.).
*/
const DEFAULT_IGNORE_SUFFIXES: readonly string[] = [
".types.md",
"SKILL.md",
"CHANGELOG.md",
];
/**
* Specific filenames at the repo root that are intentionally excluded —
* `_intro.md` is the transcluded source for `README.md`, not its own
* tree node.
*/
const DEFAULT_IGNORE_PATHS: readonly string[] = ["_intro.md"];
interface CliOptions {
strict: boolean;
quiet: boolean;
extraIgnore: string[];
}
function parseArgs(argv: readonly string[]): CliOptions {
let strict = false;
let quiet = false;
const extraIgnore: string[] = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i] ?? "";
if (arg === "--strict") strict = true;
else if (arg === "--quiet" || arg === "-q") quiet = true;
else if (arg === "--ignore") {
const next = argv[i + 1];
if (!next) throw new Error("--ignore requires a value");
extraIgnore.push(next);
i++;
} else if (arg.startsWith("--ignore=")) {
extraIgnore.push(arg.slice("--ignore=".length));
} else {
throw new Error(`Unknown flag: ${arg}`);
}
}
return { strict, quiet, extraIgnore };
}
function normalize(path: string): string {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
}
function crawlVisited(
startPath: string,
ignoreSegments: readonly string[]
): Set<string> {
const visited = new Set<string>();
const queue: string[] = [normalize(startPath)];
while (queue.length > 0) {
const current = queue.shift() as string;
if (visited.has(current)) continue;
if (!existsSync(current)) continue;
visited.add(current);
const content = tryRead(current);
if (content === undefined) continue;
const targets = extractRelativeMdLinks(content, current);
for (const target of targets) {
const resolved = normalize(target);
if (ignoreSegments.some((s) => resolved.includes(`/${s}/`)))
continue;
if (!visited.has(resolved)) queue.push(resolved);
}
}
return visited;
}
function main(): 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 ignoreSegments = [...DEFAULT_IGNORE_SEGMENTS, ...options.extraIgnore];
const visited = crawlVisited(join(ROOT, "README.md"), ignoreSegments);
const ignoredAbs = new Set(
DEFAULT_IGNORE_PATHS.map((p) => normalize(join(ROOT, p)))
);
const allMd = walkMd(ROOT, { ignoreSegments })
.filter((p) => !DEFAULT_IGNORE_SUFFIXES.some((suf) => p.endsWith(suf)))
.map(normalize)
.filter((p) => !ignoredAbs.has(p));
const onDisk = new Set(allMd);
const orphans: string[] = [];
for (const path of onDisk) {
if (!visited.has(path)) orphans.push(path);
}
const broken: string[] = [];
for (const path of visited) {
if (onDisk.has(path)) continue;
if (path.endsWith("README.md")) continue;
// Files intentionally excluded from the on-disk set (sibling type docs,
// bundled agent skills, etc.) are not "broken" — they were filtered out
// by `DEFAULT_IGNORE_SUFFIXES`.
if (DEFAULT_IGNORE_SUFFIXES.some((suf) => path.endsWith(suf))) continue;
if (!existsSync(path)) broken.push(path);
}
orphans.sort();
broken.sort();
if (!options.quiet) {
console.log("");
console.log(`📚 Visited from root README: ${visited.size}`);
console.log(`📂 .md files on disk: ${onDisk.size}`);
console.log(`❌ Orphans (unreachable): ${orphans.length}`);
console.log(`⚠️ Broken link targets: ${broken.length}`);
if (orphans.length > 0) {
console.log("");
console.log("Orphans:");
for (const o of orphans.slice(0, 50)) {
console.log(` - ${relative(ROOT, o)}`);
}
if (orphans.length > 50) {
console.log(` … and ${orphans.length - 50} more`);
}
}
if (broken.length > 0) {
console.log("");
console.log(
"Broken link targets (referenced but missing on disk):"
);
for (const b of broken.slice(0, 50)) {
console.log(` - ${relative(ROOT, b)}`);
}
if (broken.length > 50) {
console.log(` … and ${broken.length - 50} more`);
}
}
} else {
console.log(
`visited=${visited.size} disk=${onDisk.size} orphans=${orphans.length} broken=${broken.length}`
);
}
if (options.strict && (orphans.length > 0 || broken.length > 0)) {
process.exit(1);
}
process.exit(0);
}
main();

View File

@@ -0,0 +1,219 @@
#!/usr/bin/env bun
/**
* Layer 2 audit: scan every package's tests for opportunities to use
* @suaveplan/testing helpers (FakeClock, Factory, FixtureManager, MemoryStore)
* that are currently being hand-rolled.
*
* Heuristics (each one is a strong signal, not a guarantee):
*
* FakeClock — test files using `new Date(`, `Date.now(`, or
* `setTimeout(` without already importing `FakeClock`.
* Factory — test files with ≥3 occurrences of an inline literal
* object that has the shape of test data (id/name/etc).
* FixtureManager — test files with paired `beforeEach` + `afterEach`
* blocks doing setup+cleanup.
* MemoryStore — test files defining `const store = new Map(` or
* `const cache = {}` and treating it like a kv store.
*
* Output: a per-package report listing potential opportunities, NOT
* automatic edits. The fix list goes to stdout; the user reviews and
* applies selectively.
*
* Usage:
* bun scripts/audit-test-helper-usage.ts # full report
* bun scripts/audit-test-helper-usage.ts --json # machine-readable
* bun scripts/audit-test-helper-usage.ts --summary # counts only
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
const repoRoot = "/mnt/s/web/genesis";
const packagesRoot = join(repoRoot, "packages");
interface Finding {
pkg: string;
file: string;
helper: "FakeClock" | "Factory" | "FixtureManager" | "MemoryStore";
signal: string;
line: number;
}
const args = process.argv.slice(2);
const jsonMode = args.includes("--json");
const summaryMode = args.includes("--summary");
function* walkTestFiles(dir: string): Generator<string> {
if (!existsSync(dir) || !statSync(dir).isDirectory()) return;
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const s = statSync(p);
if (s.isDirectory()) {
if (
name === "node_modules" ||
name === "dist" ||
name === ".turbo" ||
name === "coverage"
)
continue;
yield* walkTestFiles(p);
} else if (
s.isFile() &&
(name.endsWith(".test.ts") || name.endsWith(".test.tsx"))
) {
yield p;
}
}
}
function scan(file: string, pkg: string): Finding[] {
const out: Finding[] = [];
const content = readFileSync(file, "utf8");
const lines = content.split("\n");
const usesFakeClock = /import\s+\{[^}]*\bFakeClock\b/.test(content);
const usesFactory = /import\s+\{[^}]*\b(Factory|defineFactory)\b/.test(
content
);
const usesFixtureMgr = /import\s+\{[^}]*\bFixtureManager\b/.test(content);
const usesMemoryStore = /import\s+\{[^}]*\bMemoryStore\b/.test(content);
let beforeEach = 0;
let afterEach = 0;
let dateNow = 0;
let newDate = 0;
let setTimeoutCount = 0;
let _mapAsStore = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
if (/\bnew\s+Date\s*\(/.test(line)) {
if (newDate === 0 && !usesFakeClock) {
out.push({
pkg,
file,
helper: "FakeClock",
signal: "uses `new Date(` directly",
line: i + 1,
});
}
newDate += 1;
}
if (/\bDate\.now\s*\(/.test(line) && !usesFakeClock) {
if (dateNow === 0)
out.push({
pkg,
file,
helper: "FakeClock",
signal: "uses `Date.now()` directly",
line: i + 1,
});
dateNow += 1;
}
if (/\bsetTimeout\s*\(/.test(line) && !usesFakeClock) {
if (setTimeoutCount === 0)
out.push({
pkg,
file,
helper: "FakeClock",
signal: "uses real `setTimeout` (consider `FakeClock.tick`)",
line: i + 1,
});
setTimeoutCount += 1;
}
if (/\bbeforeEach\s*\(/.test(line)) beforeEach += 1;
if (/\bafterEach\s*\(/.test(line)) afterEach += 1;
if (
/(const|let)\s+\w+\s*[:=]\s*new\s+Map<.*string/.test(line) &&
!usesMemoryStore
) {
out.push({
pkg,
file,
helper: "MemoryStore",
signal: "hand-rolled Map<string,…> (consider `MemoryStore`)",
line: i + 1,
});
_mapAsStore += 1;
}
}
if (beforeEach >= 1 && afterEach >= 1 && !usesFixtureMgr) {
out.push({
pkg,
file,
helper: "FixtureManager",
signal: `paired beforeEach/afterEach × ${Math.min(beforeEach, afterEach)} (consider \`FixtureManager\`)`,
line: 0,
});
}
const literalCount = (content.match(/\{\s*id:\s*["'][^"']+["']/g) ?? [])
.length;
if (literalCount >= 3 && !usesFactory) {
out.push({
pkg,
file,
helper: "Factory",
signal: `${literalCount} inline {id: "…"} test-data literals (consider \`defineFactory\`)`,
line: 0,
});
}
return out;
}
function main(): void {
const findings: Finding[] = [];
const packagesScanned: string[] = [];
for (const plugin of readdirSync(packagesRoot)) {
const pluginPath = join(packagesRoot, plugin);
if (!statSync(pluginPath).isDirectory()) continue;
for (const pkg of readdirSync(pluginPath)) {
const pkgPath = join(pluginPath, pkg);
if (!statSync(pkgPath).isDirectory()) continue;
if (!existsSync(join(pkgPath, "package.json"))) continue;
const id = `${plugin}/${pkg}`;
packagesScanned.push(id);
for (const file of walkTestFiles(pkgPath)) {
findings.push(...scan(file, id));
}
}
}
if (jsonMode) {
process.stdout.write(
`${JSON.stringify({ packagesScanned: packagesScanned.length, findings }, null, 2)}\n`
);
return;
}
const byHelper = new Map<string, number>();
for (const f of findings)
byHelper.set(f.helper, (byHelper.get(f.helper) ?? 0) + 1);
console.log(`Scanned ${packagesScanned.length} packages.`);
console.log(`Findings: ${findings.length}`);
console.log("");
console.log("By helper:");
for (const [h, n] of [...byHelper.entries()].sort((a, b) => b[1] - a[1])) {
console.log(` ${h.padEnd(16)} ${n}`);
}
if (summaryMode) return;
const byPkg = new Map<string, Finding[]>();
for (const f of findings) {
if (!byPkg.has(f.pkg)) byPkg.set(f.pkg, []);
byPkg.get(f.pkg)?.push(f);
}
const ranked = [...byPkg.entries()].sort(
(a, b) => b[1].length - a[1].length
);
console.log("");
console.log("Top 20 packages by finding count:");
for (const [pkg, fs] of ranked.slice(0, 20)) {
console.log(` ${pkg.padEnd(40)} ${fs.length}`);
}
}
main();

102
scripts/barrel.ts Normal file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bun
/**
* Regenerates barrel index.ts files across all workspace packages using ctix.
* Run: bun scripts/barrel.ts
*/
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
const ROOT = resolve(import.meta.dir, "..");
const WORKSPACE_GLOBS = [
"universal/*/*",
"browser/*/*",
"gaming/*/*",
"react/*/*",
"api/*/*",
"r3f/*/*",
"server/*/*",
"mcp/*/*",
"finance/*/*",
"python/*/*",
];
function dirs(parent: string): string[] {
if (!existsSync(parent)) return [];
return readdirSync(parent).filter((d) =>
statSync(join(parent, d)).isDirectory()
);
}
function expandGlob(pattern: string): string[] {
// Pattern is always `<root>/*/*` — two wildcard levels
const segments = pattern.split("/");
let paths = [ROOT];
for (const seg of segments) {
if (seg === "*") {
paths = paths.flatMap((p) => dirs(p).map((d) => join(p, d)));
} else {
paths = paths.map((p) => join(p, seg));
}
}
return paths.filter((p) => existsSync(p) && statSync(p).isDirectory());
}
const TSCONFIG_NAMES = ["tsconfig.lib.json", "tsconfig.json"];
const packages = WORKSPACE_GLOBS.flatMap(expandGlob).filter((p) => {
return (
TSCONFIG_NAMES.some((t) => existsSync(join(p, t))) &&
existsSync(join(p, "src"))
);
});
console.log(`Found ${packages.length} packages\n`);
let ok = 0;
let fail = 0;
for (const pkg of packages) {
const rel = relative(ROOT, pkg);
const tsconfig =
TSCONFIG_NAMES.map((t) => join(pkg, t)).find(existsSync) ??
join(pkg, "tsconfig.lib.json");
const result = spawnSync(
"bunx",
[
"ctix",
"build",
"--project",
tsconfig,
"--start-from",
join(pkg, "src"),
"--mode",
"create",
"--generation-style",
"default-alias-named-destructive",
"--overwrite",
"--skip-empty-dir",
"--exclude-files",
"**/*.test.ts",
"--exclude-files",
"**/*.spec.ts",
"--exclude-files",
"**/*.stories.tsx",
],
{ cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
);
if (result.status === 0) {
console.log(` ok ${rel}`);
ok++;
} else {
console.error(` ERR ${rel}`);
if (result.stderr) console.error(result.stderr.trim());
fail++;
}
}
console.log(`\n${ok} ok, ${fail} failed`);
if (fail > 0) process.exit(1);

38
scripts/batch-convert.sh Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Convert + biome-fix + gate a list of packages in batches.
#
# Usage: bash scripts/batch-convert.sh <pkg-path-1>:<pkg-name-1> [<pkg-path-2>:<pkg-name-2> ...]
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "${ROOT}"
PKGS=("$@")
if [ "${#PKGS[@]}" -eq 0 ]; then
echo "usage: $0 <pkg-path>:<pkg-name> ..." >&2
exit 2
fi
# Phase 1: convert + biome for every package.
for entry in "${PKGS[@]}"; do
PATH_PART="${entry%%:*}"
NAME_PART="${entry##*:}"
echo "=== convert ${NAME_PART} ==="
bash "${ROOT}/scripts/convert-package-barrels.sh" "${PATH_PART}" 2>&1 | grep -E "Done:|WARNING|no violations" | head -3 || true
cd "${ROOT}/${PATH_PART}"
for i in 1 2 3; do
bunx biome check --write --max-diagnostics 1000 src/ 2>&1 | tail -1
done
cd "${ROOT}"
done
# Phase 2: build the turbo filter list.
FILTERS=()
for entry in "${PKGS[@]}"; do
NAME_PART="${entry##*:}"
FILTERS+=("--filter=${NAME_PART}")
done
echo "=== gate (${#PKGS[@]} packages) ==="
bunx turbo lint typecheck test "${FILTERS[@]}" 2>&1 | tail -10

View File

@@ -0,0 +1,28 @@
{
"$schema": "https://suaveplan.com/schemas/bench-baselines/v1.json",
"namespace": "bitpacker",
"captured_at": "2026-05-03",
"captured_by": "bitpacker-hardening change",
"captured_with": "bun bench/pack-unpack.bench.ts (mitata ^1.0.34)",
"tolerance_pct": 10,
"baselines": {
"@suaveplan/bitpacker": {
"bitpacker.pack — u8": {
"median_ns": 1350,
"notes": "Microsecond-scale; scale-up via mitata's avg view"
},
"bitpacker.unpack — u8": {
"median_ns": 2730,
"notes": "Includes fallback heap-allocation path"
},
"bitpacker.pack — u32": {
"median_ns": 1740,
"notes": "32-bit uint pack baseline"
},
"bitpacker.unpack — u32": {
"median_ns": 1000,
"notes": "32-bit uint unpack baseline"
}
}
}
}

View File

@@ -0,0 +1,43 @@
/**
* CI check: fail if any `<id>.select.ts` has drifted from the content that
* `gen-crypto-selects.ts` would write given the current `crypto.config.ts`
* and primitive-id registry.
*/
import { existsSync, readFileSync } from "node:fs";
import { plan } from "./gen-crypto-selects.js";
async function main(): Promise<void> {
const stale: string[] = [];
const missing: string[] = [];
for (const p of plan()) {
if (!existsSync(p.path)) {
missing.push(p.path);
continue;
}
const current = readFileSync(p.path, "utf8");
if (current !== p.contents) {
stale.push(p.path);
}
}
if (missing.length > 0 || stale.length > 0) {
console.error("crypto-selects check FAILED");
if (missing.length > 0) {
console.error(" missing generated files:");
for (const m of missing) console.error(` ${m}`);
}
if (stale.length > 0) {
console.error(" stale / out-of-date generated files:");
for (const s of stale) console.error(` ${s}`);
}
console.error(
"Run `bun scripts/gen-crypto-selects.ts` and commit the result."
);
process.exit(1);
}
console.log("crypto-selects: OK");
}
if (import.meta.main) {
await main();
}

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env bun
/**
* check-fixture-resolves — verifies that every `@suaveplan/testing-integration-*`
* package's `package.json` `exports."." → import.default` points at a file that
* actually exists on disk and loads cleanly via dynamic `import()`.
*
* The bug this guards against: a vite.config.ts using `preserveModules: true`
* without `output.entryFileNames: "[name].js"` causes Rollup to derive the
* entry filename from the package name (e.g. `dist/testing-integration-redis.js`),
* leaving `package.json`'s declared entry pointing at a file that does not
* exist. Consumers then get
* `Failed to resolve entry for package "@suaveplan/testing-integration-<name>"`
* from esbuild/Vite/Bun.
*
* Resolves package paths from the repo's `packages/testing/testing-integration*`
* tree directly, since the repo-root `scripts/` directory is not a workspace
* member and cannot import `@suaveplan/*` by name under Bun's hoisting.
*
* Exits 0 when all entries resolve and import, 1 when any fail. Prints a
* per-package row so a CI failure is unambiguous.
*
* @module
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const REPO_ROOT = resolve(import.meta.dir, "..");
const FIXTURES_DIR = join(REPO_ROOT, "packages", "dev");
interface ExportConditions {
import?: string | { default?: string };
default?: string;
}
interface PackageManifest {
name?: string;
exports?: Record<string, string | ExportConditions> | string;
}
interface Result {
pkg: string;
ok: boolean;
error: string | undefined;
}
function discoverFixtures(): Array<{ name: string; dir: string }> {
const entries = readdirSync(FIXTURES_DIR, { withFileTypes: true });
const result: Array<{ name: string; dir: string }> = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.name.startsWith("testing-integration")) continue;
const dir = join(FIXTURES_DIR, entry.name);
const manifestPath = join(dir, "package.json");
if (!existsSync(manifestPath)) continue;
const manifest = JSON.parse(
readFileSync(manifestPath, "utf-8")
) as PackageManifest;
if (typeof manifest.name !== "string") continue;
result.push({ name: manifest.name, dir });
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}
function resolveMainEntry(dir: string): string | null {
const manifest = JSON.parse(
readFileSync(join(dir, "package.json"), "utf-8")
) as PackageManifest;
const exp = manifest.exports;
if (exp === undefined) return null;
if (typeof exp === "string") return join(dir, exp);
const root = exp["."];
if (root === undefined) return null;
if (typeof root === "string") return join(dir, root);
const importEntry = root.import;
if (typeof importEntry === "string") return join(dir, importEntry);
if (
importEntry !== undefined &&
typeof importEntry === "object" &&
typeof importEntry.default === "string"
) {
return join(dir, importEntry.default);
}
if (typeof root.default === "string") return join(dir, root.default);
return null;
}
async function check(pkg: { name: string; dir: string }): Promise<Result> {
const entry = resolveMainEntry(pkg.dir);
if (entry === null) {
return {
pkg: pkg.name,
ok: false,
error: 'package.json "exports" missing or has no "." entry',
};
}
if (!existsSync(entry)) {
return {
pkg: pkg.name,
ok: false,
error: `declared entry does not exist on disk: ${entry}`,
};
}
try {
const mod = await import(pathToFileURL(entry).href);
if (mod === null || typeof mod !== "object") {
return {
pkg: pkg.name,
ok: false,
error: `import resolved but module is not an object (got ${typeof mod})`,
};
}
return { pkg: pkg.name, ok: true, error: undefined };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { pkg: pkg.name, ok: false, error: message };
}
}
async function main(): Promise<number> {
const fixtures = discoverFixtures();
if (fixtures.length === 0) {
process.stderr.write(
`No testing-integration packages found under ${FIXTURES_DIR}\n`
);
return 1;
}
const results = await Promise.all(fixtures.map(check));
const width = Math.max(...fixtures.map((f) => f.name.length));
for (const r of results) {
const tag = r.ok ? "PASS" : "FAIL";
const padded = r.pkg.padEnd(width, " ");
const tail = r.ok ? "" : ` — ${r.error}`;
process.stdout.write(`${tag} ${padded}${tail}\n`);
}
const failed = results.filter((r) => !r.ok);
if (failed.length > 0) {
process.stderr.write(
`\n${failed.length}/${results.length} testing-integration packages failed to resolve.\n`
);
return 1;
}
process.stdout.write(
`\nAll ${results.length} testing-integration packages resolve.\n`
);
return 0;
}
const code = await main();
process.exit(code);

View File

@@ -0,0 +1,370 @@
#!/usr/bin/env bun
/**
* cleanup-md-monorepo-refs — carefully scrub genesis/monorepo/stage metadata
* from library .md files so they read as standalone packages.
*
* Reads `.md-cleanup-todo.txt` (path|line1,line2,...), processes files whose
* matches are all in the first N lines, and removes them from the todo.
*
* Usage:
* bun scripts/cleanup-md-monorepo-refs.ts # process all
* bun scripts/cleanup-md-monorepo-refs.ts --first10 # only first-10-line files
* bun scripts/cleanup-md-monorepo-refs.ts --readme # only README.md files
* bun scripts/cleanup-md-monorepo-refs.ts --dry-run # preview
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const TODO_PATH = ".md-cleanup-todo.txt";
interface TodoItem {
path: string;
lines: number[];
}
function parseTodo(content: string): TodoItem[] {
const items: TodoItem[] = [];
for (const line of content.split("\n")) {
const pipe = line.indexOf("|");
if (pipe === -1) continue;
const path = line.slice(0, pipe).trim();
const lines = line
.slice(pipe + 1)
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
if (path && lines.length > 0) items.push({ path, lines });
}
return items;
}
function serializeTodo(items: TodoItem[]): string {
return items.map((i) => `${i.path}|${i.lines.join(",")}`).join("\n") + "\n";
}
function maxLine(lines: number[]): number {
return Math.max(...lines);
}
/**
* Very conservative replacements. Each function receives the full text and
* returns the scrubbed text.
*/
function scrub(text: string, filePath: string): { next: string; edits: string[] } {
const edits: string[] = [];
let next = text;
// 1. Remove OpenSpec link blocks safely
if (next.includes("<!-- begin:auto-spec-link -->")) {
const before = next;
next = next.replace(
/\n?<!-- begin:auto-spec-link -->[\s\S]*?<!-- end:auto-spec-link -->\n?/g,
"\n"
);
if (next !== before) edits.push("removed OpenSpec block");
}
// 2. Remove bare "## OpenSpec" heading + following spec-link paragraph
if (/\n## OpenSpec\n/.test(next)) {
const before = next;
next = next.replace(
/\n## OpenSpec\n\nCanonical capability spec:[^\n]*\n/g,
"\n"
);
if (next !== before) edits.push("removed OpenSpec heading");
}
// 3. Remove Stage-N prefix from H1 title lines (only if line starts with # @suaveplan/... - Stage-N ...)
// Use a regex that only touches spaces/tabs, never newlines.
const h1StagePattern =
/^(# @suaveplan\/[\w-]+)[ \t]+-[ \t]+Stage-?\d+(?:\.\d+)?[ \t]+/gim;
if (h1StagePattern.test(next)) {
next = next.replace(h1StagePattern, "$1 — ");
edits.push("removed Stage from H1");
}
// 4. Remove "Stage N — " or "Stage-N " at the very start of a paragraph (after blank line or start of file)
const stagePrefixPattern =
/(^|\n\n)[ \t]*Stage-?\d+(?:\.\d+)?[ \t]*(?:—|-)[ \t]*/gi;
if (stagePrefixPattern.test(next)) {
next = next.replace(stagePrefixPattern, "$1");
edits.push("removed Stage prefix from paragraph");
}
// 5. Replace specific monorepo phrases
const phraseReplacements: { pattern: RegExp; replacement: string; label: string }[] = [
{ pattern: /\bfor the Suaveplan monorepo\b/gi, replacement: "for Suaveplan", label: "monorepo phrase" },
{ pattern: /\bthe Suaveplan monorepo\b/gi, replacement: "Suaveplan", label: "monorepo phrase" },
{ pattern: /\bthe genesis monorepo\b/gi, replacement: "Suaveplan", label: "genesis monorepo" },
{ pattern: /\bgenesis `@suaveplan\//gi, replacement: "`@suaveplan/", label: "genesis pkg prefix" },
{ pattern: /\bfor the `@suaveplan\/api` plugin foundation\b/gi, replacement: "for `@suaveplan/api`", label: "plugin foundation" },
{ pattern: /\bthe `@suaveplan\/api` plugin foundation\b/gi, replacement: "`@suaveplan/api`", label: "plugin foundation" },
{ pattern: /\bplugin foundation\b/gi, replacement: "plugin system", label: "plugin foundation" },
{ pattern: /\bGenesis Stage \d+(?:\.\d+)?[^.]*\.\s*/gi, replacement: "", label: "Genesis Stage sentence" },
{ pattern: /\bGenesis-wave package[^.]*\.\s*/gi, replacement: "", label: "Genesis-wave sentence" },
{ pattern: /\bPer design doc §\d+(?:\.\d+)*,\s+genesis\s+[^.]*\.\s*/gi, replacement: "", label: "design-doc genesis sentence" },
{ pattern: /\bgenesis (fix|package|design|crypto|wave|port|rewrite|drops?|error|monorepo)\b/gi, replacement: "$1", label: "genesis adjective" },
{ pattern: /\bStage \d+(?:\.\d+)?\s+of the umbrella plan\b/gi, replacement: "the design", label: "umbrella plan" },
{ pattern: /\bof the umbrella plan\s*\([^)]*openspec[^)]*\)\b/gi, replacement: "of the design", label: "umbrella plan" },
{ pattern: /\bOpenSpec change\b/gi, replacement: "spec change", label: "OpenSpec change" },
{ pattern: /\bOpenSpec-tracked\b/gi, replacement: "tracked", label: "OpenSpec-tracked" },
{ pattern: /\bgenesis-only\b/gi, replacement: "", label: "genesis-only" },
{ pattern: /\bgenesis original\b/gi, replacement: "original", label: "genesis original" },
{ pattern: /\bgenesis-side\b/gi, replacement: "", label: "genesis-side" },
{ pattern: /\bgenesis `EventBus/gi, replacement: "`EventBus", label: "genesis EventBus" },
{ pattern: /\bgenesis `tsconfig/gi, replacement: "`tsconfig", label: "genesis tsconfig" },
{ pattern: /\bgenesis `exactOptionalPropertyTypes/gi, replacement: "`exactOptionalPropertyTypes", label: "genesis exactOptionalPropertyTypes" },
{ pattern: /\bgenesis error ecosystem/gi, replacement: "error ecosystem", label: "genesis error ecosystem" },
{ pattern: /\bgenesis dependency graph/gi, replacement: "dependency graph", label: "genesis dependency graph" },
{ pattern: /\bgenesis stack/gi, replacement: "stack", label: "genesis stack" },
{ pattern: /\bgenesis hardening/gi, replacement: "hardening", label: "genesis hardening" },
{ pattern: /\bgenesis family/gi, replacement: "family", label: "genesis family" },
{ pattern: /\bgenesis cache family/gi, replacement: "cache family", label: "genesis cache family" },
{ pattern: /\bgenesis audio family/gi, replacement: "audio family", label: "genesis audio family" },
{ pattern: /\bgenesis mesh/gi, replacement: "mesh", label: "genesis mesh" },
{ pattern: /\bthe monorepo\b/gi, replacement: "the project", label: "the monorepo" },
{ pattern: /\bthe rest of the monorepo\b/gi, replacement: "the rest of the project", label: "rest of monorepo" },
{ pattern: /\bmonorepo-wide\b/gi, replacement: "project-wide", label: "monorepo-wide" },
{ pattern: /\bmonorepo manifest\b/gi, replacement: "project manifest", label: "monorepo manifest" },
{ pattern: /\bmonorepo Python-tier story\b/gi, replacement: "Python tooling story", label: "monorepo Python" },
{ pattern: /\bbroader monorepo\b/gi, replacement: "broader project", label: "broader monorepo" },
{ pattern: /\bextends the monorepo/gi, replacement: "extends the project", label: "extends monorepo" },
{ pattern: /\bExtending the monorepo/gi, replacement: "Extending the project", label: "extends monorepo" },
{ pattern: /\bthe genesis `audio-decode-\*\*` family/gi, replacement: "the `audio-decode-*` family", label: "genesis audio-decode family" },
{ pattern: /\bthe first downstream consumer is `@suaveplan\/audio-ir-loader`\./gi, replacement: "", label: "first downstream consumer" },
{ pattern: /\bgenesis surface\b/gi, replacement: "surface", label: "genesis surface" },
{ pattern: /\bgenesis-canonical\b/gi, replacement: "canonical", label: "genesis-canonical" },
{ pattern: /\bgenesis preserves\b/gi, replacement: "preserves", label: "genesis preserves" },
{ pattern: /\bgenesis cause-chain\b/gi, replacement: "cause-chain", label: "genesis cause-chain" },
{ pattern: /\bgenesis codec plugin\b/gi, replacement: "codec plugin", label: "genesis codec plugin" },
{ pattern: /\bgenesis voice domain conventions\b/gi, replacement: "voice domain conventions", label: "genesis voice domain conventions" },
{ pattern: /\bgenesis voice\b/gi, replacement: "voice", label: "genesis voice" },
{ pattern: /\bgenesis rule\b/gi, replacement: "rule", label: "genesis rule" },
{ pattern: /\bgenesis-added\b/gi, replacement: "added", label: "genesis-added" },
{ pattern: /\bgenesis exposes\b/gi, replacement: "exposes", label: "genesis exposes" },
{ pattern: /\bgenesis WebSocket\b/gi, replacement: "WebSocket", label: "genesis WebSocket" },
{ pattern: /\bgenesis OpenSpec\b/gi, replacement: "OpenSpec", label: "genesis OpenSpec" },
{ pattern: /\bgenesis-wide convention\b/gi, replacement: "convention", label: "genesis-wide convention" },
{ pattern: /\bgenesis packages\b/gi, replacement: "packages", label: "genesis packages" },
{ pattern: /\bgenesis packages? that\b/gi, replacement: "packages that", label: "genesis packages that" },
{ pattern: /\bgenesis (\w+ing)\b/gi, replacement: "$1", label: "genesis ...ing" },
{ pattern: /\bGenesis includes it\b/gi, replacement: "It is included", label: "Genesis includes it" },
{ pattern: /\bGenesis treats it\b/gi, replacement: "It is treated", label: "Genesis treats it" },
{ pattern: /\bGenesis treats\b/gi, replacement: "The package treats", label: "Genesis treats" },
{ pattern: /\bGenesis's preferred\b/gi, replacement: "Preferred", label: "Genesis's preferred" },
{ pattern: /\bGenesis preserves\b/gi, replacement: "Preserves", label: "Genesis preserves" },
{ pattern: /\bGenesis closes\b/gi, replacement: "This package closes", label: "Genesis closes" },
{ pattern: /\bmonorepo with dozens of packages\b/gi, replacement: "package ecosystem", label: "monorepo with dozens" },
{ pattern: /\bmonorepo's shared code space\b/gi, replacement: "shared code space", label: "monorepo's shared code" },
{ pattern: /\bmonorepo\. Wraps\b/gi, replacement: ". Wraps", label: "monorepo. Wraps" },
{ pattern: /\bthe wider monorepo\b/gi, replacement: "the wider ecosystem", label: "wider monorepo" },
{ pattern: /\bopenspec-conformance\b/gi, replacement: "conformance", label: "openspec-conformance" },
{ pattern: /\bthe other genesis\b/gi, replacement: "the other", label: "the other genesis" },
{ pattern: /\bthe genesis (lint|docs-check|storage|lint profile|cause-chain|WebRTC|WebSocket|codec|voice|surface)\b/gi, replacement: "the $1", label: "the genesis ..." },
{ pattern: /\bgenesis (lint profile|docs-check rule|storage family|storage backend|HTTP-stat parser|tier package)\b/gi, replacement: "$1", label: "genesis ..." },
{ pattern: /\bgenesis-app\b/gi, replacement: "app", label: "genesis-app" },
{ pattern: /\bgenesis bans\b/gi, replacement: "the test suite bans", label: "genesis bans" },
{ pattern: /\bgenesis-tier\b/gi, replacement: "", label: "genesis-tier" },
{ pattern: /\bgenesis-specific\b/gi, replacement: "specific", label: "genesis-specific" },
{ pattern: /\bGenesis-specific\b/gi, replacement: "Specific", label: "Genesis-specific" },
{ pattern: /\bGenesis sits\b/gi, replacement: "This package sits", label: "Genesis sits" },
{ pattern: /\bgithub\.com\/SuavePlan\/genesis\b/gi, replacement: "github.com/SuavePlan/suaveplan", label: "github.com/SuavePlan/genesis" },
{ pattern: /\bgenesis (\w+ing)\b/gi, replacement: "$1", label: "genesis ...ing" },
{ pattern: /\bgenesis project rules\b/gi, replacement: "project rules", label: "genesis project rules" },
{ pattern: /\bgenesis convention\b/gi, replacement: "convention", label: "genesis convention" },
{ pattern: /\bgenesis 300-line hard cap\b/gi, replacement: "300-line hard cap", label: "genesis 300-line" },
{ pattern: /\bgenesis wrapper\b/gi, replacement: "wrapper", label: "genesis wrapper" },
{ pattern: /\bgenesis pre-1\.0\b/gi, replacement: "pre-1.0", label: "genesis pre-1.0" },
{ pattern: /\bgenesis 5-files-per-feature convention\b/gi, replacement: "5-files-per-feature convention", label: "genesis 5-files" },
{ pattern: /\bPre-Genesis consumer\b/gi, replacement: "Pre-migration consumer", label: "Pre-Genesis consumer" },
{ pattern: /\bPre-genesis consumer\b/gi, replacement: "Pre-migration consumer", label: "Pre-genesis consumer" },
{ pattern: /\bgenesis Zod import rule\b/gi, replacement: "Zod import rule", label: "genesis Zod import" },
{ pattern: /\bgenesis boundary\b/gi, replacement: "boundary", label: "genesis boundary" },
{ pattern: /\bgenesis tsconfig\b/gi, replacement: "tsconfig", label: "genesis tsconfig" },
{ pattern: /\bthis monorepo\b/gi, replacement: "this project", label: "this monorepo" },
{ pattern: /\bPer the genesis\b/gi, replacement: "Per the", label: "Per the genesis" },
{ pattern: /\bgenesis (error|cause-chain|lint|docs-check|storage|codec|voice|surface|WebRTC|WebSocket)\b/gi, replacement: "$1", label: "genesis generic" },
{ pattern: /\bGenesis (error|cause-chain|lint|docs-check|storage|codec|voice|surface|WebRTC|WebSocket)\b/gi, replacement: "$1", label: "Genesis generic" },
{ pattern: /\bgenesis\b/gi, replacement: "", label: "genesis fallback" },
{ pattern: /\bGenesis\b/gi, replacement: "", label: "Genesis fallback" },
{ pattern: /\bmonorepo\b/gi, replacement: "project", label: "monorepo fallback" },
{ pattern: /\bMonorepo\b/gi, replacement: "Project", label: "Monorepo fallback" },
{ pattern: /\bstage[- ]?\d+\b/gi, replacement: "", label: "stage fallback" },
{ pattern: /\bStage[- ]?\d+\b/gi, replacement: "", label: "Stage fallback" },
{ pattern: /\bopenspec\b/gi, replacement: "specification", label: "openspec fallback" },
{ pattern: /\bOpenspec\b/gi, replacement: "Specification", label: "Openspec fallback" },
{ pattern: /\bOpenSpec\b/gi, replacement: "OpenSpec", label: "OpenSpec keep" },
{ pattern: /\bplugin foundation\b/gi, replacement: "plugin", label: "plugin foundation" },
{ pattern: /\bon genesis\b/gi, replacement: "", label: "on genesis" },
{ pattern: /\bin genesis\b/gi, replacement: "", label: "in genesis" },
{ pattern: /\bhas not yet landed on genesis\b/gi, replacement: "has not yet shipped", label: "not landed on genesis" },
{ pattern: /\bpackages not yet in genesis\b/gi, replacement: "packages not yet shipped", label: "not yet in genesis" },
{ pattern: /\bsuaveplan main, v\d+\.\d+\.\d+\) plus genesis hardening/gi, replacement: "harded", label: "suaveplan main plus genesis" },
{ pattern: /\bgenesis improvement\b/gi, replacement: "improvement", label: "genesis improvement" },
{ pattern: /\bgenesis game type/gi, replacement: "game type", label: "genesis game type" },
{ pattern: /\bgenesis `Middleware`/gi, replacement: "`Middleware`", label: "genesis Middleware" },
{ pattern: /\bgenesis `storage-uri-plugin-bundle`/gi, replacement: "`storage-uri-plugin-bundle`", label: "genesis storage-uri" },
{ pattern: /\bGenesis-original\b/gi, replacement: "Original", label: "Genesis-original" },
{ pattern: /\bgenesis-original\b/gi, replacement: "original", label: "genesis-original" },
{ pattern: /\bgenesis bootstrap landing/gi, replacement: "bootstrap landing", label: "genesis bootstrap" },
{ pattern: /\bgenesis JSX output/gi, replacement: "JSX output", label: "genesis JSX" },
{ pattern: /\bduring genesis\b/gi, replacement: "", label: "during genesis" },
{ pattern: /\bthe genesis port/gi, replacement: "the port", label: "genesis port" },
{ pattern: /\bthe genesis rewrite/gi, replacement: "the rewrite", label: "genesis rewrite" },
{ pattern: /\bthis migration/gi, replacement: "this release", label: "this migration" },
{ pattern: /\bgenesis deps\b/gi, replacement: "dependencies", label: "genesis deps" },
{ pattern: /\bgenesis `([^`]+)`/gi, replacement: "`$1`", label: "genesis backtick" },
{ pattern: /\bgenesis API-plugin/gi, replacement: "API-plugin", label: "genesis API-plugin" },
{ pattern: /\bgenesis ship\b/gi, replacement: "ship", label: "genesis ship" },
{ pattern: /\binitial genesis ship\b/gi, replacement: "initial ship", label: "initial genesis ship" },
{ pattern: /\blanding on `?genesis`?\b/gi, replacement: "shipping", label: "landing on genesis" },
{ pattern: /\bmigrated into genesis\b/gi, replacement: "being migrated", label: "migrated into genesis" },
{ pattern: /\bgenesis plugin\b/gi, replacement: "plugin", label: "genesis plugin" },
{ pattern: /\bgenesis catalog\b/gi, replacement: "catalog", label: "genesis catalog" },
{ pattern: /\bgenesis SurrealDB repository\b/gi, replacement: "SurrealDB repository", label: "genesis SurrealDB" },
{ pattern: /\bgenesis sibling adapter\b/gi, replacement: "sibling adapter", label: "genesis sibling" },
{ pattern: /\bgenesis-native sibling\b/gi, replacement: "native sibling", label: "genesis-native sibling" },
{ pattern: /\bgenesis dehydrated-drive workflow\b/gi, replacement: "dehydrated-drive workflow", label: "genesis dehydrated-drive" },
{ pattern: /\bgenesis drive mesh\b/gi, replacement: "drive mesh", label: "genesis drive mesh" },
{ pattern: /\bgenesis migration\b/gi, replacement: "migration", label: "genesis migration" },
{ pattern: /\bgenesis network bootstrap\b/gi, replacement: "network bootstrap", label: "genesis network bootstrap" },
{ pattern: /\bgenesis license package\b/gi, replacement: "license package", label: "genesis license" },
{ pattern: /\bGenesis enterprise extras\b/gi, replacement: "Enterprise extras", label: "Genesis enterprise extras" },
{ pattern: /\bgenesis style\b/gi, replacement: "style", label: "genesis style" },
{ pattern: /\bgenesis-stage rebuild\b/gi, replacement: "rebuild", label: "genesis-stage rebuild" },
{ pattern: /\bgenesis determin/gi, replacement: "determin", label: "genesis determin" },
{ pattern: /\bgenesis foundation\b/gi, replacement: "foundation", label: "genesis foundation" },
{ pattern: /\bgenesis default/gi, replacement: "default", label: "genesis default" },
{ pattern: /\bgenesis found/gi, replacement: "found", label: "genesis found" },
{ pattern: /\bgenesis imports/gi, replacement: "imports", label: "genesis imports" },
{ pattern: /\bgenesis `EventBus/gi, replacement: "`EventBus", label: "genesis EventBus" },
{ pattern: /\bmonorepo root directory\b/gi, replacement: "project root directory", label: "monorepo root directory" },
{ pattern: /\bmonorepo sense\b/gi, replacement: "sense", label: "monorepo sense" },
{ pattern: /\bSuaveplan monorepo\./gi, replacement: "Suaveplan.", label: "Suaveplan monorepo." },
{ pattern: /\bStage-1 contracts only\b/gi, replacement: "contracts only", label: "Stage-1 contracts only" },
{ pattern: /\bStage-1 contract package:\b/gi, replacement: "Contract package:", label: "Stage-1 contract package:" },
{ pattern: /\bStage 7\b\./gi, replacement: ".", label: "Stage 7." },
{ pattern: /\bStage 1 \(Contracts\) of the API plugin system umbrella\b/gi, replacement: "Contracts of the API plugin system umbrella", label: "Stage 1 Contracts umbrella" },
{ pattern: /\bStage-\d+'s\b/gi, replacement: "", label: "Stage-N's" },
{ pattern: /\bStage-\d+ must\b/gi, replacement: "must", label: "Stage-N must" },
{ pattern: /\bStage \d+ can be declared complete\b/gi, replacement: "can be declared complete", label: "Stage N declared complete" },
{ pattern: /\bStage \d+ and plug behind\b/gi, replacement: "and plug behind", label: "Stage N plug behind" },
{ pattern: /\b`api-tenant-provider-memory` \(Stage \d+\)/gi, replacement: "`api-tenant-provider-memory`", label: "api-tenant-provider-memory Stage" },
{ pattern: /\bStage \d+\),/gi, replacement: "),", label: "Stage N)," },
{ pattern: /\bStage 1 \(this release\)\b/gi, replacement: "this release", label: "Stage 1 this release" },
{ pattern: /\bStage 1 defines\b/gi, replacement: "defines", label: "Stage 1 defines" },
{ pattern: /\bStage 1 ships\b/gi, replacement: "ships", label: "Stage 1 ships" },
{ pattern: /\bon genesis\b/gi, replacement: "", label: "on genesis" },
{ pattern: /\bgenesis onion-model/gi, replacement: "onion-model", label: "genesis onion-model" },
{ pattern: /\bthe genesis `([^`]+)`/gi, replacement: "the `$1`", label: "the genesis backtick" },
{ pattern: /\bpart of the genesis `([^`]+)`/gi, replacement: "part of the `$1`", label: "part of genesis backtick" },
{ pattern: /\bgenesis uses/gi, replacement: "uses", label: "genesis uses" },
{ pattern: /\b`genesis`\b/gi, replacement: "", label: "backtick genesis" },
{ pattern: /\bGenesis ships\b/gi, replacement: "Ships", label: "Genesis ships" },
{ pattern: /\bGenesis uses/gi, replacement: "Uses", label: "Genesis uses" },
{ pattern: /\bforward reference to Stage \d+\b/gi, replacement: "forward reference", label: "forward reference to Stage" },
{ pattern: /\bin subsequent Stage-\d+ changes?\b/gi, replacement: "in subsequent changes", label: "subsequent Stage changes" },
{ pattern: /\bStage-\d+(?:\.\d+)? (acceptance gate|scope|changes?)\b/gi, replacement: "$1", label: "Stage noun" },
{ pattern: /\bThe Stage-\d+(?:\.\d+)? /gi, replacement: "The ", label: "The Stage-N" },
{ pattern: /\b(Stage \d+(?:\.\d+)?) host\b/gi, replacement: "host", label: "Stage N host" },
{ pattern: /\bStage \d+(?:\.\d+)? (keystone|testing|precondition gate|consumer|DB adapter|tenant resolver|concrete|Apollo Server GraphQL engine adapter|contract package|service-to-service|fixed-principal|trivial session resolver|Better-Auth adapter|concrete JWT|concrete Hono|concrete `Router`|concrete in-memory|concrete WebAssembly|concrete WebSocket|concrete WebSocket broker|concrete Apollo|concrete GraphQL|concrete GraphQL Yoga|concrete static|concrete tenant|concrete database|concrete event|concrete job|concrete logger|concrete manifest|concrete migration|concrete OpenAPI|concrete rate-limit|concrete router|concrete saga|concrete sandbox|concrete secrets|concrete SSE|concrete WS|concrete worker|concrete WS broker|concrete WS Bun)\b/gi, replacement: "$2", label: "Stage N prefix" },
{ pattern: /\bStage \d+(?:\.\d+)? (of the|—|–|-)\s*/gi, replacement: "", label: "Stage N of" },
{ pattern: /\bin Stage \d+(?:\.\d+)?\b/gi, replacement: "", label: "in Stage N" },
{ pattern: /\b\(Stage \d+(?:\.\d+)?[^)]*\)/gi, replacement: "", label: "(Stage N)" },
{ pattern: /\bStage \d+\+\b/gi, replacement: "", label: "Stage N+" },
{ pattern: /\blive in @suaveplan\/[^ ]+ \(Stage \d+\)/gi, replacement: "", label: "live in pkg (Stage N)" },
{ pattern: /\bimplement(s|ed) the contracts here\.?\s*$/gim, replacement: "implements the contracts here.", label: "implements contracts" },
{ pattern: /\bthe migration change at `openspec\/archive\/[^`]+`/gi, replacement: "", label: "migration change openspec" },
{ pattern: /\b`openspec\/archive\/[^`]+`/gi, replacement: "", label: "openspec archive path" },
{ pattern: /\b`openspec\/[^`]+`/gi, replacement: "the spec", label: "openspec path" },
{ pattern: /\b`\.\.\/\.\.\/\.\.\/\.\.\/openspec\/[^`]+`/gi, replacement: "the spec", label: "relative openspec path" },
{ pattern: /\b`\.\.\/openspec\/[^`]+`/gi, replacement: "the spec", label: "relative openspec path" },
{ pattern: /\bsee repository root\b/gi, replacement: "see the repository", label: "see repository root" },
{ pattern: /\bVersion `0\.1\.0` during genesis\.\b/gi, replacement: "", label: "version during genesis" },
{ pattern: /\b`0\.1\.0` \(genesis-original\)\b/gi, replacement: "`0.1.0`", label: "genesis-original version" },
{ pattern: /\b`STAGE_\d+_RESERVED`\b/gi, replacement: "`RESERVED`", label: "STAGE_N_RESERVED" },
{ pattern: /\breserved for Stage \d+\b/gi, replacement: "reserved", label: "reserved for Stage" },
];
for (const { pattern, replacement, label } of phraseReplacements) {
const before = next;
next = next.replace(pattern, replacement);
if (next !== before) edits.push(label);
}
// Clean up double spaces and excess blank lines left behind
next = next.replace(/[ \t]+\n/g, "\n"); // trailing spaces
next = next.replace(/\n{3,}/g, "\n\n"); // 3+ blank lines -> 2
return { next, edits };
}
function main(): void {
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const first10Only = args.includes("--first10");
const readmeOnly = args.includes("--readme");
const todoRaw = readFileSync(TODO_PATH, "utf-8");
let items = parseTodo(todoRaw);
if (readmeOnly) {
items = items.filter((i) => i.path.endsWith("README.md"));
}
if (first10Only) {
items = items.filter((i) => maxLine(i.lines) <= 10);
}
const remaining: TodoItem[] = [];
const processed: { path: string; edits: string[] }[] = [];
const errors: string[] = [];
for (const item of items) {
try {
const text = readFileSync(item.path, "utf-8");
const { next, edits } = scrub(text, item.path);
if (next !== text) {
if (!dryRun) writeFileSync(item.path, next);
processed.push({ path: item.path, edits: [...new Set(edits)] });
} else {
// No changes made — keep in todo for manual review
remaining.push(item);
}
} catch (err) {
errors.push(`${item.path}: ${err instanceof Error ? err.message : String(err)}`);
remaining.push(item);
continue;
}
}
// Rebuild todo with files that were NOT in this batch (original todo minus processed)
const originalItems = parseTodo(todoRaw);
const processedPaths = new Set(processed.map((p) => p.path));
const notProcessed = originalItems.filter((i) => !processedPaths.has(i.path));
if (!dryRun) {
writeFileSync(TODO_PATH, serializeTodo(notProcessed));
}
console.log("");
console.log(`Processed: ${processed.length}`);
console.log(`Remaining: ${notProcessed.length}`);
if (errors.length > 0) console.log(`Errors: ${errors.length}`);
if (processed.length > 0) {
console.log("");
console.log("Files changed:");
for (const { path, edits } of processed) {
console.log(` ${path} (${edits.join(", ")})`);
}
}
if (dryRun) {
console.log("");
console.log("(Dry-run — no files were written, todo not updated.)");
}
}
main();

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env bun
/**
* Import-allowlist conformance gate.
*
* Scans every workspace package's `src/` for imports of
* `@suaveplan/crypto-core`, `@suaveplan/crypto-cipher`,
* `@suaveplan/crypto-curve`, or `@suaveplan/crypto-hash` MAC subpaths.
* Fails with `error` severity for any package not on the committed
* allowlist (`scripts/conformance/crypto-core-allowlist.yml`).
*
* Open imports (always allowed):
* - `@suaveplan/crypto-utils` (encoding helpers)
* - `@suaveplan/crypto-hash` for sha256 / sha384 / sha512 / hkdf /
* pbkdf2 / argon2id (pure-hash + KDF uses); only the MAC subpath
* `@suaveplan/crypto-hash/mac` is gated, because HMAC is the
* primitive that should travel through Authority instead.
*
* Exit codes:
* 0 — all packages comply
* 1 — at least one package outside the allowlist imports a gated module
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(HERE, "..", "..");
const ALLOWLIST_PATH = join(HERE, "crypto-core-allowlist.yml");
const PACKAGES_ROOT = join(REPO_ROOT, "packages");
const GATED_PATTERNS = [
/@suaveplan\/crypto-core(\/[^"'`]*)?/,
/@suaveplan\/crypto-cipher(\/[^"'`]*)?/,
/@suaveplan\/crypto-curve(\/[^"'`]*)?/,
/@suaveplan\/crypto-hash\/mac(\/[^"'`]*)?/,
];
interface AllowlistDoc {
readonly allowlist: readonly string[];
}
function readAllowlist(path: string): AllowlistDoc {
const text = readFileSync(path, "utf8");
const allowlist: string[] = [];
let inAllowlist = false;
for (const raw of text.split(/\r?\n/)) {
const line = raw.trimEnd();
if (line.startsWith("allowlist:")) {
inAllowlist = true;
continue;
}
if (inAllowlist) {
// Section ends at the next top-level key
if (/^\S/.test(line) && !line.startsWith(" ")) {
inAllowlist = false;
continue;
}
const m = line.match(/^\s*-\s+"([^"]+)"\s*$/);
if (m?.[1]) allowlist.push(m[1]);
}
}
return { allowlist };
}
function findPackageRoots(start: string): string[] {
const out: string[] = [];
function walk(dir: string): void {
for (const entry of readdirSync(dir)) {
if (entry.startsWith(".")) continue;
const full = join(dir, entry);
const st = statSync(full);
if (!st.isDirectory()) continue;
if (entry === "node_modules" || entry === "dist") continue;
const pj = join(full, "package.json");
try {
statSync(pj);
out.push(full);
} catch {
walk(full);
}
}
}
walk(start);
return out;
}
function* walkSrcFiles(packageDir: string): Generator<string> {
const src = join(packageDir, "src");
try {
statSync(src);
} catch {
return;
}
function* recurse(d: string): Generator<string> {
for (const entry of readdirSync(d)) {
const full = join(d, entry);
const st = statSync(full);
if (st.isDirectory()) {
yield* recurse(full);
} else if (
full.endsWith(".ts") &&
!full.endsWith(".test.ts") &&
!full.endsWith(".spec.ts")
) {
yield full;
}
}
}
yield* recurse(src);
}
interface Finding {
readonly packageName: string;
readonly file: string;
readonly line: number;
readonly importPath: string;
}
function packageNameOf(packageDir: string): string {
const pj = JSON.parse(
readFileSync(join(packageDir, "package.json"), "utf8")
) as { name?: string };
return pj.name ?? `(unknown)@${relative(REPO_ROOT, packageDir)}`;
}
function scanFile(file: string, packageName: string): Finding[] {
const findings: Finding[] = [];
const text = readFileSync(file, "utf8");
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
if (!line.includes("@suaveplan/crypto-")) continue;
for (const pat of GATED_PATTERNS) {
const m = line.match(pat);
if (m?.[0]) {
findings.push({
packageName,
file: relative(REPO_ROOT, file),
line: i + 1,
importPath: m[0],
});
}
}
}
return findings;
}
export interface AllowlistCheckResult {
readonly violations: readonly Finding[];
}
export function checkAllowlist(): AllowlistCheckResult {
const allow = readAllowlist(ALLOWLIST_PATH);
const allowSet = new Set(allow.allowlist);
const violations: Finding[] = [];
for (const packageDir of findPackageRoots(PACKAGES_ROOT)) {
const name = packageNameOf(packageDir);
if (allowSet.has(name)) continue;
for (const file of walkSrcFiles(packageDir)) {
for (const f of scanFile(file, name)) {
violations.push(f);
}
}
}
return { violations };
}
if (import.meta.main) {
const result = checkAllowlist();
if (result.violations.length === 0) {
console.log("crypto-core allowlist: 0 violations");
process.exit(0);
}
console.error(
`crypto-core allowlist: ${result.violations.length} violation(s)`
);
for (const v of result.violations) {
console.error(
` [error] ${v.packageName} at ${v.file}:${v.line} imports ${v.importPath}`
);
}
process.exit(1);
}

View File

@@ -0,0 +1,77 @@
# crypto-core import allowlist
#
# Packages on this list MAY import directly from any of:
# - @suaveplan/crypto-core (and subpaths)
# - @suaveplan/crypto-cipher (and subpaths)
# - @suaveplan/crypto-curve (and subpaths)
# - @suaveplan/crypto-hash MAC subpaths (hmac and similar)
#
# Every other package MUST go through @suaveplan/crypto's opinionated
# layer (Authority, Packer, Vault). Encoding helpers from
# @suaveplan/crypto-utils and pure-hash uses from @suaveplan/crypto-hash
# (sha256/sha384/sha512) remain open.
#
# Adding a package to this list requires reviewer approval and a
# documented rationale. The allowlist is intentionally short; the
# discipline this gate enforces is the single most important guarantee
# in restore-crypto-keychain-packer.
allowlist:
- "@suaveplan/crypto"
# The opinionated layer itself. Composes primitives into the
# Authority/Packer/Vault APIs and re-exports the primitive surface
# for ergonomic single-package imports.
- "@suaveplan/crypto-cli"
# The keychain CLI. Wraps the opinionated layer for operators.
# Imports are routed through @suaveplan/crypto's barrel only.
- "@suaveplan/crypto-sodium"
# libsodium engine adapter for @suaveplan/crypto-core. Implements
# the SignEngine / AgreeEngine / AeadEngine / AuthBoxEngine /
# SealedBoxEngine interfaces. Engine adapters are by design the
# only direct consumers of the primitive engine types.
- "@suaveplan/crypto-browser"
# WebCrypto engine adapter. Same rationale as crypto-sodium.
- "@suaveplan/bitpacker-crypto"
# SignedBitSchema / EnvelopeSchema / HashChainSchema build on
# primitives directly to provide bit-packed wire format building
# blocks. The opinionated layer (Vault, Packer) is built on top of
# bitpacker-crypto, so it cannot itself depend on the opinionated
# layer without a cycle.
- "@suaveplan/slsa"
# DSSE PAE signature verification. DSSE is governed by an external
# spec (in-toto.io/dsse) without Suaveplan domain tags; the PAE
# bytes ARE the message. Verifying via verifyAuthoritySignature
# would require fabricating a domain tag that the verifier
# contract doesn't have. Builder identity SIGNING (not verifying)
# goes through @suaveplan/crypto's Authority — separate concern.
- "@suaveplan/packer"
# Pre-existing chunked AEAD packer (legacy of restore-crypto). To
# be migrated to @suaveplan/crypto's Packer in a future change;
# allowlisted for now to keep slice 4 reviewable.
- "@suaveplan/drive-plugin-chunks"
# Legacy direct primitive use; same rationale as @suaveplan/packer.
- "@suaveplan/crypto-core"
# crypto-core IS the primitive layer. It composes the
# crypto-cipher / crypto-curve / crypto-hash selector packages
# internally to expose its public API. Self-imports.
- "@suaveplan/streams"
# streams/transforms/crypto consumes crypto-cipher selectors
# directly. Migration to Authority is tracked as a future change.
- "@suaveplan/audit-logging"
# chain-integrity uses HMAC for tamper-evidence. Audit-log MAC is
# a low-level primitive that doesn't fit the Authority model.
# Encoding helpers from @suaveplan/crypto-utils stay open for ALL
# packages. The gate does not flag imports of toHex/fromHex/toBase64/etc.
encoding_helpers_open:
- "@suaveplan/crypto-utils"

340
scripts/convert-barrels.ts Normal file
View File

@@ -0,0 +1,340 @@
#!/usr/bin/env bun
/**
* Convert `export * from "./X"` lines in feature barrels to explicit
* `export { a, b, type C, ... } from "./X"`.
*
* Usage: bun scripts/convert-barrels.ts <barrel-path> [<barrel-path> ...]
*
* Reads each barrel, resolves its `export *` targets relative to its dir,
* extracts top-level exports from each target via the TypeScript compiler
* API, and rewrites the barrel with explicit named re-exports.
*
* Skipped entirely: barrels that contain anything other than `export *` or
* empty lines. Skipped per-target: a target file that fails to parse or
* exposes non-top-level export forms we can't safely round-trip.
*
* Run biome --write afterwards to canonicalise import order.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import * as ts from "typescript";
interface NamedRe {
readonly name: string; // exported identifier name
readonly isType: boolean; // true if the export is type-only
}
function readFile(p: string): string {
return fs.readFileSync(p, "utf8");
}
function topLevelExports(
filePath: string,
visiting: ReadonlySet<string> = new Set()
): NamedRe[] | undefined {
if (visiting.has(filePath)) {
// Cycle in `export *` chain — bail out so we don't loop forever.
return undefined;
}
let source: string;
try {
source = readFile(filePath);
} catch {
return undefined;
}
const sf = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);
const out: NamedRe[] = [];
const seen = new Set<string>();
const push = (name: string, isType: boolean): void => {
if (seen.has(name)) return;
seen.add(name);
out.push({ name, isType });
};
const fileDir = path.dirname(filePath);
const nextVisiting = new Set(visiting);
nextVisiting.add(filePath);
for (const stmt of sf.statements) {
const mods = ts.canHaveModifiers(stmt)
? ts.getModifiers(stmt)
: undefined;
const hasExport = mods?.some(
(m) => m.kind === ts.SyntaxKind.ExportKeyword
);
// Declarations with `export` modifier: const/let/var, function, class,
// interface, type alias, enum, namespace.
if (hasExport === true) {
if (ts.isFunctionDeclaration(stmt) && stmt.name !== undefined) {
push(stmt.name.text, false);
continue;
}
if (ts.isClassDeclaration(stmt) && stmt.name !== undefined) {
push(stmt.name.text, false);
continue;
}
if (ts.isInterfaceDeclaration(stmt)) {
push(stmt.name.text, true);
continue;
}
if (ts.isTypeAliasDeclaration(stmt)) {
push(stmt.name.text, true);
continue;
}
if (ts.isEnumDeclaration(stmt)) {
push(stmt.name.text, false);
continue;
}
if (ts.isModuleDeclaration(stmt) && stmt.name !== undefined) {
push(
ts.isIdentifier(stmt.name)
? stmt.name.text
: stmt.name.text,
false
);
continue;
}
if (ts.isVariableStatement(stmt)) {
for (const d of stmt.declarationList.declarations) {
if (ts.isIdentifier(d.name)) push(d.name.text, false);
// Destructuring patterns intentionally skipped.
}
continue;
}
}
// export { a, type B, c as d } from "..." or `export { ... };`
if (ts.isExportDeclaration(stmt)) {
const isTypeOnlyDecl = stmt.isTypeOnly === true;
if (stmt.exportClause === undefined) {
// `export * from "..."` — recurse into the target so we can
// see what symbols it ultimately exports. This handles
// generated selector chains: `feature/index.ts` →
// `feature/feature.select/feature.select.ts` → `impl/...`
if (
stmt.moduleSpecifier === undefined ||
!ts.isStringLiteral(stmt.moduleSpecifier)
) {
return undefined;
}
const spec = stmt.moduleSpecifier.text;
const resolved = resolveTarget(fileDir, spec);
if (resolved === undefined) return undefined;
const inner = topLevelExports(resolved.absPath, nextVisiting);
if (inner === undefined) return undefined;
for (const n of inner) push(n.name, n.isType || isTypeOnlyDecl);
continue;
}
if (ts.isNamespaceExport(stmt.exportClause)) {
// `export * as ns from "..."` — single named slot.
push(stmt.exportClause.name.text, false);
continue;
}
for (const spec of stmt.exportClause.elements) {
const name = spec.name.text;
push(name, isTypeOnlyDecl || spec.isTypeOnly === true);
}
}
}
return out;
}
interface BarrelLine {
readonly raw: string;
readonly target?: string;
readonly typeOnly?: boolean;
}
function parseBarrel(barrelPath: string): BarrelLine[] | undefined {
// Use the TypeScript parser to walk top-level statements rather than
// line-regex matching — this handles multi-line `export { a, b, c }
// from "..."` blocks, JSDoc comments, and the mixed-content barrels
// (`export * from X` interleaved with `export { Y } from Z`) that
// appear naturally in larger packages.
const text = readFile(barrelPath);
const sf = ts.createSourceFile(
barrelPath,
text,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);
const out: BarrelLine[] = [];
for (const stmt of sf.statements) {
if (
ts.isExportDeclaration(stmt) &&
stmt.exportClause === undefined &&
stmt.moduleSpecifier !== undefined &&
ts.isStringLiteral(stmt.moduleSpecifier)
) {
// `export * from "X"` or `export type * from "X"` — convertible.
out.push({
raw: text.slice(stmt.pos, stmt.end).trimStart(),
target: stmt.moduleSpecifier.text,
typeOnly: stmt.isTypeOnly === true,
});
continue;
}
// Anything else: pass through verbatim. We never rewrite explicit
// export lines, named imports, declarations, etc.
out.push({ raw: text.slice(stmt.pos, stmt.end).trimStart() });
}
return out;
}
function resolveTarget(
barrelDir: string,
spec: string
): { absPath: string; importSpec: string } | undefined {
// Strip `.js` if present — TS sources live alongside as `.ts`.
const relSpec = spec.endsWith(".js") ? spec.slice(0, -3) : spec;
const candidates = [
path.resolve(barrelDir, `${relSpec}.ts`),
path.resolve(barrelDir, `${relSpec}.tsx`),
path.resolve(barrelDir, relSpec, "index.ts"),
];
for (const c of candidates) {
if (fs.existsSync(c)) {
return { absPath: c, importSpec: spec };
}
}
return undefined;
}
function renderExportLine(
importSpec: string,
names: NamedRe[],
typeOnly = false
): string {
if (typeOnly) {
const parts = names.map((n) => n.name);
return `export type { ${parts.join(", ")} } from "${importSpec}";`;
}
const parts = names.map((n) => (n.isType ? `type ${n.name}` : n.name));
return `export { ${parts.join(", ")} } from "${importSpec}";`;
}
interface ConvertOutcome {
readonly path: string;
readonly status: "converted" | "skipped" | "no-violation" | "failed";
readonly reason?: string;
}
function convertBarrel(barrelPath: string): ConvertOutcome {
const lines = parseBarrel(barrelPath);
if (lines === undefined) {
return {
path: barrelPath,
status: "skipped",
reason: "barrel has non-`export *` content",
};
}
const violations = lines.filter((l) => l.target !== undefined);
if (violations.length === 0) {
return { path: barrelPath, status: "no-violation" };
}
const barrelDir = path.dirname(barrelPath);
// Dedupe across the barrel: first occurrence wins. This matches the
// semantics of `export *` collapsing identical re-exports across two
// targets (e.g. when `feature.ts` re-exports types from `feature.types.ts`
// and the barrel forwards both files).
const claimed = new Set<string>();
const newLines: string[] = [];
for (const line of lines) {
if (line.target === undefined) {
newLines.push(line.raw);
continue;
}
const resolved = resolveTarget(barrelDir, line.target);
if (resolved === undefined) {
return {
path: barrelPath,
status: "failed",
reason: `cannot resolve "${line.target}"`,
};
}
const exports = topLevelExports(resolved.absPath);
if (exports === undefined) {
return {
path: barrelPath,
status: "failed",
reason: `cannot enumerate exports of "${line.target}" (target itself contains re-export *)`,
};
}
const fresh = exports.filter((e) => {
if (claimed.has(e.name)) return false;
claimed.add(e.name);
return true;
});
if (fresh.length === 0) {
// Every name from this target was already claimed by an earlier
// target — drop the line entirely; it would be a no-op duplicate.
continue;
}
newLines.push(
renderExportLine(resolved.importSpec, fresh, line.typeOnly === true)
);
}
if (claimed.size === 0) {
return {
path: barrelPath,
status: "failed",
reason: "no top-level exports found across any target",
};
}
fs.writeFileSync(
barrelPath,
`${newLines.join("\n").replace(/\n+$/, "")}\n`
);
return { path: barrelPath, status: "converted" };
}
function main(): void {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error(
"usage: bun scripts/convert-barrels.ts <barrel> [<barrel> ...]"
);
process.exit(2);
}
let converted = 0;
let skipped = 0;
let failed = 0;
let none = 0;
for (const a of args) {
const out = convertBarrel(path.resolve(a));
switch (out.status) {
case "converted":
converted++;
console.log(`✓ ${out.path}`);
break;
case "skipped":
skipped++;
console.log(`- ${out.path} (${out.reason})`);
break;
case "no-violation":
none++;
break;
case "failed":
failed++;
console.error(`✗ ${out.path} (${out.reason})`);
break;
}
}
console.log(
`\nDone: ${converted} converted, ${skipped} skipped, ${none} no-violation, ${failed} failed`
);
if (failed > 0) process.exit(1);
}
main();

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Convert all `export * from` violations in one package's feature barrels,
# then run biome --write to canonicalise import order.
#
# Usage: bash scripts/convert-package-barrels.sh <pkg-rel-path>
# e.g. bash scripts/convert-package-barrels.sh packages/crypto/crypto-hash
#
# Skips:
# - src/index.ts (package root, out of scope per change spec)
# - *.select/index.ts (generated forwarders, allowed by §6a)
set -euo pipefail
PKG_DIR="${1:?usage: $0 <pkg-rel-path>}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ABS_PKG="${ROOT}/${PKG_DIR}"
if [ ! -d "${ABS_PKG}/src" ]; then
echo "no src/ in ${ABS_PKG}" >&2
exit 1
fi
# Pass 1: convert all leaf barrels.
mapfile -t FILES < <(
grep -rln "^export \* from" "${ABS_PKG}/src" --include="*.ts" 2>/dev/null \
| grep -v "/src/index\.ts$" \
| grep -v "\.select/" \
| sort -u
)
if [ "${#FILES[@]}" -eq 0 ]; then
echo "no violations in ${PKG_DIR} — running biome anyway"
cd "${ABS_PKG}"
for i in 1 2 3; do
bunx biome check --write --max-diagnostics 1000 src/ 2>&1 | tail -1
done
exit 0
fi
echo "Pass 1: ${#FILES[@]} barrels in ${PKG_DIR}"
bun "${ROOT}/scripts/convert-barrels.ts" "${FILES[@]}" || true
# Pass 2: handle intermediate barrels whose targets have just become explicit.
mapfile -t REMAINING < <(
grep -rln "^export \* from" "${ABS_PKG}/src" --include="*.ts" 2>/dev/null \
| grep -v "/src/index\.ts$" \
| grep -v "\.select/" \
| sort -u
)
if [ "${#REMAINING[@]}" -gt 0 ]; then
echo "Pass 2: ${#REMAINING[@]} intermediate barrels"
bun "${ROOT}/scripts/convert-barrels.ts" "${REMAINING[@]}" || true
fi
# Pass 3: third pass for deeply-nested barrel chains.
mapfile -t LEFT < <(
grep -rln "^export \* from" "${ABS_PKG}/src" --include="*.ts" 2>/dev/null \
| grep -v "/src/index\.ts$" \
| grep -v "\.select/" \
| sort -u
)
if [ "${#LEFT[@]}" -gt 0 ]; then
echo "Pass 3: ${#LEFT[@]} barrels remain"
bun "${ROOT}/scripts/convert-barrels.ts" "${LEFT[@]}" || true
fi
# Final check.
FINAL=$(grep -rln "^export \* from" "${ABS_PKG}/src" --include="*.ts" 2>/dev/null \
| grep -v "/src/index\.ts$" \
| grep -v "\.select/" \
| wc -l)
if [ "${FINAL}" -gt 0 ]; then
echo "WARNING: ${FINAL} barrels still have violations after 3 passes:"
grep -rln "^export \* from" "${ABS_PKG}/src" --include="*.ts" 2>/dev/null \
| grep -v "/src/index\.ts$" \
| grep -v "\.select/"
fi
# Biome auto-fix — run unconditionally for 3 passes to handle layered
# auto-fixes (e.g. organize-imports surfacing format issues that surface
# further organize-imports).
cd "${ABS_PKG}"
for i in 1 2 3; do
bunx biome check --write --max-diagnostics 1000 src/ 2>&1 | tail -1
done

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Copy yet-to-be-migrated packages from ../suaveplan/packages/<tier>/<pkg>/ into
# wip/<category>/<pkg>/, where <category> comes from scripts/folder-reorg/assignment.yml
# (the source of truth for the eventual packages/<category>/<pkg>/ destination).
#
# Skips packages already present in packages/<category>/<pkg>/.
# Pre-cleans ../suaveplan of node_modules / .turbo / coverage / dist to speed cp -r.
#
# Usage: bash scripts/copy-wip-from-suaveplan.sh [--dry-run] [--no-clean]
set -euo pipefail
GENESIS="/home/paul/web/genesis"
SUAVEPLAN="/home/paul/web/suaveplan"
ASSIGNMENT="$GENESIS/scripts/folder-reorg/assignment.yml"
WIP="$GENESIS/wip"
DRY=0
CLEAN=1
for arg in "$@"; do
case "$arg" in
--dry-run) DRY=1 ;;
--no-clean) CLEAN=0 ;;
*) echo "unknown flag: $arg" >&2; exit 2 ;;
esac
done
run() {
if [[ $DRY -eq 1 ]]; then
echo "DRY: $*"
else
eval "$@"
fi
}
# ---------------------------------------------------------------------------
# 1. Pre-clean ../suaveplan to speed up copying.
# ---------------------------------------------------------------------------
if [[ $CLEAN -eq 1 ]]; then
echo "==> Pruning node_modules / .turbo / coverage / dist under $SUAVEPLAN"
# -prune avoids descending into already-matched dirs.
run "find '$SUAVEPLAN' \\( \
-type d -name node_modules -o \
-type d -name .turbo -o \
-type d -name coverage -o \
-type d -name dist \
\\) -prune -exec rm -rf {} +"
fi
# ---------------------------------------------------------------------------
# 2. Parse assignment.yml -> emit "<category> <pkg>" lines.
# Format is plain YAML:
# category:
# - pkg-a
# - pkg-b
# Skip __retired__ / __retired_categories__ / blank / comment lines.
# ---------------------------------------------------------------------------
pairs() {
awk '
/^[[:space:]]*#/ { next }
/^[[:space:]]*$/ { next }
/^[A-Za-z][A-Za-z0-9_-]*:[[:space:]]*$/ {
cat = $1
sub(/:$/, "", cat)
if (cat == "__retired__" || cat == "__retired_categories__") {
cat = ""
}
next
}
/^[[:space:]]*-[[:space:]]+/ {
if (cat == "") next
pkg = $0
sub(/^[[:space:]]*-[[:space:]]+/, "", pkg)
sub(/[[:space:]].*$/, "", pkg)
print cat, pkg
}
' "$ASSIGNMENT"
}
# ---------------------------------------------------------------------------
# 3. For each (category, pkg): copy if missing in packages/, present in suaveplan,
# and not already in wip/.
# ---------------------------------------------------------------------------
copied=0
skipped_present=0
skipped_in_wip=0
skipped_no_source=0
while read -r category pkg; do
[[ -z "$category" || -z "$pkg" ]] && continue
dest_final="$GENESIS/packages/$category/$pkg"
dest_wip="$WIP/$category/$pkg"
if [[ -d "$dest_final" ]]; then
skipped_present=$((skipped_present + 1))
continue
fi
if [[ -d "$dest_wip" ]]; then
skipped_in_wip=$((skipped_in_wip + 1))
continue
fi
# Find source under any tier: packages/{universal,browser,react,server,three,finance,mcp,python}/<pkg>
src=""
for tier_dir in "$SUAVEPLAN"/packages/*/; do
candidate="${tier_dir}${pkg}"
if [[ -d "$candidate" ]]; then
src="$candidate"
break
fi
done
if [[ -z "$src" ]]; then
skipped_no_source=$((skipped_no_source + 1))
continue
fi
echo " copy: $src -> wip/$category/$pkg"
run "mkdir -p '$WIP/$category'"
run "cp -r '$src' '$dest_wip'"
copied=$((copied + 1))
done < <(pairs)
echo
echo "==> Done"
echo " copied : $copied"
echo " skipped (in packages/): $skipped_present"
echo " skipped (in wip/) : $skipped_in_wip"
echo " skipped (no source) : $skipped_no_source"

122
scripts/crypto-fuzz.ts Normal file
View File

@@ -0,0 +1,122 @@
/**
* Nightly differential-fuzz driver. For every registered primitive that has
* BOTH a noble and a native impl not stubbed out with `PrimitiveNotImplemented`,
* run `diffFuzz` native-vs-noble over N random inputs. Fails the run if any
* mismatch is observed.
*
* In Phase 0 every native is a stub, so this driver iterates every primitive,
* probes the native impl, catches `PrimitiveNotImplemented`, skips, and exits
* clean. The wiring fires the moment a real native lands.
*/
import { diffFuzz } from "../universal/crypto/crypto-utils/src/testing/diff-fuzz/diff-fuzz.ts";
/** Stable code emitted by `PrimitiveNotImplemented`. */
const NOT_IMPLEMENTED_CODE = "CRYPTO.UTILS.PRIMITIVE_NOT_IMPLEMENTED";
interface PrimitiveJob {
readonly id: string;
readonly category: "hash" | "curve" | "cipher";
readonly run: () => Promise<void>;
}
const FUZZ_ITERATIONS = Number.parseInt(
process.env.CRYPTO_FUZZ_ITERATIONS ?? "500",
10
);
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function randomBytes(len: number): Uint8Array {
const buf = new Uint8Array(len);
crypto.getRandomValues(buf);
return buf;
}
async function loadImpls<T>(
noblePath: string,
nativePath: string,
pick: (mod: Record<string, unknown>) => T
): Promise<{ noble: T; native: T } | "pending"> {
const [nobleMod, nativeMod] = await Promise.all([
import(noblePath),
import(nativePath),
]);
const noble = pick(nobleMod as Record<string, unknown>);
const native = pick(nativeMod as Record<string, unknown>);
// Probe the native with a throwaway call — if it raises
// PrimitiveNotImplemented, skip this primitive.
try {
(native as (x: Uint8Array) => unknown)(new Uint8Array(1));
} catch (err) {
if (
typeof err === "object" &&
err !== null &&
(err as { code?: unknown }).code === NOT_IMPLEMENTED_CODE
) {
return "pending";
}
// Unrelated failure — let it propagate so it's investigated.
}
return { noble, native };
}
async function fuzzHashOneShot(id: string): Promise<void> {
console.log(`[fuzz] ${id}: loading impls…`);
const noblePath = `../universal/crypto/crypto-hash/src/primitives/${id}/impl/${id}.noble.ts`;
const nativePath = `../universal/crypto/crypto-hash/src/primitives/${id}/impl/${id}.native.ts`;
const impls = await loadImpls<(data: Uint8Array) => Uint8Array>(
noblePath,
nativePath,
(m) => m[id] as (data: Uint8Array) => Uint8Array
);
if (impls === "pending") {
console.log(
`[fuzz] ${id}: native is PrimitiveNotImplemented, skipping`
);
return;
}
const result = diffFuzz<Uint8Array, Uint8Array>({
iterations: FUZZ_ITERATIONS,
sample: (_, i) => randomBytes((i % 256) + 1),
implA: impls.noble,
implB: impls.native,
equals: bytesEqual,
});
if (result.mismatches > 0) {
console.error(`[fuzz] ${id}: ${result.mismatches} mismatches`);
console.error(result.firstMismatch);
process.exit(1);
}
console.log(
`[fuzz] ${id}: ${result.iterations} iterations OK (0 mismatches)`
);
}
const HASH_ONE_SHOT_IDS = ["sha256", "sha384", "sha512"] as const;
async function main(): Promise<void> {
const jobs: PrimitiveJob[] = HASH_ONE_SHOT_IDS.map((id) => ({
id,
category: "hash",
run: () => fuzzHashOneShot(id),
}));
// Note: multi-algo primitives (hmac, hkdf, pbkdf2, argon2id), curves, and
// ciphers need richer sample builders. As their natives land the driver
// should be extended to call them. For now every native throws
// PrimitiveNotImplemented so the skip-on-probe path covers them via the
// per-impl loader — we simply do not register them here yet because the
// noble/native signatures differ from the one-shot hash shape.
for (const job of jobs) {
await job.run();
}
console.log("[fuzz] done");
}
if (import.meta.main) {
await main();
}

87
scripts/crypto-timing.ts Normal file
View File

@@ -0,0 +1,87 @@
/**
* Nightly dudect-style timing driver. For every native impl that is not a
* `PrimitiveNotImplemented` stub, sample its runtime across fixed vs. random
* inputs and flag t-statistics above the threshold as potential leaks.
*
* In Phase 0 every native is a stub — the driver probes each, catches
* `PrimitiveNotImplemented`, skips, and exits clean. The wiring fires the
* moment a real native lands.
*/
import { dudect } from "../universal/crypto/crypto-utils/src/testing/dudect/dudect.ts";
/** Stable code emitted by `PrimitiveNotImplemented`. */
const NOT_IMPLEMENTED_CODE = "CRYPTO.UTILS.PRIMITIVE_NOT_IMPLEMENTED";
const MEASUREMENTS = Number.parseInt(
process.env.CRYPTO_TIMING_MEASUREMENTS ?? "20000",
10
);
const T_THRESHOLD = Number.parseFloat(
process.env.CRYPTO_TIMING_T_THRESHOLD ?? "4.5"
);
function randomBytes(len: number): Uint8Array {
const buf = new Uint8Array(len);
crypto.getRandomValues(buf);
return buf;
}
async function probeAndRun(
id: string,
category: "hash",
sampleFixed: () => unknown,
sampleRandom: () => unknown,
pickFn: (mod: Record<string, unknown>) => (input: unknown) => unknown
): Promise<void> {
const nativePath = `../universal/crypto/crypto-${category}/src/primitives/${id}/impl/${id}.native.ts`;
const mod = (await import(nativePath)) as Record<string, unknown>;
const fn = pickFn(mod);
try {
fn(sampleFixed());
} catch (err) {
if (
typeof err === "object" &&
err !== null &&
(err as { code?: unknown }).code === NOT_IMPLEMENTED_CODE
) {
console.log(
`[timing] ${id}: native is PrimitiveNotImplemented, skipping`
);
return;
}
throw err;
}
const result = dudect({
fn,
sampleFixed,
sampleRandom,
measurements: MEASUREMENTS,
tThreshold: T_THRESHOLD,
});
console.log(
`[timing] ${id}: t=${result.tStatistic.toFixed(3)} (threshold ${result.tThreshold}) → ${
result.constantTime ? "OK" : "LEAK"
}`
);
if (!result.constantTime) process.exit(1);
}
const HASH_ONE_SHOT_IDS = ["sha256", "sha384", "sha512"] as const;
async function main(): Promise<void> {
for (const id of HASH_ONE_SHOT_IDS) {
await probeAndRun(
id,
"hash",
() => new Uint8Array(64), // class A: all zeros, fixed length
() => randomBytes(64), // class B: random bytes, same length
(mod) => mod[id] as (input: unknown) => unknown
);
}
console.log("[timing] done");
}
if (import.meta.main) {
await main();
}

154
scripts/ct-lint.ts Normal file
View File

@@ -0,0 +1,154 @@
/**
* Constant-time lint. Scans every `packages/universal/crypto-*\/src/primitives/
* <id>/impl/<id>.native.ts` for the obvious class of data-dependent bugs:
*
* (a) secret-indexed array access: `arr[secret]` or `secret[index]` where
* the indexer or the base is a parameter conventionally holding secret
* bytes (key / nonce / secret / priv / msg).
* (b) early returns on secret comparisons: `if (secretByte === x) return …`.
* (c) data-dependent branches on parameters typed as secret / key / nonce:
* `if (key[i] !== …) …`.
*
* This is a regex-grade scan, not a full AST analysis. It catches the
* obvious class of bugs and is cheap enough to run on every precommit and
* in CI. Missing a subtle leak here is not failure; a false positive that
* blocks a commit IS, so the patterns are tight and the names list is
* conservative. Natives should be reviewed by hand regardless.
*
* For Phase 0 every native.ts is a `PrimitiveNotImplemented` stub, so the
* lint passes cleanly. As natives land this file becomes the first gate.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const REPO_ROOT = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const SECRET_NAME_RE =
/(?:secret|key|priv|private|nonce|iv|msg|message|password|salt|seed)/i;
interface Finding {
readonly file: string;
readonly line: number;
readonly rule: "secret-indexed" | "secret-early-return" | "secret-branch";
readonly evidence: string;
}
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) yield* walk(full);
else if (full.endsWith(".native.ts")) yield full;
}
}
function listNativeFiles(): string[] {
const out: string[] = [];
const pkgRoot = join(REPO_ROOT, "universal/auth");
for (const pkg of readdirSync(pkgRoot)) {
if (!pkg.startsWith("crypto-")) continue;
const primitivesDir = join(pkgRoot, pkg, "src", "primitives");
try {
statSync(primitivesDir);
} catch {
continue;
}
for (const f of walk(primitivesDir)) out.push(f);
}
return out;
}
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:"'`])\/\/[^\n]*/g, "$1");
}
function scan(source: string): Finding[] {
const cleaned = stripComments(source);
const lines = cleaned.split("\n");
const raw = source.split("\n");
const findings: Finding[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (!line.trim()) continue;
// Rule (a): base[secret] — indexer name matches a secret convention.
const indexRe =
/([A-Za-z_$][A-Za-z0-9_$]*)\s*\[\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\]/g;
for (const m of line.matchAll(indexRe)) {
const base = m[1]!;
const idx = m[2]!;
if (SECRET_NAME_RE.test(idx) || SECRET_NAME_RE.test(base)) {
findings.push({
file: "",
line: i + 1,
rule: "secret-indexed",
evidence: raw[i]!.trim(),
});
}
}
// Rule (b): early return gated on a comparison involving a
// secret-named identifier.
if (
/\bif\s*\([^)]*\b(?:secret|key|priv|private|nonce|iv|password)[A-Za-z0-9_$]*\s*(?:===|!==|==|!=)/i.test(
line
) &&
/return\b/.test(line)
) {
findings.push({
file: "",
line: i + 1,
rule: "secret-early-return",
evidence: raw[i]!.trim(),
});
}
// Rule (c): `if (secret…)` / `if (priv…)` / `if (nonce…)` branches.
// Exempt `.length` — caller-visible length is not secret.
const branchMatch = /\bif\s*\(([^)]*)\)/.exec(line);
if (branchMatch) {
const cond = branchMatch[1]!;
const condWithoutLength = cond.replace(/\.length\b/g, "");
if (
/\b(?:secret|priv|private|nonce|iv)\w*/i.test(condWithoutLength)
) {
findings.push({
file: "",
line: i + 1,
rule: "secret-branch",
evidence: raw[i]!.trim(),
});
}
}
}
return findings;
}
async function main(): Promise<void> {
const files = listNativeFiles();
const all: Finding[] = [];
for (const file of files) {
const source = readFileSync(file, "utf8");
const rel = relative(REPO_ROOT, file);
for (const f of scan(source)) {
all.push({ ...f, file: rel });
}
}
if (all.length === 0) {
console.log(`ct-lint: OK (${files.length} native files scanned)`);
return;
}
console.error(`ct-lint: ${all.length} finding(s):`);
for (const f of all) {
console.error(` ${f.file}:${f.line} [${f.rule}] ${f.evidence}`);
}
process.exit(1);
}
if (import.meta.main) {
await main();
}

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env bun
/**
* delib-monorepo-refs — scrub genesis / monorepo / stage / OpenSpec metadata
* from individual library README.md files so they read as standalone packages.
*
* Usage:
* bun scripts/delib-monorepo-refs.ts # rewrite
* bun scripts/delib-monorepo-refs.ts --dry-run # preview
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { CONFIG } from "./lib/config.js";
import { walkMd } from "./lib/doc-tree.js";
const ROOT = CONFIG.paths.root;
const PACKAGES_ROOT = join(ROOT, CONFIG.paths.packagesRoot);
const REPLACEMENTS: { pattern: RegExp; replacement: string; note?: string }[] = [
// 1. Strip OpenSpec link blocks (won't resolve on npm)
{
pattern: /\n?<!-- begin:auto-spec-link -->[\s\S]*?<!-- end:auto-spec-link -->\n?/g,
replacement: "\n",
note: "OpenSpec block",
},
// 2. Stand-alone "## OpenSpec" heading + following spec-link line
{
pattern: /\n?## OpenSpec\n\nCanonical capability spec: `?[^`\n]+`?\.[ \t]*\n?/g,
replacement: "\n",
note: "OpenSpec heading",
},
// 3. Remove Stage-N.N prefix from H1 title lines
{
pattern:
/^(# @suaveplan\/[\w-]+)\s+-\s+Stage-?\d+(?:\.\d+)?\s+/gim,
replacement: "$1 — ",
note: "Stage in H1",
},
// 4. Remove Stage-N.N at start of sentences / lines (common prose prefix)
{
pattern:
/([.\n]|^)\s*Stage-?\d+(?:\.\d+)?\s+(concrete|abstract|trivial|bridge|rolled|Better-Auth|contract|package|audit|fixed-principal|session resolver|JWT|service-to-service|in-memory|reference|HTTP|WebSocket|in-process|fixed-Principal|concrete Hono|concrete GraphQL|concrete WebAssembly|concrete Apollo|concrete static|concrete tenant|concrete database|concrete event|concrete job|concrete logger|concrete manifest|concrete migration|concrete OpenAPI|concrete rate-limit|concrete router|concrete saga|concrete sandbox|concrete secrets|concrete SSE|concrete WS|concrete worker|concrete WS broker|concrete WS Bun)\b/gi,
replacement: "$1$2",
note: "Stage prefix",
},
// 5. Remove orphaned "Stage-N.N" that stands alone mid-sentence
{
pattern: /\bStage-?\d+(?:\.\d+)?\b/gi,
replacement: "",
note: "orphaned Stage",
},
// 6. "Genesis Stage N — …" sentences
{
pattern:
/\bGenesis Stage \d+(?:\.\d+)?[^.]*\.\s*/gi,
replacement: "",
note: "Genesis Stage sentence",
},
// 7. "Genesis-wave package …" sentences
{
pattern: /\bGenesis-wave package[^.]*\.\s*/gi,
replacement: "",
note: "Genesis-wave sentence",
},
// 8. "Per design doc §N.N, genesis ships …" sentences
{
pattern:
/\bPer design doc §\d+(?:\.\d+)*,\s+genesis\s+[^.]*\.\s*/gi,
replacement: "",
note: "design-doc genesis sentence",
},
// 9. "the Suaveplan monorepo" / "for the Suaveplan monorepo"
{
pattern: /\bfor the Suaveplan monorepo\b/gi,
replacement: "for Suaveplan",
note: "monorepo ref",
},
{
pattern: /\bthe Suaveplan monorepo\b/gi,
replacement: "Suaveplan",
note: "monorepo ref",
},
// 10. "the genesis monorepo"
{
pattern: /\bthe genesis monorepo\b/gi,
replacement: "Suaveplan",
note: "genesis monorepo",
},
// 11. "genesis `@suaveplan/...`" -> just the package name
{
pattern: /\bgenesis\s+(`@suaveplan\/[^`]+`)/gi,
replacement: "$1",
note: "genesis pkg prefix",
},
// 12. "for the `@suaveplan/api` plugin foundation"
{
pattern:
/\bfor the `@suaveplan\/api` plugin foundation\b/gi,
replacement: "for `@suaveplan/api`",
note: "plugin foundation",
},
{
pattern:
/\bthe `@suaveplan\/api` plugin foundation\b/gi,
replacement: "`@suaveplan/api`",
note: "plugin foundation",
},
{
pattern: /\bplugin foundation\b/gi,
replacement: "plugin system",
note: "plugin foundation",
},
// 13. Loose "genesis" when clearly referring to the project (not a name)
{
pattern: /\bgenesis (fix|package|design|crypto|wave|monorepo)\b/gi,
replacement: "$1",
note: "genesis adjective",
},
// 14. "openspec/changes/..." paths in prose
{
pattern: /`?\.\.\/\.\.\/\.\.\/\.\.\/openspec\/[^`\n]+`?/g,
replacement: "the spec",
note: "openspec path",
},
// 15. "OpenSpec change" / "OpenSpec-tracked"
{
pattern: /\bOpenSpec change\b/gi,
replacement: "spec change",
note: "OpenSpec change",
},
{
pattern: /\bOpenSpec-tracked\b/gi,
replacement: "tracked",
note: "OpenSpec-tracked",
},
// 16. "of the umbrella plan (openspec/changes/...)"
{
pattern:
/\bof the umbrella plan\s*\([^)]+openspec[^)]+\)\b/gi,
replacement: "of the design",
note: "umbrella plan",
},
// 17. "Stage N of the umbrella plan"
{
pattern:
/\bStage \d+(?:\.\d+)?\s+of the umbrella plan\b/gi,
replacement: "the design",
note: "Stage umbrella plan",
},
];
function scrub(content: string): { next: string; hits: string[] } {
let next = content;
const hits: string[] = [];
for (const { pattern, replacement, note } of REPLACEMENTS) {
const before = next;
next = next.replace(pattern, replacement);
if (next !== before && note) {
hits.push(note);
}
}
// Collapse multiple blank lines left behind
next = next.replace(/\n{3,}/g, "\n\n");
return { next, hits };
}
function main(): void {
const dryRun = process.argv.slice(2).includes("--dry-run");
const readmes = walkMd(PACKAGES_ROOT, {
ignoreDirs: ["node_modules", "dist", "build", "coverage", ".turbo"],
}).filter((p) => basename(p) === "README.md");
let changed = 0;
let unchanged = 0;
const report: string[] = [];
for (const readmePath of readmes) {
const content = readFileSync(readmePath, "utf-8");
const { next, hits } = scrub(content);
if (next === content) {
unchanged++;
continue;
}
const rel = readmePath.replace(ROOT + "/", "");
const uniqueHits = [...new Set(hits)];
report.push(`${rel} (${uniqueHits.join(", ")})`);
if (!dryRun) {
writeFileSync(readmePath, next);
}
changed++;
}
console.log("");
console.log(`✏️ Changed: ${changed}`);
console.log(`⏭️ Unchanged: ${unchanged}`);
if (report.length > 0) {
console.log("");
console.log("Files touched:");
for (const line of report) console.log(` ${line}`);
}
if (dryRun) {
console.log("");
console.log("(Dry-run — no files were written.)");
}
}
function basename(p: string): string {
return p.slice(p.lastIndexOf("/") + 1);
}
main();

178
scripts/docs-check.ts Normal file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env bun
/**
* docs-check — verify every non-excluded `.ts(x)` implementation file in a
* package's src/ tree has a co-located sibling `.md` meeting CLAUDE.md rules:
*
* - Required headings (H2 or H3): Purpose, Features, Usage (or Basic Example
* + Advanced Example), API Reference, Implementation Notes
* - >=200 words excluding fenced code blocks
*
* Usage: bun scripts/docs-check.ts [<package-dir>] (defaults to cwd)
* Exits 0 on full pass, 1 on any violation.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
type Violation = { file: string; reason: string };
const EXCLUDE_FILE = [
/\.test\.tsx?$/,
/\.spec\.tsx?$/,
/\.stories\.tsx?$/,
/\.bench\.ts$/,
/\.d\.ts$/,
];
const REQUIRED_HEADINGS: { label: string; test: (h: string[]) => boolean }[] = [
{ label: "Purpose", test: (h) => h.some((x) => /^purpose\b/.test(x)) },
{ label: "Features", test: (h) => h.some((x) => /^features\b/.test(x)) },
{
label: "Usage (basic + advanced)",
test: (h) => {
const hasBasic = h.some((x) => /basic\s+example/.test(x));
const hasAdvanced = h.some((x) => /advanced\s+example/.test(x));
const hasUsage = h.some((x) => /^usage\b/.test(x));
return (hasBasic && hasAdvanced) || hasUsage;
},
},
{
label: "API Reference",
test: (h) => h.some((x) => /api\s+reference/.test(x)),
},
{
label: "Implementation Notes",
test: (h) => h.some((x) => /implementation\s+notes/.test(x)),
},
];
function walk(dir: string, out: string[] = []): string[] {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return out;
}
for (const name of entries) {
if (name === "node_modules" || name === "dist" || name === "coverage")
continue;
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) walk(p, out);
else out.push(p);
}
return out;
}
function isExcluded(file: string, srcRoot: string): boolean {
const name = basename(file);
if (EXCLUDE_FILE.some((rx) => rx.test(name))) return true;
const rel = relative(srcRoot, file);
const parts = rel.split(sep);
if (parts[0] === "constants") return true;
if (parts.some((p) => p === "__benchmarks__" || p === "__fixtures__"))
return true;
if (name === "index.ts" || name === "index.tsx") return true;
if (/\.types\.tsx?$/.test(name)) {
if (parts[0] === "types" && parts.length === 2) return false;
return true;
}
// *.schemas.ts — co-located Zod schemas are pure data-shape definitions,
// semantically equivalent to *.types.ts. Documented via the sibling feature
// module's .md (e.g. decay.md covers decay.ts AND decay.schemas.ts).
if (/\.schemas\.tsx?$/.test(name)) return true;
return false;
}
function stripCodeBlocks(md: string): string {
return md.replace(/```[\s\S]*?```/g, "").replace(/~~~[\s\S]*?~~~/g, "");
}
function extractHeadings(md: string): string[] {
const out: string[] = [];
for (const line of md.split(/\r?\n/)) {
const m = /^(#{2,3})\s+(.+?)\s*#*\s*$/.exec(line);
if (m) out.push(m[2].toLowerCase().trim());
}
return out;
}
function wordCount(text: string): number {
const matches = text.match(/\b[\p{L}\p{N}][\p{L}\p{N}'_-]*\b/gu);
return matches ? matches.length : 0;
}
function validateMarkdown(mdPath: string): string[] {
const raw = readFileSync(mdPath, "utf8");
const stripped = stripCodeBlocks(raw);
const words = wordCount(stripped);
const headings = extractHeadings(raw);
const issues: string[] = [];
for (const req of REQUIRED_HEADINGS) {
if (!req.test(headings)) issues.push(`missing section: ${req.label}`);
}
if (words < 200)
issues.push(`word count ${words} < 200 (excl. code blocks)`);
return issues;
}
function main(): number {
const pkgDir = resolve(process.argv[2] ?? ".");
const srcRoot = join(pkgDir, "src");
let files: string[];
try {
statSync(srcRoot);
files = walk(srcRoot);
} catch {
console.log(`[docs-check] no src/ at ${pkgDir} - skipping`);
return 0;
}
const candidates = files.filter(
(f) =>
/\.tsx?$/.test(f) && !/\.d\.ts$/.test(f) && !isExcluded(f, srcRoot)
);
const violations: Violation[] = [];
let passed = 0;
for (const ts of candidates) {
const md = ts.replace(/\.tsx?$/, ".md");
const rel = relative(pkgDir, ts);
try {
statSync(md);
} catch {
violations.push({ file: rel, reason: "missing sibling .md" });
console.log(` MISS ${rel} - missing sibling .md`);
continue;
}
const issues = validateMarkdown(md);
if (issues.length > 0) {
for (const issue of issues) {
violations.push({ file: rel, reason: issue });
console.log(` FAIL ${rel} - ${issue}`);
}
} else {
passed++;
}
}
const total = candidates.length;
const pct = total === 0 ? 100 : Math.round((passed / total) * 100);
const pkgName = basename(dirname(srcRoot)) || basename(pkgDir);
console.log(
`[${pkgName}] Docs coverage: ${passed}/${total} files (${pct}%)`
);
return violations.length === 0 ? 0 : 1;
}
process.exit(main());

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env bun
/**
* ensure-category-subcategory-intros — writes placeholder `_intro.md` files
* for every category and subcategory that lacks one. Existing files are never
* overwritten.
*
* Usage: bun scripts/ensure-category-subcategory-intros.ts
*/
import { existsSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { CONFIG } from "./lib/config.js";
import { isDirectorySafe } from "./lib/doc-tree.js";
const ROOT = CONFIG.paths.root;
const PACKAGES_ROOT = join(ROOT, CONFIG.paths.packagesRoot);
const CATEGORY_INTROS: Record<string, string> = {
"api-stack":
"# `api-stack`\n\nAPI infrastructure, plugin framework, and HTTP orchestration layer for the Suaveplan monorepo.",
commerce:
"# `commerce`\n\nBooking engines, contact management, payment plugins, and commerce primitives.",
communication:
"# `communication`\n\nEmail delivery, messaging channels, and webhook clients.",
devtools:
"# `devtools`\n\nDeveloper tooling, documentation generators, Docker helpers, and build utilities.",
foundation:
"# `foundation`\n\nFoundational primitives — types, errors, configuration, data structures, and cross-cutting utilities.",
gaming: "# `gaming`\n\nGame engines, entity-component systems, and simulation libraries.",
geospatial:
"# `geospatial`\n\nGeospatial data, mapping, planetary coordinates, and logistics utilities.",
identity:
"# `identity`\n\nAuthentication, authorization, identity resolution, and credential management.",
localization:
"# `localization`\n\nInternationalization, localization, locale detection, and translation infrastructure.",
media: "# `media`\n\nAudio, video, image, and font processing libraries.",
money: "# `money`\n\nPayments, currencies, cryptocurrency, and financial primitives.",
networking:
"# `networking`\n\nDistributed networking, event buses, drive mesh, and network discovery.",
observability:
"# `observability`\n\nMonitoring, logging, health checks, audit trails, and analytics.",
persistence:
"# `persistence`\n\nCaching, database adapters, search engines, and storage abstractions.",
"security-stack":
"# `security-stack`\n\nCryptography, vaults, security utilities, and threat-detection primitives.",
testkit:
"# `testkit`\n\nTesting frameworks, A/B testing, and quality-assurance utilities.",
threejs:
"# `threejs`\n\nThree.js and React Three Fiber integrations.",
ui: "# `ui`\n\nReact components, forms, and user-interface primitives.",
};
const SUBCATEGORY_INTROS: Record<string, string> = {
api: "API orchestration, plugin lifecycle, routing, workers, and tenant resolution.",
audio: "Audio processing, playback, analysis, and effect plugins.",
"audio-fx": "Real-time audio effects and DSP plugins.",
"audio-ir": "Impulse-response capture, loading, and room-acoustic plugins.",
auth: "Authentication providers, JWT, WebAuthn, OAuth, and session resolution.",
barcode: "1-D barcode generation and parsing.",
bitpacker:
"Bit-level binary packing, schemas, compression, and streaming codecs.",
booking:
"Universal booking engine with availability, conflict detection, and pricing.",
cache: "Caching abstractions, memory caches, and Redis backends.",
contacts:
"Contact and group management with deduplication and import/export.",
core: "Core foundation — types, errors, configuration, schema registry, and utilities.",
crypto: "Cryptographic primitives, ciphers, hashes, curves, and key management.",
data: "Data manipulation, masking, deduplication, diffing, and byte utilities.",
database: "Database adapters, migrations, seeding, and SQLite support.",
datetime: "Timezone-aware date/time utilities, calendars, and recurrence.",
docker: "Docker helpers and GPU runtime configuration.",
docs: "Documentation utilities, SVG compression, PDF handling, and sitemap generation.",
drive: "Virtual filesystem, mesh networking, and chunk-addressed storage plugins.",
emails:
"Email delivery, DMARC parsing, templates, campaigns, and suppression lists.",
env: "Type-safe environment variable loading and validation.",
error: "Foundation error substrate, code registries, and wire formats.",
etl: "Extract-Transform-Load pipeline engine with typed stages.",
events: "Typed event bus, event sourcing, metrics, and transport bridges.",
"feature-flags": "Feature-flag evaluation engine with percentage and attribute targeting.",
finance:
"Currency conversion, cryptocurrency utilities, and exchange-rate providers.",
flow: "Finite state machines, CRDTs, idempotency, and CBOR serialization.",
fonts: "Font packing, compression, subsetting, and conversion tools.",
games: "Game controllers, entity-component systems, and game-server logic.",
geo: "Geospatial coordinates, planetary data, and solar-system modelling.",
i18n: "Internationalization core, backends, detectors, and locale resolution.",
image: "Image compression, colour conversion, EXIF handling, and optimisation.",
logistics: "Port data and logistics primitives.",
messaging:
"Messaging adapters — Discord, Slack, Telegram, SMS, iMessage, and notifications.",
midi: "MIDI message parsing, control surfaces, and hardware integration.",
migration: "Schema migration runners and ledger tracking.",
monitor: "Logging, health checks, audit logging, and analytics instrumentation.",
network:
"Mesh networking, WebSocket transports, bootstrap, and discovery protocols.",
payment:
"Payment intent lifecycle, provider adapters, and fraud detection.",
ports: "Maritime port and logistics data.",
random: "Pseudo-random generators, noise functions, and deterministic sequences.",
r3f: "React Three Fiber networking and scene components.",
search: "Full-text search, BM25, fuzzy matching, and faceted search.",
security: "Have-I-Been-Pwned checks, JWS, licensing, and OTP utilities.",
storage: "Storage abstractions, browser backends, S3, CDN, and URI routing.",
testing: "Testing runners, DOM helpers, E2E harnesses, and A/B testing.",
timing: "Cron, stopwatches, throttling, debouncing, and scheduling.",
tooling: "CLI frameworks, installers, and build tooling.",
"webhook-client": "Webhook delivery client with retry logic and HMAC verification.",
video: "Video encoding, decoding, and thumbnail generation.",
voice: "Voice synthesis, speech-to-text, and provider adapters.",
forms: "Form builders, validation, and React form components.",
react: "React hooks and components for audio, auth, and UI surfaces.",
};
function ensureIntro(path: string, content: string): boolean {
if (existsSync(path)) return false;
writeFileSync(path, content + "\n");
return true;
}
function main(): void {
if (!isDirectorySafe(PACKAGES_ROOT)) {
console.error(`Packages root not found: ${PACKAGES_ROOT}`);
process.exit(2);
}
let created = 0;
let skipped = 0;
const catEntries = readdirSync(PACKAGES_ROOT, { withFileTypes: true });
for (const cat of catEntries) {
if (!cat.isDirectory() || cat.name.startsWith(".")) continue;
const categoryDir = join(PACKAGES_ROOT, cat.name);
// Category intro
const catIntro = CATEGORY_INTROS[cat.name];
if (catIntro) {
const didCreate = ensureIntro(
join(categoryDir, "_intro.md"),
catIntro
);
didCreate ? created++ : skipped++;
}
const subEntries = readdirSync(categoryDir, { withFileTypes: true });
for (const sub of subEntries) {
if (!sub.isDirectory() || sub.name.startsWith(".")) continue;
const subDir = join(categoryDir, sub.name);
// Check if this subcategory actually contains packages
const pkgEntries = readdirSync(subDir, { withFileTypes: true });
const hasPackages = pkgEntries.some(
(e) =>
e.isDirectory() &&
!e.name.startsWith(".") &&
existsSync(join(subDir, e.name, "package.json"))
);
if (!hasPackages) continue;
const subIntro = SUBCATEGORY_INTROS[sub.name];
if (subIntro) {
const didCreate = ensureIntro(
join(subDir, "_intro.md"),
subIntro
);
didCreate ? created++ : skipped++;
}
}
}
console.log(`Created: ${created}`);
console.log(`Skipped (already exist): ${skipped}`);
}
main();

View File

@@ -0,0 +1,86 @@
import { readdir, readFile, stat } from "node:fs/promises";
import { join } from "node:path";
/**
* Walk `packages/<category>/<plugin>/<pkg>/README.md` for the canonical
* 3-level layout. A README is only included when its sibling `package.json`
* declares an `@suaveplan/*` workspace package — this skips stale plugin- or
* category-level READMEs that don't correspond to a real package.
*/
async function findReadmes(dir: string): Promise<string[]> {
const results: string[] = [];
const categoryEntries = await readdir(dir, { withFileTypes: true });
for (const category of categoryEntries) {
if (!category.isDirectory()) continue;
if (category.name.startsWith(".")) continue;
const categoryPath = join(dir, category.name);
const pluginEntries = await readdir(categoryPath, {
withFileTypes: true,
}).catch(() => []);
for (const plugin of pluginEntries) {
if (!plugin.isDirectory()) continue;
if (plugin.name.startsWith(".")) continue;
const pluginPath = join(categoryPath, plugin.name);
const pkgEntries = await readdir(pluginPath, {
withFileTypes: true,
}).catch(() => []);
for (const pkg of pkgEntries) {
if (!pkg.isDirectory()) continue;
if (pkg.name.startsWith(".")) continue;
const pkgPath = join(pluginPath, pkg.name);
// Only count this README if there's a workspace package.json
// (skips stale plugin-level READMEs left from prior layouts).
const pkgJsonPath = join(pkgPath, "package.json");
try {
await stat(pkgJsonPath);
} catch {
continue;
}
const readmePath = join(pkgPath, "README.md");
try {
await readFile(readmePath);
results.push(readmePath);
} catch {
// README.md doesn't exist, skip
}
}
}
}
return results;
}
async function main() {
const readmePaths = await findReadmes("./packages");
const mismatches: string[] = [];
for (const path of readmePaths) {
const content = await readFile(path, "utf-8");
const firstLine = content.split(/\r?\n/)[0] ?? "";
if (!/^#\s@[^\s]+\s/.test(firstLine)) {
mismatches.push(path);
}
}
if (mismatches.length === 0) {
console.log("All README.md files start with '# @' on the first line.");
} else {
console.log(
`Found ${mismatches.length} README.md file(s) without '# @' as the first line:`
);
for (const path of mismatches) {
console.log(` ${path}`);
}
process.exit(1);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,690 @@
# Category -> plugin -> package assignment manifest.
#
# Layout: `packages/<category>/<plugin>/<pkg>/` — three levels under packages/.
#
# Each top-level key (other than the `__categories__` block and `__retired*__`
# bookkeeping keys) is a plugin name. Each plugin's value is the list of
# packages that live in `packages/<category>/<plugin>/<pkg>/`, where
# <category> is the super-category assigned to that plugin in
# `__categories__:` below.
#
# Rules enforced by tooling:
# 1. Every package referenced here must be unique across all plugins.
# 2. Every plugin must appear in exactly one super-category in __categories__.
# 3. The 18 super-category vocabulary below is closed — changing it requires
# an OpenSpec change against the monorepo-layout capability.
#
# Source of truth: FOLDERS.md.
# 18 super-categories. Each maps to the list of plugin folders that live
# directly under packages/<category>/. Adding or removing a super-category
# requires an OpenSpec change.
__categories__:
foundation: [core, data, random, timing, bitpacker, barcode, flow, etl, feature-flags]
persistence: [database, storage, cache, search]
networking: [network, drive, events]
communication: [messaging, emails, webhook-client]
api-stack: [api]
identity: [auth]
security-stack: [security, crypto]
observability: [monitor]
media: [audio, audio-fx, audio-ir, image, fonts, video]
ui: [react, forms]
threejs: [r3f]
localization: [i18n]
geospatial: [geo, logistics]
money: [finance, payment]
commerce: [booking, contacts]
testkit: [testing]
devtools: [tooling, docs, docker]
gaming: [games]
__retired__: []
__retired_categories__:
- codes
- config
- control-flow
- dev
- email
- media
- observability
- primitives
- search
- streams
- svg
- third-party
- validation
- vault
- workflow
# ----------------------------------------------------------------
api:
- api-schemas
- api-types
- api-manifest
- api
- create-suaveplan-plugin
- api-gateway
- api-worker-rpc
- api-logger-worker-proxy
- api-plugin-admin
- api-plugin-analytics
- api-plugin-audit
- api-plugin-auth
- api-plugin-campaigns
- api-plugin-cms
- api-plugin-database
- api-plugin-events
- api-plugin-export
- api-plugin-file-upload
- api-plugin-graphql
- api-plugin-import
- api-plugin-rate-limit
- api-plugin-search
- api-plugin-site
- api-plugin-social
- api-plugin-sse
- api-plugin-tenant
- api-plugin-user
- api-plugin-webhook
- api-shared
- api-2pc
# ----------------------------------------------------------------
audio:
- audio-analysis-loudness
- audio-codecs
- audio-core
- audio-metadata
- voice
- audio-controllers
- audio-engine
- audio-engine-browser
- audio-fx-plugin-base
- audio-fx-plugin-audio-analyzer
- audio-fx-plugin-audio-player
- audio-fx-plugin-auto-filter
- audio-fx-plugin-chorus
- audio-fx-plugin-compressor
- audio-fx-plugin-delay
- audio-fx-plugin-distortion
- audio-fx-plugin-eq
- audio-fx-plugin-filter
- audio-fx-plugin-flanger
- audio-fx-plugin-game-audio
- audio-fx-plugin-gate
- audio-fx-plugin-glados
- audio-fx-plugin-limiter
- audio-fx-plugin-microphone
- audio-fx-plugin-mixer
- audio-fx-plugin-phaser
- audio-fx-plugin-pitch-shifter
- audio-fx-plugin-reverb
- audio-fx-plugin-sampler
- audio-fx-plugin-spatial
- audio-fx-plugin-synthesizer
- audio-fx-plugin-tremolo
- audio-fx-plugin-widener
- audio-sprite-packer
- dmx
- python-audio-fingerprint
- r3f-audio
- r3f-dmx
- react-audio
- react-audio-fingerprint
- react-audio-player
- react-audio-stream
- react-midi
# ----------------------------------------------------------------
auth:
- auth
- jwt-tenant
- macos-keychain
- oauth-client
- permissions
- webauthn
- react-auth
- react-auth-whitelabel
- react-permissions
# ----------------------------------------------------------------
barcode:
- barcode
- qrcode
# ----------------------------------------------------------------
bitpacker:
- bitpacker
- bitpacker-compressed
- bitpacker-crypto
- bitpacker-delta
- bitpacker-game
- bitpacker-network
- bitpacker-schema
- bitpacker-strategies
- bitpacker-streaming
- bitpacker-validation
# ----------------------------------------------------------------
commerce:
- api-plugin-asset-register
- api-plugin-bank
- api-plugin-banking
- api-plugin-payment
- bank
- banking
- booking
- currency
- currency-provider-binance
- currency-provider-coingecko
- currency-provider-fixer
- currency-providers
- money
- react-bank
# ----------------------------------------------------------------
payment:
- payment
- payment-providers
- payment-provider-adyen
- payment-provider-mollie
- payment-provider-paypal
- payment-provider-square
- payment-provider-stripe
# ----------------------------------------------------------------
contacts:
- api-plugin-contacts
- contacts
- macos-contacts
- react-contacts
# ----------------------------------------------------------------
core:
- config
- env
- error
- schema-registry
- types
- typescript-config
- utils
# ----------------------------------------------------------------
crypto:
- crypto
- crypto-bin
- crypto-browser
- crypto-cipher
- crypto-cli
- crypto-core
- crypto-curve
- crypto-hash
- crypto-rsa
- crypto-sodium
- crypto-utils
- crypto-vault
- api-plugin-vault
- cryptocurrency
- keys
- react-crypto
# ----------------------------------------------------------------
data:
- bytes
- clipboard
- data-masking
- dedup
- diff
- json
- media
- mime
- packer
- packer-browser
- pagination
- pwa
- react-packer
- serialize
- templates
- validation
- web-workers
# ----------------------------------------------------------------
database:
- db
- db-sqlite
- migration
- surrealdb
- seed
# ----------------------------------------------------------------
docs:
- compress-svg
- sitemap
- api-docs
- docs
- pdf
- react-docs
- react-pdf
- react-rich-text
# ----------------------------------------------------------------
drive:
- drive
- drive-mesh
- drive-mesh-protocol
- drive-plugin-chunks
- drive-react-sharing
- drive-sharing
- drive-template
- drive-transport-https
- drive-transport-webrtc
- drive-webrtc
# ----------------------------------------------------------------
emails:
- dmarc
- domain-normalizer
- suppression
- email-processing
- email-templates
- emailer
- campaigns
# ----------------------------------------------------------------
events:
- event-sourcing
- events
- events-metrics
- events-otel
- events-schema
- events-transport
- broadcast-channel
- message-bridge
# ----------------------------------------------------------------
cache:
- cache
- cache-redis
- cache-storage
# ----------------------------------------------------------------
flow:
- cbor
- crdt
- idempotency
- job-scheduler
- queue
- queue-distribution
- queue-distribution-http
- queue-distribution-worker-thread
- rate-limiter
- retry
- rules
- rules-hono
- streams
- undo
# ----------------------------------------------------------------
fonts:
- font-compress
- font-convert
- font-subset
- font-decompress
- react-font-decompress
# ----------------------------------------------------------------
forms:
- react-forms
- form-builder
- react-admin-forms
- react-calendar
- react-color-picker
- react-file-upload
- react-wizard
# ----------------------------------------------------------------
games:
- animation-events
- api-plugin-anticheat
- api-plugin-game-persistence
- api-plugin-game-server
- api-plugin-matchmaking
- behavior-tree
- bone-mapping
- building
- character-physics
- character-vehicle-picker
- chat
- combat
- controller
- crafting
- day-night
- destructibles
- dialogue
- economy
- ecs
- emotes
- fauna
- fruit-machine
- game-loop
- game-persistence
- game-server
- game-state-schemas
- heightmap-tiles
- hitbox
- ik-solver
- leaderboard
- lobby
- mixamo
- npc
- osm-buildings
- party
- progression
- quest
- react-controller
- react-crafting
- react-dialogue
- react-game-hud
- react-game-map
- react-game-settings
- react-social-game
- vehicle-physics
- weapons
- weather
# ----------------------------------------------------------------
geo:
- geo
- geo-earth
- geo-planet
- solar-system
- stars
- tides
- universe
# ----------------------------------------------------------------
gtap:
- api-plugin-gtap
# ----------------------------------------------------------------
i18n:
- i18n
- i18n-backend-chained
- i18n-backend-fs-multifile
- i18n-backend-http
- i18n-backend-memory
- i18n-backend-storage
- i18n-detector
- i18n-formatter-collator
- i18n-formatter-currency
- i18n-formatter-date
- i18n-formatter-display-name
- i18n-formatter-list
- i18n-formatter-number
- i18n-formatter-relative-time
- i18n-formatter-segmenter
- i18n-icu-parser
- i18n-shared
- locale-data
- react-i18n
# ----------------------------------------------------------------
image:
- compress-image
- exif
- caddy-imgproxy
- emblem-solver
- image
- image-quantisation
- react-image
# ----------------------------------------------------------------
logistics:
- api-plugin-customs
- api-plugin-logistics
- api-plugin-port-tracking
- api-plugin-procurement
- api-plugin-warehouse-ops
- customs
- delivery-tracking
- logistics
- port-tracking
- ports
- procurement
- react-port-tracking
- warehouse-ops
# ----------------------------------------------------------------
messaging:
- elevenlabs
- api-plugin-messaging
- api-plugin-notifications
- discord-client
- imessage-adapter
- react-notifications
- slack-client
- sms
- sms-providers
- telegram-client
# ----------------------------------------------------------------
monitor:
- analytics-browser
- audit-logging
- health-check
- logger
- opentelemetry
# ----------------------------------------------------------------
network:
- network
- network-bootstrap
- network-discovery-crdt
- network-discovery-gundb
- network-discovery-hybrid
- network-discovery-peerjs
- network-discovery-trystero
- network-discovery-webtorrent
- network-gundb-server
- network-libp2p-server
- network-plugin-assignment
- network-plugin-audit-mesh
- network-plugin-auth
- network-plugin-circuit-breaker
- network-plugin-content-delivery
- network-plugin-crypto-messages
- network-plugin-devtools
- network-plugin-failover
- network-plugin-governance
- network-plugin-intra-relay
- network-plugin-middleware
- network-plugin-migration
- network-plugin-monitor
- network-plugin-partition
- network-plugin-protocol
- network-plugin-qos
- network-plugin-registry
- network-plugin-relay
- network-plugin-server-bridge
- network-plugin-session
- network-plugin-simulation
- network-plugin-state
- network-plugin-topology
- network-plugin-transport-manager
- network-testing
- network-transport-hyperswarm
- network-transport-libp2p
- network-transport-standby
- network-transport-webrtc
- network-transport-websockets
- network-trystero-server
- react-network-libp2p-demo
- service-discovery
- websocket-gateway
- network-observability
- network-quality
# ----------------------------------------------------------------
platform:
- etl
- feature-flags
- search
- webhook-client
- audience-rules
- notification-channel
# ----------------------------------------------------------------
r3f:
- r3f-networking
- r3f-animation
- r3f-booking
- r3f-building
- r3f-buildings
- r3f-camera
- r3f-character
- r3f-character-vehicle
- r3f-character-vehicle-picker
- r3f-combat
- r3f-controller
- r3f-earth
- r3f-emulator
- r3f-environment
- r3f-lod
- r3f-minimap
- r3f-npc
- r3f-number-plate
- r3f-parallax
- r3f-particles
- r3f-pathfinding
- r3f-performance
- r3f-physics-cascade
- r3f-physics-core
- r3f-physics-rapier
- r3f-shaders
- r3f-terrain
- r3f-ui
- r3f-vegetation
- r3f-vehicle
- r3f-water
- r3f-weather
- r3f-world
- r3f-xr
# ----------------------------------------------------------------
random:
- fsm
- noise
- pseudo-random
- random
# ----------------------------------------------------------------
react:
- react-ui
- react-campaigns
- react-charts
- react-command-palette
- react-consent-overlay
- react-data-table
- react-dnd
- react-error-boundary
- react-hotkeys
- react-infinite-scroll
- react-iphone
- react-kanban
- react-loading
- react-modal
- react-skeleton
- react-social
- react-timeline
- react-tour
- react-tree-view
- react-ui-social
- react-virtual
# ----------------------------------------------------------------
security:
- hibp
- jws
- otp
- password-formats
- password-generator
- password-strength
- security-codes
- slsa
- srp
- tpm-struct
- vault
- vault-sync
- x509
# ----------------------------------------------------------------
storage:
- storage
- storage-browser-indexeddb
- storage-browser-localstorage
- storage-browser-opfs
- storage-file
- storage-s3
- storage-uri-plugin-fallback
# ----------------------------------------------------------------
store:
- api-plugin-inventory
- api-plugin-store
- api-plugin-subscription
- api-plugin-virtual-currency
- r3f-inventory
- react-store
- store
- subscription
- virtual-currency
# ----------------------------------------------------------------
testing:
- ab-testing
- testing
- testing-dom
- testing-e2e
- testing-integration
- testing-integration-garage-s3
- testing-integration-mailpit
- testing-integration-postgres
- testing-integration-redis
- testing-integration-step-ca
- testing-integration-surrealdb
- testing-integration-typesense
# ----------------------------------------------------------------
timing:
- cron
- datetime
- timing
# ----------------------------------------------------------------
tooling:
- cli
- install-agent-skills
- installer
- mcp
- readline
- electrobun
- license
- linter
- overview
- profiler
- sandbox
- server-keys
- test-vite
- wip
# ----------------------------------------------------------------
vatdash:
- api-plugin-vatdash-bridge
# ----------------------------------------------------------------
video:
- react-video
- video
- video-thumbnails

View File

@@ -0,0 +1,361 @@
#!/usr/bin/env bun
/**
* Generates MANIFEST.md at repo root from on-disk packages/<super>/<plugin>/<pkg>/package.json.
*
* Replaces @suaveplan/overview. Sections emitted:
* 1. Plugins table — plugin | package count | total workspace deps
* 2. Packages table — npm name | plugin | tier | version | workspace deps
* 3. Tier overlay — packages grouped by inferred tier
* 4. Dependency graph — DOT block for Graphviz
*
* Usage:
* bun scripts/folder-reorg/generate-manifest.ts # write MANIFEST.md
* bun scripts/folder-reorg/generate-manifest.ts --check # exit 1 if drift
* bun scripts/folder-reorg/generate-manifest.ts --stdout # print only
*
* Tier inference priority (matches openspec/specs/monorepo-structure/spec.md):
* python → r3f → react → browser → server → universal
*/
import {
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..");
const manifestPath = join(repoRoot, "MANIFEST.md");
const packagesRoot = join(repoRoot, "packages");
type Tier = "python" | "r3f" | "react" | "browser" | "server" | "universal";
interface PkgJson {
name: string;
version: string;
description?: string;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
browser?: unknown;
bin?: unknown;
engines?: Record<string, string>;
python?: unknown;
}
interface PkgRecord {
name: string; // @suaveplan/<x>
short: string; // <x>
category: string; // top-level super-category (e.g. foundation, threejs)
plugin: string; // mid-level plugin folder (e.g. core, r3f)
dirName: string;
version: string;
description: string;
tier: Tier;
workspaceDeps: string[]; // sorted, dedup
}
const SERVER_DEP_HINTS = new Set([
"bun:sqlite",
"node:fs",
"node:net",
"node:cluster",
"node:dgram",
"node:tls",
"node:dns",
"node:child_process",
"pg",
"ioredis",
"better-sqlite3",
"@sqlite.org/sqlite-wasm-node",
]);
function readPkgJson(absDir: string): PkgJson | null {
const f = join(absDir, "package.json");
if (!existsSync(f)) return null;
try {
return JSON.parse(readFileSync(f, "utf8")) as PkgJson;
} catch {
return null;
}
}
function inferTier(short: string, p: PkgJson): Tier {
if (p.python !== undefined || short.startsWith("python-")) return "python";
const peerKeys = Object.keys(p.peerDependencies ?? {});
if (
peerKeys.includes("@react-three/fiber") ||
peerKeys.includes("three") ||
short.startsWith("r3f-")
)
return "r3f";
if (peerKeys.includes("react") || short.startsWith("react-"))
return "react";
if (p.browser !== undefined) return "browser";
const enginesKeys = Object.keys(p.engines ?? {});
if (
enginesKeys.length > 0 &&
!enginesKeys.includes("node") &&
!enginesKeys.includes("bun")
)
return "browser";
if (p.bin !== undefined) return "server";
const allDeps = { ...p.dependencies, ...p.devDependencies };
for (const key of Object.keys(allDeps)) {
if (SERVER_DEP_HINTS.has(key)) return "server";
}
return "universal";
}
function collectWorkspaceDeps(p: PkgJson): string[] {
const out = new Set<string>();
for (const map of [
p.dependencies,
p.peerDependencies,
p.optionalDependencies,
]) {
if (!map) continue;
for (const [name, range] of Object.entries(map)) {
if (
typeof range === "string" &&
range.startsWith("workspace:") &&
name.startsWith("@suaveplan/")
) {
out.add(name);
}
}
}
return [...out].sort();
}
function discoverPackages(): PkgRecord[] {
if (!existsSync(packagesRoot)) return [];
const records: PkgRecord[] = [];
for (const category of readdirSync(packagesRoot)) {
const categoryPath = join(packagesRoot, category);
if (!statSync(categoryPath).isDirectory()) continue;
for (const plugin of readdirSync(categoryPath)) {
const pluginPath = join(categoryPath, plugin);
if (!statSync(pluginPath).isDirectory()) continue;
for (const pkgDir of readdirSync(pluginPath)) {
const pkgPath = join(pluginPath, pkgDir);
if (!statSync(pkgPath).isDirectory()) continue;
const pj = readPkgJson(pkgPath);
if (pj === null) continue;
const short = pj.name.replace(/^@suaveplan\//, "");
records.push({
name: pj.name,
short,
category,
plugin,
dirName: pkgDir,
version: pj.version ?? "0.1.0",
description: pj.description ?? "",
tier: inferTier(short, pj),
workspaceDeps: collectWorkspaceDeps(pj),
});
}
}
}
records.sort((a, b) => a.name.localeCompare(b.name));
return records;
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return `${s.slice(0, max - 1).trimEnd()}…`;
}
function emitPluginsSection(records: PkgRecord[]): string {
const counts = new Map<
string,
{ category: string; pkgs: number; deps: number }
>();
for (const r of records) {
const key = `${r.category}/${r.plugin}`;
const cur = counts.get(key) ?? {
category: r.category,
pkgs: 0,
deps: 0,
};
cur.pkgs += 1;
cur.deps += r.workspaceDeps.length;
counts.set(key, cur);
}
const lines: string[] = [];
lines.push("## Plugins");
lines.push("");
lines.push("| Category | Plugin | Packages | Workspace deps |");
lines.push("|---|---|---:|---:|");
for (const [key, c] of [...counts.entries()].sort()) {
const plugin = key.split("/")[1];
lines.push(
`| \`${c.category}\` | \`${plugin}\` | ${c.pkgs} | ${c.deps} |`
);
}
return lines.join("\n");
}
function emitPackagesSection(records: PkgRecord[]): string {
const lines: string[] = [];
lines.push("## Packages");
lines.push("");
lines.push(
"| Package | Category | Plugin | Tier | Version | Deps | Description |"
);
lines.push("|---|---|---|---|---|---:|---|");
for (const r of records) {
lines.push(
`| \`${r.name}\` | \`${r.category}\` | \`${r.plugin}\` | ${r.tier} | ${r.version} | ${r.workspaceDeps.length} | ${truncate(r.description, 80)} |`
);
}
return lines.join("\n");
}
function emitTierOverlaySection(records: PkgRecord[]): string {
const byTier = new Map<Tier, PkgRecord[]>();
const tierOrder: Tier[] = [
"universal",
"browser",
"react",
"r3f",
"server",
"python",
];
for (const t of tierOrder) byTier.set(t, []);
for (const r of records) byTier.get(r.tier)?.push(r);
const lines: string[] = [];
lines.push("## Tier overlay");
lines.push("");
lines.push(
"Tier is inferred per `openspec/specs/monorepo-structure/spec.md` priority order: python → r3f → react → browser → server → universal."
);
lines.push("");
for (const tier of tierOrder) {
const list = byTier.get(tier) ?? [];
if (list.length === 0) continue;
lines.push(`### ${tier} (${list.length})`);
lines.push("");
for (const r of list)
lines.push(`- \`${r.name}\` (\`${r.category}/${r.plugin}\`)`);
lines.push("");
}
return lines.join("\n").trimEnd();
}
function emitDependencyGraphSection(records: PkgRecord[]): string {
const known = new Set(records.map((r) => r.name));
const lines: string[] = [];
lines.push("## Dependency graph");
lines.push("");
lines.push(
"Workspace edges only. Pipe to Graphviz: `awk '/^```dot/,/^```$/' MANIFEST.md | sed '1d;$d' | dot -Tsvg > graph.svg`."
);
lines.push("");
lines.push("```dot");
lines.push("digraph workspace {");
lines.push(" rankdir=LR;");
lines.push(' node [shape=box, fontname="monospace", fontsize=10];');
for (const r of records) {
for (const dep of r.workspaceDeps) {
if (!known.has(dep)) continue;
lines.push(` "${r.name}" -> "${dep}";`);
}
}
lines.push("}");
lines.push("```");
return lines.join("\n");
}
function emitHeader(records: PkgRecord[]): string {
const tierCounts = new Map<Tier, number>();
for (const r of records)
tierCounts.set(r.tier, (tierCounts.get(r.tier) ?? 0) + 1);
const tiers = [...tierCounts.entries()]
.map(([t, c]) => `${t}: ${c}`)
.sort()
.join(", ");
const plugins = new Set(records.map((r) => `${r.category}/${r.plugin}`))
.size;
const categories = new Set(records.map((r) => r.category)).size;
const lines: string[] = [];
lines.push("# Suaveplan Monorepo Manifest");
lines.push("");
lines.push(
"> **Auto-generated by `scripts/folder-reorg/generate-manifest.ts`. Do not edit by hand — run the script after package changes.**"
);
lines.push("");
lines.push(
`Packages: **${records.length}** across **${plugins}** plugins in **${categories}** categories. Tiers: ${tiers || "—"}.`
);
return lines.join("\n");
}
function buildManifest(records: PkgRecord[]): string {
if (records.length === 0) {
return [
"# Suaveplan Monorepo Manifest",
"",
"> **Auto-generated by `scripts/folder-reorg/generate-manifest.ts`.**",
"",
"_No packages discovered under `packages/`. Run the folder reorg migration first._",
"",
].join("\n");
}
const sections = [
emitHeader(records),
emitPluginsSection(records),
emitPackagesSection(records),
emitTierOverlaySection(records),
emitDependencyGraphSection(records),
];
return `${sections.join("\n\n")}\n`;
}
function main(): void {
const argv = process.argv.slice(2);
const checkMode = argv.includes("--check");
const stdoutMode = argv.includes("--stdout");
const records = discoverPackages();
const generated = buildManifest(records);
if (stdoutMode) {
process.stdout.write(generated);
return;
}
if (checkMode) {
const onDisk = existsSync(manifestPath)
? readFileSync(manifestPath, "utf8")
: "";
if (onDisk === generated) {
console.log(
`✓ MANIFEST.md is up to date (${records.length} packages).`
);
process.exit(0);
}
console.error(
"✗ MANIFEST.md is stale. Re-run: bun scripts/folder-reorg/generate-manifest.ts"
);
process.exit(1);
}
writeFileSync(manifestPath, generated);
console.log(
`✓ Wrote ${manifestPath} (${records.length} packages, ${new Set(records.map((r) => `${r.category}/${r.plugin}`)).size} plugins, ${new Set(records.map((r) => r.category)).size} categories).`
);
}
main();

View File

@@ -0,0 +1,410 @@
#!/usr/bin/env bun
/**
* Folder reorganisation migration script.
*
* Reads scripts/folder-reorg/assignment.yml and moves every package currently
* under universal/<domain>/<pkg>/ to packages/<plugin>/<pkg>/ via `git mv`.
*
* Run modes:
* bun scripts/folder-reorg/migrate-folders.ts --dry-run # plan only
* bun scripts/folder-reorg/migrate-folders.ts # execute
* bun scripts/folder-reorg/migrate-folders.ts --check # validate manifest only
*
* Exits non-zero on:
* - duplicate package across plugins
* - on-disk package not present in any plugin's list
* - destination path already exists
* - git working tree dirty (execute mode only)
*/
import { execFileSync } from "node:child_process";
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmdirSync,
rmSync,
statSync,
} from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..");
const assignmentPath = join(here, "assignment.yml");
const LEGACY_ROOTS = [
"universal",
"browser",
"react",
"r3f",
"server",
"finance",
"mcp",
"python",
"gaming",
"api",
] as const;
type Mode = "dry-run" | "execute" | "check";
interface Assignment {
[plugin: string]: string[];
}
interface PlannedMove {
pkg: string;
plugin: string;
from: string; // absolute
to: string; // absolute
}
const log = (...args: unknown[]) => console.log(...args);
const warn = (...args: unknown[]) => console.warn("⚠ ", ...args);
const fail = (msg: string): never => {
console.error(`✗ ${msg}`);
process.exit(1);
};
function git(args: string[], cwd: string = repoRoot): string {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
function parseMode(argv: string[]): Mode {
if (argv.includes("--dry-run")) return "dry-run";
if (argv.includes("--check")) return "check";
return "execute";
}
function loadAssignment(): Assignment {
if (!existsSync(assignmentPath)) {
fail(`Assignment manifest not found: ${assignmentPath}`);
}
const raw = readFileSync(assignmentPath, "utf8");
const parsed = parseYaml(raw) as unknown;
if (typeof parsed !== "object" || parsed === null) {
fail("assignment.yml did not parse to an object");
}
const a = parsed as Record<string, unknown>;
for (const [plugin, pkgs] of Object.entries(a)) {
if (!Array.isArray(pkgs)) fail(`Plugin "${plugin}" is not a list`);
for (const p of pkgs) {
if (typeof p !== "string")
fail(`Plugin "${plugin}" has non-string entry`);
}
}
return a as Assignment;
}
interface ParsedAssignment {
pkgToPlugin: Map<string, string>;
retired: Set<string>;
}
function validateUniqueness(assignment: Assignment): ParsedAssignment {
const pkgToPlugin = new Map<string, string>();
const retired = new Set<string>(assignment.__retired__ ?? []);
const duplicates: string[] = [];
for (const [plugin, pkgs] of Object.entries(assignment)) {
if (plugin === "__retired__") continue;
for (const pkg of pkgs) {
if (retired.has(pkg)) {
duplicates.push(
` ${pkg}: listed in plugin "${plugin}" AND in __retired__`
);
continue;
}
const prior = pkgToPlugin.get(pkg);
if (prior !== undefined) {
duplicates.push(` ${pkg}: in "${prior}" AND "${plugin}"`);
} else {
pkgToPlugin.set(pkg, plugin);
}
}
}
if (duplicates.length > 0) {
fail(`Duplicate package assignments found:\n${duplicates.join("\n")}`);
}
return { pkgToPlugin, retired };
}
function discoverOnDiskPackages(): Map<string, string> {
const result = new Map<string, string>();
for (const root of LEGACY_ROOTS) {
const rootPath = join(repoRoot, root);
if (!existsSync(rootPath) || !statSync(rootPath).isDirectory())
continue;
for (const domain of readdirSync(rootPath)) {
const domainPath = join(rootPath, domain);
if (!statSync(domainPath).isDirectory()) continue;
for (const pkg of readdirSync(domainPath)) {
const pkgPath = join(domainPath, pkg);
if (!statSync(pkgPath).isDirectory()) continue;
if (!existsSync(join(pkgPath, "package.json"))) continue;
if (result.has(pkg)) {
fail(
`On-disk duplicate package directory: "${pkg}" exists in both ${result.get(pkg)} and ${pkgPath}`
);
}
result.set(pkg, pkgPath);
}
}
}
return result;
}
function planMoves(
parsed: ParsedAssignment,
onDisk: Map<string, string>
): { moves: PlannedMove[]; orphans: string[]; retiredOnDisk: string[] } {
const moves: PlannedMove[] = [];
const orphans: string[] = [];
const retiredOnDisk: string[] = [];
for (const [pkg, fromAbs] of onDisk) {
if (parsed.retired.has(pkg)) {
retiredOnDisk.push(pkg);
continue;
}
const plugin = parsed.pkgToPlugin.get(pkg);
if (plugin === undefined) {
orphans.push(pkg);
continue;
}
const toAbs = join(repoRoot, "packages", plugin, pkg);
moves.push({ pkg, plugin, from: fromAbs, to: toAbs });
}
moves.sort((a, b) =>
a.plugin === b.plugin
? a.pkg.localeCompare(b.pkg)
: a.plugin.localeCompare(b.plugin)
);
return { moves, orphans, retiredOnDisk };
}
function checkGitClean(): void {
const out = git(["status", "--porcelain"]);
if (out.trim().length > 0) {
fail(
`Working tree is dirty — commit or stash before migrating.\n\n${out}\n` +
"If you really want to proceed with uncommitted changes, run with --dry-run first to verify the plan."
);
}
}
function ensureDestinationsClear(moves: PlannedMove[]): void {
const collisions: string[] = [];
for (const m of moves) {
if (existsSync(m.to)) collisions.push(` ${relative(repoRoot, m.to)}`);
}
if (collisions.length > 0) {
fail(`Destination paths already exist:\n${collisions.join("\n")}`);
}
}
// Skip these from `cp -R` — build/test artifacts + dependency trees, regenerated
// by `bun install` post-migration. Skipping keeps moves under 1s per package
// instead of minutes (a single testing-integration package node_modules is ~500MB).
const COPY_SKIP = new Set([
"node_modules",
"dist",
".turbo",
"coverage",
".bd_modules",
".tsbuildinfo",
]);
function copyTrackedOnly(fromAbs: string, toAbs: string): void {
cpSync(fromAbs, toAbs, {
recursive: true,
filter: (src) => {
const base = src.split("/").pop() ?? "";
if (COPY_SKIP.has(base)) return false;
if (base.endsWith(".tsbuildinfo")) return false;
return true;
},
});
}
function moveOne(fromRel: string, toRel: string): "git-mv" | "cp-rm" {
// Try `git mv` first — preserves rename in the index cleanly.
try {
git(["mv", fromRel, toRel]);
return "git-mv";
} catch (err) {
const msg = (err as Error).message;
if (!msg.includes("Permission denied")) throw err;
}
// WSL2/DrvFs fallback: rename syscall blocked by Windows-side handle.
// Copy tracked files only (skip node_modules etc), remove source, restage.
// Git's rename detection still works for `log --follow` on similar files.
const fromAbs = join(repoRoot, fromRel);
const toAbs = join(repoRoot, toRel);
copyTrackedOnly(fromAbs, toAbs);
rmSync(fromAbs, { recursive: true, force: true });
git(["add", toRel]);
git(["rm", "-r", "--quiet", fromRel]);
return "cp-rm";
}
function executeMoves(moves: PlannedMove[]): void {
const seenPluginDirs = new Set<string>();
let cpRmCount = 0;
for (const m of moves) {
const pluginDir = join(repoRoot, "packages", m.plugin);
if (!seenPluginDirs.has(pluginDir)) {
mkdirSync(pluginDir, { recursive: true });
seenPluginDirs.add(pluginDir);
}
const fromRel = relative(repoRoot, m.from);
const toRel = relative(repoRoot, m.to);
const method = moveOne(fromRel, toRel);
const tag = method === "cp-rm" ? " cp+rm " : " git mv ";
log(`${tag}${fromRel} → ${toRel}`);
if (method === "cp-rm") cpRmCount += 1;
}
if (cpRmCount > 0) {
log("");
log(
`(${cpRmCount}/${moves.length} moves used cp+rm fallback due to WSL2 rename block.)`
);
}
}
function pruneEmptyDir(absPath: string): boolean {
// Returns true if the directory was removed.
if (!existsSync(absPath) || !statSync(absPath).isDirectory()) return false;
const entries = readdirSync(absPath);
let allRemoved = true;
for (const entry of entries) {
const child = join(absPath, entry);
if (statSync(child).isDirectory()) {
if (!pruneEmptyDir(child)) allRemoved = false;
} else {
allRemoved = false;
}
}
if (allRemoved && readdirSync(absPath).length === 0) {
rmdirSync(absPath);
return true;
}
return false;
}
function pruneEmptyLegacyDirs(): void {
for (const root of LEGACY_ROOTS) {
const rootPath = join(repoRoot, root);
if (pruneEmptyDir(rootPath)) {
log(` rmdir ${root}/`);
}
}
}
function summarise(
moves: PlannedMove[],
orphans: string[],
retiredOnDisk: string[],
assignment: Assignment,
onDisk: Map<string, string>,
mode: Mode
): void {
const pluginCounts = new Map<string, number>();
for (const m of moves)
pluginCounts.set(m.plugin, (pluginCounts.get(m.plugin) ?? 0) + 1);
const pluginKeys = Object.keys(assignment).filter(
(k) => k !== "__retired__"
);
const totalAssigned = pluginKeys.reduce(
(n, k) => n + (assignment[k]?.length ?? 0),
0
);
log("");
log(`Mode: ${mode}`);
log(`Plugins defined: ${pluginKeys.length}`);
log(`Packages assigned: ${totalAssigned}`);
log(`Packages retired: ${assignment.__retired__?.length ?? 0}`);
log(`Packages on disk: ${onDisk.size}`);
log(`Packages to migrate: ${moves.length}`);
log(`Packages on disk (retired, skipped): ${retiredOnDisk.length}`);
log(`Packages future-only: ${totalAssigned - moves.length}`);
log("");
log("Per-plugin migration counts:");
for (const [plugin, count] of [...pluginCounts.entries()].sort()) {
log(` ${plugin.padEnd(24)} ${count}`);
}
if (retiredOnDisk.length > 0) {
log("");
log("Retired packages on disk (NOT migrated; remove via tasks.md §8):");
for (const p of retiredOnDisk)
log(` ${p} (${relative(repoRoot, onDisk.get(p) ?? "")})`);
}
if (orphans.length > 0) {
log("");
warn("On-disk packages not assigned to any plugin:");
for (const p of orphans) warn(` ${p}`);
fail(
"Refusing to proceed — every on-disk package must appear in assignment.yml (or in __retired__)."
);
}
}
function main(): void {
const mode = parseMode(process.argv.slice(2));
log(`folder-reorg/migrate-folders.ts — mode: ${mode}`);
const assignment = loadAssignment();
const parsed = validateUniqueness(assignment);
if (mode === "check") {
const pluginCount = Object.keys(assignment).filter(
(k) => k !== "__retired__"
).length;
log(
`✓ Manifest valid: ${parsed.pkgToPlugin.size} unique packages across ${pluginCount} plugins, ${parsed.retired.size} retired.`
);
return;
}
const onDisk = discoverOnDiskPackages();
const { moves, orphans, retiredOnDisk } = planMoves(parsed, onDisk);
summarise(moves, orphans, retiredOnDisk, assignment, onDisk, mode);
if (mode === "dry-run") {
log("");
log("Planned moves (first 10):");
for (const m of moves.slice(0, 10)) {
log(
` ${relative(repoRoot, m.from)} → ${relative(repoRoot, m.to)}`
);
}
if (moves.length > 10) log(` ...and ${moves.length - 10} more`);
log("");
log("✓ Dry run complete. Re-run without --dry-run to execute.");
return;
}
checkGitClean();
ensureDestinationsClear(moves);
log("");
log("Executing moves:");
executeMoves(moves);
pruneEmptyLegacyDirs();
log("");
log(`✓ Migrated ${moves.length} packages.`);
log("");
log("Next steps:");
log(' 1. Update root package.json workspaces to: "packages/*/*"');
log(" 2. Run: rm -rf node_modules bun.lockb && bun install --force");
log(" 3. Run: bunx turbo lint typecheck test");
log(
" 4. Commit: chore(structure): move all packages to domain-first layout"
);
}
main();

View File

@@ -0,0 +1,409 @@
#!/usr/bin/env bun
/**
* Category realignment migration script.
*
* Reads scripts/folder-reorg/assignment.yml and moves every package currently
* under packages/<old-category>/<pkg>/ to packages/<new-category>/<pkg>/
* via `git mv`.
*
* Run modes:
* bun scripts/folder-reorg/realign-to-categories.ts --dry-run # plan only
* bun scripts/folder-reorg/realign-to-categories.ts # execute
* bun scripts/folder-reorg/realign-to-categories.ts --check # validate manifest only
*
* Exits non-zero on:
* - duplicate package across categories
* - on-disk package not present in any category's list
* - destination path already exists
* - git working tree dirty (execute mode only)
*/
import { execFileSync } from "node:child_process";
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmdirSync,
rmSync,
statSync,
} from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parse as parseYaml } from "yaml";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..");
const assignmentPath = join(here, "assignment.yml");
const packagesRoot = join(repoRoot, "packages");
type Mode = "dry-run" | "execute" | "check";
interface Assignment {
[category: string]: string[];
}
interface PlannedMove {
pkg: string;
category: string;
from: string; // absolute
to: string; // absolute
}
const log = (...args: unknown[]) => console.log(...args);
const warn = (...args: unknown[]) => console.warn("⚠ ", ...args);
const fail = (msg: string): never => {
console.error(`✗ ${msg}`);
process.exit(1);
};
function git(args: string[], cwd: string = repoRoot): string {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
function parseMode(argv: string[]): Mode {
if (argv.includes("--dry-run")) return "dry-run";
if (argv.includes("--check")) return "check";
return "execute";
}
function loadAssignment(): Assignment {
if (!existsSync(assignmentPath)) {
fail(`Assignment manifest not found: ${assignmentPath}`);
}
const raw = readFileSync(assignmentPath, "utf8");
const parsed = parseYaml(raw) as unknown;
if (typeof parsed !== "object" || parsed === null) {
fail("assignment.yml did not parse to an object");
}
const a = parsed as Record<string, unknown>;
for (const [category, pkgs] of Object.entries(a)) {
if (!Array.isArray(pkgs)) fail(`Category "${category}" is not a list`);
for (const p of pkgs) {
if (typeof p !== "string")
fail(`Category "${category}" has non-string entry`);
}
}
return a as Assignment;
}
interface ParsedAssignment {
pkgToCategory: Map<string, string>;
retiredPackages: Set<string>;
retiredCategories: Set<string>;
}
function validateUniqueness(assignment: Assignment): ParsedAssignment {
const pkgToCategory = new Map<string, string>();
const retiredPackages = new Set<string>(assignment.__retired__ ?? []);
const retiredCategories = new Set<string>(
Array.isArray(assignment.__retired_categories__)
? assignment.__retired_categories__
: []
);
const duplicates: string[] = [];
for (const [category, pkgs] of Object.entries(assignment)) {
if (category === "__retired__" || category === "__retired_categories__")
continue;
for (const pkg of pkgs) {
if (retiredPackages.has(pkg)) {
duplicates.push(
` ${pkg}: listed in category "${category}" AND in __retired__`
);
continue;
}
const prior = pkgToCategory.get(pkg);
if (prior !== undefined) {
duplicates.push(` ${pkg}: in "${prior}" AND "${category}"`);
} else {
pkgToCategory.set(pkg, category);
}
}
}
if (duplicates.length > 0) {
fail(`Duplicate package assignments found:\n${duplicates.join("\n")}`);
}
return { pkgToCategory, retiredPackages, retiredCategories };
}
function discoverOnDiskPackages(): Map<string, string> {
const result = new Map<string, string>();
if (!existsSync(packagesRoot) || !statSync(packagesRoot).isDirectory()) {
fail(`Packages root does not exist: ${packagesRoot}`);
}
for (const category of readdirSync(packagesRoot)) {
const categoryPath = join(packagesRoot, category);
if (!statSync(categoryPath).isDirectory()) continue;
for (const pkg of readdirSync(categoryPath)) {
const pkgPath = join(categoryPath, pkg);
if (!statSync(pkgPath).isDirectory()) continue;
if (!existsSync(join(pkgPath, "package.json"))) continue;
if (result.has(pkg)) {
fail(
`On-disk duplicate package directory: "${pkg}" exists in both ${result.get(pkg)} and ${pkgPath}`
);
}
result.set(pkg, pkgPath);
}
}
return result;
}
function planMoves(
parsed: ParsedAssignment,
onDisk: Map<string, string>
): { moves: PlannedMove[]; orphans: string[]; retiredOnDisk: string[] } {
const moves: PlannedMove[] = [];
const orphans: string[] = [];
const retiredOnDisk: string[] = [];
for (const [pkg, fromAbs] of onDisk) {
if (parsed.retiredPackages.has(pkg)) {
retiredOnDisk.push(pkg);
continue;
}
const category = parsed.pkgToCategory.get(pkg);
if (category === undefined) {
orphans.push(pkg);
continue;
}
const toAbs = join(packagesRoot, category, pkg);
if (toAbs !== fromAbs) {
moves.push({ pkg, category, from: fromAbs, to: toAbs });
}
}
moves.sort((a, b) =>
a.category === b.category
? a.pkg.localeCompare(b.pkg)
: a.category.localeCompare(b.category)
);
return { moves, orphans, retiredOnDisk };
}
function checkGitClean(): void {
const out = git(["status", "--porcelain"]);
if (out.trim().length > 0) {
fail(
`Working tree is dirty — commit or stash before migrating.\n\n${out}\n` +
"If you really want to proceed with uncommitted changes, run with --dry-run first to verify the plan."
);
}
}
function ensureDestinationsClear(moves: PlannedMove[]): void {
const collisions: string[] = [];
for (const m of moves) {
if (existsSync(m.to)) collisions.push(` ${relative(repoRoot, m.to)}`);
}
if (collisions.length > 0) {
fail(`Destination paths already exist:\n${collisions.join("\n")}`);
}
}
const COPY_SKIP = new Set([
"node_modules",
"dist",
".turbo",
"coverage",
".bd_modules",
".tsbuildinfo",
]);
function copyTrackedOnly(fromAbs: string, toAbs: string): void {
cpSync(fromAbs, toAbs, {
recursive: true,
filter: (src) => {
const base = src.split("/").pop() ?? "";
if (COPY_SKIP.has(base)) return false;
if (base.endsWith(".tsbuildinfo")) return false;
return true;
},
});
}
function moveOne(fromRel: string, toRel: string): "git-mv" | "cp-rm" {
try {
git(["mv", fromRel, toRel]);
return "git-mv";
} catch (err) {
const msg = (err as Error).message;
if (!msg.includes("Permission denied")) throw err;
}
const fromAbs = join(repoRoot, fromRel);
const toAbs = join(repoRoot, toRel);
copyTrackedOnly(fromAbs, toAbs);
rmSync(fromAbs, { recursive: true, force: true });
git(["add", toRel]);
git(["rm", "-r", "--quiet", fromRel]);
return "cp-rm";
}
function executeMoves(moves: PlannedMove[]): void {
const seenCategoryDirs = new Set<string>();
let cpRmCount = 0;
for (const m of moves) {
const categoryDir = join(packagesRoot, m.category);
if (!seenCategoryDirs.has(categoryDir)) {
mkdirSync(categoryDir, { recursive: true });
seenCategoryDirs.add(categoryDir);
}
const fromRel = relative(repoRoot, m.from);
const toRel = relative(repoRoot, m.to);
const method = moveOne(fromRel, toRel);
const tag = method === "cp-rm" ? " cp+rm " : " git mv ";
log(`${tag}${fromRel} → ${toRel}`);
if (method === "cp-rm") cpRmCount += 1;
}
if (cpRmCount > 0) {
log("");
log(
`(${cpRmCount}/${moves.length} moves used cp+rm fallback due to WSL2 rename block.)`
);
}
}
function pruneEmptyDir(absPath: string): boolean {
if (!existsSync(absPath) || !statSync(absPath).isDirectory()) return false;
const entries = readdirSync(absPath);
let allRemoved = true;
for (const entry of entries) {
const child = join(absPath, entry);
if (statSync(child).isDirectory()) {
if (!pruneEmptyDir(child)) allRemoved = false;
} else {
allRemoved = false;
}
}
if (allRemoved && readdirSync(absPath).length === 0) {
rmdirSync(absPath);
return true;
}
return false;
}
function pruneRetiredCategories(retiredCategories: Set<string>): void {
for (const category of retiredCategories) {
const categoryPath = join(packagesRoot, category);
if (pruneEmptyDir(categoryPath)) {
log(` rmdir packages/${category}/`);
}
}
}
function summarise(
moves: PlannedMove[],
orphans: string[],
retiredOnDisk: string[],
assignment: Assignment,
onDisk: Map<string, string>,
mode: Mode
): void {
const categoryCounts = new Map<string, number>();
for (const m of moves)
categoryCounts.set(
m.category,
(categoryCounts.get(m.category) ?? 0) + 1
);
const categoryKeys = Object.keys(assignment).filter(
(k) => k !== "__retired__"
);
const totalAssigned = categoryKeys.reduce(
(n, k) => n + (assignment[k]?.length ?? 0),
0
);
log("");
log(`Mode: ${mode}`);
log(`Categories defined: ${categoryKeys.length}`);
log(`Packages assigned: ${totalAssigned}`);
log(`Packages retired: ${assignment.__retired__?.length ?? 0}`);
log(
`Categories retired: ${assignment.__retired_categories__?.length ?? 0}`
);
log(`Packages on disk: ${onDisk.size}`);
log(`Packages to migrate: ${moves.length}`);
log(
`Packages already correct: ${onDisk.size - moves.length - orphans.length - retiredOnDisk.length}`
);
log(`Packages on disk (retired, skipped): ${retiredOnDisk.length}`);
log(`Packages future-only: ${totalAssigned - onDisk.size}`);
log("");
log("Per-category migration counts:");
for (const [category, count] of [...categoryCounts.entries()].sort()) {
log(` ${category.padEnd(24)} ${count}`);
}
if (retiredOnDisk.length > 0) {
log("");
log("Retired packages on disk (NOT migrated; remove via tasks.md §8):");
for (const p of retiredOnDisk)
log(` ${p} (${relative(repoRoot, onDisk.get(p) ?? "")})`);
}
if (orphans.length > 0) {
log("");
warn("On-disk packages not assigned to any category:");
for (const p of orphans) warn(` ${p}`);
fail(
"Refusing to proceed — every on-disk package must appear in assignment.yml (or in __retired__)."
);
}
}
function main(): void {
const mode = parseMode(process.argv.slice(2));
log(`folder-reorg/realign-to-categories.ts — mode: ${mode}`);
const assignment = loadAssignment();
const parsed = validateUniqueness(assignment);
if (mode === "check") {
const categoryCount = Object.keys(assignment).filter(
(k) => k !== "__retired__" && k !== "__retired_categories__"
).length;
log(
`✓ Manifest valid: ${parsed.pkgToCategory.size} unique packages across ${categoryCount} categories, ${parsed.retiredPackages.size} retired.`
);
return;
}
const onDisk = discoverOnDiskPackages();
const { moves, orphans, retiredOnDisk } = planMoves(parsed, onDisk);
summarise(moves, orphans, retiredOnDisk, assignment, onDisk, mode);
if (mode === "dry-run") {
log("");
log("Planned moves (first 10):");
for (const m of moves.slice(0, 10)) {
log(
` ${relative(repoRoot, m.from)} → ${relative(repoRoot, m.to)}`
);
}
if (moves.length > 10) log(` ...and ${moves.length - 10} more`);
log("");
log("✓ Dry run complete. Re-run without --dry-run to execute.");
return;
}
checkGitClean();
ensureDestinationsClear(moves);
log("");
log("Executing moves:");
executeMoves(moves);
pruneRetiredCategories(parsed.retiredCategories);
log("");
log(`✓ Migrated ${moves.length} packages.`);
log("");
log("Next steps:");
log(
' 1. Run: rm -rf node_modules bun.lockb && find . -type d -name "node_modules" -exec rm -rf {} + && bun install --force'
);
log(" 2. Run: bunx turbo lint typecheck test");
log(" 3. Run: bun scripts/folder-reorg/generate-manifest.ts");
log(
" 4. Commit: chore(structure): realign packages to FOLDERS.md categories"
);
}
main();

View File

@@ -0,0 +1,137 @@
/**
* Crypto primitive select codegen.
*
* Reads `crypto.config.ts` and, for every registered primitive, writes
* `packages/universal/crypto-<category>/src/primitives/<id>/<id>.select.ts`
* as a one-line re-export of `./impl/<id>.<flavour>.js`.
*
* The generated file carries a "GENERATED DO NOT EDIT" banner comment.
* Any hand-edit is detected by `check-crypto-selects.ts` and fails CI.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { cryptoConfig, type PrimitiveFlavour } from "../crypto.config.js";
// Inlined from @suaveplan/crypto-utils/primitive-id so this script can run
// without needing the crypto-utils package to be built/linked first.
// Must stay in sync with universal/crypto/crypto-utils/src/primitive-id/primitive-id.ts.
const HASH_PRIMITIVE_IDS = [
"sha256",
"sha384",
"sha512",
"hmac",
"hkdf",
"pbkdf2",
"argon2id",
] as const;
const CURVE_PRIMITIVE_IDS = ["secp256k1", "ed25519", "x25519", "p256"] as const;
const CIPHER_PRIMITIVE_IDS = [
"aes-256-gcm",
"chacha20-poly1305",
"xchacha20-poly1305",
] as const;
type HashId = (typeof HASH_PRIMITIVE_IDS)[number];
type CurveId = (typeof CURVE_PRIMITIVE_IDS)[number];
type CipherId = (typeof CIPHER_PRIMITIVE_IDS)[number];
export type PrimitiveId = HashId | CurveId | CipherId;
function primitiveCategory(id: PrimitiveId): "hash" | "curve" | "cipher" {
if ((HASH_PRIMITIVE_IDS as readonly string[]).includes(id)) return "hash";
if ((CURVE_PRIMITIVE_IDS as readonly string[]).includes(id)) return "curve";
return "cipher";
}
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export const BANNER =
"/* GENERATED — DO NOT EDIT. Regenerate with `bun scripts/gen-crypto-selects.ts`. */";
export function selectFileContents(
id: PrimitiveId,
flavour: PrimitiveFlavour
): string {
return `${BANNER}\nexport * from "./impl/${id}.${flavour}.js";\n`;
}
function categoryPackageName(id: PrimitiveId): string {
const cat = primitiveCategory(id);
if (cat === "hash") return "crypto-hash";
if (cat === "curve") return "crypto-curve";
return "crypto-cipher";
}
export function selectPath(id: PrimitiveId): string {
const pkg = categoryPackageName(id);
return resolve(
REPO_ROOT,
`universal/auth/${pkg}/src/primitives/${id}/${id}.select.ts`
);
}
export interface SelectPlan {
readonly id: PrimitiveId;
readonly flavour: PrimitiveFlavour;
readonly path: string;
readonly contents: string;
}
export function plan(): readonly SelectPlan[] {
const out: SelectPlan[] = [];
const allIds = [
...HASH_PRIMITIVE_IDS,
...CURVE_PRIMITIVE_IDS,
...CIPHER_PRIMITIVE_IDS,
] as readonly PrimitiveId[];
for (const id of allIds) {
const flavour = cryptoConfig[id];
out.push({
id,
flavour,
path: selectPath(id),
contents: selectFileContents(id, flavour),
});
}
return out;
}
export function writePlan(plans: readonly SelectPlan[]): readonly {
readonly path: string;
readonly action: "written" | "unchanged";
}[] {
const results: { path: string; action: "written" | "unchanged" }[] = [];
for (const p of plans) {
mkdirSync(dirname(p.path), { recursive: true });
let current: string | undefined;
if (existsSync(p.path)) {
current = readFileSync(p.path, "utf8");
}
if (current === p.contents) {
results.push({ path: p.path, action: "unchanged" });
continue;
}
if (current !== undefined && !current.startsWith(BANNER)) {
throw new Error(
`Refusing to overwrite hand-edited select file (banner missing): ${p.path}`
);
}
writeFileSync(p.path, p.contents, "utf8");
results.push({ path: p.path, action: "written" });
}
return results;
}
async function main(): Promise<void> {
const results = writePlan(plan());
const written = results.filter((r) => r.action === "written").length;
const unchanged = results.filter((r) => r.action === "unchanged").length;
console.log(
`crypto-selects: ${written} written, ${unchanged} unchanged (${results.length} total)`
);
}
if (import.meta.main) {
await main();
}

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env bun
/**
* generate-category-readmes — for every `packages/<category>/` folder,
* write a `README.md` containing:
*
* 1. The contents of `packages/<category>/_intro.md` verbatim, if present.
* 2. An auto-generated section grouped by subcategory. Each subcategory
* shows its `_intro.md` summary (if present) followed by a table of
* packages in that subcategory.
*
* Re-runs are idempotent. The auto section is enclosed in
* `<!-- begin:auto-docs -->`…`<!-- end:auto-docs -->` markers; the intro
* file (when present) is dropped in above.
*
* Usage:
* bun scripts/generate-category-readmes.ts # rewrite
* bun scripts/generate-category-readmes.ts --check # exit 1 on drift
*/
import { type Dirent, readdirSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { CONFIG } from "./lib/config.js";
import {
AUTO_MARKER_BEGIN,
AUTO_MARKER_END,
firstBodyParagraph,
isDirectorySafe,
tryRead,
} from "./lib/doc-tree.js";
const ROOT = CONFIG.paths.root;
const PACKAGES_ROOT = join(ROOT, CONFIG.paths.packagesRoot);
const PKG_PREFIX = "@suaveplan/";
interface CategoryPackage {
name: string;
description: string;
relPath: string;
}
interface Subcategory {
name: string;
intro: string;
packages: CategoryPackage[];
}
function describePackageReadme(readmePath: string, fallback: string): string {
const content = tryRead(readmePath) ?? "";
if (!content) return fallback;
return firstBodyParagraph(content, 160) || fallback;
}
function escapeCell(s: string): string {
return s.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
}
function readSuaveplanPackage(
pkgDir: string,
relPath: string
): CategoryPackage | null {
const pkgRaw = tryRead(join(pkgDir, "package.json"));
if (!pkgRaw) return null;
let pkg: { name?: string; description?: string };
try {
pkg = JSON.parse(pkgRaw) as { name?: string; description?: string };
} catch {
return null;
}
if (!pkg.name?.startsWith(PKG_PREFIX)) return null;
const shortName = pkg.name.slice(PKG_PREFIX.length);
const description = describePackageReadme(
join(pkgDir, "README.md"),
(pkg.description ?? "").trim()
);
return { name: shortName, description, relPath };
}
function collectSubcategoryPackages(subcategoryDir: string): CategoryPackage[] {
const out: CategoryPackage[] = [];
let entries: Dirent[];
try {
entries = readdirSync(subcategoryDir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const pkgDir = join(subcategoryDir, entry.name);
const record = readSuaveplanPackage(pkgDir, entry.name);
if (record !== null) out.push(record);
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function collectSubcategories(categoryDir: string): Subcategory[] {
const out: Subcategory[] = [];
let entries: Dirent[];
try {
entries = readdirSync(categoryDir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const subDir = join(categoryDir, entry.name);
const packages = collectSubcategoryPackages(subDir);
if (packages.length === 0) continue;
const introRaw = tryRead(join(subDir, "_intro.md"));
const intro = introRaw?.trim() ?? "";
out.push({ name: entry.name, intro, packages });
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function renderPackageTable(packages: readonly CategoryPackage[]): string {
const rows = packages
.map((p) => {
const link = `[\`@suaveplan/${p.name}\`](./${p.relPath}/README.md)`;
return `| ${link} | ${escapeCell(p.description || "—")} |`;
})
.join("\n");
return [
"| Package | Description |",
"|---|---|",
rows,
].join("\n");
}
function renderAutoBlock(
subcategories: readonly Subcategory[]
): string {
const sections = subcategories
.map((sub) => {
const lines: string[] = [];
lines.push(`### \`${sub.name}\``);
lines.push("");
if (sub.intro) {
lines.push(sub.intro);
lines.push("");
}
lines.push(renderPackageTable(sub.packages));
return lines.join("\n");
})
.join("\n\n");
const totalPackages = subcategories.reduce(
(sum, sub) => sum + sub.packages.length,
0
);
const lines = [
`## Subcategories`,
"",
`${subcategories.length} subcategor${subcategories.length === 1 ? "y" : "ies"}, ${totalPackages} package${totalPackages === 1 ? "" : "s"}.`,
"",
sections,
];
return lines.join("\n");
}
function buildReadme(
categoryName: string,
intro: string | undefined,
subcategories: readonly Subcategory[]
): string {
const header = intro?.trim()
? intro.trim()
: [
`# \`${categoryName}\``,
"",
`Auto-generated index of the \`@suaveplan/*\` packages under \`packages/${categoryName}/\`. Drop a hand-authored \`_intro.md\` in this directory to replace this default header.`,
].join("\n");
const auto = renderAutoBlock(subcategories);
return `${header}\n\n${AUTO_MARKER_BEGIN}\n${auto}\n${AUTO_MARKER_END}\n`;
}
interface CliOptions {
check: boolean;
}
function parseArgs(argv: readonly string[]): CliOptions {
let check = false;
for (const arg of argv) {
if (arg === "--check") check = true;
else throw new Error(`Unknown flag: ${arg}`);
}
return { check };
}
function main(): 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);
}
if (!isDirectorySafe(PACKAGES_ROOT)) {
console.error(`Packages root not found: ${PACKAGES_ROOT}`);
process.exit(2);
}
let categoryEntries: Dirent[];
try {
categoryEntries = readdirSync(PACKAGES_ROOT, { withFileTypes: true });
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(2);
}
let updated = 0;
let unchanged = 0;
let empty = 0;
for (const cat of categoryEntries) {
if (!cat.isDirectory()) continue;
if (cat.name.startsWith(".")) continue;
const categoryDir = join(PACKAGES_ROOT, cat.name);
const subcategories = collectSubcategories(categoryDir);
if (subcategories.length === 0) {
empty++;
continue;
}
const introPath = join(categoryDir, "_intro.md");
const intro = tryRead(introPath);
const next = buildReadme(basename(categoryDir), intro, subcategories);
const readmePath = join(categoryDir, "README.md");
const current = tryRead(readmePath) ?? "";
if (current === next) {
unchanged++;
continue;
}
if (!options.check) writeFileSync(readmePath, next);
updated++;
}
console.log("");
console.log(`✏️ Updated: ${updated}`);
console.log(`⏭️ Unchanged: ${unchanged}`);
if (empty > 0) console.log(`⚠️ Empty: ${empty}`);
if (options.check && updated > 0) {
console.error("");
console.error("Drift detected — run without --check to rewrite.");
process.exit(1);
}
process.exit(0);
}
main();

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bun
/**
* Generates MISSING.yml — same structure as FOLDERS.yml but only listing
* packages whose directories do not yet exist on disk.
* Run: bun scripts/generate-missing.ts
*/
import { existsSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
const ROOT = resolve(import.meta.dir, "..");
const foldersPath = join(ROOT, "FOLDERS.yml");
type FoldersYml = Record<string, Record<string, string[]>>;
const folders = parseYaml(await Bun.file(foldersPath).text()) as FoldersYml;
const missing: FoldersYml = {};
let totalPackages = 0;
let completedCount = 0;
let missingCount = 0;
for (const [top, domains] of Object.entries(folders)) {
for (const [domain, pkgs] of Object.entries(domains)) {
const missingPkgs: string[] = [];
for (const name of pkgs) {
totalPackages++;
const pkgDir = join(ROOT, top, domain, name);
if (existsSync(pkgDir)) {
completedCount++;
} else {
missingPkgs.push(name);
missingCount++;
}
}
if (missingPkgs.length > 0) {
if (!missing[top]) missing[top] = {};
missing[top][domain] = missingPkgs;
}
}
}
const header = [
"# MISSING — packages not yet created on disk.",
"# Auto-generated by scripts/generate-missing.ts — do not edit manually.",
"# Re-run after landing a new package to refresh.",
"",
].join("\n");
const yaml = stringifyYaml(missing, {
lineWidth: 100,
defaultKeyType: "PLAIN",
defaultStringType: "PLAIN",
});
const outPath = join(ROOT, "MISSING.yml");
writeFileSync(outPath, header + yaml, "utf8");
console.log(`Wrote ${outPath}`);
console.log(` ${totalPackages} total packages`);
console.log(` ${completedCount} completed`);
console.log(` ${missingCount} missing`);

View File

@@ -0,0 +1,334 @@
#!/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();

View File

@@ -0,0 +1,296 @@
#!/usr/bin/env bun
/**
* generate-packages-root-readme — writes `./packages/README.md` with a full
* drill-down of every category, subcategory, and `@suaveplan/*` package.
*
* 1. The contents of `packages/_intro.md` verbatim, if present.
* 2. An auto-generated hierarchy:
* ## Categories
* ### `category`
* (category _intro.md)
* #### `subcategory`
* (subcategory _intro.md)
* | Package | Description |
* ...
*
* Re-runs are idempotent. The auto section is enclosed in
* `<!-- begin:auto-docs -->`…`<!-- end:auto-docs -->` markers.
*
* Usage:
* bun scripts/generate-packages-root-readme.ts # rewrite
* bun scripts/generate-packages-root-readme.ts --check # exit 1 on drift
*/
import { type Dirent, readdirSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { CONFIG } from "./lib/config.js";
import {
AUTO_MARKER_BEGIN,
AUTO_MARKER_END,
firstBodyParagraph,
isDirectorySafe,
tryRead,
} from "./lib/doc-tree.js";
const ROOT = CONFIG.paths.root;
const PACKAGES_ROOT = join(ROOT, CONFIG.paths.packagesRoot);
const PKG_PREFIX = "@suaveplan/";
interface RootPackage {
name: string;
description: string;
relPath: string;
}
interface Subcategory {
name: string;
intro: string;
packages: RootPackage[];
}
interface Category {
name: string;
intro: string;
subcategories: Subcategory[];
}
function describePackageReadme(readmePath: string, fallback: string): string {
const content = tryRead(readmePath) ?? "";
if (!content) return fallback;
return firstBodyParagraph(content, 160) || fallback;
}
function escapeCell(s: string): string {
return s.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
}
function readSuaveplanPackage(
pkgDir: string,
relPath: string
): RootPackage | null {
const pkgRaw = tryRead(join(pkgDir, "package.json"));
if (!pkgRaw) return null;
let pkg: { name?: string; description?: string };
try {
pkg = JSON.parse(pkgRaw) as { name?: string; description?: string };
} catch {
return null;
}
if (!pkg.name?.startsWith(PKG_PREFIX)) return null;
const shortName = pkg.name.slice(PKG_PREFIX.length);
const description = describePackageReadme(
join(pkgDir, "README.md"),
(pkg.description ?? "").trim()
);
return { name: shortName, description, relPath };
}
function collectSubcategoryPackages(
subcategoryDir: string,
categoryName: string,
subcategoryName: string
): RootPackage[] {
const out: RootPackage[] = [];
let entries: Dirent[];
try {
entries = readdirSync(subcategoryDir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const pkgDir = join(subcategoryDir, entry.name);
const record = readSuaveplanPackage(
pkgDir,
`${categoryName}/${subcategoryName}/${entry.name}`
);
if (record !== null) out.push(record);
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function collectCategories(): Category[] {
const categories: Category[] = [];
let catEntries: Dirent[];
try {
catEntries = readdirSync(PACKAGES_ROOT, { withFileTypes: true });
} catch {
return categories;
}
for (const cat of catEntries) {
if (!cat.isDirectory()) continue;
if (cat.name.startsWith(".")) continue;
const categoryDir = join(PACKAGES_ROOT, cat.name);
const catIntroRaw = tryRead(join(categoryDir, "_intro.md"));
const catIntro = catIntroRaw?.trim() ?? "";
const subcategories: Subcategory[] = [];
let subEntries: Dirent[];
try {
subEntries = readdirSync(categoryDir, { withFileTypes: true });
} catch {
continue;
}
for (const sub of subEntries) {
if (!sub.isDirectory()) continue;
if (sub.name.startsWith(".")) continue;
const subDir = join(categoryDir, sub.name);
const packages = collectSubcategoryPackages(
subDir,
cat.name,
sub.name
);
if (packages.length === 0) continue;
const subIntroRaw = tryRead(join(subDir, "_intro.md"));
const subIntro = subIntroRaw?.trim() ?? "";
subcategories.push({
name: sub.name,
intro: subIntro,
packages,
});
}
if (subcategories.length === 0) continue;
subcategories.sort((a, b) => a.name.localeCompare(b.name));
categories.push({ name: cat.name, intro: catIntro, subcategories });
}
categories.sort((a, b) => a.name.localeCompare(b.name));
return categories;
}
function renderPackageTable(packages: readonly RootPackage[]): string {
const rows = packages
.map((p) => {
const link = `[\`@suaveplan/${p.name}\`](./${p.relPath}/README.md)`;
return `| ${link} | ${escapeCell(p.description || "—")} |`;
})
.join("\n");
return ["| Package | Description |", "|---|---|", rows].join("\n");
}
function stripLeadingH1(content: string): string {
return content.replace(/^#\s+.*$/m, "").trim();
}
function renderAutoBlock(categories: readonly Category[]): string {
const totalPackages = categories.reduce(
(sum, cat) =>
sum + cat.subcategories.reduce((s, sub) => s + sub.packages.length, 0),
0
);
const catSections = categories
.map((cat) => {
const lines: string[] = [];
lines.push(`### \`${cat.name}\``);
lines.push("");
if (cat.intro) {
lines.push(stripLeadingH1(cat.intro));
lines.push("");
}
for (const sub of cat.subcategories) {
lines.push(`#### \`${sub.name}\``);
lines.push("");
if (sub.intro) {
lines.push(stripLeadingH1(sub.intro));
lines.push("");
}
lines.push(renderPackageTable(sub.packages));
lines.push("");
}
return lines.join("\n").trimEnd();
})
.join("\n\n");
return [
`## Categories`,
"",
`${categories.length} categor${categories.length === 1 ? "y" : "ies"}, ${totalPackages} package${totalPackages === 1 ? "" : "s"}.`,
"",
catSections,
].join("\n");
}
function buildReadme(
intro: string | undefined,
categories: readonly Category[]
): string {
const header = intro?.trim()
? intro.trim()
: [
`# \`packages\``,
"",
`Auto-generated index of every \`@suaveplan/*\` package in the monorepo. Drop a hand-authored \`_intro.md\` in this directory to replace this default header.`,
].join("\n");
const auto = renderAutoBlock(categories);
return `${header}\n\n${AUTO_MARKER_BEGIN}\n${auto}\n${AUTO_MARKER_END}\n`;
}
interface CliOptions {
check: boolean;
}
function parseArgs(argv: readonly string[]): CliOptions {
let check = false;
for (const arg of argv) {
if (arg === "--check") check = true;
else throw new Error(`Unknown flag: ${arg}`);
}
return { check };
}
function main(): 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);
}
if (!isDirectorySafe(PACKAGES_ROOT)) {
console.error(`Packages root not found: ${PACKAGES_ROOT}`);
process.exit(2);
}
const categories = collectCategories();
if (categories.length === 0) {
console.error("No categories found with packages.");
process.exit(2);
}
const introPath = join(PACKAGES_ROOT, "_intro.md");
const intro = tryRead(introPath);
const next = buildReadme(intro, categories);
const readmePath = join(PACKAGES_ROOT, "README.md");
const current = tryRead(readmePath) ?? "";
if (current === next) {
console.log("");
console.log(`⏭️ Unchanged: 1`);
if (options.check) process.exit(0);
return;
}
if (!options.check) writeFileSync(readmePath, next);
console.log("");
console.log(`✏️ Updated: 1`);
console.log(`⏭️ Unchanged: 0`);
if (options.check) {
console.error("");
console.error("Drift detected — run without --check to rewrite.");
process.exit(1);
}
process.exit(0);
}
main();

View File

@@ -0,0 +1,196 @@
#!/usr/bin/env bun
/**
* SLSA Provenance generation script for CI/CD
*
* Generates an in-toto attestation with SLSA v1.0 provenance predicate
* for a published package. Intended to be called from the publish pipeline
* after a successful npm publish.
*
* Reads build metadata from GitLab CI environment variables and writes
* the provenance statement to a `.provenance.json` file alongside the artifact.
*
* Usage:
* bun scripts/generate-provenance.ts --package @suaveplan/utils --version 1.2.3 --digest abc123
* bun scripts/generate-provenance.ts --package @suaveplan/utils --version 1.2.3 --digest abc123 --output ./provenance/
*
* Environment variables (auto-detected in GitLab CI):
* CI_COMMIT_SHA - Git commit SHA
* CI_PROJECT_URL - Repository URL
* CI_COMMIT_REF_NAME - Branch or tag name
* CI_RUNNER_ID - Runner identifier
* CI_JOB_ID - Job identifier
* CI_JOB_STARTED_AT - Job start timestamp
* CI_PIPELINE_ID - Pipeline identifier
*/
import { createHash } from "node:crypto";
import { mkdir, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { CONFIG } from "./lib/config.js";
const ROOT_DIR = CONFIG.paths.root;
interface CliOptions {
packageName: string;
version: string;
digest: string;
outputDir?: string;
}
function parseArgs(): CliOptions {
const args = process.argv.slice(2);
let packageName = "";
let version = "";
let digest = "";
let outputDir: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--package" && i + 1 < args.length) {
packageName = args[++i];
} else if (arg.startsWith("--package=")) {
packageName = arg.slice(10);
} else if (arg === "--version" && i + 1 < args.length) {
version = args[++i];
} else if (arg.startsWith("--version=")) {
version = arg.slice(10);
} else if (arg === "--digest" && i + 1 < args.length) {
digest = args[++i];
} else if (arg.startsWith("--digest=")) {
digest = arg.slice(9);
} else if (arg === "--output" && i + 1 < args.length) {
outputDir = args[++i];
} else if (arg.startsWith("--output=")) {
outputDir = arg.slice(9);
}
}
if (!packageName || !version || !digest) {
console.error(
"Usage: bun scripts/generate-provenance.ts --package <name> --version <ver> --digest <sha256>"
);
process.exit(1);
}
return { packageName, version, digest, outputDir };
}
function getGitLabEnv() {
return {
commitSha: process.env.CI_COMMIT_SHA ?? "unknown",
projectUrl:
process.env.CI_PROJECT_URL ??
"https://gitlab.fq.io/suaveplan/monorepo",
refName: process.env.CI_COMMIT_REF_NAME ?? "unknown",
runnerId: process.env.CI_RUNNER_ID ?? "local",
jobId: process.env.CI_JOB_ID ?? `local-${Date.now()}`,
jobStartedAt: process.env.CI_JOB_STARTED_AT ?? new Date().toISOString(),
pipelineId: process.env.CI_PIPELINE_ID ?? "local",
isCI: !!(process.env.CI || process.env.GITLAB_CI),
};
}
function generateProvenanceStatement(
options: CliOptions,
env: ReturnType<typeof getGitLabEnv>
) {
const buildType = "https://suaveplan.app/slsa/build-type/bun-monorepo/v1";
const encodedName = options.packageName.replace("@", "%40");
const subjectUri = `pkg:npm/${encodedName}@${options.version}`;
const builderId = `${env.projectUrl}/-/runners/${env.runnerId}`;
const invocationId = `${env.projectUrl}/-/pipelines/${env.pipelineId}/jobs/${env.jobId}`;
return {
_type: "https://in-toto.io/Statement/v1",
subject: [
{
uri: subjectUri,
digest: { sha256: options.digest },
name: options.packageName,
},
],
predicateType: "https://slsa.dev/provenance/v1",
predicate: {
buildDefinition: {
buildType,
externalParameters: {
repository: env.projectUrl,
ref: `refs/heads/${env.refName}`,
},
internalParameters: {
environment: env.isCI ? "gitlab-ci" : "local",
resolvedDependencies: [
{
uri: `git+${env.projectUrl}@refs/heads/${env.refName}`,
digest: { sha256: env.commitSha },
name: "source",
},
],
},
resolvedDependencies: [
{
uri: `git+${env.projectUrl}@refs/heads/${env.refName}`,
digest: { sha256: env.commitSha },
name: "source",
},
],
},
runDetails: {
builder: {
id: builderId,
version: {
bun:
typeof Bun !== "undefined"
? Bun.version
: "unknown",
},
},
metadata: {
invocationId,
startedOn: env.jobStartedAt,
finishedOn: new Date().toISOString(),
},
},
},
};
}
async function main() {
const options = parseArgs();
const env = getGitLabEnv();
console.log(
`Generating SLSA provenance for ${options.packageName}@${options.version}`
);
console.log(` Commit: ${env.commitSha}`);
console.log(` Builder: ${env.projectUrl}/-/runners/${env.runnerId}`);
console.log(` CI: ${env.isCI ? "yes" : "no (local)"}`);
const statement = generateProvenanceStatement(options, env);
const json = JSON.stringify(statement, null, 2);
// Compute digest of the provenance itself
const provenanceDigest = createHash("sha256").update(json).digest("hex");
const outputDir = options.outputDir
? join(ROOT_DIR, options.outputDir)
: join(ROOT_DIR, "provenance");
try {
await stat(outputDir);
} catch {
await mkdir(outputDir, { recursive: true });
}
const safeName = options.packageName.replace(/[/@]/g, "_");
const filename = `${safeName}-${options.version}.provenance.json`;
const outputPath = join(outputDir, filename);
await writeFile(outputPath, json);
console.log(` Output: ${outputPath}`);
console.log(` Provenance digest: ${provenanceDigest}`);
}
main().catch(console.error);

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env bun
/**
* generate-root-readme — rebuild `README.md` at the repo root.
*
* The output has two regions:
*
* 1. **Header.** If `_intro.md` exists at the repo root, its contents
* are dropped in verbatim. Otherwise a small default header is
* generated.
* 2. **Auto-generated category index.** A markdown table whose rows are
* `(category, # packages, link to packages/<category>/README.md)`.
* Wrapped in `<!-- begin:auto-docs -->`…`<!-- end:auto-docs -->`
* markers so future rewrites are surgical.
*
* The per-category READMEs are owned by `generate-category-readmes.ts`;
* the per-package documentation trees are owned by
* `generate-package-module-tree.ts`. This script intentionally stays at
* the top of the doc spine — it does not duplicate package-level detail.
*
* Usage:
* bun scripts/generate-root-readme.ts # rewrite README.md
* bun scripts/generate-root-readme.ts --check # exit 1 if drift
*/
import { type Dirent, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { CONFIG } from "./lib/config.js";
import {
AUTO_MARKER_BEGIN,
AUTO_MARKER_END,
isDirectorySafe,
tryRead,
} from "./lib/doc-tree.js";
const ROOT = CONFIG.paths.root;
const README_PATH = join(ROOT, "README.md");
const INTRO_PATH = join(ROOT, "_intro.md");
const PACKAGES_ROOT = join(ROOT, CONFIG.paths.packagesRoot);
const PKG_PREFIX = "@suaveplan/";
interface CategoryEntry {
name: string;
packageCount: number;
}
function countSuaveplanPackagesAt(dir: string): number {
const pkgJson = join(dir, "package.json");
const raw = tryRead(pkgJson);
if (!raw) return 0;
try {
const pkg = JSON.parse(raw) as { name?: string };
return pkg.name?.startsWith(PKG_PREFIX) ? 1 : 0;
} catch {
return 0;
}
}
function countPackagesInCategory(categoryDir: string): number {
let entries: Dirent[];
try {
entries = readdirSync(categoryDir, { withFileTypes: true });
} catch {
return 0;
}
let n = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const pluginDir = join(categoryDir, entry.name);
// Canonical: packages/<category>/<plugin>/<pkg>/package.json (depth 4)
// Fallback: packages/<category>/<pkg>/package.json (legacy 2-level)
const direct = countSuaveplanPackagesAt(pluginDir);
if (direct > 0) {
n += direct;
continue;
}
let pkgEntries: Dirent[];
try {
pkgEntries = readdirSync(pluginDir, { withFileTypes: true });
} catch {
continue;
}
for (const pkg of pkgEntries) {
if (!pkg.isDirectory()) continue;
if (pkg.name.startsWith(".")) continue;
n += countSuaveplanPackagesAt(join(pluginDir, pkg.name));
}
}
return n;
}
function collectCategories(): CategoryEntry[] {
if (!isDirectorySafe(PACKAGES_ROOT)) return [];
let entries: Dirent[];
try {
entries = readdirSync(PACKAGES_ROOT, { withFileTypes: true });
} catch {
return [];
}
const out: CategoryEntry[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const count = countPackagesInCategory(join(PACKAGES_ROOT, entry.name));
if (count === 0) continue;
out.push({ name: entry.name, packageCount: count });
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function renderAutoBlock(categories: readonly CategoryEntry[]): string {
const totalPackages = categories.reduce(
(acc, c) => acc + c.packageCount,
0
);
const rows = categories
.map((c) => {
const link = `[\`${c.name}\`](./packages/${c.name}/README.md)`;
return `| ${link} | ${c.packageCount} |`;
})
.join("\n");
const lines = [
"## Packages by category",
"",
`${totalPackages} \`@suaveplan/*\` packages across ${categories.length} categories. Each row links to the category's own README, which in turn lists every package and its first-paragraph description.`,
"",
"| Category | Packages |",
"|---|---:|",
rows,
];
return lines.join("\n");
}
function defaultHeader(): string {
return [
"# SuavePlan",
"",
"Enterprise-grade monorepo, rebuilt bottom-up on the `genesis` branch. Drop an `_intro.md` at the repo root to replace this default header.",
"",
"## Companion docs",
"",
"- [`STATUS.md`](./STATUS.md) — migration dashboard (✅ migrated / – pending / ❌ unwritten).",
"- [`SUMMARY.md`](./SUMMARY.md) — long-form per-package summaries.",
"- [`MANIFEST.md`](./MANIFEST.md) — dependency graph + tier overlay (auto-generated).",
"- [`MISSING.yml`](./MISSING.yml) — machine-readable list of unbuilt packages.",
"- [`CLAUDE.md`](./CLAUDE.md) — repo rules and AI-agent contract.",
"- [`openspec/`](./openspec/) — capability specs + change proposals + archived changes.",
].join("\n");
}
interface CliOptions {
check: boolean;
}
function parseArgs(argv: readonly string[]): CliOptions {
let check = false;
for (const arg of argv) {
if (arg === "--check") check = true;
else throw new Error(`Unknown flag: ${arg}`);
}
return { check };
}
function main(): 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 intro = tryRead(INTRO_PATH);
const header = intro?.trim() ? intro.trim() : defaultHeader();
const categories = collectCategories();
const auto = renderAutoBlock(categories);
const next = `${header}\n\n${AUTO_MARKER_BEGIN}\n${auto}\n${AUTO_MARKER_END}\n`;
let current = "";
try {
current = readFileSync(README_PATH, "utf-8");
} catch {
current = "";
}
if (current === next) {
console.log(
`✅ README.md already up to date (${categories.length} categories).`
);
process.exit(0);
}
if (options.check) {
console.error(
"❌ README.md is stale — run without --check to rewrite."
);
process.exit(1);
}
writeFileSync(README_PATH, next);
console.log(`✏️ Wrote ${README_PATH} (${categories.length} categories).`);
}
main();

187
scripts/generate-sbom.ts Normal file
View File

@@ -0,0 +1,187 @@
#!/usr/bin/env bun
/**
* SBOM (Software Bill of Materials) generation script
*
* Generates CycloneDX 1.5 SBOMs for all (or specific) packages in the monorepo.
* SBOMs are written to each package's dist/ directory alongside the build output.
*
* Usage:
* bun scripts/generate-sbom.ts # All packages
* bun scripts/generate-sbom.ts --package @suaveplan/utils # Single package
* bun scripts/generate-sbom.ts --output-dir ./sboms # Custom output dir
*/
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { CONFIG } from "./lib/config.js";
import { discoverPackageDirs } from "./lib/discover-packages.js";
const ROOT_DIR = CONFIG.paths.root;
interface CliOptions {
specificPackage?: string;
outputDir?: string;
}
function parseArgs(): CliOptions {
const args = process.argv.slice(2);
const options: CliOptions = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--package" && i + 1 < args.length) {
options.specificPackage = args[++i];
} else if (arg.startsWith("--package=")) {
options.specificPackage = arg.slice(10);
} else if (arg === "--output-dir" && i + 1 < args.length) {
options.outputDir = args[++i];
} else if (arg.startsWith("--output-dir=")) {
options.outputDir = arg.slice(13);
}
}
return options;
}
interface PackageInfo {
name: string;
version: string;
description?: string;
license?: string;
dependencies: Record<string, string>;
dir: string;
}
async function getPackages(specificPackage?: string): Promise<PackageInfo[]> {
const pkgDirs = await discoverPackageDirs();
const packages: PackageInfo[] = [];
for (const pkgDir of pkgDirs) {
const pkgJsonPath = join(pkgDir, "package.json");
const content = await readFile(pkgJsonPath, "utf-8");
const pkg = JSON.parse(content);
if (pkg.private) continue;
if (specificPackage && pkg.name !== specificPackage) continue;
packages.push({
name: pkg.name,
version: pkg.version,
description: pkg.description,
license: pkg.license,
dependencies: pkg.dependencies ?? {},
dir: pkgDir,
});
}
return packages;
}
function generateCycloneDxSbom(pkg: PackageInfo): object {
const components = Object.entries(pkg.dependencies).map(
([name, version]) => ({
type: "library",
name,
version: resolveVersion(version),
purl: buildPurl(name, resolveVersion(version)),
})
);
return {
bomFormat: "CycloneDX",
specVersion: "1.5",
serialNumber: `urn:uuid:${crypto.randomUUID()}`,
version: 1,
metadata: {
timestamp: new Date().toISOString(),
tools: [{ name: "@suaveplan/slsa", version: "1.0.0" }],
component: {
type: "library",
name: pkg.name,
version: pkg.version,
purl: buildPurl(pkg.name, pkg.version),
description: pkg.description,
license: pkg.license,
},
},
components,
};
}
function buildPurl(name: string, version: string): string {
if (name.startsWith("@")) {
const [scope, pkg] = name.split("/");
return `pkg:npm/${encodeURIComponent(scope!)}/${pkg}@${version}`;
}
return `pkg:npm/${name}@${version}`;
}
function resolveVersion(version: string): string {
if (version.startsWith("workspace:")) {
return version.replace("workspace:", "");
}
if (version.startsWith("catalog:")) {
return version.replace("catalog:", "catalog-");
}
return version;
}
async function main() {
const options = parseArgs();
console.log("=".repeat(60));
console.log("SBOM Generation (CycloneDX 1.5)");
if (options.specificPackage) {
console.log(`Package: ${options.specificPackage}`);
}
console.log("=".repeat(60));
const packages = await getPackages(options.specificPackage);
console.log(`Found ${packages.length} packages\n`);
let generated = 0;
let failed = 0;
for (const pkg of packages) {
try {
const sbom = generateCycloneDxSbom(pkg);
const json = JSON.stringify(sbom, null, 2);
const outputDir = options.outputDir
? join(ROOT_DIR, options.outputDir)
: join(pkg.dir, "dist");
try {
await stat(outputDir);
} catch {
await mkdir(outputDir, { recursive: true });
}
const filename = "sbom.cdx.json";
const outputPath = join(outputDir, filename);
await writeFile(outputPath, json);
const depCount = Object.keys(pkg.dependencies).length;
console.log(
` ${pkg.name}@${pkg.version} - ${depCount} deps -> ${outputPath}`
);
generated++;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(` ${pkg.name} - FAILED: ${message}`);
failed++;
}
}
console.log();
console.log("=".repeat(60));
console.log(`Generated: ${generated} | Failed: ${failed}`);
console.log("=".repeat(60));
if (failed > 0) {
process.exit(1);
}
}
main().catch(console.error);

View File

@@ -0,0 +1,215 @@
#!/usr/bin/env bun
/**
* generate-subcategory-readmes — for every `packages/<category>/<subcategory>/`
* folder, write a `README.md` containing:
*
* 1. The contents of `packages/<category>/<subcategory>/_intro.md` verbatim,
* if present.
* 2. An auto-generated table of packages in that subcategory, each row
* linking to the package's own README and showing the first-paragraph
* description of that README as the second column.
*
* Re-runs are idempotent. The auto section is enclosed in
* `<!-- begin:auto-docs -->`…`<!-- end:auto-docs -->` markers; the intro
* file (when present) is dropped in above.
*
* Usage:
* bun scripts/generate-subcategory-readmes.ts # rewrite
* bun scripts/generate-subcategory-readmes.ts --check # exit 1 on drift
*/
import { type Dirent, readdirSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { CONFIG } from "./lib/config.js";
import {
AUTO_MARKER_BEGIN,
AUTO_MARKER_END,
firstBodyParagraph,
isDirectorySafe,
tryRead,
} from "./lib/doc-tree.js";
const ROOT = CONFIG.paths.root;
const PACKAGES_ROOT = join(ROOT, CONFIG.paths.packagesRoot);
const PKG_PREFIX = "@suaveplan/";
interface SubcategoryPackage {
name: string;
description: string;
relPath: string;
}
function describePackageReadme(readmePath: string, fallback: string): string {
const content = tryRead(readmePath) ?? "";
if (!content) return fallback;
return firstBodyParagraph(content, 160) || fallback;
}
function escapeCell(s: string): string {
return s.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim();
}
function readSuaveplanPackage(
pkgDir: string,
relPath: string
): SubcategoryPackage | null {
const pkgRaw = tryRead(join(pkgDir, "package.json"));
if (!pkgRaw) return null;
let pkg: { name?: string; description?: string };
try {
pkg = JSON.parse(pkgRaw) as { name?: string; description?: string };
} catch {
return null;
}
if (!pkg.name?.startsWith(PKG_PREFIX)) return null;
const shortName = pkg.name.slice(PKG_PREFIX.length);
const description = describePackageReadme(
join(pkgDir, "README.md"),
(pkg.description ?? "").trim()
);
return { name: shortName, description, relPath };
}
function collectSubcategoryPackages(subcategoryDir: string): SubcategoryPackage[] {
const out: SubcategoryPackage[] = [];
let entries: Dirent[];
try {
entries = readdirSync(subcategoryDir, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const pkgDir = join(subcategoryDir, entry.name);
const record = readSuaveplanPackage(pkgDir, entry.name);
if (record !== null) out.push(record);
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function renderAutoBlock(
subcategoryName: string,
packages: readonly SubcategoryPackage[]
): string {
const rows = packages
.map((p) => {
const link = `[\`@suaveplan/${p.name}\`](./${p.relPath}/README.md)`;
return `| ${link} | ${escapeCell(p.description || "—")} |`;
})
.join("\n");
const lines = [
`## Packages in \`${subcategoryName}\``,
"",
`${packages.length} package${packages.length === 1 ? "" : "s"}.`,
"",
"| Package | Description |",
"|---|---|",
rows,
];
return lines.join("\n");
}
function buildReadme(
subcategoryName: string,
intro: string | undefined,
packages: readonly SubcategoryPackage[]
): string {
const introBody = intro?.trim()
? intro.trim()
: `Auto-generated index of the \`@suaveplan/*\` packages under this directory. Drop a hand-authored \`_intro.md\` here to replace this default header.`;
const header = [`# \`${subcategoryName}\``, "", introBody].join("\n");
const auto = renderAutoBlock(subcategoryName, packages);
return `${header}\n\n${AUTO_MARKER_BEGIN}\n${auto}\n${AUTO_MARKER_END}\n`;
}
interface CliOptions {
check: boolean;
}
function parseArgs(argv: readonly string[]): CliOptions {
let check = false;
for (const arg of argv) {
if (arg === "--check") check = true;
else throw new Error(`Unknown flag: ${arg}`);
}
return { check };
}
function main(): 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);
}
if (!isDirectorySafe(PACKAGES_ROOT)) {
console.error(`Packages root not found: ${PACKAGES_ROOT}`);
process.exit(2);
}
let categoryEntries: Dirent[];
try {
categoryEntries = readdirSync(PACKAGES_ROOT, { withFileTypes: true });
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(2);
}
let updated = 0;
let unchanged = 0;
let empty = 0;
for (const cat of categoryEntries) {
if (!cat.isDirectory()) continue;
if (cat.name.startsWith(".")) continue;
const categoryDir = join(PACKAGES_ROOT, cat.name);
let subcategoryEntries: Dirent[];
try {
subcategoryEntries = readdirSync(categoryDir, { withFileTypes: true });
} catch {
continue;
}
for (const sub of subcategoryEntries) {
if (!sub.isDirectory()) continue;
if (sub.name.startsWith(".")) continue;
const subcategoryDir = join(categoryDir, sub.name);
const packages = collectSubcategoryPackages(subcategoryDir);
if (packages.length === 0) {
empty++;
continue;
}
const introPath = join(subcategoryDir, "_intro.md");
const intro = tryRead(introPath);
const next = buildReadme(basename(subcategoryDir), intro, packages);
const readmePath = join(subcategoryDir, "README.md");
const current = tryRead(readmePath) ?? "";
if (current === next) {
unchanged++;
continue;
}
if (!options.check) writeFileSync(readmePath, next);
updated++;
}
}
console.log("");
console.log(`✏️ Updated: ${updated}`);
console.log(`⏭️ Unchanged: ${unchanged}`);
if (empty > 0) console.log(`⚠️ Empty: ${empty}`);
if (options.check && updated > 0) {
console.error("");
console.error("Drift detected — run without --check to rewrite.");
process.exit(1);
}
process.exit(0);
}
main();

View 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();

View File

@@ -0,0 +1,294 @@
/**
* Catalog Resolver
*
* Provides utilities for loading and resolving Bun workspace catalog references.
* Handles both default catalog (catalog:) and named catalogs (catalog:testing).
*
* @example
* ```typescript
* const resolver = await CatalogResolver.create();
* const version = resolver.resolve('react', 'catalog:react');
* // Returns: '19.0.0'
* ```
*/
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { CONFIG } from "./config.js";
import { discoverPackageDirs } from "./discover-packages.js";
export type ExportsField =
| string
| { [key: string]: ExportsField }
| ExportsField[];
export interface PackageJson {
name?: string;
version?: string;
private?: boolean;
main?: string;
module?: string;
types?: string;
files?: string[];
exports?: ExportsField;
scripts?: Record<string, string>;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
workspaces?: {
catalog?: Record<string, string>;
catalogs?: Record<string, Record<string, string>>;
};
[extra: string]: unknown;
}
export interface CatalogEntry {
catalogName: string;
version: string;
}
/**
* Resolves catalog references to actual versions
*/
export class CatalogResolver {
private catalogs: Map<string, Record<string, string>> = new Map();
private workspaceVersions: Map<string, string> = new Map();
private constructor() {}
/**
* Create a CatalogResolver by loading catalogs from root package.json
* and discovering workspace package versions.
*
* @returns Initialized CatalogResolver instance
* @throws Error if root package.json cannot be read
*/
static async create(): Promise<CatalogResolver> {
const resolver = new CatalogResolver();
await resolver.loadCatalogs();
await resolver.loadWorkspaceVersions();
return resolver;
}
/**
* Discover all @suaveplan/* workspace packages and their current versions.
* Walks `packages/<plugin>/<pkg>/` via `discoverPackageDirs`.
*/
private async loadWorkspaceVersions(): Promise<void> {
const pkgDirs = await discoverPackageDirs();
for (const pkgDir of pkgDirs) {
const pkgPath = join(pkgDir, "package.json");
try {
const content = await readFile(pkgPath, "utf-8");
const pkg: PackageJson = JSON.parse(content);
if (pkg.name && pkg.version) {
this.workspaceVersions.set(pkg.name, pkg.version);
}
} catch {
// Skip entries without a readable package.json
}
}
}
/**
* Load catalogs from root package.json
*
* Loads both the default catalog (workspaces.catalog) and named catalogs
* (workspaces.catalogs) into memory for fast lookups.
*/
private async loadCatalogs(): Promise<void> {
const rootPkgPath = join(CONFIG.paths.root, "package.json");
const content = await readFile(rootPkgPath, "utf-8");
const rootPkg: PackageJson = JSON.parse(content);
// Load default catalog
if (rootPkg.workspaces?.catalog) {
this.catalogs.set("", rootPkg.workspaces.catalog);
}
// Load named catalogs
if (rootPkg.workspaces?.catalogs) {
for (const [name, versions] of Object.entries(
rootPkg.workspaces.catalogs
)) {
this.catalogs.set(name, versions);
}
}
}
/**
* Get the number of loaded catalogs
*/
getCatalogCount(): number {
return this.catalogs.size;
}
/**
* Get all catalog names
*/
getCatalogNames(): string[] {
return Array.from(this.catalogs.keys());
}
/**
* Resolve a catalog reference to an actual version
*
* @param packageName - The package name to resolve (e.g., 'react')
* @param catalogRef - The catalog reference (e.g., 'catalog:react' or 'catalog:')
* @returns The resolved version string, or null if not found
*
* @example
* ```typescript
* resolver.resolve('react', 'catalog:react') // Returns: '19.0.0'
* resolver.resolve('vitest', 'catalog:testing') // Returns: '2.1.0'
* resolver.resolve('typescript', 'catalog:') // Returns: '5.9.2'
* ```
*/
resolve(packageName: string, catalogRef: string): string | null {
const match = catalogRef.match(/^catalog:(.*)$/);
if (!match) {
return null;
}
const catalogName = match[1] || "";
const catalog = this.catalogs.get(catalogName);
if (!catalog) {
return null;
}
return catalog[packageName] || null;
}
/**
* Find which catalog contains a package
*
* @param packageName - The package name to search for
* @returns CatalogEntry with catalog name and version, or null if not found
*
* @example
* ```typescript
* resolver.findInCatalogs('react')
* // Returns: { catalogName: 'react', version: '19.0.0' }
* ```
*/
findInCatalogs(packageName: string): CatalogEntry | null {
for (const [catalogName, catalog] of this.catalogs.entries()) {
const version = catalog[packageName];
if (version) {
return { catalogName, version };
}
}
return null;
}
/**
* Resolve dependencies object, converting catalog refs to actual versions
*
* @param deps - Dependencies object from package.json
* @param currentVersion - Current package version (for workspace:* refs)
* @returns Resolved dependencies with actual versions
*
* @example
* ```typescript
* const resolved = resolver.resolveDependencies({
* 'react': 'catalog:react',
* '@suaveplan/utils': 'workspace:*',
* 'lodash': '^4.17.21'
* }, '1.0.0');
* // Returns: {
* // 'react': '19.0.0',
* // '@suaveplan/utils': '^1.0.0',
* // 'lodash': '^4.17.21'
* // }
* ```
*/
resolveDependencies(
deps: Record<string, string> | undefined,
_currentVersion: string
): Record<string, string> | undefined {
if (!deps) return undefined;
const resolved: Record<string, string> = {};
for (const [name, version] of Object.entries(deps)) {
let resolvedVersion = version;
// First, resolve catalog references
if (version.startsWith("catalog:")) {
const catalogResolved = this.resolve(name, version);
if (catalogResolved) {
resolvedVersion = catalogResolved;
} else {
continue; // Skip unresolved catalog refs
}
}
// Then, resolve workspace references (including those from catalogs)
if (resolvedVersion.startsWith("workspace:")) {
if (name.startsWith("@suaveplan/")) {
// Use the actual workspace package version so consumers
// pick up the correct minimum (prevents bun from resolving
// to stale nested versions from the registry).
const wsVersion = this.workspaceVersions.get(name);
resolved[name] = wsVersion ? `^${wsVersion}` : "^1.0.0";
} else {
resolved[name] = "*";
}
} else {
resolved[name] = resolvedVersion;
}
}
return Object.keys(resolved).length > 0 ? resolved : undefined;
}
/**
* Convert a regular version string to a catalog reference
*
* @param packageName - The package name
* @param version - The version string
* @returns Catalog reference string, or null if package not in any catalog
*
* @example
* ```typescript
* resolver.toCatalogRef('react', '19.0.0')
* // Returns: 'catalog:react'
* ```
*/
toCatalogRef(packageName: string, version: string): string | null {
const entry = this.findInCatalogs(packageName);
if (!entry) {
return null;
}
// Only convert if versions match
if (entry.version === version) {
return entry.catalogName
? `catalog:${entry.catalogName}`
: "catalog:";
}
return null;
}
/**
* Get all packages in a specific catalog
*
* @param catalogName - The catalog name (empty string for default catalog)
* @returns Record of package names to versions
*/
getCatalog(catalogName: string): Record<string, string> | undefined {
return this.catalogs.get(catalogName);
}
/**
* Check if a package exists in any catalog
*
* @param packageName - The package name to check
* @returns true if package exists in at least one catalog
*/
hasPackage(packageName: string): boolean {
return this.findInCatalogs(packageName) !== null;
}
}

72
scripts/lib/config.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* Centralized configuration for scripts
*
* All registry URLs, paths, and validation thresholds are defined here
* to avoid hardcoding values across multiple scripts.
*/
export const CONFIG = {
/**
* Registry configurations
*/
registry: {
verdaccio: {
url: process.env.VERDACCIO_URL || "https://npmjs.fq.io/",
webUrl: "https://npmjs.fq.io/-/web/detail",
},
gitlab: {
url:
process.env.GITLAB_REGISTRY ||
"https://gitlab.fq.io/api/v4/projects/2/packages/npm/",
apiKey: process.env.GITLAB_API_KEY,
},
},
/**
* Monorepo paths
*/
paths: {
root: process.env.MONOREPO_ROOT || process.cwd(),
/**
* Single root for all genesis workspace packages.
* Layout is `packages/<category>/<plugin>/<pkg>/` — see
* `discoverPackageDirs` in `./discover-packages.ts` for the 3-level
* walk (with 2-level and flat fallbacks).
*/
packagesRoot: "packages",
},
/**
* Quality and validation thresholds
*/
validation: {
minCoverage: 95,
minDocsCoverage: 90,
maxComplexity: 10,
maxMethodLines: 20,
maxClassLines: 200,
},
/**
* Build and test settings
*/
build: {
cacheDir: "node_modules/.vite",
distDir: "dist",
},
/**
* Exit codes for consistent error handling
*/
exitCodes: {
success: 0,
failure: 1,
partialSuccess: 2,
},
} as const;
/**
* Type-safe access to config values
*/
export type Config = typeof CONFIG;

View File

@@ -0,0 +1,78 @@
/**
* Walk `packages/<category>/<plugin>/<pkg>/` and return absolute directory
* paths for every workspace package (the level containing a `package.json`).
*
* Supports the canonical 3-level layout (`packages/<category>/<plugin>/<pkg>/`)
* and falls back to the legacy 2-level (`packages/<plugin>/<pkg>/`) or flat
* (`packages/<pkg>/`) layouts so experimental or transitional packages keep
* working.
*/
import { readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { CONFIG } from "./config.js";
export interface DiscoverOptions {
/** Override the monorepo root. Defaults to `CONFIG.paths.root`. */
root?: string;
/** Override the packages base. Defaults to `CONFIG.paths.packagesRoot`. */
packagesRoot?: string;
}
async function hasPackageJson(dir: string): Promise<boolean> {
try {
await stat(join(dir, "package.json"));
return true;
} catch {
return false;
}
}
async function listDirs(parent: string): Promise<string[]> {
const out: string[] = [];
const entries = await readdir(parent).catch((): string[] => []);
for (const name of entries) {
const path = join(parent, name);
const s = await stat(path).catch(() => null);
if (s?.isDirectory()) out.push(path);
}
return out;
}
export async function discoverPackageDirs(
options: DiscoverOptions = {}
): Promise<string[]> {
const root = options.root ?? CONFIG.paths.root;
const base = join(root, options.packagesRoot ?? CONFIG.paths.packagesRoot);
const dirs: string[] = [];
const level1 = await listDirs(base);
for (const lvl1 of level1) {
// Flat: packages/<pkg>/package.json
if (await hasPackageJson(lvl1)) {
dirs.push(lvl1);
continue;
}
const level2 = await listDirs(lvl1);
for (const lvl2 of level2) {
// Legacy 2-level: packages/<plugin>/<pkg>/package.json
if (await hasPackageJson(lvl2)) {
dirs.push(lvl2);
continue;
}
// Canonical 3-level: packages/<category>/<plugin>/<pkg>/package.json
const level3 = await listDirs(lvl2);
for (const lvl3 of level3) {
if (await hasPackageJson(lvl3)) {
dirs.push(lvl3);
}
}
}
}
dirs.sort();
return dirs;
}

260
scripts/lib/doc-tree.ts Normal file
View File

@@ -0,0 +1,260 @@
/**
* Shared helpers for the documentation-tree scripts:
* - `firstBodyParagraph` — extract a truncated first paragraph from a `.md`.
* - `walkMd` — recursive `.md` walker honoring an ignore list.
* - `replaceMarkedSection` — idempotent in-place section replacement.
* - `extractRelativeMdLinks` — pull `.md` link targets out of markdown content.
* - `findCanonicalMd` — locate the dominant `.md` for a directory.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
export const AUTO_MARKER_BEGIN = "<!-- begin:auto-docs -->";
export const AUTO_MARKER_END = "<!-- end:auto-docs -->";
/**
* Strip a leading `# Header` line and any frontmatter, then return the first
* non-empty paragraph (text up to the next blank line). Inline markdown
* formatting is preserved; whitespace is collapsed; the result is truncated
* to `maxChars` characters (with a trailing ellipsis when it overflows).
*/
export function firstBodyParagraph(content: string, maxChars = 120): string {
let body = content.replace(/^/, "");
if (body.startsWith("---\n")) {
const end = body.indexOf("\n---", 4);
if (end !== -1) body = body.slice(end + 4);
}
const lines = body.split("\n");
let i = 0;
// Skip leading whitespace + every heading line so the first prose
// paragraph is grabbed regardless of whether the doc starts with
// `# Title\n## Purpose\n\nprose…` or `# Title\n\nprose…`.
const isHeading = (line: string): boolean => /^#{1,6}\s/.test(line);
while (i < lines.length) {
const line = (lines[i] ?? "").trim();
if (line === "") {
i++;
continue;
}
if (isHeading(lines[i] ?? "")) {
i++;
continue;
}
break;
}
const paragraph: string[] = [];
while (i < lines.length) {
const line = lines[i] ?? "";
if (line.trim() === "") break;
if (/^(#{1,6}\s|```|---|>\s)/.test(line)) break;
paragraph.push(line);
i++;
}
// Strip inline markdown link syntax — `[text](url)` becomes `text` —
// so the truncation can never land inside a URL or break MDX autolink
// parsing downstream. Bare `<https://...>` autolinks are stripped to
// their text form too. Descriptions are display-only; the clickable
// links live elsewhere in the doc tree.
const stripped = paragraph
.join(" ")
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
.replace(/<(https?:\/\/[^>]+)>/gu, "$1")
.replace(/\s+/g, " ")
.trim();
if (!stripped) return "";
if (stripped.length <= maxChars) return stripped;
let cut = stripped.slice(0, maxChars - 1).trimEnd();
// Belt-and-braces: if anything URL-shaped still extends past the cut,
// walk back to the last whitespace before it so the truncated string
// never trails a partial URL.
const lastHttpIdx = Math.max(
cut.lastIndexOf("https://"),
cut.lastIndexOf("http://")
);
if (lastHttpIdx >= 0 && !/\s/.test(cut.slice(lastHttpIdx))) {
cut = cut.slice(0, lastHttpIdx).trimEnd();
}
return `${cut}…`;
}
export interface WalkOptions {
/** Directory names (segments) to skip entirely. */
ignoreDirs?: readonly string[];
/** File names to skip. */
ignoreFiles?: readonly string[];
/** Path segments — if any segment matches, the file is skipped. */
ignoreSegments?: readonly string[];
}
const DEFAULT_IGNORE_DIRS: readonly string[] = [
"node_modules",
"dist",
"build",
"coverage",
".turbo",
".vite",
".next",
];
export function walkMd(root: string, options: WalkOptions = {}): string[] {
const ignoreDirs = new Set([
...DEFAULT_IGNORE_DIRS,
...(options.ignoreDirs ?? []),
]);
const ignoreFiles = new Set(options.ignoreFiles ?? []);
const ignoreSegments = options.ignoreSegments ?? [];
const out: string[] = [];
const stack: string[] = [root];
while (stack.length > 0) {
const dir = stack.pop() as string;
let entries: ReturnType<typeof readdirSync>;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const name = entry.name;
const full = join(dir, name);
if (entry.isDirectory()) {
if (ignoreDirs.has(name)) continue;
if (name.startsWith(".") && name !== ".") continue;
if (ignoreSegments.some((s) => full.includes(`${s}/`)))
continue;
stack.push(full);
} else if (entry.isFile()) {
if (!name.endsWith(".md")) continue;
if (ignoreFiles.has(name)) continue;
if (ignoreSegments.some((s) => full.includes(`${s}/`)))
continue;
out.push(full);
}
}
}
out.sort();
return out;
}
/**
* Find `<!-- begin:auto-docs -->...<!-- end:auto-docs -->` in `content` and
* replace the body between the markers with `replacement`. If markers are
* absent, the markered block is inserted before the first `## ` heading
* (so the autotable sits above hand-authored sections) or appended at the
* end of the file when no heading is found. Returns `{ next, changed }`.
*/
export function replaceMarkedSection(
content: string,
replacement: string
): { next: string; changed: boolean } {
const beginIdx = content.indexOf(AUTO_MARKER_BEGIN);
const endIdx = content.indexOf(AUTO_MARKER_END);
const block = `${AUTO_MARKER_BEGIN}\n${replacement.trim()}\n${AUTO_MARKER_END}`;
if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
const before = content.slice(0, beginIdx);
let after = content.slice(endIdx + AUTO_MARKER_END.length);
// Strip any stacked trailing end markers (with optional surrounding
// whitespace) and truncated `<!-- en…` fragments — defensive cleanup
// against historical generator-output corruption.
after = after.replace(
/(?:[ \t]*<!-- end:auto-docs -->[ \t]*\n?)+/g,
""
);
after = after.replace(/[ \t]*<!--\s*en…[ \t]*\n?/g, "");
const next = `${before}${block}${after}`;
return { next, changed: next !== content };
}
const headingMatch = content.match(/^##\s.*$/m);
if (headingMatch?.index !== undefined) {
const insertAt = headingMatch.index;
const before = content.slice(0, insertAt);
const after = content.slice(insertAt);
const sep = before.endsWith("\n\n")
? ""
: before.endsWith("\n")
? "\n"
: "\n\n";
const trailing = after.startsWith("\n") ? "" : "\n\n";
const next = `${before}${sep}${block}${trailing}${after}`;
return { next, changed: true };
}
const trimmed = content.replace(/\s+$/u, "");
const next = `${trimmed}\n\n${block}\n`;
return { next, changed: next !== content };
}
/**
* Pull every relative `.md` link target out of markdown content. Returns
* absolute filesystem paths resolved against `sourcePath`'s directory.
* Non-relative links (`http(s)://`, anchors, absolute URLs) are skipped.
*/
export function extractRelativeMdLinks(
content: string,
sourcePath: string
): string[] {
const out: string[] = [];
const base = dirname(sourcePath);
const matches = content.matchAll(/\[(?:[^\]]*)\]\(([^)]+)\)/g);
for (const match of matches) {
const raw = (match[1] ?? "").split("#")[0]?.trim() ?? "";
if (raw?.endsWith(".md") && !/^(?:https?:|mailto:|\/)/.test(raw)) {
out.push(join(base, raw));
}
}
return out;
}
/**
* For a directory, return the canonical `.md` doc. Priority order:
* 1. `<dirName>.md` (matches directory name)
* 2. `README.md`
* 3. The single non-`.types.md` `.md` file if exactly one exists
* 4. `undefined`
*/
export function findCanonicalMd(
dir: string,
dirName: string
): string | undefined {
const candidates: string[] = [];
let entries: ReturnType<typeof readdirSync>;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return undefined;
}
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith(".md")) continue;
if (entry.name.endsWith(".types.md")) continue;
candidates.push(entry.name);
}
const exact = `${dirName}.md`;
if (candidates.includes(exact)) return join(dir, exact);
if (candidates.includes("README.md")) return join(dir, "README.md");
if (candidates.length === 1) return join(dir, candidates[0] ?? "");
return undefined;
}
export function isDirectorySafe(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
export function tryRead(path: string): string | undefined {
try {
return readFileSync(path, "utf-8");
} catch {
return undefined;
}
}
export function relForLink(from: string, to: string): string {
const rel = relative(dirname(from), to).replace(/\\/g, "/");
return rel.startsWith(".") ? rel : `./${rel}`;
}

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env bun
/**
* Enforce §18 of `openspec/AGENTS.md`: an open `[ ]` gate row in an
* umbrella `tasks.md` MAY NOT carry-forward from another open `[ ]` gate.
*
* For every `openspec/changes/<umbrella>/tasks.md`, scan Section 2 rows
* of the form `- [ ] 2.7 Stage 7 ✓ — ... carry forward from gate 2.X ...`
* Build the dependency graph: row 2.Y depends on row 2.X (X less than Y).
* Fail if any unchecked row depends on another unchecked row.
*/
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { Glob } from "bun";
const REPO_ROOT = resolve(import.meta.dir, "..", "..");
interface GateRow {
readonly umbrella: string;
readonly file: string;
readonly line: number;
readonly id: string;
readonly checked: boolean;
readonly carriesFrom: readonly string[];
}
const GATE_LINE_RE = /^- \[( |x)\] (2\.\d+)\b/i;
const CARRY_RE = /carry(?:ing|s)?\s+forward\s+from\s+(?:gate\s+)?(2\.\d+)/gi;
async function parseUmbrella(rel: string): Promise<GateRow[]> {
const abs = resolve(REPO_ROOT, rel);
const source = await readFile(abs, "utf8");
const lines = source.split("\n");
const rows: GateRow[] = [];
let inSection2 = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
if (line.startsWith("## 2.")) inSection2 = true;
else if (inSection2 && line.startsWith("## ") && !line.startsWith("## 2"))
inSection2 = false;
if (!inSection2) continue;
const m = GATE_LINE_RE.exec(line);
if (!m) continue;
const [, mark, id] = m;
if (id === undefined || mark === undefined) continue;
const carriesFrom: string[] = [];
for (let j = i; j < Math.min(i + 6, lines.length); j++) {
const win = lines[j] ?? "";
CARRY_RE.lastIndex = 0;
for (
let mm = CARRY_RE.exec(win);
mm !== null;
mm = CARRY_RE.exec(win)
) {
if (mm[1] !== undefined && mm[1] !== id) {
carriesFrom.push(mm[1]);
}
}
}
rows.push({
umbrella: rel.split("/")[2] ?? "?",
file: rel,
line: i + 1,
id,
checked: mark === "x" || mark === "X",
carriesFrom,
});
}
return rows;
}
async function main(): Promise<void> {
const violations: string[] = [];
const umbrellas: string[] = [];
const g = new Glob("openspec/changes/*/tasks.md");
for await (const f of g.scan({ cwd: REPO_ROOT })) {
umbrellas.push(f);
}
const allRows: GateRow[] = [];
for (const u of umbrellas) {
allRows.push(...(await parseUmbrella(u)));
}
const byKey = new Map<string, GateRow>();
for (const r of allRows) {
byKey.set(`${r.umbrella}::${r.id}`, r);
}
for (const r of allRows) {
if (r.checked) continue;
for (const dep of r.carriesFrom) {
const depRow = byKey.get(`${r.umbrella}::${dep}`);
if (depRow === undefined) {
violations.push(
`${r.file}:${r.line} gate ${r.id} carries from ${dep} but ${dep} not found in same umbrella`
);
continue;
}
if (!depRow.checked) {
violations.push(
`${r.file}:${r.line} gate ${r.id} carries from ${dep} which is also unchecked — close ${dep} first`
);
}
}
}
if (violations.length === 0) {
console.log(
`OK ${allRows.length} umbrella gate rows scanned; no carry-forward-from-unshipped violations.`
);
return;
}
console.error(
`FAIL ${violations.length} carry-forward violation(s) - §18 forbids open-to-open carry:`
);
for (const v of violations) console.error(` ${v}`);
console.error(
"\nFix: close the earlier gate first (its row goes [x]), then unblock the dependent gate."
);
process.exit(1);
}
await main();

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env bun
// §16 lint: conformance harnesses live with the contract
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
const ROOT = resolve(import.meta.dir, "../..");
const PACKAGES_DIR = join(ROOT, "packages");
interface Pkg {
readonly name: string;
readonly basename: string;
readonly dir: string;
readonly deps: ReadonlySet<string>;
readonly skip: ReadonlySet<string>;
readonly hasTesting: boolean;
}
function findPackages(dir: string, out: Pkg[], depth = 0): void {
if (depth > 5) return;
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (
e.name === "node_modules" ||
e.name === "dist" ||
e.name.startsWith(".")
)
continue;
const full = join(dir, e.name);
if (!e.isDirectory()) continue;
const pj = join(full, "package.json");
try {
if (statSync(pj).isFile()) {
const raw = JSON.parse(readFileSync(pj, "utf-8"));
const name = raw.name as string | undefined;
if (name?.startsWith("@suaveplan/")) {
out.push({
name,
basename: name.slice("@suaveplan/".length),
dir: full,
deps: new Set(
Object.keys(raw.dependencies ?? {}).filter(
(n: string) => n.startsWith("@suaveplan/")
)
),
skip: new Set(raw.suaveplan?.conformanceSkip ?? []),
hasTesting: Object.keys(raw.exports ?? {}).includes(
"./testing"
),
});
}
continue;
}
} catch {
/* not a pkg */
}
findPackages(full, out, depth + 1);
}
}
function harnessExportsOf(pkg: Pkg): readonly string[] {
if (!pkg.hasTesting) return [];
const root = join(pkg.dir, "src", "testing");
try {
statSync(root);
} catch {
return [];
}
const names = new Set<string>();
function scan(d: string): void {
for (const e of readdirSync(d, { withFileTypes: true })) {
const f = join(d, e.name);
if (e.isDirectory()) scan(f);
else if (e.isFile() && f.endsWith(".ts")) {
const text = readFileSync(f, "utf-8");
const re =
/export\s+(?:async\s+)?function\s+(run\w+Conformance)\s*\(/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m[1]) names.add(m[1]);
}
}
}
}
scan(root);
return [...names];
}
function collectTestFiles(root: string, out: string[], depth = 0): void {
if (depth > 8) return;
let entries: ReturnType<typeof readdirSync>;
try {
entries = readdirSync(root, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (
e.name === "node_modules" ||
e.name === "dist" ||
e.name.startsWith(".")
)
continue;
const full = join(root, e.name);
if (e.isDirectory()) {
collectTestFiles(full, out, depth + 1);
continue;
}
if (!e.isFile()) continue;
if (!full.endsWith(".test.ts") && !full.endsWith(".spec.ts")) continue;
out.push(full);
}
}
function hasConformanceTest(
pkg: Pkg,
contractBasename: string,
harnesses: readonly string[]
): boolean {
const candidates: string[] = [];
collectTestFiles(join(pkg.dir, "tests"), candidates);
collectTestFiles(join(pkg.dir, "src"), candidates);
const harnessSet = new Set(harnesses);
for (const file of candidates) {
const base = file.split("/").pop() ?? "";
if (!/conformance/i.test(base)) continue;
let text: string;
try {
text = readFileSync(file, "utf-8");
} catch {
continue;
}
if (text.includes(`@suaveplan/${contractBasename}/testing`))
return true;
for (const h of harnessSet) {
// function-call invocation, possibly multiline whitespace before `(`
const re = new RegExp(`\\b${h}\\s*\\(`);
if (re.test(text)) return true;
}
}
return false;
}
const pkgs: Pkg[] = [];
findPackages(PACKAGES_DIR, pkgs);
const byName = new Map(pkgs.map((p) => [p.name, p]));
interface V {
readonly consumer: string;
readonly contract: string;
readonly harnesses: readonly string[];
}
const violations: V[] = [];
for (const consumer of pkgs) {
for (const dep of consumer.deps) {
const contract = byName.get(dep);
if (!contract || contract.basename === consumer.basename) continue;
const harnesses = harnessExportsOf(contract);
if (harnesses.length === 0) continue;
if (consumer.skip.has(contract.basename)) continue;
if (!hasConformanceTest(consumer, contract.basename, harnesses)) {
violations.push({
consumer: consumer.name,
contract: contract.name,
harnesses,
});
}
}
}
if (violations.length === 0) {
console.log(
`✓ check-conformance-tests: 0 violations across ${pkgs.length} packages`
);
process.exit(0);
}
console.error(`✗ check-conformance-tests: ${violations.length} violation(s)`);
console.error(
"Per openspec/AGENTS.md §16, every package consuming a contract MUST run"
);
console.error(
"the contract's conformance harness. Add tests/conformance/<contract>.test.ts.\n"
);
for (const v of violations) {
console.error(` ${v.consumer}`);
console.error(` consumes contract: ${v.contract}`);
console.error(` harnesses available: ${v.harnesses.join(", ")}`);
const base = v.contract.slice("@suaveplan/".length);
console.error(` fix: add tests/conformance/${base}.test.ts`);
}
process.exit(1);

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bun
// §15 lint: cross-package contracts must live in contract packages
import { readdirSync, readFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";
const ROOT = resolve(import.meta.dir, "../..");
const PACKAGES_DIR = join(ROOT, "packages");
const CONTRACT_BASENAMES = new Set([
"types",
"api-types",
"api-manifest",
"api-router",
"api-graphql",
"api-ws",
"api-sse",
"api-auth",
"api-auth-provider",
"api-secrets",
"api-tenant-provider",
"api-loader",
"api-lifecycle",
]);
const SUPPRESSION = "@cross-package-contract";
interface Decl {
readonly name: string;
readonly file: string;
readonly pkg: string;
}
function walk(dir: string, out: string[]): void {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (
e.name === "node_modules" ||
e.name === "dist" ||
e.name.startsWith(".")
)
continue;
const full = join(dir, e.name);
if (e.isDirectory()) walk(full, out);
else if (
e.isFile() &&
full.endsWith(".ts") &&
!full.endsWith(".test.ts") &&
!full.endsWith(".spec.ts") &&
!full.endsWith(".stories.tsx")
)
out.push(full);
}
}
function pkgOf(src: string): string | null {
const rel = relative(PACKAGES_DIR, src);
const parts = rel.split("/");
const idx = parts.indexOf("src");
return idx > 0 ? (parts[idx - 1] ?? null) : null;
}
const files: string[] = [];
walk(PACKAGES_DIR, files);
const decls: Decl[] = [];
for (const f of files) {
const text = readFileSync(f, "utf-8");
const lines = text.split("\n");
const re = /^\s*export\s+(?:interface\s+(\w+)|type\s+(\w+)\s*[=<])/;
for (let i = 0; i < lines.length; i++) {
const prev = i > 0 ? (lines[i - 1] ?? "") : "";
if (prev.includes(SUPPRESSION)) continue;
const m = re.exec(lines[i] ?? "");
if (!m) continue;
const name = m[1] ?? m[2];
const pkg = pkgOf(f);
if (!name || !pkg) continue;
decls.push({ name, file: f, pkg });
}
}
const byName = new Map<string, Decl>();
for (const d of decls) byName.set(d.name, d);
const violations = new Map<string, string[]>();
const implRe = /\bimplements\s+([A-Z]\w*)/g;
for (const f of files) {
const consumerPkg = pkgOf(f);
if (!consumerPkg) continue;
const text = readFileSync(f, "utf-8");
implRe.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = implRe.exec(text)) !== null) {
const name = m[1];
if (!name) continue;
const d = byName.get(name);
if (!d || d.pkg === consumerPkg) continue;
if (CONTRACT_BASENAMES.has(d.pkg)) continue;
const key = `${d.pkg}#${name}`;
const arr = violations.get(key) ?? [];
arr.push(`${relative(ROOT, f)} (${consumerPkg})`);
violations.set(key, arr);
}
}
if (violations.size === 0) {
console.log(
`✓ check-cross-package-contracts: 0 violations across ${files.length} files`
);
process.exit(0);
}
console.error(
`✗ check-cross-package-contracts: ${violations.size} violation(s)`
);
console.error(
"Per openspec/AGENTS.md §15, these types should move to a contract package"
);
console.error(
"OR add `// @cross-package-contract` above the declaration to document why.\n"
);
for (const [k, cs] of violations) {
const [pkg, name] = k.split("#");
console.error(` ${name} (in ${pkg}):`);
for (const c of cs) console.error(` implemented by: ${c}`);
}
process.exit(1);

View File

@@ -0,0 +1,222 @@
#!/usr/bin/env bun
/**
* Fails when a class extending `SuaveplanError` does NOT declare its own
* public constructor.
*
* Why this rule exists
* --------------------
* `SuaveplanError` is an `abstract class` with a `protected constructor`
* (see `packages/foundation/core/error/src/base/suaveplan-error.ts`).
* Subclasses MUST declare their own public constructor or callers cannot
* `new SomeError(...)` — TypeScript rejects the call with TS2674
* "Constructor of class is protected and only accessible within the class
* declaration".
*
* Without this check, the missing-constructor pattern keeps drifting back
* in via merges from stale worktrees or IDE buffer staleness — the
* `audio-engine-server` `AudioEngineServerError` has been "restored" four
* separate times. This script makes the next drift surface in CI before
* it lands.
*
* Detection
* ---------
* For each `*.ts` source file (excluding tests, dist, node_modules,
* declaration files), find every class declaration of the shape
* `class FooError extends SuaveplanError<...>`. Within the class body,
* look for a `public constructor(...)` declaration. If absent, the file
* is reported.
*/
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { Glob } from "bun";
interface Violation {
readonly file: string;
readonly className: string;
readonly classStartLine: number;
}
const REPO_ROOT = resolve(import.meta.dir, "..", "..");
const CLASS_RE =
/^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)\s+extends\s+SuaveplanError\b/gm;
// Match `constructor(` or `public constructor(` — TypeScript treats a
// bare `constructor` as public when no modifier is set, so both forms
// satisfy the "callable from outside" invariant. `private constructor`
// and `protected constructor` are NOT matched (they're the unsafe
// forms this check guards against).
const PUBLIC_CTOR_RE = /^\s*(?:public\s+)?constructor\s*\(/m;
async function findFiles(): Promise<string[]> {
const g = new Glob("packages/**/src/**/*.ts");
const out: string[] = [];
for await (const f of g.scan({ cwd: REPO_ROOT })) {
if (
f.includes("/node_modules/") ||
f.includes("/dist/") ||
f.endsWith(".d.ts") ||
f.endsWith(".test.ts") ||
f.endsWith(".bench.ts") ||
f.endsWith(".spec.ts")
) {
continue;
}
out.push(f);
}
return out;
}
function classBody(source: string, classStart: number): string {
const braceStart = source.indexOf("{", classStart);
if (braceStart === -1) return "";
let depth = 0;
for (let i = braceStart; i < source.length; i++) {
const ch = source[i];
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) return source.slice(braceStart, i + 1);
}
}
return source.slice(braceStart);
}
function lineOf(source: string, index: number): number {
let line = 1;
for (let i = 0; i < index; i++) if (source[i] === "\n") line++;
return line;
}
async function checkFile(rel: string): Promise<Violation[]> {
const abs = resolve(REPO_ROOT, rel);
const source = await readFile(abs, "utf8");
if (!source.includes("extends SuaveplanError")) return [];
const violations: Violation[] = [];
CLASS_RE.lastIndex = 0;
for (
let match = CLASS_RE.exec(source);
match !== null;
match = CLASS_RE.exec(source)
) {
const [, className] = match;
if (!className) continue;
const body = classBody(source, match.index);
if (!PUBLIC_CTOR_RE.test(body)) {
violations.push({
file: rel,
className,
classStartLine: lineOf(source, match.index),
});
}
}
return violations;
}
async function isInstantiatedAnywhere(
className: string,
files: string[]
): Promise<boolean> {
// Match `new ClassName(` excluding the class's own declaration site.
const callPattern = new RegExp(`\\bnew\\s+${className}\\s*\\(`);
for (const f of files) {
const abs = resolve(REPO_ROOT, f);
const src = await readFile(abs, "utf8");
// Skip declarations of the same class name.
if (callPattern.test(src)) {
// Ensure the match isn't only a comment.
const lines = src.split("\n");
for (const line of lines) {
if (
callPattern.test(line) &&
!line.trim().startsWith("//") &&
!line.trim().startsWith("*")
) {
return true;
}
}
}
}
return false;
}
async function hasSubclass(
className: string,
files: string[]
): Promise<boolean> {
// A class with at least one subclass is treated as an abstract base
// — the subclass declares its own public constructor, and direct
// `new` of the parent is rare / discouraged.
const subclassPattern = new RegExp(
`\\bclass\\s+\\w+\\s+extends\\s+${className}\\b`
);
for (const f of files) {
const abs = resolve(REPO_ROOT, f);
const src = await readFile(abs, "utf8");
if (subclassPattern.test(src)) return true;
}
return false;
}
async function main(): Promise<void> {
const files = await findFiles();
// Also scan test files for `new ClassName(` — tests are the most
// common construction site that tells us a class is concrete.
const testGlob = new Glob("packages/**/src/**/*.test.ts");
const callerFiles = [...files];
for await (const f of testGlob.scan({ cwd: REPO_ROOT })) {
if (f.includes("/node_modules/") || f.includes("/dist/")) continue;
callerFiles.push(f);
}
const candidates: Violation[] = [];
await Promise.all(
files.map(async (f) => {
const v = await checkFile(f);
candidates.push(...v);
})
);
// Filter to only those that are instantiated somewhere AND have no
// subclass (subclass-having ones are de-facto abstract bases).
const allViolations: Violation[] = [];
for (const c of candidates) {
if (await hasSubclass(c.className, files)) continue;
if (await isInstantiatedAnywhere(c.className, callerFiles)) {
allViolations.push(c);
}
}
if (allViolations.length === 0) {
console.log(
`OK ${files.length.toLocaleString()} files scanned — every SuaveplanError subclass declares a public constructor.`
);
return;
}
console.error(
`FAIL ${allViolations.length} SuaveplanError subclass(es) missing a public constructor:`
);
for (const v of allViolations) {
console.error(
` ${v.file}:${v.classStartLine} class ${v.className} extends SuaveplanError`
);
}
console.error(
"\nWhy: SuaveplanError has a `protected constructor`. Subclasses that"
);
console.error(
"omit their own public constructor are unconstructable from outside the"
);
console.error(
"class declaration — every `new FooError(...)` call site fails TS2674."
);
console.error(
"\nFix: add a constructor that forwards to `super(code, messageKey, options)`."
);
console.error(
"See `packages/api-stack/api/api-saga/src/error-codes/error-codes.ts` for"
);
console.error("the canonical pattern.");
process.exit(1);
}
await main();

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bun
/**
* Fails when an implementation+test pair lives flat at a package's `src/`
* root instead of inside a feature folder.
*
* CLAUDE.md §"Package Structure" mandates the 5-files-per-feature layout:
*
* src/<feature>/
* index.ts
* <feature>.ts
* <feature>.test.ts
* <feature>.types.ts
* <feature>.md
*
* The only flat exceptions allowed at `src/` root are:
* - `index.ts` (package barrel)
* - `types/`, `utils/`, `constants/` (per the documented exemption list)
*
* Any other `<name>.ts` paired with `<name>.test.ts` directly under `src/`
* (without a `<name>/` folder) is a layout violation and fails CI.
*
* Exit codes: 0 = clean, 1 = at least one violation.
*/
import { readdirSync, statSync } from "node:fs";
import { join } from "node:path";
const PACKAGES_DIR = "packages";
const ROOT_FLAT_EXEMPT = new Set(["index.ts"]);
const ROOT_DIR_EXEMPT = new Set(["types", "utils", "constants"]);
interface Violation {
readonly pkg: string;
readonly src: string;
readonly feature: string;
readonly files: readonly string[];
}
function listPackages(): readonly string[] {
const out: string[] = [];
for (const cat of readdirSync(PACKAGES_DIR)) {
const catDir = join(PACKAGES_DIR, cat);
if (!statSync(catDir).isDirectory()) continue;
for (const plugin of readdirSync(catDir)) {
const pluginDir = join(catDir, plugin);
if (!statSync(pluginDir).isDirectory()) continue;
for (const pkg of readdirSync(pluginDir)) {
const pkgDir = join(pluginDir, pkg);
if (!statSync(pkgDir).isDirectory()) continue;
const srcDir = join(pkgDir, "src");
try {
if (statSync(srcDir).isDirectory()) out.push(pkgDir);
} catch {
// no src/ — skip
}
}
}
}
return out;
}
function checkPackage(pkgDir: string): Violation[] {
const srcDir = join(pkgDir, "src");
const entries = readdirSync(srcDir);
const featuresFound = new Map<string, string[]>();
for (const name of entries) {
const full = join(srcDir, name);
if (statSync(full).isDirectory()) continue;
if (ROOT_FLAT_EXEMPT.has(name)) continue;
const match = name.match(/^(.+?)(\.test|\.types|\.md|)\.(ts|tsx|md)$/);
if (!match) continue;
const feature = match[1] as string;
const list = featuresFound.get(feature) ?? [];
list.push(name);
featuresFound.set(feature, list);
}
const violations: Violation[] = [];
for (const [feature, files] of featuresFound) {
const hasImpl = files.some((f) => f === `${feature}.ts`);
const hasTest = files.some((f) => f === `${feature}.test.ts`);
if (hasImpl && hasTest) {
violations.push({
pkg: pkgDir,
src: srcDir,
feature,
files,
});
}
}
return violations;
}
function main(): void {
const pkgs = listPackages();
const allViolations: Violation[] = [];
for (const pkg of pkgs) {
allViolations.push(...checkPackage(pkg));
}
if (allViolations.length === 0) {
console.log(
`OK ${pkgs.length} packages: every <feature>.ts + <feature>.test.ts pair lives in a feature folder.`
);
return;
}
console.error(
`Found ${allViolations.length} layout violation${allViolations.length === 1 ? "" : "s"}:\n`
);
for (const v of allViolations) {
console.error(`FAIL ${v.pkg}`);
console.error(
` feature \`${v.feature}\` is flat at src/ root; should live in src/${v.feature}/`
);
for (const f of v.files) {
console.error(` - src/${f}`);
}
}
console.error(
`\nCLAUDE.md §"Package Structure" mandates 5-files-per-feature layout. Only ${[...ROOT_DIR_EXEMPT].map((d) => `\`${d}/\``).join(", ")} dirs and \`index.ts\` are exempt at src/ root.`
);
console.error(
"Move each violation into a folder: mkdir src/<feature>/ && git mv src/<feature>.{ts,test.ts,md} src/<feature>/, then add a src/<feature>/index.ts barrel and update sibling imports."
);
process.exit(1);
}
main();

View File

@@ -0,0 +1,482 @@
/**
* Tests for check-no-backwards-deps.ts.
*
* Bun-test runner (precedent: scripts/lint/check-test-runner-imports.test.ts):
* the canonical-runner-wrapper rule polices `packages/<plugin>/<pkg>/src/`
* test files, not repo-tooling tests under `scripts/`.
*
* Real fixtures: every test writes a real package.json to a real temp
* directory and runs the script's `findViolations` against the file.
* No fs mocks — the temp-dir lifecycle is owned via beforeEach/afterEach.
*
* Includes the "passing case for the live react-i18n package.json" so a
* future regression (someone adds @suaveplan/react-ui to dependencies)
* fails this test loudly and the CI gate, not just the gate.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import {
BANNED_PATTERNS,
bannedPatternsFor,
defaultPackageJsonPath,
findViolations,
formatViolations,
INSPECTED_BUCKETS,
} from "./check-no-backwards-deps.ts";
const REPO_ROOT = resolve(__dirname, "../..");
interface Fixture {
readonly root: string;
cleanup(): Promise<void>;
}
async function makeFixture(): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), "check-no-backwards-deps-"));
return {
root,
async cleanup() {
await rm(root, { recursive: true, force: true });
},
};
}
async function writeManifest(
root: string,
manifest: Record<string, unknown>
): Promise<string> {
const path = join(root, "package.json");
await writeFile(path, JSON.stringify(manifest, null, 2));
return path;
}
describe("check-no-backwards-deps — fixture cases", () => {
let fixture: Fixture;
beforeEach(async () => {
fixture = await makeFixture();
});
afterEach(async () => {
await fixture.cleanup();
});
it("passes for a clean manifest with only foundation deps", {
timeout: 5000,
}, async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-i18n",
dependencies: {
"@suaveplan/error": "workspace:^",
"@suaveplan/i18n": "workspace:^",
react: "^19.0.0",
},
peerDependencies: { react: "^19.0.0" },
devDependencies: { "@suaveplan/testing": "workspace:^" },
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toEqual([]);
expect(formatViolations(violations)).toContain(
"no React-stack backward dependencies"
);
});
it("flags @suaveplan/react-ui in dependencies", {
timeout: 5000,
}, async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-i18n",
dependencies: { "@suaveplan/react-ui": "*" },
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toHaveLength(1);
expect(violations[0]?.bucket).toBe("dependencies");
expect(violations[0]?.depName).toBe("@suaveplan/react-ui");
expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-ui*");
expect(formatViolations(violations)).toContain(
"React-stack backward dependencies detected"
);
});
it("flags @suaveplan/react-forms in peerDependencies", {
timeout: 5000,
}, async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-i18n",
peerDependencies: { "@suaveplan/react-forms": "*" },
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toHaveLength(1);
expect(violations[0]?.bucket).toBe("peerDependencies");
expect(violations[0]?.depName).toBe("@suaveplan/react-forms");
expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-forms*");
});
it("flags @suaveplan/react-ui-theme-dracula in devDependencies", {
timeout: 5000,
}, async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-i18n",
devDependencies: {
"@suaveplan/react-ui-theme-dracula": "*",
},
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toHaveLength(1);
expect(violations[0]?.bucket).toBe("devDependencies");
expect(violations[0]?.depName).toBe(
"@suaveplan/react-ui-theme-dracula"
);
expect(violations[0]?.matchedPattern).toBe(
"@suaveplan/react-ui-theme-*"
);
});
it("flags @suaveplan/react-ui-pack-auth across all three buckets", {
timeout: 5000,
}, async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-i18n",
dependencies: { "@suaveplan/react-ui-pack-auth": "1.0.0" },
peerDependencies: { "@suaveplan/react-ui-pack-auth": "*" },
devDependencies: { "@suaveplan/react-ui-pack-auth": "*" },
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toHaveLength(3);
for (const v of violations) {
expect(v.matchedPattern).toBe("@suaveplan/react-ui-pack-*");
}
});
it("flags multiple banned deps and groups them by bucket in the diagnostic", {
timeout: 5000,
}, async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-i18n",
dependencies: {
"@suaveplan/react-ui": "*",
"@suaveplan/react-forms": "*",
},
devDependencies: {
"@suaveplan/react-ui-theme-x": "*",
},
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toHaveLength(3);
const message = formatViolations(violations);
expect(message).toContain("dependencies (2 violations)");
expect(message).toContain("devDependencies (1 violation)");
expect(message).toContain("Total: 3 violations");
});
it("ignores absent dep buckets", { timeout: 5000 }, async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-i18n",
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toEqual([]);
});
it("ignores buckets with non-object values", {
timeout: 5000,
}, async () => {
// A malformed bucket (array instead of object) is the consumer's
// bug — outside the scope of this rule. The script must not
// crash on it.
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-i18n",
dependencies: ["@suaveplan/react-ui"] as unknown,
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toEqual([]);
});
it("ignores buckets with null values", { timeout: 5000 }, async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-i18n",
dependencies: null,
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toEqual([]);
});
it("does NOT flag arbitrary @suaveplan/* packages outside the banned set", {
timeout: 5000,
}, async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-i18n",
dependencies: {
"@suaveplan/i18n": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/react-i18n": "workspace:^",
react: "^19.0.0",
},
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toEqual([]);
});
it("renders non-string version values via JSON.stringify in the diagnostic", {
timeout: 5000,
}, async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-i18n",
dependencies: {
"@suaveplan/react-ui": { version: "1.0.0" } as unknown,
},
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toHaveLength(1);
expect(violations[0]?.version).toContain('"version":"1.0.0"');
});
it("throws on a missing package.json", { timeout: 5000 }, async () => {
await expect(
findViolations({
packageJsonPath: join(fixture.root, "missing.json"),
})
).rejects.toThrow(/cannot read/);
});
it("throws on an unparsable package.json", { timeout: 5000 }, async () => {
const path = join(fixture.root, "package.json");
await writeFile(path, "{not json");
await expect(findViolations({ packageJsonPath: path })).rejects.toThrow(
/cannot parse/
);
});
it("respects an injected readFile dependency", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const fakeRead = async (path: import("node:fs").PathLike) => {
calls.push(String(path));
return JSON.stringify({
name: "@suaveplan/react-i18n",
dependencies: {
"@suaveplan/react-forms-validation": "*",
},
});
};
const violations = await findViolations({
packageJsonPath: "/virtual/package.json",
readFileFn: fakeRead as Parameters<
typeof findViolations
>[0]["readFileFn"],
});
expect(calls).toEqual(["/virtual/package.json"]);
expect(violations).toHaveLength(1);
expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-forms*");
});
});
describe("check-no-backwards-deps — live react-i18n package.json", () => {
it("the real package.json declares no banned deps", {
timeout: 5000,
}, async () => {
const target = defaultPackageJsonPath(REPO_ROOT);
const violations = await findViolations({
packageJsonPath: target,
});
expect(violations).toEqual([]);
});
});
describe("check-no-backwards-deps — registry sanity", () => {
it("INSPECTED_BUCKETS lists exactly the three audited dep buckets", {
timeout: 5000,
}, () => {
expect(INSPECTED_BUCKETS).toEqual([
"dependencies",
"peerDependencies",
"devDependencies",
]);
});
it("BANNED_PATTERNS labels are stable", { timeout: 5000 }, () => {
const labels = BANNED_PATTERNS.map((p) => p.label).sort();
expect(labels).toEqual([
"@suaveplan/react-forms*",
"@suaveplan/react-ui*",
"@suaveplan/react-ui-pack-*",
"@suaveplan/react-ui-theme-*",
]);
});
it("defaultPackageJsonPath resolves under <root>/packages/react/react-i18n", {
timeout: 5000,
}, () => {
const path = defaultPackageJsonPath("/virtual/repo");
expect(path).toBe(
"/virtual/repo/packages/react/react-i18n/package.json"
);
});
});
describe("check-no-backwards-deps — per-tier banned-pattern hierarchy", () => {
it("react-i18n bans every leaf in the React stack", () => {
const labels = bannedPatternsFor("@suaveplan/react-i18n")
.map((p) => p.label)
.sort();
expect(labels).toEqual([
"@suaveplan/react-forms*",
"@suaveplan/react-ui*",
"@suaveplan/react-ui-pack-*",
"@suaveplan/react-ui-theme-*",
]);
});
it("react-ui bans react-forms, react-ui-theme-*, react-ui-pack-* (CAN dep react-i18n)", () => {
const labels = bannedPatternsFor("@suaveplan/react-ui")
.map((p) => p.label)
.sort();
expect(labels).toEqual([
"@suaveplan/react-forms*",
"@suaveplan/react-ui-pack-*",
"@suaveplan/react-ui-theme-*",
]);
});
it("react-forms bans theme-* and pack-* (CAN dep react-i18n + react-ui)", () => {
const labels = bannedPatternsFor("@suaveplan/react-forms")
.map((p) => p.label)
.sort();
expect(labels).toEqual([
"@suaveplan/react-ui-pack-*",
"@suaveplan/react-ui-theme-*",
]);
});
it("react-ui-theme-* bans react-forms* and pack-* (CAN dep react-i18n + react-ui)", () => {
const labels = bannedPatternsFor("@suaveplan/react-ui-theme-dracula")
.map((p) => p.label)
.sort();
expect(labels).toEqual([
"@suaveplan/react-forms*",
"@suaveplan/react-ui-pack-*",
]);
});
it("react-ui-pack-* bans NOTHING in the React stack (bottom of hierarchy)", () => {
expect(bannedPatternsFor("@suaveplan/react-ui-pack-auth")).toEqual([]);
});
it("packages outside the React stack get a no-op (empty banned list)", () => {
expect(bannedPatternsFor("@suaveplan/error")).toEqual([]);
expect(bannedPatternsFor("@suaveplan/i18n")).toEqual([]);
expect(bannedPatternsFor("@some-vendor/lib")).toEqual([]);
});
it("undefined / non-string package names yield empty banned list", () => {
expect(bannedPatternsFor(undefined)).toEqual([]);
});
});
describe("check-no-backwards-deps — per-tier enforcement", () => {
let fixture: Fixture;
beforeEach(async () => {
fixture = await makeFixture();
});
afterEach(async () => {
await fixture.cleanup();
});
it("react-ui declaring @suaveplan/react-i18n is allowed (upstream dep)", async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-ui",
dependencies: { "@suaveplan/react-i18n": "workspace:^" },
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toEqual([]);
});
it("react-ui declaring @suaveplan/react-forms IS a violation", async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-ui",
dependencies: { "@suaveplan/react-forms": "workspace:^" },
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toHaveLength(1);
expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-forms*");
});
it("react-forms declaring @suaveplan/react-ui is allowed (upstream dep)", async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-forms",
dependencies: {
"@suaveplan/react-ui": "workspace:^",
"@suaveplan/react-i18n": "workspace:^",
},
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toEqual([]);
});
it("react-forms declaring @suaveplan/react-ui-theme-dracula IS a violation", async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-forms",
dependencies: {
"@suaveplan/react-ui-theme-dracula": "workspace:^",
},
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toHaveLength(1);
expect(violations[0]?.matchedPattern).toBe(
"@suaveplan/react-ui-theme-*"
);
});
it("react-ui-theme-dracula declaring react-i18n + react-ui is allowed", async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-ui-theme-dracula",
dependencies: {
"@suaveplan/react-i18n": "workspace:^",
"@suaveplan/react-ui": "workspace:^",
},
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toEqual([]);
});
it("react-ui-theme-dracula declaring react-forms IS a violation", async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-ui-theme-dracula",
dependencies: { "@suaveplan/react-forms": "workspace:^" },
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toHaveLength(1);
expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-forms*");
});
it("react-ui-pack-auth can declare react-i18n + react-ui + react-forms (bottom-most tier)", async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/react-ui-pack-auth",
dependencies: {
"@suaveplan/react-i18n": "workspace:^",
"@suaveplan/react-ui": "workspace:^",
"@suaveplan/react-forms": "workspace:^",
},
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toEqual([]);
});
it("packages outside the React stack get a clean pass even with React-stack deps", async () => {
const path = await writeManifest(fixture.root, {
name: "@suaveplan/some-other-package",
dependencies: {
"@suaveplan/react-i18n": "workspace:^",
"@suaveplan/react-ui": "workspace:^",
"@suaveplan/react-forms": "workspace:^",
"@suaveplan/react-ui-theme-dracula": "workspace:^",
"@suaveplan/react-ui-pack-auth": "workspace:^",
},
});
const violations = await findViolations({ packageJsonPath: path });
expect(violations).toEqual([]);
});
});

View File

@@ -0,0 +1,306 @@
#!/usr/bin/env bun
/**
* check-no-backwards-deps.ts
*
* CI gate enforcing the React-stack dependency-direction invariant
* declared in:
* - openspec/changes/enterprise-react-i18n/specs/react-i18n/spec.md
* ("Branded TranslationKey typing contract")
* - openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md §11.2
*
* The React stack has a strict layered hierarchy:
*
* react-i18n (foundation; has no upstream React-stack deps)
* ▲
* react-ui (depends on react-i18n only)
* ▲
* react-forms (depends on react-i18n + react-ui)
* ▲
* react-ui-theme-* (depends on react-i18n + react-ui)
* react-ui-pack-* (depends on react-i18n + react-ui [+ react-forms])
*
* Each tier MUST NOT declare a downstream-tier package in its
* `dependencies`, `peerDependencies`, or `devDependencies` — otherwise
* the layering inverts, the TranslationKey augmentation contract breaks,
* and tree-shaking guarantees collapse.
*
* This script reads a package.json, identifies the package by its `name`,
* computes the appropriate banned-pattern subset for that tier, and
* checks the three dep buckets. Exits non-zero with a structured
* diagnostic listing every offending (bucket, package) pair.
*
* Packages outside the React stack (`@suaveplan/error`, `@suaveplan/i18n`,
* `@some/other`) get a no-op pass — this gate is React-stack-specific.
*
* Usage:
* bun scripts/lint/check-no-backwards-deps.ts [--root <repo-root>]
* [--package-json <path>]
*
* Exit codes:
* 0 — package.json declares no banned deps (or is outside React stack)
* 1 — at least one banned dep found (details on stderr)
* 2 — internal error (cannot read or parse package.json)
*/
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
export interface BannedPattern {
readonly label: string;
readonly match: (depName: string) => boolean;
}
const PATTERN_REACT_UI_THEME: BannedPattern = {
label: "@suaveplan/react-ui-theme-*",
match: (n) => n.startsWith("@suaveplan/react-ui-theme-"),
};
const PATTERN_REACT_UI_PACK: BannedPattern = {
label: "@suaveplan/react-ui-pack-*",
match: (n) => n.startsWith("@suaveplan/react-ui-pack-"),
};
const PATTERN_REACT_UI: BannedPattern = {
label: "@suaveplan/react-ui*",
match: (n) =>
n === "@suaveplan/react-ui" ||
(n.startsWith("@suaveplan/react-ui-") &&
!n.startsWith("@suaveplan/react-ui-theme-") &&
!n.startsWith("@suaveplan/react-ui-pack-")),
};
const PATTERN_REACT_FORMS: BannedPattern = {
label: "@suaveplan/react-forms*",
match: (n) =>
n === "@suaveplan/react-forms" ||
n.startsWith("@suaveplan/react-forms-"),
};
/**
* Full registry of every banned-pattern in the React stack. Per-tier
* banned subsets are derived from this set. Stable for downstream
* consumers (test fixtures, doc generators).
*/
export const BANNED_PATTERNS: ReadonlyArray<BannedPattern> = [
PATTERN_REACT_UI_THEME,
PATTERN_REACT_UI_PACK,
PATTERN_REACT_UI,
PATTERN_REACT_FORMS,
];
/**
* Per-tier banned-pattern map. Each tier's entry lists the patterns
* that MUST NOT appear in any of its dep buckets. Computed from the
* stack hierarchy:
*
* react-i18n → bans every leaf (react-ui*, react-forms*, theme-*, pack-*)
* react-ui → bans every leaf below it (react-forms*, theme-*, pack-*)
* react-forms → bans theme-* and pack-* only (CAN dep react-ui)
* react-ui-theme-* → bans react-forms* and pack-* (themes are siblings of forms;
* packs sit downstream of themes)
* react-ui-pack-* → bans nothing in the React stack (packs are bottom-most)
*/
const TIER_BANNED: Record<string, ReadonlyArray<BannedPattern>> = {
"@suaveplan/react-i18n": BANNED_PATTERNS,
"@suaveplan/react-ui": [
PATTERN_REACT_UI_THEME,
PATTERN_REACT_UI_PACK,
PATTERN_REACT_FORMS,
],
"@suaveplan/react-forms": [PATTERN_REACT_UI_THEME, PATTERN_REACT_UI_PACK],
};
/**
* Compute the banned-pattern subset for a given package name. Returns
* an empty array for packages outside the React stack so the gate
* no-ops on them.
*/
export function bannedPatternsFor(
packageName: string | undefined
): ReadonlyArray<BannedPattern> {
if (typeof packageName !== "string") return [];
if (Object.hasOwn(TIER_BANNED, packageName)) {
return TIER_BANNED[packageName] as ReadonlyArray<BannedPattern>;
}
if (packageName.startsWith("@suaveplan/react-ui-theme-")) {
return [PATTERN_REACT_FORMS, PATTERN_REACT_UI_PACK];
}
if (packageName.startsWith("@suaveplan/react-ui-pack-")) {
return [];
}
return [];
}
/**
* The package.json buckets we inspect. Listed explicitly (rather than
* walking every key) so the rule is auditable and so unrelated buckets
* (e.g. `optionalDependencies`, `bundleDependencies`) cannot smuggle a
* banned dep through.
*/
export const INSPECTED_BUCKETS = [
"dependencies",
"peerDependencies",
"devDependencies",
] as const;
export type InspectedBucket = (typeof INSPECTED_BUCKETS)[number];
export interface Violation {
readonly bucket: InspectedBucket;
readonly depName: string;
readonly version: string;
readonly matchedPattern: string;
}
export interface CheckOptions {
readonly packageJsonPath: string;
readonly readFileFn?: typeof readFile;
}
/**
* Read the supplied `package.json` and return every banned dep
* declared in `dependencies`, `peerDependencies`, or
* `devDependencies`. Buckets that are absent or malformed contribute
* zero violations (a malformed bucket is the consumer's bug — out of
* scope for this gate).
*/
export async function findViolations(
options: CheckOptions
): Promise<Violation[]> {
const readImpl = options.readFileFn ?? readFile;
let raw: string;
try {
raw = await readImpl(options.packageJsonPath, "utf8");
} catch (cause) {
throw new Error(
`check-no-backwards-deps: cannot read ${options.packageJsonPath}: ${(cause as Error).message}`
);
}
let manifest: Record<string, unknown>;
try {
manifest = JSON.parse(raw) as Record<string, unknown>;
} catch (cause) {
throw new Error(
`check-no-backwards-deps: cannot parse ${options.packageJsonPath}: ${(cause as Error).message}`
);
}
const packageName =
typeof manifest.name === "string" ? manifest.name : undefined;
const banned = bannedPatternsFor(packageName);
if (banned.length === 0) return [];
const violations: Violation[] = [];
for (const bucket of INSPECTED_BUCKETS) {
const value = manifest[bucket];
if (value === undefined || value === null) continue;
if (typeof value !== "object" || Array.isArray(value)) continue;
const deps = value as Record<string, unknown>;
for (const [depName, depVersion] of Object.entries(deps)) {
for (const pattern of banned) {
if (!pattern.match(depName)) continue;
violations.push({
bucket,
depName,
version:
typeof depVersion === "string"
? depVersion
: JSON.stringify(depVersion),
matchedPattern: pattern.label,
});
}
}
}
return violations;
}
/**
* Render a human-readable diagnostic block. The empty-violations form
* is the single line printed on stdout for the success case.
*/
export function formatViolations(violations: readonly Violation[]): string {
if (violations.length === 0) {
return "Package declares no React-stack backward dependencies.";
}
const lines: string[] = [
"❌ React-stack backward dependencies detected.",
"",
"Rule: openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md §11.2.",
"Fix: remove the dep — each tier in the stack must only depend on tiers",
" strictly above it (react-i18n → react-ui → react-forms / theme / pack).",
"",
];
const grouped = new Map<InspectedBucket, Violation[]>();
for (const v of violations) {
const list = grouped.get(v.bucket) ?? [];
list.push(v);
grouped.set(v.bucket, list);
}
for (const bucket of INSPECTED_BUCKETS) {
const items = grouped.get(bucket);
if (!items || items.length === 0) continue;
lines.push(
` ${bucket} (${items.length} violation${items.length === 1 ? "" : "s"})`
);
for (const v of items) {
lines.push(
` ${v.depName}@${v.version} (matched: ${v.matchedPattern})`
);
}
lines.push("");
}
lines.push(
`Total: ${violations.length} violation${violations.length === 1 ? "" : "s"}.`
);
return lines.join("\n");
}
/**
* Default canonical path: react-i18n's `package.json` inside the repo.
* The CLI accepts overrides for both the repo root and the package.json
* path so test fixtures can drive the script without mutating the real
* file.
*/
export function defaultPackageJsonPath(repoRoot: string): string {
return join(
resolve(repoRoot),
"packages",
"react",
"react-i18n",
"package.json"
);
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
let repoRoot = process.cwd();
let packageJsonPath: string | undefined;
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--root" && i + 1 < argv.length) {
repoRoot = resolve(argv[i + 1] ?? ".");
i += 1;
continue;
}
if (argv[i] === "--package-json" && i + 1 < argv.length) {
packageJsonPath = resolve(argv[i + 1] ?? ".");
i += 1;
}
}
const target = packageJsonPath ?? defaultPackageJsonPath(repoRoot);
let violations: readonly Violation[];
try {
violations = await findViolations({ packageJsonPath: target });
} catch (cause) {
process.stderr.write(`${(cause as Error).message}\n`);
process.exit(2);
}
const message = formatViolations(violations);
if (violations.length === 0) {
process.stdout.write(`${message}\n`);
process.exit(0);
}
process.stderr.write(`${message}\n`);
process.exit(1);
}
if (import.meta.main) {
await main();
}

View File

@@ -0,0 +1,469 @@
#!/usr/bin/env bun
/**
* check-no-hardcoded-strings.ts
*
* CI gate enforcing that every user-perceivable string in the React-tier
* packages flows through a translation call (`t(...)`, `<Trans>`,
* `i18n.t(...)`, `tDynamic(...)`) rather than being hardcoded as a
* literal. The gate walks .tsx / .ts files (skipping tests, stories,
* type files, and barrels) and flags three rule families:
*
* 1. JSX text nodes that are not whitespace-only and not wrapped in
* a translation call.
* 2. JSX attribute values on the watchlist (`aria-label`,
* `aria-description`, `aria-roledescription`, `aria-valuetext`,
* `aria-keyshortcuts`, `placeholder`, `title`, `alt`) that are
* string literals.
* 3. Object-literal properties whose key is on the watchlist
* (`label`, `message`, `description`, `tooltip`, `placeholder`,
* `errorMessage`, `helperText`, `validationMessage`) and whose
* value is a string literal.
*
* Watchlist is extensible via a per-package `lint.config.ts` (loaded
* from the package root if present); v1 ships with the defaults
* documented above.
*
* Usage:
* bun scripts/lint/check-no-hardcoded-strings.ts <root> [<root>...]
*
* Exit codes:
* 0 — clean
* 1 — at least one violation
* 2 — internal failure
*/
import { readdir, readFile, stat } from "node:fs/promises";
import { extname, join, relative, resolve } from "node:path";
import * as ts from "typescript";
export interface Diagnostic {
readonly file: string;
readonly line: number;
readonly column: number;
readonly rule: string;
readonly snippet: string;
}
export const ARIA_ATTRIBUTE_WATCHLIST = new Set<string>([
"aria-label",
"aria-description",
"aria-roledescription",
"aria-valuetext",
"aria-keyshortcuts",
"placeholder",
"title",
"alt",
]);
/**
* `description` is intentionally NOT in the default watchlist because
* OTel `createCounter` / `createHistogram` calls accept a `description`
* option whose value is metadata, not user-visible UI copy. Form
* libraries that DO use `description` as user-facing copy (the
* `react-forms` field-config shape) layer their own gate atop this
* file and add `description` to the watchlist there.
*/
export const OBJECT_PROPERTY_WATCHLIST = new Set<string>([
"label",
"message",
"tooltip",
"placeholder",
"errorMessage",
"helperText",
"validationMessage",
]);
const TRANSLATION_CALL_NAMES = new Set<string>(["t", "tDynamic"]);
const TRANSLATION_MEMBER_NAMES = new Set<string>(["t", "tDynamic"]);
const TRANS_COMPONENT_NAMES = new Set<string>(["Trans"]);
const SKIP_SUFFIXES = [
".test.ts",
".test.tsx",
".test-fixtures.ts",
".test-fixtures.tsx",
".spec.ts",
".stories.ts",
".stories.tsx",
".bench.ts",
".d.ts",
];
const SKIP_FILENAMES = new Set<string>(["augmentation.ts", "schema.ts"]);
interface ScanOptions {
readonly root: string;
}
function isSkippableFile(file: string): boolean {
const base = file.split("/").pop() ?? "";
if (SKIP_FILENAMES.has(base)) return true;
for (const suffix of SKIP_SUFFIXES) {
if (file.endsWith(suffix)) return true;
}
if (file.includes("/spike/")) return true;
if (file.includes("/__benchmarks__/")) return true;
return false;
}
async function listSourceFiles(root: string): Promise<string[]> {
const out: string[] = [];
async function walk(dir: string): Promise<void> {
let entries: {
name: string;
isDirectory(): boolean;
isFile(): boolean;
}[];
try {
entries = (await readdir(dir, { withFileTypes: true })) as Array<{
name: string;
isDirectory(): boolean;
isFile(): boolean;
}>;
} catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (
entry.name === "node_modules" ||
entry.name === "dist" ||
entry.name === ".turbo" ||
entry.name === "coverage"
) {
continue;
}
await walk(full);
continue;
}
if (!entry.isFile()) continue;
const ext = extname(entry.name);
if (ext !== ".ts" && ext !== ".tsx") continue;
if (isSkippableFile(full)) continue;
out.push(full);
}
}
const s = await stat(root).catch(() => null);
if (!s) return [];
if (s.isDirectory()) {
await walk(root);
} else {
out.push(root);
}
return out;
}
function isTranslationCall(node: ts.Node): boolean {
if (ts.isCallExpression(node)) {
const callee = node.expression;
if (
ts.isIdentifier(callee) &&
TRANSLATION_CALL_NAMES.has(callee.text)
) {
return true;
}
if (
ts.isPropertyAccessExpression(callee) &&
ts.isIdentifier(callee.name) &&
TRANSLATION_MEMBER_NAMES.has(callee.name.text)
) {
return true;
}
}
return false;
}
function isTransComponent(node: ts.Node): boolean {
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
const open = ts.isJsxElement(node) ? node.openingElement : node;
const tagName = open.tagName;
if (
ts.isIdentifier(tagName) &&
TRANS_COMPONENT_NAMES.has(tagName.text)
) {
return true;
}
}
return false;
}
function findTranslationOrTrans(expr: ts.Node): boolean {
let found = false;
function visit(n: ts.Node): void {
if (found) return;
if (isTranslationCall(n) || isTransComponent(n)) {
found = true;
return;
}
n.forEachChild(visit);
}
visit(expr);
return found;
}
function lineCol(
sourceFile: ts.SourceFile,
pos: number
): { line: number; column: number } {
const lc = sourceFile.getLineAndCharacterOfPosition(pos);
return { line: lc.line + 1, column: lc.character + 1 };
}
function scanSource(filePath: string, source: string): Diagnostic[] {
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
);
const diagnostics: Diagnostic[] = [];
function visit(node: ts.Node): void {
// Rule 1: bare JSX text node
if (ts.isJsxText(node)) {
const text = node.text;
if (text.trim().length > 0) {
const { line, column } = lineCol(
sourceFile,
node.getStart(sourceFile)
);
diagnostics.push({
file: filePath,
line,
column,
rule: "jsx-text",
snippet: text.trim().slice(0, 120),
});
}
}
// Rule 1b: JSX expression-container holding a string literal
if (ts.isJsxExpression(node)) {
const expr = node.expression;
if (
expr !== undefined &&
(ts.isStringLiteral(expr) ||
ts.isNoSubstitutionTemplateLiteral(expr))
) {
// Only a violation when the expression-container is a CHILD
// of a JsxElement (i.e. {`...`} as text). Attribute values
// are handled by Rule 2 below.
const parent = node.parent;
if (
parent !== undefined &&
(ts.isJsxElement(parent) || ts.isJsxFragment(parent))
) {
if (expr.text.trim().length > 0) {
const { line, column } = lineCol(
sourceFile,
expr.getStart(sourceFile)
);
diagnostics.push({
file: filePath,
line,
column,
rule: "jsx-text-expression",
snippet: expr.text.slice(0, 120),
});
}
}
} else if (expr !== undefined) {
// Non-literal expression — only flag when none of the
// sub-expressions invoke t() / Trans / etc.
// Skip — non-literal expressions are presumed dynamic
// and handled by other gates.
}
}
// Rule 2: JSX attribute on the watchlist with a string literal
if (ts.isJsxAttribute(node)) {
const name = node.name;
const attrName = ts.isIdentifier(name)
? name.text
: `${name.namespace.text}:${name.name.text}`;
if (
ARIA_ATTRIBUTE_WATCHLIST.has(attrName) &&
node.initializer !== undefined
) {
const init = node.initializer;
if (ts.isStringLiteral(init)) {
const { line, column } = lineCol(
sourceFile,
init.getStart(sourceFile)
);
diagnostics.push({
file: filePath,
line,
column,
rule: `jsx-attr-${attrName}`,
snippet: init.text.slice(0, 120),
});
} else if (
ts.isJsxExpression(init) &&
init.expression !== undefined
) {
const inner = init.expression;
if (
ts.isStringLiteral(inner) ||
ts.isNoSubstitutionTemplateLiteral(inner)
) {
const { line, column } = lineCol(
sourceFile,
inner.getStart(sourceFile)
);
diagnostics.push({
file: filePath,
line,
column,
rule: `jsx-attr-${attrName}`,
snippet: inner.text.slice(0, 120),
});
}
}
}
}
// Rule 3: object-literal property on watchlist with string literal value
if (ts.isPropertyAssignment(node)) {
const propName = ts.isIdentifier(node.name)
? node.name.text
: ts.isStringLiteral(node.name)
? node.name.text
: "";
if (OBJECT_PROPERTY_WATCHLIST.has(propName)) {
const value = node.initializer;
if (
ts.isStringLiteral(value) ||
ts.isNoSubstitutionTemplateLiteral(value)
) {
const { line, column } = lineCol(
sourceFile,
value.getStart(sourceFile)
);
diagnostics.push({
file: filePath,
line,
column,
rule: `object-${propName}`,
snippet: value.text.slice(0, 120),
});
}
}
}
node.forEachChild(visit);
}
visit(sourceFile);
// Pass 2: scrub diagnostics that turn out to be inside a t()/Trans
// expression. We do this by walking JsxExpression containers a
// second time and discarding any rule-1 hit whose enclosing
// expression is a translation call.
const filtered: Diagnostic[] = [];
for (const d of diagnostics) {
if (d.rule === "jsx-text-expression") {
// Re-find the JsxExpression at that position and inspect
const node = findNodeAt(sourceFile, d.line, d.column);
if (node !== null && findTranslationOrTrans(node)) {
continue;
}
}
filtered.push(d);
}
return filtered;
}
function findNodeAt(
sourceFile: ts.SourceFile,
line: number,
column: number
): ts.Node | null {
const target = sourceFile.getPositionOfLineAndCharacter(
line - 1,
column - 1
);
let result: ts.Node | null = null;
function visit(node: ts.Node): void {
if (result !== null) return;
if (node.getStart(sourceFile) <= target && target < node.getEnd()) {
result = node;
node.forEachChild(visit);
}
}
visit(sourceFile);
return result;
}
export async function checkRoots(
options: ReadonlyArray<ScanOptions>
): Promise<Diagnostic[]> {
const all: Diagnostic[] = [];
for (const opt of options) {
const root = resolve(opt.root);
const files = await listSourceFiles(root);
for (const file of files) {
const text = await readFile(file, "utf8");
const diagnostics = scanSource(file, text);
for (const d of diagnostics) {
all.push(d);
}
}
}
return all;
}
function formatDiagnostics(
diagnostics: ReadonlyArray<Diagnostic>,
cwd: string
): string {
if (diagnostics.length === 0) {
return "check-no-hardcoded-strings: clean.";
}
const lines: string[] = [
`check-no-hardcoded-strings: ${diagnostics.length} violation(s).`,
];
for (const d of diagnostics) {
lines.push(
` ${relative(cwd, d.file)}:${d.line}:${d.column} [${d.rule}] "${d.snippet}"`
);
}
lines.push("");
lines.push(
" Hint: wrap user-facing strings in t('react-ui.<key>') or use <Trans>."
);
return lines.join("\n");
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0) {
process.stderr.write(
"Usage: check-no-hardcoded-strings.ts <root> [<root>...]\n"
);
process.exit(2);
}
const cwd = process.cwd();
const diagnostics = await checkRoots(
argv.map((root) => ({ root: resolve(cwd, root) }))
);
const message = formatDiagnostics(diagnostics, cwd);
if (diagnostics.length === 0) {
process.stdout.write(`${message}\n`);
process.exit(0);
}
process.stderr.write(`${message}\n`);
process.exit(1);
}
if (import.meta.main) {
await main().catch((err: unknown) => {
process.stderr.write(`internal error: ${(err as Error).message}\n`);
process.exit(2);
});
}

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bun
/**
* Enforce §19 of openspec/AGENTS.md: every proposal for a package
* on the configured primitive-package list MUST identify its concrete
* consumer, its host-bridge (if needed), and the e2e/ test path.
*
* The primitive list lives in `scripts/lint/primitives.json`. Each
* entry is the package short-name (e.g. `api-sandbox-vm`) plus an
* optional `requiresBridge` flag and a `bridgePackagePattern`.
*
* For each `openspec/changes/<primitive>/proposal.md` whose package
* name matches the list, scan the proposal text for the three
* sentinel sections:
* - `## Concrete consumer` containing at least one backtick-fenced
* `@suaveplan/...` reference.
* - `## Host bridge` (only if requiresBridge=true) citing the
* bridge package by name matching bridgePackagePattern.
* - `## End-to-end test` citing an `e2e/foundation-stack-stage-N/`
* path.
*
* Missing any sentinel for a listed primitive is a violation.
*/
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { Glob } from "bun";
const REPO_ROOT = resolve(import.meta.dir, "..", "..");
interface PrimitiveSpec {
readonly name: string;
readonly requiresBridge?: boolean;
readonly bridgePackagePattern?: string;
}
async function loadPrimitives(): Promise<PrimitiveSpec[]> {
const abs = resolve(REPO_ROOT, "scripts/lint/primitives.json");
try {
const raw = await readFile(abs, "utf8");
return JSON.parse(raw) as PrimitiveSpec[];
} catch {
return [];
}
}
async function checkProposal(
rel: string,
spec: PrimitiveSpec
): Promise<string[]> {
const abs = resolve(REPO_ROOT, rel);
const source = await readFile(abs, "utf8");
const violations: string[] = [];
if (!source.includes("## Concrete consumer")) {
violations.push(
`${rel} §19: proposal for primitive '${spec.name}' lacks a '## Concrete consumer' section`
);
}
if (spec.requiresBridge === true) {
if (!source.includes("## Host bridge")) {
violations.push(
`${rel} §19: proposal for primitive '${spec.name}' lacks a '## Host bridge' section`
);
} else if (
spec.bridgePackagePattern !== undefined &&
!new RegExp(spec.bridgePackagePattern).test(source)
) {
violations.push(
`${rel} §19: '## Host bridge' section does not cite a bridge package matching pattern '${spec.bridgePackagePattern}'`
);
}
}
if (!source.includes("## End-to-end test")) {
violations.push(
`${rel} §19: proposal for primitive '${spec.name}' lacks a '## End-to-end test' section`
);
} else if (!/e2e\/foundation-stack-stage-\d+/.test(source) && !/e2e\/[a-z0-9-]+/.test(source)) {
violations.push(
`${rel} §19: '## End-to-end test' section does not cite an e2e/<umbrella>-stage-N/ path`
);
}
return violations;
}
async function main(): Promise<void> {
const primitives = await loadPrimitives();
if (primitives.length === 0) {
console.log("OK no primitives configured in scripts/lint/primitives.json — nothing to check.");
return;
}
const byName = new Map(primitives.map((p) => [p.name, p]));
const violations: string[] = [];
const g = new Glob("openspec/changes/*/proposal.md");
for await (const f of g.scan({ cwd: REPO_ROOT })) {
const changeId = f.split("/")[2] ?? "?";
const spec = byName.get(changeId);
if (spec === undefined) continue;
violations.push(...(await checkProposal(f, spec)));
}
if (violations.length === 0) {
console.log(
`OK ${primitives.length} primitive package(s) on the §19 list; every proposal carries the three required sections.`
);
return;
}
console.error(`FAIL ${violations.length} §19 violation(s):`);
for (const v of violations) console.error(` ${v}`);
console.error(
"\n§19: every primitive package proposal MUST include '## Concrete consumer', '## Host bridge' (if required), and '## End-to-end test' sections."
);
process.exit(1);
}
await main();

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bun
/**
* Enforce §18 of openspec/AGENTS.md: every Stage block in an umbrella
* tasks.md MUST list a `N.0 e2e/<umbrella>-stage-N/` closure-smoke row
* alongside the stage's per-package rows.
*
* Grandfather clause: umbrellas whose archive folder predates
* RULE_EFFECTIVE_DATE are exempt. `api-plugin-foundation` is also
* grandfathered (opened pre-§18).
*/
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { Glob } from "bun";
const REPO_ROOT = resolve(import.meta.dir, "..", "..");
const RULE_EFFECTIVE_DATE = "2026-05-27";
interface StageBlock {
readonly umbrella: string;
readonly file: string;
readonly stageId: number;
readonly stageHeadingLine: number;
readonly hasSmokeRow: boolean;
}
const STAGE_HEADING_RE = /^### Stage (\d+)\b/;
const PACKAGE_ROW_RE = /^- \[( |x)\]\s+(\d+)\.(\d+)\s+/i;
async function parseUmbrella(rel: string): Promise<StageBlock[]> {
const abs = resolve(REPO_ROOT, rel);
const source = await readFile(abs, "utf8");
const lines = source.split("\n");
const umbrellaName = rel.split("/")[2] ?? "?";
const blocks: StageBlock[] = [];
let inSection1 = false;
let currentStage: number | undefined;
let currentStageLine = 0;
let currentHasSmoke = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
if (/^## 1\b/.test(line)) {
inSection1 = true;
continue;
}
if (inSection1 && /^## /.test(line)) {
if (currentStage !== undefined) {
blocks.push({
umbrella: umbrellaName,
file: rel,
stageId: currentStage,
stageHeadingLine: currentStageLine,
hasSmokeRow: currentHasSmoke,
});
currentStage = undefined;
}
inSection1 = false;
continue;
}
if (!inSection1) continue;
const sh = STAGE_HEADING_RE.exec(line);
if (sh !== null) {
if (currentStage !== undefined) {
blocks.push({
umbrella: umbrellaName,
file: rel,
stageId: currentStage,
stageHeadingLine: currentStageLine,
hasSmokeRow: currentHasSmoke,
});
}
currentStage = Number.parseInt(sh[1] ?? "0", 10);
currentStageLine = i + 1;
currentHasSmoke = false;
continue;
}
const pr = PACKAGE_ROW_RE.exec(line);
if (pr !== null) {
const [, , majorStr, minorStr] = pr;
if (minorStr === "0" && majorStr !== undefined) {
if (line.includes("e2e/") && line.includes("stage-")) {
currentHasSmoke = true;
}
}
}
}
if (currentStage !== undefined) {
blocks.push({
umbrella: umbrellaName,
file: rel,
stageId: currentStage,
stageHeadingLine: currentStageLine,
hasSmokeRow: currentHasSmoke,
});
}
return blocks;
}
async function isGrandfathered(umbrella: string): Promise<boolean> {
const g = new Glob(`openspec/archive/*-${umbrella}/proposal.md`);
for await (const f of g.scan({ cwd: REPO_ROOT })) {
const m = f.match(/openspec\/archive\/(\d{4}-\d{2}-\d{2})-/);
if (m !== null && m[1] !== undefined) {
if (m[1] < RULE_EFFECTIVE_DATE) return true;
}
}
if (umbrella === "api-plugin-foundation") return true;
return false;
}
async function main(): Promise<void> {
const violations: string[] = [];
const g = new Glob("openspec/changes/*/tasks.md");
for await (const f of g.scan({ cwd: REPO_ROOT })) {
const umbrella = f.split("/")[2] ?? "?";
if (await isGrandfathered(umbrella)) continue;
const blocks = await parseUmbrella(f);
for (const b of blocks) {
if (!b.hasSmokeRow) {
violations.push(
`${b.file}:${b.stageHeadingLine} Stage ${b.stageId} lacks an N.0 e2e/${b.umbrella}-stage-${b.stageId}/ closure-smoke row`
);
}
}
}
if (violations.length === 0) {
console.log("OK every Stage-N block lists its closure-smoke row.");
return;
}
console.error(
`FAIL ${violations.length} stage block(s) missing closure-smoke row:`
);
for (const v of violations) console.error(` ${v}`);
console.error(
"\n§18: every Stage N block MUST include - [ ] N.0 e2e/<umbrella>-stage-N/ alongside its package rows."
);
process.exit(1);
}
await main();

View File

@@ -0,0 +1,222 @@
/**
* Unit test for check-test-runner-imports.ts.
*
* Run with: `bun test scripts/lint/check-test-runner-imports.test.ts`
*
* Uses Bun's native test runner because this file is repo tooling, not a
* workspace package — the canonical-runner-wrapper rule (openspec/AGENTS.md
* §7) governs `packages/<plugin>/<pkg>/src/**\/*.test.ts`, not `scripts/`.
* Importing the rule-enforcer's own test from `@suaveplan/testing/runner`
* would create a circular dependency between the gate and the wrapper it
* polices.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
EXEMPT_PACKAGES,
findViolations,
formatViolations,
} from "./check-test-runner-imports.ts";
interface Fixture {
readonly root: string;
cleanup(): Promise<void>;
}
async function makeFixture(): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), "check-runner-imports-"));
return {
root,
async cleanup() {
await rm(root, { recursive: true, force: true });
},
};
}
async function writePackage(args: {
root: string;
plugin: string;
pkg: string;
name: string;
files: Record<string, string>;
}): Promise<void> {
const pkgDir = join(args.root, "packages", args.plugin, args.pkg);
await mkdir(pkgDir, { recursive: true });
await writeFile(
join(pkgDir, "package.json"),
JSON.stringify({ name: args.name })
);
for (const [relPath, content] of Object.entries(args.files)) {
const full = join(pkgDir, relPath);
await mkdir(join(full, ".."), { recursive: true });
await writeFile(full, content);
}
}
describe("check-test-runner-imports", () => {
let fixture: Fixture;
beforeEach(async () => {
fixture = await makeFixture();
});
afterEach(async () => {
await fixture.cleanup();
});
it("reports zero violations when every package uses @suaveplan/testing/runner", {
timeout: 5000,
}, async () => {
await writePackage({
root: fixture.root,
plugin: "demo",
pkg: "good-pkg",
name: "@suaveplan/good-pkg",
files: {
"src/feature/feature.test.ts":
'import { describe, it, expect } from "@suaveplan/testing/runner";\n' +
'describe("ok", () => { it("passes", () => { expect(1).toBe(1); }); });\n',
},
});
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toEqual([]);
expect(formatViolations(violations)).toContain(
"All non-exempt packages use the canonical"
);
});
it("flags a direct vitest import in a non-exempt package", {
timeout: 5000,
}, async () => {
await writePackage({
root: fixture.root,
plugin: "demo",
pkg: "bad-pkg",
name: "@suaveplan/bad-pkg",
files: {
"src/feature/feature.test.ts":
'import { describe, it, expect } from "vitest";\n',
},
});
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toHaveLength(1);
expect(violations[0]?.packageName).toBe("@suaveplan/bad-pkg");
expect(violations[0]?.file).toBe(
"packages/demo/bad-pkg/src/feature/feature.test.ts"
);
expect(violations[0]?.line).toBe(1);
expect(violations[0]?.source).toContain('from "vitest"');
const message = formatViolations(violations);
expect(message).toContain("❌ Direct vitest / bun:test imports");
expect(message).toContain("@suaveplan/bad-pkg");
expect(message).toContain("openspec/AGENTS.md §7");
});
it("flags a direct bun:test import in a non-exempt package", {
timeout: 5000,
}, async () => {
await writePackage({
root: fixture.root,
plugin: "demo",
pkg: "bad-bun",
name: "@suaveplan/bad-bun",
files: {
"src/x.test.ts": "import { describe } from 'bun:test';\n",
},
});
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toHaveLength(1);
expect(violations[0]?.source).toContain("'bun:test'");
});
it("skips every package in the exempt allowlist", {
timeout: 5000,
}, async () => {
for (const exemptName of EXEMPT_PACKAGES) {
const slug = exemptName.replace("@suaveplan/", "");
await writePackage({
root: fixture.root,
plugin: "dev",
pkg: slug,
name: exemptName,
files: {
"src/x.test.ts":
'import { describe, it, expect } from "vitest";\n' +
"import { describe as bunDescribe } from 'bun:test';\n",
},
});
}
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toEqual([]);
});
it("ignores packages without a src/ directory", {
timeout: 5000,
}, async () => {
await writePackage({
root: fixture.root,
plugin: "demo",
pkg: "no-src",
name: "@suaveplan/no-src",
files: {
"README.md": "# no src here\n",
},
});
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toEqual([]);
});
it("does not flag re-exports from a barrel (only `import` statements)", {
timeout: 5000,
}, async () => {
await writePackage({
root: fixture.root,
plugin: "demo",
pkg: "reexport",
name: "@suaveplan/reexport",
files: {
"src/x.ts": 'export { describe } from "vitest";\n',
},
});
const violations = await findViolations({ repoRoot: fixture.root });
// The rule scope is `import` statements; re-exports from vitest in
// non-test code are caught by other rules (and are nonsensical for
// a non-exempt package — they would mean shipping vitest at
// runtime). The check stays import-statement-scoped to keep the
// signal high.
expect(violations).toEqual([]);
});
it("ignores packages whose package.json is missing or unparsable", {
timeout: 5000,
}, async () => {
const pkgDir = join(fixture.root, "packages", "demo", "no-manifest");
await mkdir(join(pkgDir, "src"), { recursive: true });
await writeFile(
join(pkgDir, "src", "x.test.ts"),
'import { describe } from "vitest";\n'
);
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toEqual([]);
});
it("scans .tsx as well as .ts", { timeout: 5000 }, async () => {
await writePackage({
root: fixture.root,
plugin: "demo",
pkg: "tsx-pkg",
name: "@suaveplan/tsx-pkg",
files: {
"src/x.test.tsx": 'import { describe } from "vitest";\n',
},
});
const violations = await findViolations({ repoRoot: fixture.root });
expect(violations).toHaveLength(1);
expect(violations[0]?.file).toEndWith(".tsx");
});
});

View File

@@ -0,0 +1,264 @@
#!/usr/bin/env bun
/**
* check-test-runner-imports.ts
*
* CI gate enforcing the canonical test-runner-import rule defined in:
* - openspec/specs/monorepo-structure/spec.md (Requirement: "Test files
* import test-runner primitives via the canonical wrapper")
* - openspec/AGENTS.md §7 "Test runner imports — mandatory"
* - CLAUDE.md Critical Rules
*
* For every package under packages/<plugin>/<pkg>/ whose `package.json#name`
* is NOT in the testing-infra exempt allowlist, this script scans
* src/**\/*.{ts,tsx} for direct imports from "vitest" or "bun:test" and
* exits non-zero with a per-file error list when any are found.
*
* Exempt allowlist (the only packages permitted to import vitest / bun:test
* directly — they author or extend the @suaveplan/testing/runner wrapper):
*
* - @suaveplan/testing
* - @suaveplan/testing-dom
* - @suaveplan/testing-e2e
* - @suaveplan/testing-integration
* - @suaveplan/testing-integration-step-ca
*
* Adding a package to the allowlist requires its own OpenSpec change with
* justification — extending fixtures or being a heavy @suaveplan/testing
* consumer does NOT qualify.
*
* Usage:
* bun scripts/lint/check-test-runner-imports.ts [--root <repo-root>]
*
* Exit codes:
* 0 — all non-exempt packages clean
* 1 — at least one violation found (details printed to stderr)
* 2 — internal error (cannot read repo, unparsable package.json, etc.)
*/
import { readdir, readFile, stat } from "node:fs/promises";
import { join, relative, resolve } from "node:path";
export const EXEMPT_PACKAGES: ReadonlySet<string> = new Set([
"@suaveplan/testing",
"@suaveplan/testing-dom",
"@suaveplan/testing-e2e",
"@suaveplan/testing-integration",
"@suaveplan/testing-integration-step-ca",
]);
const FORBIDDEN_IMPORT_REGEX =
/^[^/]*\bimport\b[^;]*?\bfrom\s+(['"])(vitest|bun:test)\1/m;
const FORBIDDEN_LINE_REGEX = /\bfrom\s+(['"])(vitest|bun:test)\1/;
export interface Violation {
readonly packageName: string;
readonly file: string;
readonly line: number;
readonly source: string;
}
export interface CheckOptions {
readonly repoRoot: string;
readonly readDir?: typeof readdir;
readonly readFileFn?: typeof readFile;
readonly statFn?: typeof stat;
}
export async function findViolations(
options: CheckOptions
): Promise<Violation[]> {
const readDirFn = options.readDir ?? readdir;
const readFileImpl = options.readFileFn ?? readFile;
const statImpl = options.statFn ?? stat;
const violations: Violation[] = [];
for (const root of [
"packages",
// , "wip"
]) {
const packagesDir = join(options.repoRoot, root);
let categoryDirs: Awaited<ReturnType<typeof readDirFn>>;
try {
categoryDirs = await readDirFn(packagesDir, {
withFileTypes: true,
});
} catch {
continue;
}
for (const categoryDirent of categoryDirs) {
if (!categoryDirent.isDirectory()) continue;
const categoryPath = join(packagesDir, categoryDirent.name);
const pluginDirs = await readDirFn(categoryPath, {
withFileTypes: true,
});
for (const pluginDirent of pluginDirs) {
if (!pluginDirent.isDirectory()) continue;
const pluginPath = join(categoryPath, pluginDirent.name);
const pkgDirs = await readDirFn(pluginPath, {
withFileTypes: true,
});
for (const pkgDirent of pkgDirs) {
if (!pkgDirent.isDirectory()) continue;
const pkgPath = join(pluginPath, pkgDirent.name);
const manifestPath = join(pkgPath, "package.json");
let manifestRaw: string;
try {
manifestRaw = await readFileImpl(manifestPath, "utf8");
} catch {
continue;
}
let manifest: { name?: string };
try {
manifest = JSON.parse(manifestRaw) as {
name?: string;
};
} catch (cause) {
throw new Error(
`Cannot parse ${manifestPath}: ${(cause as Error).message}`
);
}
const name = manifest.name;
if (typeof name !== "string" || name.length === 0) continue;
if (EXEMPT_PACKAGES.has(name)) continue;
const srcDir = join(pkgPath, "src");
try {
await statImpl(srcDir);
} catch {
continue;
}
await walkAndScan({
dir: srcDir,
packageName: name,
repoRoot: options.repoRoot,
violations,
readDirFn,
readFileImpl,
});
}
}
}
}
return violations;
}
interface WalkArgs {
readonly dir: string;
readonly packageName: string;
readonly repoRoot: string;
readonly violations: Violation[];
readonly readDirFn: typeof readdir;
readonly readFileImpl: typeof readFile;
}
async function walkAndScan(args: WalkArgs): Promise<void> {
const entries = await args.readDirFn(args.dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(args.dir, entry.name);
if (entry.isDirectory()) {
await walkAndScan({ ...args, dir: fullPath });
continue;
}
if (!entry.isFile()) continue;
if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".tsx")) {
continue;
}
const content = await args.readFileImpl(fullPath, "utf8");
if (!FORBIDDEN_IMPORT_REGEX.test(content)) continue;
const lines = content.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? "";
if (/\bimport\b/.test(line) && FORBIDDEN_LINE_REGEX.test(line)) {
// Skip lines that are obviously inside a backtick
// template literal — `create-suaveplan-plugin` and
// similar scaffolders embed test-runner imports as
// template strings for the files they generate.
// Heuristic: the line starts with optional indent
// and a backtick character (template-literal opener).
const trimmed = line.trimStart();
if (trimmed.startsWith("`") || trimmed.startsWith("\"`")) {
continue;
}
args.violations.push({
packageName: args.packageName,
file: relative(args.repoRoot, fullPath),
line: i + 1,
source: line.trim(),
});
}
}
}
}
export function formatViolations(violations: readonly Violation[]): string {
if (violations.length === 0) {
return "All non-exempt packages use the canonical @suaveplan/testing/runner wrapper.";
}
const grouped = new Map<string, Violation[]>();
for (const v of violations) {
const list = grouped.get(v.packageName) ?? [];
list.push(v);
grouped.set(v.packageName, list);
}
const lines: string[] = [
"❌ Direct vitest / bun:test imports detected in non-exempt packages.",
"",
"Rule: openspec/AGENTS.md §7 'Test runner imports — mandatory'.",
'Fix: rewrite each line to `from "@suaveplan/testing/runner"`.',
"",
];
for (const [pkg, items] of [...grouped].sort((a, b) =>
a[0].localeCompare(b[0])
)) {
lines.push(
` ${pkg} (${items.length} violation${items.length === 1 ? "" : "s"})`
);
for (const v of items) {
lines.push(` ${v.file}:${v.line} ${v.source}`);
}
lines.push("");
}
lines.push(
`Total: ${violations.length} violation${violations.length === 1 ? "" : "s"} across ${grouped.size} package${grouped.size === 1 ? "" : "s"}.`
);
return lines.join("\n");
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
let repoRoot = process.cwd();
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--root" && i + 1 < argv.length) {
repoRoot = resolve(argv[i + 1] ?? ".");
i += 1;
}
}
let violations: readonly Violation[];
try {
violations = await findViolations({ repoRoot });
} catch (cause) {
process.stderr.write(
`check-test-runner-imports: internal error: ${(cause as Error).message}\n`
);
process.exit(2);
}
const message = formatViolations(violations);
if (violations.length === 0) {
process.stdout.write(`${message}\n`);
process.exit(0);
}
process.stderr.write(`${message}\n`);
process.exit(1);
}
if (import.meta.main) {
await main();
}

View File

@@ -0,0 +1,249 @@
#!/usr/bin/env bun
/**
* Enforce §20 of `openspec/AGENTS.md`: every requirement in every spec
* delta MUST carry a `**Verified by:**` line. At archive time, the
* cited file path MUST exist and the cited it("...") title MUST be
* present.
*
* Modes
* -----
* - `--mode=author` (default): runs on changes in `openspec/changes/`.
* Verified-by lines must be syntactically valid; cited files need
* not exist yet.
* - `--mode=archive`: runs on changes in `openspec/archive/`. Cited
* files MUST exist and contain the cited title.
*/
import { readFile, stat } from "node:fs/promises";
import { resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { Glob } from "bun";
const REPO_ROOT = resolve(import.meta.dir, "..", "..");
const RULE_EFFECTIVE_DATE = "2026-05-27";
/**
* Change folders whose first commit predates RULE_EFFECTIVE_DATE are
* grandfathered (§20 applies to new authoring only). Cached per
* change-id so we run git log at most once per change.
*/
const grandfatherCache = new Map<string, boolean>();
function changeIdOf(specRel: string): string | undefined {
// openspec/(changes|archive)/<change-id>/specs/...
const parts = specRel.split("/");
if (parts.length < 3) return undefined;
return parts[2];
}
function isGrandfathered(specRel: string): boolean {
const changeId = changeIdOf(specRel);
if (changeId === undefined) return false;
const cached = grandfatherCache.get(changeId);
if (cached !== undefined) return cached;
const root = specRel.startsWith("openspec/archive/")
? `openspec/archive/${changeId}`
: `openspec/changes/${changeId}`;
const result = spawnSync(
"git",
[
"-C",
REPO_ROOT,
"log",
"--diff-filter=A",
"--format=%cI",
"--",
root,
],
{ encoding: "utf8" }
);
const out = (result.stdout ?? "").trim().split("\n").filter(Boolean);
if (out.length === 0) {
grandfatherCache.set(changeId, false);
return false;
}
const firstCommitISO = out[out.length - 1];
if (firstCommitISO === undefined) {
grandfatherCache.set(changeId, false);
return false;
}
const firstCommitDate = firstCommitISO.slice(0, 10);
const grandfathered = firstCommitDate < RULE_EFFECTIVE_DATE;
grandfatherCache.set(changeId, grandfathered);
return grandfathered;
}
interface Violation {
readonly file: string;
readonly line: number;
readonly reason: string;
}
const REQ_RE = /^### Requirement:\s+(.+)$/;
const VERIFIED_RE = /^\*\*Verified by:\*\*\s+(.+)$/;
const CITE_RE = /`([^`]+\.test\.tsx?|[^`]+\.spec\.tsx?)::"([^"]+)"`/g;
function modeFromArgv(): "author" | "archive" {
const arg = process.argv.find((a) => a.startsWith("--mode="));
if (arg === undefined) return "author";
const val = arg.slice("--mode=".length);
if (val !== "author" && val !== "archive") {
console.error(`bad --mode value: ${val}`);
process.exit(2);
}
return val;
}
async function fileExists(absPath: string): Promise<boolean> {
try {
await stat(absPath);
return true;
} catch {
return false;
}
}
async function checkSpecFile(
rel: string,
mode: "author" | "archive"
): Promise<Violation[]> {
const abs = resolve(REPO_ROOT, rel);
const source = await readFile(abs, "utf8");
const lines = source.split("\n");
const violations: Violation[] = [];
let inRequirement = false;
let reqStartLine = 0;
let reqTitle = "";
let sawVerified = false;
let verifiedLine = "";
let verifiedLineNo = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
const m = REQ_RE.exec(line);
if (m !== null) {
if (inRequirement && !sawVerified) {
violations.push({
file: rel,
line: reqStartLine,
reason: `Requirement "${reqTitle}" lacks a **Verified by:** line`,
});
}
inRequirement = true;
reqStartLine = i + 1;
reqTitle = m[1] ?? "";
sawVerified = false;
verifiedLine = "";
verifiedLineNo = 0;
continue;
}
if (line.startsWith("### ") || line.startsWith("## ")) {
if (inRequirement && !sawVerified) {
violations.push({
file: rel,
line: reqStartLine,
reason: `Requirement "${reqTitle}" lacks a **Verified by:** line`,
});
}
inRequirement = false;
continue;
}
if (!inRequirement) continue;
const vm = VERIFIED_RE.exec(line);
if (vm !== null) {
sawVerified = true;
verifiedLine = vm[1] ?? "";
verifiedLineNo = i + 1;
const cites = [...verifiedLine.matchAll(CITE_RE)];
if (cites.length === 0) {
violations.push({
file: rel,
line: verifiedLineNo,
reason: `Verified-by line lacks a parseable path::"title" citation (got: ${verifiedLine.slice(0, 80)})`,
});
continue;
}
if (mode === "archive") {
for (const c of cites) {
const path = c[1];
const title = c[2];
if (path === undefined || title === undefined) continue;
const pkgGuess = pkgRootOf(rel);
if (pkgGuess === undefined) continue;
const testAbs = resolve(REPO_ROOT, pkgGuess, path);
if (!(await fileExists(testAbs))) {
violations.push({
file: rel,
line: verifiedLineNo,
reason: `cited test file does not exist: ${pkgGuess}/${path}`,
});
continue;
}
const src = await readFile(testAbs, "utf8");
if (!src.includes(title)) {
violations.push({
file: rel,
line: verifiedLineNo,
reason: `cited title not found in ${path}: "${title}"`,
});
}
}
}
}
}
if (inRequirement && !sawVerified) {
violations.push({
file: rel,
line: reqStartLine,
reason: `Requirement "${reqTitle}" lacks a **Verified by:** line`,
});
}
return violations;
}
function pkgRootOf(specRel: string): string | undefined {
// Capability-name -> package root mapping is ambiguous in a 3-level
// monorepo. Skip strict archive-time existence check unless the
// resolver can be made unambiguous via a `package-root-map.json`.
void specRel;
return undefined;
}
async function main(): Promise<void> {
const mode = modeFromArgv();
const pattern =
mode === "archive"
? "openspec/archive/*/specs/*/spec.md"
: "openspec/changes/*/specs/*/spec.md";
const g = new Glob(pattern);
const files: string[] = [];
for await (const f of g.scan({ cwd: REPO_ROOT })) {
files.push(f);
}
const allViolations: Violation[] = [];
let skipped = 0;
for (const f of files) {
if (isGrandfathered(f)) {
skipped++;
continue;
}
allViolations.push(...(await checkSpecFile(f, mode)));
}
if (allViolations.length === 0) {
console.log(
`OK ${files.length} spec deltas scanned (mode=${mode}, ${skipped} grandfathered); every checked Requirement has a **Verified by:** citation.`
);
return;
}
console.error(
`FAIL ${allViolations.length} Verified-by violation(s) (mode=${mode}):`
);
for (const v of allViolations) {
console.error(` ${v.file}:${v.line} ${v.reason}`);
}
console.error(
"\n§20: every Requirement MUST have **Verified by:** path::title."
);
process.exit(1);
}
await main();

View File

@@ -0,0 +1,110 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import {
banReason,
checkWasmSubpathDeps,
extractSpecifiers,
} from "./check-wasm-subpath-deps.js";
let tmp: string;
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), "wasm-deps-"));
});
afterEach(async () => {
await rm(tmp, { recursive: true, force: true });
});
async function writeFixture(rel: string, content: string): Promise<string> {
const full = join(tmp, rel);
await mkdir(join(full, ".."), { recursive: true });
await writeFile(full, content, "utf8");
return full;
}
describe("check-wasm-subpath-deps", () => {
it("banReason flags node: specifiers", { timeout: 5000 }, () => {
expect(banReason("node:fs")).toMatch(/node:/);
});
it("banReason flags exact bans", { timeout: 5000 }, () => {
expect(banReason("hono")).toMatch(/banned package/);
expect(banReason("@suaveplan/api-host")).toMatch(/banned package/);
});
it("banReason flags prefix bans", { timeout: 5000 }, () => {
expect(banReason("@hono/zod-validator")).toMatch(/banned prefix/);
expect(banReason("@opentelemetry/sdk-metrics")).toMatch(
/banned prefix/
);
expect(banReason("@opentelemetry/sdk-trace-base")).toMatch(
/banned prefix/
);
});
it("banReason passes safe specifiers", { timeout: 5000 }, () => {
expect(banReason("@opentelemetry/api")).toBeNull();
expect(banReason("zod")).toBeNull();
expect(banReason("@suaveplan/error")).toBeNull();
});
it("extractSpecifiers picks up named, default, and type imports", {
timeout: 5000,
}, () => {
const src = `
import x from "a";
import { b } from "b";
import type { C } from "c";
export { d } from "d";
// import "ignored-in-comment";
`;
const out = extractSpecifiers(src);
expect(out).toContain("a");
expect(out).toContain("b");
expect(out).toContain("c");
expect(out).toContain("d");
expect(out).not.toContain("ignored-in-comment");
});
it("rejects banned specifier in wasm subpath", {
timeout: 5000,
}, async () => {
// Build a tiny fixture: entry imports "hono" directly.
const entry = await writeFixture(
"packages/example/wasm/index.ts",
`import { Hono } from "hono";\nexport const app = new Hono();\n`
);
const hits = await checkWasmSubpathDeps({ repoRoot: tmp, entry });
expect(hits.length).toBeGreaterThan(0);
const hononHit = hits.find((h) => h.specifier === "hono");
expect(hononHit).toBeDefined();
});
it("accepts a clean entry with only allowed specifiers", {
timeout: 5000,
}, async () => {
const entry = await writeFixture(
"packages/example/wasm/index.ts",
`import z from "zod";\nimport { foo } from "./foo.js";\n`
);
await writeFixture(
"packages/example/wasm/foo.ts",
"export const foo = 1;\n"
);
const hits = await checkWasmSubpathDeps({ repoRoot: tmp, entry });
expect(hits).toEqual([]);
});
it("detects node:* specifier in entry", { timeout: 5000 }, async () => {
const entry = await writeFixture(
"packages/example/wasm/index.ts",
`import fs from "node:fs";\n`
);
const hits = await checkWasmSubpathDeps({ repoRoot: tmp, entry });
expect(hits.length).toBeGreaterThan(0);
expect(hits[0]?.specifier).toBe("node:fs");
});
});

View File

@@ -0,0 +1,313 @@
#!/usr/bin/env bun
/**
* check-wasm-subpath-deps.ts
*
* CI gate enforcing the wasm-tier dependency constraint declared in
* openspec/changes/api-plugin-example-dual-shape/specs/api-plugin-example/spec.md
* ("The ./wasm subpath SHALL have a constrained dependency graph").
*
* Walks every static import / import-type transitively from
* packages/api-stack/api/api-plugin-example/src/wasm/index.ts,
* following relative paths AND package resolutions, and rejects any
* specifier matching the wasm-tier ban list:
*
* Exact: @suaveplan/api-host, hono
* Prefix: @hono/, @opentelemetry/sdk-metrics,
* @opentelemetry/sdk-trace-, @opentelemetry/sdk-node
* Pattern: node:*
* Transitive: workspace package whose own deps include the above
*
* Exit codes:
* 0 — clean
* 1 — at least one violation found
* 2 — internal error
*/
import { readdir, readFile, stat } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
export interface BannedHit {
readonly file: string;
readonly specifier: string;
readonly reason: string;
}
const EXACT_BANS: ReadonlySet<string> = new Set([
"@suaveplan/api-host",
"hono",
]);
const PREFIX_BANS: readonly string[] = [
"@hono/",
"@opentelemetry/sdk-metrics",
"@opentelemetry/sdk-trace-",
"@opentelemetry/sdk-node",
];
export function banReason(specifier: string): string | null {
if (specifier.startsWith("node:")) return "node:* specifier";
if (EXACT_BANS.has(specifier)) return `banned package: ${specifier}`;
for (const prefix of PREFIX_BANS) {
if (specifier === prefix || specifier.startsWith(prefix)) {
return `banned prefix: ${prefix}`;
}
}
return null;
}
const IMPORT_RE =
/(?:^|\n)\s*(?:import|export)(?:\s+type)?\s+(?:[^'"`;]+\sfrom\s+)?['"]([^'"]+)['"]/g;
export function extractSpecifiers(source: string): string[] {
const stripped = source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/[^\n]*/g, "$1");
const out: string[] = [];
let match: RegExpExecArray | null = IMPORT_RE.exec(stripped);
while (match !== null) {
const spec = match[1];
if (spec !== undefined) out.push(spec);
match = IMPORT_RE.exec(stripped);
}
return out;
}
interface WalkArgs {
readonly repoRoot: string;
readonly visitedFiles: Set<string>;
readonly visitedPackages: Set<string>;
readonly hits: BannedHit[];
}
async function resolveRelative(
fromFile: string,
spec: string
): Promise<string | null> {
const base = resolve(dirname(fromFile), spec);
const candidates = [
base,
`${base}.ts`,
`${base}.tsx`,
`${base.replace(/\.js$/, "")}.ts`,
`${base.replace(/\.js$/, "")}.tsx`,
join(base, "index.ts"),
join(base, "index.tsx"),
];
for (const c of candidates) {
try {
const s = await stat(c);
if (s.isFile()) return c;
} catch {
// continue
}
}
return null;
}
async function findPackageDir(
wsRoot: string,
pkgName: string
): Promise<string | null> {
let cats: Awaited<ReturnType<typeof readdir>>;
try {
cats = await readdir(wsRoot, { withFileTypes: true });
} catch {
return null;
}
for (const cat of cats) {
if (!cat.isDirectory()) continue;
const catDir = join(wsRoot, cat.name);
let plugins: typeof cats;
try {
plugins = await readdir(catDir, { withFileTypes: true });
} catch {
continue;
}
for (const plugin of plugins) {
if (!plugin.isDirectory()) continue;
const pluginDir = join(catDir, plugin.name);
let pkgs: typeof cats;
try {
pkgs = await readdir(pluginDir, { withFileTypes: true });
} catch {
continue;
}
for (const pkg of pkgs) {
if (!pkg.isDirectory()) continue;
const pkgDir = join(pluginDir, pkg.name);
const manifestPath = join(pkgDir, "package.json");
try {
const raw = await readFile(manifestPath, "utf8");
const parsed = JSON.parse(raw) as { name?: string };
if (parsed.name === pkgName) return pkgDir;
} catch {
// continue
}
}
}
}
return null;
}
async function resolvePackageJson(
spec: string,
repoRoot: string
): Promise<{
pkgName: string;
pkgJson: Record<string, unknown>;
} | null> {
let pkgName: string;
if (spec.startsWith("@")) {
const parts = spec.split("/");
if (parts.length < 2) return null;
pkgName = `${parts[0]}/${parts[1]}`;
} else {
pkgName = spec.split("/")[0] ?? spec;
}
const dir = await findPackageDir(join(repoRoot, "packages"), pkgName);
if (dir === null) return null;
const raw = await readFile(join(dir, "package.json"), "utf8");
return { pkgName, pkgJson: JSON.parse(raw) as Record<string, unknown> };
}
async function walkFile(args: WalkArgs, file: string): Promise<void> {
if (args.visitedFiles.has(file)) return;
args.visitedFiles.add(file);
let source: string;
try {
source = await readFile(file, "utf8");
} catch {
return;
}
const specs = extractSpecifiers(source);
const relFile = relative(args.repoRoot, file);
for (const spec of specs) {
const banned = banReason(spec);
if (banned !== null) {
args.hits.push({
file: relFile,
specifier: spec,
reason: banned,
});
continue;
}
if (spec.startsWith(".") || isAbsolute(spec)) {
const resolved = await resolveRelative(file, spec);
if (resolved !== null) {
await walkFile(args, resolved);
}
continue;
}
await walkPackage(args, spec, relFile);
}
}
async function walkPackage(
args: WalkArgs,
spec: string,
importingFile: string
): Promise<void> {
const pkg = await resolvePackageJson(spec, args.repoRoot);
if (pkg === null) return;
if (args.visitedPackages.has(pkg.pkgName)) return;
args.visitedPackages.add(pkg.pkgName);
const deps = {
...(pkg.pkgJson.dependencies as Record<string, unknown> | undefined),
...(pkg.pkgJson.peerDependencies as
| Record<string, unknown>
| undefined),
};
for (const depName of Object.keys(deps)) {
const reason = banReason(depName);
if (reason !== null) {
args.hits.push({
file: `${importingFile} -> ${pkg.pkgName} (transitive)`,
specifier: depName,
reason: `${reason} (via ${pkg.pkgName})`,
});
}
}
}
export interface CheckOptions {
readonly repoRoot: string;
readonly entry: string;
}
export async function checkWasmSubpathDeps(
options: CheckOptions
): Promise<BannedHit[]> {
const hits: BannedHit[] = [];
const args: WalkArgs = {
repoRoot: options.repoRoot,
visitedFiles: new Set(),
visitedPackages: new Set(),
hits,
};
await walkFile(args, options.entry);
return hits;
}
export function formatHits(hits: readonly BannedHit[]): string {
if (hits.length === 0) {
return "wasm-subpath-deps: clean — no banned specifiers reachable from src/wasm/index.ts.";
}
const lines: string[] = [
"Banned dependency in wasm subpath import closure.",
"",
"Rule: openspec/changes/api-plugin-example-dual-shape/specs/api-plugin-example/spec.md",
" 'The ./wasm subpath SHALL have a constrained dependency graph'",
"",
];
for (const h of hits) {
lines.push(` ${h.file}`);
lines.push(` -> ${h.specifier} (${h.reason})`);
}
lines.push("");
lines.push(
`Total: ${hits.length} violation${hits.length === 1 ? "" : "s"}.`
);
return lines.join("\n");
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
let repoRoot = process.cwd();
let entry: string | null = null;
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--root" && i + 1 < argv.length) {
repoRoot = resolve(argv[i + 1] ?? ".");
i += 1;
} else if (argv[i] === "--entry" && i + 1 < argv.length) {
entry = resolve(argv[i + 1] ?? ".");
i += 1;
}
}
if (entry === null) {
entry = resolve(
repoRoot,
"packages/api-stack/api/api-plugin-example/src/wasm/index.ts"
);
}
let hits: readonly BannedHit[];
try {
hits = await checkWasmSubpathDeps({ repoRoot, entry });
} catch (cause) {
process.stderr.write(
`check-wasm-subpath-deps: internal error: ${(cause as Error).message}\n`
);
process.exit(2);
}
const msg = formatHits(hits);
if (hits.length === 0) {
process.stdout.write(`${msg}\n`);
process.exit(0);
}
process.stderr.write(`${msg}\n`);
process.exit(1);
}
if (import.meta.main) {
await main();
}

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bun
/**
* One-shot cleanup: strips stacked `<!-- end:auto-docs -->` markers from
* every .md file in packages/. Keeps the first end marker after each begin
* marker and removes any additional end markers that follow before the next
* begin marker (or end-of-file).
*
* Why this exists: an earlier doc-generator bug accumulated trailing
* `<!-- end:auto-docs -->` markers across runs. Once stacked, the current
* generator's replace-between-markers logic preserves them on subsequent
* runs (it picks the FIRST end and concatenates everything after it
* verbatim). This script normalises the existing corruption.
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const BEGIN = "<!-- begin:auto-docs -->";
const END = "<!-- end:auto-docs -->";
function walkMd(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const s = statSync(full);
if (s.isDirectory()) {
if (name === "node_modules" || name === "dist" || name === ".turbo")
continue;
out.push(...walkMd(full));
} else if (name.endsWith(".md")) {
out.push(full);
}
}
return out;
}
function clean(src: string): string {
// Split on BEGIN markers; for each section after a BEGIN, keep content up
// to and including the first END marker, then strip any additional END
// markers (and any whitespace/newlines immediately surrounding them)
// before the next BEGIN.
const parts = src.split(BEGIN);
if (parts.length === 1) return src; // no markers, untouched
const out: string[] = [parts[0] as string];
for (let i = 1; i < parts.length; i++) {
const section = parts[i] as string;
const endIdx = section.indexOf(END);
if (endIdx === -1) {
out.push(BEGIN + section);
continue;
}
const head = section.slice(0, endIdx + END.length);
let tail = section.slice(endIdx + END.length);
// Strip any further END markers (with optional surrounding whitespace
// or trailing-spaces-then-newline) until the next BEGIN or EOF.
tail = tail.replace(/(?:[ \t]*<!-- end:auto-docs -->[ \t]*\n?)+/g, "");
out.push(BEGIN + head + tail);
}
return out.join("");
}
function main(): void {
const files = walkMd("packages");
let changed = 0;
for (const file of files) {
const src = readFileSync(file, "utf8");
const next = clean(src);
if (next !== src) {
writeFileSync(file, next);
changed++;
}
}
console.log(`Cleaned ${changed} .md files.`);
}
main();

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bun
/**
* Strips truncated/orphan `<!-- en…` (or `<!-- en` followed by ellipsis)
* fragments from .md files. These were produced by a display-truncated
* comment that got round-tripped back into the source.
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
function walkMd(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const s = statSync(full);
if (s.isDirectory()) {
if (name === "node_modules" || name === "dist" || name === ".turbo")
continue;
out.push(...walkMd(full));
} else if (name.endsWith(".md")) {
out.push(full);
}
}
return out;
}
function clean(src: string): string {
let next = src;
// Strip `<!-- en…` (Unicode ellipsis) on its own line or inline.
next = next.replace(/[ \t]*<!--\s*en…[ \t]*\n?/g, "");
// Also strip any unterminated `<!-- end:auto-docs -->` (missing closing -->).
next = next.replace(/[ \t]*<!-- end:auto-docs[^>]*?\n/g, (m) => {
if (m.includes("-->")) return m;
return "";
});
return next;
}
function main(): void {
const files = walkMd("packages");
let changed = 0;
for (const file of files) {
const src = readFileSync(file, "utf8");
const next = clean(src);
if (next !== src) {
writeFileSync(file, next);
changed++;
}
}
console.log(`Cleaned ${changed} .md files.`);
}
main();

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env bun
/**
* Fixes `../<X>.js` imports that now point to folders instead of files
* (since migrate-feature-layout moved X.ts → X/X.ts). Rewrites to the
* appropriate file path.
*/
import {
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join, relative } from "node:path";
const PACKAGES_DIR = "packages";
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const s = statSync(full);
if (s.isDirectory()) out.push(...walkTs(full));
else if (
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
!name.endsWith(".d.ts")
) {
out.push(full);
}
}
return out;
}
function listPackages(): string[] {
const out: string[] = [];
for (const cat of readdirSync(PACKAGES_DIR)) {
const catDir = join(PACKAGES_DIR, cat);
if (!statSync(catDir).isDirectory()) continue;
for (const plugin of readdirSync(catDir)) {
const pluginDir = join(catDir, plugin);
if (!statSync(pluginDir).isDirectory()) continue;
for (const pkg of readdirSync(pluginDir)) {
const pkgDir = join(pluginDir, pkg);
if (!statSync(pkgDir).isDirectory()) continue;
if (existsSync(join(pkgDir, "src"))) out.push(pkgDir);
}
}
}
return out;
}
function fileExists(p: string): boolean {
try {
statSync(p);
return true;
} catch {
return false;
}
}
function isDirectory(p: string): boolean {
try {
return statSync(p).isDirectory();
} catch {
return false;
}
}
function fixFile(file: string, srcDir: string): number {
const src = readFileSync(file, "utf8");
let updated = src;
let fixes = 0;
const re = /"((?:\.\.?\/)+[^"]+\.js)"/g;
const seen = new Set<string>();
for (const m of src.matchAll(re)) seen.add(m[1] as string);
for (const importPath of seen) {
const fileDir = dirname(file);
const stripped = importPath.replace(/\.js$/, "");
const resolved = join(fileDir, stripped);
// Skip if outside src/
const relFromSrc = relative(srcDir, resolved);
if (relFromSrc.startsWith("..")) continue;
// If the .ts file exists at this exact path, resolution is fine.
if (fileExists(`${resolved}.ts`) || fileExists(`${resolved}.tsx`))
continue;
// If a directory exists at this path, the import is broken — must rewrite.
if (!isDirectory(resolved)) continue;
const baseName = relative(srcDir, resolved).split("/").pop() as string;
const innerImpl = join(resolved, `${baseName}.ts`);
const innerIndex = join(resolved, "index.ts");
// Prefer the impl file (matches migrate-feature-layout's sibling pattern).
let newImportPath: string | null = null;
if (fileExists(innerImpl)) {
newImportPath = importPath.replace(/\.js$/, `/${baseName}.js`);
} else if (fileExists(innerIndex)) {
newImportPath = importPath.replace(/\.js$/, "/index.js");
}
if (newImportPath === null) continue;
updated = updated.split(`"${importPath}"`).join(`"${newImportPath}"`);
fixes++;
}
if (updated !== src) writeFileSync(file, updated);
return fixes;
}
function main(): void {
let totalFiles = 0;
let totalFixes = 0;
for (const pkg of listPackages()) {
const srcDir = join(pkg, "src");
const files = walkTs(srcDir);
for (const f of files) {
const fixes = fixFile(f, srcDir);
if (fixes > 0) {
totalFiles++;
totalFixes += fixes;
console.log(`FIX ${f} (${fixes})`);
}
}
}
console.log(`\nFixed ${totalFixes} imports across ${totalFiles} files.`);
}
main();

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env bun
/**
* Walks tests/ directories at package roots (siblings of src/) and rewrites
* relative imports that now point to migrated feature folders.
*
* For each `../src/<X>.js` import that no longer resolves but
* `../src/<X>/<X>.ts` does, rewrites to `../src/<X>/<X>.js`.
* Falls back to `../src/<X>/index.js` if only the barrel exists.
*/
import {
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join, relative } from "node:path";
const PACKAGES_DIR = "packages";
function walkTs(dir: string): string[] {
const out: string[] = [];
try {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const s = statSync(full);
if (s.isDirectory()) out.push(...walkTs(full));
else if (
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
!name.endsWith(".d.ts")
) {
out.push(full);
}
}
} catch {}
return out;
}
function listPackages(): string[] {
const out: string[] = [];
for (const cat of readdirSync(PACKAGES_DIR)) {
const catDir = join(PACKAGES_DIR, cat);
if (!statSync(catDir).isDirectory()) continue;
for (const plugin of readdirSync(catDir)) {
const pluginDir = join(catDir, plugin);
if (!statSync(pluginDir).isDirectory()) continue;
for (const pkg of readdirSync(pluginDir)) {
const pkgDir = join(pluginDir, pkg);
if (!statSync(pkgDir).isDirectory()) continue;
if (existsSync(join(pkgDir, "src"))) out.push(pkgDir);
}
}
}
return out;
}
function fileExists(p: string): boolean {
try {
statSync(p);
return true;
} catch {
return false;
}
}
function isDirectory(p: string): boolean {
try {
return statSync(p).isDirectory();
} catch {
return false;
}
}
function fixFile(file: string): number {
const src = readFileSync(file, "utf8");
let updated = src;
let fixes = 0;
const re = /"((?:\.\.?\/)+[^"]+\.js)"/g;
const seen = new Set<string>();
for (const m of src.matchAll(re)) seen.add(m[1] as string);
for (const importPath of seen) {
const fileDir = dirname(file);
const stripped = importPath.replace(/\.js$/, "");
const resolved = join(fileDir, stripped);
if (fileExists(`${resolved}.ts`) || fileExists(`${resolved}.tsx`))
continue;
if (!isDirectory(resolved)) continue;
const baseName = relative(fileDir, resolved).split("/").pop() as string;
const innerImpl = join(resolved, `${baseName}.ts`);
const innerIndex = join(resolved, "index.ts");
let newImportPath: string | null = null;
if (fileExists(innerImpl)) {
newImportPath = importPath.replace(/\.js$/, `/${baseName}.js`);
} else if (fileExists(innerIndex)) {
newImportPath = importPath.replace(/\.js$/, "/index.js");
}
if (newImportPath === null) continue;
updated = updated.split(`"${importPath}"`).join(`"${newImportPath}"`);
fixes++;
}
if (updated !== src) writeFileSync(file, updated);
return fixes;
}
function main(): void {
let totalFiles = 0;
let totalFixes = 0;
for (const pkg of listPackages()) {
const testsDir = join(pkg, "tests");
if (!isDirectory(testsDir)) continue;
const files = walkTs(testsDir);
for (const f of files) {
const fixes = fixFile(f);
if (fixes > 0) {
totalFiles++;
totalFixes += fixes;
console.log(`FIX ${f} (${fixes})`);
}
}
}
console.log(`\nFixed ${totalFixes} imports across ${totalFiles} files.`);
}
main();

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env bun
/**
* Fixes broken relative imports inside files that were just migrated from
* src/<feature>.ts to src/<feature>/<feature>.ts.
*
* The migrate-feature-layout script moved files one level deeper but only
* rewrote the imports OF the moved files, not imports WITHIN them. Any
* `./X.js` (or `./X/index.js`) inside a moved file now points at
* `src/<feature>/X.js` (which doesn't exist) instead of the intended
* `src/X.js`.
*
* For each `src/<feature>/{<feature>.ts, <feature>.test.ts}`, walks its
* relative imports and rewrites `./X` → `../X` when the target resolves at
* the package's src/ root (one level up). Leaves intra-folder imports
* alone (`./<feature>.types.js` etc.) since those are still correct.
*
* Idempotent: a second run finds nothing to fix.
*/
import {
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
const PACKAGES_DIR = "packages";
interface MovedFile {
readonly file: string;
readonly srcDir: string;
readonly featureDir: string;
readonly feature: string;
}
function listMovedFiles(): MovedFile[] {
const out: MovedFile[] = [];
for (const cat of readdirSync(PACKAGES_DIR)) {
const catDir = join(PACKAGES_DIR, cat);
if (!statSync(catDir).isDirectory()) continue;
for (const plugin of readdirSync(catDir)) {
const pluginDir = join(catDir, plugin);
if (!statSync(pluginDir).isDirectory()) continue;
for (const pkg of readdirSync(pluginDir)) {
const pkgDir = join(pluginDir, pkg);
if (!statSync(pkgDir).isDirectory()) continue;
const srcDir = join(pkgDir, "src");
try {
statSync(srcDir);
} catch {
continue;
}
for (const entry of readdirSync(srcDir)) {
const featureDir = join(srcDir, entry);
try {
if (!statSync(featureDir).isDirectory()) continue;
} catch {
continue;
}
const feature = entry;
const impl = join(featureDir, `${feature}.ts`);
const test = join(featureDir, `${feature}.test.ts`);
if (existsSync(impl))
out.push({ file: impl, srcDir, featureDir, feature });
if (existsSync(test))
out.push({ file: test, srcDir, featureDir, feature });
}
}
}
}
return out;
}
function targetResolves(targetBase: string, lookupDir: string): boolean {
// targetBase is e.g. "./errors/serialize-error" — strip leading ./, then
// look for matching candidates from lookupDir.
const stripped = targetBase.replace(/^\.\//, "");
const candidates = [
join(lookupDir, `${stripped}.ts`),
join(lookupDir, `${stripped}.tsx`),
join(lookupDir, stripped, "index.ts"),
join(lookupDir, stripped, "index.tsx"),
join(lookupDir, stripped), // for asset / json imports
];
return candidates.some((c) => {
try {
statSync(c);
return true;
} catch {
return false;
}
});
}
function fixFile(moved: MovedFile): number {
const src = readFileSync(moved.file, "utf8");
// Match every `"./<something>.js"` whether in import or side-effect.
const re = /"(\.\/[^"]+\.js)"/g;
let updated = src;
const touches = new Set<string>();
for (const m of src.matchAll(re)) {
const fullPath = m[1] as string; // e.g. "./foo.js" or "./foo/bar.js"
const withoutJs = fullPath.replace(/\.js$/, "");
const base = withoutJs.replace(/\/index$/, "");
// Skip imports that target THE feature folder itself or its siblings
// — those resolve correctly inside the new feature dir.
const intraFolder =
base === `./${moved.feature}` ||
base.startsWith(`./${moved.feature}.`) ||
// sibling files inside the same feature folder
!base.includes("/", 2);
// Step 1: does it currently resolve from the file's location?
const fileDir = dirname(moved.file);
if (targetResolves(base, fileDir)) continue;
// Step 2: does it resolve at src/ root (one level up)?
if (targetResolves(base, moved.srcDir)) {
const newPath = fullPath.replace(/^\.\//, "../");
updated = updated.split(`"${fullPath}"`).join(`"${newPath}"`);
touches.add(fullPath);
continue;
}
// Avoid lint warning about unused variable
void intraFolder;
}
if (updated !== src) {
writeFileSync(moved.file, updated);
return touches.size;
}
return 0;
}
function main(): void {
const moved = listMovedFiles();
let filesChanged = 0;
let importsRewritten = 0;
for (const m of moved) {
const n = fixFile(m);
if (n > 0) {
filesChanged++;
importsRewritten += n;
console.log(`FIX ${m.file} (${n} imports)`);
}
}
console.log(
`\nFixed ${importsRewritten} import path${importsRewritten === 1 ? "" : "s"} across ${filesChanged} file${filesChanged === 1 ? "" : "s"}.`
);
}
main();

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env bun
/**
* Fixes vite.config.ts entry points and package.json subpath exports for
* features that were migrated from flat `src/<feature>.ts` to nested
* `src/<feature>/<feature>.ts` by `migrate-feature-layout.ts`.
*
* For each package that has a `src/<feature>/<feature>.ts` AND no longer has
* a `src/<feature>.ts`, rewrites:
* - vite.config.ts entry: "./src/<feature>.ts" → "./src/<feature>/index.ts"
* - package.json exports: "./dist/<feature>.js" → "./dist/<feature>/index.js"
* "./dist/<feature>.d.ts" → "./dist/<feature>/index.d.ts"
*
* Uses the sub-barrel (index.ts) rather than the impl directly so that
* downstream consumers get the explicit named re-exports defined there.
*
* Idempotent.
*/
import {
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
const PACKAGES_DIR = "packages";
interface FeatureRename {
readonly feature: string;
}
function listPackages(): readonly string[] {
const out: string[] = [];
for (const cat of readdirSync(PACKAGES_DIR)) {
const catDir = join(PACKAGES_DIR, cat);
if (!statSync(catDir).isDirectory()) continue;
for (const plugin of readdirSync(catDir)) {
const pluginDir = join(catDir, plugin);
if (!statSync(pluginDir).isDirectory()) continue;
for (const pkg of readdirSync(pluginDir)) {
const pkgDir = join(pluginDir, pkg);
if (!statSync(pkgDir).isDirectory()) continue;
if (existsSync(join(pkgDir, "src"))) out.push(pkgDir);
}
}
}
return out;
}
function findMigratedFeatures(pkgDir: string): FeatureRename[] {
const srcDir = join(pkgDir, "src");
const out: FeatureRename[] = [];
for (const entry of readdirSync(srcDir)) {
const featureDir = join(srcDir, entry);
try {
if (!statSync(featureDir).isDirectory()) continue;
} catch {
continue;
}
const impl = join(featureDir, `${entry}.ts`);
const flatImpl = join(srcDir, `${entry}.ts`);
// Migrated if nested impl exists AND no flat sibling exists.
if (existsSync(impl) && !existsSync(flatImpl)) {
out.push({ feature: entry });
}
}
return out;
}
function rewriteFileRefs(
file: string,
features: readonly FeatureRename[]
): boolean {
const src = readFileSync(file, "utf8");
let updated = src;
for (const { feature } of features) {
// vite.config: "./src/<feature>.ts" / "src/<feature>.ts" → folder/index.ts
updated = updated
.split(`"./src/${feature}.ts"`)
.join(`"./src/${feature}/index.ts"`);
updated = updated
.split(`"src/${feature}.ts"`)
.join(`"src/${feature}/index.ts"`);
// package.json: "./dist/<feature>.js" / .d.ts → "./dist/<feature>/index.{js,d.ts}"
updated = updated
.split(`"./dist/${feature}.js"`)
.join(`"./dist/${feature}/index.js"`);
updated = updated
.split(`"./dist/${feature}.d.ts"`)
.join(`"./dist/${feature}/index.d.ts"`);
}
if (updated !== src) {
writeFileSync(file, updated);
return true;
}
return false;
}
function main(): void {
const pkgs = listPackages();
let touched = 0;
for (const pkgDir of pkgs) {
const features = findMigratedFeatures(pkgDir);
if (features.length === 0) continue;
const targets = [
join(pkgDir, "vite.config.ts"),
join(pkgDir, "package.json"),
join(pkgDir, "vite.lib.config.ts"),
join(pkgDir, "tsup.config.ts"),
];
for (const t of targets) {
if (!existsSync(t)) continue;
if (rewriteFileRefs(t, features)) {
touched++;
console.log(`FIX ${t}`);
}
}
}
console.log(`\nUpdated ${touched} config file${touched === 1 ? "" : "s"}.`);
}
main();

View File

@@ -0,0 +1,330 @@
#!/usr/bin/env bun
/**
* Migrates flat-at-src-root <feature>.{ts,test.ts,md} into
* src/<feature>/<feature>.{ts,test.ts,md} + src/<feature>/index.ts barrel.
*
* For each violation flagged by `scripts/lint/check-feature-layout.ts`:
* 1. mkdir src/<feature>/
* 2. git mv each flat file into the folder
* 3. Parse the impl file for top-level named exports + type exports
* 4. Write src/<feature>/index.ts with explicit named re-exports
* (rule 10 — no export *)
* 5. Rewrite every sibling `./<feature>.js` / `../<feature>.js` import
* in the same package's src/ tree to point at `./<feature>/<feature>.js`
* / `../<feature>/<feature>.js`. If the import target is the package
* root (src/index.ts), use the new sub-barrel `./<feature>/index.js`.
*
* Skips already-fixed violations. Idempotent.
*
* Usage:
* bun scripts/lint/migrate-feature-layout.ts [--dry-run] [--package <pkg-dir>]
*/
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join, relative } from "node:path";
const ROOT_FLAT_EXEMPT = new Set(["index.ts"]);
const PACKAGES_DIR = "packages";
interface Violation {
readonly pkgDir: string;
readonly srcDir: string;
readonly feature: string;
readonly impl: string;
readonly test: string;
readonly doc: string | null;
}
interface NamedExports {
readonly values: readonly string[];
readonly types: readonly string[];
}
function listPackagesWithSrc(): readonly string[] {
const out: string[] = [];
for (const cat of readdirSync(PACKAGES_DIR)) {
const catDir = join(PACKAGES_DIR, cat);
if (!statSync(catDir).isDirectory()) continue;
for (const plugin of readdirSync(catDir)) {
const pluginDir = join(catDir, plugin);
if (!statSync(pluginDir).isDirectory()) continue;
for (const pkg of readdirSync(pluginDir)) {
const pkgDir = join(pluginDir, pkg);
if (!statSync(pkgDir).isDirectory()) continue;
const srcDir = join(pkgDir, "src");
try {
if (statSync(srcDir).isDirectory()) out.push(pkgDir);
} catch {
/* skip */
}
}
}
}
return out;
}
function findViolations(pkgDir: string): Violation[] {
const srcDir = join(pkgDir, "src");
const entries = readdirSync(srcDir);
const featuresFound = new Map<string, string[]>();
for (const name of entries) {
const full = join(srcDir, name);
if (statSync(full).isDirectory()) continue;
if (ROOT_FLAT_EXEMPT.has(name)) continue;
const match = name.match(/^(.+?)(\.test|\.types|\.md|)\.(ts|tsx|md)$/);
if (!match) continue;
const feature = match[1] as string;
const list = featuresFound.get(feature) ?? [];
list.push(name);
featuresFound.set(feature, list);
}
const violations: Violation[] = [];
for (const [feature, files] of featuresFound) {
const hasImpl = files.includes(`${feature}.ts`);
const hasTest = files.includes(`${feature}.test.ts`);
if (!hasImpl || !hasTest) continue;
violations.push({
pkgDir,
srcDir,
feature,
impl: `${feature}.ts`,
test: `${feature}.test.ts`,
doc: files.includes(`${feature}.md`) ? `${feature}.md` : null,
});
}
return violations;
}
function parseExports(implPath: string): NamedExports {
const src = readFileSync(implPath, "utf8");
const values = new Set<string>();
const types = new Set<string>();
const cleaned = src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/.*$/gm, "");
const re =
/\bexport\s+(?:default\s+)?(?:async\s+)?(const|let|var|function|class|enum|interface|type)\s+([A-Za-z_$][\w$]*)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(cleaned)) !== null) {
const kind = match[1] as string;
const name = match[2] as string;
if (kind === "type" || kind === "interface") types.add(name);
else values.add(name);
}
const listRe = /export\s+(type\s+)?\{([^}]+)\}/g;
while ((match = listRe.exec(cleaned)) !== null) {
const blockIsType = Boolean(match[1]);
const body = match[2] as string;
for (const piece of body.split(",")) {
const trimmed = piece.trim();
if (!trimmed) continue;
const typeM = trimmed.match(/^type\s+(.+)$/);
const inner = typeM ? (typeM[1] as string) : trimmed;
const asM = inner.match(
/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/
);
const name = asM ? (asM[2] as string) : inner;
if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
if (blockIsType || typeM) types.add(name);
else values.add(name);
}
}
return {
values: [...values].sort(),
types: [...types].sort(),
};
}
function buildBarrel(feature: string, exports: NamedExports): string {
const lines: string[] = [];
if (exports.types.length > 0) {
lines.push("export type {");
for (const t of exports.types) lines.push(` ${t},`);
lines.push(`} from "./${feature}.js";`);
}
if (exports.values.length > 0) {
lines.push("export {");
for (const v of exports.values) lines.push(` ${v},`);
lines.push(`} from "./${feature}.js";`);
}
if (lines.length === 0) {
lines.push(`export {} from "./${feature}.js";`);
}
lines.push("");
return lines.join("\n");
}
function git(args: readonly string[], cwd?: string): void {
execFileSync("git", args as string[], { cwd, stdio: "pipe" });
}
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const s = statSync(full);
if (s.isDirectory()) out.push(...walkTs(full));
else if (
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
!name.endsWith(".d.ts")
)
out.push(full);
}
return out;
}
function rewriteImports(
pkgDir: string,
feature: string,
dryRun: boolean
): number {
const srcDir = join(pkgDir, "src");
const tsFiles = walkTs(srcDir);
let changed = 0;
for (const file of tsFiles) {
const src = readFileSync(file, "utf8");
let updated = src;
const fileDir = dirname(file);
const featureFromHere = `${feature}.js`;
// Root-level imports: `./<feature>.js` from src/<file>.ts
if (fileDir === srcDir) {
const localFrom = `"./${feature}.js"`;
if (updated.includes(localFrom)) {
if (file === join(srcDir, "index.ts")) {
updated = updated
.split(localFrom)
.join(`"./${feature}/index.js"`);
} else {
updated = updated
.split(localFrom)
.join(`"./${feature}/${featureFromHere}"`);
}
}
}
// One-level-deep: `../<feature>.js` from src/<subdir>/<file>.ts
const relFromSrc = relative(srcDir, fileDir);
if (
relFromSrc !== "" &&
!relFromSrc.startsWith("..") &&
!relFromSrc.includes("/")
) {
const parentFrom = `"../${feature}.js"`;
if (updated.includes(parentFrom)) {
updated = updated
.split(parentFrom)
.join(`"../${feature}/${featureFromHere}"`);
}
}
// Two-or-more-level-deep: `../../<feature>.js` etc.
if (relFromSrc !== "" && relFromSrc.includes("/")) {
const depth = relFromSrc.split("/").length;
const prefix = "../".repeat(depth);
const deepFrom = `"${prefix}${feature}.js"`;
if (updated.includes(deepFrom)) {
updated = updated
.split(deepFrom)
.join(`"${prefix}${feature}/${featureFromHere}"`);
}
}
if (updated !== src) {
changed++;
if (!dryRun) writeFileSync(file, updated);
}
}
return changed;
}
function migrate(v: Violation, dryRun: boolean): void {
const newDir = join(v.srcDir, v.feature);
if (existsSync(newDir)) {
console.log(`SKIP ${v.pkgDir} :: ${v.feature} (folder already exists)`);
return;
}
console.log(`MIGRATE ${v.pkgDir} :: ${v.feature}`);
if (!dryRun) mkdirSync(newDir);
const moves: Array<[string, string]> = [
[v.impl, `${v.feature}/${v.impl}`],
[v.test, `${v.feature}/${v.test}`],
];
if (v.doc) moves.push([v.doc, `${v.feature}/${v.doc}`]);
for (const [from, to] of moves) {
if (dryRun) {
console.log(` would git mv ${from} -> ${to}`);
continue;
}
git(["mv", from, to], v.srcDir);
}
const implPath = join(newDir, v.impl);
const exports = dryRun ? { values: [], types: [] } : parseExports(implPath);
const barrel = buildBarrel(v.feature, exports);
if (dryRun) {
console.log(
` would write index.ts (${exports.values.length} values, ${exports.types.length} types)`
);
} else {
writeFileSync(join(newDir, "index.ts"), barrel);
git(["add", "index.ts"], newDir);
}
const rewriteCount = rewriteImports(v.pkgDir, v.feature, dryRun);
console.log(
` rewrote ${rewriteCount} import path${rewriteCount === 1 ? "" : "s"}`
);
}
function main(): void {
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const pkgFilterIdx = args.indexOf("--package");
const pkgFilter =
pkgFilterIdx >= 0 ? (args[pkgFilterIdx + 1] as string) : null;
const pkgs = listPackagesWithSrc();
const allViolations: Violation[] = [];
for (const pkg of pkgs) {
if (pkgFilter && pkg !== pkgFilter) continue;
allViolations.push(...findViolations(pkg));
}
if (allViolations.length === 0) {
console.log("OK no violations to migrate.");
return;
}
console.log(
`Found ${allViolations.length} violations${dryRun ? " (dry-run)" : ""}.\n`
);
for (const v of allViolations) {
migrate(v, dryRun);
}
console.log(
`\n${dryRun ? "Would migrate" : "Migrated"} ${allViolations.length} violations across ${new Set(allViolations.map((v) => v.pkgDir)).size} packages.`
);
if (!dryRun) {
console.log(
"Next: run `bunx turbo lint typecheck test` per affected package to verify."
);
}
}
main();

View File

@@ -0,0 +1,24 @@
[
{
"name": "api-sandbox-vm",
"requiresBridge": true,
"bridgePackagePattern": "api-sandbox-vm-host-bridge"
},
{
"name": "api-sandbox-wasm",
"requiresBridge": true,
"bridgePackagePattern": "api-sandbox-wasm-host-bridge"
},
{
"name": "api-sandbox-permissions",
"requiresBridge": false
},
{
"name": "api-ws-broker-redis",
"requiresBridge": false
},
{
"name": "api-host-pipeline-events",
"requiresBridge": false
}
]

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env bun
/**
* Regenerates src/<feature>/index.ts barrels.
*/
import {
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
const PACKAGES_DIR = "packages";
interface NamedExports {
readonly values: readonly string[];
readonly types: readonly string[];
readonly hasDefault: boolean;
}
function parseExports(implPath: string): NamedExports {
const src = readFileSync(implPath, "utf8");
const values = new Set<string>();
const types = new Set<string>();
let hasDefault = false;
const cleaned = src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/.*$/gm, "");
// Captures: 1 = "default" or "", 2 = kind, 3 = name.
const re =
/\bexport\s+(default\s+)?(?:abstract\s+)?(?:declare\s+)?(?:async\s+)?(const|let|var|function|class|enum|interface|type)\s+([A-Za-z_$][\w$]*)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(cleaned)) !== null) {
const isDefault = Boolean(match[1]);
const kind = match[2] as string;
const name = match[3] as string;
if (isDefault) {
hasDefault = true;
// `export default class X {}` also creates a named binding X locally,
// but the named export only happens through the default slot.
continue;
}
if (kind === "type" || kind === "interface") types.add(name);
else values.add(name);
}
// Detect bare `export default Identifier;`.
const bareDefaultRe = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;/g;
while ((match = bareDefaultRe.exec(cleaned)) !== null) {
hasDefault = true;
}
const listRe = /export\s+(type\s+)?\{([^}]+)\}/g;
while ((match = listRe.exec(cleaned)) !== null) {
const blockIsType = Boolean(match[1]);
const body = match[2] as string;
for (const piece of body.split(",")) {
const trimmed = piece.trim();
if (!trimmed) continue;
const typeM = trimmed.match(/^type\s+(.+)$/);
const inner = typeM ? (typeM[1] as string) : trimmed;
const asM = inner.match(
/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/
);
const name = asM ? (asM[2] as string) : inner;
if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
if (blockIsType || typeM) types.add(name);
else values.add(name);
}
}
values.delete("default");
return {
values: [...values].sort(),
types: [...types].sort(),
hasDefault,
};
}
function buildBarrel(feature: string, exports: NamedExports): string {
const lines: string[] = [];
if (exports.types.length > 0) {
lines.push("export type {");
for (const t of exports.types) lines.push(` ${t},`);
lines.push(`} from "./${feature}.js";`);
}
if (exports.values.length > 0 || exports.hasDefault) {
const names = [...exports.values];
if (exports.hasDefault) names.push("default");
lines.push("export {");
for (const v of names) lines.push(` ${v},`);
lines.push(`} from "./${feature}.js";`);
}
if (lines.length === 0) lines.push(`export {} from "./${feature}.js";`);
lines.push("");
return lines.join("\n");
}
function listPackages(): readonly string[] {
const out: string[] = [];
for (const cat of readdirSync(PACKAGES_DIR)) {
const catDir = join(PACKAGES_DIR, cat);
if (!statSync(catDir).isDirectory()) continue;
for (const plugin of readdirSync(catDir)) {
const pluginDir = join(catDir, plugin);
if (!statSync(pluginDir).isDirectory()) continue;
for (const pkg of readdirSync(pluginDir)) {
const pkgDir = join(pluginDir, pkg);
if (!statSync(pkgDir).isDirectory()) continue;
if (existsSync(join(pkgDir, "src"))) out.push(pkgDir);
}
}
}
return out;
}
function main(): void {
let touched = 0;
for (const pkgDir of listPackages()) {
const srcDir = join(pkgDir, "src");
for (const entry of readdirSync(srcDir)) {
const featureDir = join(srcDir, entry);
try {
if (!statSync(featureDir).isDirectory()) continue;
} catch {
continue;
}
const impl = join(featureDir, `${entry}.ts`);
const indexFile = join(featureDir, "index.ts");
if (!existsSync(impl) || !existsSync(indexFile)) continue;
const current = readFileSync(indexFile, "utf8");
const onlyTouchesImpl = current
.split("\n")
.filter((l) => l.includes("from "))
.every((l) => l.includes(`from "./${entry}.js"`));
if (!onlyTouchesImpl) continue;
const exports = parseExports(impl);
const barrel = buildBarrel(entry, exports);
if (barrel !== current) {
writeFileSync(indexFile, barrel);
console.log(`REGEN ${indexFile}`);
touched++;
}
}
}
console.log(`\nRegenerated ${touched} barrels.`);
}
main();

370
scripts/mark-migrated.ts Normal file
View File

@@ -0,0 +1,370 @@
#!/usr/bin/env bun
/**
* mark-migrated — flip STATUS.md rows from `–` / `❌` to `✅` for the given
* package names. Optionally adds new ✅ rows for genesis-original packages
* that have no existing STATUS.md entry.
*
* Usage:
* bun scripts/mark-migrated.ts cron job-scheduler
* bun scripts/mark-migrated.ts @suaveplan/retry @suaveplan/types
* bun scripts/mark-migrated.ts --add cron
* bun scripts/mark-migrated.ts --add --section primitives cron
*
* Args may include or omit the `@suaveplan/` prefix.
*
* Flags:
* --add Create ✅ rows for packages not already in STATUS.md.
* The description is read from each package's
* package.json; the section defaults to the on-disk
* plugin folder (`packages/<category>/<plugin>/<pkg>/`)
* unless overridden by `--section`.
* --section <name> Explicit section under which `--add` inserts new
* rows. Must match an existing `### \`<name>\`` heading
* in STATUS.md.
*
* Recomputes the count table at the top of STATUS.md after substitutions.
*
* Exit codes:
* 0 — every supplied package was already ✅, flipped to ✅, or added.
* 1 — at least one supplied package could not be flipped or added.
* 2 — STATUS.md is malformed (missing counts table or required section).
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { CONFIG } from "./lib/config.js";
import { discoverPackageDirs } from "./lib/discover-packages.js";
const STATUS_PATH = join(CONFIG.paths.root, "STATUS.md");
const PKG_PREFIX = "@suaveplan/";
interface MarkResult {
flipped: string[];
alreadyMigrated: string[];
added: string[];
notFound: string[];
}
interface PackageMeta {
name: string; // short name without @suaveplan/
description: string;
plugin: string; // e.g. "primitives", "control-flow"
}
function normalize(name: string): string {
const trimmed = name.trim();
return trimmed.startsWith(PKG_PREFIX)
? trimmed.slice(PKG_PREFIX.length)
: trimmed;
}
/**
* Parse a STATUS.md row, returning `{ name, status }` if it's a package row,
* `null` otherwise. Rows look like:
* | `@suaveplan/<name>` | <status> | <description> |
*/
function parseRow(line: string): { name: string; status: string } | null {
const match = line.match(
/^\|\s*`@suaveplan\/([^`]+)`\s*\|\s*(✅|–|❌)\s*\|/u
);
if (!match) return null;
return { name: match[1] ?? "", status: match[2] ?? "" };
}
function flipRow(line: string): string {
return line.replace(/^(\|\s*`@suaveplan\/[^`]+`\s*\|\s*)(–|❌)/u, "$1✅");
}
function recomputeCounts(lines: string[]): string[] {
let migrated = 0;
let pending = 0;
let unwritten = 0;
for (const line of lines) {
const parsed = parseRow(line);
if (!parsed) continue;
if (parsed.status === "✅") migrated++;
else if (parsed.status === "–") pending++;
else if (parsed.status === "❌") unwritten++;
}
const total = migrated + pending + unwritten;
const out = [...lines];
let countsTableSeen = false;
for (let i = 0; i < out.length; i++) {
const line = out[i] ?? "";
if (line.startsWith("## Counts")) {
countsTableSeen = true;
continue;
}
if (!countsTableSeen) continue;
if (line.startsWith("| ✅ Migrated |")) {
out[i] = `| ✅ Migrated | ${migrated} |`;
} else if (line.startsWith("| – Yet to be migrated |")) {
out[i] = `| – Yet to be migrated | ${pending} |`;
} else if (line.startsWith("| ❌ Hasn't been written yet |")) {
out[i] = `| ❌ Hasn't been written yet | ${unwritten} |`;
} else if (line.startsWith("| **Total tracked** |")) {
out[i] = `| **Total tracked** | **${total}** |`;
break;
}
}
if (!countsTableSeen) {
throw new Error("STATUS.md is missing the '## Counts' section");
}
return out;
}
async function loadPackageMetaIndex(): Promise<Map<string, PackageMeta>> {
const pkgDirs = await discoverPackageDirs();
const index = new Map<string, PackageMeta>();
for (const dir of pkgDirs) {
const pkgJsonPath = join(dir, "package.json");
let raw: string;
try {
raw = readFileSync(pkgJsonPath, "utf-8");
} catch {
continue;
}
let pkg: { name?: string; description?: string };
try {
pkg = JSON.parse(raw) as { name?: string; description?: string };
} catch {
continue;
}
if (!pkg.name?.startsWith(PKG_PREFIX)) continue;
const shortName = pkg.name.slice(PKG_PREFIX.length);
// Plugin folder is the parent of <pkg> in the canonical 3-level
// layout: `packages/<category>/<plugin>/<pkg>/`.
const segments = dir.split(/[\\/]/);
const plugin = segments[segments.length - 2] ?? "";
index.set(shortName, {
name: shortName,
description: pkg.description ?? "",
plugin,
});
}
return index;
}
function findSectionInsertIndex(
lines: readonly string[],
sectionName: string
): number {
const sectionHeader = `### \`${sectionName}\``;
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i] === sectionHeader) {
start = i;
break;
}
}
if (start === -1) {
throw new Error(
`STATUS.md has no section "### \`${sectionName}\`" — pass --section <name> to target an existing section`
);
}
// Insert at the last contiguous table row in this section (before the
// next heading or blank gap leading to a heading).
let insertAt = start;
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i] ?? "";
if (line.startsWith("### ") || line.startsWith("## ")) break;
if (line.startsWith("| `@suaveplan/")) insertAt = i;
}
return insertAt + 1;
}
function buildRow(meta: PackageMeta): string {
const desc = meta.description.replace(/\s+/g, " ").trim();
return `| \`@suaveplan/${meta.name}\` | ✅ | ${desc} |`;
}
interface MarkOptions {
add: boolean;
section: string | undefined;
}
function markMigrated(
statusMd: string,
targets: ReadonlySet<string>,
options: MarkOptions,
metaIndex: ReadonlyMap<string, PackageMeta>
): { content: string; result: MarkResult } {
const lines = statusMd.split("\n");
const flipped: string[] = [];
const alreadyMigrated: string[] = [];
const added: string[] = [];
const seen = new Set<string>();
for (let i = 0; i < lines.length; i++) {
const parsed = parseRow(lines[i] ?? "");
if (!parsed) continue;
if (!targets.has(parsed.name)) continue;
seen.add(parsed.name);
if (parsed.status === "✅") {
alreadyMigrated.push(parsed.name);
} else {
lines[i] = flipRow(lines[i] ?? "");
flipped.push(parsed.name);
}
}
const stillMissing = [...targets].filter((name) => !seen.has(name));
const notFound: string[] = [];
if (options.add) {
// Process from last-to-first so insertion indexes stay valid as we mutate.
// Group by target section to insert all rows of one section together.
const grouped = new Map<string, PackageMeta[]>();
for (const name of stillMissing) {
const meta = metaIndex.get(name);
if (!meta) {
notFound.push(name);
continue;
}
const section = options.section ?? meta.plugin;
if (!section) {
notFound.push(name);
continue;
}
const bucket = grouped.get(section) ?? [];
bucket.push(meta);
grouped.set(section, bucket);
}
for (const [section, metas] of grouped) {
let insertAt: number;
try {
insertAt = findSectionInsertIndex(lines, section);
} catch (err) {
for (const meta of metas) {
notFound.push(meta.name);
console.error(
err instanceof Error ? err.message : String(err)
);
}
continue;
}
// Insert all rows for this section at the same point in order.
const rows = metas.map(buildRow);
lines.splice(insertAt, 0, ...rows);
for (const meta of metas) added.push(meta.name);
}
} else {
notFound.push(...stillMissing);
}
const recomputed = recomputeCounts(lines);
return {
content: recomputed.join("\n"),
result: { flipped, alreadyMigrated, added, notFound },
};
}
function printSummary(result: MarkResult): void {
if (result.flipped.length > 0) {
console.log(`✅ Flipped to migrated (${result.flipped.length}):`);
for (const name of result.flipped)
console.log(` - @suaveplan/${name}`);
}
if (result.added.length > 0) {
console.log(`➕ Added new ✅ rows (${result.added.length}):`);
for (const name of result.added) console.log(` - @suaveplan/${name}`);
}
if (result.alreadyMigrated.length > 0) {
console.log(`⏭️ Already migrated (${result.alreadyMigrated.length}):`);
for (const name of result.alreadyMigrated)
console.log(` - @suaveplan/${name}`);
}
if (result.notFound.length > 0) {
console.log(`❌ Not found in STATUS.md (${result.notFound.length}):`);
for (const name of result.notFound)
console.log(` - @suaveplan/${name}`);
if (result.added.length === 0) {
console.log(
" (pass --add to create new ✅ rows for genesis-original packages)"
);
}
}
}
interface ParsedArgs {
targets: Set<string>;
add: boolean;
section: string | undefined;
}
function parseArgs(argv: readonly string[]): ParsedArgs {
const targets = new Set<string>();
let add = false;
let section: string | undefined;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i] ?? "";
if (arg === "--add") {
add = true;
} else if (arg === "--section") {
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
throw new Error("--section requires a value");
}
section = next;
i++;
} else if (arg.startsWith("--section=")) {
section = arg.slice("--section=".length);
} else if (arg.startsWith("--")) {
throw new Error(`Unknown flag: ${arg}`);
} else {
targets.add(normalize(arg));
}
}
return { targets, add, section };
}
async function main(): Promise<void> {
let parsed: ParsedArgs;
try {
parsed = parseArgs(process.argv.slice(2));
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
if (parsed.targets.size === 0) {
console.error(
"Usage: bun scripts/mark-migrated.ts [--add] [--section <name>] <pkg> [<pkg> ...]"
);
console.error("Names may include or omit the `@suaveplan/` prefix.");
process.exit(1);
}
const statusMd = readFileSync(STATUS_PATH, "utf-8");
const metaIndex = parsed.add
? await loadPackageMetaIndex()
: new Map<string, PackageMeta>();
let next: { content: string; result: MarkResult };
try {
next = markMigrated(
statusMd,
parsed.targets,
{ add: parsed.add, section: parsed.section },
metaIndex
);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(2);
}
if (next.content !== statusMd) {
writeFileSync(STATUS_PATH, next.content);
console.log(`✏️ Wrote ${STATUS_PATH}`);
} else {
console.log("No changes to STATUS.md.");
}
printSummary(next.result);
process.exit(next.result.notFound.length === 0 ? 0 : 1);
}
void main();

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env bun
/**
* Migrates every packages/<plugin>/<pkg>/vitest.config.ts to use
* `createTestConfig` from `@suaveplan/testing/config`. Also:
*
* - Adds `@suaveplan/testing` as a workspace devDependency where missing.
* - Removes the 81 per-package `tests/setup/polyfills.ts` files.
* - Heuristically detects async-heavy packages (those whose existing
* config sets `testTimeout: 30000` or higher) and preserves the timeout.
* - Heuristically detects DOM packages (env: happy-dom) and preserves
* `environment` + sets `fileParallelism: false`.
*
* Run:
* bun scripts/migrate-vitest-configs.ts # apply
* bun scripts/migrate-vitest-configs.ts --dry # preview
*/
import {
existsSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
const repoRoot = "/mnt/s/web/genesis";
const packagesRoot = join(repoRoot, "packages");
const TESTING_PKG = "@suaveplan/testing";
const TESTING_OWN_PATH = join(packagesRoot, "dev", "testing");
const dry = process.argv.includes("--dry");
interface Detected {
timeout: number;
hookTimeout?: number;
environment: "node" | "happy-dom" | "jsdom";
fileParallelism: boolean;
additionalSetupFiles: string[];
additionalIncludes: string[];
additionalCoverageExcludes: string[];
}
function detect(content: string): Detected {
const out: Detected = {
timeout: 5000,
environment: "node",
fileParallelism: true,
additionalSetupFiles: [],
additionalIncludes: [],
additionalCoverageExcludes: [],
};
const tm = content.match(/testTimeout:\s*(\d+)/);
if (tm?.[1] !== undefined) out.timeout = Number(tm[1]);
const hm = content.match(/hookTimeout:\s*(\d+)/);
if (hm?.[1] !== undefined) out.hookTimeout = Number(hm[1]);
if (/environment:\s*"happy-dom"/.test(content))
out.environment = "happy-dom";
else if (/environment:\s*"jsdom"/.test(content)) out.environment = "jsdom";
if (/fileParallelism:\s*false/.test(content)) out.fileParallelism = false;
const setupMatch = content.match(/setupFiles:\s*\[([^\]]*)\]/s);
if (setupMatch?.[1]) {
const entries = setupMatch[1]
.split(",")
.map((s) => s.trim().replace(/["']/g, ""))
.filter(Boolean)
.filter(
(p) =>
!p.endsWith("polyfills.ts") && !p.endsWith("polyfills.js")
);
out.additionalSetupFiles = entries;
}
const incMatch = content.match(/include:\s*\[([^\]]*)\]/s);
if (incMatch?.[1]) {
const entries = incMatch[1]
.split(",")
.map((s) => s.trim().replace(/["']/g, ""))
.filter(Boolean)
.filter(
(p) => p !== "src/**/*.test.ts" && p !== "tests/**/*.test.ts"
);
out.additionalIncludes = entries;
}
return out;
}
function buildConfig(d: Detected): string {
const opts: string[] = [];
if (d.environment !== "node") opts.push(`environment: "${d.environment}"`);
if (d.timeout !== 5000) opts.push(`testTimeout: ${d.timeout}`);
if (d.hookTimeout !== undefined && d.hookTimeout !== d.timeout)
opts.push(`hookTimeout: ${d.hookTimeout}`);
if (d.fileParallelism === false) opts.push("fileParallelism: false");
if (d.additionalSetupFiles.length > 0) {
opts.push(
`additionalSetupFiles: [${d.additionalSetupFiles.map((s) => `"${s}"`).join(", ")}]`
);
}
if (d.additionalIncludes.length > 0) {
opts.push(
`additionalIncludes: [${d.additionalIncludes.map((s) => `"${s}"`).join(", ")}]`
);
}
const optsBlock = opts.length === 0 ? "" : `{\n\t${opts.join(",\n\t")},\n}`;
const call =
optsBlock === ""
? "createTestConfig()"
: `createTestConfig(${optsBlock})`;
return `import { createTestConfig } from "@suaveplan/testing/config";\n\nexport default ${call};\n`;
}
function ensureTestingDep(pkgJsonPath: string): boolean {
const raw = readFileSync(pkgJsonPath, "utf8");
const pj = JSON.parse(raw);
const dev = pj.devDependencies ?? (pj.devDependencies = {});
if (dev[TESTING_PKG] !== undefined) return false;
dev[TESTING_PKG] = "workspace:^";
const sorted: Record<string, string> = {};
for (const k of Object.keys(dev).sort()) sorted[k] = dev[k];
pj.devDependencies = sorted;
if (!dry) writeFileSync(pkgJsonPath, `${JSON.stringify(pj, null, 2)}\n`);
return true;
}
function migrateOne(pkgDir: string): {
configChanged: boolean;
depAdded: boolean;
polyfillRemoved: boolean;
} {
const result = {
configChanged: false,
depAdded: false,
polyfillRemoved: false,
};
const cfgPath = join(pkgDir, "vitest.config.ts");
const pkgJsonPath = join(pkgDir, "package.json");
if (!existsSync(cfgPath) || !existsSync(pkgJsonPath)) return result;
if (pkgDir === TESTING_OWN_PATH) return result; // do not migrate the testing package itself
const original = readFileSync(cfgPath, "utf8");
const detected = detect(original);
const replacement = buildConfig(detected);
if (original.trim() !== replacement.trim()) {
if (!dry) writeFileSync(cfgPath, replacement);
result.configChanged = true;
}
if (ensureTestingDep(pkgJsonPath)) result.depAdded = true;
const polyfillPath = join(pkgDir, "tests", "setup", "polyfills.ts");
if (existsSync(polyfillPath)) {
if (!dry) rmSync(polyfillPath, { force: true });
result.polyfillRemoved = true;
}
const setupDir = join(pkgDir, "tests", "setup");
if (existsSync(setupDir) && readdirSync(setupDir).length === 0 && !dry) {
try {
rmSync(setupDir, { recursive: true });
} catch {}
}
return result;
}
function main(): void {
const counts = {
configChanged: 0,
depAdded: 0,
polyfillRemoved: 0,
scanned: 0,
};
const skipped: string[] = [];
for (const plugin of readdirSync(packagesRoot)) {
const pluginPath = join(packagesRoot, plugin);
if (!statSync(pluginPath).isDirectory()) continue;
for (const pkg of readdirSync(pluginPath)) {
const pkgPath = join(pluginPath, pkg);
if (!statSync(pkgPath).isDirectory()) continue;
const cfgPath = join(pkgPath, "vitest.config.ts");
if (!existsSync(cfgPath)) {
skipped.push(`${plugin}/${pkg} — no vitest.config.ts`);
continue;
}
counts.scanned += 1;
const r = migrateOne(pkgPath);
if (r.configChanged) counts.configChanged += 1;
if (r.depAdded) counts.depAdded += 1;
if (r.polyfillRemoved) counts.polyfillRemoved += 1;
}
}
console.log(`Mode: ${dry ? "dry-run" : "execute"}`);
console.log(`Scanned vitest configs: ${counts.scanned}`);
console.log(`Config rewrites: ${counts.configChanged}`);
console.log(`Deps added: ${counts.depAdded}`);
console.log(`Polyfill files removed: ${counts.polyfillRemoved}`);
if (skipped.length > 0) {
console.log(`\nSkipped (${skipped.length}):`);
for (const s of skipped.slice(0, 10)) console.log(` ${s}`);
if (skipped.length > 10)
console.log(` ...and ${skipped.length - 10} more`);
}
console.log(dry ? "\n(no changes written)" : "\n✓ migration applied");
}
main();

View File

@@ -0,0 +1,582 @@
#!/usr/bin/env bun
/**
* openspec-conformance — assert that a package's implementation actually
* matches its OpenSpec contract.
*
* The hygiene gates (lint, typecheck, coverage, docs:check) verify that
* something exists and compiles. They cannot tell whether the spec's
* MUST clauses are reflected in code. This script closes that gap with
* five static checks:
*
* 1. **Spec parsing** — every `### CAP-NN —` requirement in the
* relevant spec(s) must carry a `**Verified by:** path::"name"` line.
* 2. **Error codes thrown** — every code in `src/error-codes.ts` is
* thrown from at least one non-test source file.
* 3. **Schemas at boundaries** — every schema exported from
* `src/schemas/` is invoked via `.parse(` / `.safeParse(` at a
* non-test call site.
* 4. **No `: unknown` in public d.ts** — public types must not leak
* `unknown` (often a sign of a generic that wasn't threaded
* through). Limited to the package's own `dist/`, ignoring
* bundled vendor `.d.ts` files.
* 5. **Side-effect code registration** — if `src/error-codes.ts`
* exists, `src/index.ts` MUST side-effect-import it.
* 6. **No retroactive ticking** — `tasks.md` of any change folder
* pointing at this package must not contain the literal string
* "retroactively ticked".
*
* Usage:
* bun scripts/openspec-conformance.ts <pkg-name|pkg-dir>
* bun scripts/openspec-conformance.ts --all
* bun scripts/openspec-conformance.ts --json <pkg>
*
* Exits 0 on full pass, 1 on any failure.
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { basename, join, relative, resolve } from "node:path";
const ROOT = resolve(import.meta.dir, "..");
const ARCHIVE_DIR = join(ROOT, "openspec", "archive");
const CHANGES_DIR = join(ROOT, "openspec", "changes");
type Severity = "error" | "warn" | "info";
interface Finding {
severity: Severity;
rule: string;
message: string;
evidence?: string;
}
interface Report {
pkgName: string;
pkgDir: string;
findings: Finding[];
skipped: string[];
}
// ── Argument parsing ──────────────────────────────────────────────────────────
function parseArgs(): { targets: string[]; json: boolean; all: boolean } {
const args = process.argv.slice(2);
let json = false;
let all = false;
const targets: string[] = [];
for (const a of args) {
if (a === "--json") json = true;
else if (a === "--all") all = true;
else targets.push(a);
}
return { targets, json, all };
}
// ── Package resolution ────────────────────────────────────────────────────────
function findPackageDir(target: string): string {
// Allow absolute / relative path
const asPath = resolve(target);
if (existsSync(join(asPath, "package.json"))) return asPath;
// Strip @suaveplan/ prefix if present
const bare = target.replace(/^@suaveplan\//, "");
const pkgsRoot = join(ROOT, "packages");
const groups = readdirSync(pkgsRoot, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const g of groups) {
const candidate = join(pkgsRoot, g, bare);
if (existsSync(join(candidate, "package.json"))) return candidate;
}
throw new Error(`package not found: ${target}`);
}
function readPkgName(pkgDir: string): string {
const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
return pkg.name as string;
}
function listAllPackages(): string[] {
const pkgsRoot = join(ROOT, "packages");
const out: string[] = [];
for (const g of readdirSync(pkgsRoot, { withFileTypes: true })) {
if (!g.isDirectory()) continue;
const groupDir = join(pkgsRoot, g.name);
for (const p of readdirSync(groupDir, { withFileTypes: true })) {
if (!p.isDirectory()) continue;
if (existsSync(join(groupDir, p.name, "package.json"))) {
out.push(join(groupDir, p.name));
}
}
}
return out;
}
// ── File walker (src/) ────────────────────────────────────────────────────────
function walkSrc(dir: string, out: string[] = []): string[] {
if (!existsSync(dir)) return out;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
// Avoid descending into vendored deps or build outputs that may
// appear under src/ in some packages.
if (
entry.name === "node_modules" ||
entry.name === "dist" ||
entry.name === ".turbo"
)
continue;
const p = join(dir, entry.name);
if (entry.isDirectory()) {
walkSrc(p, out);
} else if (
/\.tsx?$/.test(entry.name) &&
!/\.(test|spec|bench|stories)\.tsx?$/.test(entry.name)
) {
out.push(p);
}
}
return out;
}
// Cache per-package src reads — checkErrorCodes and checkSchemas both want
// the full source list, and readFileSync over many files is the dominant
// per-package cost on `--all` runs.
const srcCache = new Map<string, { path: string; content: string }[]>();
function readAllSrc(pkgDir: string): { path: string; content: string }[] {
const cached = srcCache.get(pkgDir);
if (cached) return cached;
const files = walkSrc(join(pkgDir, "src"));
const out = files.map((f) => ({
path: f,
content: readFileSync(f, "utf8"),
}));
srcCache.set(pkgDir, out);
return out;
}
// ── Check: error codes thrown ─────────────────────────────────────────────────
function checkErrorCodes(pkgDir: string, findings: Finding[]): void {
const codesPath = join(pkgDir, "src", "error-codes.ts");
if (!existsSync(codesPath)) return;
const content = readFileSync(codesPath, "utf8");
const m = content.match(
/defineCodes\(\s*["'][^"']+["']\s*,\s*\[([\s\S]*?)\]\s*as const\s*\)/
);
if (!m) {
findings.push({
severity: "warn",
rule: "error-codes",
message:
"src/error-codes.ts present but defineCodes(...) call not parseable",
evidence: relative(ROOT, codesPath),
});
return;
}
const codeNames = [...(m[1] as string).matchAll(/["']([A-Z0-9_]+)["']/g)]
.map((c) => c[1] as string)
.filter(Boolean);
if (codeNames.length === 0) return;
const sources = readAllSrc(pkgDir).filter(
(s) => !s.path.endsWith("error-codes.ts")
);
for (const code of codeNames) {
const re = new RegExp(`\\b${code}\\b`);
const hit = sources.find((s) => re.test(s.content));
if (!hit) {
findings.push({
severity: "error",
rule: "error-codes-thrown",
message: `error code ${code} is registered but never used in src/`,
});
}
}
}
// ── Check: schemas at boundaries ──────────────────────────────────────────────
function checkSchemas(pkgDir: string, findings: Finding[]): void {
const schemasDir = join(pkgDir, "src", "schemas");
if (!existsSync(schemasDir)) return;
const schemaFiles = walkSrc(schemasDir);
if (schemaFiles.length === 0) return;
const schemaNames: string[] = [];
for (const f of schemaFiles) {
const content = readFileSync(f, "utf8");
for (const m of content.matchAll(
/\bexport\s+const\s+([A-Z]\w*Schema)\b/g
)) {
schemaNames.push(m[1] as string);
}
}
const sources = readAllSrc(pkgDir).filter(
(s) => !s.path.startsWith(schemasDir)
);
for (const name of schemaNames) {
const re = new RegExp(`\\b${name}\\.(safe)?[Pp]arse\\(`);
const hit = sources.find((s) => re.test(s.content));
if (!hit) {
findings.push({
severity: "warn",
rule: "schemas-at-boundaries",
message: `${name} is exported but never invoked outside src/schemas/. If it is consumer-facing only, mark with a "// schema: boundary-only" comment in the schema file to suppress.`,
});
}
}
}
// ── Check: side-effect import in index ────────────────────────────────────────
function checkSideEffectImport(pkgDir: string, findings: Finding[]): void {
const codes = join(pkgDir, "src", "error-codes.ts");
if (!existsSync(codes)) return;
const idx = join(pkgDir, "src", "index.ts");
if (!existsSync(idx)) return;
const content = readFileSync(idx, "utf8");
const sideEffect = /^\s*import\s+["']\.\/error-codes(?:\.js)?["']\s*;?$/m;
if (!sideEffect.test(content)) {
findings.push({
severity: "error",
rule: "side-effect-codes-import",
message:
'src/index.ts must side-effect-import "./error-codes.js" so subpath consumers register codes too. Add: import "./error-codes.js";',
});
}
}
// ── Check: : unknown in public d.ts ───────────────────────────────────────────
function checkPublicUnknown(pkgDir: string, findings: Finding[]): void {
const dist = join(pkgDir, "dist");
if (!existsSync(dist)) {
findings.push({
severity: "info",
rule: "public-unknown",
message:
"dist/ not found — run `bunx turbo build` to enable public-type leakage check",
});
return;
}
const dts: string[] = [];
walkDts(dist, dts);
let leakCount = 0;
const samples: string[] = [];
for (const f of dts) {
// Skip bundled vendor d.ts files (the build pulls in @opentelemetry etc.)
if (f.includes("/node_modules/")) continue;
const lines = readFileSync(f, "utf8").split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i] as string;
// Skip private/protected members and comment lines
if (/^\s*(private|protected|\/\/|\/\*)/.test(line)) continue;
// Match `: unknown` followed by `;`, `,`, `)`, `=`, `|`, end-of-line
// but not as part of `Record<string, unknown>` / `unknown[]`.
// Allow standard ES `Error.cause` shape (`cause?: unknown`),
// which is the spec-mandated type for Error.cause and not
// a sign of generics drift.
if (/\bcause\??\s*:\s*unknown\b/.test(line)) continue;
if (/:\s*unknown\s*[;,)|=]?\s*$/.test(line)) {
leakCount++;
if (samples.length < 5) {
samples.push(
`${relative(ROOT, f)}:${i + 1}: ${line.trim()}`
);
}
}
}
}
if (leakCount > 0) {
findings.push({
severity: "warn",
rule: "public-unknown",
message: `${leakCount} public position(s) in dist/*.d.ts resolve to "unknown". This often means a generic was declared in the spec but not threaded through the public API.`,
evidence: samples.join("\n "),
});
}
}
function walkDts(dir: string, out: string[]): void {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
// Skip vendored deps — they cost the bulk of the wall-clock for
// packages that bundle large dependency graphs (@opentelemetry,
// libsodium, sharp). The leak check filters these on the path
// anyway; pruning here saves the readdirSync recursion.
if (entry.name === "node_modules") continue;
const p = join(dir, entry.name);
if (entry.isDirectory()) {
walkDts(p, out);
} else if (entry.name.endsWith(".d.ts")) {
out.push(p);
}
}
}
// ── Check: spec requirements have Verified-by ────────────────────────────────
function findRelatedChangeFolders(pkgName: string): string[] {
const bare = pkgName.replace(/^@suaveplan\//, "");
const out: string[] = [];
// Authoritative match: a change folder is "related" to this package
// only if its proposal.md declares `**Package**: @suaveplan/<bare>`
// in the frontmatter (first 30 lines). Folder-name matching is
// unreliable — e.g. `enterprise-testing-dom`, `enterprise-ab-testing`,
// and `enterprise-network-testing` all contain the `testing` token
// but belong to different packages/capabilities.
// Match list-item or bare frontmatter line:
// "- **Package**: `@suaveplan/<bare>`"
// "**Package**: @suaveplan/<bare>"
const frontmatterRe = new RegExp(
`(?:^|\\n)\\s*-?\\s*\\*\\*Package\\*\\*:\\s*\`?@suaveplan/${escapeRe(bare)}\`?\\s*(?:$|\\n)`
);
for (const root of [ARCHIVE_DIR, CHANGES_DIR]) {
if (!existsSync(root)) continue;
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const folder = join(root, entry.name);
const proposal = join(folder, "proposal.md");
if (!existsSync(proposal)) continue;
const content = readFileSync(proposal, "utf8");
const head = content.split("\n").slice(0, 30).join("\n");
if (frontmatterRe.test(head)) out.push(folder);
}
}
return out;
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function findRelatedSpecCapabilities(pkgName: string): string[] {
// Map related change folders → capability names referenced in their
// specs/<cap>/ subdirs. The folded source-of-truth specs live under
// `openspec/specs/<cap>/spec.md`.
const folders = findRelatedChangeFolders(pkgName);
const caps = new Set<string>();
for (const folder of folders) {
const specsDir = join(folder, "specs");
if (!existsSync(specsDir)) continue;
for (const cap of readdirSync(specsDir, { withFileTypes: true })) {
if (cap.isDirectory()) caps.add(cap.name);
}
}
// Fallback: package basename as capability name (common pattern).
const bare = pkgName.replace(/^@suaveplan\//, "");
if (existsSync(join(ROOT, "openspec", "specs", bare))) caps.add(bare);
return [...caps];
}
function checkSpecVerifiedBy(
pkgName: string,
pkgDir: string,
findings: Finding[]
): void {
const caps = findRelatedSpecCapabilities(pkgName);
if (caps.length === 0) {
findings.push({
severity: "info",
rule: "spec-verified-by",
message:
"no related OpenSpec capability found; spec-conformance skipped",
});
return;
}
// Authoritative spec sources: the folded `openspec/specs/<cap>/spec.md`
// PLUS any active change in `openspec/changes/<id>/specs/<cap>/spec.md`
// whose proposal.md frontmatter explicitly names THIS package. Active
// changes that touch the same capability but belong to a different
// package (e.g. `enterprise-testing-integration` writes deltas under
// `specs/testing/` while owning a sibling capability) MUST NOT be
// pulled in here — that would force this package's audit to verify
// requirements another change is on the hook for.
const relatedFolders = new Set(findRelatedChangeFolders(pkgName));
const specPaths: string[] = [];
for (const cap of caps) {
const folded = join(ROOT, "openspec", "specs", cap, "spec.md");
if (existsSync(folded)) specPaths.push(folded);
}
for (const folder of relatedFolders) {
if (!folder.startsWith(CHANGES_DIR)) continue;
for (const cap of caps) {
const spec = join(folder, "specs", cap, "spec.md");
if (existsSync(spec)) specPaths.push(spec);
}
}
let totalReqs = 0;
let unverified = 0;
const samples: string[] = [];
for (const spec of specPaths) {
const content = readFileSync(spec, "utf8");
const blocks = splitByHeading(content, /^###\s+(.+?)\s*$/m);
for (const { heading, body } of blocks) {
// Accept two requirement-heading styles:
// "CAP-NN — <name>" (e.g. "I18N-01 — Structured errors")
// "Requirement: <name>" (events / prose style)
const isStructured = /^[A-Z][A-Z0-9]*-\d+\b/.test(heading);
const isProse = /^Requirement:\s+/i.test(heading);
if (!isStructured && !isProse) continue;
totalReqs++;
if (!/\*\*Verified by:\*\*/i.test(body)) {
unverified++;
if (samples.length < 5) {
samples.push(`${relative(ROOT, spec)}: ${heading}`);
}
}
}
}
if (totalReqs === 0) {
findings.push({
severity: "info",
rule: "spec-verified-by",
message:
"no `### CAP-NN —` requirements parsed in folded specs/ or active changes/ for this package",
});
return;
}
if (unverified > 0) {
findings.push({
severity: "error",
rule: "spec-verified-by",
message: `${unverified}/${totalReqs} requirement(s) lack a **Verified by:** line in folded spec or active change. Add a line naming the test that asserts the requirement.`,
evidence: samples.join("\n "),
});
}
void pkgDir;
}
function splitByHeading(
content: string,
re: RegExp
): { heading: string; body: string }[] {
const lines = content.split("\n");
const out: { heading: string; body: string }[] = [];
let current: { heading: string; body: string[] } | null = null;
for (const line of lines) {
const m = line.match(re);
if (m) {
if (current)
out.push({
heading: current.heading,
body: current.body.join("\n"),
});
current = { heading: m[1] as string, body: [] };
} else if (current) {
current.body.push(line);
}
}
if (current)
out.push({
heading: current.heading,
body: current.body.join("\n"),
});
return out;
}
// ── Check: no retroactive ticking ─────────────────────────────────────────────
function checkNoRetroactiveTicking(pkgName: string, findings: Finding[]): void {
const bare = pkgName.replace(/^@suaveplan\//, "");
const folders = findRelatedChangeFolders(pkgName);
// A *-spec-conformance change (active or archived) rectifies prior drift.
const hasRectification = folders.some((f) =>
/spec-conformance|conformance/.test(basename(f))
);
for (const folder of folders) {
const tasks = join(folder, "tasks.md");
if (!existsSync(tasks)) continue;
const content = readFileSync(tasks, "utf8");
if (!/retroactively ticked/i.test(content)) continue;
const isHistorical = folder.startsWith(ARCHIVE_DIR);
if (isHistorical && hasRectification) {
findings.push({
severity: "info",
rule: "no-retroactive-ticking",
message: `${relative(ROOT, tasks)} was retroactively ticked, but a *-spec-conformance change has rectified it.`,
});
} else {
findings.push({
severity: "error",
rule: "no-retroactive-ticking",
message: `${relative(ROOT, tasks)} contains the phrase "retroactively ticked" — open a ${bare}-spec-conformance change before more migrations.`,
});
}
}
}
// ── Run ───────────────────────────────────────────────────────────────────────
function runOne(pkgDir: string): Report {
const pkgName = readPkgName(pkgDir);
const findings: Finding[] = [];
const skipped: string[] = [];
checkErrorCodes(pkgDir, findings);
checkSchemas(pkgDir, findings);
checkSideEffectImport(pkgDir, findings);
checkPublicUnknown(pkgDir, findings);
checkSpecVerifiedBy(pkgName, pkgDir, findings);
checkNoRetroactiveTicking(pkgName, findings);
return { pkgName, pkgDir, findings, skipped };
}
function printReport(report: Report): boolean {
const errors = report.findings.filter((f) => f.severity === "error");
const warns = report.findings.filter((f) => f.severity === "warn");
const infos = report.findings.filter((f) => f.severity === "info");
const status = errors.length === 0 ? "PASS" : "FAIL";
const summary = `${status} ${report.pkgName} (${errors.length} error, ${warns.length} warn, ${infos.length} info)`;
process.stdout.write(`${summary}\n`);
for (const f of report.findings) {
const tag =
f.severity === "error" ? "✖" : f.severity === "warn" ? "!" : "·";
process.stdout.write(` ${tag} [${f.rule}] ${f.message}\n`);
if (f.evidence) {
for (const line of f.evidence.split("\n")) {
process.stdout.write(` ${line}\n`);
}
}
}
return errors.length === 0;
}
function main(): void {
const { targets, json, all } = parseArgs();
const dirs = all
? listAllPackages()
: targets.map((t) => findPackageDir(t));
if (dirs.length === 0) {
process.stderr.write(
"usage: bun scripts/openspec-conformance.ts <pkg-name|pkg-dir> [--json] | --all\n"
);
process.exit(2);
}
// Stream per-package: run + print as we go so `--all` produces output
// immediately instead of buffering until every package has been scanned.
const reports: Report[] = [];
let failedCount = 0;
for (const dir of dirs) {
const report = runOne(dir);
reports.push(report);
if (!json) {
printReport(report);
}
if (report.findings.some((f) => f.severity === "error")) {
failedCount++;
}
}
if (json) {
process.stdout.write(`${JSON.stringify(reports, null, 2)}\n`);
}
if (!json && all) {
process.stdout.write(
`\n=== ${reports.length - failedCount}/${reports.length} packages pass ===\n`
);
}
process.exit(failedCount === 0 ? 0 : 1);
}
main();

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bun
/**
* Fails if any unarchived openspec/changes/STAR/tasks.md is missing the
* canonical pre-flight section (Section 0). Run from repo root.
*
* Exit codes: 0 = all clean, 1 = at least one tasks.md missing the section.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
const CHANGES_DIR = "openspec/changes";
const REQUIRED_LINE_PATTERNS: readonly RegExp[] = [
/Pre-flight \(non-negotiable\)/,
/git worktree list/,
/\.claude\/worktrees/,
];
function findTasksMd(): readonly string[] {
const entries: string[] = [];
for (const name of readdirSync(CHANGES_DIR)) {
const dir = join(CHANGES_DIR, name);
if (!statSync(dir).isDirectory()) continue;
const tasks = join(dir, "tasks.md");
try {
statSync(tasks);
entries.push(tasks);
} catch {
// tasks.md may not exist for stub change folders; skip
}
}
return entries;
}
function check(path: string): readonly string[] {
const text = readFileSync(path, "utf8");
const missing: string[] = [];
for (const pattern of REQUIRED_LINE_PATTERNS) {
if (!pattern.test(text)) missing.push(pattern.source);
}
return missing;
}
function main(): void {
const tasksFiles = findTasksMd();
let failed = 0;
for (const file of tasksFiles) {
const missing = check(file);
if (missing.length > 0) {
console.error(`FAIL ${file}`);
for (const m of missing) {
console.error(` missing pattern /${m}/`);
}
failed++;
}
}
if (failed > 0) {
console.error(
`\n${failed} of ${tasksFiles.length} unarchived change folders missing the pre-flight section.`
);
console.error(
"Restore Section 0 (Pre-flight) from openspec/templates/tasks.md."
);
process.exit(1);
}
console.log(
`OK ${tasksFiles.length} unarchived change folders carry the pre-flight section.`
);
}
main();

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Convert barrels in a package, biome-fix, then run lint+typecheck+test.
# Returns 0 on green, 1 on regression (test or lint failure).
# Pre-existing coverage / build / bare-config issues outside the touched
# barrel surface are reported but not treated as regressions of this change.
#
# Usage: bash scripts/process-package.sh <pkg-rel-path> <pkg-name>
# e.g. bash scripts/process-package.sh packages/crypto/crypto-cipher @suaveplan/crypto-cipher
set -euo pipefail
PKG_DIR="${1:?usage: $0 <pkg-rel-path> <pkg-name>}"
PKG_NAME="${2:?usage: $0 <pkg-rel-path> <pkg-name>}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
bash "${ROOT}/scripts/convert-package-barrels.sh" "${PKG_DIR}" 2>&1 | tail -5
# Belt-and-braces biome pass — convert-package-barrels.sh sometimes leaves
# format residue when biome's organize-imports surfaces fresh format work.
cd "${ROOT}/${PKG_DIR}"
for i in 1 2 3; do
bunx biome check --write --max-diagnostics 1000 src/ 2>&1 | tail -1
done
cd "${ROOT}"
echo "--- gate: ${PKG_NAME}"
if bunx turbo lint typecheck test --filter="${PKG_NAME}" 2>&1 | tail -5; then
echo "GREEN: ${PKG_NAME}"
exit 0
else
echo "RED: ${PKG_NAME}"
exit 1
fi

View File

@@ -0,0 +1,840 @@
#!/usr/bin/env bun
/**
* Publish script for Verdaccio registry
*
* This script publishes packages without modifying source files:
* 1. Queries Verdaccio to check published versions
* 2. Compares local vs published versions
* 3. Copies package.json + README.md + documentation into dist/
* 4. Resolves workspace:* and catalog:* references to actual versions
* 5. Adjusts paths (removes dist/ prefix)
* 6. Publishes from dist/ (only if version changed or forced)
* 7. Cleans up
*
* Usage:
* bun scripts/publish-to-verdaccio.ts
* bun scripts/publish-to-verdaccio.ts --dry-run
* bun scripts/publish-to-verdaccio.ts --package @suaveplan/utils
* bun scripts/publish-to-verdaccio.ts --force
* bun scripts/publish-to-verdaccio.ts --force=@suaveplan/utils,@suaveplan/crypto
*/
import { createHash } from "node:crypto";
import {
cp,
mkdir,
readdir,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { join, relative } from "node:path";
import { $ } from "bun";
import {
CatalogResolver,
type ExportsField,
type PackageJson,
} from "./lib/catalog-resolver.js";
import { CONFIG } from "./lib/config.js";
import { discoverPackageDirs } from "./lib/discover-packages.js";
const REGISTRY = CONFIG.registry.verdaccio.url;
const REGISTRY_WEB = CONFIG.registry.verdaccio.webUrl;
const ROOT_DIR = CONFIG.paths.root;
// CLI options
interface CliOptions {
dryRun: boolean;
forcePackages: Set<string>;
forceAll: boolean;
specificPackage?: string;
}
/**
* Parse CLI arguments
*
* Supports:
* - --dry-run: Preview changes without publishing
* - --force: Force publish all packages with version increment
* - --force=pkg1,pkg2: Force publish specific packages with version increment
* - --package=name: Publish only specific package
*/
function parseArgs(): CliOptions {
const args = process.argv.slice(2);
const options: CliOptions = {
dryRun: false,
forcePackages: new Set(),
forceAll: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--dry-run") {
options.dryRun = true;
} else if (arg === "--force") {
options.forceAll = true;
} else if (arg.startsWith("--force=")) {
const packages = arg.slice(8).split(",");
for (const pkg of packages) {
options.forcePackages.add(pkg.trim());
}
} else if (arg.startsWith("--package=")) {
options.specificPackage = arg.slice(10);
} else if (arg === "--package" && i + 1 < args.length) {
// Support both --package=name and --package name
options.specificPackage = args[i + 1];
i++; // Skip next arg since we consumed it
}
}
return options;
}
/**
* Query Verdaccio for published version of a package
*
* @param packageName - Full package name (e.g., '@suaveplan/utils')
* @returns Latest published version, or null if never published
*/
async function getPublishedVersion(
packageName: string
): Promise<string | null> {
try {
const response = await fetch(`${REGISTRY}${packageName}`);
if (response.status === 404) {
return null; // Package never published
}
if (!response.ok) {
console.warn(
` Warning: Failed to fetch ${packageName} from registry: ${response.status}`
);
return null;
}
const data = await response.json();
return data["dist-tags"]?.latest || null;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.warn(` Warning: Error fetching ${packageName}: ${message}`);
return null;
}
}
/**
* Simple semver comparison
*
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
*/
function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split(".").map(Number);
const parts2 = v2.split(".").map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const num1 = parts1[i] || 0;
const num2 = parts2[i] || 0;
if (num1 > num2) return 1;
if (num1 < num2) return -1;
}
return 0;
}
/**
* Increment patch version (1.2.3 -> 1.2.4)
*/
function incrementVersion(version: string): string {
const parts = version.split(".");
if (parts.length !== 3) {
throw new Error(`Invalid version format: ${version}`);
}
parts[2] = String(Number(parts[2]) + 1);
return parts.join(".");
}
/**
* Update package.json version in source (for --force)
*/
async function updatePackageVersion(
pkgJsonPath: string,
newVersion: string
): Promise<void> {
const content = await readFile(pkgJsonPath, "utf-8");
const pkg = JSON.parse(content);
pkg.version = newVersion;
await writeFile(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
}
/**
* Get all package directories from the configured packages root.
*/
async function getPackageDirs(): Promise<string[]> {
return discoverPackageDirs();
}
/**
* Transform package.json for publishing from dist/
*
* Removes dist/ prefix from paths since we're publishing from within dist/
*/
function transformPackageJson(
pkg: PackageJson,
publishVersion: string,
resolver: CatalogResolver
): PackageJson {
const transformed = { ...pkg };
// Update version to the one we're publishing
transformed.version = publishVersion;
// Resolve workspace/catalog references in dependencies
transformed.dependencies = resolver.resolveDependencies(
pkg.dependencies,
publishVersion
);
transformed.peerDependencies = resolver.resolveDependencies(
pkg.peerDependencies,
publishVersion
);
// Remove devDependencies - not needed for published package
delete transformed.devDependencies;
// Remove scripts - not needed for published package
delete transformed.scripts;
// Remove files field - we're publishing the whole dist folder
delete transformed.files;
// Fix exports paths - remove "dist/" prefix since we're in dist/
if (transformed.exports) {
transformed.exports = fixPaths(transformed.exports);
}
// Fix main/module/types paths
if (transformed.main) {
transformed.main = transformed.main.replace(/^\.\/dist\//, "./");
}
if (transformed.module) {
transformed.module = transformed.module.replace(/^\.\/dist\//, "./");
}
if (transformed.types) {
transformed.types = transformed.types.replace(/^\.\/dist\//, "./");
}
return transformed;
}
/**
* Transform overview README.md links from relative .md to registry URLs
*/
function transformOverviewReadme(content: string): string {
// Transform links like [text](./package-name.md) to [text](https://npmjs.fq.io/-/web/detail/@suaveplan/package-name)
return content.replace(
/\[([^\]]+)\]\(\.\/([^)]+)\.md\)/g,
(_, text, packageName) =>
`[${text}](${REGISTRY_WEB}/@suaveplan/${packageName})`
);
}
/**
* Recursively fix paths in exports object
*/
function fixPaths(obj: ExportsField): ExportsField {
if (typeof obj === "string") {
return obj.replace(/^\.\/dist\//, "./");
}
if (Array.isArray(obj)) {
return obj.map(fixPaths);
}
if (typeof obj === "object" && obj !== null) {
const fixed: Record<string, ExportsField> = {};
for (const [key, value] of Object.entries(obj)) {
fixed[key] = fixPaths(value);
}
return fixed;
}
return obj;
}
/**
* Recursively find all .md files in a directory
*/
async function findMarkdownFiles(dir: string): Promise<string[]> {
const results: string[] = [];
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (
entry.isDirectory() &&
entry.name !== "node_modules" &&
entry.name !== "dist"
) {
results.push(...(await findMarkdownFiles(fullPath)));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
results.push(fullPath);
}
}
} catch {
// Directory doesn't exist or isn't readable
}
return results;
}
/**
* Copy all documentation files into dist/ for publishing
*
* Copies:
* - Root-level .md files (README.md, SECURITY.md, TODO.md, etc.)
* - docs/ directory (preserving structure)
* - src/*.md files (co-located documentation, preserving structure)
* - schemas/*.md files outside src/ (preserving structure)
*
* @returns List of paths copied into dist/ (for cleanup)
*/
async function copyDocsToDistDir(
pkgDir: string,
distDir: string
): Promise<string[]> {
const copiedPaths: string[] = [];
// 1. Copy all root-level .md files
try {
const rootEntries = await readdir(pkgDir, { withFileTypes: true });
for (const entry of rootEntries) {
if (entry.isFile() && entry.name.endsWith(".md")) {
const src = join(pkgDir, entry.name);
const dest = join(distDir, entry.name);
await cp(src, dest, { force: true });
copiedPaths.push(dest);
}
}
} catch {
// Skip if we can't read the directory
}
// 2. Copy docs/ directory if it exists
const docsDir = join(pkgDir, "docs");
try {
const docsStat = await stat(docsDir);
if (docsStat.isDirectory()) {
const destDocs = join(distDir, "docs");
await cp(docsDir, destDocs, { recursive: true, force: true });
copiedPaths.push(destDocs);
}
} catch {
// No docs/ directory
}
// 3. Copy .md files from src/ (preserving directory structure)
const srcDir = join(pkgDir, "src");
const srcMdFiles = await findMarkdownFiles(srcDir);
if (srcMdFiles.length > 0) {
const destSrcDocs = join(distDir, "src-docs");
for (const mdFile of srcMdFiles) {
const relPath = relative(srcDir, mdFile);
const destPath = join(destSrcDocs, relPath);
const destParent = join(destPath, "..");
await $`mkdir -p ${destParent}`.quiet().nothrow();
await cp(mdFile, destPath, { force: true });
}
copiedPaths.push(destSrcDocs);
}
// 4. Copy schemas/ directory .md files if it exists outside src/
const schemasDir = join(pkgDir, "schemas");
try {
const schemasStat = await stat(schemasDir);
if (schemasStat.isDirectory()) {
const schemaMdFiles = await findMarkdownFiles(schemasDir);
if (schemaMdFiles.length > 0) {
const destSchemas = join(distDir, "schemas");
for (const mdFile of schemaMdFiles) {
const relPath = relative(schemasDir, mdFile);
const destPath = join(destSchemas, relPath);
const destParent = join(destPath, "..");
await $`mkdir -p ${destParent}`.quiet().nothrow();
await cp(mdFile, destPath, { force: true });
}
copiedPaths.push(destSchemas);
}
}
} catch {
// No schemas/ directory
}
return copiedPaths;
}
/**
* Generate SLSA provenance for a successfully published package
*/
async function generatePublishProvenance(
packageName: string,
version: string,
distDir: string
): Promise<void> {
try {
// Compute digest of the dist directory's package.json (representing the published artifact)
const distPkgPath = join(distDir, "package.json");
let artifactDigest: string;
try {
const content = await readFile(distPkgPath);
artifactDigest = createHash("sha256").update(content).digest("hex");
} catch {
// dist/package.json already cleaned up, use package name as fallback
artifactDigest = createHash("sha256")
.update(`${packageName}@${version}`)
.digest("hex");
}
// Read Git info
const commitSha = (await $`git rev-parse HEAD`.text()).trim();
const refName = (
await $`git rev-parse --abbrev-ref HEAD`.text()
).trim();
const projectUrl =
process.env.CI_PROJECT_URL ??
"https://gitlab.fq.io/suaveplan/monorepo";
const runnerId = process.env.CI_RUNNER_ID ?? "local";
const jobId = process.env.CI_JOB_ID ?? `local-${Date.now()}`;
const pipelineId = process.env.CI_PIPELINE_ID ?? "local";
const isCI = !!(process.env.CI || process.env.GITLAB_CI);
const encodedName = packageName.replace("@", "%40");
const statement = {
_type: "https://in-toto.io/Statement/v1",
subject: [
{
uri: `pkg:npm/${encodedName}@${version}`,
digest: { sha256: artifactDigest },
name: packageName,
},
],
predicateType: "https://slsa.dev/provenance/v1",
predicate: {
buildDefinition: {
buildType:
"https://suaveplan.app/slsa/build-type/bun-monorepo/v1",
externalParameters: {
repository: projectUrl,
ref: `refs/heads/${refName}`,
},
internalParameters: {
environment: isCI ? "gitlab-ci" : "local",
},
resolvedDependencies: [
{
uri: `git+${projectUrl}@refs/heads/${refName}`,
digest: { sha256: commitSha },
name: "source",
},
],
},
runDetails: {
builder: {
id: `${projectUrl}/-/runners/${runnerId}`,
version: { bun: Bun.version },
},
metadata: {
invocationId: `${projectUrl}/-/pipelines/${pipelineId}/jobs/${jobId}`,
startedOn:
process.env.CI_JOB_STARTED_AT ??
new Date().toISOString(),
finishedOn: new Date().toISOString(),
},
},
},
};
const provenanceDir = join(ROOT_DIR, "provenance");
try {
await stat(provenanceDir);
} catch {
await mkdir(provenanceDir, { recursive: true });
}
const safeName = packageName.replace(/[/@]/g, "_");
const filename = `${safeName}-${version}.provenance.json`;
await writeFile(
join(provenanceDir, filename),
JSON.stringify(statement, null, 2)
);
} catch {
// Provenance generation failure should not block publishing
console.warn(
` Warning: Failed to generate provenance for ${packageName}`
);
}
}
interface PublishResult {
name: string;
success: boolean;
error?: string;
localVersion?: string;
publishedVersion?: string | null;
action?: "published" | "skipped" | "updated" | "failed";
}
/**
* Publish a single package to Verdaccio
*
* @param pkgDir - Absolute path to package directory
* @param options - CLI options (dry-run, force, etc.)
* @param resolver - CatalogResolver instance for dependency resolution
* @returns PublishResult with status and metadata
*/
async function publishPackage(
pkgDir: string,
options: CliOptions,
resolver: CatalogResolver
): Promise<PublishResult> {
const pkgJsonPath = join(pkgDir, "package.json");
const distDir = join(pkgDir, CONFIG.build.distDir);
try {
// Read original package.json
const pkgContent = await readFile(pkgJsonPath, "utf-8");
const pkg: PackageJson = JSON.parse(pkgContent);
// Skip private packages
if (pkg.private) {
return {
name: pkg.name!,
success: true,
error: "private package",
action: "skipped",
};
}
// Check if dist exists
try {
await stat(distDir);
} catch {
return {
name: pkg.name!,
success: false,
error: "no dist folder",
action: "failed",
};
}
const localVersion = pkg.version;
if (!localVersion) {
return {
name: pkg.name!,
success: false,
error: "no version in package.json",
action: "failed",
};
}
// Query Verdaccio for published version
const publishedVersion = await getPublishedVersion(pkg.name!);
// Determine if we should publish
let _shouldPublish = false;
let versionToPublish = localVersion;
let wasForceIncremented = false;
// Check if force is enabled for this package
const isForced =
options.forceAll || options.forcePackages.has(pkg.name!);
if (publishedVersion === null) {
// Never published before
_shouldPublish = true;
} else {
const comparison = compareVersions(localVersion, publishedVersion);
if (comparison > 0) {
// Local version is newer
_shouldPublish = true;
} else if (comparison === 0) {
// Same version - check if force flag is set for this package
if (isForced) {
// Increment version and update source package.json
versionToPublish = incrementVersion(localVersion);
if (!options.dryRun) {
await updatePackageVersion(
pkgJsonPath,
versionToPublish
);
}
_shouldPublish = true;
wasForceIncremented = true;
} else {
// Skip - same version
return {
name: pkg.name!,
success: true,
localVersion,
publishedVersion,
error: "same version",
action: "skipped",
};
}
} else {
// Local version is older than published
if (isForced) {
// Force enabled: increment published version and update local
versionToPublish = incrementVersion(publishedVersion);
if (!options.dryRun) {
await updatePackageVersion(
pkgJsonPath,
versionToPublish
);
}
_shouldPublish = true;
wasForceIncremented = true;
} else {
// Force not enabled: this is an error
return {
name: pkg.name!,
success: false,
localVersion,
publishedVersion,
error: "local version < published version",
action: "failed",
};
}
}
}
// If dry run, stop here
if (options.dryRun) {
return {
name: pkg.name!,
success: true,
localVersion: versionToPublish,
publishedVersion,
error: wasForceIncremented
? "would increment and publish"
: "would publish",
action: "skipped",
};
}
// Transform package.json with the version we're publishing
const transformedPkg = transformPackageJson(
pkg,
versionToPublish,
resolver
);
// Write transformed package.json to dist/
await writeFile(
join(distDir, "package.json"),
JSON.stringify(transformedPkg, null, 2)
);
// Copy all documentation into dist/ for publishing
const copiedDocPaths = await copyDocsToDistDir(pkgDir, distDir);
// Special handling for overview package README links
try {
const distReadme = join(distDir, "README.md");
const readmeContent = await readFile(distReadme, "utf-8");
if (pkg.name === "@suaveplan/overview") {
await writeFile(
distReadme,
transformOverviewReadme(readmeContent)
);
}
} catch {
// No README, that's ok
}
// Publish from dist/ using npm (more reliable than bun for this)
// Note: We use --userconfig to ensure auth token from root .npmrc is used
// (writing package.json to dist/ makes npm treat it as project root, stopping .npmrc walk-up)
const npmrcPath = join(ROOT_DIR, ".npmrc");
const result =
await $`npm publish --registry ${REGISTRY} --userconfig ${npmrcPath}`
.cwd(distDir)
.quiet()
.nothrow();
// Clean up - remove package.json and documentation from dist
await rm(join(distDir, "package.json")).catch(() => {});
for (const docPath of copiedDocPaths) {
await rm(docPath, { recursive: true }).catch(() => {});
}
if (result.exitCode !== 0) {
const stderr = result.stderr.toString();
const stdout = result.stdout.toString();
const output = stderr + stdout;
if (
output.includes("already exists") ||
output.includes("cannot publish over") ||
output.includes("E409") ||
output.includes("already present")
) {
return {
name: pkg.name!,
success: true,
localVersion: versionToPublish,
publishedVersion,
error: "already published",
action: "skipped",
};
}
// Filter out npm notice lines to surface real error
const errorLines = stderr
.split("\n")
.filter((line) => !line.startsWith("npm notice"))
.join("\n")
.trim();
return {
name: pkg.name!,
success: false,
localVersion: versionToPublish,
publishedVersion,
error: errorLines || stderr.slice(0, 500),
action: "failed",
};
}
// Generate SLSA provenance for the published artifact
await generatePublishProvenance(pkg.name!, versionToPublish, distDir);
return {
name: pkg.name!,
success: true,
localVersion: versionToPublish,
publishedVersion,
action: wasForceIncremented ? "updated" : "published",
};
} catch (err: unknown) {
return {
name: pkgDir,
success: false,
error: err instanceof Error ? err.message : String(err),
action: "failed",
};
}
}
async function main() {
const options = parseArgs();
console.log("=".repeat(70));
console.log(`Publishing to Verdaccio: ${REGISTRY}`);
if (options.dryRun) {
console.log("🔍 DRY RUN MODE - No actual publishing will occur");
}
if (options.specificPackage) {
console.log(`📦 Publishing only: ${options.specificPackage}`);
}
if (options.forceAll) {
console.log(
"⚡ Force publishing ALL packages (with version increment)"
);
} else if (options.forcePackages.size > 0) {
console.log(
`⚡ Force publishing (with version increment): ${Array.from(options.forcePackages).join(", ")}`
);
}
console.log("=".repeat(70));
console.log();
// Load catalogs
const resolver = await CatalogResolver.create();
console.log(`Loaded ${resolver.getCatalogCount()} catalogs`);
console.log();
let packageDirs = await getPackageDirs();
// Filter to specific package if requested
if (options.specificPackage) {
const filteredDirs: string[] = [];
for (const dir of packageDirs) {
const pkgJsonPath = join(dir, "package.json");
try {
const pkg: PackageJson = JSON.parse(
await readFile(pkgJsonPath, "utf-8")
);
if (pkg.name === options.specificPackage) {
filteredDirs.push(dir);
}
} catch {
// Skip packages with invalid JSON
}
}
packageDirs = filteredDirs;
}
console.log(`Found ${packageDirs.length} packages\n`);
let publishedCount = 0;
let updatedCount = 0;
let skipCount = 0;
let failCount = 0;
for (const pkgDir of packageDirs) {
const result = await publishPackage(pkgDir, options, resolver);
// Format output based on result
const versionInfo = result.localVersion
? `${result.localVersion}${result.publishedVersion !== null ? ` (was: ${result.publishedVersion})` : " (new)"}`
: "";
switch (result.action) {
case "published":
console.log(`✅ ${result.name}@${versionInfo}`);
publishedCount++;
break;
case "updated":
console.log(
`🔄 ${result.name}@${versionInfo} (force incremented)`
);
updatedCount++;
break;
case "skipped":
if (result.error === "same version") {
console.log(
`⏭️ ${result.name}@${versionInfo} - up to date`
);
} else if (result.error?.includes("would")) {
console.log(
`🔍 ${result.name}@${versionInfo} - ${result.error}`
);
} else {
console.log(`⏭️ ${result.name} - ${result.error}`);
}
skipCount++;
break;
case "failed":
console.log(
`❌ ${result.name}@${versionInfo || "?"} - ${result.error}`
);
failCount++;
break;
default:
console.log(`❓ ${result.name} - unknown status`);
}
}
console.log();
console.log("=".repeat(70));
console.log(
`Published: ${publishedCount} | Updated: ${updatedCount} | Skipped: ${skipCount} | Failed: ${failCount}`
);
if (options.dryRun) {
console.log("🔍 DRY RUN - No changes were made");
}
console.log("=".repeat(70));
// Exit with standardized code
if (failCount > 0) {
process.exit(CONFIG.exitCodes.failure);
}
process.exit(CONFIG.exitCodes.success);
}
main().catch(console.error);

842
scripts/publish.ts Normal file
View File

@@ -0,0 +1,842 @@
#!/usr/bin/env bun
/**
* Publish script for Verdaccio registry
*
* This script publishes packages without modifying source files:
* 1. Queries Verdaccio to check published versions
* 2. Compares local vs published versions
* 3. Copies package.json + README.md + documentation into dist/
* 4. Resolves workspace:* and catalog:* references to actual versions
* 5. Adjusts paths (removes dist/ prefix)
* 6. Publishes from dist/ (only if version changed or forced)
* 7. Cleans up
*
* Usage:
* bun scripts/publish.ts
* bun scripts/publish.ts --dry-run
* bun scripts/publish.ts --package @suaveplan/utils
* bun scripts/publish.ts --force
* bun scripts/publish.ts --force=@suaveplan/utils,@suaveplan/crypto
*/
import { createHash } from "node:crypto";
import {
cp,
mkdir,
readdir,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { join, relative } from "node:path";
import { $ } from "bun";
import {
CatalogResolver,
type ExportsField,
type PackageJson,
} from "./lib/catalog-resolver.js";
import { CONFIG } from "./lib/config.js";
import { discoverPackageDirs } from "./lib/discover-packages.js";
const REGISTRY = CONFIG.registry.verdaccio.url;
const REGISTRY_WEB = CONFIG.registry.verdaccio.webUrl;
const ROOT_DIR = CONFIG.paths.root;
// CLI options
interface CliOptions {
dryRun: boolean;
forcePackages: Set<string>;
forceAll: boolean;
specificPackage?: string;
}
/**
* Parse CLI arguments
*
* Supports:
* - --dry-run: Preview changes without publishing
* - --force: Force publish all packages with version increment
* - --force=pkg1,pkg2: Force publish specific packages with version increment
* - --package=name: Publish only specific package
*/
function parseArgs(): CliOptions {
const args = process.argv.slice(2);
const options: CliOptions = {
dryRun: false,
forcePackages: new Set(),
forceAll: false,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--dry-run") {
options.dryRun = true;
} else if (arg === "--force") {
options.forceAll = true;
} else if (arg.startsWith("--force=")) {
const packages = arg.slice(8).split(",");
for (const pkg of packages) {
options.forcePackages.add(pkg.trim());
}
} else if (arg.startsWith("--package=")) {
options.specificPackage = arg.slice(10);
} else if (arg === "--package" && i + 1 < args.length) {
// Support both --package=name and --package name
options.specificPackage = args[i + 1];
i++; // Skip next arg since we consumed it
}
}
return options;
}
/**
* Query Verdaccio for published version of a package
*
* @param packageName - Full package name (e.g., '@suaveplan/utils')
* @returns Latest published version, or null if never published
*/
async function getPublishedVersion(
packageName: string
): Promise<string | null> {
try {
const response = await fetch(`${REGISTRY}${packageName}`);
if (response.status === 404) {
return null; // Package never published
}
if (!response.ok) {
console.warn(
` Warning: Failed to fetch ${packageName} from registry: ${response.status}`
);
return null;
}
const data = (await response.json()) as {
"dist-tags"?: { latest?: string };
};
return data["dist-tags"]?.latest || null;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(` Warning: Error fetching ${packageName}: ${message}`);
return null;
}
}
/**
* Simple semver comparison
*
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
*/
function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split(".").map(Number);
const parts2 = v2.split(".").map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const num1 = parts1[i] || 0;
const num2 = parts2[i] || 0;
if (num1 > num2) return 1;
if (num1 < num2) return -1;
}
return 0;
}
/**
* Increment patch version (1.2.3 -> 1.2.4)
*/
function incrementVersion(version: string): string {
const parts = version.split(".");
if (parts.length !== 3) {
throw new Error(`Invalid version format: ${version}`);
}
parts[2] = String(Number(parts[2]) + 1);
return parts.join(".");
}
/**
* Update package.json version in source (for --force)
*/
async function updatePackageVersion(
pkgJsonPath: string,
newVersion: string
): Promise<void> {
const content = await readFile(pkgJsonPath, "utf-8");
const pkg = JSON.parse(content);
pkg.version = newVersion;
await writeFile(pkgJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
}
/**
* Get all package directories from the configured packages root.
*/
async function getPackageDirs(): Promise<string[]> {
return discoverPackageDirs();
}
/**
* Transform package.json for publishing from dist/
*
* Removes dist/ prefix from paths since we're publishing from within dist/
*/
function transformPackageJson(
pkg: PackageJson,
publishVersion: string,
resolver: CatalogResolver
): PackageJson {
const transformed = { ...pkg };
// Update version to the one we're publishing
transformed.version = publishVersion;
// Resolve workspace/catalog references in dependencies
transformed.dependencies = resolver.resolveDependencies(
pkg.dependencies,
publishVersion
);
transformed.peerDependencies = resolver.resolveDependencies(
pkg.peerDependencies,
publishVersion
);
// Remove devDependencies - not needed for published package
delete transformed.devDependencies;
// Remove scripts - not needed for published package
delete transformed.scripts;
// Remove files field - we're publishing the whole dist folder
delete transformed.files;
// Fix exports paths - remove "dist/" prefix since we're in dist/
if (transformed.exports) {
transformed.exports = fixPaths(transformed.exports);
}
// Fix main/module/types paths
if (transformed.main) {
transformed.main = transformed.main.replace(/^\.\/dist\//, "./");
}
if (transformed.module) {
transformed.module = transformed.module.replace(/^\.\/dist\//, "./");
}
if (transformed.types) {
transformed.types = transformed.types.replace(/^\.\/dist\//, "./");
}
return transformed;
}
/**
* Transform overview README.md links from relative .md to registry URLs
*/
function transformOverviewReadme(content: string): string {
// Transform links like [text](./package-name.md) to [text](https://npmjs.fq.io/-/web/detail/@suaveplan/package-name)
return content.replace(
/\[([^\]]+)\]\(\.\/([^)]+)\.md\)/g,
(_, text, packageName) =>
`[${text}](${REGISTRY_WEB}/@suaveplan/${packageName})`
);
}
/**
* Recursively fix paths in exports object
*/
function fixPaths<T extends ExportsField>(obj: T): T {
if (typeof obj === "string") {
return obj.replace(/^\.\/dist\//, "./") as T;
}
if (Array.isArray(obj)) {
return obj.map((item) => fixPaths(item)) as T;
}
if (typeof obj === "object" && obj !== null) {
const fixed: Record<string, ExportsField> = {};
for (const [key, value] of Object.entries(obj)) {
fixed[key] = fixPaths(value);
}
return fixed as T;
}
return obj;
}
/**
* Recursively find all .md files in a directory
*/
async function findMarkdownFiles(dir: string): Promise<string[]> {
const results: string[] = [];
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (
entry.isDirectory() &&
entry.name !== "node_modules" &&
entry.name !== "dist"
) {
results.push(...(await findMarkdownFiles(fullPath)));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
results.push(fullPath);
}
}
} catch {
// Directory doesn't exist or isn't readable
}
return results;
}
/**
* Copy all documentation files into dist/ for publishing
*
* Copies:
* - Root-level .md files (README.md, SECURITY.md, TODO.md, etc.)
* - docs/ directory (preserving structure)
* - src/*.md files (co-located documentation, preserving structure)
* - schemas/*.md files outside src/ (preserving structure)
*
* @returns List of paths copied into dist/ (for cleanup)
*/
async function copyDocsToDistDir(
pkgDir: string,
distDir: string
): Promise<string[]> {
const copiedPaths: string[] = [];
// 1. Copy all root-level .md files
try {
const rootEntries = await readdir(pkgDir, { withFileTypes: true });
for (const entry of rootEntries) {
if (entry.isFile() && entry.name.endsWith(".md")) {
const src = join(pkgDir, entry.name);
const dest = join(distDir, entry.name);
await cp(src, dest, { force: true });
copiedPaths.push(dest);
}
}
} catch {
// Skip if we can't read the directory
}
// 2. Copy docs/ directory if it exists
const docsDir = join(pkgDir, "docs");
try {
const docsStat = await stat(docsDir);
if (docsStat.isDirectory()) {
const destDocs = join(distDir, "docs");
await cp(docsDir, destDocs, { recursive: true, force: true });
copiedPaths.push(destDocs);
}
} catch {
// No docs/ directory
}
// 3. Copy .md files from src/ (preserving directory structure)
const srcDir = join(pkgDir, "src");
const srcMdFiles = await findMarkdownFiles(srcDir);
if (srcMdFiles.length > 0) {
const destSrcDocs = join(distDir, "src-docs");
for (const mdFile of srcMdFiles) {
const relPath = relative(srcDir, mdFile);
const destPath = join(destSrcDocs, relPath);
const destParent = join(destPath, "..");
await $`mkdir -p ${destParent}`.quiet().nothrow();
await cp(mdFile, destPath, { force: true });
}
copiedPaths.push(destSrcDocs);
}
// 4. Copy schemas/ directory .md files if it exists outside src/
const schemasDir = join(pkgDir, "schemas");
try {
const schemasStat = await stat(schemasDir);
if (schemasStat.isDirectory()) {
const schemaMdFiles = await findMarkdownFiles(schemasDir);
if (schemaMdFiles.length > 0) {
const destSchemas = join(distDir, "schemas");
for (const mdFile of schemaMdFiles) {
const relPath = relative(schemasDir, mdFile);
const destPath = join(destSchemas, relPath);
const destParent = join(destPath, "..");
await $`mkdir -p ${destParent}`.quiet().nothrow();
await cp(mdFile, destPath, { force: true });
}
copiedPaths.push(destSchemas);
}
}
} catch {
// No schemas/ directory
}
return copiedPaths;
}
/**
* Generate SLSA provenance for a successfully published package
*/
async function generatePublishProvenance(
packageName: string,
version: string,
distDir: string
): Promise<void> {
try {
// Compute digest of the dist directory's package.json (representing the published artifact)
const distPkgPath = join(distDir, "package.json");
let artifactDigest: string;
try {
const content = await readFile(distPkgPath);
artifactDigest = createHash("sha256").update(content).digest("hex");
} catch {
// dist/package.json already cleaned up, use package name as fallback
artifactDigest = createHash("sha256")
.update(`${packageName}@${version}`)
.digest("hex");
}
// Read Git info
const commitSha = (await $`git rev-parse HEAD`.text()).trim();
const refName = (
await $`git rev-parse --abbrev-ref HEAD`.text()
).trim();
const projectUrl =
process.env.CI_PROJECT_URL ??
"https://gitlab.fq.io/suaveplan/monorepo";
const runnerId = process.env.CI_RUNNER_ID ?? "local";
const jobId = process.env.CI_JOB_ID ?? `local-${Date.now()}`;
const pipelineId = process.env.CI_PIPELINE_ID ?? "local";
const isCI = !!(process.env.CI || process.env.GITLAB_CI);
const encodedName = packageName.replace("@", "%40");
const statement = {
_type: "https://in-toto.io/Statement/v1",
subject: [
{
uri: `pkg:npm/${encodedName}@${version}`,
digest: { sha256: artifactDigest },
name: packageName,
},
],
predicateType: "https://slsa.dev/provenance/v1",
predicate: {
buildDefinition: {
buildType:
"https://suaveplan.app/slsa/build-type/bun-monorepo/v1",
externalParameters: {
repository: projectUrl,
ref: `refs/heads/${refName}`,
},
internalParameters: {
environment: isCI ? "gitlab-ci" : "local",
},
resolvedDependencies: [
{
uri: `git+${projectUrl}@refs/heads/${refName}`,
digest: { sha256: commitSha },
name: "source",
},
],
},
runDetails: {
builder: {
id: `${projectUrl}/-/runners/${runnerId}`,
version: { bun: Bun.version },
},
metadata: {
invocationId: `${projectUrl}/-/pipelines/${pipelineId}/jobs/${jobId}`,
startedOn:
process.env.CI_JOB_STARTED_AT ??
new Date().toISOString(),
finishedOn: new Date().toISOString(),
},
},
},
};
const provenanceDir = join(ROOT_DIR, "provenance");
try {
await stat(provenanceDir);
} catch {
await mkdir(provenanceDir, { recursive: true });
}
const safeName = packageName.replace(/[/@]/g, "_");
const filename = `${safeName}-${version}.provenance.json`;
await writeFile(
join(provenanceDir, filename),
JSON.stringify(statement, null, 2)
);
} catch {
// Provenance generation failure should not block publishing
console.warn(
` Warning: Failed to generate provenance for ${packageName}`
);
}
}
interface PublishResult {
name: string;
success: boolean;
error?: string;
localVersion?: string;
publishedVersion?: string | null;
action?: "published" | "skipped" | "updated" | "failed";
}
/**
* Publish a single package to Verdaccio
*
* @param pkgDir - Absolute path to package directory
* @param options - CLI options (dry-run, force, etc.)
* @param resolver - CatalogResolver instance for dependency resolution
* @returns PublishResult with status and metadata
*/
async function publishPackage(
pkgDir: string,
options: CliOptions,
resolver: CatalogResolver
): Promise<PublishResult> {
const pkgJsonPath = join(pkgDir, "package.json");
const distDir = join(pkgDir, CONFIG.build.distDir);
try {
// Read original package.json
const pkgContent = await readFile(pkgJsonPath, "utf-8");
const pkg: PackageJson = JSON.parse(pkgContent);
// Skip private packages
if (pkg.private) {
return {
name: pkg.name!,
success: true,
error: "private package",
action: "skipped",
};
}
// Check if dist exists
try {
await stat(distDir);
} catch {
return {
name: pkg.name!,
success: false,
error: "no dist folder",
action: "failed",
};
}
const localVersion = pkg.version;
if (!localVersion) {
return {
name: pkg.name!,
success: false,
error: "no version in package.json",
action: "failed",
};
}
// Query Verdaccio for published version
const publishedVersion = await getPublishedVersion(pkg.name!);
// Determine if we should publish
let _shouldPublish = false;
let versionToPublish = localVersion;
let wasForceIncremented = false;
// Check if force is enabled for this package
const isForced =
options.forceAll || options.forcePackages.has(pkg.name!);
if (publishedVersion === null) {
// Never published before
_shouldPublish = true;
} else {
const comparison = compareVersions(localVersion, publishedVersion);
if (comparison > 0) {
// Local version is newer
_shouldPublish = true;
} else if (comparison === 0) {
// Same version - check if force flag is set for this package
if (isForced) {
// Increment version and update source package.json
versionToPublish = incrementVersion(localVersion);
if (!options.dryRun) {
await updatePackageVersion(
pkgJsonPath,
versionToPublish
);
}
_shouldPublish = true;
wasForceIncremented = true;
} else {
// Skip - same version
return {
name: pkg.name!,
success: true,
localVersion,
publishedVersion,
error: "same version",
action: "skipped",
};
}
} else {
// Local version is older than published
if (isForced) {
// Force enabled: increment published version and update local
versionToPublish = incrementVersion(publishedVersion);
if (!options.dryRun) {
await updatePackageVersion(
pkgJsonPath,
versionToPublish
);
}
_shouldPublish = true;
wasForceIncremented = true;
} else {
// Force not enabled: this is an error
return {
name: pkg.name!,
success: false,
localVersion,
publishedVersion,
error: "local version < published version",
action: "failed",
};
}
}
}
// If dry run, stop here
if (options.dryRun) {
return {
name: pkg.name!,
success: true,
localVersion: versionToPublish,
publishedVersion,
error: wasForceIncremented
? "would increment and publish"
: "would publish",
action: "skipped",
};
}
// Transform package.json with the version we're publishing
const transformedPkg = transformPackageJson(
pkg,
versionToPublish,
resolver
);
// Write transformed package.json to dist/
await writeFile(
join(distDir, "package.json"),
JSON.stringify(transformedPkg, null, 2)
);
// Copy all documentation into dist/ for publishing
const copiedDocPaths = await copyDocsToDistDir(pkgDir, distDir);
// Special handling for overview package README links
try {
const distReadme = join(distDir, "README.md");
const readmeContent = await readFile(distReadme, "utf-8");
if (pkg.name === "@suaveplan/overview") {
await writeFile(
distReadme,
transformOverviewReadme(readmeContent)
);
}
} catch {
// No README, that's ok
}
// Publish from dist/ using npm (more reliable than bun for this)
// Note: We use --userconfig to ensure auth token from root .npmrc is used
// (writing package.json to dist/ makes npm treat it as project root, stopping .npmrc walk-up)
const npmrcPath = join(ROOT_DIR, ".npmrc");
const result =
await $`npm publish --registry ${REGISTRY} --userconfig ${npmrcPath}`
.cwd(distDir)
.quiet()
.nothrow();
// Clean up - remove package.json and documentation from dist
await rm(join(distDir, "package.json")).catch(() => {});
for (const docPath of copiedDocPaths) {
await rm(docPath, { recursive: true }).catch(() => {});
}
if (result.exitCode !== 0) {
const stderr = result.stderr.toString();
const stdout = result.stdout.toString();
const output = stderr + stdout;
if (
output.includes("already exists") ||
output.includes("cannot publish over") ||
output.includes("E409") ||
output.includes("already present")
) {
return {
name: pkg.name!,
success: true,
localVersion: versionToPublish,
publishedVersion,
error: "already published",
action: "skipped",
};
}
// Filter out npm notice lines to surface real error
const errorLines = stderr
.split("\n")
.filter((line: string) => !line.startsWith("npm notice"))
.join("\n")
.trim();
return {
name: pkg.name!,
success: false,
localVersion: versionToPublish,
publishedVersion,
error: errorLines || stderr.slice(0, 500),
action: "failed",
};
}
// Generate SLSA provenance for the published artifact
await generatePublishProvenance(pkg.name!, versionToPublish, distDir);
return {
name: pkg.name!,
success: true,
localVersion: versionToPublish,
publishedVersion,
action: wasForceIncremented ? "updated" : "published",
};
} catch (err) {
return {
name: pkgDir,
success: false,
error: err instanceof Error ? err.message : String(err),
action: "failed",
};
}
}
async function main() {
const options = parseArgs();
console.log("=".repeat(70));
console.log(`Publishing to Verdaccio: ${REGISTRY}`);
if (options.dryRun) {
console.log("🔍 DRY RUN MODE - No actual publishing will occur");
}
if (options.specificPackage) {
console.log(`📦 Publishing only: ${options.specificPackage}`);
}
if (options.forceAll) {
console.log(
"⚡ Force publishing ALL packages (with version increment)"
);
} else if (options.forcePackages.size > 0) {
console.log(
`⚡ Force publishing (with version increment): ${Array.from(options.forcePackages).join(", ")}`
);
}
console.log("=".repeat(70));
console.log();
// Load catalogs
const resolver = await CatalogResolver.create();
console.log(`Loaded ${resolver.getCatalogCount()} catalogs`);
console.log();
let packageDirs = await getPackageDirs();
// Filter to specific package if requested
if (options.specificPackage) {
const filteredDirs: string[] = [];
for (const dir of packageDirs) {
const pkgJsonPath = join(dir, "package.json");
try {
const pkg: PackageJson = JSON.parse(
await readFile(pkgJsonPath, "utf-8")
);
if (pkg.name === options.specificPackage) {
filteredDirs.push(dir);
}
} catch {
// Skip packages with invalid JSON
}
}
packageDirs = filteredDirs;
}
console.log(`Found ${packageDirs.length} packages\n`);
let publishedCount = 0;
let updatedCount = 0;
let skipCount = 0;
let failCount = 0;
for (const pkgDir of packageDirs) {
const result = await publishPackage(pkgDir, options, resolver);
// Format output based on result
const versionInfo = result.localVersion
? `${result.localVersion}${result.publishedVersion !== null ? ` (was: ${result.publishedVersion})` : " (new)"}`
: "";
switch (result.action) {
case "published":
console.log(`✅ ${result.name}@${versionInfo}`);
publishedCount++;
break;
case "updated":
console.log(
`🔄 ${result.name}@${versionInfo} (force incremented)`
);
updatedCount++;
break;
case "skipped":
if (result.error === "same version") {
console.log(
`⏭️ ${result.name}@${versionInfo} - up to date`
);
} else if (result.error?.includes("would")) {
console.log(
`🔍 ${result.name}@${versionInfo} - ${result.error}`
);
} else {
console.log(`⏭️ ${result.name} - ${result.error}`);
}
skipCount++;
break;
case "failed":
console.log(
`❌ ${result.name}@${versionInfo || "?"} - ${result.error}`
);
failCount++;
break;
default:
console.log(`❓ ${result.name} - unknown status`);
}
}
console.log();
console.log("=".repeat(70));
console.log(
`Published: ${publishedCount} | Updated: ${updatedCount} | Skipped: ${skipCount} | Failed: ${failCount}`
);
if (options.dryRun) {
console.log("🔍 DRY RUN - No changes were made");
}
console.log("=".repeat(70));
// Exit with standardized code
if (failCount > 0) {
process.exit(CONFIG.exitCodes.failure);
}
process.exit(CONFIG.exitCodes.success);
}
main().catch(console.error);

View File

@@ -0,0 +1,334 @@
#!/usr/bin/env bun
/**
* snapshot-docs-content — mirror the repo's documentation tree into
* `apps/docs/docs/` so the SSG (VitePress) can produce a self-contained
* static site whose links resolve locally (no GitHub URLs).
*
* What's mirrored:
*
* <repo>/README.md → apps/docs/docs/index.md
* <repo>/_intro.md → (skipped — already in README.md)
* <repo>/STATUS.md → apps/docs/docs/STATUS.md
* <repo>/SUMMARY.md → apps/docs/docs/SUMMARY.md
* <repo>/MANIFEST.md → apps/docs/docs/MANIFEST.md
* <repo>/CLAUDE.md → apps/docs/docs/CLAUDE.md
* <repo>/AGENTS.md → apps/docs/docs/AGENTS.md
* <repo>/packages/<cat>/README.md → apps/docs/docs/packages/<cat>/index.md
* <repo>/packages/<cat>/<plugin>/README.md → apps/docs/docs/packages/<cat>/<plugin>/index.md
* <repo>/packages/<cat>/<plugin>/<pkg>/README.md → apps/docs/docs/packages/<cat>/<plugin>/<pkg>/index.md
* <repo>/packages/<cat>/<plugin>/<pkg>/src/.../[file].md → mirrored under the
* same relative path inside apps/docs/docs/packages/.
* <repo>/openspec/specs/<cap>/spec.md → apps/docs/docs/openspec/specs/<cap>/spec.md
*
* Path translation rules:
* - `README.md` files become `index.md` so VitePress maps them to clean
* directory URLs (`packages/api-stack/api/api-schemas/`).
* - Relative `.md` links inside content are rewritten so that links
* pointing at `<sibling>/README.md` end up pointing at `<sibling>/`
* (works with VitePress' clean-URL routing) and links pointing at
* `./<feature>.md` remain unchanged.
*
* Skipped:
* - `.types.md` and `SKILL.md` files (excluded from the documentation
* hierarchy by convention — see `scripts/analyze-doc-tree.ts`).
* - `node_modules`, `dist`, `build`, `coverage`, `.turbo`, `.cache`.
* - Everything under `openspec/changes/`, `openspec/archive/`,
* `openspec/templates/`, `docs/old-network-planning/`.
*
* Usage:
* bun scripts/snapshot-docs-content.ts # rewrite
* bun scripts/snapshot-docs-content.ts --check # exit 1 on drift
* bun scripts/snapshot-docs-content.ts --quiet # counts only
*/
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { CONFIG } from "./lib/config.js";
const ROOT = CONFIG.paths.root;
const DOCS_OUT = join(ROOT, "apps", "docs", "docs");
const TOP_LEVEL_DOCS: ReadonlyArray<[string, string]> = [
["README.md", "index.md"],
["STATUS.md", "STATUS.md"],
["SUMMARY.md", "SUMMARY.md"],
["MANIFEST.md", "MANIFEST.md"],
["CLAUDE.md", "CLAUDE.md"],
["AGENTS.md", "AGENTS.md"],
];
const IGNORE_DIR_SEGMENTS: readonly string[] = [
"node_modules",
"dist",
"build",
"coverage",
".turbo",
".vite",
".cache",
".docusaurus",
"openspec/changes",
"openspec/archive",
"openspec/templates",
"docs/old-network-planning",
];
const IGNORE_FILE_SUFFIXES: readonly string[] = [".types.md", "SKILL.md"];
function shouldSkipPath(absPath: string): boolean {
const rel = relative(ROOT, absPath).replace(/\\/g, "/");
for (const seg of IGNORE_DIR_SEGMENTS) {
if (rel === seg || rel.startsWith(`${seg}/`)) return true;
}
return IGNORE_FILE_SUFFIXES.some((suf) => absPath.endsWith(suf));
}
function walkMdFiles(dir: string): string[] {
const out: string[] = [];
const stack: string[] = [dir];
while (stack.length > 0) {
const current = stack.pop() as string;
let entries: ReturnType<typeof readdirSync>;
try {
entries = readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
const fileNames = new Set(
entries.filter((e) => e.isFile()).map((e) => e.name)
);
// If a directory contains both `<dirname>.md` and `index.md`, drop the
// `index.md` from the walk — both files map to the same Docusaurus
// route under the directory and `<dirname>.md` is the canonical
// module doc by convention. Same logic for `README.md` vs
// `<dirname>.md`.
const dirName = current.split(/[/\\]/).pop() ?? "";
const canonicalDirMd = `${dirName}.md`;
const dropIndex =
fileNames.has(canonicalDirMd) && fileNames.has("index.md");
const dropReadme =
fileNames.has(canonicalDirMd) && fileNames.has("README.md");
for (const entry of entries) {
const next = join(current, entry.name);
if (shouldSkipPath(next)) continue;
if (entry.isDirectory()) {
if (entry.name.startsWith(".")) continue;
if (entry.name === "node_modules") continue;
stack.push(next);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
if (dropIndex && entry.name === "index.md") continue;
if (dropReadme && entry.name === "README.md") continue;
out.push(next);
}
}
out.sort();
return out;
}
/**
* Translate a source `.md` path to its destination under `apps/docs/docs/`.
*
* Returns undefined for files outside the mirrored scope.
*/
function destinationFor(srcAbs: string): string | undefined {
const rel = relative(ROOT, srcAbs).replace(/\\/g, "/");
for (const [from, to] of TOP_LEVEL_DOCS) {
if (rel === from) return join(DOCS_OUT, to);
}
if (rel.startsWith("packages/")) {
// Translate trailing `/README.md` to `/index.md` so VitePress routes
// the directory cleanly.
const translated = rel.replace(/\/README\.md$/u, "/index.md");
return join(DOCS_OUT, translated);
}
if (rel.startsWith("openspec/specs/")) {
return join(DOCS_OUT, rel);
}
return undefined;
}
/**
* Rewrite relative `.md` links inside content:
* - `README.md` → `index.md` so the SSG routes the directory cleanly.
* - Links pointing at `.types.md` or `SKILL.md` (intentionally excluded
* from the snapshot) collapse to inline-code spans so the link checker
* does not flag them as unresolved.
*/
function rewriteLinks(content: string): string {
return content.replace(
/\[([^\]]*)\]\(([^)]+\.md)(#[^)]*)?\)/gu,
(match, label: string, target: string, anchor: string | undefined) => {
if (/^https?:|^mailto:|^\//u.test(target)) return match;
if (/\.types\.md$/u.test(target) || /SKILL\.md$/u.test(target)) {
return `\`${label || target}\``;
}
const remapped = target.replace(
/(?:^|\/)README\.md$/u,
"/index.md"
);
return `[${label}](${remapped}${anchor ?? ""})`;
}
);
}
function ensureDir(path: string): void {
mkdirSync(dirname(path), { recursive: true });
}
function readFileSafe(path: string): string | undefined {
try {
return readFileSync(path, "utf-8");
} catch {
return undefined;
}
}
interface CliOptions {
check: boolean;
quiet: boolean;
}
function parseArgs(argv: readonly string[]): CliOptions {
let check = false;
let quiet = false;
for (const arg of argv) {
if (arg === "--check") check = true;
else if (arg === "--quiet" || arg === "-q") quiet = true;
else throw new Error(`Unknown flag: ${arg}`);
}
return { check, quiet };
}
interface Stats {
written: number;
unchanged: number;
skipped: number;
}
function processFile(
srcAbs: string,
options: CliOptions,
stats: Stats,
seenDest: Set<string>
): void {
const dest = destinationFor(srcAbs);
if (!dest) {
stats.skipped++;
return;
}
seenDest.add(resolve(dest));
const original = readFileSafe(srcAbs);
if (original === undefined) {
stats.skipped++;
return;
}
const next = rewriteLinks(original);
const current = readFileSafe(dest) ?? "";
if (current === next) {
stats.unchanged++;
return;
}
if (!options.check) {
ensureDir(dest);
writeFileSync(dest, next);
}
stats.written++;
}
function walkSnapshotDir(dir: string): string[] {
if (!existsSync(dir)) return [];
const out: string[] = [];
const stack: string[] = [dir];
while (stack.length > 0) {
const current = stack.pop() as string;
let entries: ReturnType<typeof readdirSync>;
try {
entries = readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const next = join(current, entry.name);
if (entry.isDirectory()) {
stack.push(next);
continue;
}
if (entry.isFile() && entry.name.endsWith(".md")) out.push(next);
}
}
return out;
}
function main(): 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);
}
if (!existsSync(DOCS_OUT)) mkdirSync(DOCS_OUT, { recursive: true });
const stats: Stats = { written: 0, unchanged: 0, skipped: 0 };
const seenDest = new Set<string>();
// Top-level docs first.
for (const [src] of TOP_LEVEL_DOCS) {
const abs = join(ROOT, src);
if (existsSync(abs)) processFile(abs, options, stats, seenDest);
}
// Walk packages/ and openspec/specs/.
for (const rootSub of ["packages", "openspec/specs"]) {
const abs = join(ROOT, rootSub);
if (!existsSync(abs)) continue;
for (const md of walkMdFiles(abs)) {
processFile(md, options, stats, seenDest);
}
}
// Prune stale files in DOCS_OUT that weren't written this run. Skip
// VitePress config dir and any non-`.md` artifacts.
let pruned = 0;
for (const existing of walkSnapshotDir(DOCS_OUT)) {
const normalized = resolve(existing);
if (seenDest.has(normalized)) continue;
// Preserve hand-authored content under `.vitepress/` and any sibling
// non-`.md` assets — only delete `.md` files that the snapshot is
// responsible for and no longer produces.
if (existing.includes("/.vitepress/")) continue;
if (!options.check) rmSync(existing);
pruned++;
}
if (!options.quiet) {
console.log("");
console.log(`✏️ Written: ${stats.written}`);
console.log(`⏭️ Unchanged: ${stats.unchanged}`);
if (stats.skipped > 0) console.log(`⚠️ Skipped: ${stats.skipped}`);
if (pruned > 0) console.log(`🗑️ Pruned: ${pruned}`);
} else {
console.log(
`written=${stats.written} unchanged=${stats.unchanged} pruned=${pruned}`
);
}
if (options.check && (stats.written > 0 || pruned > 0)) {
console.error("");
console.error("Drift detected — run without --check to refresh.");
process.exit(1);
}
process.exit(0);
}
main();

View File

@@ -0,0 +1,208 @@
#!/usr/bin/env bun
/**
* Scaffold a new @suaveplan/audio-fx-plugin-<slug> package by:
* 1. Copying every template under ./template/, replacing {PLUGIN_NAME} markers.
* 2. git-mv'ing the source folder from packages/audio/audio-plugins-fx/src/plugins/<src-dir>/ → packages/audio-fx/audio-fx-plugin-<slug>/src/.
* 3. Rewriting relative imports inside the moved sources to point at @suaveplan/audio-fx-plugin-base/* barrels.
* 4. Rewriting @suaveplan/audio-plugins-fx/<slug> doc references to @suaveplan/audio-fx-plugin-<slug>.
*
* Usage:
* bun scripts/split-audio-fx-plugin/scaffold.ts --name compressor
* bun scripts/split-audio-fx-plugin/scaffold.ts --name game-audio --source-dir game
*
* After scaffold:
* bunx turbo lint typecheck test --filter=@suaveplan/audio-fx-plugin-<slug>
* bunx biome check --write src/ # may need formatting fix
*/
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmdirSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const SLUG_RE = /^[a-z][a-z0-9-]*$/;
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const TEMPLATE_DIR = join(SCRIPT_DIR, "template");
const REPO_ROOT = join(SCRIPT_DIR, "..", "..");
const SOURCE_PARENT = join(
REPO_ROOT,
"packages",
"audio",
"audio-plugins-fx",
"src",
"plugins"
);
const DEST_PARENT = join(REPO_ROOT, "packages", "audio");
interface Args {
name: string;
sourceDir: string;
description: string;
}
function parseArgs(): Args {
const argv = process.argv.slice(2);
let name = "";
let sourceDir = "";
let description = "";
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
const next = argv[i + 1];
if (!next) continue;
if (flag === "--name") {
name = next;
i++;
} else if (flag === "--source-dir") {
sourceDir = next;
i++;
} else if (flag === "--description") {
description = next;
i++;
}
}
if (!name || !SLUG_RE.test(name)) {
throw new Error(
`--name must match /^[a-z][a-z0-9-]*$/ (got: ${JSON.stringify(name)})`
);
}
if (!sourceDir) sourceDir = name;
if (!description) {
description = `${name} plugin for the @suaveplan/audio-fx-plugin-* family.`;
}
return { name, sourceDir, description };
}
function gitMv(from: string, to: string): void {
const result = spawnSync("git", ["mv", from, to], {
cwd: REPO_ROOT,
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(
`git mv ${from} ${to} failed with status ${result.status}`
);
}
}
function walkFiles(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) walkFiles(full, out);
else out.push(full);
}
return out;
}
function substitute(content: string, args: Args): string {
return content
.replaceAll("{PLUGIN_NAME}", args.name)
.replaceAll("{PLUGIN_DESCRIPTION}", args.description);
}
function copyTemplates(destPkg: string, args: Args): void {
for (const entry of readdirSync(TEMPLATE_DIR)) {
const src = join(TEMPLATE_DIR, entry);
const destName = entry.endsWith(".template")
? entry.slice(0, -".template".length)
: entry;
const dest = join(destPkg, destName);
const content = readFileSync(src, "utf8");
writeFileSync(dest, substitute(content, args));
}
}
function moveSource(args: Args, destPkg: string): void {
const srcDir = join(SOURCE_PARENT, args.sourceDir);
if (!existsSync(srcDir)) {
throw new Error(`source dir not found: ${srcDir}`);
}
const destSrc = join(destPkg, "src");
for (const child of readdirSync(srcDir)) {
gitMv(join(srcDir, child), join(destSrc, child));
}
try {
rmdirSync(srcDir);
} catch {
// Non-fatal: if non-empty, leave for manual inspection.
}
}
const RELATIVE_IMPORT_REWRITES: Array<[RegExp, string]> = [
// `base/<anything>.js` → base barrel
[
/from "\.\.\/\.\.\/base\/[a-z0-9./-]+\.js"/g,
'from "@suaveplan/audio-fx-plugin-base/base"',
],
// `chain/<anything>.js` (including nested context/, event-bus/, etc.) → chain barrel
[
/from "\.\.\/\.\.\/chain\/[a-z0-9./-]+\.js"/g,
'from "@suaveplan/audio-fx-plugin-base/chain"',
],
// `registry/<anything>.js` → registry barrel
[
/from "\.\.\/\.\.\/registry\/[a-z0-9./-]+\.js"/g,
'from "@suaveplan/audio-fx-plugin-base/registry"',
],
// root-level error-codes
[
/from "\.\.\/\.\.\/error-codes\.js"/g,
'from "@suaveplan/audio-fx-plugin-base/error-codes"',
],
];
const CROSS_PLUGIN_RE = /from "\.\.\/[a-z][a-z0-9-]*\//;
function rewriteImports(destPkg: string, args: Args): void {
const srcDir = join(destPkg, "src");
for (const file of walkFiles(srcDir)) {
if (!file.endsWith(".ts") && !file.endsWith(".md")) continue;
let content = readFileSync(file, "utf8");
const before = content;
for (const [pattern, replacement] of RELATIVE_IMPORT_REWRITES) {
content = content.replace(pattern, replacement);
}
content = content.replaceAll(
`@suaveplan/audio-plugins-fx/${args.name}`,
`@suaveplan/audio-fx-plugin-${args.name}`
);
const lines = content.split("\n");
for (const line of lines) {
if (
CROSS_PLUGIN_RE.test(line) &&
!line.includes("audio-fx-plugin-base")
) {
throw new Error(
`Cross-plugin import detected in ${file}: ${line.trim()} — abort. ` +
"Fix the moved source manually, then re-run."
);
}
}
if (content !== before) writeFileSync(file, content);
}
}
function main(): void {
const args = parseArgs();
const destPkg = join(DEST_PARENT, `audio-fx-plugin-${args.name}`);
if (existsSync(destPkg)) {
throw new Error(`destination already exists: ${destPkg}`);
}
mkdirSync(join(destPkg, "src"), { recursive: true });
copyTemplates(destPkg, args);
moveSource(args, destPkg);
rewriteImports(destPkg, args);
console.log(
`scaffolded @suaveplan/audio-fx-plugin-${args.name} at ${destPkg}\n` +
`next: bunx turbo lint typecheck test --filter=@suaveplan/audio-fx-plugin-${args.name}`
);
}
main();

View File

@@ -0,0 +1,22 @@
# @suaveplan/audio-fx-plugin-{PLUGIN_NAME}
{PLUGIN_DESCRIPTION}
Member of the `@suaveplan/audio-fx-plugin-*` family. Universal-tier (browser / server / offline / worklet identical). Depends on `@suaveplan/audio-fx-plugin-base` for the `AudioFxPlugin` contract; declares no other `@suaveplan/audio-fx-plugin-*` deps.
## Composition
```ts
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { /* Plugin */ } from "@suaveplan/audio-fx-plugin-{PLUGIN_NAME}";
const plugin = new /* Plugin */();
const chain = new PluginChain({
buses: new DefaultBusRegistry(),
plugins: [plugin],
});
```
## Status
Replaces the `./{PLUGIN_NAME}` subpath of the bundled `@suaveplan/audio-plugins-fx`. See `openspec/archive/2026-05-19-split-audio-plugins-fx/` for migration history.

View File

@@ -0,0 +1,47 @@
{
"name": "@suaveplan/audio-fx-plugin-{PLUGIN_NAME}",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "{PLUGIN_DESCRIPTION}",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "vite build",
"lint": "biome check --max-diagnostics 500 src/",
"typecheck": "tsc --noEmit -p tsconfig.lib.json",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"docs:check": "bun run ../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/audio-fx-plugin-base": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/audio-core": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"tests/**",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/audio-fx-plugin-{PLUGIN_NAME}",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: { index: "src/index.ts" },
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({ testTimeout: 30_000 });

179
scripts/unpublish.ts Normal file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env bun
/**
* Remove one or more versions of a @suaveplan/* package from the Verdaccio
* registry at https://npmjs.fq.io/. Used when a forced-republish accidentally
* bumped a genesis package past 0.0.0 and we want to reclaim the version.
*
* Usage:
* bun scripts/unpublish.ts --package @suaveplan/foo --version 0.0.1
* bun scripts/unpublish.ts --package @suaveplan/foo --version 0.0.1 --confirm
* bun scripts/unpublish.ts --package @suaveplan/foo --all --confirm
*
* Defaults to dry-run — nothing is deleted unless `--confirm` is passed.
*
* Requires `NPM_TOKEN` in .env with unpublish scope on the Verdaccio registry.
* (Verified: the token currently in use has unpublish rights.)
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { CONFIG } from "./lib/config.js";
const execFileAsync = promisify(execFile);
const REGISTRY = CONFIG.registry.verdaccio.url;
interface CliOptions {
package: string | null;
version: string | null;
all: boolean;
confirm: boolean;
}
function parseArgs(argv: readonly string[]): CliOptions {
const opts: CliOptions = {
package: null,
version: null,
all: false,
confirm: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--package") {
opts.package = argv[++i] ?? null;
} else if (arg === "--version") {
opts.version = argv[++i] ?? null;
} else if (arg === "--all") {
opts.all = true;
} else if (arg === "--confirm") {
opts.confirm = true;
}
}
return opts;
}
function printHelp(): void {
console.log(`Usage:
bun scripts/unpublish.ts --package <@suaveplan/name> --version <version>
bun scripts/unpublish.ts --package <@suaveplan/name> --all
Flags:
--confirm Actually perform the unpublish (default is dry-run)
Examples:
bun scripts/unpublish.ts --package @suaveplan/error --version 0.0.1
bun scripts/unpublish.ts --package @suaveplan/error --version 0.0.1 --confirm
bun scripts/unpublish.ts --package @suaveplan/error --all --confirm`);
}
async function listVersions(pkg: string): Promise<string[]> {
try {
const { stdout } = await execFileAsync("npm", [
"view",
pkg,
"versions",
"--json",
`--registry=${REGISTRY}`,
]);
const parsed = JSON.parse(stdout) as string | string[];
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return [];
}
}
async function unpublishOne(pkg: string, version: string): Promise<void> {
await execFileAsync("npm", [
"unpublish",
`${pkg}@${version}`,
"--force",
`--registry=${REGISTRY}`,
]);
}
function validatePackageName(name: string): void {
if (!name.startsWith("@suaveplan/")) {
throw new Error(
`Package must start with '@suaveplan/' — refusing to operate on '${name}'.`
);
}
// Defence in depth: block path traversal / shell metacharacters even
// though execFile doesn't invoke a shell.
if (!/^@suaveplan\/[a-z0-9-]+$/.test(name)) {
throw new Error(
`Package name '${name}' fails strict validation (lowercase, digits, hyphens only).`
);
}
}
function validateVersion(v: string): void {
if (!/^\d+\.\d+\.\d+(?:-[0-9a-zA-Z.-]+)?$/.test(v)) {
throw new Error(`Version '${v}' is not a valid semver.`);
}
}
async function main(): Promise<void> {
const opts = parseArgs(process.argv.slice(2));
if (opts.package === null) {
printHelp();
process.exit(1);
}
validatePackageName(opts.package);
const existing = await listVersions(opts.package);
if (existing.length === 0) {
console.log(`No versions of ${opts.package} found on ${REGISTRY}.`);
return;
}
let targets: string[];
if (opts.all) {
targets = existing;
} else if (opts.version !== null) {
validateVersion(opts.version);
if (!existing.includes(opts.version)) {
console.log(
`Version ${opts.version} not found on registry for ${opts.package}. Existing: ${existing.join(", ")}`
);
return;
}
targets = [opts.version];
} else {
console.error("Must pass either --version <v> or --all.");
printHelp();
process.exit(1);
}
console.log(`Package: ${opts.package}`);
console.log(`Registry: ${REGISTRY}`);
console.log(`Versions to unpublish: ${targets.join(", ")}`);
console.log(
`Remaining after op: ${
existing.filter((v) => !targets.includes(v)).join(", ") || "(none)"
}`
);
if (!opts.confirm) {
console.log("\n🔍 DRY RUN — pass --confirm to actually unpublish.");
return;
}
for (const version of targets) {
process.stdout.write(` • ${opts.package}@${version} ... `);
try {
await unpublishOne(opts.package, version);
console.log("✅");
} catch (err) {
console.log(`❌ ${(err as Error).message}`);
process.exit(1);
}
}
console.log(`\nDone. ${targets.length} version(s) unpublished.`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

50
scripts/update-base-md.sh Normal file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# update-base-md — refresh every auto-generated base .md / .yml file at the
# repo root, and optionally flip STATUS.md rows to ✅ for the package names
# passed as arguments.
#
# Usage:
# scripts/update-base-md.sh # refresh manifest + missing
# scripts/update-base-md.sh cron job-scheduler # also mark migrated in STATUS.md
# scripts/update-base-md.sh @suaveplan/cron @suaveplan/retry
#
# Run order is significant: STATUS.md updates first so the manifest /
# missing generators see the canonical state if they ever read from it.
set -euo pipefail
cd "$(dirname "$0")/.."
ROOT="$(pwd)"
if [[ $# -gt 0 ]]; then
echo "==> Marking ${#} package(s) as migrated in STATUS.md"
bun "${ROOT}/scripts/mark-migrated.ts" "$@"
echo
fi
echo "==> Refreshing MANIFEST.md"
bun "${ROOT}/scripts/folder-reorg/generate-manifest.ts"
echo
echo "==> Hoisting README titles from package.json descriptions"
bun "${ROOT}/scripts/hoist-readme-titles.ts"
echo
echo "==> Regenerating per-package documentation trees"
bun "${ROOT}/scripts/generate-package-module-tree.ts"
echo
echo "==> Regenerating per-category README files"
bun "${ROOT}/scripts/generate-category-readmes.ts"
echo
echo "==> Regenerating root README.md"
bun "${ROOT}/scripts/generate-root-readme.ts"
echo
echo "==> Analyzing documentation tree for orphans"
bun "${ROOT}/scripts/analyze-doc-tree.ts" --quiet || true
echo
echo "✅ Base .md files updated."

149
scripts/worktree-prune.ts Normal file
View File

@@ -0,0 +1,149 @@
#!/usr/bin/env bun
/**
* Audit and prune stale worktrees under `.claude/worktrees/`.
*
* Default mode (no args): reports per-worktree status (branch, HEAD SHA,
* dirty/clean, reachable-from-a-branch). Exit 0 always.
*
* `--remove`: removes every worktree whose HEAD commit IS reachable from
* a branch ref in the main repo (so the work survives) AND whose tree is
* clean (so no uncommitted work is lost). Skips and reports any worktree
* that doesn't meet both criteria.
*
* `--force`: with `--remove`, also removes dirty worktrees. Use with care
* — uncommitted changes inside the worktree will be lost.
*/
import { execFileSync } from "node:child_process";
interface WorktreeRow {
readonly path: string;
readonly branch: string;
readonly sha: string;
readonly dirty: boolean;
readonly reachable: boolean;
readonly locked: boolean;
}
function git(args: readonly string[], cwd?: string): string {
const opts = cwd
? { cwd, encoding: "utf8" as const }
: { encoding: "utf8" as const };
return execFileSync("git", args, opts).trim();
}
function listWorktrees(): readonly WorktreeRow[] {
const out = git(["worktree", "list", "--porcelain"]);
const blocks = out.split("\n\n").filter((b) => b.trim() !== "");
const root = git(["rev-parse", "--show-toplevel"]);
const rows: WorktreeRow[] = [];
for (const block of blocks) {
const lines = block.split("\n");
const path = lines
.find((l) => l.startsWith("worktree "))!
.slice("worktree ".length);
if (path === root) continue;
if (!path.includes(".claude/worktrees/")) continue;
const sha = lines
.find((l) => l.startsWith("HEAD "))!
.slice("HEAD ".length);
const branchLine = lines.find((l) => l.startsWith("branch "));
const branch = branchLine
? branchLine.slice("branch ".length).replace(/^refs\/heads\//, "")
: "(detached)";
const status = git(["status", "--porcelain"], path);
const dirty = status.length > 0;
const containing = git([
"branch",
"--contains",
sha,
"--format=%(refname:short)",
])
.split("\n")
.filter((b) => b.trim() && b !== branch);
const reachable = containing.length > 0 || branch.startsWith("feat/");
const locked = lines.some(
(l) => l === "locked" || l.startsWith("locked ")
);
rows.push({
path,
branch,
sha: sha.slice(0, 9),
dirty,
reachable,
locked,
});
}
return rows;
}
function printTable(rows: readonly WorktreeRow[]): void {
if (rows.length === 0) {
console.log("No worktrees under .claude/worktrees/.");
return;
}
console.log(
`${"branch".padEnd(40)} ${"sha".padEnd(10)} ${"dirty".padEnd(7)} reachable`
);
console.log("-".repeat(80));
for (const r of rows) {
console.log(
`${r.branch.padEnd(40)} ${r.sha.padEnd(10)} ${(r.dirty ? "dirty" : "clean").padEnd(7)} ${r.reachable ? "yes" : "no"}`
);
}
}
function prune(rows: readonly WorktreeRow[], force: boolean): void {
let removed = 0;
let skipped = 0;
for (const r of rows) {
if (r.locked && !force) {
console.log(
`SKIP ${r.branch}: worktree is locked (active agent likely running). Re-run with --force to override — risks destroying in-progress work that hasn't committed yet.`
);
skipped++;
continue;
}
if (!r.reachable) {
console.log(
`SKIP ${r.branch}: HEAD ${r.sha} not reachable from any branch — would lose work`
);
skipped++;
continue;
}
if (r.dirty && !force) {
console.log(
`SKIP ${r.branch}: uncommitted changes (re-run with --force to discard them)`
);
skipped++;
continue;
}
try {
git(["worktree", "unlock", r.path]);
} catch {
// already unlocked
}
git(["worktree", "remove", "--force", r.path]);
console.log(`REMOVED ${r.branch} (${r.sha})`);
removed++;
}
console.log(`\nRemoved ${removed}, skipped ${skipped}.`);
}
function main(): void {
const args = process.argv.slice(2);
const doRemove = args.includes("--remove");
const force = args.includes("--force");
const rows = listWorktrees();
printTable(rows);
if (doRemove) {
console.log("");
prune(rows, force);
} else if (rows.length > 0) {
console.log(
`\n(${rows.length} stale worktree${rows.length === 1 ? "" : "s"}; re-run with --remove to prune.)`
);
}
}
main();