Initial Commit
This commit is contained in:
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();
|
||||
Reference in New Issue
Block a user