diff --git a/scripts/check-component-props.mjs b/scripts/check-component-props.mjs index 215be51..d74b2a0 100644 --- a/scripts/check-component-props.mjs +++ b/scripts/check-component-props.mjs @@ -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) {