From ee04f11a676967b679e9002f489a94d8f7947392 Mon Sep 17 00:00:00 2001 From: Daniel Chen Date: Mon, 4 May 2026 16:24:51 +0000 Subject: [PATCH 1/3] feat: add no-redirect-to-route-group and require-auth-initiate-call rules Rebased onto main to resolve conflicts after #51 merge. --- README.md | 63 ++++++++- src/rules/index.ts | 4 + src/rules/meta.ts | 2 + src/rules/no-redirect-to-route-group.ts | 85 ++++++++++++ src/rules/require-auth-initiate-call.ts | 166 +++++++++++++++++++++++ tests/config-modes.test.ts | 2 +- tests/no-redirect-to-route-group.test.ts | 80 +++++++++++ tests/require-auth-initiate-call.test.ts | 155 +++++++++++++++++++++ 8 files changed, 549 insertions(+), 8 deletions(-) create mode 100644 src/rules/no-redirect-to-route-group.ts create mode 100644 src/rules/require-auth-initiate-call.ts create mode 100644 tests/no-redirect-to-route-group.test.ts create mode 100644 tests/require-auth-initiate-call.test.ts diff --git a/README.md b/README.md index 2c32f34..5a03d43 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ const webRules = getRulesForPlatform('web'); const backendRules = getRulesForPlatform('backend'); ``` -## Available Rules (53 total) +## Available Rules (55 total) ### Expo Router Rules @@ -231,12 +231,16 @@ const backendRules = getRulesForPlatform('backend'); ### 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) | -| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons | -| `no-module-level-new` | error | web | Don't use `new` at module scope (crashes during SSR) | +| 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) | +| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons | +| `no-module-level-new` | error | web | Don't use `new` at module scope (crashes during SSR) | +| `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-redirect-to-route-group` | error | expo | `` must point to a real route, not a route group | +| `require-auth-initiate-call` | error | expo | If a layout gates render on `useAuth().isReady`, it must call `initiate()` | --- @@ -779,6 +783,51 @@ Callbacks (`.map`, `.filter`, `.reduce`, `.sort`, `.then`, etc.) and `React.forw --- +### `no-redirect-to-route-group` + +```tsx +// Bad - "(tabs)" is a route group, stripped during URL resolution. +// app/(tabs)/index.tsx already maps to "/"; redirecting to "/(tabs)" creates +// a conflict (or silent no-op) and the screen renders blank. +import { Redirect } from 'expo-router'; +export default function Index() { + return ; +} + +// Good - either redirect to a concrete sub-route... +return ; + +// ...or remove app/index.tsx entirely and let app/(tabs)/index.tsx handle "/". +``` + +### `require-auth-initiate-call` + +```tsx +// Bad - useAuth().isReady gates render, but initiate() is never called, +// so the persisted JWT is never loaded from SecureStore. isReady stays +// false forever and the app renders blank. +import { useAuth } from '@/utils/auth/useAuth'; + +export default function RootLayout() { + const { isReady } = useAuth(); + if (!isReady) return null; + return ; +} + +// Good - destructure initiate and call it in a useEffect. +import { useAuth } from '@/utils/auth/useAuth'; +import { useEffect } from 'react'; + +export default function RootLayout() { + const { initiate, isReady } = useAuth(); + useEffect(() => { + initiate(); + }, [initiate]); + if (!isReady) return null; + return ; +} +``` + ## Adding a New Rule 1. Create a rule file in `src/rules/`: diff --git a/src/rules/index.ts b/src/rules/index.ts index bd58375..0e7ab92 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -52,6 +52,8 @@ 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 { noRedirectToRouteGroup } from './no-redirect-to-route-group'; +import { requireAuthInitiateCall } from './require-auth-initiate-call'; export const rules: Record = { 'no-relative-paths': noRelativePaths, @@ -107,4 +109,6 @@ export const rules: Record = { 'ssr-browser-api-guard': ssrBrowserApiGuard, 'no-react-native-in-web': noReactNativeInWeb, 'no-module-level-new': noModuleLevelNew, + 'no-redirect-to-route-group': noRedirectToRouteGroup, + 'require-auth-initiate-call': requireAuthInitiateCall, }; diff --git a/src/rules/meta.ts b/src/rules/meta.ts index 060d249..99f730b 100644 --- a/src/rules/meta.ts +++ b/src/rules/meta.ts @@ -25,6 +25,8 @@ export const rulePlatforms: Partial> = { 'transition-gesture-scrollview': ['expo'], 'transition-shared-tag-mismatch': ['expo'], 'transition-prefer-blank-stack': ['expo'], + 'no-redirect-to-route-group': ['expo'], + 'require-auth-initiate-call': ['expo'], // Web 'no-inline-script-code': ['web'], diff --git a/src/rules/no-redirect-to-route-group.ts b/src/rules/no-redirect-to-route-group.ts new file mode 100644 index 0000000..84f4374 --- /dev/null +++ b/src/rules/no-redirect-to-route-group.ts @@ -0,0 +1,85 @@ +import traverse from '@babel/traverse'; +import type { File } from '@babel/types'; +import type { LintResult } from '../types'; + +const RULE_NAME = 'no-redirect-to-route-group'; + +/** + * In Expo Router, segments wrapped in parentheses like `(tabs)` are route + * GROUPS — they're stripped from the resolved URL. So `` + * targets a path that doesn't resolve to any real route: the (tabs) group's + * index.tsx maps to `/`, not `/(tabs)`. This typically creates either a + * conflict with another file mapping to `/`, or a redirect that silently + * does nothing. Either way the screen renders blank. + * + * Flag any href whose segments are ALL route-group segments (no concrete + * path beyond them). `/(tabs)/explore` is fine — it strips to `/explore`. + * `/(tabs)` alone is the bug. + */ +export function noRedirectToRouteGroup(ast: File, _code: string): LintResult[] { + const results: LintResult[] = []; + + traverse(ast, { + JSXOpeningElement(path) { + const { name, attributes } = path.node; + + if (name.type !== 'JSXIdentifier' || name.name !== 'Redirect') { + return; + } + + for (const attr of attributes) { + if ( + attr.type !== 'JSXAttribute' || + attr.name.type !== 'JSXIdentifier' || + attr.name.name !== 'href' + ) { + continue; + } + + const hrefValue = extractStringLiteral(attr.value); + if (hrefValue === null) { + continue; + } + + if (!hrefIsOnlyRouteGroups(hrefValue)) { + continue; + } + + const loc = attr.value?.loc ?? attr.loc; + results.push({ + rule: RULE_NAME, + message: ` targets a route group, not a real URL. Route groups like "(tabs)" are stripped during URL resolution — there is no concrete route at "${hrefValue}". Either redirect to a concrete sub-route (e.g. href="/explore") or remove this Redirect and let the (tabs)/index.tsx file handle "/" directly.`, + line: loc?.start.line ?? 0, + column: loc?.start.column ?? 0, + severity: 'error', + }); + } + }, + }); + + return results; +} + +function extractStringLiteral(value: unknown): string | null { + if (!value || typeof value !== 'object') return null; + const node = value as { type: string; value?: unknown; expression?: unknown }; + + if (node.type === 'StringLiteral' && typeof node.value === 'string') { + return node.value; + } + + if (node.type === 'JSXExpressionContainer') { + const expr = node.expression as { type?: string; value?: unknown } | null; + if (expr?.type === 'StringLiteral' && typeof expr.value === 'string') { + return expr.value; + } + } + + return null; +} + +function hrefIsOnlyRouteGroups(href: string): boolean { + const segments = href.split('/').filter((s) => s.length > 0); + if (segments.length === 0) return false; + return segments.every((s) => s.startsWith('(') && s.endsWith(')')); +} diff --git a/src/rules/require-auth-initiate-call.ts b/src/rules/require-auth-initiate-call.ts new file mode 100644 index 0000000..fdc7192 --- /dev/null +++ b/src/rules/require-auth-initiate-call.ts @@ -0,0 +1,166 @@ +import traverse from '@babel/traverse'; +import type { File } from '@babel/types'; +import type { LintResult } from '../types'; + +const RULE_NAME = 'require-auth-initiate-call'; + +/** + * The shipped V2 mobile auth `useAuth()` exposes both `isReady` (a boolean + * gate that flips true once the persisted JWT has been loaded from + * SecureStore) and `initiate()` (the function that does that load). The + * gate doesn't flip on its own — `initiate()` must be invoked, typically + * once from the root `_layout.tsx` in a `useEffect`. If a file gates + * render on `isReady` (e.g. `if (!isReady) return null;`) without calling + * `initiate()`, the gate stays closed forever and the app renders blank. + * + * This rule fires when: + * - `useAuth()` is destructured to pull out `isReady`, AND + * - `isReady` is used as a render gate (negated, returning null/falsy), AND + * - `initiate` is neither destructured + invoked nor called via member + * access on the auth return value. + */ +export function requireAuthInitiateCall(ast: File, _code: string): LintResult[] { + const results: LintResult[] = []; + + const useAuthDestructures: { + isReadyName: string | null; + initiateName: string | null; + line: number; + column: number; + }[] = []; + + let initiateCalled = false; + + traverse(ast, { + VariableDeclarator(path) { + const { id, init } = path.node; + if ( + init?.type !== 'CallExpression' || + init.callee.type !== 'Identifier' || + init.callee.name !== 'useAuth' || + id.type !== 'ObjectPattern' + ) { + return; + } + + let isReadyName: string | null = null; + let initiateName: string | null = null; + for (const prop of id.properties) { + if ( + prop.type !== 'ObjectProperty' || + prop.key.type !== 'Identifier' || + prop.value.type !== 'Identifier' + ) { + continue; + } + if (prop.key.name === 'isReady') { + isReadyName = prop.value.name; + } + if (prop.key.name === 'initiate') { + initiateName = prop.value.name; + } + } + + if (isReadyName === null) return; + + useAuthDestructures.push({ + isReadyName, + initiateName, + line: init.loc?.start.line ?? 0, + column: init.loc?.start.column ?? 0, + }); + }, + + CallExpression(path) { + const { callee } = path.node; + + // Direct invocation: `initiate()` (where local name is from destructure) + if (callee.type === 'Identifier') { + for (const d of useAuthDestructures) { + if (d.initiateName !== null && callee.name === d.initiateName) { + initiateCalled = true; + } + } + } + + // Member call: `something.initiate()` — covers + // `useAuth().initiate()`, `auth.initiate()`, etc. Conservatively + // accept any `.initiate(` to avoid false positives. + if ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + callee.property.name === 'initiate' + ) { + initiateCalled = true; + } + }, + }); + + if (useAuthDestructures.length === 0 || initiateCalled) { + return results; + } + + // Need to confirm `isReady` is being used as a render gate (not just + // shown as a spinner or read for some other purpose). Look for + // `if (!) return ...;` or similar early-return patterns. + let usedAsRenderGate = false; + traverse(ast, { + IfStatement(path) { + const { test, consequent } = path.node; + const matchedName = matchesNegatedIdentifier( + test, + useAuthDestructures.map((d) => d.isReadyName).filter((n): n is string => n !== null), + ); + if (matchedName === null) return; + if (containsReturn(consequent)) { + usedAsRenderGate = true; + } + }, + }); + + if (!usedAsRenderGate) { + return results; + } + + for (const d of useAuthDestructures) { + results.push({ + rule: RULE_NAME, + message: + d.initiateName === null + ? `useAuth() destructures "isReady" and gates render on it, but never destructures or calls "initiate()". The persisted JWT is never loaded from SecureStore, so isReady stays false forever and the app renders blank. Pull "initiate" from useAuth() and call it in a useEffect.` + : `useAuth() destructures "isReady" and "initiate", and gates render on isReady, but "initiate()" is never invoked. Call it in a useEffect: useEffect(() => { initiate(); }, [initiate]);`, + line: d.line, + column: d.column, + severity: 'error', + }); + } + + return results; +} + +function matchesNegatedIdentifier(node: unknown, names: string[]): string | null { + if (!node || typeof node !== 'object') return null; + const n = node as { type?: string; operator?: string; argument?: unknown; name?: string }; + if (n.type === 'UnaryExpression' && n.operator === '!') { + const arg = n.argument as { type?: string; name?: string } | null; + if (arg?.type === 'Identifier' && arg.name && names.includes(arg.name)) { + return arg.name; + } + } + return null; +} + +function containsReturn(node: unknown): boolean { + if (!node || typeof node !== 'object') return false; + const n = node as { + type?: string; + body?: unknown[]; + consequent?: unknown; + alternate?: unknown; + }; + if (n.type === 'ReturnStatement') return true; + if (n.type === 'BlockStatement' && Array.isArray(n.body)) { + return n.body.some((s) => containsReturn(s)); + } + return false; +} diff --git a/tests/config-modes.test.ts b/tests/config-modes.test.ts index c18404f..ff43264 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(53); + expect(ruleNames.length).toBe(55); }); }); }); diff --git a/tests/no-redirect-to-route-group.test.ts b/tests/no-redirect-to-route-group.test.ts new file mode 100644 index 0000000..01182e2 --- /dev/null +++ b/tests/no-redirect-to-route-group.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { lintJsxCode } from '../src'; + +const config = { rules: ['no-redirect-to-route-group'] }; + +describe('no-redirect-to-route-group rule', () => { + it('flags ', () => { + const code = ` + import { Redirect } from 'expo-router'; + export default function Index() { + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].rule).toBe('no-redirect-to-route-group'); + expect(results[0].severity).toBe('error'); + expect(results[0].message).toContain('"/(tabs)"'); + }); + + it('flags without leading slash', () => { + const code = ``; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + }); + + it('flags with trailing slash', () => { + const code = ``; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + }); + + it('flags with multiple group segments', () => { + const code = ``; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + }); + + it('allows with concrete sub-route', () => { + const code = ``; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('allows ', () => { + const code = ``; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('allows ', () => { + const code = ``; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('handles JSXExpressionContainer string literal', () => { + const code = ``; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + }); + + it('ignores non-Redirect components', () => { + const code = ``; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('ignores Redirect with dynamic href expression', () => { + const code = ``; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('ignores Redirect without an href attribute', () => { + const code = ``; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); +}); diff --git a/tests/require-auth-initiate-call.test.ts b/tests/require-auth-initiate-call.test.ts new file mode 100644 index 0000000..435d355 --- /dev/null +++ b/tests/require-auth-initiate-call.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { lintJsxCode } from '../src'; + +const config = { rules: ['require-auth-initiate-call'] }; + +describe('require-auth-initiate-call rule', () => { + it('flags layout that destructures isReady and gates render but never calls initiate', () => { + const code = ` + import { useAuth } from '@/utils/auth/useAuth'; + export default function RootLayout() { + const { isReady } = useAuth(); + if (!isReady) return null; + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].rule).toBe('require-auth-initiate-call'); + expect(results[0].severity).toBe('error'); + expect(results[0].message).toContain('initiate'); + }); + + it('flags layout that destructures both but never calls initiate()', () => { + const code = ` + import { useAuth } from '@/utils/auth/useAuth'; + export default function RootLayout() { + const { isReady, initiate } = useAuth(); + if (!isReady) return null; + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + expect(results[0].message).toContain('useEffect'); + }); + + it('allows correct usage: initiate() called in useEffect', () => { + const code = ` + import { useAuth } from '@/utils/auth/useAuth'; + import { useEffect } from 'react'; + export default function RootLayout() { + const { initiate, isReady } = useAuth(); + useEffect(() => { initiate(); }, [initiate]); + if (!isReady) return null; + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('allows aliased isReady when initiate is called', () => { + const code = ` + import { useAuth } from '@/utils/auth/useAuth'; + import { useEffect } from 'react'; + export default function RootLayout() { + const { initiate, isReady: authReady } = useAuth(); + useEffect(() => { initiate(); }, [initiate]); + if (!authReady) return null; + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('flags aliased isReady when initiate is missing', () => { + const code = ` + import { useAuth } from '@/utils/auth/useAuth'; + export default function RootLayout() { + const { isReady: authReady } = useAuth(); + if (!authReady) return null; + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(1); + }); + + it('allows member-access initiate: auth.initiate()', () => { + const code = ` + import { useAuth } from '@/utils/auth/useAuth'; + import { useEffect } from 'react'; + export default function RootLayout() { + const { isReady } = useAuth(); + const auth = useAuth(); + useEffect(() => { auth.initiate(); }, [auth]); + if (!isReady) return null; + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('allows non-gate isReady reads (e.g. small spinner conditional)', () => { + const code = ` + import { useAuth } from '@/utils/auth/useAuth'; + export function ProfileTab() { + const { isReady, auth } = useAuth(); + return {isReady ? : null}; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('does not fire when useAuth is not used', () => { + const code = ` + export default function RootLayout() { + return ; + } + `; + const results = lintJsxCode(code, config); + expect(results).toHaveLength(0); + }); + + it('does not fire when useAuth is used but isReady is not destructured', () => { + const code = ` + import { useAuth } from '@/utils/auth/useAuth'; + export function SignInButton() { + const { signIn } = useAuth(); + return