Initial Commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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