Files
suaveplan/scripts/lint/check-error-subclass-constructors.ts
2026-05-27 23:47:53 +01:00

223 lines
7.4 KiB
TypeScript

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