/** * Enforce a maximum number of explicitly declared props per component * interface. * * Components should consume shared application state via React context * providers, not prop drilling. Props are reserved for per-instance * configuration (a specific data item, a layout variant, a ref). * * 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 { relative } from "node:path"; import ts from "typescript"; const MAX_PROPS = 8; const files = execSync( "git ls-files -- 'apps/web/src/components/*.tsx' 'apps/web/src/components/**/*.tsx'", { encoding: "utf-8" }, ) .trim() .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; for (const file of files) { const sourceFile = program.getSourceFile(file); if (!sourceFile) continue; ts.forEachChild(sourceFile, (node) => { if (!ts.isInterfaceDeclaration(node)) return; if (!node.name.text.endsWith("Props")) return; const propCount = node.members.filter((m) => ts.isPropertySignature(m), ).length; 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 (errors > 0) { console.error( `\n${errors} component(s) exceed the ${MAX_PROPS}-prop limit. Use React context to reduce props.`, ); process.exit(1); } else { console.log( `check-component-props: all component interfaces within ${MAX_PROPS}-prop limit`, ); }