Initial Commit
This commit is contained in:
203
scripts/analyze-doc-tree.ts
Normal file
203
scripts/analyze-doc-tree.ts
Normal 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();
|
||||
219
scripts/audit-test-helper-usage.ts
Normal file
219
scripts/audit-test-helper-usage.ts
Normal 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
102
scripts/barrel.ts
Normal 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
38
scripts/batch-convert.sh
Normal 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
|
||||
28
scripts/bench/bitpacker-baselines.json
Normal file
28
scripts/bench/bitpacker-baselines.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
scripts/check-crypto-selects.ts
Normal file
43
scripts/check-crypto-selects.ts
Normal 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();
|
||||
}
|
||||
153
scripts/check-fixture-resolves.ts
Normal file
153
scripts/check-fixture-resolves.ts
Normal 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);
|
||||
370
scripts/cleanup-md-monorepo-refs.ts
Normal file
370
scripts/cleanup-md-monorepo-refs.ts
Normal 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();
|
||||
184
scripts/conformance/check-crypto-core-allowlist.ts
Normal file
184
scripts/conformance/check-crypto-core-allowlist.ts
Normal 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);
|
||||
}
|
||||
77
scripts/conformance/crypto-core-allowlist.yml
Normal file
77
scripts/conformance/crypto-core-allowlist.yml
Normal 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
340
scripts/convert-barrels.ts
Normal 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();
|
||||
88
scripts/convert-package-barrels.sh
Normal file
88
scripts/convert-package-barrels.sh
Normal 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
|
||||
131
scripts/copy-wip-from-suaveplan.sh
Normal file
131
scripts/copy-wip-from-suaveplan.sh
Normal 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
122
scripts/crypto-fuzz.ts
Normal 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
87
scripts/crypto-timing.ts
Normal 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
154
scripts/ct-lint.ts
Normal 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();
|
||||
}
|
||||
236
scripts/delib-monorepo-refs.ts
Normal file
236
scripts/delib-monorepo-refs.ts
Normal 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
178
scripts/docs-check.ts
Normal 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());
|
||||
178
scripts/ensure-category-subcategory-intros.ts
Normal file
178
scripts/ensure-category-subcategory-intros.ts
Normal 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();
|
||||
86
scripts/find-readmes-without-header.ts
Normal file
86
scripts/find-readmes-without-header.ts
Normal 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);
|
||||
});
|
||||
690
scripts/folder-reorg/assignment.yml
Normal file
690
scripts/folder-reorg/assignment.yml
Normal 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
|
||||
361
scripts/folder-reorg/generate-manifest.ts
Normal file
361
scripts/folder-reorg/generate-manifest.ts
Normal 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();
|
||||
410
scripts/folder-reorg/migrate-folders.ts
Normal file
410
scripts/folder-reorg/migrate-folders.ts
Normal 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();
|
||||
409
scripts/folder-reorg/realign-to-categories.ts
Normal file
409
scripts/folder-reorg/realign-to-categories.ts
Normal 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();
|
||||
137
scripts/gen-crypto-selects.ts
Normal file
137
scripts/gen-crypto-selects.ts
Normal 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();
|
||||
}
|
||||
256
scripts/generate-category-readmes.ts
Normal file
256
scripts/generate-category-readmes.ts
Normal 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();
|
||||
66
scripts/generate-missing.ts
Normal file
66
scripts/generate-missing.ts
Normal 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`);
|
||||
334
scripts/generate-package-module-tree.ts
Normal file
334
scripts/generate-package-module-tree.ts
Normal 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();
|
||||
296
scripts/generate-packages-root-readme.ts
Normal file
296
scripts/generate-packages-root-readme.ts
Normal 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();
|
||||
196
scripts/generate-provenance.ts
Normal file
196
scripts/generate-provenance.ts
Normal 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);
|
||||
206
scripts/generate-root-readme.ts
Normal file
206
scripts/generate-root-readme.ts
Normal 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
187
scripts/generate-sbom.ts
Normal 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);
|
||||
215
scripts/generate-subcategory-readmes.ts
Normal file
215
scripts/generate-subcategory-readmes.ts
Normal 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();
|
||||
213
scripts/hoist-readme-titles.ts
Normal file
213
scripts/hoist-readme-titles.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* hoist-readme-titles — for every workspace package, ensure README.md's
|
||||
* first line follows the convention:
|
||||
*
|
||||
* # @suaveplan/<name> - <description>
|
||||
*
|
||||
* The `<description>` is sourced from each package's `package.json`
|
||||
* `description` field. The script is idempotent: rerunning with no upstream
|
||||
* changes results in zero file writes.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/hoist-readme-titles.ts # rewrite where needed
|
||||
* bun scripts/hoist-readme-titles.ts --check # exit 1 if any drift
|
||||
* bun scripts/hoist-readme-titles.ts --verbose # log every package
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — every package's README first line matches its package.json description.
|
||||
* 1 — drift detected (with `--check`) or write failures occurred.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { discoverPackageDirs } from "./lib/discover-packages.js";
|
||||
|
||||
const PKG_PREFIX = "@suaveplan/";
|
||||
const TITLE_RE = /^#\s+`?@suaveplan\/([\w-]+)`?(?:\s+[-—]\s+(.*?))?\s*$/u;
|
||||
|
||||
interface Report {
|
||||
updated: string[];
|
||||
unchanged: string[];
|
||||
missingDescription: string[];
|
||||
missingReadme: string[];
|
||||
malformedTitle: string[];
|
||||
}
|
||||
|
||||
function normalize(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function buildTitle(name: string, description: string): string {
|
||||
return `# @suaveplan/${name} - ${description}`;
|
||||
}
|
||||
|
||||
interface ProcessResult {
|
||||
next: string;
|
||||
changed: boolean;
|
||||
malformed: boolean;
|
||||
}
|
||||
|
||||
function rewriteFirstLine(
|
||||
contents: string,
|
||||
name: string,
|
||||
description: string
|
||||
): ProcessResult {
|
||||
const newlineIndex = contents.indexOf("\n");
|
||||
const firstLine =
|
||||
newlineIndex === -1 ? contents : contents.slice(0, newlineIndex);
|
||||
const rest = newlineIndex === -1 ? "" : contents.slice(newlineIndex);
|
||||
|
||||
const match = firstLine.match(TITLE_RE);
|
||||
if (!match) {
|
||||
return { next: contents, changed: false, malformed: true };
|
||||
}
|
||||
|
||||
const parsedName = match[1];
|
||||
if (parsedName !== name) {
|
||||
return { next: contents, changed: false, malformed: true };
|
||||
}
|
||||
|
||||
const existingDescription = (match[2] ?? "").trim();
|
||||
const desired = normalize(description);
|
||||
if (existingDescription === desired) {
|
||||
return { next: contents, changed: false, malformed: false };
|
||||
}
|
||||
|
||||
const nextLine = buildTitle(name, desired);
|
||||
return { next: nextLine + rest, changed: true, malformed: false };
|
||||
}
|
||||
|
||||
interface CliOptions {
|
||||
check: boolean;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): CliOptions {
|
||||
let check = false;
|
||||
let verbose = false;
|
||||
for (const arg of argv) {
|
||||
if (arg === "--check") check = true;
|
||||
else if (arg === "--verbose" || arg === "-v") verbose = true;
|
||||
else throw new Error(`Unknown flag: ${arg}`);
|
||||
}
|
||||
return { check, verbose };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let options: CliOptions;
|
||||
try {
|
||||
options = parseArgs(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pkgDirs = await discoverPackageDirs();
|
||||
const report: Report = {
|
||||
updated: [],
|
||||
unchanged: [],
|
||||
missingDescription: [],
|
||||
missingReadme: [],
|
||||
malformedTitle: [],
|
||||
};
|
||||
|
||||
for (const dir of pkgDirs) {
|
||||
const pkgJsonPath = join(dir, "package.json");
|
||||
const readmePath = join(dir, "README.md");
|
||||
|
||||
let pkgJson: { name?: string; description?: string };
|
||||
try {
|
||||
pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as {
|
||||
name?: string;
|
||||
description?: string;
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pkgJson.name?.startsWith(PKG_PREFIX)) continue;
|
||||
const shortName = pkgJson.name.slice(PKG_PREFIX.length);
|
||||
|
||||
const description = (pkgJson.description ?? "").trim();
|
||||
if (!description) {
|
||||
report.missingDescription.push(shortName);
|
||||
if (options.verbose) {
|
||||
console.log(
|
||||
` missing description: @suaveplan/${shortName} (${dir})`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let readme: string;
|
||||
try {
|
||||
readme = readFileSync(readmePath, "utf-8");
|
||||
} catch {
|
||||
report.missingReadme.push(shortName);
|
||||
if (options.verbose) {
|
||||
console.log(` missing README: @suaveplan/${shortName}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = rewriteFirstLine(readme, shortName, description);
|
||||
if (result.malformed) {
|
||||
report.malformedTitle.push(shortName);
|
||||
if (options.verbose) {
|
||||
console.log(
|
||||
` malformed title: @suaveplan/${shortName} (expected "# @suaveplan/${shortName}" on line 1)`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.changed) {
|
||||
if (!options.check) writeFileSync(readmePath, result.next);
|
||||
report.updated.push(shortName);
|
||||
if (options.verbose) {
|
||||
console.log(` updated: @suaveplan/${shortName}`);
|
||||
}
|
||||
} else {
|
||||
report.unchanged.push(shortName);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`✏️ Updated: ${report.updated.length}`);
|
||||
console.log(`⏭️ Already current: ${report.unchanged.length}`);
|
||||
if (report.missingDescription.length > 0) {
|
||||
console.log(
|
||||
`⚠️ Missing description: ${report.missingDescription.length}`
|
||||
);
|
||||
}
|
||||
if (report.missingReadme.length > 0) {
|
||||
console.log(`⚠️ Missing README: ${report.missingReadme.length}`);
|
||||
}
|
||||
if (report.malformedTitle.length > 0) {
|
||||
console.log(`❌ Malformed title: ${report.malformedTitle.length}`);
|
||||
for (const name of report.malformedTitle) {
|
||||
console.log(` - @suaveplan/${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.check) {
|
||||
if (
|
||||
report.updated.length > 0 ||
|
||||
report.malformedTitle.length > 0 ||
|
||||
report.missingReadme.length > 0
|
||||
) {
|
||||
console.error("");
|
||||
console.error(
|
||||
"Drift, malformed titles, or missing READMEs detected — run without --check to rewrite (malformed titles need manual fixing)."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Default mode: diagnostic warnings only. Pipeline steps continue.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
void main();
|
||||
294
scripts/lib/catalog-resolver.ts
Normal file
294
scripts/lib/catalog-resolver.ts
Normal 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
72
scripts/lib/config.ts
Normal 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;
|
||||
78
scripts/lib/discover-packages.ts
Normal file
78
scripts/lib/discover-packages.ts
Normal 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
260
scripts/lib/doc-tree.ts
Normal 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}`;
|
||||
}
|
||||
120
scripts/lint/check-carry-forward-deps.ts
Normal file
120
scripts/lint/check-carry-forward-deps.ts
Normal 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();
|
||||
190
scripts/lint/check-conformance-tests.ts
Normal file
190
scripts/lint/check-conformance-tests.ts
Normal 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);
|
||||
123
scripts/lint/check-cross-package-contracts.ts
Normal file
123
scripts/lint/check-cross-package-contracts.ts
Normal 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);
|
||||
222
scripts/lint/check-error-subclass-constructors.ts
Normal file
222
scripts/lint/check-error-subclass-constructors.ts
Normal 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();
|
||||
128
scripts/lint/check-feature-layout.ts
Normal file
128
scripts/lint/check-feature-layout.ts
Normal 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();
|
||||
482
scripts/lint/check-no-backwards-deps.test.ts
Normal file
482
scripts/lint/check-no-backwards-deps.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
306
scripts/lint/check-no-backwards-deps.ts
Normal file
306
scripts/lint/check-no-backwards-deps.ts
Normal 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();
|
||||
}
|
||||
469
scripts/lint/check-no-hardcoded-strings.ts
Normal file
469
scripts/lint/check-no-hardcoded-strings.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
113
scripts/lint/check-primitive-host-integration.ts
Normal file
113
scripts/lint/check-primitive-host-integration.ts
Normal 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();
|
||||
140
scripts/lint/check-stage-smoke.ts
Normal file
140
scripts/lint/check-stage-smoke.ts
Normal 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();
|
||||
222
scripts/lint/check-test-runner-imports.test.ts
Normal file
222
scripts/lint/check-test-runner-imports.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
264
scripts/lint/check-test-runner-imports.ts
Normal file
264
scripts/lint/check-test-runner-imports.ts
Normal 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();
|
||||
}
|
||||
249
scripts/lint/check-verified-by.ts
Normal file
249
scripts/lint/check-verified-by.ts
Normal 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();
|
||||
110
scripts/lint/check-wasm-subpath-deps.test.ts
Normal file
110
scripts/lint/check-wasm-subpath-deps.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
313
scripts/lint/check-wasm-subpath-deps.ts
Normal file
313
scripts/lint/check-wasm-subpath-deps.ts
Normal 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();
|
||||
}
|
||||
76
scripts/lint/clean-stacked-end-markers.ts
Normal file
76
scripts/lint/clean-stacked-end-markers.ts
Normal 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();
|
||||
53
scripts/lint/clean-truncated-markers.ts
Normal file
53
scripts/lint/clean-truncated-markers.ts
Normal 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();
|
||||
133
scripts/lint/fix-deep-imports.ts
Normal file
133
scripts/lint/fix-deep-imports.ts
Normal 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();
|
||||
131
scripts/lint/fix-external-test-imports.ts
Normal file
131
scripts/lint/fix-external-test-imports.ts
Normal 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();
|
||||
157
scripts/lint/fix-migrated-imports.ts
Normal file
157
scripts/lint/fix-migrated-imports.ts
Normal 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();
|
||||
124
scripts/lint/fix-package-config.ts
Normal file
124
scripts/lint/fix-package-config.ts
Normal 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();
|
||||
330
scripts/lint/migrate-feature-layout.ts
Normal file
330
scripts/lint/migrate-feature-layout.ts
Normal 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();
|
||||
24
scripts/lint/primitives.json
Normal file
24
scripts/lint/primitives.json
Normal 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
|
||||
}
|
||||
]
|
||||
154
scripts/lint/regen-feature-barrels.ts
Normal file
154
scripts/lint/regen-feature-barrels.ts
Normal 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
370
scripts/mark-migrated.ts
Normal 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();
|
||||
210
scripts/migrate-vitest-configs.ts
Normal file
210
scripts/migrate-vitest-configs.ts
Normal 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();
|
||||
582
scripts/openspec-conformance.ts
Normal file
582
scripts/openspec-conformance.ts
Normal 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();
|
||||
72
scripts/openspec/check-preflight.ts
Normal file
72
scripts/openspec/check-preflight.ts
Normal 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();
|
||||
33
scripts/process-package.sh
Normal file
33
scripts/process-package.sh
Normal 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
|
||||
840
scripts/publish-to-verdaccio.ts
Normal file
840
scripts/publish-to-verdaccio.ts
Normal 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
842
scripts/publish.ts
Normal 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);
|
||||
334
scripts/snapshot-docs-content.ts
Normal file
334
scripts/snapshot-docs-content.ts
Normal 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();
|
||||
208
scripts/split-audio-fx-plugin/scaffold.ts
Normal file
208
scripts/split-audio-fx-plugin/scaffold.ts
Normal 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();
|
||||
22
scripts/split-audio-fx-plugin/template/README.md.template
Normal file
22
scripts/split-audio-fx-plugin/template/README.md.template
Normal 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.
|
||||
47
scripts/split-audio-fx-plugin/template/package.json.template
Normal file
47
scripts/split-audio-fx-plugin/template/package.json.template
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({ testTimeout: 30_000 });
|
||||
179
scripts/unpublish.ts
Normal file
179
scripts/unpublish.ts
Normal 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
50
scripts/update-base-md.sh
Normal 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
149
scripts/worktree-prune.ts
Normal 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();
|
||||
Reference in New Issue
Block a user