Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 73 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ const webRules = getRulesForPlatform('web');
const backendRules = getRulesForPlatform('backend');
```

## Available Rules (54 total)
## Available Rules (56 total)

### Expo Router Rules

Expand Down Expand Up @@ -231,17 +231,33 @@ 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) |
| `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 |
| `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 |
| `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-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 | `<Redirect href>` 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()` |

---

Expand Down Expand Up @@ -784,6 +800,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 <Redirect href="/(tabs)" />;
}

// Good - either redirect to a concrete sub-route...
return <Redirect href="/explore" />;

// ...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 <Stack />;
}

// 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 <Stack />;
}
```

## Adding a New Rule

1. Create a rule file in `src/rules/`:
Expand Down
4 changes: 4 additions & 0 deletions src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ 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';
import { noRedirectToRouteGroup } from './no-redirect-to-route-group';
import { requireAuthInitiateCall } from './require-auth-initiate-call';

export const rules: Record<string, RuleFunction> = {
'no-relative-paths': noRelativePaths,
Expand Down Expand Up @@ -111,4 +113,6 @@ export const rules: Record<string, RuleFunction> = {
'no-module-level-new': noModuleLevelNew,
'no-unrestricted-loop-in-serverless': noUnrestrictedLoopInServerless,
'prefer-promise-all': preferPromiseAll,
'no-redirect-to-route-group': noRedirectToRouteGroup,
'require-auth-initiate-call': requireAuthInitiateCall,
};
2 changes: 2 additions & 0 deletions src/rules/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export const rulePlatforms: Partial<Record<string, Platform[]>> = {
'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'],
Expand Down
85 changes: 85 additions & 0 deletions src/rules/no-redirect-to-route-group.ts
Original file line number Diff line number Diff line change
@@ -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 `<Redirect href="/(tabs)" />`
* 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') {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same bug class via imperative API isn't caught (verified):

router.replace('/(tabs)'); router.push('/(tabs)'); router.navigate('/(tabs)');

Add a CallExpression visitor for <id>.replace/.push/.navigate with a string-literal first arg, reusing hrefIsOnlyRouteGroups. If out of scope here, document the gap in the rule's JSDoc.

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: `<Redirect href="${hrefValue}"> 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(')'));
}
166 changes: 166 additions & 0 deletions src/rules/require-auth-initiate-call.ts
Original file line number Diff line number Diff line change
@@ -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 (!<isReadyName>) 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;
Comment on lines +108 to +122
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Render-gate detection misses two common forms (verified []):

return isReady ? <Stack /> : null;          // ternary
if (isReady) return <Stack />; return null; // positive early-return

Same bug from a codegen perspective. Extend usedAsRenderGate to recognize:

  • ReturnStatement whose argument is a ConditionalExpression testing the destructured name;
  • IfStatement with a positive identifier test (no !) whose consequent returns.

}

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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unknown + ad-hoc casts in matchesNegatedIdentifier/containsReturn (and extractStringLiteral in the sibling rule) fight CLAUDE.md's "use Babel's type system directly". Use t.isUnaryExpression/t.isIdentifier/t.isReturnStatement/t.isBlockStatement to narrow.

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;
}
2 changes: 1 addition & 1 deletion tests/config-modes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).toBe(57);
});
});
});
Loading
Loading