Initial Commit
This commit is contained in:
120
scripts/lint/check-carry-forward-deps.ts
Normal file
120
scripts/lint/check-carry-forward-deps.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Enforce §18 of `openspec/AGENTS.md`: an open `[ ]` gate row in an
|
||||
* umbrella `tasks.md` MAY NOT carry-forward from another open `[ ]` gate.
|
||||
*
|
||||
* For every `openspec/changes/<umbrella>/tasks.md`, scan Section 2 rows
|
||||
* of the form `- [ ] 2.7 Stage 7 ✓ — ... carry forward from gate 2.X ...`
|
||||
* Build the dependency graph: row 2.Y depends on row 2.X (X less than Y).
|
||||
* Fail if any unchecked row depends on another unchecked row.
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { Glob } from "bun";
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, "..", "..");
|
||||
|
||||
interface GateRow {
|
||||
readonly umbrella: string;
|
||||
readonly file: string;
|
||||
readonly line: number;
|
||||
readonly id: string;
|
||||
readonly checked: boolean;
|
||||
readonly carriesFrom: readonly string[];
|
||||
}
|
||||
|
||||
const GATE_LINE_RE = /^- \[( |x)\] (2\.\d+)\b/i;
|
||||
const CARRY_RE = /carry(?:ing|s)?\s+forward\s+from\s+(?:gate\s+)?(2\.\d+)/gi;
|
||||
|
||||
async function parseUmbrella(rel: string): Promise<GateRow[]> {
|
||||
const abs = resolve(REPO_ROOT, rel);
|
||||
const source = await readFile(abs, "utf8");
|
||||
const lines = source.split("\n");
|
||||
const rows: GateRow[] = [];
|
||||
let inSection2 = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
if (line.startsWith("## 2.")) inSection2 = true;
|
||||
else if (inSection2 && line.startsWith("## ") && !line.startsWith("## 2"))
|
||||
inSection2 = false;
|
||||
if (!inSection2) continue;
|
||||
const m = GATE_LINE_RE.exec(line);
|
||||
if (!m) continue;
|
||||
const [, mark, id] = m;
|
||||
if (id === undefined || mark === undefined) continue;
|
||||
const carriesFrom: string[] = [];
|
||||
for (let j = i; j < Math.min(i + 6, lines.length); j++) {
|
||||
const win = lines[j] ?? "";
|
||||
CARRY_RE.lastIndex = 0;
|
||||
for (
|
||||
let mm = CARRY_RE.exec(win);
|
||||
mm !== null;
|
||||
mm = CARRY_RE.exec(win)
|
||||
) {
|
||||
if (mm[1] !== undefined && mm[1] !== id) {
|
||||
carriesFrom.push(mm[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
umbrella: rel.split("/")[2] ?? "?",
|
||||
file: rel,
|
||||
line: i + 1,
|
||||
id,
|
||||
checked: mark === "x" || mark === "X",
|
||||
carriesFrom,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const violations: string[] = [];
|
||||
const umbrellas: string[] = [];
|
||||
const g = new Glob("openspec/changes/*/tasks.md");
|
||||
for await (const f of g.scan({ cwd: REPO_ROOT })) {
|
||||
umbrellas.push(f);
|
||||
}
|
||||
const allRows: GateRow[] = [];
|
||||
for (const u of umbrellas) {
|
||||
allRows.push(...(await parseUmbrella(u)));
|
||||
}
|
||||
const byKey = new Map<string, GateRow>();
|
||||
for (const r of allRows) {
|
||||
byKey.set(`${r.umbrella}::${r.id}`, r);
|
||||
}
|
||||
for (const r of allRows) {
|
||||
if (r.checked) continue;
|
||||
for (const dep of r.carriesFrom) {
|
||||
const depRow = byKey.get(`${r.umbrella}::${dep}`);
|
||||
if (depRow === undefined) {
|
||||
violations.push(
|
||||
`${r.file}:${r.line} gate ${r.id} carries from ${dep} but ${dep} not found in same umbrella`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!depRow.checked) {
|
||||
violations.push(
|
||||
`${r.file}:${r.line} gate ${r.id} carries from ${dep} which is also unchecked — close ${dep} first`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (violations.length === 0) {
|
||||
console.log(
|
||||
`OK ${allRows.length} umbrella gate rows scanned; no carry-forward-from-unshipped violations.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`FAIL ${violations.length} carry-forward violation(s) - §18 forbids open-to-open carry:`
|
||||
);
|
||||
for (const v of violations) console.error(` ${v}`);
|
||||
console.error(
|
||||
"\nFix: close the earlier gate first (its row goes [x]), then unblock the dependent gate."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await main();
|
||||
190
scripts/lint/check-conformance-tests.ts
Normal file
190
scripts/lint/check-conformance-tests.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bun
|
||||
// §16 lint: conformance harnesses live with the contract
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const ROOT = resolve(import.meta.dir, "../..");
|
||||
const PACKAGES_DIR = join(ROOT, "packages");
|
||||
|
||||
interface Pkg {
|
||||
readonly name: string;
|
||||
readonly basename: string;
|
||||
readonly dir: string;
|
||||
readonly deps: ReadonlySet<string>;
|
||||
readonly skip: ReadonlySet<string>;
|
||||
readonly hasTesting: boolean;
|
||||
}
|
||||
|
||||
function findPackages(dir: string, out: Pkg[], depth = 0): void {
|
||||
if (depth > 5) return;
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (
|
||||
e.name === "node_modules" ||
|
||||
e.name === "dist" ||
|
||||
e.name.startsWith(".")
|
||||
)
|
||||
continue;
|
||||
const full = join(dir, e.name);
|
||||
if (!e.isDirectory()) continue;
|
||||
const pj = join(full, "package.json");
|
||||
try {
|
||||
if (statSync(pj).isFile()) {
|
||||
const raw = JSON.parse(readFileSync(pj, "utf-8"));
|
||||
const name = raw.name as string | undefined;
|
||||
if (name?.startsWith("@suaveplan/")) {
|
||||
out.push({
|
||||
name,
|
||||
basename: name.slice("@suaveplan/".length),
|
||||
dir: full,
|
||||
deps: new Set(
|
||||
Object.keys(raw.dependencies ?? {}).filter(
|
||||
(n: string) => n.startsWith("@suaveplan/")
|
||||
)
|
||||
),
|
||||
skip: new Set(raw.suaveplan?.conformanceSkip ?? []),
|
||||
hasTesting: Object.keys(raw.exports ?? {}).includes(
|
||||
"./testing"
|
||||
),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
/* not a pkg */
|
||||
}
|
||||
findPackages(full, out, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function harnessExportsOf(pkg: Pkg): readonly string[] {
|
||||
if (!pkg.hasTesting) return [];
|
||||
const root = join(pkg.dir, "src", "testing");
|
||||
try {
|
||||
statSync(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const names = new Set<string>();
|
||||
function scan(d: string): void {
|
||||
for (const e of readdirSync(d, { withFileTypes: true })) {
|
||||
const f = join(d, e.name);
|
||||
if (e.isDirectory()) scan(f);
|
||||
else if (e.isFile() && f.endsWith(".ts")) {
|
||||
const text = readFileSync(f, "utf-8");
|
||||
const re =
|
||||
/export\s+(?:async\s+)?function\s+(run\w+Conformance)\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
if (m[1]) names.add(m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scan(root);
|
||||
return [...names];
|
||||
}
|
||||
|
||||
function collectTestFiles(root: string, out: string[], depth = 0): void {
|
||||
if (depth > 8) return;
|
||||
let entries: ReturnType<typeof readdirSync>;
|
||||
try {
|
||||
entries = readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (
|
||||
e.name === "node_modules" ||
|
||||
e.name === "dist" ||
|
||||
e.name.startsWith(".")
|
||||
)
|
||||
continue;
|
||||
const full = join(root, e.name);
|
||||
if (e.isDirectory()) {
|
||||
collectTestFiles(full, out, depth + 1);
|
||||
continue;
|
||||
}
|
||||
if (!e.isFile()) continue;
|
||||
if (!full.endsWith(".test.ts") && !full.endsWith(".spec.ts")) continue;
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
function hasConformanceTest(
|
||||
pkg: Pkg,
|
||||
contractBasename: string,
|
||||
harnesses: readonly string[]
|
||||
): boolean {
|
||||
const candidates: string[] = [];
|
||||
collectTestFiles(join(pkg.dir, "tests"), candidates);
|
||||
collectTestFiles(join(pkg.dir, "src"), candidates);
|
||||
const harnessSet = new Set(harnesses);
|
||||
for (const file of candidates) {
|
||||
const base = file.split("/").pop() ?? "";
|
||||
if (!/conformance/i.test(base)) continue;
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(file, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (text.includes(`@suaveplan/${contractBasename}/testing`))
|
||||
return true;
|
||||
for (const h of harnessSet) {
|
||||
// function-call invocation, possibly multiline whitespace before `(`
|
||||
const re = new RegExp(`\\b${h}\\s*\\(`);
|
||||
if (re.test(text)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const pkgs: Pkg[] = [];
|
||||
findPackages(PACKAGES_DIR, pkgs);
|
||||
const byName = new Map(pkgs.map((p) => [p.name, p]));
|
||||
|
||||
interface V {
|
||||
readonly consumer: string;
|
||||
readonly contract: string;
|
||||
readonly harnesses: readonly string[];
|
||||
}
|
||||
const violations: V[] = [];
|
||||
|
||||
for (const consumer of pkgs) {
|
||||
for (const dep of consumer.deps) {
|
||||
const contract = byName.get(dep);
|
||||
if (!contract || contract.basename === consumer.basename) continue;
|
||||
const harnesses = harnessExportsOf(contract);
|
||||
if (harnesses.length === 0) continue;
|
||||
if (consumer.skip.has(contract.basename)) continue;
|
||||
if (!hasConformanceTest(consumer, contract.basename, harnesses)) {
|
||||
violations.push({
|
||||
consumer: consumer.name,
|
||||
contract: contract.name,
|
||||
harnesses,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length === 0) {
|
||||
console.log(
|
||||
`✓ check-conformance-tests: 0 violations across ${pkgs.length} packages`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(`✗ check-conformance-tests: ${violations.length} violation(s)`);
|
||||
console.error(
|
||||
"Per openspec/AGENTS.md §16, every package consuming a contract MUST run"
|
||||
);
|
||||
console.error(
|
||||
"the contract's conformance harness. Add tests/conformance/<contract>.test.ts.\n"
|
||||
);
|
||||
for (const v of violations) {
|
||||
console.error(` ${v.consumer}`);
|
||||
console.error(` consumes contract: ${v.contract}`);
|
||||
console.error(` harnesses available: ${v.harnesses.join(", ")}`);
|
||||
const base = v.contract.slice("@suaveplan/".length);
|
||||
console.error(` fix: add tests/conformance/${base}.test.ts`);
|
||||
}
|
||||
process.exit(1);
|
||||
123
scripts/lint/check-cross-package-contracts.ts
Normal file
123
scripts/lint/check-cross-package-contracts.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bun
|
||||
// §15 lint: cross-package contracts must live in contract packages
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
const ROOT = resolve(import.meta.dir, "../..");
|
||||
const PACKAGES_DIR = join(ROOT, "packages");
|
||||
const CONTRACT_BASENAMES = new Set([
|
||||
"types",
|
||||
"api-types",
|
||||
"api-manifest",
|
||||
"api-router",
|
||||
"api-graphql",
|
||||
"api-ws",
|
||||
"api-sse",
|
||||
"api-auth",
|
||||
"api-auth-provider",
|
||||
"api-secrets",
|
||||
"api-tenant-provider",
|
||||
"api-loader",
|
||||
"api-lifecycle",
|
||||
]);
|
||||
const SUPPRESSION = "@cross-package-contract";
|
||||
|
||||
interface Decl {
|
||||
readonly name: string;
|
||||
readonly file: string;
|
||||
readonly pkg: string;
|
||||
}
|
||||
|
||||
function walk(dir: string, out: string[]): void {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (
|
||||
e.name === "node_modules" ||
|
||||
e.name === "dist" ||
|
||||
e.name.startsWith(".")
|
||||
)
|
||||
continue;
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) walk(full, out);
|
||||
else if (
|
||||
e.isFile() &&
|
||||
full.endsWith(".ts") &&
|
||||
!full.endsWith(".test.ts") &&
|
||||
!full.endsWith(".spec.ts") &&
|
||||
!full.endsWith(".stories.tsx")
|
||||
)
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
function pkgOf(src: string): string | null {
|
||||
const rel = relative(PACKAGES_DIR, src);
|
||||
const parts = rel.split("/");
|
||||
const idx = parts.indexOf("src");
|
||||
return idx > 0 ? (parts[idx - 1] ?? null) : null;
|
||||
}
|
||||
|
||||
const files: string[] = [];
|
||||
walk(PACKAGES_DIR, files);
|
||||
|
||||
const decls: Decl[] = [];
|
||||
for (const f of files) {
|
||||
const text = readFileSync(f, "utf-8");
|
||||
const lines = text.split("\n");
|
||||
const re = /^\s*export\s+(?:interface\s+(\w+)|type\s+(\w+)\s*[=<])/;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const prev = i > 0 ? (lines[i - 1] ?? "") : "";
|
||||
if (prev.includes(SUPPRESSION)) continue;
|
||||
const m = re.exec(lines[i] ?? "");
|
||||
if (!m) continue;
|
||||
const name = m[1] ?? m[2];
|
||||
const pkg = pkgOf(f);
|
||||
if (!name || !pkg) continue;
|
||||
decls.push({ name, file: f, pkg });
|
||||
}
|
||||
}
|
||||
|
||||
const byName = new Map<string, Decl>();
|
||||
for (const d of decls) byName.set(d.name, d);
|
||||
|
||||
const violations = new Map<string, string[]>();
|
||||
const implRe = /\bimplements\s+([A-Z]\w*)/g;
|
||||
for (const f of files) {
|
||||
const consumerPkg = pkgOf(f);
|
||||
if (!consumerPkg) continue;
|
||||
const text = readFileSync(f, "utf-8");
|
||||
implRe.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = implRe.exec(text)) !== null) {
|
||||
const name = m[1];
|
||||
if (!name) continue;
|
||||
const d = byName.get(name);
|
||||
if (!d || d.pkg === consumerPkg) continue;
|
||||
if (CONTRACT_BASENAMES.has(d.pkg)) continue;
|
||||
const key = `${d.pkg}#${name}`;
|
||||
const arr = violations.get(key) ?? [];
|
||||
arr.push(`${relative(ROOT, f)} (${consumerPkg})`);
|
||||
violations.set(key, arr);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.size === 0) {
|
||||
console.log(
|
||||
`✓ check-cross-package-contracts: 0 violations across ${files.length} files`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(
|
||||
`✗ check-cross-package-contracts: ${violations.size} violation(s)`
|
||||
);
|
||||
console.error(
|
||||
"Per openspec/AGENTS.md §15, these types should move to a contract package"
|
||||
);
|
||||
console.error(
|
||||
"OR add `// @cross-package-contract` above the declaration to document why.\n"
|
||||
);
|
||||
for (const [k, cs] of violations) {
|
||||
const [pkg, name] = k.split("#");
|
||||
console.error(` ${name} (in ${pkg}):`);
|
||||
for (const c of cs) console.error(` implemented by: ${c}`);
|
||||
}
|
||||
process.exit(1);
|
||||
222
scripts/lint/check-error-subclass-constructors.ts
Normal file
222
scripts/lint/check-error-subclass-constructors.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fails when a class extending `SuaveplanError` does NOT declare its own
|
||||
* public constructor.
|
||||
*
|
||||
* Why this rule exists
|
||||
* --------------------
|
||||
* `SuaveplanError` is an `abstract class` with a `protected constructor`
|
||||
* (see `packages/foundation/core/error/src/base/suaveplan-error.ts`).
|
||||
* Subclasses MUST declare their own public constructor or callers cannot
|
||||
* `new SomeError(...)` — TypeScript rejects the call with TS2674
|
||||
* "Constructor of class is protected and only accessible within the class
|
||||
* declaration".
|
||||
*
|
||||
* Without this check, the missing-constructor pattern keeps drifting back
|
||||
* in via merges from stale worktrees or IDE buffer staleness — the
|
||||
* `audio-engine-server` `AudioEngineServerError` has been "restored" four
|
||||
* separate times. This script makes the next drift surface in CI before
|
||||
* it lands.
|
||||
*
|
||||
* Detection
|
||||
* ---------
|
||||
* For each `*.ts` source file (excluding tests, dist, node_modules,
|
||||
* declaration files), find every class declaration of the shape
|
||||
* `class FooError extends SuaveplanError<...>`. Within the class body,
|
||||
* look for a `public constructor(...)` declaration. If absent, the file
|
||||
* is reported.
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { Glob } from "bun";
|
||||
|
||||
interface Violation {
|
||||
readonly file: string;
|
||||
readonly className: string;
|
||||
readonly classStartLine: number;
|
||||
}
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, "..", "..");
|
||||
|
||||
const CLASS_RE =
|
||||
/^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)\s+extends\s+SuaveplanError\b/gm;
|
||||
// Match `constructor(` or `public constructor(` — TypeScript treats a
|
||||
// bare `constructor` as public when no modifier is set, so both forms
|
||||
// satisfy the "callable from outside" invariant. `private constructor`
|
||||
// and `protected constructor` are NOT matched (they're the unsafe
|
||||
// forms this check guards against).
|
||||
const PUBLIC_CTOR_RE = /^\s*(?:public\s+)?constructor\s*\(/m;
|
||||
|
||||
async function findFiles(): Promise<string[]> {
|
||||
const g = new Glob("packages/**/src/**/*.ts");
|
||||
const out: string[] = [];
|
||||
for await (const f of g.scan({ cwd: REPO_ROOT })) {
|
||||
if (
|
||||
f.includes("/node_modules/") ||
|
||||
f.includes("/dist/") ||
|
||||
f.endsWith(".d.ts") ||
|
||||
f.endsWith(".test.ts") ||
|
||||
f.endsWith(".bench.ts") ||
|
||||
f.endsWith(".spec.ts")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
out.push(f);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function classBody(source: string, classStart: number): string {
|
||||
const braceStart = source.indexOf("{", classStart);
|
||||
if (braceStart === -1) return "";
|
||||
let depth = 0;
|
||||
for (let i = braceStart; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
if (ch === "{") depth++;
|
||||
else if (ch === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return source.slice(braceStart, i + 1);
|
||||
}
|
||||
}
|
||||
return source.slice(braceStart);
|
||||
}
|
||||
|
||||
function lineOf(source: string, index: number): number {
|
||||
let line = 1;
|
||||
for (let i = 0; i < index; i++) if (source[i] === "\n") line++;
|
||||
return line;
|
||||
}
|
||||
|
||||
async function checkFile(rel: string): Promise<Violation[]> {
|
||||
const abs = resolve(REPO_ROOT, rel);
|
||||
const source = await readFile(abs, "utf8");
|
||||
if (!source.includes("extends SuaveplanError")) return [];
|
||||
const violations: Violation[] = [];
|
||||
CLASS_RE.lastIndex = 0;
|
||||
for (
|
||||
let match = CLASS_RE.exec(source);
|
||||
match !== null;
|
||||
match = CLASS_RE.exec(source)
|
||||
) {
|
||||
const [, className] = match;
|
||||
if (!className) continue;
|
||||
const body = classBody(source, match.index);
|
||||
if (!PUBLIC_CTOR_RE.test(body)) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
className,
|
||||
classStartLine: lineOf(source, match.index),
|
||||
});
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
async function isInstantiatedAnywhere(
|
||||
className: string,
|
||||
files: string[]
|
||||
): Promise<boolean> {
|
||||
// Match `new ClassName(` excluding the class's own declaration site.
|
||||
const callPattern = new RegExp(`\\bnew\\s+${className}\\s*\\(`);
|
||||
for (const f of files) {
|
||||
const abs = resolve(REPO_ROOT, f);
|
||||
const src = await readFile(abs, "utf8");
|
||||
// Skip declarations of the same class name.
|
||||
if (callPattern.test(src)) {
|
||||
// Ensure the match isn't only a comment.
|
||||
const lines = src.split("\n");
|
||||
for (const line of lines) {
|
||||
if (
|
||||
callPattern.test(line) &&
|
||||
!line.trim().startsWith("//") &&
|
||||
!line.trim().startsWith("*")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function hasSubclass(
|
||||
className: string,
|
||||
files: string[]
|
||||
): Promise<boolean> {
|
||||
// A class with at least one subclass is treated as an abstract base
|
||||
// — the subclass declares its own public constructor, and direct
|
||||
// `new` of the parent is rare / discouraged.
|
||||
const subclassPattern = new RegExp(
|
||||
`\\bclass\\s+\\w+\\s+extends\\s+${className}\\b`
|
||||
);
|
||||
for (const f of files) {
|
||||
const abs = resolve(REPO_ROOT, f);
|
||||
const src = await readFile(abs, "utf8");
|
||||
if (subclassPattern.test(src)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const files = await findFiles();
|
||||
// Also scan test files for `new ClassName(` — tests are the most
|
||||
// common construction site that tells us a class is concrete.
|
||||
const testGlob = new Glob("packages/**/src/**/*.test.ts");
|
||||
const callerFiles = [...files];
|
||||
for await (const f of testGlob.scan({ cwd: REPO_ROOT })) {
|
||||
if (f.includes("/node_modules/") || f.includes("/dist/")) continue;
|
||||
callerFiles.push(f);
|
||||
}
|
||||
const candidates: Violation[] = [];
|
||||
await Promise.all(
|
||||
files.map(async (f) => {
|
||||
const v = await checkFile(f);
|
||||
candidates.push(...v);
|
||||
})
|
||||
);
|
||||
// Filter to only those that are instantiated somewhere AND have no
|
||||
// subclass (subclass-having ones are de-facto abstract bases).
|
||||
const allViolations: Violation[] = [];
|
||||
for (const c of candidates) {
|
||||
if (await hasSubclass(c.className, files)) continue;
|
||||
if (await isInstantiatedAnywhere(c.className, callerFiles)) {
|
||||
allViolations.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (allViolations.length === 0) {
|
||||
console.log(
|
||||
`OK ${files.length.toLocaleString()} files scanned — every SuaveplanError subclass declares a public constructor.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`FAIL ${allViolations.length} SuaveplanError subclass(es) missing a public constructor:`
|
||||
);
|
||||
for (const v of allViolations) {
|
||||
console.error(
|
||||
` ${v.file}:${v.classStartLine} class ${v.className} extends SuaveplanError`
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
"\nWhy: SuaveplanError has a `protected constructor`. Subclasses that"
|
||||
);
|
||||
console.error(
|
||||
"omit their own public constructor are unconstructable from outside the"
|
||||
);
|
||||
console.error(
|
||||
"class declaration — every `new FooError(...)` call site fails TS2674."
|
||||
);
|
||||
console.error(
|
||||
"\nFix: add a constructor that forwards to `super(code, messageKey, options)`."
|
||||
);
|
||||
console.error(
|
||||
"See `packages/api-stack/api/api-saga/src/error-codes/error-codes.ts` for"
|
||||
);
|
||||
console.error("the canonical pattern.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await main();
|
||||
128
scripts/lint/check-feature-layout.ts
Normal file
128
scripts/lint/check-feature-layout.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fails when an implementation+test pair lives flat at a package's `src/`
|
||||
* root instead of inside a feature folder.
|
||||
*
|
||||
* CLAUDE.md §"Package Structure" mandates the 5-files-per-feature layout:
|
||||
*
|
||||
* src/<feature>/
|
||||
* index.ts
|
||||
* <feature>.ts
|
||||
* <feature>.test.ts
|
||||
* <feature>.types.ts
|
||||
* <feature>.md
|
||||
*
|
||||
* The only flat exceptions allowed at `src/` root are:
|
||||
* - `index.ts` (package barrel)
|
||||
* - `types/`, `utils/`, `constants/` (per the documented exemption list)
|
||||
*
|
||||
* Any other `<name>.ts` paired with `<name>.test.ts` directly under `src/`
|
||||
* (without a `<name>/` folder) is a layout violation and fails CI.
|
||||
*
|
||||
* Exit codes: 0 = clean, 1 = at least one violation.
|
||||
*/
|
||||
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PACKAGES_DIR = "packages";
|
||||
const ROOT_FLAT_EXEMPT = new Set(["index.ts"]);
|
||||
const ROOT_DIR_EXEMPT = new Set(["types", "utils", "constants"]);
|
||||
|
||||
interface Violation {
|
||||
readonly pkg: string;
|
||||
readonly src: string;
|
||||
readonly feature: string;
|
||||
readonly files: readonly string[];
|
||||
}
|
||||
|
||||
function listPackages(): readonly string[] {
|
||||
const out: string[] = [];
|
||||
for (const cat of readdirSync(PACKAGES_DIR)) {
|
||||
const catDir = join(PACKAGES_DIR, cat);
|
||||
if (!statSync(catDir).isDirectory()) continue;
|
||||
for (const plugin of readdirSync(catDir)) {
|
||||
const pluginDir = join(catDir, plugin);
|
||||
if (!statSync(pluginDir).isDirectory()) continue;
|
||||
for (const pkg of readdirSync(pluginDir)) {
|
||||
const pkgDir = join(pluginDir, pkg);
|
||||
if (!statSync(pkgDir).isDirectory()) continue;
|
||||
const srcDir = join(pkgDir, "src");
|
||||
try {
|
||||
if (statSync(srcDir).isDirectory()) out.push(pkgDir);
|
||||
} catch {
|
||||
// no src/ — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkPackage(pkgDir: string): Violation[] {
|
||||
const srcDir = join(pkgDir, "src");
|
||||
const entries = readdirSync(srcDir);
|
||||
const featuresFound = new Map<string, string[]>();
|
||||
|
||||
for (const name of entries) {
|
||||
const full = join(srcDir, name);
|
||||
if (statSync(full).isDirectory()) continue;
|
||||
if (ROOT_FLAT_EXEMPT.has(name)) continue;
|
||||
const match = name.match(/^(.+?)(\.test|\.types|\.md|)\.(ts|tsx|md)$/);
|
||||
if (!match) continue;
|
||||
const feature = match[1] as string;
|
||||
const list = featuresFound.get(feature) ?? [];
|
||||
list.push(name);
|
||||
featuresFound.set(feature, list);
|
||||
}
|
||||
|
||||
const violations: Violation[] = [];
|
||||
for (const [feature, files] of featuresFound) {
|
||||
const hasImpl = files.some((f) => f === `${feature}.ts`);
|
||||
const hasTest = files.some((f) => f === `${feature}.test.ts`);
|
||||
if (hasImpl && hasTest) {
|
||||
violations.push({
|
||||
pkg: pkgDir,
|
||||
src: srcDir,
|
||||
feature,
|
||||
files,
|
||||
});
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const pkgs = listPackages();
|
||||
const allViolations: Violation[] = [];
|
||||
for (const pkg of pkgs) {
|
||||
allViolations.push(...checkPackage(pkg));
|
||||
}
|
||||
if (allViolations.length === 0) {
|
||||
console.log(
|
||||
`OK ${pkgs.length} packages: every <feature>.ts + <feature>.test.ts pair lives in a feature folder.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`Found ${allViolations.length} layout violation${allViolations.length === 1 ? "" : "s"}:\n`
|
||||
);
|
||||
for (const v of allViolations) {
|
||||
console.error(`FAIL ${v.pkg}`);
|
||||
console.error(
|
||||
` feature \`${v.feature}\` is flat at src/ root; should live in src/${v.feature}/`
|
||||
);
|
||||
for (const f of v.files) {
|
||||
console.error(` - src/${f}`);
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
`\nCLAUDE.md §"Package Structure" mandates 5-files-per-feature layout. Only ${[...ROOT_DIR_EXEMPT].map((d) => `\`${d}/\``).join(", ")} dirs and \`index.ts\` are exempt at src/ root.`
|
||||
);
|
||||
console.error(
|
||||
"Move each violation into a folder: mkdir src/<feature>/ && git mv src/<feature>.{ts,test.ts,md} src/<feature>/, then add a src/<feature>/index.ts barrel and update sibling imports."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
482
scripts/lint/check-no-backwards-deps.test.ts
Normal file
482
scripts/lint/check-no-backwards-deps.test.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* Tests for check-no-backwards-deps.ts.
|
||||
*
|
||||
* Bun-test runner (precedent: scripts/lint/check-test-runner-imports.test.ts):
|
||||
* the canonical-runner-wrapper rule polices `packages/<plugin>/<pkg>/src/`
|
||||
* test files, not repo-tooling tests under `scripts/`.
|
||||
*
|
||||
* Real fixtures: every test writes a real package.json to a real temp
|
||||
* directory and runs the script's `findViolations` against the file.
|
||||
* No fs mocks — the temp-dir lifecycle is owned via beforeEach/afterEach.
|
||||
*
|
||||
* Includes the "passing case for the live react-i18n package.json" so a
|
||||
* future regression (someone adds @suaveplan/react-ui to dependencies)
|
||||
* fails this test loudly and the CI gate, not just the gate.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
BANNED_PATTERNS,
|
||||
bannedPatternsFor,
|
||||
defaultPackageJsonPath,
|
||||
findViolations,
|
||||
formatViolations,
|
||||
INSPECTED_BUCKETS,
|
||||
} from "./check-no-backwards-deps.ts";
|
||||
|
||||
const REPO_ROOT = resolve(__dirname, "../..");
|
||||
|
||||
interface Fixture {
|
||||
readonly root: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
async function makeFixture(): Promise<Fixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), "check-no-backwards-deps-"));
|
||||
return {
|
||||
root,
|
||||
async cleanup() {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeManifest(
|
||||
root: string,
|
||||
manifest: Record<string, unknown>
|
||||
): Promise<string> {
|
||||
const path = join(root, "package.json");
|
||||
await writeFile(path, JSON.stringify(manifest, null, 2));
|
||||
return path;
|
||||
}
|
||||
|
||||
describe("check-no-backwards-deps — fixture cases", () => {
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => {
|
||||
fixture = await makeFixture();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
it("passes for a clean manifest with only foundation deps", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-i18n",
|
||||
dependencies: {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/i18n": "workspace:^",
|
||||
react: "^19.0.0",
|
||||
},
|
||||
peerDependencies: { react: "^19.0.0" },
|
||||
devDependencies: { "@suaveplan/testing": "workspace:^" },
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toEqual([]);
|
||||
expect(formatViolations(violations)).toContain(
|
||||
"no React-stack backward dependencies"
|
||||
);
|
||||
});
|
||||
|
||||
it("flags @suaveplan/react-ui in dependencies", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-i18n",
|
||||
dependencies: { "@suaveplan/react-ui": "*" },
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]?.bucket).toBe("dependencies");
|
||||
expect(violations[0]?.depName).toBe("@suaveplan/react-ui");
|
||||
expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-ui*");
|
||||
expect(formatViolations(violations)).toContain(
|
||||
"React-stack backward dependencies detected"
|
||||
);
|
||||
});
|
||||
|
||||
it("flags @suaveplan/react-forms in peerDependencies", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-i18n",
|
||||
peerDependencies: { "@suaveplan/react-forms": "*" },
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]?.bucket).toBe("peerDependencies");
|
||||
expect(violations[0]?.depName).toBe("@suaveplan/react-forms");
|
||||
expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-forms*");
|
||||
});
|
||||
|
||||
it("flags @suaveplan/react-ui-theme-dracula in devDependencies", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-i18n",
|
||||
devDependencies: {
|
||||
"@suaveplan/react-ui-theme-dracula": "*",
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]?.bucket).toBe("devDependencies");
|
||||
expect(violations[0]?.depName).toBe(
|
||||
"@suaveplan/react-ui-theme-dracula"
|
||||
);
|
||||
expect(violations[0]?.matchedPattern).toBe(
|
||||
"@suaveplan/react-ui-theme-*"
|
||||
);
|
||||
});
|
||||
|
||||
it("flags @suaveplan/react-ui-pack-auth across all three buckets", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-i18n",
|
||||
dependencies: { "@suaveplan/react-ui-pack-auth": "1.0.0" },
|
||||
peerDependencies: { "@suaveplan/react-ui-pack-auth": "*" },
|
||||
devDependencies: { "@suaveplan/react-ui-pack-auth": "*" },
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toHaveLength(3);
|
||||
for (const v of violations) {
|
||||
expect(v.matchedPattern).toBe("@suaveplan/react-ui-pack-*");
|
||||
}
|
||||
});
|
||||
|
||||
it("flags multiple banned deps and groups them by bucket in the diagnostic", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-i18n",
|
||||
dependencies: {
|
||||
"@suaveplan/react-ui": "*",
|
||||
"@suaveplan/react-forms": "*",
|
||||
},
|
||||
devDependencies: {
|
||||
"@suaveplan/react-ui-theme-x": "*",
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toHaveLength(3);
|
||||
const message = formatViolations(violations);
|
||||
expect(message).toContain("dependencies (2 violations)");
|
||||
expect(message).toContain("devDependencies (1 violation)");
|
||||
expect(message).toContain("Total: 3 violations");
|
||||
});
|
||||
|
||||
it("ignores absent dep buckets", { timeout: 5000 }, async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-i18n",
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores buckets with non-object values", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// A malformed bucket (array instead of object) is the consumer's
|
||||
// bug — outside the scope of this rule. The script must not
|
||||
// crash on it.
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-i18n",
|
||||
dependencies: ["@suaveplan/react-ui"] as unknown,
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores buckets with null values", { timeout: 5000 }, async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-i18n",
|
||||
dependencies: null,
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT flag arbitrary @suaveplan/* packages outside the banned set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-i18n",
|
||||
dependencies: {
|
||||
"@suaveplan/i18n": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@suaveplan/react-i18n": "workspace:^",
|
||||
react: "^19.0.0",
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders non-string version values via JSON.stringify in the diagnostic", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-i18n",
|
||||
dependencies: {
|
||||
"@suaveplan/react-ui": { version: "1.0.0" } as unknown,
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]?.version).toContain('"version":"1.0.0"');
|
||||
});
|
||||
|
||||
it("throws on a missing package.json", { timeout: 5000 }, async () => {
|
||||
await expect(
|
||||
findViolations({
|
||||
packageJsonPath: join(fixture.root, "missing.json"),
|
||||
})
|
||||
).rejects.toThrow(/cannot read/);
|
||||
});
|
||||
|
||||
it("throws on an unparsable package.json", { timeout: 5000 }, async () => {
|
||||
const path = join(fixture.root, "package.json");
|
||||
await writeFile(path, "{not json");
|
||||
await expect(findViolations({ packageJsonPath: path })).rejects.toThrow(
|
||||
/cannot parse/
|
||||
);
|
||||
});
|
||||
|
||||
it("respects an injected readFile dependency", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const fakeRead = async (path: import("node:fs").PathLike) => {
|
||||
calls.push(String(path));
|
||||
return JSON.stringify({
|
||||
name: "@suaveplan/react-i18n",
|
||||
dependencies: {
|
||||
"@suaveplan/react-forms-validation": "*",
|
||||
},
|
||||
});
|
||||
};
|
||||
const violations = await findViolations({
|
||||
packageJsonPath: "/virtual/package.json",
|
||||
readFileFn: fakeRead as Parameters<
|
||||
typeof findViolations
|
||||
>[0]["readFileFn"],
|
||||
});
|
||||
expect(calls).toEqual(["/virtual/package.json"]);
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-forms*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("check-no-backwards-deps — live react-i18n package.json", () => {
|
||||
it("the real package.json declares no banned deps", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const target = defaultPackageJsonPath(REPO_ROOT);
|
||||
const violations = await findViolations({
|
||||
packageJsonPath: target,
|
||||
});
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("check-no-backwards-deps — registry sanity", () => {
|
||||
it("INSPECTED_BUCKETS lists exactly the three audited dep buckets", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(INSPECTED_BUCKETS).toEqual([
|
||||
"dependencies",
|
||||
"peerDependencies",
|
||||
"devDependencies",
|
||||
]);
|
||||
});
|
||||
|
||||
it("BANNED_PATTERNS labels are stable", { timeout: 5000 }, () => {
|
||||
const labels = BANNED_PATTERNS.map((p) => p.label).sort();
|
||||
expect(labels).toEqual([
|
||||
"@suaveplan/react-forms*",
|
||||
"@suaveplan/react-ui*",
|
||||
"@suaveplan/react-ui-pack-*",
|
||||
"@suaveplan/react-ui-theme-*",
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaultPackageJsonPath resolves under <root>/packages/react/react-i18n", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const path = defaultPackageJsonPath("/virtual/repo");
|
||||
expect(path).toBe(
|
||||
"/virtual/repo/packages/react/react-i18n/package.json"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("check-no-backwards-deps — per-tier banned-pattern hierarchy", () => {
|
||||
it("react-i18n bans every leaf in the React stack", () => {
|
||||
const labels = bannedPatternsFor("@suaveplan/react-i18n")
|
||||
.map((p) => p.label)
|
||||
.sort();
|
||||
expect(labels).toEqual([
|
||||
"@suaveplan/react-forms*",
|
||||
"@suaveplan/react-ui*",
|
||||
"@suaveplan/react-ui-pack-*",
|
||||
"@suaveplan/react-ui-theme-*",
|
||||
]);
|
||||
});
|
||||
|
||||
it("react-ui bans react-forms, react-ui-theme-*, react-ui-pack-* (CAN dep react-i18n)", () => {
|
||||
const labels = bannedPatternsFor("@suaveplan/react-ui")
|
||||
.map((p) => p.label)
|
||||
.sort();
|
||||
expect(labels).toEqual([
|
||||
"@suaveplan/react-forms*",
|
||||
"@suaveplan/react-ui-pack-*",
|
||||
"@suaveplan/react-ui-theme-*",
|
||||
]);
|
||||
});
|
||||
|
||||
it("react-forms bans theme-* and pack-* (CAN dep react-i18n + react-ui)", () => {
|
||||
const labels = bannedPatternsFor("@suaveplan/react-forms")
|
||||
.map((p) => p.label)
|
||||
.sort();
|
||||
expect(labels).toEqual([
|
||||
"@suaveplan/react-ui-pack-*",
|
||||
"@suaveplan/react-ui-theme-*",
|
||||
]);
|
||||
});
|
||||
|
||||
it("react-ui-theme-* bans react-forms* and pack-* (CAN dep react-i18n + react-ui)", () => {
|
||||
const labels = bannedPatternsFor("@suaveplan/react-ui-theme-dracula")
|
||||
.map((p) => p.label)
|
||||
.sort();
|
||||
expect(labels).toEqual([
|
||||
"@suaveplan/react-forms*",
|
||||
"@suaveplan/react-ui-pack-*",
|
||||
]);
|
||||
});
|
||||
|
||||
it("react-ui-pack-* bans NOTHING in the React stack (bottom of hierarchy)", () => {
|
||||
expect(bannedPatternsFor("@suaveplan/react-ui-pack-auth")).toEqual([]);
|
||||
});
|
||||
|
||||
it("packages outside the React stack get a no-op (empty banned list)", () => {
|
||||
expect(bannedPatternsFor("@suaveplan/error")).toEqual([]);
|
||||
expect(bannedPatternsFor("@suaveplan/i18n")).toEqual([]);
|
||||
expect(bannedPatternsFor("@some-vendor/lib")).toEqual([]);
|
||||
});
|
||||
|
||||
it("undefined / non-string package names yield empty banned list", () => {
|
||||
expect(bannedPatternsFor(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("check-no-backwards-deps — per-tier enforcement", () => {
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => {
|
||||
fixture = await makeFixture();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
it("react-ui declaring @suaveplan/react-i18n is allowed (upstream dep)", async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-ui",
|
||||
dependencies: { "@suaveplan/react-i18n": "workspace:^" },
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("react-ui declaring @suaveplan/react-forms IS a violation", async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-ui",
|
||||
dependencies: { "@suaveplan/react-forms": "workspace:^" },
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-forms*");
|
||||
});
|
||||
|
||||
it("react-forms declaring @suaveplan/react-ui is allowed (upstream dep)", async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-forms",
|
||||
dependencies: {
|
||||
"@suaveplan/react-ui": "workspace:^",
|
||||
"@suaveplan/react-i18n": "workspace:^",
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("react-forms declaring @suaveplan/react-ui-theme-dracula IS a violation", async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-forms",
|
||||
dependencies: {
|
||||
"@suaveplan/react-ui-theme-dracula": "workspace:^",
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]?.matchedPattern).toBe(
|
||||
"@suaveplan/react-ui-theme-*"
|
||||
);
|
||||
});
|
||||
|
||||
it("react-ui-theme-dracula declaring react-i18n + react-ui is allowed", async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-ui-theme-dracula",
|
||||
dependencies: {
|
||||
"@suaveplan/react-i18n": "workspace:^",
|
||||
"@suaveplan/react-ui": "workspace:^",
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("react-ui-theme-dracula declaring react-forms IS a violation", async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-ui-theme-dracula",
|
||||
dependencies: { "@suaveplan/react-forms": "workspace:^" },
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]?.matchedPattern).toBe("@suaveplan/react-forms*");
|
||||
});
|
||||
|
||||
it("react-ui-pack-auth can declare react-i18n + react-ui + react-forms (bottom-most tier)", async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/react-ui-pack-auth",
|
||||
dependencies: {
|
||||
"@suaveplan/react-i18n": "workspace:^",
|
||||
"@suaveplan/react-ui": "workspace:^",
|
||||
"@suaveplan/react-forms": "workspace:^",
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("packages outside the React stack get a clean pass even with React-stack deps", async () => {
|
||||
const path = await writeManifest(fixture.root, {
|
||||
name: "@suaveplan/some-other-package",
|
||||
dependencies: {
|
||||
"@suaveplan/react-i18n": "workspace:^",
|
||||
"@suaveplan/react-ui": "workspace:^",
|
||||
"@suaveplan/react-forms": "workspace:^",
|
||||
"@suaveplan/react-ui-theme-dracula": "workspace:^",
|
||||
"@suaveplan/react-ui-pack-auth": "workspace:^",
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ packageJsonPath: path });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
306
scripts/lint/check-no-backwards-deps.ts
Normal file
306
scripts/lint/check-no-backwards-deps.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* check-no-backwards-deps.ts
|
||||
*
|
||||
* CI gate enforcing the React-stack dependency-direction invariant
|
||||
* declared in:
|
||||
* - openspec/changes/enterprise-react-i18n/specs/react-i18n/spec.md
|
||||
* ("Branded TranslationKey typing contract")
|
||||
* - openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md §11.2
|
||||
*
|
||||
* The React stack has a strict layered hierarchy:
|
||||
*
|
||||
* react-i18n (foundation; has no upstream React-stack deps)
|
||||
* ▲
|
||||
* react-ui (depends on react-i18n only)
|
||||
* ▲
|
||||
* react-forms (depends on react-i18n + react-ui)
|
||||
* ▲
|
||||
* react-ui-theme-* (depends on react-i18n + react-ui)
|
||||
* react-ui-pack-* (depends on react-i18n + react-ui [+ react-forms])
|
||||
*
|
||||
* Each tier MUST NOT declare a downstream-tier package in its
|
||||
* `dependencies`, `peerDependencies`, or `devDependencies` — otherwise
|
||||
* the layering inverts, the TranslationKey augmentation contract breaks,
|
||||
* and tree-shaking guarantees collapse.
|
||||
*
|
||||
* This script reads a package.json, identifies the package by its `name`,
|
||||
* computes the appropriate banned-pattern subset for that tier, and
|
||||
* checks the three dep buckets. Exits non-zero with a structured
|
||||
* diagnostic listing every offending (bucket, package) pair.
|
||||
*
|
||||
* Packages outside the React stack (`@suaveplan/error`, `@suaveplan/i18n`,
|
||||
* `@some/other`) get a no-op pass — this gate is React-stack-specific.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/lint/check-no-backwards-deps.ts [--root <repo-root>]
|
||||
* [--package-json <path>]
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — package.json declares no banned deps (or is outside React stack)
|
||||
* 1 — at least one banned dep found (details on stderr)
|
||||
* 2 — internal error (cannot read or parse package.json)
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export interface BannedPattern {
|
||||
readonly label: string;
|
||||
readonly match: (depName: string) => boolean;
|
||||
}
|
||||
|
||||
const PATTERN_REACT_UI_THEME: BannedPattern = {
|
||||
label: "@suaveplan/react-ui-theme-*",
|
||||
match: (n) => n.startsWith("@suaveplan/react-ui-theme-"),
|
||||
};
|
||||
|
||||
const PATTERN_REACT_UI_PACK: BannedPattern = {
|
||||
label: "@suaveplan/react-ui-pack-*",
|
||||
match: (n) => n.startsWith("@suaveplan/react-ui-pack-"),
|
||||
};
|
||||
|
||||
const PATTERN_REACT_UI: BannedPattern = {
|
||||
label: "@suaveplan/react-ui*",
|
||||
match: (n) =>
|
||||
n === "@suaveplan/react-ui" ||
|
||||
(n.startsWith("@suaveplan/react-ui-") &&
|
||||
!n.startsWith("@suaveplan/react-ui-theme-") &&
|
||||
!n.startsWith("@suaveplan/react-ui-pack-")),
|
||||
};
|
||||
|
||||
const PATTERN_REACT_FORMS: BannedPattern = {
|
||||
label: "@suaveplan/react-forms*",
|
||||
match: (n) =>
|
||||
n === "@suaveplan/react-forms" ||
|
||||
n.startsWith("@suaveplan/react-forms-"),
|
||||
};
|
||||
|
||||
/**
|
||||
* Full registry of every banned-pattern in the React stack. Per-tier
|
||||
* banned subsets are derived from this set. Stable for downstream
|
||||
* consumers (test fixtures, doc generators).
|
||||
*/
|
||||
export const BANNED_PATTERNS: ReadonlyArray<BannedPattern> = [
|
||||
PATTERN_REACT_UI_THEME,
|
||||
PATTERN_REACT_UI_PACK,
|
||||
PATTERN_REACT_UI,
|
||||
PATTERN_REACT_FORMS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Per-tier banned-pattern map. Each tier's entry lists the patterns
|
||||
* that MUST NOT appear in any of its dep buckets. Computed from the
|
||||
* stack hierarchy:
|
||||
*
|
||||
* react-i18n → bans every leaf (react-ui*, react-forms*, theme-*, pack-*)
|
||||
* react-ui → bans every leaf below it (react-forms*, theme-*, pack-*)
|
||||
* react-forms → bans theme-* and pack-* only (CAN dep react-ui)
|
||||
* react-ui-theme-* → bans react-forms* and pack-* (themes are siblings of forms;
|
||||
* packs sit downstream of themes)
|
||||
* react-ui-pack-* → bans nothing in the React stack (packs are bottom-most)
|
||||
*/
|
||||
const TIER_BANNED: Record<string, ReadonlyArray<BannedPattern>> = {
|
||||
"@suaveplan/react-i18n": BANNED_PATTERNS,
|
||||
"@suaveplan/react-ui": [
|
||||
PATTERN_REACT_UI_THEME,
|
||||
PATTERN_REACT_UI_PACK,
|
||||
PATTERN_REACT_FORMS,
|
||||
],
|
||||
"@suaveplan/react-forms": [PATTERN_REACT_UI_THEME, PATTERN_REACT_UI_PACK],
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the banned-pattern subset for a given package name. Returns
|
||||
* an empty array for packages outside the React stack so the gate
|
||||
* no-ops on them.
|
||||
*/
|
||||
export function bannedPatternsFor(
|
||||
packageName: string | undefined
|
||||
): ReadonlyArray<BannedPattern> {
|
||||
if (typeof packageName !== "string") return [];
|
||||
if (Object.hasOwn(TIER_BANNED, packageName)) {
|
||||
return TIER_BANNED[packageName] as ReadonlyArray<BannedPattern>;
|
||||
}
|
||||
if (packageName.startsWith("@suaveplan/react-ui-theme-")) {
|
||||
return [PATTERN_REACT_FORMS, PATTERN_REACT_UI_PACK];
|
||||
}
|
||||
if (packageName.startsWith("@suaveplan/react-ui-pack-")) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The package.json buckets we inspect. Listed explicitly (rather than
|
||||
* walking every key) so the rule is auditable and so unrelated buckets
|
||||
* (e.g. `optionalDependencies`, `bundleDependencies`) cannot smuggle a
|
||||
* banned dep through.
|
||||
*/
|
||||
export const INSPECTED_BUCKETS = [
|
||||
"dependencies",
|
||||
"peerDependencies",
|
||||
"devDependencies",
|
||||
] as const;
|
||||
|
||||
export type InspectedBucket = (typeof INSPECTED_BUCKETS)[number];
|
||||
|
||||
export interface Violation {
|
||||
readonly bucket: InspectedBucket;
|
||||
readonly depName: string;
|
||||
readonly version: string;
|
||||
readonly matchedPattern: string;
|
||||
}
|
||||
|
||||
export interface CheckOptions {
|
||||
readonly packageJsonPath: string;
|
||||
readonly readFileFn?: typeof readFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the supplied `package.json` and return every banned dep
|
||||
* declared in `dependencies`, `peerDependencies`, or
|
||||
* `devDependencies`. Buckets that are absent or malformed contribute
|
||||
* zero violations (a malformed bucket is the consumer's bug — out of
|
||||
* scope for this gate).
|
||||
*/
|
||||
export async function findViolations(
|
||||
options: CheckOptions
|
||||
): Promise<Violation[]> {
|
||||
const readImpl = options.readFileFn ?? readFile;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readImpl(options.packageJsonPath, "utf8");
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`check-no-backwards-deps: cannot read ${options.packageJsonPath}: ${(cause as Error).message}`
|
||||
);
|
||||
}
|
||||
let manifest: Record<string, unknown>;
|
||||
try {
|
||||
manifest = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`check-no-backwards-deps: cannot parse ${options.packageJsonPath}: ${(cause as Error).message}`
|
||||
);
|
||||
}
|
||||
const packageName =
|
||||
typeof manifest.name === "string" ? manifest.name : undefined;
|
||||
const banned = bannedPatternsFor(packageName);
|
||||
if (banned.length === 0) return [];
|
||||
const violations: Violation[] = [];
|
||||
for (const bucket of INSPECTED_BUCKETS) {
|
||||
const value = manifest[bucket];
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value !== "object" || Array.isArray(value)) continue;
|
||||
const deps = value as Record<string, unknown>;
|
||||
for (const [depName, depVersion] of Object.entries(deps)) {
|
||||
for (const pattern of banned) {
|
||||
if (!pattern.match(depName)) continue;
|
||||
violations.push({
|
||||
bucket,
|
||||
depName,
|
||||
version:
|
||||
typeof depVersion === "string"
|
||||
? depVersion
|
||||
: JSON.stringify(depVersion),
|
||||
matchedPattern: pattern.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a human-readable diagnostic block. The empty-violations form
|
||||
* is the single line printed on stdout for the success case.
|
||||
*/
|
||||
export function formatViolations(violations: readonly Violation[]): string {
|
||||
if (violations.length === 0) {
|
||||
return "Package declares no React-stack backward dependencies.";
|
||||
}
|
||||
const lines: string[] = [
|
||||
"❌ React-stack backward dependencies detected.",
|
||||
"",
|
||||
"Rule: openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md §11.2.",
|
||||
"Fix: remove the dep — each tier in the stack must only depend on tiers",
|
||||
" strictly above it (react-i18n → react-ui → react-forms / theme / pack).",
|
||||
"",
|
||||
];
|
||||
const grouped = new Map<InspectedBucket, Violation[]>();
|
||||
for (const v of violations) {
|
||||
const list = grouped.get(v.bucket) ?? [];
|
||||
list.push(v);
|
||||
grouped.set(v.bucket, list);
|
||||
}
|
||||
for (const bucket of INSPECTED_BUCKETS) {
|
||||
const items = grouped.get(bucket);
|
||||
if (!items || items.length === 0) continue;
|
||||
lines.push(
|
||||
` ${bucket} (${items.length} violation${items.length === 1 ? "" : "s"})`
|
||||
);
|
||||
for (const v of items) {
|
||||
lines.push(
|
||||
` ${v.depName}@${v.version} (matched: ${v.matchedPattern})`
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(
|
||||
`Total: ${violations.length} violation${violations.length === 1 ? "" : "s"}.`
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Default canonical path: react-i18n's `package.json` inside the repo.
|
||||
* The CLI accepts overrides for both the repo root and the package.json
|
||||
* path so test fixtures can drive the script without mutating the real
|
||||
* file.
|
||||
*/
|
||||
export function defaultPackageJsonPath(repoRoot: string): string {
|
||||
return join(
|
||||
resolve(repoRoot),
|
||||
"packages",
|
||||
"react",
|
||||
"react-i18n",
|
||||
"package.json"
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
let repoRoot = process.cwd();
|
||||
let packageJsonPath: string | undefined;
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === "--root" && i + 1 < argv.length) {
|
||||
repoRoot = resolve(argv[i + 1] ?? ".");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (argv[i] === "--package-json" && i + 1 < argv.length) {
|
||||
packageJsonPath = resolve(argv[i + 1] ?? ".");
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
const target = packageJsonPath ?? defaultPackageJsonPath(repoRoot);
|
||||
let violations: readonly Violation[];
|
||||
try {
|
||||
violations = await findViolations({ packageJsonPath: target });
|
||||
} catch (cause) {
|
||||
process.stderr.write(`${(cause as Error).message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
const message = formatViolations(violations);
|
||||
if (violations.length === 0) {
|
||||
process.stdout.write(`${message}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await main();
|
||||
}
|
||||
469
scripts/lint/check-no-hardcoded-strings.ts
Normal file
469
scripts/lint/check-no-hardcoded-strings.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* check-no-hardcoded-strings.ts
|
||||
*
|
||||
* CI gate enforcing that every user-perceivable string in the React-tier
|
||||
* packages flows through a translation call (`t(...)`, `<Trans>`,
|
||||
* `i18n.t(...)`, `tDynamic(...)`) rather than being hardcoded as a
|
||||
* literal. The gate walks .tsx / .ts files (skipping tests, stories,
|
||||
* type files, and barrels) and flags three rule families:
|
||||
*
|
||||
* 1. JSX text nodes that are not whitespace-only and not wrapped in
|
||||
* a translation call.
|
||||
* 2. JSX attribute values on the watchlist (`aria-label`,
|
||||
* `aria-description`, `aria-roledescription`, `aria-valuetext`,
|
||||
* `aria-keyshortcuts`, `placeholder`, `title`, `alt`) that are
|
||||
* string literals.
|
||||
* 3. Object-literal properties whose key is on the watchlist
|
||||
* (`label`, `message`, `description`, `tooltip`, `placeholder`,
|
||||
* `errorMessage`, `helperText`, `validationMessage`) and whose
|
||||
* value is a string literal.
|
||||
*
|
||||
* Watchlist is extensible via a per-package `lint.config.ts` (loaded
|
||||
* from the package root if present); v1 ships with the defaults
|
||||
* documented above.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/lint/check-no-hardcoded-strings.ts <root> [<root>...]
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — clean
|
||||
* 1 — at least one violation
|
||||
* 2 — internal failure
|
||||
*/
|
||||
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { extname, join, relative, resolve } from "node:path";
|
||||
import * as ts from "typescript";
|
||||
|
||||
export interface Diagnostic {
|
||||
readonly file: string;
|
||||
readonly line: number;
|
||||
readonly column: number;
|
||||
readonly rule: string;
|
||||
readonly snippet: string;
|
||||
}
|
||||
|
||||
export const ARIA_ATTRIBUTE_WATCHLIST = new Set<string>([
|
||||
"aria-label",
|
||||
"aria-description",
|
||||
"aria-roledescription",
|
||||
"aria-valuetext",
|
||||
"aria-keyshortcuts",
|
||||
"placeholder",
|
||||
"title",
|
||||
"alt",
|
||||
]);
|
||||
|
||||
/**
|
||||
* `description` is intentionally NOT in the default watchlist because
|
||||
* OTel `createCounter` / `createHistogram` calls accept a `description`
|
||||
* option whose value is metadata, not user-visible UI copy. Form
|
||||
* libraries that DO use `description` as user-facing copy (the
|
||||
* `react-forms` field-config shape) layer their own gate atop this
|
||||
* file and add `description` to the watchlist there.
|
||||
*/
|
||||
export const OBJECT_PROPERTY_WATCHLIST = new Set<string>([
|
||||
"label",
|
||||
"message",
|
||||
"tooltip",
|
||||
"placeholder",
|
||||
"errorMessage",
|
||||
"helperText",
|
||||
"validationMessage",
|
||||
]);
|
||||
|
||||
const TRANSLATION_CALL_NAMES = new Set<string>(["t", "tDynamic"]);
|
||||
|
||||
const TRANSLATION_MEMBER_NAMES = new Set<string>(["t", "tDynamic"]);
|
||||
|
||||
const TRANS_COMPONENT_NAMES = new Set<string>(["Trans"]);
|
||||
|
||||
const SKIP_SUFFIXES = [
|
||||
".test.ts",
|
||||
".test.tsx",
|
||||
".test-fixtures.ts",
|
||||
".test-fixtures.tsx",
|
||||
".spec.ts",
|
||||
".stories.ts",
|
||||
".stories.tsx",
|
||||
".bench.ts",
|
||||
".d.ts",
|
||||
];
|
||||
|
||||
const SKIP_FILENAMES = new Set<string>(["augmentation.ts", "schema.ts"]);
|
||||
|
||||
interface ScanOptions {
|
||||
readonly root: string;
|
||||
}
|
||||
|
||||
function isSkippableFile(file: string): boolean {
|
||||
const base = file.split("/").pop() ?? "";
|
||||
if (SKIP_FILENAMES.has(base)) return true;
|
||||
for (const suffix of SKIP_SUFFIXES) {
|
||||
if (file.endsWith(suffix)) return true;
|
||||
}
|
||||
if (file.includes("/spike/")) return true;
|
||||
if (file.includes("/__benchmarks__/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function listSourceFiles(root: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
async function walk(dir: string): Promise<void> {
|
||||
let entries: {
|
||||
name: string;
|
||||
isDirectory(): boolean;
|
||||
isFile(): boolean;
|
||||
}[];
|
||||
try {
|
||||
entries = (await readdir(dir, { withFileTypes: true })) as Array<{
|
||||
name: string;
|
||||
isDirectory(): boolean;
|
||||
isFile(): boolean;
|
||||
}>;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (
|
||||
entry.name === "node_modules" ||
|
||||
entry.name === "dist" ||
|
||||
entry.name === ".turbo" ||
|
||||
entry.name === "coverage"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = extname(entry.name);
|
||||
if (ext !== ".ts" && ext !== ".tsx") continue;
|
||||
if (isSkippableFile(full)) continue;
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
const s = await stat(root).catch(() => null);
|
||||
if (!s) return [];
|
||||
if (s.isDirectory()) {
|
||||
await walk(root);
|
||||
} else {
|
||||
out.push(root);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isTranslationCall(node: ts.Node): boolean {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const callee = node.expression;
|
||||
if (
|
||||
ts.isIdentifier(callee) &&
|
||||
TRANSLATION_CALL_NAMES.has(callee.text)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
ts.isPropertyAccessExpression(callee) &&
|
||||
ts.isIdentifier(callee.name) &&
|
||||
TRANSLATION_MEMBER_NAMES.has(callee.name.text)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTransComponent(node: ts.Node): boolean {
|
||||
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
||||
const open = ts.isJsxElement(node) ? node.openingElement : node;
|
||||
const tagName = open.tagName;
|
||||
if (
|
||||
ts.isIdentifier(tagName) &&
|
||||
TRANS_COMPONENT_NAMES.has(tagName.text)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findTranslationOrTrans(expr: ts.Node): boolean {
|
||||
let found = false;
|
||||
function visit(n: ts.Node): void {
|
||||
if (found) return;
|
||||
if (isTranslationCall(n) || isTransComponent(n)) {
|
||||
found = true;
|
||||
return;
|
||||
}
|
||||
n.forEachChild(visit);
|
||||
}
|
||||
visit(expr);
|
||||
return found;
|
||||
}
|
||||
|
||||
function lineCol(
|
||||
sourceFile: ts.SourceFile,
|
||||
pos: number
|
||||
): { line: number; column: number } {
|
||||
const lc = sourceFile.getLineAndCharacterOfPosition(pos);
|
||||
return { line: lc.line + 1, column: lc.character + 1 };
|
||||
}
|
||||
|
||||
function scanSource(filePath: string, source: string): Diagnostic[] {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX
|
||||
);
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
// Rule 1: bare JSX text node
|
||||
if (ts.isJsxText(node)) {
|
||||
const text = node.text;
|
||||
if (text.trim().length > 0) {
|
||||
const { line, column } = lineCol(
|
||||
sourceFile,
|
||||
node.getStart(sourceFile)
|
||||
);
|
||||
diagnostics.push({
|
||||
file: filePath,
|
||||
line,
|
||||
column,
|
||||
rule: "jsx-text",
|
||||
snippet: text.trim().slice(0, 120),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 1b: JSX expression-container holding a string literal
|
||||
if (ts.isJsxExpression(node)) {
|
||||
const expr = node.expression;
|
||||
if (
|
||||
expr !== undefined &&
|
||||
(ts.isStringLiteral(expr) ||
|
||||
ts.isNoSubstitutionTemplateLiteral(expr))
|
||||
) {
|
||||
// Only a violation when the expression-container is a CHILD
|
||||
// of a JsxElement (i.e. {`...`} as text). Attribute values
|
||||
// are handled by Rule 2 below.
|
||||
const parent = node.parent;
|
||||
if (
|
||||
parent !== undefined &&
|
||||
(ts.isJsxElement(parent) || ts.isJsxFragment(parent))
|
||||
) {
|
||||
if (expr.text.trim().length > 0) {
|
||||
const { line, column } = lineCol(
|
||||
sourceFile,
|
||||
expr.getStart(sourceFile)
|
||||
);
|
||||
diagnostics.push({
|
||||
file: filePath,
|
||||
line,
|
||||
column,
|
||||
rule: "jsx-text-expression",
|
||||
snippet: expr.text.slice(0, 120),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (expr !== undefined) {
|
||||
// Non-literal expression — only flag when none of the
|
||||
// sub-expressions invoke t() / Trans / etc.
|
||||
// Skip — non-literal expressions are presumed dynamic
|
||||
// and handled by other gates.
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: JSX attribute on the watchlist with a string literal
|
||||
if (ts.isJsxAttribute(node)) {
|
||||
const name = node.name;
|
||||
const attrName = ts.isIdentifier(name)
|
||||
? name.text
|
||||
: `${name.namespace.text}:${name.name.text}`;
|
||||
if (
|
||||
ARIA_ATTRIBUTE_WATCHLIST.has(attrName) &&
|
||||
node.initializer !== undefined
|
||||
) {
|
||||
const init = node.initializer;
|
||||
if (ts.isStringLiteral(init)) {
|
||||
const { line, column } = lineCol(
|
||||
sourceFile,
|
||||
init.getStart(sourceFile)
|
||||
);
|
||||
diagnostics.push({
|
||||
file: filePath,
|
||||
line,
|
||||
column,
|
||||
rule: `jsx-attr-${attrName}`,
|
||||
snippet: init.text.slice(0, 120),
|
||||
});
|
||||
} else if (
|
||||
ts.isJsxExpression(init) &&
|
||||
init.expression !== undefined
|
||||
) {
|
||||
const inner = init.expression;
|
||||
if (
|
||||
ts.isStringLiteral(inner) ||
|
||||
ts.isNoSubstitutionTemplateLiteral(inner)
|
||||
) {
|
||||
const { line, column } = lineCol(
|
||||
sourceFile,
|
||||
inner.getStart(sourceFile)
|
||||
);
|
||||
diagnostics.push({
|
||||
file: filePath,
|
||||
line,
|
||||
column,
|
||||
rule: `jsx-attr-${attrName}`,
|
||||
snippet: inner.text.slice(0, 120),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 3: object-literal property on watchlist with string literal value
|
||||
if (ts.isPropertyAssignment(node)) {
|
||||
const propName = ts.isIdentifier(node.name)
|
||||
? node.name.text
|
||||
: ts.isStringLiteral(node.name)
|
||||
? node.name.text
|
||||
: "";
|
||||
if (OBJECT_PROPERTY_WATCHLIST.has(propName)) {
|
||||
const value = node.initializer;
|
||||
if (
|
||||
ts.isStringLiteral(value) ||
|
||||
ts.isNoSubstitutionTemplateLiteral(value)
|
||||
) {
|
||||
const { line, column } = lineCol(
|
||||
sourceFile,
|
||||
value.getStart(sourceFile)
|
||||
);
|
||||
diagnostics.push({
|
||||
file: filePath,
|
||||
line,
|
||||
column,
|
||||
rule: `object-${propName}`,
|
||||
snippet: value.text.slice(0, 120),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.forEachChild(visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
|
||||
// Pass 2: scrub diagnostics that turn out to be inside a t()/Trans
|
||||
// expression. We do this by walking JsxExpression containers a
|
||||
// second time and discarding any rule-1 hit whose enclosing
|
||||
// expression is a translation call.
|
||||
const filtered: Diagnostic[] = [];
|
||||
for (const d of diagnostics) {
|
||||
if (d.rule === "jsx-text-expression") {
|
||||
// Re-find the JsxExpression at that position and inspect
|
||||
const node = findNodeAt(sourceFile, d.line, d.column);
|
||||
if (node !== null && findTranslationOrTrans(node)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
filtered.push(d);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function findNodeAt(
|
||||
sourceFile: ts.SourceFile,
|
||||
line: number,
|
||||
column: number
|
||||
): ts.Node | null {
|
||||
const target = sourceFile.getPositionOfLineAndCharacter(
|
||||
line - 1,
|
||||
column - 1
|
||||
);
|
||||
let result: ts.Node | null = null;
|
||||
function visit(node: ts.Node): void {
|
||||
if (result !== null) return;
|
||||
if (node.getStart(sourceFile) <= target && target < node.getEnd()) {
|
||||
result = node;
|
||||
node.forEachChild(visit);
|
||||
}
|
||||
}
|
||||
visit(sourceFile);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function checkRoots(
|
||||
options: ReadonlyArray<ScanOptions>
|
||||
): Promise<Diagnostic[]> {
|
||||
const all: Diagnostic[] = [];
|
||||
for (const opt of options) {
|
||||
const root = resolve(opt.root);
|
||||
const files = await listSourceFiles(root);
|
||||
for (const file of files) {
|
||||
const text = await readFile(file, "utf8");
|
||||
const diagnostics = scanSource(file, text);
|
||||
for (const d of diagnostics) {
|
||||
all.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
function formatDiagnostics(
|
||||
diagnostics: ReadonlyArray<Diagnostic>,
|
||||
cwd: string
|
||||
): string {
|
||||
if (diagnostics.length === 0) {
|
||||
return "check-no-hardcoded-strings: clean.";
|
||||
}
|
||||
const lines: string[] = [
|
||||
`check-no-hardcoded-strings: ${diagnostics.length} violation(s).`,
|
||||
];
|
||||
for (const d of diagnostics) {
|
||||
lines.push(
|
||||
` ${relative(cwd, d.file)}:${d.line}:${d.column} [${d.rule}] "${d.snippet}"`
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(
|
||||
" Hint: wrap user-facing strings in t('react-ui.<key>') or use <Trans>."
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.length === 0) {
|
||||
process.stderr.write(
|
||||
"Usage: check-no-hardcoded-strings.ts <root> [<root>...]\n"
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const cwd = process.cwd();
|
||||
const diagnostics = await checkRoots(
|
||||
argv.map((root) => ({ root: resolve(cwd, root) }))
|
||||
);
|
||||
const message = formatDiagnostics(diagnostics, cwd);
|
||||
if (diagnostics.length === 0) {
|
||||
process.stdout.write(`${message}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await main().catch((err: unknown) => {
|
||||
process.stderr.write(`internal error: ${(err as Error).message}\n`);
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
113
scripts/lint/check-primitive-host-integration.ts
Normal file
113
scripts/lint/check-primitive-host-integration.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Enforce §19 of openspec/AGENTS.md: every proposal for a package
|
||||
* on the configured primitive-package list MUST identify its concrete
|
||||
* consumer, its host-bridge (if needed), and the e2e/ test path.
|
||||
*
|
||||
* The primitive list lives in `scripts/lint/primitives.json`. Each
|
||||
* entry is the package short-name (e.g. `api-sandbox-vm`) plus an
|
||||
* optional `requiresBridge` flag and a `bridgePackagePattern`.
|
||||
*
|
||||
* For each `openspec/changes/<primitive>/proposal.md` whose package
|
||||
* name matches the list, scan the proposal text for the three
|
||||
* sentinel sections:
|
||||
* - `## Concrete consumer` containing at least one backtick-fenced
|
||||
* `@suaveplan/...` reference.
|
||||
* - `## Host bridge` (only if requiresBridge=true) citing the
|
||||
* bridge package by name matching bridgePackagePattern.
|
||||
* - `## End-to-end test` citing an `e2e/foundation-stack-stage-N/`
|
||||
* path.
|
||||
*
|
||||
* Missing any sentinel for a listed primitive is a violation.
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { Glob } from "bun";
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, "..", "..");
|
||||
|
||||
interface PrimitiveSpec {
|
||||
readonly name: string;
|
||||
readonly requiresBridge?: boolean;
|
||||
readonly bridgePackagePattern?: string;
|
||||
}
|
||||
|
||||
async function loadPrimitives(): Promise<PrimitiveSpec[]> {
|
||||
const abs = resolve(REPO_ROOT, "scripts/lint/primitives.json");
|
||||
try {
|
||||
const raw = await readFile(abs, "utf8");
|
||||
return JSON.parse(raw) as PrimitiveSpec[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProposal(
|
||||
rel: string,
|
||||
spec: PrimitiveSpec
|
||||
): Promise<string[]> {
|
||||
const abs = resolve(REPO_ROOT, rel);
|
||||
const source = await readFile(abs, "utf8");
|
||||
const violations: string[] = [];
|
||||
if (!source.includes("## Concrete consumer")) {
|
||||
violations.push(
|
||||
`${rel} §19: proposal for primitive '${spec.name}' lacks a '## Concrete consumer' section`
|
||||
);
|
||||
}
|
||||
if (spec.requiresBridge === true) {
|
||||
if (!source.includes("## Host bridge")) {
|
||||
violations.push(
|
||||
`${rel} §19: proposal for primitive '${spec.name}' lacks a '## Host bridge' section`
|
||||
);
|
||||
} else if (
|
||||
spec.bridgePackagePattern !== undefined &&
|
||||
!new RegExp(spec.bridgePackagePattern).test(source)
|
||||
) {
|
||||
violations.push(
|
||||
`${rel} §19: '## Host bridge' section does not cite a bridge package matching pattern '${spec.bridgePackagePattern}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!source.includes("## End-to-end test")) {
|
||||
violations.push(
|
||||
`${rel} §19: proposal for primitive '${spec.name}' lacks a '## End-to-end test' section`
|
||||
);
|
||||
} else if (!/e2e\/foundation-stack-stage-\d+/.test(source) && !/e2e\/[a-z0-9-]+/.test(source)) {
|
||||
violations.push(
|
||||
`${rel} §19: '## End-to-end test' section does not cite an e2e/<umbrella>-stage-N/ path`
|
||||
);
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const primitives = await loadPrimitives();
|
||||
if (primitives.length === 0) {
|
||||
console.log("OK no primitives configured in scripts/lint/primitives.json — nothing to check.");
|
||||
return;
|
||||
}
|
||||
const byName = new Map(primitives.map((p) => [p.name, p]));
|
||||
const violations: string[] = [];
|
||||
const g = new Glob("openspec/changes/*/proposal.md");
|
||||
for await (const f of g.scan({ cwd: REPO_ROOT })) {
|
||||
const changeId = f.split("/")[2] ?? "?";
|
||||
const spec = byName.get(changeId);
|
||||
if (spec === undefined) continue;
|
||||
violations.push(...(await checkProposal(f, spec)));
|
||||
}
|
||||
if (violations.length === 0) {
|
||||
console.log(
|
||||
`OK ${primitives.length} primitive package(s) on the §19 list; every proposal carries the three required sections.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(`FAIL ${violations.length} §19 violation(s):`);
|
||||
for (const v of violations) console.error(` ${v}`);
|
||||
console.error(
|
||||
"\n§19: every primitive package proposal MUST include '## Concrete consumer', '## Host bridge' (if required), and '## End-to-end test' sections."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await main();
|
||||
140
scripts/lint/check-stage-smoke.ts
Normal file
140
scripts/lint/check-stage-smoke.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Enforce §18 of openspec/AGENTS.md: every Stage block in an umbrella
|
||||
* tasks.md MUST list a `N.0 e2e/<umbrella>-stage-N/` closure-smoke row
|
||||
* alongside the stage's per-package rows.
|
||||
*
|
||||
* Grandfather clause: umbrellas whose archive folder predates
|
||||
* RULE_EFFECTIVE_DATE are exempt. `api-plugin-foundation` is also
|
||||
* grandfathered (opened pre-§18).
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { Glob } from "bun";
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, "..", "..");
|
||||
const RULE_EFFECTIVE_DATE = "2026-05-27";
|
||||
|
||||
interface StageBlock {
|
||||
readonly umbrella: string;
|
||||
readonly file: string;
|
||||
readonly stageId: number;
|
||||
readonly stageHeadingLine: number;
|
||||
readonly hasSmokeRow: boolean;
|
||||
}
|
||||
|
||||
const STAGE_HEADING_RE = /^### Stage (\d+)\b/;
|
||||
const PACKAGE_ROW_RE = /^- \[( |x)\]\s+(\d+)\.(\d+)\s+/i;
|
||||
|
||||
async function parseUmbrella(rel: string): Promise<StageBlock[]> {
|
||||
const abs = resolve(REPO_ROOT, rel);
|
||||
const source = await readFile(abs, "utf8");
|
||||
const lines = source.split("\n");
|
||||
const umbrellaName = rel.split("/")[2] ?? "?";
|
||||
const blocks: StageBlock[] = [];
|
||||
let inSection1 = false;
|
||||
let currentStage: number | undefined;
|
||||
let currentStageLine = 0;
|
||||
let currentHasSmoke = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
if (/^## 1\b/.test(line)) {
|
||||
inSection1 = true;
|
||||
continue;
|
||||
}
|
||||
if (inSection1 && /^## /.test(line)) {
|
||||
if (currentStage !== undefined) {
|
||||
blocks.push({
|
||||
umbrella: umbrellaName,
|
||||
file: rel,
|
||||
stageId: currentStage,
|
||||
stageHeadingLine: currentStageLine,
|
||||
hasSmokeRow: currentHasSmoke,
|
||||
});
|
||||
currentStage = undefined;
|
||||
}
|
||||
inSection1 = false;
|
||||
continue;
|
||||
}
|
||||
if (!inSection1) continue;
|
||||
const sh = STAGE_HEADING_RE.exec(line);
|
||||
if (sh !== null) {
|
||||
if (currentStage !== undefined) {
|
||||
blocks.push({
|
||||
umbrella: umbrellaName,
|
||||
file: rel,
|
||||
stageId: currentStage,
|
||||
stageHeadingLine: currentStageLine,
|
||||
hasSmokeRow: currentHasSmoke,
|
||||
});
|
||||
}
|
||||
currentStage = Number.parseInt(sh[1] ?? "0", 10);
|
||||
currentStageLine = i + 1;
|
||||
currentHasSmoke = false;
|
||||
continue;
|
||||
}
|
||||
const pr = PACKAGE_ROW_RE.exec(line);
|
||||
if (pr !== null) {
|
||||
const [, , majorStr, minorStr] = pr;
|
||||
if (minorStr === "0" && majorStr !== undefined) {
|
||||
if (line.includes("e2e/") && line.includes("stage-")) {
|
||||
currentHasSmoke = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentStage !== undefined) {
|
||||
blocks.push({
|
||||
umbrella: umbrellaName,
|
||||
file: rel,
|
||||
stageId: currentStage,
|
||||
stageHeadingLine: currentStageLine,
|
||||
hasSmokeRow: currentHasSmoke,
|
||||
});
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
async function isGrandfathered(umbrella: string): Promise<boolean> {
|
||||
const g = new Glob(`openspec/archive/*-${umbrella}/proposal.md`);
|
||||
for await (const f of g.scan({ cwd: REPO_ROOT })) {
|
||||
const m = f.match(/openspec\/archive\/(\d{4}-\d{2}-\d{2})-/);
|
||||
if (m !== null && m[1] !== undefined) {
|
||||
if (m[1] < RULE_EFFECTIVE_DATE) return true;
|
||||
}
|
||||
}
|
||||
if (umbrella === "api-plugin-foundation") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const violations: string[] = [];
|
||||
const g = new Glob("openspec/changes/*/tasks.md");
|
||||
for await (const f of g.scan({ cwd: REPO_ROOT })) {
|
||||
const umbrella = f.split("/")[2] ?? "?";
|
||||
if (await isGrandfathered(umbrella)) continue;
|
||||
const blocks = await parseUmbrella(f);
|
||||
for (const b of blocks) {
|
||||
if (!b.hasSmokeRow) {
|
||||
violations.push(
|
||||
`${b.file}:${b.stageHeadingLine} Stage ${b.stageId} lacks an N.0 e2e/${b.umbrella}-stage-${b.stageId}/ closure-smoke row`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (violations.length === 0) {
|
||||
console.log("OK every Stage-N block lists its closure-smoke row.");
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`FAIL ${violations.length} stage block(s) missing closure-smoke row:`
|
||||
);
|
||||
for (const v of violations) console.error(` ${v}`);
|
||||
console.error(
|
||||
"\n§18: every Stage N block MUST include - [ ] N.0 e2e/<umbrella>-stage-N/ alongside its package rows."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await main();
|
||||
222
scripts/lint/check-test-runner-imports.test.ts
Normal file
222
scripts/lint/check-test-runner-imports.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Unit test for check-test-runner-imports.ts.
|
||||
*
|
||||
* Run with: `bun test scripts/lint/check-test-runner-imports.test.ts`
|
||||
*
|
||||
* Uses Bun's native test runner because this file is repo tooling, not a
|
||||
* workspace package — the canonical-runner-wrapper rule (openspec/AGENTS.md
|
||||
* §7) governs `packages/<plugin>/<pkg>/src/**\/*.test.ts`, not `scripts/`.
|
||||
* Importing the rule-enforcer's own test from `@suaveplan/testing/runner`
|
||||
* would create a circular dependency between the gate and the wrapper it
|
||||
* polices.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
EXEMPT_PACKAGES,
|
||||
findViolations,
|
||||
formatViolations,
|
||||
} from "./check-test-runner-imports.ts";
|
||||
|
||||
interface Fixture {
|
||||
readonly root: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
async function makeFixture(): Promise<Fixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), "check-runner-imports-"));
|
||||
return {
|
||||
root,
|
||||
async cleanup() {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writePackage(args: {
|
||||
root: string;
|
||||
plugin: string;
|
||||
pkg: string;
|
||||
name: string;
|
||||
files: Record<string, string>;
|
||||
}): Promise<void> {
|
||||
const pkgDir = join(args.root, "packages", args.plugin, args.pkg);
|
||||
await mkdir(pkgDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(pkgDir, "package.json"),
|
||||
JSON.stringify({ name: args.name })
|
||||
);
|
||||
for (const [relPath, content] of Object.entries(args.files)) {
|
||||
const full = join(pkgDir, relPath);
|
||||
await mkdir(join(full, ".."), { recursive: true });
|
||||
await writeFile(full, content);
|
||||
}
|
||||
}
|
||||
|
||||
describe("check-test-runner-imports", () => {
|
||||
let fixture: Fixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
fixture = await makeFixture();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
it("reports zero violations when every package uses @suaveplan/testing/runner", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writePackage({
|
||||
root: fixture.root,
|
||||
plugin: "demo",
|
||||
pkg: "good-pkg",
|
||||
name: "@suaveplan/good-pkg",
|
||||
files: {
|
||||
"src/feature/feature.test.ts":
|
||||
'import { describe, it, expect } from "@suaveplan/testing/runner";\n' +
|
||||
'describe("ok", () => { it("passes", () => { expect(1).toBe(1); }); });\n',
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ repoRoot: fixture.root });
|
||||
expect(violations).toEqual([]);
|
||||
expect(formatViolations(violations)).toContain(
|
||||
"All non-exempt packages use the canonical"
|
||||
);
|
||||
});
|
||||
|
||||
it("flags a direct vitest import in a non-exempt package", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writePackage({
|
||||
root: fixture.root,
|
||||
plugin: "demo",
|
||||
pkg: "bad-pkg",
|
||||
name: "@suaveplan/bad-pkg",
|
||||
files: {
|
||||
"src/feature/feature.test.ts":
|
||||
'import { describe, it, expect } from "vitest";\n',
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ repoRoot: fixture.root });
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]?.packageName).toBe("@suaveplan/bad-pkg");
|
||||
expect(violations[0]?.file).toBe(
|
||||
"packages/demo/bad-pkg/src/feature/feature.test.ts"
|
||||
);
|
||||
expect(violations[0]?.line).toBe(1);
|
||||
expect(violations[0]?.source).toContain('from "vitest"');
|
||||
|
||||
const message = formatViolations(violations);
|
||||
expect(message).toContain("❌ Direct vitest / bun:test imports");
|
||||
expect(message).toContain("@suaveplan/bad-pkg");
|
||||
expect(message).toContain("openspec/AGENTS.md §7");
|
||||
});
|
||||
|
||||
it("flags a direct bun:test import in a non-exempt package", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writePackage({
|
||||
root: fixture.root,
|
||||
plugin: "demo",
|
||||
pkg: "bad-bun",
|
||||
name: "@suaveplan/bad-bun",
|
||||
files: {
|
||||
"src/x.test.ts": "import { describe } from 'bun:test';\n",
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ repoRoot: fixture.root });
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]?.source).toContain("'bun:test'");
|
||||
});
|
||||
|
||||
it("skips every package in the exempt allowlist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
for (const exemptName of EXEMPT_PACKAGES) {
|
||||
const slug = exemptName.replace("@suaveplan/", "");
|
||||
await writePackage({
|
||||
root: fixture.root,
|
||||
plugin: "dev",
|
||||
pkg: slug,
|
||||
name: exemptName,
|
||||
files: {
|
||||
"src/x.test.ts":
|
||||
'import { describe, it, expect } from "vitest";\n' +
|
||||
"import { describe as bunDescribe } from 'bun:test';\n",
|
||||
},
|
||||
});
|
||||
}
|
||||
const violations = await findViolations({ repoRoot: fixture.root });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores packages without a src/ directory", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writePackage({
|
||||
root: fixture.root,
|
||||
plugin: "demo",
|
||||
pkg: "no-src",
|
||||
name: "@suaveplan/no-src",
|
||||
files: {
|
||||
"README.md": "# no src here\n",
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ repoRoot: fixture.root });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not flag re-exports from a barrel (only `import` statements)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await writePackage({
|
||||
root: fixture.root,
|
||||
plugin: "demo",
|
||||
pkg: "reexport",
|
||||
name: "@suaveplan/reexport",
|
||||
files: {
|
||||
"src/x.ts": 'export { describe } from "vitest";\n',
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ repoRoot: fixture.root });
|
||||
// The rule scope is `import` statements; re-exports from vitest in
|
||||
// non-test code are caught by other rules (and are nonsensical for
|
||||
// a non-exempt package — they would mean shipping vitest at
|
||||
// runtime). The check stays import-statement-scoped to keep the
|
||||
// signal high.
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores packages whose package.json is missing or unparsable", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkgDir = join(fixture.root, "packages", "demo", "no-manifest");
|
||||
await mkdir(join(pkgDir, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(pkgDir, "src", "x.test.ts"),
|
||||
'import { describe } from "vitest";\n'
|
||||
);
|
||||
const violations = await findViolations({ repoRoot: fixture.root });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("scans .tsx as well as .ts", { timeout: 5000 }, async () => {
|
||||
await writePackage({
|
||||
root: fixture.root,
|
||||
plugin: "demo",
|
||||
pkg: "tsx-pkg",
|
||||
name: "@suaveplan/tsx-pkg",
|
||||
files: {
|
||||
"src/x.test.tsx": 'import { describe } from "vitest";\n',
|
||||
},
|
||||
});
|
||||
const violations = await findViolations({ repoRoot: fixture.root });
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]?.file).toEndWith(".tsx");
|
||||
});
|
||||
});
|
||||
264
scripts/lint/check-test-runner-imports.ts
Normal file
264
scripts/lint/check-test-runner-imports.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* check-test-runner-imports.ts
|
||||
*
|
||||
* CI gate enforcing the canonical test-runner-import rule defined in:
|
||||
* - openspec/specs/monorepo-structure/spec.md (Requirement: "Test files
|
||||
* import test-runner primitives via the canonical wrapper")
|
||||
* - openspec/AGENTS.md §7 "Test runner imports — mandatory"
|
||||
* - CLAUDE.md Critical Rules
|
||||
*
|
||||
* For every package under packages/<plugin>/<pkg>/ whose `package.json#name`
|
||||
* is NOT in the testing-infra exempt allowlist, this script scans
|
||||
* src/**\/*.{ts,tsx} for direct imports from "vitest" or "bun:test" and
|
||||
* exits non-zero with a per-file error list when any are found.
|
||||
*
|
||||
* Exempt allowlist (the only packages permitted to import vitest / bun:test
|
||||
* directly — they author or extend the @suaveplan/testing/runner wrapper):
|
||||
*
|
||||
* - @suaveplan/testing
|
||||
* - @suaveplan/testing-dom
|
||||
* - @suaveplan/testing-e2e
|
||||
* - @suaveplan/testing-integration
|
||||
* - @suaveplan/testing-integration-step-ca
|
||||
*
|
||||
* Adding a package to the allowlist requires its own OpenSpec change with
|
||||
* justification — extending fixtures or being a heavy @suaveplan/testing
|
||||
* consumer does NOT qualify.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/lint/check-test-runner-imports.ts [--root <repo-root>]
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — all non-exempt packages clean
|
||||
* 1 — at least one violation found (details printed to stderr)
|
||||
* 2 — internal error (cannot read repo, unparsable package.json, etc.)
|
||||
*/
|
||||
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
export const EXEMPT_PACKAGES: ReadonlySet<string> = new Set([
|
||||
"@suaveplan/testing",
|
||||
"@suaveplan/testing-dom",
|
||||
"@suaveplan/testing-e2e",
|
||||
"@suaveplan/testing-integration",
|
||||
"@suaveplan/testing-integration-step-ca",
|
||||
]);
|
||||
|
||||
const FORBIDDEN_IMPORT_REGEX =
|
||||
/^[^/]*\bimport\b[^;]*?\bfrom\s+(['"])(vitest|bun:test)\1/m;
|
||||
|
||||
const FORBIDDEN_LINE_REGEX = /\bfrom\s+(['"])(vitest|bun:test)\1/;
|
||||
|
||||
export interface Violation {
|
||||
readonly packageName: string;
|
||||
readonly file: string;
|
||||
readonly line: number;
|
||||
readonly source: string;
|
||||
}
|
||||
|
||||
export interface CheckOptions {
|
||||
readonly repoRoot: string;
|
||||
readonly readDir?: typeof readdir;
|
||||
readonly readFileFn?: typeof readFile;
|
||||
readonly statFn?: typeof stat;
|
||||
}
|
||||
|
||||
export async function findViolations(
|
||||
options: CheckOptions
|
||||
): Promise<Violation[]> {
|
||||
const readDirFn = options.readDir ?? readdir;
|
||||
const readFileImpl = options.readFileFn ?? readFile;
|
||||
const statImpl = options.statFn ?? stat;
|
||||
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const root of [
|
||||
"packages",
|
||||
// , "wip"
|
||||
]) {
|
||||
const packagesDir = join(options.repoRoot, root);
|
||||
|
||||
let categoryDirs: Awaited<ReturnType<typeof readDirFn>>;
|
||||
try {
|
||||
categoryDirs = await readDirFn(packagesDir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const categoryDirent of categoryDirs) {
|
||||
if (!categoryDirent.isDirectory()) continue;
|
||||
const categoryPath = join(packagesDir, categoryDirent.name);
|
||||
|
||||
const pluginDirs = await readDirFn(categoryPath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
for (const pluginDirent of pluginDirs) {
|
||||
if (!pluginDirent.isDirectory()) continue;
|
||||
const pluginPath = join(categoryPath, pluginDirent.name);
|
||||
|
||||
const pkgDirs = await readDirFn(pluginPath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
for (const pkgDirent of pkgDirs) {
|
||||
if (!pkgDirent.isDirectory()) continue;
|
||||
const pkgPath = join(pluginPath, pkgDirent.name);
|
||||
|
||||
const manifestPath = join(pkgPath, "package.json");
|
||||
let manifestRaw: string;
|
||||
try {
|
||||
manifestRaw = await readFileImpl(manifestPath, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let manifest: { name?: string };
|
||||
try {
|
||||
manifest = JSON.parse(manifestRaw) as {
|
||||
name?: string;
|
||||
};
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`Cannot parse ${manifestPath}: ${(cause as Error).message}`
|
||||
);
|
||||
}
|
||||
const name = manifest.name;
|
||||
if (typeof name !== "string" || name.length === 0) continue;
|
||||
if (EXEMPT_PACKAGES.has(name)) continue;
|
||||
|
||||
const srcDir = join(pkgPath, "src");
|
||||
try {
|
||||
await statImpl(srcDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
await walkAndScan({
|
||||
dir: srcDir,
|
||||
packageName: name,
|
||||
repoRoot: options.repoRoot,
|
||||
violations,
|
||||
readDirFn,
|
||||
readFileImpl,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
interface WalkArgs {
|
||||
readonly dir: string;
|
||||
readonly packageName: string;
|
||||
readonly repoRoot: string;
|
||||
readonly violations: Violation[];
|
||||
readonly readDirFn: typeof readdir;
|
||||
readonly readFileImpl: typeof readFile;
|
||||
}
|
||||
|
||||
async function walkAndScan(args: WalkArgs): Promise<void> {
|
||||
const entries = await args.readDirFn(args.dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(args.dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walkAndScan({ ...args, dir: fullPath });
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".tsx")) {
|
||||
continue;
|
||||
}
|
||||
const content = await args.readFileImpl(fullPath, "utf8");
|
||||
if (!FORBIDDEN_IMPORT_REGEX.test(content)) continue;
|
||||
const lines = content.split("\n");
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i] ?? "";
|
||||
if (/\bimport\b/.test(line) && FORBIDDEN_LINE_REGEX.test(line)) {
|
||||
// Skip lines that are obviously inside a backtick
|
||||
// template literal — `create-suaveplan-plugin` and
|
||||
// similar scaffolders embed test-runner imports as
|
||||
// template strings for the files they generate.
|
||||
// Heuristic: the line starts with optional indent
|
||||
// and a backtick character (template-literal opener).
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith("`") || trimmed.startsWith("\"`")) {
|
||||
continue;
|
||||
}
|
||||
args.violations.push({
|
||||
packageName: args.packageName,
|
||||
file: relative(args.repoRoot, fullPath),
|
||||
line: i + 1,
|
||||
source: line.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatViolations(violations: readonly Violation[]): string {
|
||||
if (violations.length === 0) {
|
||||
return "All non-exempt packages use the canonical @suaveplan/testing/runner wrapper.";
|
||||
}
|
||||
const grouped = new Map<string, Violation[]>();
|
||||
for (const v of violations) {
|
||||
const list = grouped.get(v.packageName) ?? [];
|
||||
list.push(v);
|
||||
grouped.set(v.packageName, list);
|
||||
}
|
||||
const lines: string[] = [
|
||||
"❌ Direct vitest / bun:test imports detected in non-exempt packages.",
|
||||
"",
|
||||
"Rule: openspec/AGENTS.md §7 'Test runner imports — mandatory'.",
|
||||
'Fix: rewrite each line to `from "@suaveplan/testing/runner"`.',
|
||||
"",
|
||||
];
|
||||
for (const [pkg, items] of [...grouped].sort((a, b) =>
|
||||
a[0].localeCompare(b[0])
|
||||
)) {
|
||||
lines.push(
|
||||
` ${pkg} (${items.length} violation${items.length === 1 ? "" : "s"})`
|
||||
);
|
||||
for (const v of items) {
|
||||
lines.push(` ${v.file}:${v.line} ${v.source}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(
|
||||
`Total: ${violations.length} violation${violations.length === 1 ? "" : "s"} across ${grouped.size} package${grouped.size === 1 ? "" : "s"}.`
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
let repoRoot = process.cwd();
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === "--root" && i + 1 < argv.length) {
|
||||
repoRoot = resolve(argv[i + 1] ?? ".");
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let violations: readonly Violation[];
|
||||
try {
|
||||
violations = await findViolations({ repoRoot });
|
||||
} catch (cause) {
|
||||
process.stderr.write(
|
||||
`check-test-runner-imports: internal error: ${(cause as Error).message}\n`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const message = formatViolations(violations);
|
||||
if (violations.length === 0) {
|
||||
process.stdout.write(`${message}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await main();
|
||||
}
|
||||
249
scripts/lint/check-verified-by.ts
Normal file
249
scripts/lint/check-verified-by.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Enforce §20 of `openspec/AGENTS.md`: every requirement in every spec
|
||||
* delta MUST carry a `**Verified by:**` line. At archive time, the
|
||||
* cited file path MUST exist and the cited it("...") title MUST be
|
||||
* present.
|
||||
*
|
||||
* Modes
|
||||
* -----
|
||||
* - `--mode=author` (default): runs on changes in `openspec/changes/`.
|
||||
* Verified-by lines must be syntactically valid; cited files need
|
||||
* not exist yet.
|
||||
* - `--mode=archive`: runs on changes in `openspec/archive/`. Cited
|
||||
* files MUST exist and contain the cited title.
|
||||
*/
|
||||
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { Glob } from "bun";
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, "..", "..");
|
||||
const RULE_EFFECTIVE_DATE = "2026-05-27";
|
||||
|
||||
/**
|
||||
* Change folders whose first commit predates RULE_EFFECTIVE_DATE are
|
||||
* grandfathered (§20 applies to new authoring only). Cached per
|
||||
* change-id so we run git log at most once per change.
|
||||
*/
|
||||
const grandfatherCache = new Map<string, boolean>();
|
||||
|
||||
function changeIdOf(specRel: string): string | undefined {
|
||||
// openspec/(changes|archive)/<change-id>/specs/...
|
||||
const parts = specRel.split("/");
|
||||
if (parts.length < 3) return undefined;
|
||||
return parts[2];
|
||||
}
|
||||
|
||||
function isGrandfathered(specRel: string): boolean {
|
||||
const changeId = changeIdOf(specRel);
|
||||
if (changeId === undefined) return false;
|
||||
const cached = grandfatherCache.get(changeId);
|
||||
if (cached !== undefined) return cached;
|
||||
const root = specRel.startsWith("openspec/archive/")
|
||||
? `openspec/archive/${changeId}`
|
||||
: `openspec/changes/${changeId}`;
|
||||
const result = spawnSync(
|
||||
"git",
|
||||
[
|
||||
"-C",
|
||||
REPO_ROOT,
|
||||
"log",
|
||||
"--diff-filter=A",
|
||||
"--format=%cI",
|
||||
"--",
|
||||
root,
|
||||
],
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
const out = (result.stdout ?? "").trim().split("\n").filter(Boolean);
|
||||
if (out.length === 0) {
|
||||
grandfatherCache.set(changeId, false);
|
||||
return false;
|
||||
}
|
||||
const firstCommitISO = out[out.length - 1];
|
||||
if (firstCommitISO === undefined) {
|
||||
grandfatherCache.set(changeId, false);
|
||||
return false;
|
||||
}
|
||||
const firstCommitDate = firstCommitISO.slice(0, 10);
|
||||
const grandfathered = firstCommitDate < RULE_EFFECTIVE_DATE;
|
||||
grandfatherCache.set(changeId, grandfathered);
|
||||
return grandfathered;
|
||||
}
|
||||
|
||||
interface Violation {
|
||||
readonly file: string;
|
||||
readonly line: number;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
const REQ_RE = /^### Requirement:\s+(.+)$/;
|
||||
const VERIFIED_RE = /^\*\*Verified by:\*\*\s+(.+)$/;
|
||||
const CITE_RE = /`([^`]+\.test\.tsx?|[^`]+\.spec\.tsx?)::"([^"]+)"`/g;
|
||||
|
||||
function modeFromArgv(): "author" | "archive" {
|
||||
const arg = process.argv.find((a) => a.startsWith("--mode="));
|
||||
if (arg === undefined) return "author";
|
||||
const val = arg.slice("--mode=".length);
|
||||
if (val !== "author" && val !== "archive") {
|
||||
console.error(`bad --mode value: ${val}`);
|
||||
process.exit(2);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
async function fileExists(absPath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(absPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSpecFile(
|
||||
rel: string,
|
||||
mode: "author" | "archive"
|
||||
): Promise<Violation[]> {
|
||||
const abs = resolve(REPO_ROOT, rel);
|
||||
const source = await readFile(abs, "utf8");
|
||||
const lines = source.split("\n");
|
||||
const violations: Violation[] = [];
|
||||
let inRequirement = false;
|
||||
let reqStartLine = 0;
|
||||
let reqTitle = "";
|
||||
let sawVerified = false;
|
||||
let verifiedLine = "";
|
||||
let verifiedLineNo = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const m = REQ_RE.exec(line);
|
||||
if (m !== null) {
|
||||
if (inRequirement && !sawVerified) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
line: reqStartLine,
|
||||
reason: `Requirement "${reqTitle}" lacks a **Verified by:** line`,
|
||||
});
|
||||
}
|
||||
inRequirement = true;
|
||||
reqStartLine = i + 1;
|
||||
reqTitle = m[1] ?? "";
|
||||
sawVerified = false;
|
||||
verifiedLine = "";
|
||||
verifiedLineNo = 0;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("### ") || line.startsWith("## ")) {
|
||||
if (inRequirement && !sawVerified) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
line: reqStartLine,
|
||||
reason: `Requirement "${reqTitle}" lacks a **Verified by:** line`,
|
||||
});
|
||||
}
|
||||
inRequirement = false;
|
||||
continue;
|
||||
}
|
||||
if (!inRequirement) continue;
|
||||
const vm = VERIFIED_RE.exec(line);
|
||||
if (vm !== null) {
|
||||
sawVerified = true;
|
||||
verifiedLine = vm[1] ?? "";
|
||||
verifiedLineNo = i + 1;
|
||||
const cites = [...verifiedLine.matchAll(CITE_RE)];
|
||||
if (cites.length === 0) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
line: verifiedLineNo,
|
||||
reason: `Verified-by line lacks a parseable path::"title" citation (got: ${verifiedLine.slice(0, 80)})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (mode === "archive") {
|
||||
for (const c of cites) {
|
||||
const path = c[1];
|
||||
const title = c[2];
|
||||
if (path === undefined || title === undefined) continue;
|
||||
const pkgGuess = pkgRootOf(rel);
|
||||
if (pkgGuess === undefined) continue;
|
||||
const testAbs = resolve(REPO_ROOT, pkgGuess, path);
|
||||
if (!(await fileExists(testAbs))) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
line: verifiedLineNo,
|
||||
reason: `cited test file does not exist: ${pkgGuess}/${path}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const src = await readFile(testAbs, "utf8");
|
||||
if (!src.includes(title)) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
line: verifiedLineNo,
|
||||
reason: `cited title not found in ${path}: "${title}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inRequirement && !sawVerified) {
|
||||
violations.push({
|
||||
file: rel,
|
||||
line: reqStartLine,
|
||||
reason: `Requirement "${reqTitle}" lacks a **Verified by:** line`,
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function pkgRootOf(specRel: string): string | undefined {
|
||||
// Capability-name -> package root mapping is ambiguous in a 3-level
|
||||
// monorepo. Skip strict archive-time existence check unless the
|
||||
// resolver can be made unambiguous via a `package-root-map.json`.
|
||||
void specRel;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const mode = modeFromArgv();
|
||||
const pattern =
|
||||
mode === "archive"
|
||||
? "openspec/archive/*/specs/*/spec.md"
|
||||
: "openspec/changes/*/specs/*/spec.md";
|
||||
const g = new Glob(pattern);
|
||||
const files: string[] = [];
|
||||
for await (const f of g.scan({ cwd: REPO_ROOT })) {
|
||||
files.push(f);
|
||||
}
|
||||
const allViolations: Violation[] = [];
|
||||
let skipped = 0;
|
||||
for (const f of files) {
|
||||
if (isGrandfathered(f)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
allViolations.push(...(await checkSpecFile(f, mode)));
|
||||
}
|
||||
if (allViolations.length === 0) {
|
||||
console.log(
|
||||
`OK ${files.length} spec deltas scanned (mode=${mode}, ${skipped} grandfathered); every checked Requirement has a **Verified by:** citation.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`FAIL ${allViolations.length} Verified-by violation(s) (mode=${mode}):`
|
||||
);
|
||||
for (const v of allViolations) {
|
||||
console.error(` ${v.file}:${v.line} ${v.reason}`);
|
||||
}
|
||||
console.error(
|
||||
"\n§20: every Requirement MUST have **Verified by:** path::title."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await main();
|
||||
110
scripts/lint/check-wasm-subpath-deps.test.ts
Normal file
110
scripts/lint/check-wasm-subpath-deps.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
banReason,
|
||||
checkWasmSubpathDeps,
|
||||
extractSpecifiers,
|
||||
} from "./check-wasm-subpath-deps.js";
|
||||
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(join(tmpdir(), "wasm-deps-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeFixture(rel: string, content: string): Promise<string> {
|
||||
const full = join(tmp, rel);
|
||||
await mkdir(join(full, ".."), { recursive: true });
|
||||
await writeFile(full, content, "utf8");
|
||||
return full;
|
||||
}
|
||||
|
||||
describe("check-wasm-subpath-deps", () => {
|
||||
it("banReason flags node: specifiers", { timeout: 5000 }, () => {
|
||||
expect(banReason("node:fs")).toMatch(/node:/);
|
||||
});
|
||||
|
||||
it("banReason flags exact bans", { timeout: 5000 }, () => {
|
||||
expect(banReason("hono")).toMatch(/banned package/);
|
||||
expect(banReason("@suaveplan/api-host")).toMatch(/banned package/);
|
||||
});
|
||||
|
||||
it("banReason flags prefix bans", { timeout: 5000 }, () => {
|
||||
expect(banReason("@hono/zod-validator")).toMatch(/banned prefix/);
|
||||
expect(banReason("@opentelemetry/sdk-metrics")).toMatch(
|
||||
/banned prefix/
|
||||
);
|
||||
expect(banReason("@opentelemetry/sdk-trace-base")).toMatch(
|
||||
/banned prefix/
|
||||
);
|
||||
});
|
||||
|
||||
it("banReason passes safe specifiers", { timeout: 5000 }, () => {
|
||||
expect(banReason("@opentelemetry/api")).toBeNull();
|
||||
expect(banReason("zod")).toBeNull();
|
||||
expect(banReason("@suaveplan/error")).toBeNull();
|
||||
});
|
||||
|
||||
it("extractSpecifiers picks up named, default, and type imports", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const src = `
|
||||
import x from "a";
|
||||
import { b } from "b";
|
||||
import type { C } from "c";
|
||||
export { d } from "d";
|
||||
// import "ignored-in-comment";
|
||||
`;
|
||||
const out = extractSpecifiers(src);
|
||||
expect(out).toContain("a");
|
||||
expect(out).toContain("b");
|
||||
expect(out).toContain("c");
|
||||
expect(out).toContain("d");
|
||||
expect(out).not.toContain("ignored-in-comment");
|
||||
});
|
||||
|
||||
it("rejects banned specifier in wasm subpath", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Build a tiny fixture: entry imports "hono" directly.
|
||||
const entry = await writeFixture(
|
||||
"packages/example/wasm/index.ts",
|
||||
`import { Hono } from "hono";\nexport const app = new Hono();\n`
|
||||
);
|
||||
const hits = await checkWasmSubpathDeps({ repoRoot: tmp, entry });
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
const hononHit = hits.find((h) => h.specifier === "hono");
|
||||
expect(hononHit).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts a clean entry with only allowed specifiers", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const entry = await writeFixture(
|
||||
"packages/example/wasm/index.ts",
|
||||
`import z from "zod";\nimport { foo } from "./foo.js";\n`
|
||||
);
|
||||
await writeFixture(
|
||||
"packages/example/wasm/foo.ts",
|
||||
"export const foo = 1;\n"
|
||||
);
|
||||
const hits = await checkWasmSubpathDeps({ repoRoot: tmp, entry });
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects node:* specifier in entry", { timeout: 5000 }, async () => {
|
||||
const entry = await writeFixture(
|
||||
"packages/example/wasm/index.ts",
|
||||
`import fs from "node:fs";\n`
|
||||
);
|
||||
const hits = await checkWasmSubpathDeps({ repoRoot: tmp, entry });
|
||||
expect(hits.length).toBeGreaterThan(0);
|
||||
expect(hits[0]?.specifier).toBe("node:fs");
|
||||
});
|
||||
});
|
||||
313
scripts/lint/check-wasm-subpath-deps.ts
Normal file
313
scripts/lint/check-wasm-subpath-deps.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* check-wasm-subpath-deps.ts
|
||||
*
|
||||
* CI gate enforcing the wasm-tier dependency constraint declared in
|
||||
* openspec/changes/api-plugin-example-dual-shape/specs/api-plugin-example/spec.md
|
||||
* ("The ./wasm subpath SHALL have a constrained dependency graph").
|
||||
*
|
||||
* Walks every static import / import-type transitively from
|
||||
* packages/api-stack/api/api-plugin-example/src/wasm/index.ts,
|
||||
* following relative paths AND package resolutions, and rejects any
|
||||
* specifier matching the wasm-tier ban list:
|
||||
*
|
||||
* Exact: @suaveplan/api-host, hono
|
||||
* Prefix: @hono/, @opentelemetry/sdk-metrics,
|
||||
* @opentelemetry/sdk-trace-, @opentelemetry/sdk-node
|
||||
* Pattern: node:*
|
||||
* Transitive: workspace package whose own deps include the above
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — clean
|
||||
* 1 — at least one violation found
|
||||
* 2 — internal error
|
||||
*/
|
||||
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
export interface BannedHit {
|
||||
readonly file: string;
|
||||
readonly specifier: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
const EXACT_BANS: ReadonlySet<string> = new Set([
|
||||
"@suaveplan/api-host",
|
||||
"hono",
|
||||
]);
|
||||
|
||||
const PREFIX_BANS: readonly string[] = [
|
||||
"@hono/",
|
||||
"@opentelemetry/sdk-metrics",
|
||||
"@opentelemetry/sdk-trace-",
|
||||
"@opentelemetry/sdk-node",
|
||||
];
|
||||
|
||||
export function banReason(specifier: string): string | null {
|
||||
if (specifier.startsWith("node:")) return "node:* specifier";
|
||||
if (EXACT_BANS.has(specifier)) return `banned package: ${specifier}`;
|
||||
for (const prefix of PREFIX_BANS) {
|
||||
if (specifier === prefix || specifier.startsWith(prefix)) {
|
||||
return `banned prefix: ${prefix}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const IMPORT_RE =
|
||||
/(?:^|\n)\s*(?:import|export)(?:\s+type)?\s+(?:[^'"`;]+\sfrom\s+)?['"]([^'"]+)['"]/g;
|
||||
|
||||
export function extractSpecifiers(source: string): string[] {
|
||||
const stripped = source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, "$1");
|
||||
const out: string[] = [];
|
||||
let match: RegExpExecArray | null = IMPORT_RE.exec(stripped);
|
||||
while (match !== null) {
|
||||
const spec = match[1];
|
||||
if (spec !== undefined) out.push(spec);
|
||||
match = IMPORT_RE.exec(stripped);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface WalkArgs {
|
||||
readonly repoRoot: string;
|
||||
readonly visitedFiles: Set<string>;
|
||||
readonly visitedPackages: Set<string>;
|
||||
readonly hits: BannedHit[];
|
||||
}
|
||||
|
||||
async function resolveRelative(
|
||||
fromFile: string,
|
||||
spec: string
|
||||
): Promise<string | null> {
|
||||
const base = resolve(dirname(fromFile), spec);
|
||||
const candidates = [
|
||||
base,
|
||||
`${base}.ts`,
|
||||
`${base}.tsx`,
|
||||
`${base.replace(/\.js$/, "")}.ts`,
|
||||
`${base.replace(/\.js$/, "")}.tsx`,
|
||||
join(base, "index.ts"),
|
||||
join(base, "index.tsx"),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const s = await stat(c);
|
||||
if (s.isFile()) return c;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findPackageDir(
|
||||
wsRoot: string,
|
||||
pkgName: string
|
||||
): Promise<string | null> {
|
||||
let cats: Awaited<ReturnType<typeof readdir>>;
|
||||
try {
|
||||
cats = await readdir(wsRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const cat of cats) {
|
||||
if (!cat.isDirectory()) continue;
|
||||
const catDir = join(wsRoot, cat.name);
|
||||
let plugins: typeof cats;
|
||||
try {
|
||||
plugins = await readdir(catDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.isDirectory()) continue;
|
||||
const pluginDir = join(catDir, plugin.name);
|
||||
let pkgs: typeof cats;
|
||||
try {
|
||||
pkgs = await readdir(pluginDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const pkg of pkgs) {
|
||||
if (!pkg.isDirectory()) continue;
|
||||
const pkgDir = join(pluginDir, pkg.name);
|
||||
const manifestPath = join(pkgDir, "package.json");
|
||||
try {
|
||||
const raw = await readFile(manifestPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as { name?: string };
|
||||
if (parsed.name === pkgName) return pkgDir;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolvePackageJson(
|
||||
spec: string,
|
||||
repoRoot: string
|
||||
): Promise<{
|
||||
pkgName: string;
|
||||
pkgJson: Record<string, unknown>;
|
||||
} | null> {
|
||||
let pkgName: string;
|
||||
if (spec.startsWith("@")) {
|
||||
const parts = spec.split("/");
|
||||
if (parts.length < 2) return null;
|
||||
pkgName = `${parts[0]}/${parts[1]}`;
|
||||
} else {
|
||||
pkgName = spec.split("/")[0] ?? spec;
|
||||
}
|
||||
const dir = await findPackageDir(join(repoRoot, "packages"), pkgName);
|
||||
if (dir === null) return null;
|
||||
const raw = await readFile(join(dir, "package.json"), "utf8");
|
||||
return { pkgName, pkgJson: JSON.parse(raw) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
async function walkFile(args: WalkArgs, file: string): Promise<void> {
|
||||
if (args.visitedFiles.has(file)) return;
|
||||
args.visitedFiles.add(file);
|
||||
|
||||
let source: string;
|
||||
try {
|
||||
source = await readFile(file, "utf8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const specs = extractSpecifiers(source);
|
||||
const relFile = relative(args.repoRoot, file);
|
||||
for (const spec of specs) {
|
||||
const banned = banReason(spec);
|
||||
if (banned !== null) {
|
||||
args.hits.push({
|
||||
file: relFile,
|
||||
specifier: spec,
|
||||
reason: banned,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (spec.startsWith(".") || isAbsolute(spec)) {
|
||||
const resolved = await resolveRelative(file, spec);
|
||||
if (resolved !== null) {
|
||||
await walkFile(args, resolved);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await walkPackage(args, spec, relFile);
|
||||
}
|
||||
}
|
||||
|
||||
async function walkPackage(
|
||||
args: WalkArgs,
|
||||
spec: string,
|
||||
importingFile: string
|
||||
): Promise<void> {
|
||||
const pkg = await resolvePackageJson(spec, args.repoRoot);
|
||||
if (pkg === null) return;
|
||||
if (args.visitedPackages.has(pkg.pkgName)) return;
|
||||
args.visitedPackages.add(pkg.pkgName);
|
||||
const deps = {
|
||||
...(pkg.pkgJson.dependencies as Record<string, unknown> | undefined),
|
||||
...(pkg.pkgJson.peerDependencies as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
};
|
||||
for (const depName of Object.keys(deps)) {
|
||||
const reason = banReason(depName);
|
||||
if (reason !== null) {
|
||||
args.hits.push({
|
||||
file: `${importingFile} -> ${pkg.pkgName} (transitive)`,
|
||||
specifier: depName,
|
||||
reason: `${reason} (via ${pkg.pkgName})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface CheckOptions {
|
||||
readonly repoRoot: string;
|
||||
readonly entry: string;
|
||||
}
|
||||
|
||||
export async function checkWasmSubpathDeps(
|
||||
options: CheckOptions
|
||||
): Promise<BannedHit[]> {
|
||||
const hits: BannedHit[] = [];
|
||||
const args: WalkArgs = {
|
||||
repoRoot: options.repoRoot,
|
||||
visitedFiles: new Set(),
|
||||
visitedPackages: new Set(),
|
||||
hits,
|
||||
};
|
||||
await walkFile(args, options.entry);
|
||||
return hits;
|
||||
}
|
||||
|
||||
export function formatHits(hits: readonly BannedHit[]): string {
|
||||
if (hits.length === 0) {
|
||||
return "wasm-subpath-deps: clean — no banned specifiers reachable from src/wasm/index.ts.";
|
||||
}
|
||||
const lines: string[] = [
|
||||
"Banned dependency in wasm subpath import closure.",
|
||||
"",
|
||||
"Rule: openspec/changes/api-plugin-example-dual-shape/specs/api-plugin-example/spec.md",
|
||||
" 'The ./wasm subpath SHALL have a constrained dependency graph'",
|
||||
"",
|
||||
];
|
||||
for (const h of hits) {
|
||||
lines.push(` ${h.file}`);
|
||||
lines.push(` -> ${h.specifier} (${h.reason})`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`Total: ${hits.length} violation${hits.length === 1 ? "" : "s"}.`
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
let repoRoot = process.cwd();
|
||||
let entry: string | null = null;
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === "--root" && i + 1 < argv.length) {
|
||||
repoRoot = resolve(argv[i + 1] ?? ".");
|
||||
i += 1;
|
||||
} else if (argv[i] === "--entry" && i + 1 < argv.length) {
|
||||
entry = resolve(argv[i + 1] ?? ".");
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if (entry === null) {
|
||||
entry = resolve(
|
||||
repoRoot,
|
||||
"packages/api-stack/api/api-plugin-example/src/wasm/index.ts"
|
||||
);
|
||||
}
|
||||
let hits: readonly BannedHit[];
|
||||
try {
|
||||
hits = await checkWasmSubpathDeps({ repoRoot, entry });
|
||||
} catch (cause) {
|
||||
process.stderr.write(
|
||||
`check-wasm-subpath-deps: internal error: ${(cause as Error).message}\n`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
const msg = formatHits(hits);
|
||||
if (hits.length === 0) {
|
||||
process.stdout.write(`${msg}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write(`${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await main();
|
||||
}
|
||||
76
scripts/lint/clean-stacked-end-markers.ts
Normal file
76
scripts/lint/clean-stacked-end-markers.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* One-shot cleanup: strips stacked `<!-- end:auto-docs -->` markers from
|
||||
* every .md file in packages/. Keeps the first end marker after each begin
|
||||
* marker and removes any additional end markers that follow before the next
|
||||
* begin marker (or end-of-file).
|
||||
*
|
||||
* Why this exists: an earlier doc-generator bug accumulated trailing
|
||||
* `<!-- end:auto-docs -->` markers across runs. Once stacked, the current
|
||||
* generator's replace-between-markers logic preserves them on subsequent
|
||||
* runs (it picks the FIRST end and concatenates everything after it
|
||||
* verbatim). This script normalises the existing corruption.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const BEGIN = "<!-- begin:auto-docs -->";
|
||||
const END = "<!-- end:auto-docs -->";
|
||||
|
||||
function walkMd(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) {
|
||||
if (name === "node_modules" || name === "dist" || name === ".turbo")
|
||||
continue;
|
||||
out.push(...walkMd(full));
|
||||
} else if (name.endsWith(".md")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clean(src: string): string {
|
||||
// Split on BEGIN markers; for each section after a BEGIN, keep content up
|
||||
// to and including the first END marker, then strip any additional END
|
||||
// markers (and any whitespace/newlines immediately surrounding them)
|
||||
// before the next BEGIN.
|
||||
const parts = src.split(BEGIN);
|
||||
if (parts.length === 1) return src; // no markers, untouched
|
||||
const out: string[] = [parts[0] as string];
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const section = parts[i] as string;
|
||||
const endIdx = section.indexOf(END);
|
||||
if (endIdx === -1) {
|
||||
out.push(BEGIN + section);
|
||||
continue;
|
||||
}
|
||||
const head = section.slice(0, endIdx + END.length);
|
||||
let tail = section.slice(endIdx + END.length);
|
||||
// Strip any further END markers (with optional surrounding whitespace
|
||||
// or trailing-spaces-then-newline) until the next BEGIN or EOF.
|
||||
tail = tail.replace(/(?:[ \t]*<!-- end:auto-docs -->[ \t]*\n?)+/g, "");
|
||||
out.push(BEGIN + head + tail);
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const files = walkMd("packages");
|
||||
let changed = 0;
|
||||
for (const file of files) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
const next = clean(src);
|
||||
if (next !== src) {
|
||||
writeFileSync(file, next);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
console.log(`Cleaned ${changed} .md files.`);
|
||||
}
|
||||
|
||||
main();
|
||||
53
scripts/lint/clean-truncated-markers.ts
Normal file
53
scripts/lint/clean-truncated-markers.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Strips truncated/orphan `<!-- en…` (or `<!-- en` followed by ellipsis)
|
||||
* fragments from .md files. These were produced by a display-truncated
|
||||
* comment that got round-tripped back into the source.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
function walkMd(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) {
|
||||
if (name === "node_modules" || name === "dist" || name === ".turbo")
|
||||
continue;
|
||||
out.push(...walkMd(full));
|
||||
} else if (name.endsWith(".md")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clean(src: string): string {
|
||||
let next = src;
|
||||
// Strip `<!-- en…` (Unicode ellipsis) on its own line or inline.
|
||||
next = next.replace(/[ \t]*<!--\s*en…[ \t]*\n?/g, "");
|
||||
// Also strip any unterminated `<!-- end:auto-docs -->` (missing closing -->).
|
||||
next = next.replace(/[ \t]*<!-- end:auto-docs[^>]*?\n/g, (m) => {
|
||||
if (m.includes("-->")) return m;
|
||||
return "";
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const files = walkMd("packages");
|
||||
let changed = 0;
|
||||
for (const file of files) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
const next = clean(src);
|
||||
if (next !== src) {
|
||||
writeFileSync(file, next);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
console.log(`Cleaned ${changed} .md files.`);
|
||||
}
|
||||
|
||||
main();
|
||||
133
scripts/lint/fix-deep-imports.ts
Normal file
133
scripts/lint/fix-deep-imports.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fixes `../<X>.js` imports that now point to folders instead of files
|
||||
* (since migrate-feature-layout moved X.ts → X/X.ts). Rewrites to the
|
||||
* appropriate file path.
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
|
||||
const PACKAGES_DIR = "packages";
|
||||
|
||||
function walkTs(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) out.push(...walkTs(full));
|
||||
else if (
|
||||
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
|
||||
!name.endsWith(".d.ts")
|
||||
) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function listPackages(): string[] {
|
||||
const out: string[] = [];
|
||||
for (const cat of readdirSync(PACKAGES_DIR)) {
|
||||
const catDir = join(PACKAGES_DIR, cat);
|
||||
if (!statSync(catDir).isDirectory()) continue;
|
||||
for (const plugin of readdirSync(catDir)) {
|
||||
const pluginDir = join(catDir, plugin);
|
||||
if (!statSync(pluginDir).isDirectory()) continue;
|
||||
for (const pkg of readdirSync(pluginDir)) {
|
||||
const pkgDir = join(pluginDir, pkg);
|
||||
if (!statSync(pkgDir).isDirectory()) continue;
|
||||
if (existsSync(join(pkgDir, "src"))) out.push(pkgDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fileExists(p: string): boolean {
|
||||
try {
|
||||
statSync(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(p: string): boolean {
|
||||
try {
|
||||
return statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fixFile(file: string, srcDir: string): number {
|
||||
const src = readFileSync(file, "utf8");
|
||||
let updated = src;
|
||||
let fixes = 0;
|
||||
|
||||
const re = /"((?:\.\.?\/)+[^"]+\.js)"/g;
|
||||
const seen = new Set<string>();
|
||||
for (const m of src.matchAll(re)) seen.add(m[1] as string);
|
||||
|
||||
for (const importPath of seen) {
|
||||
const fileDir = dirname(file);
|
||||
const stripped = importPath.replace(/\.js$/, "");
|
||||
const resolved = join(fileDir, stripped);
|
||||
|
||||
// Skip if outside src/
|
||||
const relFromSrc = relative(srcDir, resolved);
|
||||
if (relFromSrc.startsWith("..")) continue;
|
||||
|
||||
// If the .ts file exists at this exact path, resolution is fine.
|
||||
if (fileExists(`${resolved}.ts`) || fileExists(`${resolved}.tsx`))
|
||||
continue;
|
||||
// If a directory exists at this path, the import is broken — must rewrite.
|
||||
if (!isDirectory(resolved)) continue;
|
||||
|
||||
const baseName = relative(srcDir, resolved).split("/").pop() as string;
|
||||
const innerImpl = join(resolved, `${baseName}.ts`);
|
||||
const innerIndex = join(resolved, "index.ts");
|
||||
|
||||
// Prefer the impl file (matches migrate-feature-layout's sibling pattern).
|
||||
let newImportPath: string | null = null;
|
||||
if (fileExists(innerImpl)) {
|
||||
newImportPath = importPath.replace(/\.js$/, `/${baseName}.js`);
|
||||
} else if (fileExists(innerIndex)) {
|
||||
newImportPath = importPath.replace(/\.js$/, "/index.js");
|
||||
}
|
||||
if (newImportPath === null) continue;
|
||||
|
||||
updated = updated.split(`"${importPath}"`).join(`"${newImportPath}"`);
|
||||
fixes++;
|
||||
}
|
||||
|
||||
if (updated !== src) writeFileSync(file, updated);
|
||||
return fixes;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
let totalFiles = 0;
|
||||
let totalFixes = 0;
|
||||
for (const pkg of listPackages()) {
|
||||
const srcDir = join(pkg, "src");
|
||||
const files = walkTs(srcDir);
|
||||
for (const f of files) {
|
||||
const fixes = fixFile(f, srcDir);
|
||||
if (fixes > 0) {
|
||||
totalFiles++;
|
||||
totalFixes += fixes;
|
||||
console.log(`FIX ${f} (${fixes})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`\nFixed ${totalFixes} imports across ${totalFiles} files.`);
|
||||
}
|
||||
|
||||
main();
|
||||
131
scripts/lint/fix-external-test-imports.ts
Normal file
131
scripts/lint/fix-external-test-imports.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Walks tests/ directories at package roots (siblings of src/) and rewrites
|
||||
* relative imports that now point to migrated feature folders.
|
||||
*
|
||||
* For each `../src/<X>.js` import that no longer resolves but
|
||||
* `../src/<X>/<X>.ts` does, rewrites to `../src/<X>/<X>.js`.
|
||||
* Falls back to `../src/<X>/index.js` if only the barrel exists.
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
|
||||
const PACKAGES_DIR = "packages";
|
||||
|
||||
function walkTs(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
try {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) out.push(...walkTs(full));
|
||||
else if (
|
||||
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
|
||||
!name.endsWith(".d.ts")
|
||||
) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return out;
|
||||
}
|
||||
|
||||
function listPackages(): string[] {
|
||||
const out: string[] = [];
|
||||
for (const cat of readdirSync(PACKAGES_DIR)) {
|
||||
const catDir = join(PACKAGES_DIR, cat);
|
||||
if (!statSync(catDir).isDirectory()) continue;
|
||||
for (const plugin of readdirSync(catDir)) {
|
||||
const pluginDir = join(catDir, plugin);
|
||||
if (!statSync(pluginDir).isDirectory()) continue;
|
||||
for (const pkg of readdirSync(pluginDir)) {
|
||||
const pkgDir = join(pluginDir, pkg);
|
||||
if (!statSync(pkgDir).isDirectory()) continue;
|
||||
if (existsSync(join(pkgDir, "src"))) out.push(pkgDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fileExists(p: string): boolean {
|
||||
try {
|
||||
statSync(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(p: string): boolean {
|
||||
try {
|
||||
return statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fixFile(file: string): number {
|
||||
const src = readFileSync(file, "utf8");
|
||||
let updated = src;
|
||||
let fixes = 0;
|
||||
const re = /"((?:\.\.?\/)+[^"]+\.js)"/g;
|
||||
const seen = new Set<string>();
|
||||
for (const m of src.matchAll(re)) seen.add(m[1] as string);
|
||||
|
||||
for (const importPath of seen) {
|
||||
const fileDir = dirname(file);
|
||||
const stripped = importPath.replace(/\.js$/, "");
|
||||
const resolved = join(fileDir, stripped);
|
||||
|
||||
if (fileExists(`${resolved}.ts`) || fileExists(`${resolved}.tsx`))
|
||||
continue;
|
||||
if (!isDirectory(resolved)) continue;
|
||||
|
||||
const baseName = relative(fileDir, resolved).split("/").pop() as string;
|
||||
const innerImpl = join(resolved, `${baseName}.ts`);
|
||||
const innerIndex = join(resolved, "index.ts");
|
||||
|
||||
let newImportPath: string | null = null;
|
||||
if (fileExists(innerImpl)) {
|
||||
newImportPath = importPath.replace(/\.js$/, `/${baseName}.js`);
|
||||
} else if (fileExists(innerIndex)) {
|
||||
newImportPath = importPath.replace(/\.js$/, "/index.js");
|
||||
}
|
||||
if (newImportPath === null) continue;
|
||||
|
||||
updated = updated.split(`"${importPath}"`).join(`"${newImportPath}"`);
|
||||
fixes++;
|
||||
}
|
||||
|
||||
if (updated !== src) writeFileSync(file, updated);
|
||||
return fixes;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
let totalFiles = 0;
|
||||
let totalFixes = 0;
|
||||
for (const pkg of listPackages()) {
|
||||
const testsDir = join(pkg, "tests");
|
||||
if (!isDirectory(testsDir)) continue;
|
||||
const files = walkTs(testsDir);
|
||||
for (const f of files) {
|
||||
const fixes = fixFile(f);
|
||||
if (fixes > 0) {
|
||||
totalFiles++;
|
||||
totalFixes += fixes;
|
||||
console.log(`FIX ${f} (${fixes})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`\nFixed ${totalFixes} imports across ${totalFiles} files.`);
|
||||
}
|
||||
|
||||
main();
|
||||
157
scripts/lint/fix-migrated-imports.ts
Normal file
157
scripts/lint/fix-migrated-imports.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fixes broken relative imports inside files that were just migrated from
|
||||
* src/<feature>.ts to src/<feature>/<feature>.ts.
|
||||
*
|
||||
* The migrate-feature-layout script moved files one level deeper but only
|
||||
* rewrote the imports OF the moved files, not imports WITHIN them. Any
|
||||
* `./X.js` (or `./X/index.js`) inside a moved file now points at
|
||||
* `src/<feature>/X.js` (which doesn't exist) instead of the intended
|
||||
* `src/X.js`.
|
||||
*
|
||||
* For each `src/<feature>/{<feature>.ts, <feature>.test.ts}`, walks its
|
||||
* relative imports and rewrites `./X` → `../X` when the target resolves at
|
||||
* the package's src/ root (one level up). Leaves intra-folder imports
|
||||
* alone (`./<feature>.types.js` etc.) since those are still correct.
|
||||
*
|
||||
* Idempotent: a second run finds nothing to fix.
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const PACKAGES_DIR = "packages";
|
||||
|
||||
interface MovedFile {
|
||||
readonly file: string;
|
||||
readonly srcDir: string;
|
||||
readonly featureDir: string;
|
||||
readonly feature: string;
|
||||
}
|
||||
|
||||
function listMovedFiles(): MovedFile[] {
|
||||
const out: MovedFile[] = [];
|
||||
for (const cat of readdirSync(PACKAGES_DIR)) {
|
||||
const catDir = join(PACKAGES_DIR, cat);
|
||||
if (!statSync(catDir).isDirectory()) continue;
|
||||
for (const plugin of readdirSync(catDir)) {
|
||||
const pluginDir = join(catDir, plugin);
|
||||
if (!statSync(pluginDir).isDirectory()) continue;
|
||||
for (const pkg of readdirSync(pluginDir)) {
|
||||
const pkgDir = join(pluginDir, pkg);
|
||||
if (!statSync(pkgDir).isDirectory()) continue;
|
||||
const srcDir = join(pkgDir, "src");
|
||||
try {
|
||||
statSync(srcDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of readdirSync(srcDir)) {
|
||||
const featureDir = join(srcDir, entry);
|
||||
try {
|
||||
if (!statSync(featureDir).isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const feature = entry;
|
||||
const impl = join(featureDir, `${feature}.ts`);
|
||||
const test = join(featureDir, `${feature}.test.ts`);
|
||||
if (existsSync(impl))
|
||||
out.push({ file: impl, srcDir, featureDir, feature });
|
||||
if (existsSync(test))
|
||||
out.push({ file: test, srcDir, featureDir, feature });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function targetResolves(targetBase: string, lookupDir: string): boolean {
|
||||
// targetBase is e.g. "./errors/serialize-error" — strip leading ./, then
|
||||
// look for matching candidates from lookupDir.
|
||||
const stripped = targetBase.replace(/^\.\//, "");
|
||||
const candidates = [
|
||||
join(lookupDir, `${stripped}.ts`),
|
||||
join(lookupDir, `${stripped}.tsx`),
|
||||
join(lookupDir, stripped, "index.ts"),
|
||||
join(lookupDir, stripped, "index.tsx"),
|
||||
join(lookupDir, stripped), // for asset / json imports
|
||||
];
|
||||
return candidates.some((c) => {
|
||||
try {
|
||||
statSync(c);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fixFile(moved: MovedFile): number {
|
||||
const src = readFileSync(moved.file, "utf8");
|
||||
// Match every `"./<something>.js"` whether in import or side-effect.
|
||||
const re = /"(\.\/[^"]+\.js)"/g;
|
||||
let updated = src;
|
||||
const touches = new Set<string>();
|
||||
|
||||
for (const m of src.matchAll(re)) {
|
||||
const fullPath = m[1] as string; // e.g. "./foo.js" or "./foo/bar.js"
|
||||
const withoutJs = fullPath.replace(/\.js$/, "");
|
||||
const base = withoutJs.replace(/\/index$/, "");
|
||||
|
||||
// Skip imports that target THE feature folder itself or its siblings
|
||||
// — those resolve correctly inside the new feature dir.
|
||||
const intraFolder =
|
||||
base === `./${moved.feature}` ||
|
||||
base.startsWith(`./${moved.feature}.`) ||
|
||||
// sibling files inside the same feature folder
|
||||
!base.includes("/", 2);
|
||||
|
||||
// Step 1: does it currently resolve from the file's location?
|
||||
const fileDir = dirname(moved.file);
|
||||
if (targetResolves(base, fileDir)) continue;
|
||||
|
||||
// Step 2: does it resolve at src/ root (one level up)?
|
||||
if (targetResolves(base, moved.srcDir)) {
|
||||
const newPath = fullPath.replace(/^\.\//, "../");
|
||||
updated = updated.split(`"${fullPath}"`).join(`"${newPath}"`);
|
||||
touches.add(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Avoid lint warning about unused variable
|
||||
void intraFolder;
|
||||
}
|
||||
|
||||
if (updated !== src) {
|
||||
writeFileSync(moved.file, updated);
|
||||
return touches.size;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const moved = listMovedFiles();
|
||||
let filesChanged = 0;
|
||||
let importsRewritten = 0;
|
||||
for (const m of moved) {
|
||||
const n = fixFile(m);
|
||||
if (n > 0) {
|
||||
filesChanged++;
|
||||
importsRewritten += n;
|
||||
console.log(`FIX ${m.file} (${n} imports)`);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`\nFixed ${importsRewritten} import path${importsRewritten === 1 ? "" : "s"} across ${filesChanged} file${filesChanged === 1 ? "" : "s"}.`
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
124
scripts/lint/fix-package-config.ts
Normal file
124
scripts/lint/fix-package-config.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fixes vite.config.ts entry points and package.json subpath exports for
|
||||
* features that were migrated from flat `src/<feature>.ts` to nested
|
||||
* `src/<feature>/<feature>.ts` by `migrate-feature-layout.ts`.
|
||||
*
|
||||
* For each package that has a `src/<feature>/<feature>.ts` AND no longer has
|
||||
* a `src/<feature>.ts`, rewrites:
|
||||
* - vite.config.ts entry: "./src/<feature>.ts" → "./src/<feature>/index.ts"
|
||||
* - package.json exports: "./dist/<feature>.js" → "./dist/<feature>/index.js"
|
||||
* "./dist/<feature>.d.ts" → "./dist/<feature>/index.d.ts"
|
||||
*
|
||||
* Uses the sub-barrel (index.ts) rather than the impl directly so that
|
||||
* downstream consumers get the explicit named re-exports defined there.
|
||||
*
|
||||
* Idempotent.
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PACKAGES_DIR = "packages";
|
||||
|
||||
interface FeatureRename {
|
||||
readonly feature: string;
|
||||
}
|
||||
|
||||
function listPackages(): readonly string[] {
|
||||
const out: string[] = [];
|
||||
for (const cat of readdirSync(PACKAGES_DIR)) {
|
||||
const catDir = join(PACKAGES_DIR, cat);
|
||||
if (!statSync(catDir).isDirectory()) continue;
|
||||
for (const plugin of readdirSync(catDir)) {
|
||||
const pluginDir = join(catDir, plugin);
|
||||
if (!statSync(pluginDir).isDirectory()) continue;
|
||||
for (const pkg of readdirSync(pluginDir)) {
|
||||
const pkgDir = join(pluginDir, pkg);
|
||||
if (!statSync(pkgDir).isDirectory()) continue;
|
||||
if (existsSync(join(pkgDir, "src"))) out.push(pkgDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findMigratedFeatures(pkgDir: string): FeatureRename[] {
|
||||
const srcDir = join(pkgDir, "src");
|
||||
const out: FeatureRename[] = [];
|
||||
for (const entry of readdirSync(srcDir)) {
|
||||
const featureDir = join(srcDir, entry);
|
||||
try {
|
||||
if (!statSync(featureDir).isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const impl = join(featureDir, `${entry}.ts`);
|
||||
const flatImpl = join(srcDir, `${entry}.ts`);
|
||||
// Migrated if nested impl exists AND no flat sibling exists.
|
||||
if (existsSync(impl) && !existsSync(flatImpl)) {
|
||||
out.push({ feature: entry });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rewriteFileRefs(
|
||||
file: string,
|
||||
features: readonly FeatureRename[]
|
||||
): boolean {
|
||||
const src = readFileSync(file, "utf8");
|
||||
let updated = src;
|
||||
for (const { feature } of features) {
|
||||
// vite.config: "./src/<feature>.ts" / "src/<feature>.ts" → folder/index.ts
|
||||
updated = updated
|
||||
.split(`"./src/${feature}.ts"`)
|
||||
.join(`"./src/${feature}/index.ts"`);
|
||||
updated = updated
|
||||
.split(`"src/${feature}.ts"`)
|
||||
.join(`"src/${feature}/index.ts"`);
|
||||
// package.json: "./dist/<feature>.js" / .d.ts → "./dist/<feature>/index.{js,d.ts}"
|
||||
updated = updated
|
||||
.split(`"./dist/${feature}.js"`)
|
||||
.join(`"./dist/${feature}/index.js"`);
|
||||
updated = updated
|
||||
.split(`"./dist/${feature}.d.ts"`)
|
||||
.join(`"./dist/${feature}/index.d.ts"`);
|
||||
}
|
||||
if (updated !== src) {
|
||||
writeFileSync(file, updated);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const pkgs = listPackages();
|
||||
let touched = 0;
|
||||
for (const pkgDir of pkgs) {
|
||||
const features = findMigratedFeatures(pkgDir);
|
||||
if (features.length === 0) continue;
|
||||
const targets = [
|
||||
join(pkgDir, "vite.config.ts"),
|
||||
join(pkgDir, "package.json"),
|
||||
join(pkgDir, "vite.lib.config.ts"),
|
||||
join(pkgDir, "tsup.config.ts"),
|
||||
];
|
||||
for (const t of targets) {
|
||||
if (!existsSync(t)) continue;
|
||||
if (rewriteFileRefs(t, features)) {
|
||||
touched++;
|
||||
console.log(`FIX ${t}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`\nUpdated ${touched} config file${touched === 1 ? "" : "s"}.`);
|
||||
}
|
||||
|
||||
main();
|
||||
330
scripts/lint/migrate-feature-layout.ts
Normal file
330
scripts/lint/migrate-feature-layout.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Migrates flat-at-src-root <feature>.{ts,test.ts,md} into
|
||||
* src/<feature>/<feature>.{ts,test.ts,md} + src/<feature>/index.ts barrel.
|
||||
*
|
||||
* For each violation flagged by `scripts/lint/check-feature-layout.ts`:
|
||||
* 1. mkdir src/<feature>/
|
||||
* 2. git mv each flat file into the folder
|
||||
* 3. Parse the impl file for top-level named exports + type exports
|
||||
* 4. Write src/<feature>/index.ts with explicit named re-exports
|
||||
* (rule 10 — no export *)
|
||||
* 5. Rewrite every sibling `./<feature>.js` / `../<feature>.js` import
|
||||
* in the same package's src/ tree to point at `./<feature>/<feature>.js`
|
||||
* / `../<feature>/<feature>.js`. If the import target is the package
|
||||
* root (src/index.ts), use the new sub-barrel `./<feature>/index.js`.
|
||||
*
|
||||
* Skips already-fixed violations. Idempotent.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/lint/migrate-feature-layout.ts [--dry-run] [--package <pkg-dir>]
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
|
||||
const ROOT_FLAT_EXEMPT = new Set(["index.ts"]);
|
||||
const PACKAGES_DIR = "packages";
|
||||
|
||||
interface Violation {
|
||||
readonly pkgDir: string;
|
||||
readonly srcDir: string;
|
||||
readonly feature: string;
|
||||
readonly impl: string;
|
||||
readonly test: string;
|
||||
readonly doc: string | null;
|
||||
}
|
||||
|
||||
interface NamedExports {
|
||||
readonly values: readonly string[];
|
||||
readonly types: readonly string[];
|
||||
}
|
||||
|
||||
function listPackagesWithSrc(): readonly string[] {
|
||||
const out: string[] = [];
|
||||
for (const cat of readdirSync(PACKAGES_DIR)) {
|
||||
const catDir = join(PACKAGES_DIR, cat);
|
||||
if (!statSync(catDir).isDirectory()) continue;
|
||||
for (const plugin of readdirSync(catDir)) {
|
||||
const pluginDir = join(catDir, plugin);
|
||||
if (!statSync(pluginDir).isDirectory()) continue;
|
||||
for (const pkg of readdirSync(pluginDir)) {
|
||||
const pkgDir = join(pluginDir, pkg);
|
||||
if (!statSync(pkgDir).isDirectory()) continue;
|
||||
const srcDir = join(pkgDir, "src");
|
||||
try {
|
||||
if (statSync(srcDir).isDirectory()) out.push(pkgDir);
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findViolations(pkgDir: string): Violation[] {
|
||||
const srcDir = join(pkgDir, "src");
|
||||
const entries = readdirSync(srcDir);
|
||||
const featuresFound = new Map<string, string[]>();
|
||||
for (const name of entries) {
|
||||
const full = join(srcDir, name);
|
||||
if (statSync(full).isDirectory()) continue;
|
||||
if (ROOT_FLAT_EXEMPT.has(name)) continue;
|
||||
const match = name.match(/^(.+?)(\.test|\.types|\.md|)\.(ts|tsx|md)$/);
|
||||
if (!match) continue;
|
||||
const feature = match[1] as string;
|
||||
const list = featuresFound.get(feature) ?? [];
|
||||
list.push(name);
|
||||
featuresFound.set(feature, list);
|
||||
}
|
||||
const violations: Violation[] = [];
|
||||
for (const [feature, files] of featuresFound) {
|
||||
const hasImpl = files.includes(`${feature}.ts`);
|
||||
const hasTest = files.includes(`${feature}.test.ts`);
|
||||
if (!hasImpl || !hasTest) continue;
|
||||
violations.push({
|
||||
pkgDir,
|
||||
srcDir,
|
||||
feature,
|
||||
impl: `${feature}.ts`,
|
||||
test: `${feature}.test.ts`,
|
||||
doc: files.includes(`${feature}.md`) ? `${feature}.md` : null,
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function parseExports(implPath: string): NamedExports {
|
||||
const src = readFileSync(implPath, "utf8");
|
||||
const values = new Set<string>();
|
||||
const types = new Set<string>();
|
||||
|
||||
const cleaned = src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/\/\/.*$/gm, "");
|
||||
|
||||
const re =
|
||||
/\bexport\s+(?:default\s+)?(?:async\s+)?(const|let|var|function|class|enum|interface|type)\s+([A-Za-z_$][\w$]*)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(cleaned)) !== null) {
|
||||
const kind = match[1] as string;
|
||||
const name = match[2] as string;
|
||||
if (kind === "type" || kind === "interface") types.add(name);
|
||||
else values.add(name);
|
||||
}
|
||||
|
||||
const listRe = /export\s+(type\s+)?\{([^}]+)\}/g;
|
||||
while ((match = listRe.exec(cleaned)) !== null) {
|
||||
const blockIsType = Boolean(match[1]);
|
||||
const body = match[2] as string;
|
||||
for (const piece of body.split(",")) {
|
||||
const trimmed = piece.trim();
|
||||
if (!trimmed) continue;
|
||||
const typeM = trimmed.match(/^type\s+(.+)$/);
|
||||
const inner = typeM ? (typeM[1] as string) : trimmed;
|
||||
const asM = inner.match(
|
||||
/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/
|
||||
);
|
||||
const name = asM ? (asM[2] as string) : inner;
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
|
||||
if (blockIsType || typeM) types.add(name);
|
||||
else values.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
values: [...values].sort(),
|
||||
types: [...types].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBarrel(feature: string, exports: NamedExports): string {
|
||||
const lines: string[] = [];
|
||||
if (exports.types.length > 0) {
|
||||
lines.push("export type {");
|
||||
for (const t of exports.types) lines.push(` ${t},`);
|
||||
lines.push(`} from "./${feature}.js";`);
|
||||
}
|
||||
if (exports.values.length > 0) {
|
||||
lines.push("export {");
|
||||
for (const v of exports.values) lines.push(` ${v},`);
|
||||
lines.push(`} from "./${feature}.js";`);
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
lines.push(`export {} from "./${feature}.js";`);
|
||||
}
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function git(args: readonly string[], cwd?: string): void {
|
||||
execFileSync("git", args as string[], { cwd, stdio: "pipe" });
|
||||
}
|
||||
|
||||
function walkTs(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) out.push(...walkTs(full));
|
||||
else if (
|
||||
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
|
||||
!name.endsWith(".d.ts")
|
||||
)
|
||||
out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rewriteImports(
|
||||
pkgDir: string,
|
||||
feature: string,
|
||||
dryRun: boolean
|
||||
): number {
|
||||
const srcDir = join(pkgDir, "src");
|
||||
const tsFiles = walkTs(srcDir);
|
||||
let changed = 0;
|
||||
for (const file of tsFiles) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
let updated = src;
|
||||
const fileDir = dirname(file);
|
||||
const featureFromHere = `${feature}.js`;
|
||||
|
||||
// Root-level imports: `./<feature>.js` from src/<file>.ts
|
||||
if (fileDir === srcDir) {
|
||||
const localFrom = `"./${feature}.js"`;
|
||||
if (updated.includes(localFrom)) {
|
||||
if (file === join(srcDir, "index.ts")) {
|
||||
updated = updated
|
||||
.split(localFrom)
|
||||
.join(`"./${feature}/index.js"`);
|
||||
} else {
|
||||
updated = updated
|
||||
.split(localFrom)
|
||||
.join(`"./${feature}/${featureFromHere}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One-level-deep: `../<feature>.js` from src/<subdir>/<file>.ts
|
||||
const relFromSrc = relative(srcDir, fileDir);
|
||||
if (
|
||||
relFromSrc !== "" &&
|
||||
!relFromSrc.startsWith("..") &&
|
||||
!relFromSrc.includes("/")
|
||||
) {
|
||||
const parentFrom = `"../${feature}.js"`;
|
||||
if (updated.includes(parentFrom)) {
|
||||
updated = updated
|
||||
.split(parentFrom)
|
||||
.join(`"../${feature}/${featureFromHere}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Two-or-more-level-deep: `../../<feature>.js` etc.
|
||||
if (relFromSrc !== "" && relFromSrc.includes("/")) {
|
||||
const depth = relFromSrc.split("/").length;
|
||||
const prefix = "../".repeat(depth);
|
||||
const deepFrom = `"${prefix}${feature}.js"`;
|
||||
if (updated.includes(deepFrom)) {
|
||||
updated = updated
|
||||
.split(deepFrom)
|
||||
.join(`"${prefix}${feature}/${featureFromHere}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (updated !== src) {
|
||||
changed++;
|
||||
if (!dryRun) writeFileSync(file, updated);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function migrate(v: Violation, dryRun: boolean): void {
|
||||
const newDir = join(v.srcDir, v.feature);
|
||||
if (existsSync(newDir)) {
|
||||
console.log(`SKIP ${v.pkgDir} :: ${v.feature} (folder already exists)`);
|
||||
return;
|
||||
}
|
||||
console.log(`MIGRATE ${v.pkgDir} :: ${v.feature}`);
|
||||
|
||||
if (!dryRun) mkdirSync(newDir);
|
||||
|
||||
const moves: Array<[string, string]> = [
|
||||
[v.impl, `${v.feature}/${v.impl}`],
|
||||
[v.test, `${v.feature}/${v.test}`],
|
||||
];
|
||||
if (v.doc) moves.push([v.doc, `${v.feature}/${v.doc}`]);
|
||||
for (const [from, to] of moves) {
|
||||
if (dryRun) {
|
||||
console.log(` would git mv ${from} -> ${to}`);
|
||||
continue;
|
||||
}
|
||||
git(["mv", from, to], v.srcDir);
|
||||
}
|
||||
|
||||
const implPath = join(newDir, v.impl);
|
||||
const exports = dryRun ? { values: [], types: [] } : parseExports(implPath);
|
||||
const barrel = buildBarrel(v.feature, exports);
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
` would write index.ts (${exports.values.length} values, ${exports.types.length} types)`
|
||||
);
|
||||
} else {
|
||||
writeFileSync(join(newDir, "index.ts"), barrel);
|
||||
git(["add", "index.ts"], newDir);
|
||||
}
|
||||
|
||||
const rewriteCount = rewriteImports(v.pkgDir, v.feature, dryRun);
|
||||
console.log(
|
||||
` rewrote ${rewriteCount} import path${rewriteCount === 1 ? "" : "s"}`
|
||||
);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const pkgFilterIdx = args.indexOf("--package");
|
||||
const pkgFilter =
|
||||
pkgFilterIdx >= 0 ? (args[pkgFilterIdx + 1] as string) : null;
|
||||
|
||||
const pkgs = listPackagesWithSrc();
|
||||
const allViolations: Violation[] = [];
|
||||
for (const pkg of pkgs) {
|
||||
if (pkgFilter && pkg !== pkgFilter) continue;
|
||||
allViolations.push(...findViolations(pkg));
|
||||
}
|
||||
|
||||
if (allViolations.length === 0) {
|
||||
console.log("OK no violations to migrate.");
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`Found ${allViolations.length} violations${dryRun ? " (dry-run)" : ""}.\n`
|
||||
);
|
||||
|
||||
for (const v of allViolations) {
|
||||
migrate(v, dryRun);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n${dryRun ? "Would migrate" : "Migrated"} ${allViolations.length} violations across ${new Set(allViolations.map((v) => v.pkgDir)).size} packages.`
|
||||
);
|
||||
if (!dryRun) {
|
||||
console.log(
|
||||
"Next: run `bunx turbo lint typecheck test` per affected package to verify."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
24
scripts/lint/primitives.json
Normal file
24
scripts/lint/primitives.json
Normal file
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{
|
||||
"name": "api-sandbox-vm",
|
||||
"requiresBridge": true,
|
||||
"bridgePackagePattern": "api-sandbox-vm-host-bridge"
|
||||
},
|
||||
{
|
||||
"name": "api-sandbox-wasm",
|
||||
"requiresBridge": true,
|
||||
"bridgePackagePattern": "api-sandbox-wasm-host-bridge"
|
||||
},
|
||||
{
|
||||
"name": "api-sandbox-permissions",
|
||||
"requiresBridge": false
|
||||
},
|
||||
{
|
||||
"name": "api-ws-broker-redis",
|
||||
"requiresBridge": false
|
||||
},
|
||||
{
|
||||
"name": "api-host-pipeline-events",
|
||||
"requiresBridge": false
|
||||
}
|
||||
]
|
||||
154
scripts/lint/regen-feature-barrels.ts
Normal file
154
scripts/lint/regen-feature-barrels.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Regenerates src/<feature>/index.ts barrels.
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PACKAGES_DIR = "packages";
|
||||
|
||||
interface NamedExports {
|
||||
readonly values: readonly string[];
|
||||
readonly types: readonly string[];
|
||||
readonly hasDefault: boolean;
|
||||
}
|
||||
|
||||
function parseExports(implPath: string): NamedExports {
|
||||
const src = readFileSync(implPath, "utf8");
|
||||
const values = new Set<string>();
|
||||
const types = new Set<string>();
|
||||
let hasDefault = false;
|
||||
|
||||
const cleaned = src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/\/\/.*$/gm, "");
|
||||
|
||||
// Captures: 1 = "default" or "", 2 = kind, 3 = name.
|
||||
const re =
|
||||
/\bexport\s+(default\s+)?(?:abstract\s+)?(?:declare\s+)?(?:async\s+)?(const|let|var|function|class|enum|interface|type)\s+([A-Za-z_$][\w$]*)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(cleaned)) !== null) {
|
||||
const isDefault = Boolean(match[1]);
|
||||
const kind = match[2] as string;
|
||||
const name = match[3] as string;
|
||||
if (isDefault) {
|
||||
hasDefault = true;
|
||||
// `export default class X {}` also creates a named binding X locally,
|
||||
// but the named export only happens through the default slot.
|
||||
continue;
|
||||
}
|
||||
if (kind === "type" || kind === "interface") types.add(name);
|
||||
else values.add(name);
|
||||
}
|
||||
|
||||
// Detect bare `export default Identifier;`.
|
||||
const bareDefaultRe = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;/g;
|
||||
while ((match = bareDefaultRe.exec(cleaned)) !== null) {
|
||||
hasDefault = true;
|
||||
}
|
||||
|
||||
const listRe = /export\s+(type\s+)?\{([^}]+)\}/g;
|
||||
while ((match = listRe.exec(cleaned)) !== null) {
|
||||
const blockIsType = Boolean(match[1]);
|
||||
const body = match[2] as string;
|
||||
for (const piece of body.split(",")) {
|
||||
const trimmed = piece.trim();
|
||||
if (!trimmed) continue;
|
||||
const typeM = trimmed.match(/^type\s+(.+)$/);
|
||||
const inner = typeM ? (typeM[1] as string) : trimmed;
|
||||
const asM = inner.match(
|
||||
/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/
|
||||
);
|
||||
const name = asM ? (asM[2] as string) : inner;
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
|
||||
if (blockIsType || typeM) types.add(name);
|
||||
else values.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
values.delete("default");
|
||||
|
||||
return {
|
||||
values: [...values].sort(),
|
||||
types: [...types].sort(),
|
||||
hasDefault,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBarrel(feature: string, exports: NamedExports): string {
|
||||
const lines: string[] = [];
|
||||
if (exports.types.length > 0) {
|
||||
lines.push("export type {");
|
||||
for (const t of exports.types) lines.push(` ${t},`);
|
||||
lines.push(`} from "./${feature}.js";`);
|
||||
}
|
||||
if (exports.values.length > 0 || exports.hasDefault) {
|
||||
const names = [...exports.values];
|
||||
if (exports.hasDefault) names.push("default");
|
||||
lines.push("export {");
|
||||
for (const v of names) lines.push(` ${v},`);
|
||||
lines.push(`} from "./${feature}.js";`);
|
||||
}
|
||||
if (lines.length === 0) lines.push(`export {} from "./${feature}.js";`);
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function listPackages(): readonly string[] {
|
||||
const out: string[] = [];
|
||||
for (const cat of readdirSync(PACKAGES_DIR)) {
|
||||
const catDir = join(PACKAGES_DIR, cat);
|
||||
if (!statSync(catDir).isDirectory()) continue;
|
||||
for (const plugin of readdirSync(catDir)) {
|
||||
const pluginDir = join(catDir, plugin);
|
||||
if (!statSync(pluginDir).isDirectory()) continue;
|
||||
for (const pkg of readdirSync(pluginDir)) {
|
||||
const pkgDir = join(pluginDir, pkg);
|
||||
if (!statSync(pkgDir).isDirectory()) continue;
|
||||
if (existsSync(join(pkgDir, "src"))) out.push(pkgDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
let touched = 0;
|
||||
for (const pkgDir of listPackages()) {
|
||||
const srcDir = join(pkgDir, "src");
|
||||
for (const entry of readdirSync(srcDir)) {
|
||||
const featureDir = join(srcDir, entry);
|
||||
try {
|
||||
if (!statSync(featureDir).isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const impl = join(featureDir, `${entry}.ts`);
|
||||
const indexFile = join(featureDir, "index.ts");
|
||||
if (!existsSync(impl) || !existsSync(indexFile)) continue;
|
||||
const current = readFileSync(indexFile, "utf8");
|
||||
const onlyTouchesImpl = current
|
||||
.split("\n")
|
||||
.filter((l) => l.includes("from "))
|
||||
.every((l) => l.includes(`from "./${entry}.js"`));
|
||||
if (!onlyTouchesImpl) continue;
|
||||
const exports = parseExports(impl);
|
||||
const barrel = buildBarrel(entry, exports);
|
||||
if (barrel !== current) {
|
||||
writeFileSync(indexFile, barrel);
|
||||
console.log(`REGEN ${indexFile}`);
|
||||
touched++;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`\nRegenerated ${touched} barrels.`);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user