#!/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 { 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 { 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 { // 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 { // 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 { 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();