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 * Only scans component files (not hooks, adapters, etc.) and only
* counts properties declared directly in *Props interfaces — inherited * counts properties declared directly in *Props interfaces — inherited
* or extended HTML attributes are not counted. * 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 { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { relative } from "node:path"; import { relative } from "node:path";
import ts from "typescript";
const MAX_PROPS = 8; const MAX_PROPS = 8;
@@ -25,66 +28,38 @@ const files = execSync(
.split("\n") .split("\n")
.filter(Boolean); .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; let errors = 0;
const propsRegex = /^(?:export\s+)?interface\s+(\w+Props)\s*\{/;
for (const file of files) { for (const file of files) {
const content = readFileSync(file, "utf-8"); const sourceFile = program.getSourceFile(file);
const lines = content.split("\n"); if (!sourceFile) continue;
let inInterface = false; ts.forEachChild(sourceFile, (node) => {
let interfaceName = ""; if (!ts.isInterfaceDeclaration(node)) return;
let braceDepth = 0; if (!node.name.text.endsWith("Props")) return;
let parenDepth = 0;
let propCount = 0;
let startLine = 0;
for (let i = 0; i < lines.length; i++) { const propCount = node.members.filter((m) =>
const line = lines[i]; 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 (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) { if (propCount > MAX_PROPS) {
const rel = relative(process.cwd(), file); const rel = relative(process.cwd(), file);
const { line } = sourceFile.getLineAndCharacterOfPosition(node.name.pos);
console.error( console.error(
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`, `${rel}:${line + 1}: ${node.name.text} has ${propCount} props (max ${MAX_PROPS})`,
); );
errors++; errors++;
} }
inInterface = false; });
}
}
}
} }
if (errors > 0) { if (errors > 0) {