|
| 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 | +} |
0 commit comments