diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1db0390..2d8e3ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,3 +129,28 @@ jobs: - name: Check for unused dependencies run: npm run knip + + sync-verify: + name: Registry & README in sync + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run sync script + run: npm run sync + + - name: Verify no diff + run: | + if ! git diff --exit-code -- src/rules/index.ts README.md; then + echo "::error::scripts/sync.ts produced a diff. Run 'npm run sync' locally and commit the result." + exit 1 + fi diff --git a/CLAUDE.md b/CLAUDE.md index ffaefa6..14c1186 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,12 +25,25 @@ laint is an AI Agent Lint Rules SDK — a programmatic API for linting JSX/TSX c ## Adding a new rule -1. Create `src/rules/my-rule.ts` exporting a `RuleFunction` -2. Register it in `src/rules/index.ts` -3. Create `tests/my-rule.test.ts` -4. **Update the rule count assertion in `tests/config-modes.test.ts`** — the `getAllRuleNames` test has `expect(ruleNames.length).toBe(...)` that must match the total number of registered rules -5. **Document the rule in `README.md`** — CI checks that every registered rule name appears in the README -6. Run `npm test` to verify +1. Create `src/rules/my-rule.ts` exporting BOTH a `RuleFunction` and a `meta` object: + + ```ts + export const meta = { + name: 'my-rule', + severity: 'error' as const, + platforms: ['expo', 'web'] as Platform[] | null, + category: 'React / JSX', + description: 'One-line summary shown in README table', + }; + + export function myRule(ast: File, _code: string): LintResult[] { ... } + ``` + +2. Create `tests/my-rule.test.ts` +3. Run `npm run sync` — regenerates `src/rules/index.ts` and the README rule tables from the per-rule `meta` exports +4. Run `npm test` to verify + +That's it. The registry, README rule tables, platform tags, and rule count are all derived from the rule files. CI verifies `npm run sync` was run (no diff). ## Code style diff --git a/README.md b/README.md index 93891fa..e236cad 100644 --- a/README.md +++ b/README.md @@ -18,17 +18,12 @@ By default, all 45 rules run. To customize, create a `laint.config.json` in your ```json // Only run these specific rules (include mode) -{ - "rules": ["no-relative-paths", "expo-image-import", "fetch-response-ok-check"] -} +{ "rules": ["no-relative-paths", "expo-image-import", "fetch-response-ok-check"] } ``` ```json // Run all rules except these (exclude mode) -{ - "rules": ["no-tailwind-animation-classes", "no-stylesheet-create"], - "exclude": true -} +{ "rules": ["no-tailwind-animation-classes", "no-stylesheet-create"], "exclude": true } ``` ```json @@ -122,82 +117,87 @@ const webRules = getRulesForPlatform('web'); const backendRules = getRulesForPlatform('backend'); ``` -## Available Rules (54 total) +## Available Rules (55 total) + + ### Expo Router Rules | Rule | Severity | Platform | Description | | -------------------- | -------- | --------- | -------------------------------------------------------- | -| `no-relative-paths` | error | expo, web | Use absolute paths in router.navigate/push and Link href | | `header-shown-false` | warning | expo | (tabs) Screen in root layout needs `headerShown: false` | +| `no-relative-paths` | error | expo, web | Use absolute paths in router.navigate/push and Link href | ### React Native / Expo Rules | Rule | Severity | Platform | Description | | ---------------------------------- | -------- | -------- | ---------------------------------------------------- | -| `no-stylesheet-create` | warning | expo | Use inline styles instead of StyleSheet.create() | -| `no-safeareaview` | warning | expo | Use useSafeAreaInsets() hook instead of SafeAreaView | +| `expo-font-loaded-check` | error | expo | useFonts() must check loaded before rendering | | `expo-image-import` | warning | expo | Import Image from expo-image, not react-native | +| `native-tabs-bottom-padding` | warning | expo | NativeTabs screens need 64px bottom padding | +| `no-safeareaview` | warning | expo | Use useSafeAreaInsets() hook instead of SafeAreaView | +| `no-stylesheet-create` | warning | expo | Use inline styles instead of StyleSheet.create() | | `no-tab-bar-height` | error | expo | Never set explicit height in tabBarStyle | | `scrollview-horizontal-flexgrow` | warning | expo | Horizontal ScrollView needs `flexGrow: 0` | -| `expo-font-loaded-check` | error | expo | useFonts() must check loaded before rendering | | `tabs-screen-options-header-shown` | warning | expo | Tabs screenOptions should have `headerShown: false` | -| `native-tabs-bottom-padding` | warning | expo | NativeTabs screens need 64px bottom padding | | `textinput-keyboard-avoiding` | warning | expo | TextInput should be inside KeyboardAvoidingView | ### Liquid Glass Rules (expo-glass-effect) | Rule | Severity | Platform | Description | | ---------------------------- | -------- | -------- | ----------------------------------------------------- | -| `no-border-width-on-glass` | error | expo | No borderWidth on GlassView (breaks borderRadius) | -| `glass-needs-fallback` | warning | expo | Check isLiquidGlassAvailable() before using GlassView | | `glass-interactive-prop` | warning | expo | GlassView in pressables needs `isInteractive={true}` | +| `glass-needs-fallback` | warning | expo | Check isLiquidGlassAvailable() before using GlassView | | `glass-no-opacity-animation` | warning | expo | No opacity animations on GlassView | +| `no-border-width-on-glass` | error | expo | No borderWidth on GlassView (breaks borderRadius) | ### Next.js Rules | Rule | Severity | Platform | Description | | ---------------------------- | -------- | -------- | ----------------------------------------------------------------- | -| `require-use-client` | error | web | Files using client-only features must have "use client" directive | +| `no-module-level-new` | error | web | Avoid module-level constructors that execute during SSR | +| `no-react-native-in-web` | error | web | Do not import React Native modules from web code | | `no-server-import-in-client` | error | web | "use client" files must not import server-only modules | -| `ssr-browser-api-guard` | error | web | Browser globals in server components crash during SSR | +| `require-use-client` | error | web | Files using client-only features must have "use client" directive | +| `ssr-browser-api-guard` | error | web | Guard browser-only APIs in files that run during SSR | ### React / JSX Rules | Rule | Severity | Platform | Description | | ---------------------------- | -------- | ------------ | --------------------------------------------- | -| `no-class-components` | warning | expo, web | Use function components with hooks | -| `no-inline-script-code` | error | web | Script tags should use template literals | -| `no-react-query-missing` | warning | expo, web | Use @tanstack/react-query for data fetching | | `browser-api-in-useeffect` | warning | web | window/localStorage only in useEffect for SSR | | `fetch-response-ok-check` | warning | web, backend | Check response.ok when using fetch | +| `no-class-components` | warning | expo, web | Use function components with hooks | | `no-complex-jsx-expressions` | warning | expo, web | Avoid IIFEs and complex expressions in JSX | +| `no-inline-script-code` | error | web | Script tags should use template literals | +| `no-react-query-missing` | warning | expo, web | Use @tanstack/react-query for data fetching | ### Screen Transitions Rules (react-native-screen-transitions) | Rule | Severity | Platform | Description | | -------------------------------- | -------- | -------- | ------------------------------------------------------------------------- | -| `transition-worklet-directive` | error | expo | screenStyleInterpolator functions must include "worklet" directive | -| `transition-progress-range` | warning | expo | interpolate() should cover full [0, 1, 2] range including exit phase | | `transition-gesture-scrollview` | warning | expo | Use Transition.ScrollView/FlatList instead of regular versions | -| `transition-shared-tag-mismatch` | warning | expo | sharedBoundTag on Transition.Pressable must have matching Transition.View | | `transition-prefer-blank-stack` | warning | expo | Use Blank Stack instead of enableTransitions on Native Stack | +| `transition-progress-range` | warning | expo | interpolate() should cover full [0, 1, 2] range including exit phase | +| `transition-shared-tag-mismatch` | warning | expo | sharedBoundTag on Transition.Pressable must have matching Transition.View | +| `transition-worklet-directive` | error | expo | screenStyleInterpolator functions must include "worklet" directive | ### Tailwind CSS Rules -| Rule | Severity | Platform | Description | -| ------------------------------- | -------- | -------- | ------------------------------------------------------ | -| `no-tailwind-animation-classes` | warning | web | Avoid animate-\* classes, use style jsx global instead | -| `no-inline-styles` | warning | web | Avoid inline styles, use Tailwind CSS classes instead | +| Rule | Severity | Platform | Description | +| ------------------------------- | -------- | --------- | ------------------------------------------------------ | +| `no-inline-styles` | warning | universal | Avoid inline styles, use Tailwind CSS classes instead | +| `no-tailwind-animation-classes` | warning | web | Avoid animate-\* classes, use style jsx global instead | ### Backend / SQL Rules -| Rule | Severity | Platform | Description | -| ---------------------------- | -------- | -------- | ------------------------------------------------------------- | -| `no-require-statements` | error | backend | Use ES imports, not CommonJS require | -| `no-response-json-lowercase` | warning | backend | Use Response.json() instead of new Response(JSON.stringify()) | -| `sql-no-nested-calls` | error | backend | Don't nest sql template tags | -| `no-sync-fs` | error | backend | Use fs.promises or fs/promises instead of sync fs methods | +| Rule | Severity | Platform | Description | +| ------------------------------------ | -------- | -------- | ------------------------------------------------------------- | +| `no-require-statements` | error | backend | Use ES imports, not CommonJS require | +| `no-response-json-lowercase` | warning | backend | Use Response.json() instead of new Response(JSON.stringify()) | +| `no-sync-fs` | error | backend | Use fs.promises or fs/promises instead of sync fs methods | +| `no-unrestricted-loop-in-serverless` | error | backend | Avoid unbounded loops that can time out serverless functions | +| `sql-no-nested-calls` | error | backend | Don't nest sql template tags | ### URL Rules @@ -215,33 +215,28 @@ const backendRules = getRulesForPlatform('backend'); | Rule | Severity | Platform | Description | | ------------------------ | -------- | --------- | ---------------------------------------------------------------- | -| `prefer-guard-clauses` | warning | universal | Use early returns instead of nesting if statements | -| `no-type-assertion` | warning | universal | Avoid `as` type casts; use type narrowing or proper types | -| `safe-json-parse` | warning | universal | Wrap JSON.parse in try-catch to handle malformed input | +| `logger-error-with-err` | warning | universal | logger.error() must include { err: Error } for stack traces | +| `no-emoji-icons` | warning | universal | Use icons from lucide-react instead of emoji characters | | `no-loose-equality` | warning | universal | Use === and !== instead of == and != (except == null) | | `no-magic-env-strings` | warning | universal | Use centralized enum for env variable names, not magic strings | +| `no-manual-retry-loop` | warning | universal | Use a retry library instead of manual retry/polling loops | | `no-nested-try-catch` | warning | universal | Avoid nested try-catch blocks, extract to separate functions | -| `no-string-coerce-error` | warning | universal | Use JSON.stringify instead of String() for unknown caught errors | -| `logger-error-with-err` | warning | universal | logger.error() must include { err: Error } for stack traces | | `no-optional-props` | warning | universal | Use `prop: T \| null` instead of `prop?: T` in interfaces | | `no-silent-skip` | warning | universal | Add else branch with logging instead of silently skipping | -| `no-manual-retry-loop` | warning | universal | Use a retry library instead of manual retry/polling loops | -| `no-emoji-icons` | warning | universal | Use icons from lucide-react instead of emoji characters | +| `no-string-coerce-error` | warning | universal | Use JSON.stringify instead of String() for unknown caught errors | +| `no-type-assertion` | warning | universal | Avoid `as` type casts; use type narrowing or proper types | +| `prefer-guard-clauses` | warning | universal | Use early returns instead of nesting if statements | | `prefer-named-params` | warning | universal | Use object destructuring instead of positional parameters | +| `prefer-promise-all` | warning | universal | Prefer Promise.all for independent async work in loops | +| `safe-json-parse` | warning | universal | Wrap JSON.parse in try-catch to handle malformed input | ### General Rules -| Rule | Severity | Platform | Description | -| ------------------------------------ | -------- | --------- | ---------------------------------------------------------------- | -| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons | -| `no-react-native-in-web` | error | web | Don't import react-native in web modules (causes ESM failures) | -| `no-module-level-new` | error | web | Don't use `new` at module scope (crashes during SSR) | -| `no-require-statements` | error | backend | Use ES imports, not CommonJS require | -| `no-response-json-lowercase` | warning | backend | Use Response.json() instead of new Response(JSON.stringify()) | -| `sql-no-nested-calls` | error | backend | Don't nest sql template tags | -| `no-sync-fs` | error | backend | Use fs.promises or fs/promises instead of sync fs methods | -| `no-unrestricted-loop-in-serverless` | error | backend | Unbounded loops (while(true), for(;;)) cause serverless timeouts | -| `prefer-promise-all` | warning | universal | Use Promise.all instead of sequential await in for...of loops | +| Rule | Severity | Platform | Description | +| --------------------- | -------- | --------- | --------------------------------------------- | +| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons | + + --- diff --git a/package-lock.json b/package-lock.json index b657803..2f97836 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "globals": "^17.3.0", "knip": "^5.83.1", "prettier": "^3.8.1", + "tsx": "^4.19.0", "typescript": "^5.3.0", "typescript-eslint": "^8.54.0", "vitest": "^4.0.18" @@ -1635,6 +1636,7 @@ "integrity": "sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1684,6 +1686,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1859,6 +1862,7 @@ "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.54.0", @@ -2294,6 +2298,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2573,6 +2578,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4005,6 +4011,510 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz", + "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4024,6 +4534,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4070,6 +4581,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -4114,6 +4626,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index 2d43326..ba0d4fa 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "lint": "eslint . && prettier --check .", "lint:fix": "eslint --fix . && prettier --write .", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "sync": "tsx scripts/sync.ts", + "sync:check": "tsx scripts/sync.ts && git diff --exit-code -- src/rules/index.ts README.md" }, "keywords": [ "lint", @@ -56,6 +58,7 @@ "prettier": "^3.8.1", "typescript": "^5.3.0", "typescript-eslint": "^8.54.0", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "tsx": "^4.19.0" } } diff --git a/scripts/sync.ts b/scripts/sync.ts new file mode 100644 index 0000000..47635b2 --- /dev/null +++ b/scripts/sync.ts @@ -0,0 +1,185 @@ +#!/usr/bin/env node +/** + * Regenerate the rule registry (src/rules/index.ts) and the README rule + * tables from the per-rule `meta` exports. + * + * Run after adding, removing, or renaming a rule. CI verifies this script + * has been run and committed (no diff after running). + */ +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { format } from 'prettier'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT = join(__dirname, '..'); +const RULES_DIR = join(ROOT, 'src', 'rules'); +const INDEX_PATH = join(RULES_DIR, 'index.ts'); +const README_PATH = join(ROOT, 'README.md'); + +interface DiscoveredRule { + file: string; + exportName: string; + metaName: string; + meta: { + name: string; + severity: 'error' | 'warning'; + platforms: string[] | null; + category: string; + description: string; + }; +} + +const SKIP = new Set(['index.ts', 'meta.ts']); +const fileNames = readdirSync(RULES_DIR) + .filter((f) => f.endsWith('.ts') && !SKIP.has(f)) + .sort(); + +const discovered: DiscoveredRule[] = []; +for (const file of fileNames) { + const src = readFileSync(join(RULES_DIR, file), 'utf8'); + const nameM = src.match(/const RULE_NAME = '([^']+)';/); + const exportM = src.match(/export function (\w+)\(/); + const metaM = src.match(/export const meta = (\{[\s\S]*?\n\});/m); + if (!nameM || !exportM || !metaM) { + throw new Error(`${file}: missing RULE_NAME, export function, or export const meta block`); + } + // Strip TS-only annotations so the literal is plain JS, then evaluate. + // Object literals support unquoted keys, single quotes, and trailing + // commas — all valid JS, so we wrap in `(...)` and Function-eval it. + const cleaned = metaM[1] + .replace(/\s+as\s+Platform\[\]\s*\|\s*null/g, '') + .replace(/\s+as\s+const\b/g, ''); + let parsed: DiscoveredRule['meta']; + try { + parsed = new Function(`return (${cleaned});`)(); + } catch (err) { + throw new Error(`${file}: failed to parse meta block: ${(err as Error).message}\n${cleaned}`); + } + discovered.push({ + file, + exportName: exportM[1], + metaName: exportM[1] + 'Meta', + meta: parsed, + }); +} + +discovered.sort((a, b) => a.meta.name.localeCompare(b.meta.name)); + +// Generate src/rules/index.ts +const imports = discovered + .map( + (d) => + `import { ${d.exportName}, meta as ${d.metaName} } from './${d.file.replace(/\.ts$/, '')}';`, + ) + .join('\n'); +const rulesObj = discovered.map((d) => ` '${d.meta.name}': ${d.exportName},`).join('\n'); +const metaObj = discovered.map((d) => ` '${d.meta.name}': ${d.metaName},`).join('\n'); +const indexTs = `// AUTO-GENERATED by scripts/sync.ts — do not edit manually. +// Run \`npm run sync\` after adding or removing a rule. +import type { RuleFunction, RuleMeta } from '../types'; +${imports} + +export const rules: Record = { +${rulesObj} +}; + +export const ruleMeta: Record = { +${metaObj} +}; +`; +writeFileSync( + INDEX_PATH, + await format(indexTs, { + parser: 'typescript', + singleQuote: true, + trailingComma: 'all', + printWidth: 100, + }), +); + +// Generate README rule section between markers +const DISPLAY_ORDER = [ + ['Expo Router', 'Expo Router Rules', true], + ['React Native / Expo', 'React Native / Expo Rules', true], + ['Liquid Glass', 'Liquid Glass Rules (expo-glass-effect)', true], + ['Next.js', 'Next.js Rules', true], + ['React / JSX', 'React / JSX Rules', true], + ['Screen Transitions', 'Screen Transitions Rules (react-native-screen-transitions)', true], + ['Tailwind CSS', 'Tailwind CSS Rules', true], + ['Backend / SQL', 'Backend / SQL Rules', true], + ['URL', 'URL Rules', false], + ['Error Handling', 'Error Handling Rules', false], + ['Code Style', 'Code Style Rules', true], + ['General', 'General Rules', true], +] as const; + +const grouped = new Map(); +for (const d of discovered) { + const arr = grouped.get(d.meta.category) ?? []; + arr.push(d); + grouped.set(d.meta.category, arr); +} +// Escape characters that have meaning inside a Markdown table cell. +function mdCell(s: string): string { + return s.replace(/\|/g, '\\|').replace(/\*/g, '\\*'); +} + +const sectionLines: string[] = []; +for (const [cat, title, withPlatform] of DISPLAY_ORDER) { + const items = grouped.get(cat); + if (!items?.length) continue; + sectionLines.push(`### ${title}`); + sectionLines.push(''); + if (withPlatform) { + sectionLines.push('| Rule | Severity | Platform | Description |'); + sectionLines.push('| --- | --- | --- | --- |'); + for (const d of items.sort((a, b) => a.meta.name.localeCompare(b.meta.name))) { + const plat = d.meta.platforms?.join(', ') ?? 'universal'; + sectionLines.push( + `| \`${d.meta.name}\` | ${d.meta.severity} | ${plat} | ${mdCell(d.meta.description)} |`, + ); + } + } else { + sectionLines.push('| Rule | Severity | Description |'); + sectionLines.push('| --- | --- | --- |'); + for (const d of items.sort((a, b) => a.meta.name.localeCompare(b.meta.name))) { + sectionLines.push( + `| \`${d.meta.name}\` | ${d.meta.severity} | ${mdCell(d.meta.description)} |`, + ); + } + } + sectionLines.push(''); +} + +const readme = readFileSync(README_PATH, 'utf8'); +const startMarker = + ''; +const endMarker = ''; +const startIdx = readme.indexOf(startMarker); +const endIdx = readme.indexOf(endMarker); +if (startIdx < 0 || endIdx < 0) { + throw new Error('README.md is missing AUTOGEN:RULES markers'); +} +// Update count in the heading just above the markers +const countRe = /## Available Rules \((\d+) total\)/; +const newCount = `## Available Rules (${discovered.length} total)`; +const updatedReadme = + readme.slice(0, startIdx + startMarker.length) + + '\n\n' + + sectionLines.join('\n').trimEnd() + + '\n\n' + + readme.slice(endIdx); +writeFileSync(README_PATH, updatedReadme.replace(countRe, newCount)); +writeFileSync( + README_PATH, + await format(readFileSync(README_PATH, 'utf8'), { + parser: 'markdown', + singleQuote: true, + trailingComma: 'all', + printWidth: 100, + }), +); + +console.log(`sync: ${discovered.length} rules — wrote src/rules/index.ts + README.md`); diff --git a/src/index.ts b/src/index.ts index 23afe74..03ca1fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,8 @@ import { parseJsx } from './parser'; -import { rules } from './rules'; -import { rulePlatforms } from './rules/meta'; -import type { LintConfig, LintResult, Platform } from './types'; +import { rules, ruleMeta } from './rules'; +import type { LintConfig, LintResult, Platform, RuleMeta } from './types'; -export type { LintConfig, LintResult, RuleFunction, Platform } from './types'; +export type { LintConfig, LintResult, RuleFunction, Platform, RuleMeta } from './types'; /** * Get all available rule names @@ -12,14 +11,21 @@ export function getAllRuleNames(): string[] { return Object.keys(rules); } +/** + * Get the metadata for a rule, or undefined if the rule does not exist. + */ +export function getRuleMeta(name: string): RuleMeta | undefined { + return ruleMeta[name]; +} + /** * Get rule names applicable to a platform. * Returns platform-tagged rules + universal rules (those without a platform tag). */ export function getRulesForPlatform(platform: Platform): string[] { return Object.keys(rules).filter((name) => { - const platforms = rulePlatforms[name]; - // No platforms = universal, always included + const platforms = ruleMeta[name]?.platforms; + // null/missing platforms = universal, always included if (!platforms) return true; return platforms.includes(platform); }); @@ -29,18 +35,14 @@ export function lintJsxCode(code: string, config: LintConfig): LintResult[] { const ast = parseJsx(code); const results: LintResult[] = []; - // Determine which rules to run let rulesToRun: string[]; if (config.platform) { - // Platform mode: run rules tagged for this platform + universal rules rulesToRun = getRulesForPlatform(config.platform); } else if (config.exclude) { - // Exclude mode: run all rules except those listed const excludeSet = new Set(config.rules ?? []); rulesToRun = Object.keys(rules).filter((name) => !excludeSet.has(name)); } else { - // Include mode (default): only run rules that are listed rulesToRun = config.rules ?? []; } diff --git a/src/rules/browser-api-in-useeffect.ts b/src/rules/browser-api-in-useeffect.ts index 079a827..d67c796 100644 --- a/src/rules/browser-api-in-useeffect.ts +++ b/src/rules/browser-api-in-useeffect.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'browser-api-in-useeffect'; +export const meta = { + name: 'browser-api-in-useeffect', + severity: 'warning' as const, + platforms: ['web'] as Platform[] | null, + category: 'React / JSX', + description: 'window/localStorage only in useEffect for SSR', +}; + const BROWSER_APIS = ['window', 'localStorage', 'sessionStorage', 'document']; export function browserApiInUseEffect(ast: File, _code: string): LintResult[] { diff --git a/src/rules/catch-must-log-to-sentry.ts b/src/rules/catch-must-log-to-sentry.ts index 1582018..f80e486 100644 --- a/src/rules/catch-must-log-to-sentry.ts +++ b/src/rules/catch-must-log-to-sentry.ts @@ -1,10 +1,18 @@ import traverse from '@babel/traverse'; import * as t from '@babel/types'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'catch-must-log-to-sentry'; +export const meta = { + name: 'catch-must-log-to-sentry', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Error Handling', + description: 'Catch blocks with logger.error/console.error must also call Sentry', +}; + /** * Recursively check if a node contains a call matching `object.method` pattern. */ diff --git a/src/rules/expo-font-loaded-check.ts b/src/rules/expo-font-loaded-check.ts index 5237675..540f014 100644 --- a/src/rules/expo-font-loaded-check.ts +++ b/src/rules/expo-font-loaded-check.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'expo-font-loaded-check'; +export const meta = { + name: 'expo-font-loaded-check', + severity: 'error' as const, + platforms: ['expo'] as Platform[] | null, + category: 'React Native / Expo', + description: 'useFonts() must check loaded before rendering', +}; + export function expoFontLoadedCheck(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/expo-image-import.ts b/src/rules/expo-image-import.ts index dea424d..9411671 100644 --- a/src/rules/expo-image-import.ts +++ b/src/rules/expo-image-import.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'expo-image-import'; +export const meta = { + name: 'expo-image-import', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'React Native / Expo', + description: 'Import Image from expo-image, not react-native', +}; + export function expoImageImport(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/fetch-response-ok-check.ts b/src/rules/fetch-response-ok-check.ts index d043f80..0c10154 100644 --- a/src/rules/fetch-response-ok-check.ts +++ b/src/rules/fetch-response-ok-check.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'fetch-response-ok-check'; +export const meta = { + name: 'fetch-response-ok-check', + severity: 'warning' as const, + platforms: ['web', 'backend'] as Platform[] | null, + category: 'React / JSX', + description: 'Check response.ok when using fetch', +}; + export function fetchResponseOkCheck(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/glass-interactive-prop.ts b/src/rules/glass-interactive-prop.ts index b75d96c..5f1b8db 100644 --- a/src/rules/glass-interactive-prop.ts +++ b/src/rules/glass-interactive-prop.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'glass-interactive-prop'; +export const meta = { + name: 'glass-interactive-prop', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'Liquid Glass', + description: 'GlassView in pressables needs `isInteractive={true}`', +}; + const PRESSABLE_COMPONENTS = [ 'TouchableOpacity', 'TouchableHighlight', diff --git a/src/rules/glass-needs-fallback.ts b/src/rules/glass-needs-fallback.ts index a3591d2..e1360a0 100644 --- a/src/rules/glass-needs-fallback.ts +++ b/src/rules/glass-needs-fallback.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'glass-needs-fallback'; +export const meta = { + name: 'glass-needs-fallback', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'Liquid Glass', + description: 'Check isLiquidGlassAvailable() before using GlassView', +}; + export function glassNeedsFallback(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/glass-no-opacity-animation.ts b/src/rules/glass-no-opacity-animation.ts index 0502d08..bb3965c 100644 --- a/src/rules/glass-no-opacity-animation.ts +++ b/src/rules/glass-no-opacity-animation.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'glass-no-opacity-animation'; +export const meta = { + name: 'glass-no-opacity-animation', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'Liquid Glass', + description: 'No opacity animations on GlassView', +}; + export function glassNoOpacityAnimation(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/header-shown-false.ts b/src/rules/header-shown-false.ts index d5adf89..676c294 100644 --- a/src/rules/header-shown-false.ts +++ b/src/rules/header-shown-false.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'header-shown-false'; +export const meta = { + name: 'header-shown-false', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'Expo Router', + description: '(tabs) Screen in root layout needs `headerShown: false`', +}; + export function headerShownFalse(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/index.ts b/src/rules/index.ts index e803798..2db279b 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -1,114 +1,222 @@ -import type { RuleFunction } from '../types'; -import { noRelativePaths } from './no-relative-paths'; -import { noRequireStatements } from './no-require-statements'; -import { noStylesheetCreate } from './no-stylesheet-create'; -import { noSafeAreaView } from './no-safeareaview'; -import { noClassComponents } from './no-class-components'; -import { noTabBarHeight } from './no-tab-bar-height'; -import { noBorderWidthOnGlass } from './no-border-width-on-glass'; -import { expoImageImport } from './expo-image-import'; -import { preferLucideIcons } from './prefer-lucide-icons'; -import { scrollviewHorizontalFlexgrow } from './scrollview-horizontal-flexgrow'; -import { noInlineScriptCode } from './no-inline-script-code'; -import { glassNeedsFallback } from './glass-needs-fallback'; -import { glassInteractiveProp } from './glass-interactive-prop'; -import { headerShownFalse } from './header-shown-false'; -import { expoFontLoadedCheck } from './expo-font-loaded-check'; -import { noReactQueryMissing } from './no-react-query-missing'; -import { browserApiInUseEffect } from './browser-api-in-useeffect'; -import { fetchResponseOkCheck } from './fetch-response-ok-check'; -import { tabsScreenOptionsHeaderShown } from './tabs-screen-options-header-shown'; -import { noResponseJsonLowercase } from './no-response-json-lowercase'; -import { nativeTabsBottomPadding } from './native-tabs-bottom-padding'; -import { noTailwindAnimationClasses } from './no-tailwind-animation-classes'; -import { sqlNoNestedCalls } from './sql-no-nested-calls'; -import { glassNoOpacityAnimation } from './glass-no-opacity-animation'; -import { noComplexJsxExpressions } from './no-complex-jsx-expressions'; -import { textInputKeyboardAvoiding } from './textinput-keyboard-avoiding'; -import { transitionWorkletDirective } from './transition-worklet-directive'; -import { transitionProgressRange } from './transition-progress-range'; -import { transitionGestureScrollview } from './transition-gesture-scrollview'; -import { transitionSharedTagMismatch } from './transition-shared-tag-mismatch'; -import { transitionPreferBlankStack } from './transition-prefer-blank-stack'; -import { preferGuardClauses } from './prefer-guard-clauses'; -import { noTypeAssertion } from './no-type-assertion'; -import { safeJsonParse } from './safe-json-parse'; -import { noLooseEquality } from './no-loose-equality'; -import { noMagicEnvStrings } from './no-magic-env-strings'; -import { urlParamsMustEncode } from './url-params-must-encode'; -import { catchMustLogToSentry } from './catch-must-log-to-sentry'; -import { noNestedTryCatch } from './no-nested-try-catch'; -import { noInlineStyles } from './no-inline-styles'; -import { noStringCoerceError } from './no-string-coerce-error'; -import { loggerErrorWithErr } from './logger-error-with-err'; -import { noOptionalProps } from './no-optional-props'; -import { noSilentSkip } from './no-silent-skip'; -import { noManualRetryLoop } from './no-manual-retry-loop'; -import { noEmojiIcons } from './no-emoji-icons'; -import { noSyncFs } from './no-sync-fs'; -import { preferNamedParams } from './prefer-named-params'; -import { requireUseClient } from './require-use-client'; -import { noServerImportInClient } from './no-server-import-in-client'; -import { ssrBrowserApiGuard } from './ssr-browser-api-guard'; -import { noReactNativeInWeb } from './no-react-native-in-web'; -import { noModuleLevelNew } from './no-module-level-new'; -import { noUnrestrictedLoopInServerless } from './no-unrestricted-loop-in-serverless'; -import { preferPromiseAll } from './prefer-promise-all'; +// AUTO-GENERATED by scripts/sync.ts — do not edit manually. +// Run `npm run sync` after adding or removing a rule. +import type { RuleFunction, RuleMeta } from '../types'; +import { + browserApiInUseEffect, + meta as browserApiInUseEffectMeta, +} from './browser-api-in-useeffect'; +import { catchMustLogToSentry, meta as catchMustLogToSentryMeta } from './catch-must-log-to-sentry'; +import { expoFontLoadedCheck, meta as expoFontLoadedCheckMeta } from './expo-font-loaded-check'; +import { expoImageImport, meta as expoImageImportMeta } from './expo-image-import'; +import { fetchResponseOkCheck, meta as fetchResponseOkCheckMeta } from './fetch-response-ok-check'; +import { glassInteractiveProp, meta as glassInteractivePropMeta } from './glass-interactive-prop'; +import { glassNeedsFallback, meta as glassNeedsFallbackMeta } from './glass-needs-fallback'; +import { + glassNoOpacityAnimation, + meta as glassNoOpacityAnimationMeta, +} from './glass-no-opacity-animation'; +import { headerShownFalse, meta as headerShownFalseMeta } from './header-shown-false'; +import { loggerErrorWithErr, meta as loggerErrorWithErrMeta } from './logger-error-with-err'; +import { + nativeTabsBottomPadding, + meta as nativeTabsBottomPaddingMeta, +} from './native-tabs-bottom-padding'; +import { noBorderWidthOnGlass, meta as noBorderWidthOnGlassMeta } from './no-border-width-on-glass'; +import { noClassComponents, meta as noClassComponentsMeta } from './no-class-components'; +import { + noComplexJsxExpressions, + meta as noComplexJsxExpressionsMeta, +} from './no-complex-jsx-expressions'; +import { noEmojiIcons, meta as noEmojiIconsMeta } from './no-emoji-icons'; +import { noInlineScriptCode, meta as noInlineScriptCodeMeta } from './no-inline-script-code'; +import { noInlineStyles, meta as noInlineStylesMeta } from './no-inline-styles'; +import { noLooseEquality, meta as noLooseEqualityMeta } from './no-loose-equality'; +import { noMagicEnvStrings, meta as noMagicEnvStringsMeta } from './no-magic-env-strings'; +import { noManualRetryLoop, meta as noManualRetryLoopMeta } from './no-manual-retry-loop'; +import { noModuleLevelNew, meta as noModuleLevelNewMeta } from './no-module-level-new'; +import { noNestedTryCatch, meta as noNestedTryCatchMeta } from './no-nested-try-catch'; +import { noOptionalProps, meta as noOptionalPropsMeta } from './no-optional-props'; +import { noReactNativeInWeb, meta as noReactNativeInWebMeta } from './no-react-native-in-web'; +import { noReactQueryMissing, meta as noReactQueryMissingMeta } from './no-react-query-missing'; +import { noRelativePaths, meta as noRelativePathsMeta } from './no-relative-paths'; +import { noRequireStatements, meta as noRequireStatementsMeta } from './no-require-statements'; +import { + noResponseJsonLowercase, + meta as noResponseJsonLowercaseMeta, +} from './no-response-json-lowercase'; +import { noSafeAreaView, meta as noSafeAreaViewMeta } from './no-safeareaview'; +import { + noServerImportInClient, + meta as noServerImportInClientMeta, +} from './no-server-import-in-client'; +import { noSilentSkip, meta as noSilentSkipMeta } from './no-silent-skip'; +import { noStringCoerceError, meta as noStringCoerceErrorMeta } from './no-string-coerce-error'; +import { noStylesheetCreate, meta as noStylesheetCreateMeta } from './no-stylesheet-create'; +import { noSyncFs, meta as noSyncFsMeta } from './no-sync-fs'; +import { noTabBarHeight, meta as noTabBarHeightMeta } from './no-tab-bar-height'; +import { + noTailwindAnimationClasses, + meta as noTailwindAnimationClassesMeta, +} from './no-tailwind-animation-classes'; +import { noTypeAssertion, meta as noTypeAssertionMeta } from './no-type-assertion'; +import { + noUnrestrictedLoopInServerless, + meta as noUnrestrictedLoopInServerlessMeta, +} from './no-unrestricted-loop-in-serverless'; +import { preferGuardClauses, meta as preferGuardClausesMeta } from './prefer-guard-clauses'; +import { preferLucideIcons, meta as preferLucideIconsMeta } from './prefer-lucide-icons'; +import { preferNamedParams, meta as preferNamedParamsMeta } from './prefer-named-params'; +import { preferPromiseAll, meta as preferPromiseAllMeta } from './prefer-promise-all'; +import { requireUseClient, meta as requireUseClientMeta } from './require-use-client'; +import { safeJsonParse, meta as safeJsonParseMeta } from './safe-json-parse'; +import { + scrollviewHorizontalFlexgrow, + meta as scrollviewHorizontalFlexgrowMeta, +} from './scrollview-horizontal-flexgrow'; +import { sqlNoNestedCalls, meta as sqlNoNestedCallsMeta } from './sql-no-nested-calls'; +import { ssrBrowserApiGuard, meta as ssrBrowserApiGuardMeta } from './ssr-browser-api-guard'; +import { + tabsScreenOptionsHeaderShown, + meta as tabsScreenOptionsHeaderShownMeta, +} from './tabs-screen-options-header-shown'; +import { + textInputKeyboardAvoiding, + meta as textInputKeyboardAvoidingMeta, +} from './textinput-keyboard-avoiding'; +import { + transitionGestureScrollview, + meta as transitionGestureScrollviewMeta, +} from './transition-gesture-scrollview'; +import { + transitionPreferBlankStack, + meta as transitionPreferBlankStackMeta, +} from './transition-prefer-blank-stack'; +import { + transitionProgressRange, + meta as transitionProgressRangeMeta, +} from './transition-progress-range'; +import { + transitionSharedTagMismatch, + meta as transitionSharedTagMismatchMeta, +} from './transition-shared-tag-mismatch'; +import { + transitionWorkletDirective, + meta as transitionWorkletDirectiveMeta, +} from './transition-worklet-directive'; +import { urlParamsMustEncode, meta as urlParamsMustEncodeMeta } from './url-params-must-encode'; export const rules: Record = { - 'no-relative-paths': noRelativePaths, - 'no-require-statements': noRequireStatements, - 'no-stylesheet-create': noStylesheetCreate, - 'no-safeareaview': noSafeAreaView, - 'no-class-components': noClassComponents, - 'no-tab-bar-height': noTabBarHeight, - 'no-border-width-on-glass': noBorderWidthOnGlass, + 'browser-api-in-useeffect': browserApiInUseEffect, + 'catch-must-log-to-sentry': catchMustLogToSentry, + 'expo-font-loaded-check': expoFontLoadedCheck, 'expo-image-import': expoImageImport, - 'prefer-lucide-icons': preferLucideIcons, - 'scrollview-horizontal-flexgrow': scrollviewHorizontalFlexgrow, - 'no-inline-script-code': noInlineScriptCode, - 'glass-needs-fallback': glassNeedsFallback, + 'fetch-response-ok-check': fetchResponseOkCheck, 'glass-interactive-prop': glassInteractiveProp, + 'glass-needs-fallback': glassNeedsFallback, + 'glass-no-opacity-animation': glassNoOpacityAnimation, 'header-shown-false': headerShownFalse, - 'expo-font-loaded-check': expoFontLoadedCheck, - 'no-react-query-missing': noReactQueryMissing, - 'browser-api-in-useeffect': browserApiInUseEffect, - 'fetch-response-ok-check': fetchResponseOkCheck, - 'tabs-screen-options-header-shown': tabsScreenOptionsHeaderShown, - 'no-response-json-lowercase': noResponseJsonLowercase, + 'logger-error-with-err': loggerErrorWithErr, 'native-tabs-bottom-padding': nativeTabsBottomPadding, - 'no-tailwind-animation-classes': noTailwindAnimationClasses, - 'sql-no-nested-calls': sqlNoNestedCalls, - 'glass-no-opacity-animation': glassNoOpacityAnimation, + 'no-border-width-on-glass': noBorderWidthOnGlass, + 'no-class-components': noClassComponents, 'no-complex-jsx-expressions': noComplexJsxExpressions, - 'textinput-keyboard-avoiding': textInputKeyboardAvoiding, - 'transition-worklet-directive': transitionWorkletDirective, - 'transition-progress-range': transitionProgressRange, - 'transition-gesture-scrollview': transitionGestureScrollview, - 'transition-shared-tag-mismatch': transitionSharedTagMismatch, - 'transition-prefer-blank-stack': transitionPreferBlankStack, - 'prefer-guard-clauses': preferGuardClauses, - 'no-type-assertion': noTypeAssertion, - 'safe-json-parse': safeJsonParse, + 'no-emoji-icons': noEmojiIcons, + 'no-inline-script-code': noInlineScriptCode, + 'no-inline-styles': noInlineStyles, 'no-loose-equality': noLooseEquality, 'no-magic-env-strings': noMagicEnvStrings, - 'url-params-must-encode': urlParamsMustEncode, - 'catch-must-log-to-sentry': catchMustLogToSentry, + 'no-manual-retry-loop': noManualRetryLoop, + 'no-module-level-new': noModuleLevelNew, 'no-nested-try-catch': noNestedTryCatch, - 'no-inline-styles': noInlineStyles, - 'no-string-coerce-error': noStringCoerceError, - 'logger-error-with-err': loggerErrorWithErr, 'no-optional-props': noOptionalProps, + 'no-react-native-in-web': noReactNativeInWeb, + 'no-react-query-missing': noReactQueryMissing, + 'no-relative-paths': noRelativePaths, + 'no-require-statements': noRequireStatements, + 'no-response-json-lowercase': noResponseJsonLowercase, + 'no-safeareaview': noSafeAreaView, + 'no-server-import-in-client': noServerImportInClient, 'no-silent-skip': noSilentSkip, - 'no-manual-retry-loop': noManualRetryLoop, - 'no-emoji-icons': noEmojiIcons, + 'no-string-coerce-error': noStringCoerceError, + 'no-stylesheet-create': noStylesheetCreate, 'no-sync-fs': noSyncFs, + 'no-tab-bar-height': noTabBarHeight, + 'no-tailwind-animation-classes': noTailwindAnimationClasses, + 'no-type-assertion': noTypeAssertion, + 'no-unrestricted-loop-in-serverless': noUnrestrictedLoopInServerless, + 'prefer-guard-clauses': preferGuardClauses, + 'prefer-lucide-icons': preferLucideIcons, 'prefer-named-params': preferNamedParams, + 'prefer-promise-all': preferPromiseAll, 'require-use-client': requireUseClient, - 'no-server-import-in-client': noServerImportInClient, + 'safe-json-parse': safeJsonParse, + 'scrollview-horizontal-flexgrow': scrollviewHorizontalFlexgrow, + 'sql-no-nested-calls': sqlNoNestedCalls, 'ssr-browser-api-guard': ssrBrowserApiGuard, - 'no-react-native-in-web': noReactNativeInWeb, - 'no-module-level-new': noModuleLevelNew, - 'no-unrestricted-loop-in-serverless': noUnrestrictedLoopInServerless, - 'prefer-promise-all': preferPromiseAll, + 'tabs-screen-options-header-shown': tabsScreenOptionsHeaderShown, + 'textinput-keyboard-avoiding': textInputKeyboardAvoiding, + 'transition-gesture-scrollview': transitionGestureScrollview, + 'transition-prefer-blank-stack': transitionPreferBlankStack, + 'transition-progress-range': transitionProgressRange, + 'transition-shared-tag-mismatch': transitionSharedTagMismatch, + 'transition-worklet-directive': transitionWorkletDirective, + 'url-params-must-encode': urlParamsMustEncode, +}; + +export const ruleMeta: Record = { + 'browser-api-in-useeffect': browserApiInUseEffectMeta, + 'catch-must-log-to-sentry': catchMustLogToSentryMeta, + 'expo-font-loaded-check': expoFontLoadedCheckMeta, + 'expo-image-import': expoImageImportMeta, + 'fetch-response-ok-check': fetchResponseOkCheckMeta, + 'glass-interactive-prop': glassInteractivePropMeta, + 'glass-needs-fallback': glassNeedsFallbackMeta, + 'glass-no-opacity-animation': glassNoOpacityAnimationMeta, + 'header-shown-false': headerShownFalseMeta, + 'logger-error-with-err': loggerErrorWithErrMeta, + 'native-tabs-bottom-padding': nativeTabsBottomPaddingMeta, + 'no-border-width-on-glass': noBorderWidthOnGlassMeta, + 'no-class-components': noClassComponentsMeta, + 'no-complex-jsx-expressions': noComplexJsxExpressionsMeta, + 'no-emoji-icons': noEmojiIconsMeta, + 'no-inline-script-code': noInlineScriptCodeMeta, + 'no-inline-styles': noInlineStylesMeta, + 'no-loose-equality': noLooseEqualityMeta, + 'no-magic-env-strings': noMagicEnvStringsMeta, + 'no-manual-retry-loop': noManualRetryLoopMeta, + 'no-module-level-new': noModuleLevelNewMeta, + 'no-nested-try-catch': noNestedTryCatchMeta, + 'no-optional-props': noOptionalPropsMeta, + 'no-react-native-in-web': noReactNativeInWebMeta, + 'no-react-query-missing': noReactQueryMissingMeta, + 'no-relative-paths': noRelativePathsMeta, + 'no-require-statements': noRequireStatementsMeta, + 'no-response-json-lowercase': noResponseJsonLowercaseMeta, + 'no-safeareaview': noSafeAreaViewMeta, + 'no-server-import-in-client': noServerImportInClientMeta, + 'no-silent-skip': noSilentSkipMeta, + 'no-string-coerce-error': noStringCoerceErrorMeta, + 'no-stylesheet-create': noStylesheetCreateMeta, + 'no-sync-fs': noSyncFsMeta, + 'no-tab-bar-height': noTabBarHeightMeta, + 'no-tailwind-animation-classes': noTailwindAnimationClassesMeta, + 'no-type-assertion': noTypeAssertionMeta, + 'no-unrestricted-loop-in-serverless': noUnrestrictedLoopInServerlessMeta, + 'prefer-guard-clauses': preferGuardClausesMeta, + 'prefer-lucide-icons': preferLucideIconsMeta, + 'prefer-named-params': preferNamedParamsMeta, + 'prefer-promise-all': preferPromiseAllMeta, + 'require-use-client': requireUseClientMeta, + 'safe-json-parse': safeJsonParseMeta, + 'scrollview-horizontal-flexgrow': scrollviewHorizontalFlexgrowMeta, + 'sql-no-nested-calls': sqlNoNestedCallsMeta, + 'ssr-browser-api-guard': ssrBrowserApiGuardMeta, + 'tabs-screen-options-header-shown': tabsScreenOptionsHeaderShownMeta, + 'textinput-keyboard-avoiding': textInputKeyboardAvoidingMeta, + 'transition-gesture-scrollview': transitionGestureScrollviewMeta, + 'transition-prefer-blank-stack': transitionPreferBlankStackMeta, + 'transition-progress-range': transitionProgressRangeMeta, + 'transition-shared-tag-mismatch': transitionSharedTagMismatchMeta, + 'transition-worklet-directive': transitionWorkletDirectiveMeta, + 'url-params-must-encode': urlParamsMustEncodeMeta, }; diff --git a/src/rules/logger-error-with-err.ts b/src/rules/logger-error-with-err.ts index 0e8387e..91e0d84 100644 --- a/src/rules/logger-error-with-err.ts +++ b/src/rules/logger-error-with-err.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'logger-error-with-err'; +export const meta = { + name: 'logger-error-with-err', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'logger.error() must include { err: Error } for stack traces', +}; + export function loggerErrorWithErr(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/meta.ts b/src/rules/meta.ts deleted file mode 100644 index f17b80f..0000000 --- a/src/rules/meta.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Platform } from '../types'; - -/** - * Platform tags for rules. Only platform-specific rules are listed here. - * Any rule NOT in this map is universal (included on all platforms). - */ -export const rulePlatforms: Partial> = { - // Expo / React Native - 'no-stylesheet-create': ['expo'], - 'no-safeareaview': ['expo'], - 'no-tab-bar-height': ['expo'], - 'no-border-width-on-glass': ['expo'], - 'expo-image-import': ['expo'], - 'scrollview-horizontal-flexgrow': ['expo'], - 'glass-needs-fallback': ['expo'], - 'glass-interactive-prop': ['expo'], - 'header-shown-false': ['expo'], - 'expo-font-loaded-check': ['expo'], - 'tabs-screen-options-header-shown': ['expo'], - 'native-tabs-bottom-padding': ['expo'], - 'glass-no-opacity-animation': ['expo'], - 'textinput-keyboard-avoiding': ['expo'], - 'transition-worklet-directive': ['expo'], - 'transition-progress-range': ['expo'], - 'transition-gesture-scrollview': ['expo'], - 'transition-shared-tag-mismatch': ['expo'], - 'transition-prefer-blank-stack': ['expo'], - - // Web - 'no-inline-script-code': ['web'], - 'browser-api-in-useeffect': ['web'], - 'no-tailwind-animation-classes': ['web'], - 'require-use-client': ['web'], - 'no-server-import-in-client': ['web'], - 'ssr-browser-api-guard': ['web'], - 'no-react-native-in-web': ['web'], - 'no-module-level-new': ['web'], - - // Expo + Web (shared frontend) - 'no-relative-paths': ['expo', 'web'], - 'no-class-components': ['expo', 'web'], - 'no-react-query-missing': ['expo', 'web'], - 'no-complex-jsx-expressions': ['expo', 'web'], - 'prefer-lucide-icons': ['expo', 'web'], - - // Web + Backend - 'fetch-response-ok-check': ['web', 'backend'], - - // Backend only - 'no-require-statements': ['backend'], - 'no-response-json-lowercase': ['backend'], - 'sql-no-nested-calls': ['backend'], - 'no-sync-fs': ['backend'], - 'no-unrestricted-loop-in-serverless': ['backend'], - - // Universal rules (NOT listed here): prefer-guard-clauses, no-type-assertion, - // no-string-coerce-error -}; diff --git a/src/rules/native-tabs-bottom-padding.ts b/src/rules/native-tabs-bottom-padding.ts index 3943ca3..fc4d7d4 100644 --- a/src/rules/native-tabs-bottom-padding.ts +++ b/src/rules/native-tabs-bottom-padding.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'native-tabs-bottom-padding'; +export const meta = { + name: 'native-tabs-bottom-padding', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'React Native / Expo', + description: 'NativeTabs screens need 64px bottom padding', +}; + export function nativeTabsBottomPadding(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-border-width-on-glass.ts b/src/rules/no-border-width-on-glass.ts index f4f6d61..6af7459 100644 --- a/src/rules/no-border-width-on-glass.ts +++ b/src/rules/no-border-width-on-glass.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-border-width-on-glass'; +export const meta = { + name: 'no-border-width-on-glass', + severity: 'error' as const, + platforms: ['expo'] as Platform[] | null, + category: 'Liquid Glass', + description: 'No borderWidth on GlassView (breaks borderRadius)', +}; + export function noBorderWidthOnGlass(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-class-components.ts b/src/rules/no-class-components.ts index b8dc6fc..e89fa32 100644 --- a/src/rules/no-class-components.ts +++ b/src/rules/no-class-components.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-class-components'; +export const meta = { + name: 'no-class-components', + severity: 'warning' as const, + platforms: ['expo', 'web'] as Platform[] | null, + category: 'React / JSX', + description: 'Use function components with hooks', +}; + export function noClassComponents(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-complex-jsx-expressions.ts b/src/rules/no-complex-jsx-expressions.ts index 36ce20a..db314a0 100644 --- a/src/rules/no-complex-jsx-expressions.ts +++ b/src/rules/no-complex-jsx-expressions.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-complex-jsx-expressions'; +export const meta = { + name: 'no-complex-jsx-expressions', + severity: 'warning' as const, + platforms: ['expo', 'web'] as Platform[] | null, + category: 'React / JSX', + description: 'Avoid IIFEs and complex expressions in JSX', +}; + export function noComplexJsxExpressions(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-emoji-icons.ts b/src/rules/no-emoji-icons.ts index 440f493..e50db96 100644 --- a/src/rules/no-emoji-icons.ts +++ b/src/rules/no-emoji-icons.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-emoji-icons'; +export const meta = { + name: 'no-emoji-icons', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'Use icons from lucide-react instead of emoji characters', +}; + // Regex covering common emoji Unicode ranges const EMOJI_REGEX = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{200D}\u{20E3}\u{E0020}-\u{E007F}\u{2300}-\u{23FF}\u{2B50}\u{2B55}\u{231A}\u{231B}\u{23E9}-\u{23F3}\u{23F8}-\u{23FA}\u{25AA}\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2934}\u{2935}\u{2B05}-\u{2B07}\u{3030}\u{303D}\u{3297}\u{3299}\u{2139}\u{2194}-\u{2199}\u{21A9}\u{21AA}\u{23CF}\u{24C2}\u{25AA}\u{25AB}\u{2122}\u{2328}\u{23ED}-\u{23EF}]/u; diff --git a/src/rules/no-inline-script-code.ts b/src/rules/no-inline-script-code.ts index 5ec2244..6361c51 100644 --- a/src/rules/no-inline-script-code.ts +++ b/src/rules/no-inline-script-code.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-inline-script-code'; +export const meta = { + name: 'no-inline-script-code', + severity: 'error' as const, + platforms: ['web'] as Platform[] | null, + category: 'React / JSX', + description: 'Script tags should use template literals', +}; + export function noInlineScriptCode(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-inline-styles.ts b/src/rules/no-inline-styles.ts index 0e1e2b8..6c200a4 100644 --- a/src/rules/no-inline-styles.ts +++ b/src/rules/no-inline-styles.ts @@ -1,10 +1,18 @@ import traverse from '@babel/traverse'; import * as t from '@babel/types'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-inline-styles'; +export const meta = { + name: 'no-inline-styles', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Tailwind CSS', + description: 'Avoid inline styles, use Tailwind CSS classes instead', +}; + export function noInlineStyles(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-loose-equality.ts b/src/rules/no-loose-equality.ts index d189c8f..4cf2912 100644 --- a/src/rules/no-loose-equality.ts +++ b/src/rules/no-loose-equality.ts @@ -1,10 +1,18 @@ import traverse from '@babel/traverse'; import * as t from '@babel/types'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-loose-equality'; +export const meta = { + name: 'no-loose-equality', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'Use === and !== instead of == and != (except == null)', +}; + export function noLooseEquality(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-magic-env-strings.ts b/src/rules/no-magic-env-strings.ts index d16c345..50073e6 100644 --- a/src/rules/no-magic-env-strings.ts +++ b/src/rules/no-magic-env-strings.ts @@ -1,10 +1,18 @@ import traverse from '@babel/traverse'; import * as t from '@babel/types'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-magic-env-strings'; +export const meta = { + name: 'no-magic-env-strings', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'Use centralized enum for env variable names, not magic strings', +}; + export function noMagicEnvStrings(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-manual-retry-loop.ts b/src/rules/no-manual-retry-loop.ts index 1f9985f..29197ca 100644 --- a/src/rules/no-manual-retry-loop.ts +++ b/src/rules/no-manual-retry-loop.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-manual-retry-loop'; +export const meta = { + name: 'no-manual-retry-loop', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'Use a retry library instead of manual retry/polling loops', +}; + export function noManualRetryLoop(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-module-level-new.ts b/src/rules/no-module-level-new.ts index 43f6565..88a6924 100644 --- a/src/rules/no-module-level-new.ts +++ b/src/rules/no-module-level-new.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-module-level-new'; +export const meta = { + name: 'no-module-level-new', + severity: 'error' as const, + platforms: ['web'] as Platform[] | null, + category: 'Next.js', + description: 'Avoid module-level constructors that execute during SSR', +}; + const SAFE_CONSTRUCTORS = new Set([ 'Error', 'TypeError', diff --git a/src/rules/no-nested-try-catch.ts b/src/rules/no-nested-try-catch.ts index ae2f4ea..03cb616 100644 --- a/src/rules/no-nested-try-catch.ts +++ b/src/rules/no-nested-try-catch.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-nested-try-catch'; +export const meta = { + name: 'no-nested-try-catch', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'Avoid nested try-catch blocks, extract to separate functions', +}; + export function noNestedTryCatch(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-optional-props.ts b/src/rules/no-optional-props.ts index 3a02c43..24fe0b9 100644 --- a/src/rules/no-optional-props.ts +++ b/src/rules/no-optional-props.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-optional-props'; +export const meta = { + name: 'no-optional-props', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'Use `prop: T \| null` instead of `prop?: T` in interfaces', +}; + export function noOptionalProps(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-react-native-in-web.ts b/src/rules/no-react-native-in-web.ts index 2ebb70d..68614dd 100644 --- a/src/rules/no-react-native-in-web.ts +++ b/src/rules/no-react-native-in-web.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-react-native-in-web'; +export const meta = { + name: 'no-react-native-in-web', + severity: 'error' as const, + platforms: ['web'] as Platform[] | null, + category: 'Next.js', + description: 'Do not import React Native modules from web code', +}; + const REACT_NATIVE_MODULES = ['react-native', 'react-native-web']; export function noReactNativeInWeb(ast: File, _code: string): LintResult[] { diff --git a/src/rules/no-react-query-missing.ts b/src/rules/no-react-query-missing.ts index edaca9f..1216cbb 100644 --- a/src/rules/no-react-query-missing.ts +++ b/src/rules/no-react-query-missing.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-react-query-missing'; +export const meta = { + name: 'no-react-query-missing', + severity: 'warning' as const, + platforms: ['expo', 'web'] as Platform[] | null, + category: 'React / JSX', + description: 'Use @tanstack/react-query for data fetching', +}; + export function noReactQueryMissing(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-relative-paths.ts b/src/rules/no-relative-paths.ts index f79447a..217c66e 100644 --- a/src/rules/no-relative-paths.ts +++ b/src/rules/no-relative-paths.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-relative-paths'; +export const meta = { + name: 'no-relative-paths', + severity: 'error' as const, + platforms: ['expo', 'web'] as Platform[] | null, + category: 'Expo Router', + description: 'Use absolute paths in router.navigate/push and Link href', +}; + function isRelativePath(path: string): boolean { return path.startsWith('./') || path.startsWith('../'); } diff --git a/src/rules/no-require-statements.ts b/src/rules/no-require-statements.ts index f381e6c..c0ea023 100644 --- a/src/rules/no-require-statements.ts +++ b/src/rules/no-require-statements.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-require-statements'; +export const meta = { + name: 'no-require-statements', + severity: 'error' as const, + platforms: ['backend'] as Platform[] | null, + category: 'Backend / SQL', + description: 'Use ES imports, not CommonJS require', +}; + export function noRequireStatements(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-response-json-lowercase.ts b/src/rules/no-response-json-lowercase.ts index 751f266..6ecaee3 100644 --- a/src/rules/no-response-json-lowercase.ts +++ b/src/rules/no-response-json-lowercase.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-response-json-lowercase'; +export const meta = { + name: 'no-response-json-lowercase', + severity: 'warning' as const, + platforms: ['backend'] as Platform[] | null, + category: 'Backend / SQL', + description: 'Use Response.json() instead of new Response(JSON.stringify())', +}; + export function noResponseJsonLowercase(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-safeareaview.ts b/src/rules/no-safeareaview.ts index b7b49b9..97dc5d2 100644 --- a/src/rules/no-safeareaview.ts +++ b/src/rules/no-safeareaview.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-safeareaview'; +export const meta = { + name: 'no-safeareaview', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'React Native / Expo', + description: 'Use useSafeAreaInsets() hook instead of SafeAreaView', +}; + export function noSafeAreaView(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-server-import-in-client.ts b/src/rules/no-server-import-in-client.ts index bc438d9..5d5da36 100644 --- a/src/rules/no-server-import-in-client.ts +++ b/src/rules/no-server-import-in-client.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-server-import-in-client'; +export const meta = { + name: 'no-server-import-in-client', + severity: 'error' as const, + platforms: ['web'] as Platform[] | null, + category: 'Next.js', + description: '"use client" files must not import server-only modules', +}; + const SERVER_ONLY_MODULES = ['server-only', 'next/headers']; function isServerOnlyModule(source: string): boolean { diff --git a/src/rules/no-silent-skip.ts b/src/rules/no-silent-skip.ts index ad66fbf..84b25ac 100644 --- a/src/rules/no-silent-skip.ts +++ b/src/rules/no-silent-skip.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File, IfStatement, Statement } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-silent-skip'; +export const meta = { + name: 'no-silent-skip', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'Add else branch with logging instead of silently skipping', +}; + const EARLY_EXIT_TYPES = new Set([ 'ReturnStatement', 'ThrowStatement', diff --git a/src/rules/no-string-coerce-error.ts b/src/rules/no-string-coerce-error.ts index cc14a6b..179bcb1 100644 --- a/src/rules/no-string-coerce-error.ts +++ b/src/rules/no-string-coerce-error.ts @@ -1,10 +1,18 @@ import traverse from '@babel/traverse'; import * as t from '@babel/types'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-string-coerce-error'; +export const meta = { + name: 'no-string-coerce-error', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'Use JSON.stringify instead of String() for unknown caught errors', +}; + /** * Check if an expression contains a `String(name)` call where `name` matches the given identifier. */ diff --git a/src/rules/no-stylesheet-create.ts b/src/rules/no-stylesheet-create.ts index d629d0e..547d1e0 100644 --- a/src/rules/no-stylesheet-create.ts +++ b/src/rules/no-stylesheet-create.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-stylesheet-create'; +export const meta = { + name: 'no-stylesheet-create', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'React Native / Expo', + description: 'Use inline styles instead of StyleSheet.create()', +}; + export function noStylesheetCreate(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-sync-fs.ts b/src/rules/no-sync-fs.ts index 1323eef..88dd67a 100644 --- a/src/rules/no-sync-fs.ts +++ b/src/rules/no-sync-fs.ts @@ -1,10 +1,18 @@ import traverse from '@babel/traverse'; import * as t from '@babel/types'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-sync-fs'; +export const meta = { + name: 'no-sync-fs', + severity: 'error' as const, + platforms: ['backend'] as Platform[] | null, + category: 'Backend / SQL', + description: 'Use fs.promises or fs/promises instead of sync fs methods', +}; + function isSyncMethod(name: string): boolean { return name.endsWith('Sync'); } diff --git a/src/rules/no-tab-bar-height.ts b/src/rules/no-tab-bar-height.ts index 9108486..323be64 100644 --- a/src/rules/no-tab-bar-height.ts +++ b/src/rules/no-tab-bar-height.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File, ObjectMethod, ObjectProperty, SpreadElement } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-tab-bar-height'; +export const meta = { + name: 'no-tab-bar-height', + severity: 'error' as const, + platforms: ['expo'] as Platform[] | null, + category: 'React Native / Expo', + description: 'Never set explicit height in tabBarStyle', +}; + export function noTabBarHeight(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-tailwind-animation-classes.ts b/src/rules/no-tailwind-animation-classes.ts index 6e3e734..bdf937d 100644 --- a/src/rules/no-tailwind-animation-classes.ts +++ b/src/rules/no-tailwind-animation-classes.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-tailwind-animation-classes'; +export const meta = { + name: 'no-tailwind-animation-classes', + severity: 'warning' as const, + platforms: ['web'] as Platform[] | null, + category: 'Tailwind CSS', + description: 'Avoid animate-\* classes, use style jsx global instead', +}; + // Tailwind animation class patterns const ANIMATION_CLASS_PATTERN = /\banimate-\w+/; diff --git a/src/rules/no-type-assertion.ts b/src/rules/no-type-assertion.ts index c0ef808..bbf3d96 100644 --- a/src/rules/no-type-assertion.ts +++ b/src/rules/no-type-assertion.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-type-assertion'; +export const meta = { + name: 'no-type-assertion', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'Avoid `as` type casts; use type narrowing or proper types', +}; + export function noTypeAssertion(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/no-unrestricted-loop-in-serverless.ts b/src/rules/no-unrestricted-loop-in-serverless.ts index 91fe476..a738e42 100644 --- a/src/rules/no-unrestricted-loop-in-serverless.ts +++ b/src/rules/no-unrestricted-loop-in-serverless.ts @@ -1,10 +1,18 @@ import traverse from '@babel/traverse'; import * as t from '@babel/types'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'no-unrestricted-loop-in-serverless'; +export const meta = { + name: 'no-unrestricted-loop-in-serverless', + severity: 'error' as const, + platforms: ['backend'] as Platform[] | null, + category: 'Backend / SQL', + description: 'Avoid unbounded loops that can time out serverless functions', +}; + export function noUnrestrictedLoopInServerless(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/prefer-guard-clauses.ts b/src/rules/prefer-guard-clauses.ts index 63cc1e2..e9cc3b8 100644 --- a/src/rules/prefer-guard-clauses.ts +++ b/src/rules/prefer-guard-clauses.ts @@ -7,10 +7,18 @@ import type { IfStatement, Statement, } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'prefer-guard-clauses'; +export const meta = { + name: 'prefer-guard-clauses', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'Use early returns instead of nesting if statements', +}; + type FunctionNode = ArrowFunctionExpression | FunctionDeclaration | FunctionExpression; function bodyStatements(node: FunctionNode): Statement[] | null { diff --git a/src/rules/prefer-lucide-icons.ts b/src/rules/prefer-lucide-icons.ts index dd4af39..0356963 100644 --- a/src/rules/prefer-lucide-icons.ts +++ b/src/rules/prefer-lucide-icons.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'prefer-lucide-icons'; +export const meta = { + name: 'prefer-lucide-icons', + severity: 'warning' as const, + platforms: ['expo', 'web'] as Platform[] | null, + category: 'General', + description: 'Prefer lucide-react/lucide-react-native icons', +}; + // Common icon libraries that should be replaced with lucide const DISCOURAGED_ICON_PACKAGES = [ '@expo/vector-icons', diff --git a/src/rules/prefer-named-params.ts b/src/rules/prefer-named-params.ts index b365445..6df69e6 100644 --- a/src/rules/prefer-named-params.ts +++ b/src/rules/prefer-named-params.ts @@ -7,10 +7,18 @@ import type { FunctionDeclaration, FunctionExpression, } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'prefer-named-params'; +export const meta = { + name: 'prefer-named-params', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'Use object destructuring instead of positional parameters', +}; + type FunctionNode = ArrowFunctionExpression | FunctionDeclaration | FunctionExpression; function checkFunction(path: NodePath, results: LintResult[]): void { diff --git a/src/rules/prefer-promise-all.ts b/src/rules/prefer-promise-all.ts index 4a9f6cf..68ee8f9 100644 --- a/src/rules/prefer-promise-all.ts +++ b/src/rules/prefer-promise-all.ts @@ -15,6 +15,14 @@ import type { LintResult } from '../types'; const RULE_NAME = 'prefer-promise-all'; +export const meta = { + name: 'prefer-promise-all', + severity: 'warning' as const, + platforms: null, + category: 'Code Style', + description: 'Prefer Promise.all for independent async work in loops', +}; + export function preferPromiseAll(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/require-use-client.ts b/src/rules/require-use-client.ts index 338188c..405dac5 100644 --- a/src/rules/require-use-client.ts +++ b/src/rules/require-use-client.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'require-use-client'; +export const meta = { + name: 'require-use-client', + severity: 'error' as const, + platforms: ['web'] as Platform[] | null, + category: 'Next.js', + description: 'Files using client-only features must have "use client" directive', +}; + export function requireUseClient(ast: File, _code: string): LintResult[] { const hasDirective = ast.program.directives.some( (d) => d.value.value === 'use client' || d.value.value === 'use server', diff --git a/src/rules/safe-json-parse.ts b/src/rules/safe-json-parse.ts index 024ac78..f5953ae 100644 --- a/src/rules/safe-json-parse.ts +++ b/src/rules/safe-json-parse.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'safe-json-parse'; +export const meta = { + name: 'safe-json-parse', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'Code Style', + description: 'Wrap JSON.parse in try-catch to handle malformed input', +}; + export function safeJsonParse(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/scrollview-horizontal-flexgrow.ts b/src/rules/scrollview-horizontal-flexgrow.ts index 0e336cd..0eab092 100644 --- a/src/rules/scrollview-horizontal-flexgrow.ts +++ b/src/rules/scrollview-horizontal-flexgrow.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'scrollview-horizontal-flexgrow'; +export const meta = { + name: 'scrollview-horizontal-flexgrow', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'React Native / Expo', + description: 'Horizontal ScrollView needs `flexGrow: 0`', +}; + export function scrollviewHorizontalFlexgrow(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/sql-no-nested-calls.ts b/src/rules/sql-no-nested-calls.ts index ef85282..178baf0 100644 --- a/src/rules/sql-no-nested-calls.ts +++ b/src/rules/sql-no-nested-calls.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'sql-no-nested-calls'; +export const meta = { + name: 'sql-no-nested-calls', + severity: 'error' as const, + platforms: ['backend'] as Platform[] | null, + category: 'Backend / SQL', + description: "Don't nest sql template tags", +}; + export function sqlNoNestedCalls(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/ssr-browser-api-guard.ts b/src/rules/ssr-browser-api-guard.ts index d3f7d30..b026a13 100644 --- a/src/rules/ssr-browser-api-guard.ts +++ b/src/rules/ssr-browser-api-guard.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'ssr-browser-api-guard'; +export const meta = { + name: 'ssr-browser-api-guard', + severity: 'error' as const, + platforms: ['web'] as Platform[] | null, + category: 'Next.js', + description: 'Guard browser-only APIs in files that run during SSR', +}; + const BROWSER_GLOBALS = [ 'window', 'localStorage', diff --git a/src/rules/tabs-screen-options-header-shown.ts b/src/rules/tabs-screen-options-header-shown.ts index e14f06e..106ab0f 100644 --- a/src/rules/tabs-screen-options-header-shown.ts +++ b/src/rules/tabs-screen-options-header-shown.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'tabs-screen-options-header-shown'; +export const meta = { + name: 'tabs-screen-options-header-shown', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'React Native / Expo', + description: 'Tabs screenOptions should have `headerShown: false`', +}; + export function tabsScreenOptionsHeaderShown(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/textinput-keyboard-avoiding.ts b/src/rules/textinput-keyboard-avoiding.ts index 5b3d318..180278e 100644 --- a/src/rules/textinput-keyboard-avoiding.ts +++ b/src/rules/textinput-keyboard-avoiding.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'textinput-keyboard-avoiding'; +export const meta = { + name: 'textinput-keyboard-avoiding', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'React Native / Expo', + description: 'TextInput should be inside KeyboardAvoidingView', +}; + export function textInputKeyboardAvoiding(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/transition-gesture-scrollview.ts b/src/rules/transition-gesture-scrollview.ts index d438147..6f0ef8f 100644 --- a/src/rules/transition-gesture-scrollview.ts +++ b/src/rules/transition-gesture-scrollview.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'transition-gesture-scrollview'; +export const meta = { + name: 'transition-gesture-scrollview', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'Screen Transitions', + description: 'Use Transition.ScrollView/FlatList instead of regular versions', +}; + const COMPONENTS_TO_REPLACE: Record = { ScrollView: 'Transition.ScrollView', FlatList: 'Transition.FlatList', diff --git a/src/rules/transition-prefer-blank-stack.ts b/src/rules/transition-prefer-blank-stack.ts index 015d3ff..a3b4cde 100644 --- a/src/rules/transition-prefer-blank-stack.ts +++ b/src/rules/transition-prefer-blank-stack.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'transition-prefer-blank-stack'; +export const meta = { + name: 'transition-prefer-blank-stack', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'Screen Transitions', + description: 'Use Blank Stack instead of enableTransitions on Native Stack', +}; + export function transitionPreferBlankStack(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/transition-progress-range.ts b/src/rules/transition-progress-range.ts index 8e8400e..4922ad6 100644 --- a/src/rules/transition-progress-range.ts +++ b/src/rules/transition-progress-range.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'transition-progress-range'; +export const meta = { + name: 'transition-progress-range', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'Screen Transitions', + description: 'interpolate() should cover full [0, 1, 2] range including exit phase', +}; + export function transitionProgressRange(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/transition-shared-tag-mismatch.ts b/src/rules/transition-shared-tag-mismatch.ts index 14d2ef0..5e291ad 100644 --- a/src/rules/transition-shared-tag-mismatch.ts +++ b/src/rules/transition-shared-tag-mismatch.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'transition-shared-tag-mismatch'; +export const meta = { + name: 'transition-shared-tag-mismatch', + severity: 'warning' as const, + platforms: ['expo'] as Platform[] | null, + category: 'Screen Transitions', + description: 'sharedBoundTag on Transition.Pressable must have matching Transition.View', +}; + interface TagInfo { tag: string; line: number; diff --git a/src/rules/transition-worklet-directive.ts b/src/rules/transition-worklet-directive.ts index 849aafe..8aa6cd7 100644 --- a/src/rules/transition-worklet-directive.ts +++ b/src/rules/transition-worklet-directive.ts @@ -1,9 +1,17 @@ import traverse from '@babel/traverse'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'transition-worklet-directive'; +export const meta = { + name: 'transition-worklet-directive', + severity: 'error' as const, + platforms: ['expo'] as Platform[] | null, + category: 'Screen Transitions', + description: 'screenStyleInterpolator functions must include "worklet" directive', +}; + export function transitionWorkletDirective(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/rules/url-params-must-encode.ts b/src/rules/url-params-must-encode.ts index 5c5d426..8cd5235 100644 --- a/src/rules/url-params-must-encode.ts +++ b/src/rules/url-params-must-encode.ts @@ -1,10 +1,18 @@ import traverse from '@babel/traverse'; import * as t from '@babel/types'; import type { File } from '@babel/types'; -import type { LintResult } from '../types'; +import type { LintResult, Platform } from '../types'; const RULE_NAME = 'url-params-must-encode'; +export const meta = { + name: 'url-params-must-encode', + severity: 'warning' as const, + platforms: null as Platform[] | null, + category: 'URL', + description: 'URL query param values must be wrapped in encodeURIComponent()', +}; + export function urlParamsMustEncode(ast: File, _code: string): LintResult[] { const results: LintResult[] = []; diff --git a/src/types.ts b/src/types.ts index f3caf4e..6d019be 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,3 +31,11 @@ export interface LintConfig { } export type RuleFunction = (ast: File, code: string) => LintResult[]; + +export interface RuleMeta { + name: string; + severity: 'error' | 'warning'; + platforms: Platform[] | null; + category: string; + description: string; +} diff --git a/tests/config-modes.test.ts b/tests/config-modes.test.ts index ff43264..4538c7e 100644 --- a/tests/config-modes.test.ts +++ b/tests/config-modes.test.ts @@ -123,7 +123,7 @@ describe('config modes', () => { expect(ruleNames).toContain('no-relative-paths'); expect(ruleNames).toContain('expo-image-import'); expect(ruleNames).toContain('no-stylesheet-create'); - expect(ruleNames.length).toBe(55); + expect(ruleNames.length).toBeGreaterThan(0); }); }); }); diff --git a/tests/platform.test.ts b/tests/platform.test.ts index 70fc247..7dbd326 100644 --- a/tests/platform.test.ts +++ b/tests/platform.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getRulesForPlatform, getAllRuleNames, lintJsxCode } from '../src'; -import { rulePlatforms } from '../src/rules/meta'; +import { getRulesForPlatform, getAllRuleNames, getRuleMeta, lintJsxCode } from '../src'; describe('getRulesForPlatform', () => { const allRules = getAllRuleNames(); @@ -85,7 +84,7 @@ describe('getRulesForPlatform', () => { const webRules = new Set(getRulesForPlatform('web')); const backendRules = new Set(getRulesForPlatform('backend')); - const universalRules = allRules.filter((name) => !rulePlatforms[name]); + const universalRules = allRules.filter((name) => !getRuleMeta(name)?.platforms); expect(universalRules.length).toBeGreaterThan(0); for (const rule of universalRules) {