Guards Expo / React Native work in Claude Code: blocks explicit `any` and non-Expo package installs, warns on TanStack Query, UI, architecture and duplication…

A Claude Code mod (a plugin of function hooks) for Expo / React Native projects that use TypeScript, Expo Router, TanStack Query, Axios, Zustand and Zod.
It watches the edits and shell commands Claude makes in your project:
any, and package installs that bypass npx expo install./check slash command that runs your project's TypeScript, lint and test checks.It never edits your application files itself.
From a shell:
claude plugin marketplace add AbdelrahmanMohamed-sa3oor/react-native-code-guardian
claude plugin install react-native-code-guardian@react-native-code-guardian
Or at the prompt of a Claude Code terminal session:
/plugin marketplace add AbdelrahmanMohamed-sa3oor/react-native-code-guardian
/plugin install react-native-code-guardian@react-native-code-guardian
The default install scope is user, so the mod runs in every project you open. It only acts in a project whose package.json depends on expo or react-native. Everywhere else it stays out of the way, apart from making /check available.
git on your PATH. It's used read-only (git ls-files, git grep) for the duplicate and theme checks. Without git, those checks are silently skipped./check to run your scripts.There is nothing to set up per project: open Claude Code in the project's root (the folder with package.json) and the mod is active. For /check to cover everything:
node_modules/.bin/tsc exists, or add a typecheck script.npx expo lint creates the config). /check won't run expo lint while no ESLint config exists, because that would install packages and write files.test script if you have tests. It must not run in watch mode./check/check # TypeScript, lint and tests
/check ts # TypeScript only (also: tsc, types, typecheck)
/check lint # lint only (also: eslint)
/check test # tests only (also: tests, jest)
/check ts lint # any combination
Each check is reported as PASSED, FAILED (with the last 40 lines of output) or SKIPPED (with the reason). A check is PASSED only when its command actually ran and exited with code 0. A command that times out (5 minutes) or can't start is reported as FAILED. The report is also handed to Claude, so it can act on the failures.
| Check | What runs |
|---|---|
| TypeScript | The first of the typecheck, type-check, tsc, check:types or types scripts. Otherwise the project's own node_modules/.bin/tsc --noEmit (never npx, which could download a package). |
| Lint | The lint script, skipped when it is expo lint and the project has no ESLint config. |
| Tests | The test script with CI=true, skipped when missing, when it's npm's placeholder, or when it uses --watch. |
The package manager comes from the lockfile: bun.lock / bun.lockb mean bun, yarn.lock yarn, pnpm-lock.yaml pnpm, anything else npm.
Blocked: the tool call is refused and Claude is told why, so it can retry correctly. Both policies can be switched to warn.
| Rule | Trigger |
|---|---|
Explicit any | An Edit or Write that adds as any, : any, <any>, any[], = any or similar to a .ts/.tsx file. Lines already in the file and any inside strings or comments are ignored. |
| Expo installs | npm install <pkg>, npm i, yarn add, pnpm add or bun add with package names, in an Expo project. Claude is told the npx expo install … command to use instead (with -- --save-dev for dev dependencies). A plain npm install, global installs and npx expo install pass through. |
Warnings: the edit goes through, Claude is told what to fix, and you get a toast. Only the lines an edit adds are examined.
| Area | Warning | How it's detected |
|---|---|---|
| TypeScript | ts-suppress | @ts-ignore / @ts-nocheck added |
| Expo / RN | web-element | <div>, <span>, <p>, <button>, <img>, … in a .tsx/.jsx file that isn't .web.tsx or +html.tsx |
browser-api | document., window.location, localStorage, … in native files | |
router-convention | A new non-screen file under app/ (components/, hooks/, use*.ts, plain .ts other than +api.ts), or a new screen without export default | |
| TanStack Query | direct-http | fetch( / axios.get(…) outside a services/, api/, clients/ or lib/api/ folder |
effect-fetch | useEffect and an HTTP call added together | |
query-stale-time | useQuery with no staleTime in the file, while no new QueryClient({ defaultOptions … staleTime }) exists in the project | |
query-key | queryKey containing Date.now, Math.random or new Date | |
query-global-refetch | refetchOnMount / refetchOnWindowFocus / refetchOnReconnect: false or staleTime / gcTime: Infinity in QueryClient defaults | |
store-server-data | A Zustand store that makes HTTP calls | |
| UI design | badge | <…Badge>, <…Chip>, <…Pill>, <…Tag> elements or badge/chip/pill style keys |
icons | More than maxIconsPerEdit icon elements (Ionicons, MaterialIcons, IconSymbol, SymbolView, *Icon, …) in one edit | |
color | Hex, rgb()/hsl() or named colors outside the theme file. A color that already exists in your theme gets its token named (e.g. use Colors.light.text); others are marked "not in the theme". | |
| Code quality | component-size | An edit grows a .tsx/.jsx file past maxComponentLines |
duplicate-file | A new file whose name (minus extension and platform suffix) matches an existing file | |
duplicate-export | A newly exported function, const, type or class whose name another file already exports |
The theme index is built from files named theme, colors, tokens or palette (.ts/.tsx/.js), reading key: '#hex' entries.
| Option | Default | Meaning |
|---|---|---|
anyPolicy | block | block refuses edits that add any. warn lets them through with a warning. |
installPolicy | block | block refuses non-Expo installs. warn lets them run with a warning. |
maxComponentLines | 250 | Line count above which an edit growing a .tsx file is warned about |
maxIconsPerEdit | 3 | Icon elements one edit may add before it's warned about |
Set them from /config in a session, at install time (claude plugin install … --config anyPolicy=warn), or with claude plugin configure react-native-code-guardian.
CLAUDE.md./check for those.&&, ||, ;, | and newlines. Installs hidden in scripts or subshells aren't detected.package.json with expo or react-native. Start Claude Code from the project root.claude --debug; skipped hooks and the reason are logged as react-native-code-guardian: …./check says TypeScript is not installed. Install your dependencies first; /check never downloads a TypeScript compiler.npx expo lint once, yourself).claude plugin uninstall react-native-code-guardian@react-native-code-guardian
claude plugin marketplace remove react-native-code-guardian
.claude-plugin/plugin.json manifest and options
.claude-plugin/marketplace.json makes this repository installable as a marketplace
hooks/hooks.json points the engine at the hooks module
hooks/register.ts hooks and every call into Claude Code ($)
hooks/rules.ts pure detectors for edits and shell commands
hooks/check.ts pure planning and reporting for /check
hooks/project.ts pure package.json and git-output parsing
tests/guardian.test.ts behaviour tests run by `claude plugin test`
claude --plugin-dir /path/to/react-native-code-guardian # run a session with your working copy
claude plugin validate /path/to/react-native-code-guardian/.claude-plugin/plugin.json
claude plugin test /path/to/react-native-code-guardian
tsc -p /path/to/react-native-code-guardian # after Claude Code has loaded the mod once
tsconfig.json extends .claude-plugin/types/tsconfig.json. Claude Code writes that folder (the API's type declarations) each time it loads the mod from a folder you own, so it isn't committed.
MIT. Chosen for this project as a permissive license suited to a reusable developer tool.
hooks/register.ts 258 lines1import type { EngineInterface, PluginOptions, Register, ToolCallResult } from 'claude-code'
2
3import { PLAN_FILES, formatOutcomes, outcomeOf, parseCheckArgs, planChecks } from './check'
4import type { CheckName, CheckOutcome } from './check'
5import { LOCKFILES, THEME_PATH, exportingFiles, lines, parseProject, setsDefaultStaleTime } from './project'
6import type { Project } from './project'
7import {
8 addedLines,
9 analyzeEdit,
10 duplicateFiles,
11 expoInstallHint,
12 exportedNames,
13 findExplicitAny,
14 findNonExpoInstalls,
15 formatFindings,
16 indexTheme,
17 isAnalyzable,
18} from './rules'
19import type { Finding, ThemeIndex } from './rules'
20
21type Policy = 'block' | 'warn'
22
23type Settings = {
24 anyPolicy: Policy
25 installPolicy: Policy
26 maxComponentLines: number
27 maxIconsPerEdit: number
28}
29
30/** An edit judged before it runs: denied, or the facts to report on once it has. */
31type EditPlan =
32 | { deny: string }
33 | { deny?: undefined; project: Project; path: string; absolute: string; before: string | undefined; after: string }
34
35const NAME = 'react-native-code-guardian'
36const CODE_GLOBS = ['*.ts', '*.tsx', '*.js', '*.jsx']
37const GIT_TIMEOUT_MS = 10_000
38const CHECK_TIMEOUT_MS = 5 * 60_000
39
40// Caches for this load only; a reload starts them over.
41let project: Promise<Project | undefined> | undefined
42let theme: Promise<ThemeIndex> | undefined
43let staleTimeDefault: Promise<boolean> | undefined
44
45function readSettings(options: PluginOptions): Settings {
46 const policy = (value: unknown): Policy => (value === 'warn' ? 'warn' : 'block')
47 const count = (value: unknown, fallback: number): number =>
48 typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
49 return {
50 anyPolicy: policy(options.anyPolicy),
51 installPolicy: policy(options.installPolicy),
52 maxComponentLines: count(options.maxComponentLines, 250),
53 maxIconsPerEdit: count(options.maxIconsPerEdit, 3),
54 }
55}
56
57async function exists($: EngineInterface, path: string): Promise<boolean> {
58 try {
59 return await $.fs.exists(path)
60 } catch {
61 return false
62 }
63}
64
65async function readText($: EngineInterface, path: string): Promise<string | undefined> {
66 try {
67 return (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
68 } catch {
69 return undefined
70 }
71}
72
73/** git's stdout for argv in the project, or undefined when git failed or is missing. */
74async function git($: EngineInterface, root: string, argv: readonly string[]): Promise<string | undefined> {
75 try {
76 const ran = await $.process.run(['git', ...argv], { cwd: root, timeoutMs: GIT_TIMEOUT_MS })
77 return ran.exitCode === 0 ? ran.stdout : undefined
78 } catch {
79 return undefined
80 }
81}
82
83async function loadProject($: EngineInterface): Promise<Project | undefined> {
84 const root = await $.session.cwd()
85 const manifest = await readText($, `${root}/package.json`)
86 if (manifest === undefined) return undefined
87 let lockfile: string | undefined
88 for (const [file] of LOCKFILES) if (lockfile === undefined && (await exists($, `${root}/${file}`))) lockfile = file
89 return parseProject(root, manifest, lockfile)
90}
91
92function projectOf($: EngineInterface): Promise<Project | undefined> {
93 project ??= loadProject($)
94 return project
95}
96
97async function trackedFiles($: EngineInterface, root: string): Promise<string[]> {
98 return lines((await git($, root, ['ls-files', '--cached', '--others', '--exclude-standard'])) ?? '')
99}
100
101async function loadTheme($: EngineInterface, root: string): Promise<ThemeIndex> {
102 const index = new Map<string, string>()
103 for (const file of (await trackedFiles($, root)).filter(path => THEME_PATH.test(path))) {
104 const source = await readText($, `${root}/${file}`)
105 for (const [color, token] of indexTheme(source ?? '')) if (!index.has(color)) index.set(color, token)
106 }
107 return index
108}
109
110function themeOf($: EngineInterface, root: string): Promise<ThemeIndex> {
111 theme ??= loadTheme($, root)
112 return theme
113}
114
115async function loadStaleTimeDefault($: EngineInterface, root: string): Promise<boolean> {
116 const files = lines((await git($, root, ['grep', '-l', '-F', '--untracked', '-e', 'new QueryClient', '--', ...CODE_GLOBS])) ?? '')
117 for (const file of files) if (setsDefaultStaleTime((await readText($, `${root}/${file}`)) ?? '')) return true
118 return false
119}
120
121function staleTimeOf($: EngineInterface, root: string): Promise<boolean> {
122 staleTimeDefault ??= loadStaleTimeDefault($, root)
123 return staleTimeDefault
124}
125
126async function duplicatesOf($: EngineInterface, root: string, path: string, before: string | undefined, after: string): Promise<Finding[]> {
127 const findings: Finding[] = []
128 if (before === undefined) {
129 const same = duplicateFiles(path, await trackedFiles($, root))
130 if (same.length > 0)
131 findings.push({ rule: 'duplicate-file', message: `A file named like ${path} already exists: ${same.slice(0, 3).join(', ')}. Reuse or extend it instead of adding a parallel implementation.` })
132 }
133 for (const name of exportedNames(addedLines(before, after))) {
134 const rows = await git($, root, ['grep', '-I', '-F', '--untracked', '-e', name, '--', ...CODE_GLOBS])
135 const others = exportingFiles(rows ?? '', name, path)
136 if (others.length > 0)
137 findings.push({ rule: 'duplicate-export', message: `\`${name}\` is already exported from ${others.slice(0, 3).join(', ')}. Reuse that implementation (or rename it if it is genuinely different).` })
138 }
139 return findings
140}
141
142/** Judges an Edit or Write before it runs; undefined when the file is not this mod's business. */
143async function planEdit($: EngineInterface, settings: Settings, absolute: string, simulate: (before: string | undefined) => string): Promise<EditPlan | undefined> {
144 const current = await projectOf($)
145 if (current === undefined || !current.isReactNative || !absolute.startsWith(`${current.root}/`)) return undefined
146 const path = absolute.slice(current.root.length + 1)
147 if (!isAnalyzable(path)) return undefined
148 const before = await readText($, absolute)
149 const after = simulate(before)
150 const anyLine = findExplicitAny(path, addedLines(before, after))[0]
151 if (settings.anyPolicy === 'block' && anyLine !== undefined)
152 return {
153 deny: `${NAME}: this edit adds an explicit \`any\` to ${path} (${anyLine}). The project forbids \`any\` / \`as any\`. Use a precise type, a generic, or \`unknown\` narrowed by a type guard or a Zod schema, then retry.`,
154 }
155 return { project: current, path, absolute, before, after }
156}
157
158/** After an edit ran: the warnings for what it added, for the model (context) and the person (toast). */
159async function reportEdit<T extends ToolCallResult>($: EngineInterface, settings: Settings, plan: EditPlan | undefined, ran: T): Promise<T> {
160 if (plan === undefined || plan.deny !== undefined || ran.deny !== undefined || ran.isError === true) return ran
161 const { project: current, path, before } = plan
162 const after = (await readText($, plan.absolute)) ?? plan.after
163 if (after.includes('QueryClient')) staleTimeDefault = undefined
164 if (THEME_PATH.test(path)) theme = undefined
165 const findings = [
166 ...analyzeEdit({
167 path,
168 before,
169 after,
170 theme: await themeOf($, current.root),
171 hasGlobalStaleTime: await staleTimeOf($, current.root),
172 maxComponentLines: settings.maxComponentLines,
173 maxIconsPerEdit: settings.maxIconsPerEdit,
174 }),
175 ...(await duplicatesOf($, current.root, path, before, after)),
176 ]
177 if (findings.length === 0) return ran
178 const rules = [...new Set(findings.map(finding => finding.rule))].join(', ')
179 $.ui.toast(`RN guardian: ${path}: ${rules}`, { timeoutMs: 6000 })
180 return { ...ran, context: [...(ran.context ?? []), formatFindings(path, findings)] }
181}
182
183async function runChecks($: EngineInterface, current: Project, names: readonly CheckName[]): Promise<CheckOutcome[]> {
184 const present = new Set<string>()
185 for (const file of PLAN_FILES) if (await exists($, `${current.root}/${file}`)) present.add(file)
186 const outcomes: CheckOutcome[] = []
187 for (const plan of planChecks(current, present, names)) {
188 if ('skip' in plan) {
189 outcomes.push({ name: plan.name, status: 'skipped', reason: plan.skip })
190 continue
191 }
192 $.ui.status(`/check: running ${plan.name} (${plan.argv.join(' ')})`)
193 const started = await $.clock.now()
194 try {
195 const ran = await $.process.run(plan.argv, { cwd: current.root, env: plan.env, timeoutMs: CHECK_TIMEOUT_MS })
196 outcomes.push(outcomeOf(plan, (await $.clock.now()) - started, ran))
197 } catch (error) {
198 const message = error instanceof Error ? error.message : String(error)
199 outcomes.push(outcomeOf(plan, (await $.clock.now()) - started, { error: message }))
200 }
201 }
202 $.ui.status(undefined)
203 return outcomes
204}
205
206export const register: Register = (on, options) => {
207 const settings = readSettings(options)
208
209 on('session.start', async ($, e, next) => {
210 await $.command.register({
211 name: 'check',
212 description: "Run the project's TypeScript, lint and test checks (args: ts, lint, test)",
213 argumentHint: '[ts] [lint] [test]',
214 })
215 return next(e)
216 })
217
218 on('command.run', { command: 'check' }, async ($, e) => {
219 const current = await projectOf($)
220 if (current === undefined) return { text: '/check: no package.json in the working directory; nothing was run.' }
221 const names = parseCheckArgs(e.args)
222 if (names === undefined) return { text: `/check: unknown check in "${e.args}". Use ts, lint or test.` }
223 const report = formatOutcomes(await runChecks($, current, names))
224 return { text: report, context: [report] }
225 })
226
227 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
228 if (e.tool !== 'Edit') return next(e)
229 const { old_string, new_string, replace_all } = e
230 const plan = await planEdit($, settings, e.file_path, before => {
231 const text = before ?? ''
232 return replace_all === true ? text.split(old_string).join(new_string) : text.replace(old_string, () => new_string)
233 })
234 if (plan?.deny !== undefined) return { deny: plan.deny }
235 return reportEdit($, settings, plan, await next(e))
236 }).catch(($, e, next) => next(e))
237
238 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
239 if (e.tool !== 'Write') return next(e)
240 const { content } = e
241 const plan = await planEdit($, settings, e.file_path, () => content)
242 if (plan?.deny !== undefined) return { deny: plan.deny }
243 return reportEdit($, settings, plan, await next(e))
244 }).catch(($, e, next) => next(e))
245
246 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
247 if (e.tool !== 'Bash') return next(e)
248 const current = await projectOf($)
249 const installs = current?.isExpo === true ? findNonExpoInstalls(e.command) : []
250 if (installs.length === 0) return next(e)
251 const message = `${NAME}: this is an Expo project; add packages with \`${installs.map(expoInstallHint).join('; ')}\` so versions match the Expo SDK (instead of: ${installs.join('; ')}).`
252 if (settings.installPolicy === 'block') return { deny: message }
253 $.ui.toast('RN guardian: use npx expo install', { timeoutMs: 6000 })
254 const ran = await next(e)
255 return ran.deny === undefined ? { ...ran, context: [...(ran.context ?? []), message] } : ran
256 }).catch(($, e, next) => next(e))
257}
258hooks/check.ts 103 lines1// Pure planning and reporting for /check; register.ts runs the commands.
2
3import type { PackageManager, Project } from './project'
4
5export type CheckName = 'typescript' | 'lint' | 'tests'
6
7export type CheckPlan =
8 | { name: CheckName; argv: readonly string[]; env?: Record<string, string> }
9 | { name: CheckName; skip: string }
10
11export type CheckOutcome =
12 | { name: CheckName; status: 'passed'; command: string; durationMs: number }
13 | { name: CheckName; status: 'failed'; command: string; durationMs: number; output: string }
14 | { name: CheckName; status: 'skipped'; reason: string }
15
16/** Project files whose presence decides the plans, relative to the root. */
17export const ESLINT_CONFIGS = [
18 'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts',
19 '.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml',
20] as const
21export const PLAN_FILES = ['tsconfig.json', 'node_modules/.bin/tsc', ...ESLINT_CONFIGS] as const
22
23const TYPECHECK_SCRIPTS = ['typecheck', 'type-check', 'tsc', 'check:types', 'types']
24const ALL: readonly CheckName[] = ['typescript', 'lint', 'tests']
25
26const runScript = (manager: PackageManager, script: string): string[] => {
27 if (manager === 'npm') return ['npm', 'run', '--silent', script]
28 if (manager === 'yarn') return ['yarn', script]
29 return [manager, 'run', script]
30}
31
32const planTypescript = (project: Project, present: ReadonlySet<string>): CheckPlan => {
33 const script = TYPECHECK_SCRIPTS.find(name => name in project.scripts)
34 if (script !== undefined) return { name: 'typescript', argv: runScript(project.packageManager, script) }
35 if (!present.has('tsconfig.json')) return { name: 'typescript', skip: 'no tsconfig.json' }
36 // The local binary only: `npx tsc` could download a package.
37 if (!present.has('node_modules/.bin/tsc'))
38 return { name: 'typescript', skip: 'typescript is not installed (node_modules/.bin/tsc missing); install dependencies first' }
39 return { name: 'typescript', argv: ['node_modules/.bin/tsc', '--noEmit'] }
40}
41
42const planLint = (project: Project, present: ReadonlySet<string>): CheckPlan => {
43 const script = project.scripts.lint
44 if (script === undefined) return { name: 'lint', skip: 'no "lint" script in package.json' }
45 // `expo lint` with no config installs and configures ESLint: network access and new files.
46 if (/\bexpo\s+lint\b/.test(script) && !ESLINT_CONFIGS.some(file => present.has(file)))
47 return { name: 'lint', skip: '`expo lint` has no ESLint config yet and would install and configure ESLint (network access, new files); run `npx expo lint` yourself once to set it up' }
48 return { name: 'lint', argv: runScript(project.packageManager, 'lint') }
49}
50
51const planTests = (project: Project): CheckPlan => {
52 const script = project.scripts.test
53 if (script === undefined || /no test specified/.test(script)) return { name: 'tests', skip: 'no "test" script in package.json' }
54 if (/--watch/.test(script)) return { name: 'tests', skip: 'the "test" script runs in watch mode and would not exit' }
55 return { name: 'tests', argv: runScript(project.packageManager, 'test'), env: { CI: 'true' } }
56}
57
58export const planChecks = (project: Project, present: ReadonlySet<string>, names: readonly CheckName[]): CheckPlan[] =>
59 names.map(name => {
60 if (name === 'typescript') return planTypescript(project, present)
61 if (name === 'lint') return planLint(project, present)
62 return planTests(project)
63 })
64
65const ALIASES: Readonly<Record<string, CheckName>> = {
66 ts: 'typescript', tsc: 'typescript', types: 'typescript', typescript: 'typescript', typecheck: 'typescript',
67 lint: 'lint', eslint: 'lint',
68 test: 'tests', tests: 'tests', jest: 'tests',
69}
70
71/** The checks `/check <args>` asks for: all when none named; undefined when a word names none. */
72export const parseCheckArgs = (args: string): CheckName[] | undefined => {
73 const words = args.toLowerCase().split(/[\s,]+/).filter(Boolean)
74 if (words.length === 0) return [...ALL]
75 const named = words.map(word => ALIASES[word])
76 if (named.some(name => name === undefined)) return undefined
77 return ALL.filter(name => named.includes(name))
78}
79
80const tail = (text: string, count: number): string => text.trimEnd().split('\n').slice(-count).join('\n')
81
82export const outcomeOf = (
83 plan: Extract<CheckPlan, { argv: readonly string[] }>,
84 durationMs: number,
85 ran: { exitCode: number; stdout: string; stderr: string } | { error: string },
86): CheckOutcome => {
87 const command = plan.argv.join(' ')
88 if ('error' in ran) return { name: plan.name, status: 'failed', command, durationMs, output: `did not finish: ${ran.error}` }
89 return ran.exitCode === 0
90 ? { name: plan.name, status: 'passed', command, durationMs }
91 : { name: plan.name, status: 'failed', command, durationMs, output: tail(`${ran.stdout}\n${ran.stderr}`, 40) || `exit code ${ran.exitCode}` }
92}
93
94export const formatOutcomes = (outcomes: readonly CheckOutcome[]): string => {
95 const rows = outcomes.map(outcome => {
96 if (outcome.status === 'skipped') return `${outcome.name}: SKIPPED (${outcome.reason})`
97 return `${outcome.name}: ${outcome.status.toUpperCase()} in ${(outcome.durationMs / 1000).toFixed(1)}s (${outcome.command})`
98 })
99 const failures = outcomes.flatMap(outcome => (outcome.status === 'failed' ? [`\n${outcome.name} output:\n${outcome.output}`] : []))
100 const counts = (['passed', 'failed', 'skipped'] as const).map(status => `${outcomes.filter(outcome => outcome.status === status).length} ${status}`)
101 return [`/check: ${counts.join(', ')}`, ...rows, ...failures].join('\n')
102}
103hooks/project.ts 65 lines1// Pure reading of the project's package.json and git output; register.ts does the I/O.
2
3export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'
4
5export type Project = {
6 root: string
7 isReactNative: boolean
8 isExpo: boolean
9 scripts: Readonly<Record<string, string>>
10 packageManager: PackageManager
11}
12
13export const LOCKFILES: readonly (readonly [string, PackageManager])[] = [
14 ['bun.lock', 'bun'],
15 ['bun.lockb', 'bun'],
16 ['yarn.lock', 'yarn'],
17 ['pnpm-lock.yaml', 'pnpm'],
18]
19
20export const THEME_PATH = /(^|\/)(theme|colors?|tokens?|palette)\.(ts|tsx|js)$/i
21
22const isRecord = (value: unknown): value is Record<string, unknown> =>
23 typeof value === 'object' && value !== null && !Array.isArray(value)
24
25const stringRecord = (value: unknown): Record<string, string> =>
26 isRecord(value)
27 ? Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))
28 : {}
29
30/** The project described by its package.json text; undefined when the text is not a manifest. */
31export const parseProject = (root: string, manifestText: string, lockfile: string | undefined): Project | undefined => {
32 let manifest: unknown
33 try {
34 manifest = JSON.parse(manifestText)
35 } catch {
36 return undefined
37 }
38 if (!isRecord(manifest)) return undefined
39 const deps = { ...stringRecord(manifest.dependencies), ...stringRecord(manifest.devDependencies) }
40 return {
41 root,
42 isReactNative: 'react-native' in deps || 'expo' in deps,
43 isExpo: 'expo' in deps,
44 scripts: stringRecord(manifest.scripts),
45 packageManager: LOCKFILES.find(([file]) => file === lockfile)?.[1] ?? 'npm',
46 }
47}
48
49export const lines = (stdout: string): string[] => stdout.split('\n').filter(Boolean)
50
51/** Whether a QueryClient source sets a default staleTime. */
52export const setsDefaultStaleTime = (source: string): boolean => /defaultOptions[\s\S]*staleTime\s*:/.test(source)
53
54/** Files of `git grep -F <name>` rows (`file:text`) that declare an export of `name`, other than `exclude`. */
55export const exportingFiles = (stdout: string, name: string, exclude: string): string[] => {
56 const declares = new RegExp(String.raw`^\s*export\s+(default\s+)?(async\s+)?(function|const|let|class|interface|type|enum)\s+${name}\b`)
57 const files = new Set<string>()
58 for (const row of lines(stdout)) {
59 const split = row.indexOf(':')
60 const file = row.slice(0, split)
61 if (split > 0 && file !== exclude && !file.startsWith('node_modules/') && declares.test(row.slice(split + 1))) files.add(file)
62 }
63 return [...files]
64}
65hooks/rules.ts 299 lines1// Pure detectors: no `$`, no I/O. Each one reads the lines an edit adds and
2// answers findings; register.ts decides whether a finding blocks or warns.
3
4export type RuleId =
5 | 'any'
6 | 'ts-suppress'
7 | 'web-element'
8 | 'browser-api'
9 | 'router-convention'
10 | 'direct-http'
11 | 'effect-fetch'
12 | 'query-stale-time'
13 | 'query-key'
14 | 'query-global-refetch'
15 | 'store-server-data'
16 | 'badge'
17 | 'icons'
18 | 'color'
19 | 'component-size'
20 | 'duplicate-file'
21 | 'duplicate-export'
22
23export type Finding = { rule: RuleId; message: string }
24
25export type ThemeIndex = ReadonlyMap<string, string>
26
27export type EditFacts = {
28 /** Path relative to the project root, `/`-separated. */
29 path: string
30 /** File text before the edit; undefined when the file is new. */
31 before: string | undefined
32 after: string
33 theme: ThemeIndex
34 /** Whether a QueryClient in the project sets a default staleTime. */
35 hasGlobalStaleTime: boolean
36 maxComponentLines: number
37 maxIconsPerEdit: number
38}
39
40const CODE_FILE = /\.(ts|tsx|js|jsx|mts|cts)$/
41const TS_FILE = /\.(ts|tsx|mts|cts)$/
42const JSX_FILE = /\.(tsx|jsx)$/
43const WEB_ONLY_FILE = /(\.web\.(tsx|jsx|ts|js)|\/\+html\.(tsx|jsx))$/
44const SERVICE_LAYER = /(^|\/)(services?|api|apis|clients?|lib\/(api|http))\//
45const THEME_FILE = /(^|\/)(theme|colors?|tokens?|palette)(\.[a-z]+)?\.(ts|tsx|js)$/i
46const APP_DIR = /^(src\/)?app\//
47const IGNORED_DIR = /^(node_modules|\.expo|ios|android|dist|build|web-build)\//
48
49export const isAnalyzable = (path: string): boolean =>
50 CODE_FILE.test(path) && !IGNORED_DIR.test(path)
51
52/** Lines of `after` that `before` did not hold (a multiset diff, whitespace-trimmed). */
53export const addedLines = (before: string | undefined, after: string): string[] => {
54 const left = new Map<string, number>()
55 for (const line of (before ?? '').split('\n')) {
56 const key = line.trim()
57 left.set(key, (left.get(key) ?? 0) + 1)
58 }
59 const added: string[] = []
60 for (const line of after.split('\n')) {
61 const key = line.trim()
62 const count = left.get(key) ?? 0
63 if (count > 0) left.set(key, count - 1)
64 else if (key !== '') added.push(line)
65 }
66 return added
67}
68
69const stripComments = (line: string): string => {
70 const trimmed = line.trim()
71 if (trimmed.startsWith('*') || trimmed.startsWith('/*')) return ''
72 return line.replace(/(^|[^:])\/\/.*$/, '$1').replace(/\/\*.*?\*\//g, '')
73}
74
75const stripStrings = (line: string): string =>
76 line.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/g, '""')
77
78/** Code with comments and string contents removed: what the type and JSX rules read. */
79const codeOf = (lines: readonly string[]): string[] =>
80 lines.map(line => stripStrings(stripComments(line)))
81
82const ANY_PATTERNS = [/\bas\s+any\b/, /[:<,|&(=]\s*any\b(?!\s*[=:(.])/, /\bany\s*\[\s*\]/]
83
84export const findExplicitAny = (path: string, added: readonly string[]): string[] =>
85 TS_FILE.test(path)
86 ? codeOf(added)
87 .map((code, index) => ({ code, raw: added[index] ?? '' }))
88 .filter(({ code }) => ANY_PATTERNS.some(pattern => pattern.test(code)))
89 .map(({ raw }) => raw.trim())
90 : []
91
92/** A Bash command's package installs that bypass `npx expo install`. */
93export const findNonExpoInstalls = (command: string): string[] => {
94 const offending: string[] = []
95 for (const segment of command.split(/&&|\|\||;|\||\n/)) {
96 const words = segment.trim().split(/\s+/).filter(Boolean)
97 const [tool, sub, ...rest] = words
98 if (tool === undefined || sub === undefined) continue
99 const packages = rest.filter(word => !word.startsWith('-'))
100 const isGlobal = rest.some(word => word === '-g' || word === '--global')
101 if (packages.length === 0 || isGlobal) continue
102 const installs =
103 (tool === 'npm' && ['install', 'i', 'add', 'isntall'].includes(sub)) ||
104 (tool === 'yarn' && sub === 'add') ||
105 (tool === 'pnpm' && ['add', 'install', 'i'].includes(sub)) ||
106 (tool === 'bun' && ['add', 'install', 'i'].includes(sub))
107 if (installs) offending.push(segment.trim())
108 }
109 return offending
110}
111
112export const expoInstallHint = (segment: string): string => {
113 const words = segment.split(/\s+/).slice(2)
114 const isDev = words.some(word => ['-D', '--save-dev', '--dev', '-d'].includes(word))
115 const packages = words.filter(word => !word.startsWith('-'))
116 return `npx expo install ${packages.join(' ')}${isDev ? ' -- --save-dev' : ''}`
117}
118
119const WEB_ELEMENT =
120 /<(div|span|p|h[1-6]|button|input|textarea|select|option|img|a|ul|ol|li|section|header|footer|nav|main|article|aside|form|label|table|thead|tbody|tr|td|th|br|hr|iframe|video|audio|canvas|strong|em)(\s|>|\/)/
121const BROWSER_API = /\b(document\.|window\.(location|addEventListener|innerWidth|innerHeight)|localStorage|sessionStorage)\b/
122const HTTP_CALL = /\b(fetch\s*\(|axios(\.(get|post|put|patch|delete|request|head))?\s*\()/
123const EFFECT = /\buseEffect\s*\(/
124const ANY_HTTP = /\b(fetch\s*\(|axios\b|\bapi\.(get|post|put|patch|delete)\s*\()/
125const QUERY_HOOK = /\buse(Suspense)?(Infinite)?Query\s*\(/
126const UNSTABLE_KEY = /queryKey\s*:\s*\[[^\]]*\b(Date\.now|Math\.random|new Date)\b/
127const GLOBAL_REFETCH = /\b(refetchOnMount|refetchOnWindowFocus|refetchOnReconnect)\s*:\s*false\b|\b(staleTime|gcTime)\s*:\s*Infinity\b/
128const ZUSTAND_CREATE = /\bcreate\s*(<[^>]*>)?\s*\(\s*(\(|set\b)/
129const BADGE = /<\w*(Badge|Chip|Pill|Tag)\b|^\s*\w*(badge|chip|pill)\w*\s*:\s*\{/i
130const ICON =
131 /<(Ionicons|MaterialIcons|MaterialCommunityIcons|Feather|FontAwesome\d?|AntDesign|Entypo|EvilIcons|Octicons|SimpleLineIcons|Foundation|Icon|IconSymbol|SymbolView|\w+Icon)\b/g
132const HEX = /['"`](#[0-9a-fA-F]{3,8})['"`]/g
133const COLOR_FN = /\b(rgba?|hsla?)\s*\(/
134const NAMED_COLOR =
135 /\b\w*[cC]olor\s*[:=]\s*['"](?!transparent|inherit|currentColor)([a-zA-Z]+)['"]/
136
137const firstLine = (lines: readonly string[], pattern: RegExp): string | undefined =>
138 lines.find(line => pattern.test(line))?.trim()
139
140/** What every detector reads: the edit, its added lines, and the file's kind. */
141type Scope = EditFacts & {
142 added: string[]
143 code: string[]
144 withStrings: string[]
145 isJsx: boolean
146}
147
148type Detector = (scope: Scope) => Finding[]
149
150const found = (rule: RuleId, message: string | undefined): Finding[] =>
151 message === undefined ? [] : [{ rule, message }]
152
153const typeSafety: Detector = ({ path, added }) => {
154 const anyLine = findExplicitAny(path, added)[0]
155 const suppress = firstLine(added, /@ts-(ignore|nocheck)\b/)
156 return [
157 ...found('any', anyLine && `Explicit \`any\` added (${anyLine}). Use a precise type, a generic, or \`unknown\` narrowed by a type guard or a Zod schema.`),
158 ...found('ts-suppress', suppress && `Type errors silenced (${suppress}). Fix the type instead of suppressing it.`),
159 ]
160}
161
162const nativeOnly: Detector = ({ path, code, isJsx }) => {
163 if (!isJsx || WEB_ONLY_FILE.test(path)) return []
164 const element = code.map(line => WEB_ELEMENT.exec(line)?.[1]).find(name => name !== undefined)
165 const api = firstLine(code, BROWSER_API)
166 return [
167 ...found('web-element', element && `Web-only <${element}> in a native file. Use React Native primitives (View, Text, Pressable, Image from expo-image) or the project's themed components; put web-only code in a .web.tsx file.`),
168 ...found('browser-api', api && `Browser-only API in native code (${api}). Use the Expo/React Native equivalent or a .web.tsx file.`),
169 ]
170}
171
172const NON_ROUTE_DIR = /(^|\/)(components|hooks|utils|lib|services|api|store|stores|schemas|types|constants)\//
173
174const routerConvention: Detector = ({ path, before, after, isJsx }) => {
175 if (!APP_DIR.test(path) || before !== undefined) return []
176 const name = path.split('/').pop() ?? ''
177 const isPlainTs = name.endsWith('.ts') && !name.endsWith('+api.ts')
178 if (NON_ROUTE_DIR.test(path) || /^use[A-Z-]/.test(name) || isPlainTs)
179 return found('router-convention', `${path} is not a screen, but every file under app/ becomes a route in Expo Router. Move it to src/components, src/hooks, src/services, etc.`)
180 if (isJsx && !/export\s+default\b/.test(after))
181 return found('router-convention', `${path} is a route without a default export; Expo Router needs \`export default\` for the screen.`)
182 return []
183}
184
185const serverState: Detector = ({ path, after, code, withStrings, hasGlobalStaleTime }) => {
186 const call = SERVICE_LAYER.test(path) ? undefined : firstLine(code, HTTP_CALL)
187 const fetchesInEffect = code.some(line => EFFECT.test(line)) && code.some(line => ANY_HTTP.test(line))
188 const lacksStaleTime = code.some(line => QUERY_HOOK.test(line)) && !hasGlobalStaleTime && !/\bstaleTime\s*:/.test(after)
189 const key = firstLine(withStrings, UNSTABLE_KEY)
190 const global = /new QueryClient|defaultOptions/.test(after) ? firstLine(code, GLOBAL_REFETCH) : undefined
191 const storesServerData = /from\s+['"]zustand['"]/.test(after) && ZUSTAND_CREATE.test(after) && code.some(line => ANY_HTTP.test(line))
192 return [
193 ...found('direct-http', call && `Direct HTTP call outside the service layer (${call}). Put the request in a service (e.g. src/services) on the shared Axios instance and read it through a useQuery/useMutation hook.`),
194 ...found('effect-fetch', fetchesInEffect ? 'Data fetched inside useEffect bypasses TanStack Query (no cache, refetches on every mount). Use useQuery with a stable key instead.' : undefined),
195 ...found('query-stale-time', lacksStaleTime ? 'useQuery without a staleTime, and the QueryClient sets no default: the data counts as stale at once and refetches on every mount. Set a staleTime that fits how often this resource changes.' : undefined),
196 ...found('query-key', key && `Unstable query key (${key}): a new key each render means a new cache entry and a refetch every time. Build keys from stable inputs, ideally a shared key factory.`),
197 ...found('query-global-refetch', global && `Global refetch/cache default changed (${global}). This affects every query; confirm it is deliberate and prefer per-query options.`),
198 ...found('store-server-data', storesServerData ? 'API data fetched into a Zustand store. Keep server state in TanStack Query; use Zustand only for client state.' : undefined),
199 ]
200}
201
202const uiRules: Detector = ({ path, before, after, code, isJsx, maxIconsPerEdit, maxComponentLines }) => {
203 if (!isJsx) return []
204 const badge = firstLine(code, BADGE)
205 const icons = code.flatMap(line => [...line.matchAll(ICON)].map(match => match[1]))
206 const lines = after.split('\n').length
207 const linesBefore = before?.split('\n').length ?? 0
208 const isOversized = lines > maxComponentLines && lines > linesBefore
209 return [
210 ...found('badge', badge && `Badge/chip/tag added (${badge}). Project rule: no badges, chips or decorative tags above section headings; use a plain section title.`),
211 ...found('icons', icons.length > maxIconsPerEdit ? `${icons.length} icons added in one edit (${[...new Set(icons)].join(', ')}). Use icons only where they carry meaning.` : undefined),
212 ...found('component-size', isOversized ? `${path} is now ${lines} lines (limit ${maxComponentLines}). Split it into smaller components or move logic into hooks.` : undefined),
213 ]
214}
215
216const colorsIn = (line: string): string[] => {
217 const named = NAMED_COLOR.exec(line)?.[1]
218 return [
219 ...[...line.matchAll(HEX)].flatMap(match => (match[1] === undefined ? [] : [match[1]])),
220 ...(COLOR_FN.test(line) ? [line.trim()] : []),
221 ...(named === undefined ? [] : [named]),
222 ]
223}
224
225const themeColors: Detector = ({ path, withStrings, theme }) => {
226 if (THEME_FILE.test(path)) return []
227 const colors = [...new Set(withStrings.flatMap(colorsIn))]
228 if (colors.length === 0) return []
229 const described = colors.slice(0, 5).map(color => {
230 const token = theme.get(color.toLowerCase())
231 return token === undefined ? `${color} (not in the theme)` : `${color} (use ${token})`
232 })
233 return found('color', `Hard-coded color${colors.length > 1 ? 's' : ''}: ${described.join(', ')}. Use the theme tokens (useTheme / Colors in constants/theme); do not add colors outside the palette.`)
234}
235
236const DETECTORS: readonly Detector[] = [typeSafety, nativeOnly, routerConvention, serverState, uiRules, themeColors]
237
238export const analyzeEdit = (facts: EditFacts): Finding[] => {
239 if (!isAnalyzable(facts.path)) return []
240 const added = addedLines(facts.before, facts.after)
241 if (added.length === 0) return []
242 const scope: Scope = {
243 ...facts,
244 added,
245 code: codeOf(added),
246 withStrings: added.map(stripComments),
247 isJsx: JSX_FILE.test(facts.path),
248 }
249 return DETECTORS.flatMap(detect => detect(scope))
250}
251
252/** Names a set of added lines exports: candidates for the duplicate-export check. */
253export const exportedNames = (added: readonly string[]): string[] => {
254 const names = new Set<string>()
255 for (const line of codeOf(added)) {
256 const match = /^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function|const|let|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/.exec(line)
257 if (match?.[1] !== undefined && match[1].length > 2) names.add(match[1])
258 }
259 return [...names].slice(0, 10)
260}
261
262/** A file's name with its extension and platform suffix removed (`button.web.tsx` -> `button`). */
263export const baseName = (path: string): string =>
264 (path.split('/').pop() ?? '').replace(/\.(ios|android|native|web)?\.?(tsx|ts|jsx|js)$/, '').replace(/\.$/, '')
265
266const GENERIC_NAMES = new Set(['index', '_layout', 'types', 'constants', 'styles', 'utils', '+html', '+not-found'])
267
268export const duplicateFiles = (path: string, tracked: readonly string[]): string[] => {
269 const name = baseName(path)
270 if (GENERIC_NAMES.has(name) || name.startsWith('[')) return []
271 return tracked.filter(other => other !== path && isAnalyzable(other) && baseName(other) === name)
272}
273
274/** Maps each lower-cased hex color in a theme file to the token path that holds it. */
275export const indexTheme = (source: string): Map<string, string> => {
276 const index = new Map<string, string>()
277 const stack: string[] = []
278 for (const line of source.split('\n')) {
279 const open = /^\s*(?:export\s+const\s+)?([A-Za-z_$][\w$]*)\s*[:=]\s*\{\s*$/.exec(line)
280 if (open?.[1] !== undefined) {
281 stack.push(open[1])
282 continue
283 }
284 const value = /^\s*([A-Za-z_$][\w$]*)\s*:\s*['"](#[0-9a-fA-F]{3,8})['"]/.exec(line)
285 if (value?.[1] !== undefined && value[2] !== undefined) {
286 const key = value[2].toLowerCase()
287 if (!index.has(key)) index.set(key, [...stack, value[1]].join('.'))
288 }
289 if (/^\s*\}/.test(line)) stack.pop()
290 }
291 return index
292}
293
294export const formatFindings = (path: string, findings: readonly Finding[]): string =>
295 [
296 `react-native-code-guardian found ${findings.length} rule issue${findings.length > 1 ? 's' : ''} in ${path}. Fix them where they apply, and tell the user about any you leave on purpose:`,
297 ...findings.map(finding => `- [${finding.rule}] ${finding.message}`),
298 ].join('\n')
299