diff --git a/package.json b/package.json index ea65394910..0074daffb3 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "ios": "cd apps/expo && bun ios", "lefthook": "lefthook install", "lint": "biome check --write", - "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts && bun run packages/env/scripts/no-raw-process-env.ts && bun run scripts/lint/no-duplicate-guards.ts && bun run scripts/lint/no-unauth-routes.ts", + "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts && bun run packages/env/scripts/no-raw-process-env.ts && bun run scripts/lint/no-duplicate-guards.ts && bun run scripts/lint/no-unauth-routes.ts && bun run check:max-params", "lint:strict": "biome check && bun run lint:custom", "lint-unsafe": "biome check --write --unsafe", "mcp": "bun run --cwd packages/mcp dev", @@ -49,7 +49,8 @@ "test:landing": "vitest run --config apps/landing/vitest.config.ts", "test:mcp": "bun run --cwd packages/mcp test", "trails": "bun run --cwd apps/trails dev", - "web": "bun run --cwd apps/web dev" + "web": "bun run --cwd apps/web dev", + "check:max-params": "bun run --cwd packages/checks check:max-params" }, "overrides": { "@packrat-ai/nativewindui": "2.0.3-2", diff --git a/packages/checks/package.json b/packages/checks/package.json index 10c105548f..5fb799cacf 100644 --- a/packages/checks/package.json +++ b/packages/checks/package.json @@ -6,6 +6,7 @@ "scripts": { "check:casts": "bun ./src/check-type-casts.ts", "check:casts:strict": "bun ./src/check-type-casts.ts --strict", - "check:magic-strings": "bun ./src/check-magic-strings.ts" + "check:magic-strings": "bun ./src/check-magic-strings.ts", + "check:max-params": "bun ./src/check-max-params.ts" } } diff --git a/packages/checks/src/check-max-params.ts b/packages/checks/src/check-max-params.ts new file mode 100644 index 0000000000..26c27554b7 --- /dev/null +++ b/packages/checks/src/check-max-params.ts @@ -0,0 +1,162 @@ +#!/usr/bin/env bun + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { extname, join } from 'node:path'; +import ts from 'typescript'; + +const ROOT = join(import.meta.dir, '..', '..', '..'); +const SCAN_ROOTS = ['apps', 'packages']; +const EXCLUDED_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + '.next', + '.expo', + 'drizzle', + 'coverage', + 'ios', + 'android', +]); +const TARGET_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']); +const EXCLUDED_FILE_PATTERNS = [ + /\.test\./, + /\.spec\./, + /\.stories\./, + /\.d\.ts$/, + /\/__tests__\//, + /\/test\//, + /\/tests\//, + /\/node_modules\//, + /\/dist\//, + /\/build\//, +]; + +const ALLOW_ANNOTATION = /@allow-multi-param/; + +interface Violation { + file: string; + line: number; + col: number; + params: number; + kind: string; + snippet: string; +} + +function isTargetFile(filePath: string): boolean { + if (!TARGET_EXTENSIONS.has(extname(filePath))) return false; + return !EXCLUDED_FILE_PATTERNS.some((p) => p.test(filePath)); +} + +function getKindLabel(node: ts.Node): string { + if (ts.isFunctionDeclaration(node)) return 'function declaration'; + if (ts.isMethodDeclaration(node)) return 'method declaration'; + if (ts.isArrowFunction(node)) return 'arrow function'; + if (ts.isFunctionExpression(node)) return 'function expression'; + if (ts.isConstructorDeclaration(node)) return 'constructor'; + return 'function-like'; +} + +function isCallbackForInvocation(node: ts.Node): boolean { + const parent = node.parent; + if (!parent || !ts.isCallExpression(parent)) return false; + return parent.arguments.some((arg) => arg === node); +} + +function hasAllowAnnotation(source: ts.SourceFile, node: ts.Node): boolean { + const fullText = source.getFullText(); + const leading = ts.getLeadingCommentRanges(fullText, node.getFullStart()) ?? []; + for (const range of leading) { + if (ALLOW_ANNOTATION.test(fullText.slice(range.pos, range.end))) { + return true; + } + } + return false; +} + +function collectViolations(filePath: string): Violation[] { + const code = readFileSync(filePath, 'utf8'); + const source = ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true); + const violations: Violation[] = []; + + const visit = (node: ts.Node): void => { + if ( + ts.isFunctionLike(node) && + node.parameters.length > 1 && + !hasAllowAnnotation(source, node) && + !isCallbackForInvocation(node) + ) { + const start = source.getLineAndCharacterOfPosition(node.getStart()); + const end = Math.min(node.getEnd(), node.getStart() + 120); + const snippet = source.getText().slice(node.getStart(), end).replace(/\s+/g, ' ').trim(); + violations.push({ + file: filePath.replace(`${ROOT}/`, ''), + line: start.line + 1, + col: start.character + 1, + params: node.parameters.length, + kind: getKindLabel(node), + snippet, + }); + } + ts.forEachChild(node, visit); + }; + + visit(source); + return violations; +} + +const targetFiles: string[] = []; + +function walkDir(dir: string): void { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + + for (const entry of entries) { + if (EXCLUDED_DIRS.has(entry)) continue; + const fullPath = join(dir, entry); + let isDir = false; + try { + isDir = statSync(fullPath).isDirectory(); + } catch { + continue; + } + + if (isDir) { + walkDir(fullPath); + continue; + } + + if (isTargetFile(fullPath)) { + targetFiles.push(fullPath); + } + } +} + +for (const root of SCAN_ROOTS) { + walkDir(join(ROOT, root)); +} + +const violations = targetFiles.flatMap(collectViolations); + +if (violations.length === 0) { + console.log('✓ No multi-parameter functions found.'); + process.exit(0); +} + +console.log(`Found ${violations.length} function(s) with more than 1 parameter:\n`); +let lastFile = ''; +for (const v of violations) { + if (v.file !== lastFile) { + console.log(`\n ${v.file}`); + lastFile = v.file; + } + console.log(` ${v.line}:${v.col} ${v.kind} (${v.params} params)`); + console.log(` ${v.snippet}`); +} + +console.log('\nPrefer a single typed object parameter.'); +console.log('Add @allow-multi-param in a leading comment only when a workaround is required.'); +process.exit(1);