|
| 1 | +/** |
| 2 | + * AST-based linting using ast-grep. |
| 3 | + * Catches patterns that ESLint/TypeScript miss or handle poorly. |
| 4 | + * Usage: npx tsx scripts/lint-ast-grep.ts |
| 5 | + */ |
| 6 | + |
| 7 | +import { parse, Lang } from '@ast-grep/napi'; |
| 8 | +import fs from 'node:fs'; |
| 9 | +import path from 'node:path'; |
| 10 | + |
| 11 | +interface LintViolation { |
| 12 | + file: string; |
| 13 | + line: number; |
| 14 | + column: number; |
| 15 | + rule: string; |
| 16 | + message: string; |
| 17 | + code: string; |
| 18 | +} |
| 19 | + |
| 20 | +interface LintRule { |
| 21 | + id: string; |
| 22 | + pattern: string; |
| 23 | + message: string; |
| 24 | + filter?: (code: string) => boolean; |
| 25 | + includeTests?: boolean; // default true - set false to skip test files |
| 26 | +} |
| 27 | + |
| 28 | +const rules: LintRule[] = [ |
| 29 | + { |
| 30 | + id: 'no-double-type-assertion', |
| 31 | + pattern: '$X as unknown as $Y', |
| 32 | + message: 'Avoid double type assertion (as unknown as). Use proper type guards or fix the source type.', |
| 33 | + }, |
| 34 | + { |
| 35 | + id: 'no-as-any', |
| 36 | + pattern: '$X as any', |
| 37 | + message: 'Avoid "as any" type assertion. Use proper typing or unknown with type guards.', |
| 38 | + includeTests: false, // acceptable in test mocks |
| 39 | + }, |
| 40 | + { |
| 41 | + id: 'no-array-index-key', |
| 42 | + pattern: 'key={$IDX}', |
| 43 | + message: 'Avoid using array index as React key. Use a stable unique identifier.', |
| 44 | + filter: (code) => /key=\{(idx|index|i)\}/.test(code), |
| 45 | + }, |
| 46 | + { |
| 47 | + id: 'no-parse-float-without-validation', |
| 48 | + pattern: 'parseFloat($X).toFixed($Y)', |
| 49 | + message: 'parseFloat can return NaN. Validate input or use toNumber() helper from shared/types.ts.', |
| 50 | + }, |
| 51 | +]; |
| 52 | + |
| 53 | +function isTestFile(filePath: string): boolean { |
| 54 | + return /\.(test|spec)\.(ts|tsx)$/.test(filePath) || filePath.includes('/tests/'); |
| 55 | +} |
| 56 | + |
| 57 | +function findTsFiles(dir: string, files: string[] = []): string[] { |
| 58 | + const entries = fs.readdirSync(dir, { withFileTypes: true }); |
| 59 | + |
| 60 | + for (const entry of entries) { |
| 61 | + const fullPath = path.join(dir, entry.name); |
| 62 | + |
| 63 | + if (entry.isDirectory()) { |
| 64 | + if (['node_modules', 'dist', 'build', '.git'].includes(entry.name)) continue; |
| 65 | + findTsFiles(fullPath, files); |
| 66 | + } else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) { |
| 67 | + files.push(fullPath); |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + return files; |
| 72 | +} |
| 73 | + |
| 74 | +function lintFile(filePath: string, rules: LintRule[]): LintViolation[] { |
| 75 | + const violations: LintViolation[] = []; |
| 76 | + const content = fs.readFileSync(filePath, 'utf-8'); |
| 77 | + const lang = filePath.endsWith('.tsx') ? Lang.Tsx : Lang.TypeScript; |
| 78 | + const testFile = isTestFile(filePath); |
| 79 | + |
| 80 | + const ast = parse(lang, content); |
| 81 | + const root = ast.root(); |
| 82 | + |
| 83 | + for (const rule of rules) { |
| 84 | + // skip rules that don't apply to test files |
| 85 | + if (testFile && rule.includeTests === false) continue; |
| 86 | + |
| 87 | + const matches = root.findAll(rule.pattern); |
| 88 | + |
| 89 | + for (const match of matches) { |
| 90 | + const code = match.text(); |
| 91 | + |
| 92 | + if (rule.filter && !rule.filter(code)) continue; |
| 93 | + |
| 94 | + const range = match.range(); |
| 95 | + violations.push({ |
| 96 | + file: filePath, |
| 97 | + line: range.start.line + 1, |
| 98 | + column: range.start.column + 1, |
| 99 | + rule: rule.id, |
| 100 | + message: rule.message, |
| 101 | + code: code.length > 80 ? code.slice(0, 77) + '...' : code, |
| 102 | + }); |
| 103 | + } |
| 104 | + } |
| 105 | + |
| 106 | + return violations; |
| 107 | +} |
| 108 | + |
| 109 | +function main(): void { |
| 110 | + const rootDir = process.cwd(); |
| 111 | + const files = findTsFiles(rootDir); |
| 112 | + |
| 113 | + console.log(`Scanning ${files.length} TypeScript files...\n`); |
| 114 | + |
| 115 | + const allViolations: LintViolation[] = []; |
| 116 | + |
| 117 | + for (const file of files) { |
| 118 | + const violations = lintFile(file, rules); |
| 119 | + allViolations.push(...violations); |
| 120 | + } |
| 121 | + |
| 122 | + if (allViolations.length === 0) { |
| 123 | + console.log('No ast-grep lint violations found.'); |
| 124 | + process.exit(0); |
| 125 | + } |
| 126 | + |
| 127 | + console.log(`Found ${allViolations.length} violation(s):\n`); |
| 128 | + |
| 129 | + for (const v of allViolations) { |
| 130 | + const relPath = path.relative(rootDir, v.file); |
| 131 | + console.log(`${relPath}:${v.line}:${v.column}`); |
| 132 | + console.log(` ${v.rule}: ${v.message}`); |
| 133 | + console.log(` > ${v.code}\n`); |
| 134 | + } |
| 135 | + |
| 136 | + process.exit(1); |
| 137 | +} |
| 138 | + |
| 139 | +main(); |
0 commit comments