-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add no-redirect-to-route-group and require-auth-initiate-call rules #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ee04f11
8039ea8
fe53900
2a9e06d
c09ee23
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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') { | ||
| 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(')')); | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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-returnSame bug from a codegen perspective. Extend
|
||
| } | ||
|
|
||
| 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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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; | ||
| } | ||
There was a problem hiding this comment.
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):
Add a
CallExpressionvisitor for<id>.replace/.push/.navigatewith a string-literal first arg, reusinghrefIsOnlyRouteGroups. If out of scope here, document the gap in the rule's JSDoc.