223 lines
7.4 KiB
TypeScript
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();
|