Replace regex prop counter with TypeScript compiler API

Uses ts.createProgram to parse real AST instead of regex + brace-depth
state machine. Immune to comments, strings, and complex type syntax.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-28 18:11:34 +01:00
parent b7a97c3d88
commit 01b1bba6d6

View File

@@ -9,11 +9,14 @@
* Only scans component files (not hooks, adapters, etc.) and only
* counts properties declared directly in *Props interfaces — inherited
* or extended HTML attributes are not counted.
*
* Uses the TypeScript compiler API for accurate AST-based counting,
* immune to comments, strings, and complex type syntax.
*/
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { relative } from "node:path";
import ts from "typescript";
const MAX_PROPS = 8;
@@ -25,66 +28,38 @@ const files = execSync(
.split("\n")
.filter(Boolean);
const program = ts.createProgram(files, {
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
jsx: ts.JsxEmit.ReactJSX,
strict: true,
noEmit: true,
skipLibCheck: true,
});
let errors = 0;
const propsRegex = /^(?:export\s+)?interface\s+(\w+Props)\s*\{/;
for (const file of files) {
const content = readFileSync(file, "utf-8");
const lines = content.split("\n");
const sourceFile = program.getSourceFile(file);
if (!sourceFile) continue;
let inInterface = false;
let interfaceName = "";
let braceDepth = 0;
let parenDepth = 0;
let propCount = 0;
let startLine = 0;
ts.forEachChild(sourceFile, (node) => {
if (!ts.isInterfaceDeclaration(node)) return;
if (!node.name.text.endsWith("Props")) return;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const propCount = node.members.filter((m) =>
ts.isPropertySignature(m),
).length;
if (!inInterface) {
const match = propsRegex.exec(line);
if (match) {
inInterface = true;
interfaceName = match[1];
braceDepth = 0;
parenDepth = 0;
propCount = 0;
startLine = i + 1;
}
if (propCount > MAX_PROPS) {
const rel = relative(process.cwd(), file);
const { line } = sourceFile.getLineAndCharacterOfPosition(node.name.pos);
console.error(
`${rel}:${line + 1}: ${node.name.text} has ${propCount} props (max ${MAX_PROPS})`,
);
errors++;
}
if (inInterface) {
for (const ch of line) {
if (ch === "{") braceDepth++;
if (ch === "}") braceDepth--;
if (ch === "(") parenDepth++;
if (ch === ")") parenDepth--;
}
// Count prop lines at brace depth 1 and not inside function params:
// Matches " propName?: type" and " readonly propName: type"
if (
braceDepth === 1 &&
parenDepth === 0 &&
/^\s+(?:readonly\s+)?\w+\??\s*:/.test(line)
) {
propCount++;
}
if (braceDepth === 0) {
if (propCount > MAX_PROPS) {
const rel = relative(process.cwd(), file);
console.error(
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`,
);
errors++;
}
inInterface = false;
}
}
}
});
}
if (errors > 0) {