Skip to content

Commit ee04f11

Browse files
committed
feat: add no-redirect-to-route-group and require-auth-initiate-call rules
Rebased onto main to resolve conflicts after #51 merge.
1 parent 239f0cc commit ee04f11

8 files changed

Lines changed: 549 additions & 8 deletions

README.md

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ const webRules = getRulesForPlatform('web');
122122
const backendRules = getRulesForPlatform('backend');
123123
```
124124

125-
## Available Rules (53 total)
125+
## Available Rules (55 total)
126126

127127
### Expo Router Rules
128128

@@ -231,12 +231,16 @@ const backendRules = getRulesForPlatform('backend');
231231

232232
### General Rules
233233

234-
| Rule | Severity | Platform | Description |
235-
| ------------------------ | -------- | --------- | -------------------------------------------------------------- |
236-
| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons |
237-
| `no-react-native-in-web` | error | web | Don't import react-native in web modules (causes ESM failures) |
238-
| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons |
239-
| `no-module-level-new` | error | web | Don't use `new` at module scope (crashes during SSR) |
234+
| Rule | Severity | Platform | Description |
235+
| ---------------------------- | -------- | --------- | -------------------------------------------------------------------------- |
236+
| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons |
237+
| `no-react-native-in-web` | error | web | Don't import react-native in web modules (causes ESM failures) |
238+
| `prefer-lucide-icons` | warning | expo, web | Prefer lucide-react/lucide-react-native icons |
239+
| `no-module-level-new` | error | web | Don't use `new` at module scope (crashes during SSR) |
240+
| `no-relative-paths` | error | expo, web | Use absolute paths in router.navigate/push and Link href |
241+
| `header-shown-false` | warning | expo | (tabs) Screen in root layout needs `headerShown: false` |
242+
| `no-redirect-to-route-group` | error | expo | `<Redirect href>` must point to a real route, not a route group |
243+
| `require-auth-initiate-call` | error | expo | If a layout gates render on `useAuth().isReady`, it must call `initiate()` |
240244

241245
---
242246

@@ -779,6 +783,51 @@ Callbacks (`.map`, `.filter`, `.reduce`, `.sort`, `.then`, etc.) and `React.forw
779783

780784
---
781785

786+
### `no-redirect-to-route-group`
787+
788+
```tsx
789+
// Bad - "(tabs)" is a route group, stripped during URL resolution.
790+
// app/(tabs)/index.tsx already maps to "/"; redirecting to "/(tabs)" creates
791+
// a conflict (or silent no-op) and the screen renders blank.
792+
import { Redirect } from 'expo-router';
793+
export default function Index() {
794+
return <Redirect href="/(tabs)" />;
795+
}
796+
797+
// Good - either redirect to a concrete sub-route...
798+
return <Redirect href="/explore" />;
799+
800+
// ...or remove app/index.tsx entirely and let app/(tabs)/index.tsx handle "/".
801+
```
802+
803+
### `require-auth-initiate-call`
804+
805+
```tsx
806+
// Bad - useAuth().isReady gates render, but initiate() is never called,
807+
// so the persisted JWT is never loaded from SecureStore. isReady stays
808+
// false forever and the app renders blank.
809+
import { useAuth } from '@/utils/auth/useAuth';
810+
811+
export default function RootLayout() {
812+
const { isReady } = useAuth();
813+
if (!isReady) return null;
814+
return <Stack />;
815+
}
816+
817+
// Good - destructure initiate and call it in a useEffect.
818+
import { useAuth } from '@/utils/auth/useAuth';
819+
import { useEffect } from 'react';
820+
821+
export default function RootLayout() {
822+
const { initiate, isReady } = useAuth();
823+
useEffect(() => {
824+
initiate();
825+
}, [initiate]);
826+
if (!isReady) return null;
827+
return <Stack />;
828+
}
829+
```
830+
782831
## Adding a New Rule
783832

784833
1. Create a rule file in `src/rules/`:

src/rules/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ import { noServerImportInClient } from './no-server-import-in-client';
5252
import { ssrBrowserApiGuard } from './ssr-browser-api-guard';
5353
import { noReactNativeInWeb } from './no-react-native-in-web';
5454
import { noModuleLevelNew } from './no-module-level-new';
55+
import { noRedirectToRouteGroup } from './no-redirect-to-route-group';
56+
import { requireAuthInitiateCall } from './require-auth-initiate-call';
5557

5658
export const rules: Record<string, RuleFunction> = {
5759
'no-relative-paths': noRelativePaths,
@@ -107,4 +109,6 @@ export const rules: Record<string, RuleFunction> = {
107109
'ssr-browser-api-guard': ssrBrowserApiGuard,
108110
'no-react-native-in-web': noReactNativeInWeb,
109111
'no-module-level-new': noModuleLevelNew,
112+
'no-redirect-to-route-group': noRedirectToRouteGroup,
113+
'require-auth-initiate-call': requireAuthInitiateCall,
110114
};

src/rules/meta.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export const rulePlatforms: Partial<Record<string, Platform[]>> = {
2525
'transition-gesture-scrollview': ['expo'],
2626
'transition-shared-tag-mismatch': ['expo'],
2727
'transition-prefer-blank-stack': ['expo'],
28+
'no-redirect-to-route-group': ['expo'],
29+
'require-auth-initiate-call': ['expo'],
2830

2931
// Web
3032
'no-inline-script-code': ['web'],
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import traverse from '@babel/traverse';
2+
import type { File } from '@babel/types';
3+
import type { LintResult } from '../types';
4+
5+
const RULE_NAME = 'no-redirect-to-route-group';
6+
7+
/**
8+
* In Expo Router, segments wrapped in parentheses like `(tabs)` are route
9+
* GROUPS — they're stripped from the resolved URL. So `<Redirect href="/(tabs)" />`
10+
* targets a path that doesn't resolve to any real route: the (tabs) group's
11+
* index.tsx maps to `/`, not `/(tabs)`. This typically creates either a
12+
* conflict with another file mapping to `/`, or a redirect that silently
13+
* does nothing. Either way the screen renders blank.
14+
*
15+
* Flag any href whose segments are ALL route-group segments (no concrete
16+
* path beyond them). `/(tabs)/explore` is fine — it strips to `/explore`.
17+
* `/(tabs)` alone is the bug.
18+
*/
19+
export function noRedirectToRouteGroup(ast: File, _code: string): LintResult[] {
20+
const results: LintResult[] = [];
21+
22+
traverse(ast, {
23+
JSXOpeningElement(path) {
24+
const { name, attributes } = path.node;
25+
26+
if (name.type !== 'JSXIdentifier' || name.name !== 'Redirect') {
27+
return;
28+
}
29+
30+
for (const attr of attributes) {
31+
if (
32+
attr.type !== 'JSXAttribute' ||
33+
attr.name.type !== 'JSXIdentifier' ||
34+
attr.name.name !== 'href'
35+
) {
36+
continue;
37+
}
38+
39+
const hrefValue = extractStringLiteral(attr.value);
40+
if (hrefValue === null) {
41+
continue;
42+
}
43+
44+
if (!hrefIsOnlyRouteGroups(hrefValue)) {
45+
continue;
46+
}
47+
48+
const loc = attr.value?.loc ?? attr.loc;
49+
results.push({
50+
rule: RULE_NAME,
51+
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.`,
52+
line: loc?.start.line ?? 0,
53+
column: loc?.start.column ?? 0,
54+
severity: 'error',
55+
});
56+
}
57+
},
58+
});
59+
60+
return results;
61+
}
62+
63+
function extractStringLiteral(value: unknown): string | null {
64+
if (!value || typeof value !== 'object') return null;
65+
const node = value as { type: string; value?: unknown; expression?: unknown };
66+
67+
if (node.type === 'StringLiteral' && typeof node.value === 'string') {
68+
return node.value;
69+
}
70+
71+
if (node.type === 'JSXExpressionContainer') {
72+
const expr = node.expression as { type?: string; value?: unknown } | null;
73+
if (expr?.type === 'StringLiteral' && typeof expr.value === 'string') {
74+
return expr.value;
75+
}
76+
}
77+
78+
return null;
79+
}
80+
81+
function hrefIsOnlyRouteGroups(href: string): boolean {
82+
const segments = href.split('/').filter((s) => s.length > 0);
83+
if (segments.length === 0) return false;
84+
return segments.every((s) => s.startsWith('(') && s.endsWith(')'));
85+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import traverse from '@babel/traverse';
2+
import type { File } from '@babel/types';
3+
import type { LintResult } from '../types';
4+
5+
const RULE_NAME = 'require-auth-initiate-call';
6+
7+
/**
8+
* The shipped V2 mobile auth `useAuth()` exposes both `isReady` (a boolean
9+
* gate that flips true once the persisted JWT has been loaded from
10+
* SecureStore) and `initiate()` (the function that does that load). The
11+
* gate doesn't flip on its own — `initiate()` must be invoked, typically
12+
* once from the root `_layout.tsx` in a `useEffect`. If a file gates
13+
* render on `isReady` (e.g. `if (!isReady) return null;`) without calling
14+
* `initiate()`, the gate stays closed forever and the app renders blank.
15+
*
16+
* This rule fires when:
17+
* - `useAuth()` is destructured to pull out `isReady`, AND
18+
* - `isReady` is used as a render gate (negated, returning null/falsy), AND
19+
* - `initiate` is neither destructured + invoked nor called via member
20+
* access on the auth return value.
21+
*/
22+
export function requireAuthInitiateCall(ast: File, _code: string): LintResult[] {
23+
const results: LintResult[] = [];
24+
25+
const useAuthDestructures: {
26+
isReadyName: string | null;
27+
initiateName: string | null;
28+
line: number;
29+
column: number;
30+
}[] = [];
31+
32+
let initiateCalled = false;
33+
34+
traverse(ast, {
35+
VariableDeclarator(path) {
36+
const { id, init } = path.node;
37+
if (
38+
init?.type !== 'CallExpression' ||
39+
init.callee.type !== 'Identifier' ||
40+
init.callee.name !== 'useAuth' ||
41+
id.type !== 'ObjectPattern'
42+
) {
43+
return;
44+
}
45+
46+
let isReadyName: string | null = null;
47+
let initiateName: string | null = null;
48+
for (const prop of id.properties) {
49+
if (
50+
prop.type !== 'ObjectProperty' ||
51+
prop.key.type !== 'Identifier' ||
52+
prop.value.type !== 'Identifier'
53+
) {
54+
continue;
55+
}
56+
if (prop.key.name === 'isReady') {
57+
isReadyName = prop.value.name;
58+
}
59+
if (prop.key.name === 'initiate') {
60+
initiateName = prop.value.name;
61+
}
62+
}
63+
64+
if (isReadyName === null) return;
65+
66+
useAuthDestructures.push({
67+
isReadyName,
68+
initiateName,
69+
line: init.loc?.start.line ?? 0,
70+
column: init.loc?.start.column ?? 0,
71+
});
72+
},
73+
74+
CallExpression(path) {
75+
const { callee } = path.node;
76+
77+
// Direct invocation: `initiate()` (where local name is from destructure)
78+
if (callee.type === 'Identifier') {
79+
for (const d of useAuthDestructures) {
80+
if (d.initiateName !== null && callee.name === d.initiateName) {
81+
initiateCalled = true;
82+
}
83+
}
84+
}
85+
86+
// Member call: `something.initiate()` — covers
87+
// `useAuth().initiate()`, `auth.initiate()`, etc. Conservatively
88+
// accept any `.initiate(` to avoid false positives.
89+
if (
90+
callee.type === 'MemberExpression' &&
91+
callee.property.type === 'Identifier' &&
92+
callee.property.name === 'initiate'
93+
) {
94+
initiateCalled = true;
95+
}
96+
},
97+
});
98+
99+
if (useAuthDestructures.length === 0 || initiateCalled) {
100+
return results;
101+
}
102+
103+
// Need to confirm `isReady` is being used as a render gate (not just
104+
// shown as a spinner or read for some other purpose). Look for
105+
// `if (!<isReadyName>) return ...;` or similar early-return patterns.
106+
let usedAsRenderGate = false;
107+
traverse(ast, {
108+
IfStatement(path) {
109+
const { test, consequent } = path.node;
110+
const matchedName = matchesNegatedIdentifier(
111+
test,
112+
useAuthDestructures.map((d) => d.isReadyName).filter((n): n is string => n !== null),
113+
);
114+
if (matchedName === null) return;
115+
if (containsReturn(consequent)) {
116+
usedAsRenderGate = true;
117+
}
118+
},
119+
});
120+
121+
if (!usedAsRenderGate) {
122+
return results;
123+
}
124+
125+
for (const d of useAuthDestructures) {
126+
results.push({
127+
rule: RULE_NAME,
128+
message:
129+
d.initiateName === null
130+
? `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.`
131+
: `useAuth() destructures "isReady" and "initiate", and gates render on isReady, but "initiate()" is never invoked. Call it in a useEffect: useEffect(() => { initiate(); }, [initiate]);`,
132+
line: d.line,
133+
column: d.column,
134+
severity: 'error',
135+
});
136+
}
137+
138+
return results;
139+
}
140+
141+
function matchesNegatedIdentifier(node: unknown, names: string[]): string | null {
142+
if (!node || typeof node !== 'object') return null;
143+
const n = node as { type?: string; operator?: string; argument?: unknown; name?: string };
144+
if (n.type === 'UnaryExpression' && n.operator === '!') {
145+
const arg = n.argument as { type?: string; name?: string } | null;
146+
if (arg?.type === 'Identifier' && arg.name && names.includes(arg.name)) {
147+
return arg.name;
148+
}
149+
}
150+
return null;
151+
}
152+
153+
function containsReturn(node: unknown): boolean {
154+
if (!node || typeof node !== 'object') return false;
155+
const n = node as {
156+
type?: string;
157+
body?: unknown[];
158+
consequent?: unknown;
159+
alternate?: unknown;
160+
};
161+
if (n.type === 'ReturnStatement') return true;
162+
if (n.type === 'BlockStatement' && Array.isArray(n.body)) {
163+
return n.body.some((s) => containsReturn(s));
164+
}
165+
return false;
166+
}

tests/config-modes.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ describe('config modes', () => {
123123
expect(ruleNames).toContain('no-relative-paths');
124124
expect(ruleNames).toContain('expo-image-import');
125125
expect(ruleNames).toContain('no-stylesheet-create');
126-
expect(ruleNames.length).toBe(53);
126+
expect(ruleNames.length).toBe(55);
127127
});
128128
});
129129
});

0 commit comments

Comments
 (0)