diff --git a/packages/appkit/bin/appkit-lint.js b/packages/appkit/bin/appkit-lint.js new file mode 100644 index 0000000..1202ac7 --- /dev/null +++ b/packages/appkit/bin/appkit-lint.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * AST-based linting using ast-grep. + * Catches patterns that ESLint/TypeScript miss or handle poorly. + * Usage: npx appkit-lint + */ +import { parse, Lang } from "@ast-grep/napi"; +import fs from "node:fs"; +import path from "node:path"; + +const rules = [ + { + id: "no-double-type-assertion", + pattern: "$X as unknown as $Y", + message: + "Avoid double type assertion (as unknown as). Use proper type guards or fix the source type.", + }, + { + id: "no-as-any", + pattern: "$X as any", + message: + 'Avoid "as any" type assertion. Use proper typing or unknown with type guards.', + includeTests: false, // acceptable in test mocks + }, + { + id: "no-array-index-key", + pattern: "key={$IDX}", + message: + "Avoid using array index as React key. Use a stable unique identifier.", + filter: (code) => /key=\{(idx|index|i)\}/.test(code), + }, + { + id: "no-parse-float-without-validation", + pattern: "parseFloat($X).toFixed($Y)", + message: + "parseFloat can return NaN. Validate input or use toNumber() helper from shared/types.ts.", + }, +]; + +function isTestFile(filePath) { + return ( + /\.(test|spec)\.(ts|tsx)$/.test(filePath) || filePath.includes("/tests/") + ); +} + +function findTsFiles(dir, files = []) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if (["node_modules", "dist", "build", ".git"].includes(entry.name)) + continue; + findTsFiles(fullPath, files); + } else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name)) { + files.push(fullPath); + } + } + + return files; +} + +function lintFile(filePath, rules) { + const violations = []; + const content = fs.readFileSync(filePath, "utf-8"); + const lang = filePath.endsWith(".tsx") ? Lang.Tsx : Lang.TypeScript; + const testFile = isTestFile(filePath); + + const ast = parse(lang, content); + const root = ast.root(); + + for (const rule of rules) { + // skip rules that don't apply to test files + if (testFile && rule.includeTests === false) continue; + + const matches = root.findAll(rule.pattern); + + for (const match of matches) { + const code = match.text(); + + if (rule.filter && !rule.filter(code)) continue; + + const range = match.range(); + violations.push({ + file: filePath, + line: range.start.line + 1, + column: range.start.column + 1, + rule: rule.id, + message: rule.message, + code: code.length > 80 ? code.slice(0, 77) + "..." : code, + }); + } + } + + return violations; +} + +function main() { + const rootDir = process.cwd(); + const files = findTsFiles(rootDir); + + console.log(`Scanning ${files.length} TypeScript files...\n`); + + const allViolations = []; + + for (const file of files) { + const violations = lintFile(file, rules); + allViolations.push(...violations); + } + + if (allViolations.length === 0) { + console.log("No ast-grep lint violations found."); + process.exit(0); + } + + console.log(`Found ${allViolations.length} violation(s):\n`); + + for (const v of allViolations) { + const relPath = path.relative(rootDir, v.file); + console.log(`${relPath}:${v.line}:${v.column}`); + console.log(` ${v.rule}: ${v.message}`); + console.log(` > ${v.code}\n`); + } + + process.exit(1); +} + +main(); diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 5886cc5..9cb701d 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -28,7 +28,8 @@ "./package.json": "./package.json" }, "bin": { - "appkit-generate-types": "./bin/generate-types.js" + "appkit-generate-types": "./bin/generate-types.js", + "appkit-lint": "./bin/appkit-lint.js" }, "scripts": { "build:package": "tsdown --config tsdown.config.ts", @@ -40,6 +41,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@ast-grep/napi": "^0.37.0", "@databricks/sdk-experimental": "^0.15.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fcb992..64b2c95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: packages/appkit: dependencies: + '@ast-grep/napi': + specifier: ^0.37.0 + version: 0.37.0 '@databricks/sdk-experimental': specifier: ^0.15.0 version: 0.15.0 @@ -590,6 +593,64 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@ast-grep/napi-darwin-arm64@0.37.0': + resolution: {integrity: sha512-QAiIiaAbLvMEg/yBbyKn+p1gX2/FuaC0SMf7D7capm/oG4xGMzdeaQIcSosF4TCxxV+hIH4Bz9e4/u7w6Bnk3Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@ast-grep/napi-darwin-x64@0.37.0': + resolution: {integrity: sha512-zvcvdgekd4ySV3zUbUp8HF5nk5zqwiMXTuVzTUdl/w08O7JjM6XPOIVT+d2o/MqwM9rsXdzdergY5oY2RdhSPA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@ast-grep/napi-linux-arm64-gnu@0.37.0': + resolution: {integrity: sha512-L7Sj0lXy8X+BqSMgr1LB8cCoWk0rericdeu+dC8/c8zpsav5Oo2IQKY1PmiZ7H8IHoFBbURLf8iklY9wsD+cyA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@ast-grep/napi-linux-arm64-musl@0.37.0': + resolution: {integrity: sha512-LF9sAvYy6es/OdyJDO3RwkX3I82Vkfsng1sqUBcoWC1jVb1wX5YVzHtpQox9JrEhGl+bNp7FYxB4Qba9OdA5GA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@ast-grep/napi-linux-x64-gnu@0.37.0': + resolution: {integrity: sha512-TViz5/klqre6aSmJzswEIjApnGjJzstG/SE8VDWsrftMBMYt2PTu3MeluZVwzSqDao8doT/P+6U11dU05UOgxw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@ast-grep/napi-linux-x64-musl@0.37.0': + resolution: {integrity: sha512-/BcCH33S9E3ovOAEoxYngUNXgb+JLg991sdyiNP2bSoYd30a9RHrG7CYwW6fMgua3ijQ474eV6cq9yZO1bCpXg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@ast-grep/napi-win32-arm64-msvc@0.37.0': + resolution: {integrity: sha512-TjQA4cFoIEW2bgjLkaL9yqT4XWuuLa5MCNd0VCDhGRDMNQ9+rhwi9eLOWRaap3xzT7g+nlbcEHL3AkVCD2+b3A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@ast-grep/napi-win32-ia32-msvc@0.37.0': + resolution: {integrity: sha512-uNmVka8fJCdYsyOlF9aZqQMLTatEYBynjChVTzUfFMDfmZ0bihs/YTqJVbkSm8TZM7CUX82apvn50z/dX5iWRA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@ast-grep/napi-win32-x64-msvc@0.37.0': + resolution: {integrity: sha512-vCiFOT3hSCQuHHfZ933GAwnPzmL0G04JxQEsBRfqONywyT8bSdDc/ECpAfr3S9VcS4JZ9/F6tkePKW/Om2Dq2g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@ast-grep/napi@0.37.0': + resolution: {integrity: sha512-Hb4o6h1Pf6yRUAX07DR4JVY7dmQw+RVQMW5/m55GoiAT/VRoKCWBtIUPPOnqDVhbx1Cjfil9b6EDrgJsUAujEQ==} + engines: {node: '>= 10'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -10631,6 +10692,45 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@ast-grep/napi-darwin-arm64@0.37.0': + optional: true + + '@ast-grep/napi-darwin-x64@0.37.0': + optional: true + + '@ast-grep/napi-linux-arm64-gnu@0.37.0': + optional: true + + '@ast-grep/napi-linux-arm64-musl@0.37.0': + optional: true + + '@ast-grep/napi-linux-x64-gnu@0.37.0': + optional: true + + '@ast-grep/napi-linux-x64-musl@0.37.0': + optional: true + + '@ast-grep/napi-win32-arm64-msvc@0.37.0': + optional: true + + '@ast-grep/napi-win32-ia32-msvc@0.37.0': + optional: true + + '@ast-grep/napi-win32-x64-msvc@0.37.0': + optional: true + + '@ast-grep/napi@0.37.0': + optionalDependencies: + '@ast-grep/napi-darwin-arm64': 0.37.0 + '@ast-grep/napi-darwin-x64': 0.37.0 + '@ast-grep/napi-linux-arm64-gnu': 0.37.0 + '@ast-grep/napi-linux-arm64-musl': 0.37.0 + '@ast-grep/napi-linux-x64-gnu': 0.37.0 + '@ast-grep/napi-linux-x64-musl': 0.37.0 + '@ast-grep/napi-win32-arm64-msvc': 0.37.0 + '@ast-grep/napi-win32-ia32-msvc': 0.37.0 + '@ast-grep/napi-win32-x64-msvc': 0.37.0 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1