1use crate::cli::toml_config::TomlRule;
2use std::collections::HashMap;
3use std::fmt;
4
5#[derive(Debug)]
6pub enum PresetError {
7 UnknownPreset {
8 name: String,
9 available: Vec<&'static str>,
10 },
11}
12
13impl fmt::Display for PresetError {
14 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15 match self {
16 PresetError::UnknownPreset { name, available } => {
17 write!(
18 f,
19 "unknown preset '{}'. available presets: {}",
20 name,
21 available.join(", ")
22 )
23 }
24 }
25 }
26}
27
28impl std::error::Error for PresetError {}
29
30#[derive(Debug, Clone, Copy)]
31enum Preset {
32 ShadcnStrict,
33 ShadcnMigrate,
34 AiSafety,
35 Security,
36 Nextjs,
37 AiCodegen,
38 React,
39 NextjsBestPractices,
40 Accessibility,
41 ReactNative,
42}
43
44pub fn available_presets() -> &'static [&'static str] {
46 &[
47 "shadcn-strict",
48 "shadcn-migrate",
49 "ai-safety",
50 "security",
51 "nextjs",
52 "ai-codegen",
53 "react",
54 "nextjs-best-practices",
55 "accessibility",
56 "react-native",
57 ]
58}
59
60fn resolve_preset(name: &str) -> Option<Preset> {
61 match name {
62 "shadcn-strict" => Some(Preset::ShadcnStrict),
63 "shadcn-migrate" => Some(Preset::ShadcnMigrate),
64 "ai-safety" => Some(Preset::AiSafety),
65 "security" => Some(Preset::Security),
66 "nextjs" => Some(Preset::Nextjs),
67 "ai-codegen" => Some(Preset::AiCodegen),
68 "react" => Some(Preset::React),
69 "nextjs-best-practices" => Some(Preset::NextjsBestPractices),
70 "accessibility" => Some(Preset::Accessibility),
71 "react-native" => Some(Preset::ReactNative),
72 _ => None,
73 }
74}
75
76fn preset_rules(preset: Preset) -> Vec<TomlRule> {
77 match preset {
78 Preset::ShadcnStrict => vec![
79 TomlRule {
80 id: "enforce-dark-mode".into(),
81 rule_type: "tailwind-dark-mode".into(),
82 severity: "error".into(),
83 glob: Some("**/*.{tsx,jsx}".into()),
84 message: "Missing dark: variant for color class".into(),
85 suggest: Some(
86 "Use a shadcn semantic token class or add an explicit dark: counterpart"
87 .into(),
88 ),
89 ..Default::default()
90 },
91 TomlRule {
92 id: "use-theme-tokens".into(),
93 rule_type: "tailwind-theme-tokens".into(),
94 severity: "error".into(),
95 glob: Some("**/*.{tsx,jsx}".into()),
96 message: "Use shadcn semantic token instead of raw color".into(),
97 ..Default::default()
98 },
99 TomlRule {
100 id: "no-inline-styles".into(),
101 rule_type: "banned-pattern".into(),
102 severity: "warning".into(),
103 glob: Some("**/*.{tsx,jsx}".into()),
104 pattern: Some("style={{".into()),
105 message: "Avoid inline styles — use Tailwind utility classes instead".into(),
106 suggest: Some("Replace style={{ ... }} with Tailwind classes".into()),
107 ..Default::default()
108 },
109 TomlRule {
110 id: "no-css-in-js".into(),
111 rule_type: "banned-import".into(),
112 severity: "error".into(),
113 packages: vec![
114 "styled-components".into(),
115 "@emotion/styled".into(),
116 "@emotion/css".into(),
117 "@emotion/react".into(),
118 ],
119 message: "CSS-in-JS libraries conflict with Tailwind — use utility classes instead"
120 .into(),
121 ..Default::default()
122 },
123 TomlRule {
124 id: "no-competing-frameworks".into(),
125 rule_type: "banned-dependency".into(),
126 severity: "error".into(),
127 packages: vec![
128 "bootstrap".into(),
129 "bulma".into(),
130 "@mui/material".into(),
131 "antd".into(),
132 ],
133 message:
134 "Competing CSS framework detected — this project uses Tailwind + shadcn/ui"
135 .into(),
136 ..Default::default()
137 },
138 ],
139 Preset::ShadcnMigrate => vec![
140 TomlRule {
141 id: "enforce-dark-mode".into(),
142 rule_type: "tailwind-dark-mode".into(),
143 severity: "error".into(),
144 glob: Some("**/*.{tsx,jsx}".into()),
145 message: "Missing dark: variant for color class".into(),
146 suggest: Some(
147 "Use a shadcn semantic token class or add an explicit dark: counterpart"
148 .into(),
149 ),
150 ..Default::default()
151 },
152 TomlRule {
153 id: "use-theme-tokens".into(),
154 rule_type: "tailwind-theme-tokens".into(),
155 severity: "warning".into(),
156 glob: Some("**/*.{tsx,jsx}".into()),
157 message: "Use shadcn semantic token instead of raw color".into(),
158 ..Default::default()
159 },
160 ],
161 Preset::AiSafety => vec![
162 TomlRule {
163 id: "no-moment".into(),
164 rule_type: "banned-dependency".into(),
165 severity: "error".into(),
166 packages: vec!["moment".into(), "moment-timezone".into()],
167 message: "moment.js is deprecated — use date-fns or Temporal API".into(),
168 ..Default::default()
169 },
170 TomlRule {
171 id: "no-lodash".into(),
172 rule_type: "banned-dependency".into(),
173 severity: "error".into(),
174 packages: vec!["lodash".into()],
175 message: "lodash is unnecessary — use native JS methods".into(),
176 ..Default::default()
177 },
178 TomlRule {
179 id: "no-deprecated-request".into(),
180 rule_type: "banned-dependency".into(),
181 severity: "error".into(),
182 packages: vec!["request".into(), "request-promise".into()],
183 message: "The 'request' package is deprecated — use 'node-fetch' or 'undici'".into(),
184 ..Default::default()
185 },
186 ],
187 Preset::Security => vec![
188 TomlRule {
189 id: "no-env-files".into(),
190 rule_type: "file-presence".into(),
191 severity: "error".into(),
192 forbidden_files: vec![
193 ".env".into(),
194 ".env.local".into(),
195 ".env.development".into(),
196 ".env.production".into(),
197 ".env.staging".into(),
198 ],
199 message: "Environment files must not be committed — add to .gitignore".into(),
200 ..Default::default()
201 },
202 TomlRule {
203 id: "no-hardcoded-secrets".into(),
204 rule_type: "banned-pattern".into(),
205 severity: "error".into(),
206 pattern: Some(r#"(?i)(?:api_key|apikey|secret_key|secretkey|auth_token|access_token|private_key|password|passwd|secret|client_secret)\s*[:=]\s*["'][a-zA-Z0-9_\-]{8,}"#.into()),
207 regex: true,
208 exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
209 message: "Hardcoded secret detected — use environment variables instead".into(),
210 ..Default::default()
211 },
212 TomlRule {
213 id: "no-eval".into(),
214 rule_type: "banned-pattern".into(),
215 severity: "error".into(),
216 pattern: Some(r"\beval\s*\(".into()),
217 regex: true,
218 message: "eval() is a security risk — avoid arbitrary code execution".into(),
219 ..Default::default()
220 },
221 TomlRule {
222 id: "no-dangerous-html".into(),
223 rule_type: "banned-pattern".into(),
224 severity: "error".into(),
225 pattern: Some("dangerouslySetInnerHTML".into()),
226 message: "dangerouslySetInnerHTML can lead to XSS — sanitize content or use a safe alternative".into(),
227 ..Default::default()
228 },
229 TomlRule {
230 id: "no-innerhtml".into(),
231 rule_type: "banned-pattern".into(),
232 severity: "error".into(),
233 pattern: Some(r"\.innerHTML\s*\+?=".into()),
234 regex: true,
235 message: "Direct innerHTML assignment can lead to XSS — use textContent or a sanitizer".into(),
236 ..Default::default()
237 },
238 TomlRule {
239 id: "no-console-log".into(),
240 rule_type: "banned-pattern".into(),
241 severity: "warning".into(),
242 pattern: Some(r"console\.(log|debug)\(".into()),
243 regex: true,
244 exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
245 message: "Remove console.log/debug before deploying to production".into(),
246 ..Default::default()
247 },
248 TomlRule {
249 id: "no-document-write".into(),
250 rule_type: "banned-pattern".into(),
251 severity: "error".into(),
252 pattern: Some(r"document\.write\s*\(".into()),
253 regex: true,
254 message: "document.write() is an XSS risk and blocks rendering — use DOM APIs instead".into(),
255 ..Default::default()
256 },
257 TomlRule {
258 id: "no-postmessage-wildcard".into(),
259 rule_type: "banned-pattern".into(),
260 severity: "error".into(),
261 pattern: Some(r#"\.postMessage\(.*,\s*['"]\*['"]"#.into()),
262 regex: true,
263 message: "postMessage with '*' origin exposes data to any window — specify the target origin".into(),
264 ..Default::default()
265 },
266 TomlRule {
267 id: "no-outerhtml".into(),
268 rule_type: "banned-pattern".into(),
269 severity: "error".into(),
270 pattern: Some(r"\.outerHTML\s*\+?=".into()),
271 regex: true,
272 message: "Direct outerHTML assignment can lead to XSS — use DOM APIs or a sanitizer".into(),
273 ..Default::default()
274 },
275 TomlRule {
276 id: "no-http-links".into(),
277 rule_type: "banned-pattern".into(),
278 severity: "warning".into(),
279 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
280 pattern: Some(r#"['"]http://"#.into()),
281 regex: true,
282 exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
283 message: "Insecure http:// URL — use https:// instead".into(),
284 ..Default::default()
285 },
286 TomlRule {
287 id: "no-paste-prevention".into(),
288 rule_type: "banned-pattern".into(),
289 severity: "warning".into(),
290 pattern: Some(r"onPaste[^=]*=[^;]*preventDefault".into()),
291 regex: true,
292 message: "Preventing paste harms accessibility and password manager users".into(),
293 suggest: Some("Remove onPaste preventDefault — let users paste freely".into()),
294 ..Default::default()
295 },
296 ],
297 Preset::Nextjs => vec![
298 TomlRule {
299 id: "use-next-image".into(),
300 rule_type: "banned-pattern".into(),
301 severity: "warning".into(),
302 glob: Some("**/*.{tsx,jsx}".into()),
303 pattern: Some(r"<img\s".into()),
304 regex: true,
305 message: "Use next/image instead of <img> for automatic optimization".into(),
306 suggest: Some("Import Image from 'next/image' and use <Image> component".into()),
307 ..Default::default()
308 },
309 TomlRule {
310 id: "no-next-head".into(),
311 rule_type: "banned-import".into(),
312 severity: "error".into(),
313 glob: Some("app/**".into()),
314 packages: vec!["next/head".into()],
315 message: "next/head is not supported in App Router — use the Metadata API instead".into(),
316 ..Default::default()
317 },
318 TomlRule {
319 id: "no-private-env-client".into(),
320 rule_type: "banned-pattern".into(),
321 severity: "error".into(),
322 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
323 pattern: Some(r"process\.env\.(?:[A-MO-Za-z_]\w*|N[A-DF-Za-z0-9_]\w*|NE[A-WYZa-z0-9_]\w*|NEX[A-SU-Za-z0-9_]\w*|NEXT[A-Za-z0-9]\w*|NEXT_[A-OQ-Za-z0-9_]\w*|NEXT_P[A-TV-Za-z0-9_]\w*|NEXT_PU[A-AC-Za-z0-9_]\w*|NEXT_PUB[A-KM-Za-z0-9_]\w*|NEXT_PUBL[A-HJ-Za-z0-9_]\w*|NEXT_PUBLI[A-BD-Za-z0-9_]\w*|NEXT_PUBLIC[A-Za-z0-9]\w*)".into()),
325 regex: true,
326 file_contains: Some("use client".into()),
327 message: "Private env vars are undefined in client components — prefix with NEXT_PUBLIC_".into(),
328 ..Default::default()
329 },
330 TomlRule {
331 id: "require-use-client-for-hooks".into(),
332 rule_type: "required-pattern".into(),
333 severity: "error".into(),
334 glob: Some("app/**".into()),
335 pattern: Some("use client".into()),
336 regex: true,
337 condition_pattern: Some(r"use(State|Effect|Context|Reducer|Callback|Memo|Ref|Transition|DeferredValue|InsertionEffect|SyncExternalStore|FormStatus|Optimistic)\s*\(".into()),
338 message: "Files using React hooks must include 'use client' directive in App Router".into(),
339 ..Default::default()
340 },
341 TomlRule {
342 id: "use-next-link".into(),
343 rule_type: "banned-pattern".into(),
344 severity: "warning".into(),
345 glob: Some("**/*.{tsx,jsx}".into()),
346 pattern: Some(r#"<a\s+href=["']/"#.into()),
347 regex: true,
348 message: "Use next/link instead of <a> for client-side navigation".into(),
349 suggest: Some("Import Link from 'next/link' and use <Link> component".into()),
350 ..Default::default()
351 },
352 TomlRule {
353 id: "no-next-router-in-app".into(),
354 rule_type: "banned-import".into(),
355 severity: "error".into(),
356 glob: Some("app/**".into()),
357 packages: vec!["next/router".into()],
358 message: "next/router is not available in App Router — use next/navigation instead".into(),
359 ..Default::default()
360 },
361 TomlRule {
362 id: "no-sync-scripts".into(),
363 rule_type: "banned-pattern".into(),
364 severity: "warning".into(),
365 glob: Some("**/*.{tsx,jsx}".into()),
366 pattern: Some(r"<script\s".into()),
367 regex: true,
368 message: "Use next/script instead of <script> for optimized script loading".into(),
369 suggest: Some("Import Script from 'next/script' and use <Script> component".into()),
370 ..Default::default()
371 },
372 TomlRule {
373 id: "no-link-fonts".into(),
374 rule_type: "banned-pattern".into(),
375 severity: "warning".into(),
376 glob: Some("**/*.{tsx,jsx}".into()),
377 pattern: Some(r"<link[^>]*fonts\.googleapis\.com".into()),
378 regex: true,
379 message: "Use next/font instead of Google Fonts <link> for zero layout shift".into(),
380 suggest: Some("Import from 'next/font/google' for automatic font optimization".into()),
381 ..Default::default()
382 },
383 ],
384 Preset::React => {
385 #[allow(unused_mut)]
386 let mut rules = vec![
387 TomlRule {
389 id: "no-array-index-key".into(),
390 rule_type: "banned-pattern".into(),
391 severity: "error".into(),
392 glob: Some("**/*.{tsx,jsx}".into()),
393 pattern: Some(r"key=\{[a-zA-Z_]*[iI](?:ndex|dx)".into()),
394 regex: true,
395 message: "Don't use array index as key — causes bugs on reorder/filter".into(),
396 suggest: Some("Use a stable unique identifier from the data instead".into()),
397 ..Default::default()
398 },
399 TomlRule {
400 id: "no-conditional-render-zero".into(),
401 rule_type: "banned-pattern".into(),
402 severity: "warning".into(),
403 glob: Some("**/*.{tsx,jsx}".into()),
404 pattern: Some(r"\{\s*\w+\.length\s*&&".into()),
405 regex: true,
406 message: "array.length && <JSX> renders '0' when empty — use array.length > 0".into(),
407 suggest: Some("Replace {arr.length && ...} with {arr.length > 0 && ...}".into()),
408 ..Default::default()
409 },
410 #[cfg(not(feature = "ast"))]
412 TomlRule {
413 id: "no-nested-component-def".into(),
414 rule_type: "banned-pattern".into(),
415 severity: "error".into(),
416 glob: Some("**/*.{tsx,jsx}".into()),
417 pattern: Some(r"^\s+(?:const|let|function)\s+[A-Z][a-zA-Z0-9]*\s*(?::\s*React\.FC|=\s*(?:\([^)]*\)|[a-zA-Z_]\w*)\s*(?::\s*[A-Za-z<>\[\]|&, ]+)?\s*=>|=\s*function|\()".into()),
418 regex: true,
419 message: "Component defined inside another component — causes remounting on every render".into(),
420 suggest: Some("Move component definition to module scope or extract to a separate file".into()),
421 ..Default::default()
422 },
423 #[cfg(feature = "ast")]
424 TomlRule {
425 id: "no-nested-component-def".into(),
426 rule_type: "no-nested-components".into(),
427 severity: "error".into(),
428 glob: Some("**/*.{tsx,jsx}".into()),
429 message: "Component defined inside another component — causes remounting on every render".into(),
430 suggest: Some("Move component definition to module scope or extract to a separate file".into()),
431 ..Default::default()
432 },
433 TomlRule {
435 id: "no-dangerous-html".into(),
436 rule_type: "banned-pattern".into(),
437 severity: "warning".into(),
438 glob: Some("**/*.{tsx,jsx}".into()),
439 pattern: Some("dangerouslySetInnerHTML".into()),
440 message: "dangerouslySetInnerHTML can lead to XSS — sanitize content or use a safe alternative".into(),
441 ..Default::default()
442 },
443 TomlRule {
445 id: "no-full-lodash-import".into(),
446 rule_type: "banned-import".into(),
447 severity: "warning".into(),
448 packages: vec!["lodash".into()],
449 message: "Importing all of lodash (~70kb) — use lodash-es or per-function imports like lodash/debounce".into(),
450 ..Default::default()
451 },
452 TomlRule {
453 id: "no-moment".into(),
454 rule_type: "banned-import".into(),
455 severity: "warning".into(),
456 packages: vec!["moment".into(), "moment-timezone".into()],
457 message: "moment.js is 300kb+ and deprecated — use date-fns, dayjs, or Temporal API".into(),
458 ..Default::default()
459 },
460 TomlRule {
461 id: "no-moment-dep".into(),
462 rule_type: "banned-dependency".into(),
463 severity: "warning".into(),
464 packages: vec!["moment".into(), "moment-timezone".into()],
465 message: "moment.js is 300kb+ and deprecated — use date-fns, dayjs, or Temporal API".into(),
466 ..Default::default()
467 },
468 TomlRule {
469 id: "no-new-function".into(),
470 rule_type: "banned-pattern".into(),
471 severity: "error".into(),
472 pattern: Some(r"\bnew\s+Function\s*\(".into()),
473 regex: true,
474 message: "new Function() is equivalent to eval() — avoid dynamic code execution".into(),
475 ..Default::default()
476 },
477 TomlRule {
479 id: "no-transition-all".into(),
480 rule_type: "banned-pattern".into(),
481 severity: "warning".into(),
482 glob: Some("**/*.{tsx,jsx}".into()),
483 pattern: Some(r#"transition:\s*["']all"#.into()),
484 regex: true,
485 message: "transition: 'all' is expensive — list specific properties to transition".into(),
486 ..Default::default()
487 },
488 TomlRule {
489 id: "no-layout-animation".into(),
490 rule_type: "banned-pattern".into(),
491 severity: "warning".into(),
492 glob: Some("**/*.{tsx,jsx,css}".into()),
493 pattern: Some(r"(?:animation|transition)(?:-property)?:\s*(?:.*\b(?:width|height|top|left|right|bottom|margin|padding)\b)".into()),
494 regex: true,
495 message: "Animating layout properties (width/height/margin) triggers expensive reflows — use transform instead".into(),
496 suggest: Some("Use transform: scale() or translate() for smooth GPU-accelerated animations".into()),
497 ..Default::default()
498 },
499 TomlRule {
501 id: "no-sequential-await".into(),
502 rule_type: "window-pattern".into(),
503 severity: "warning".into(),
504 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
505 pattern: Some(r"^\s*(?:const\s+\w+\s*=\s*)?await\s".into()),
506 condition_pattern: Some(r"^\s*(?:const\s+\w+\s*=\s*)?await\s".into()),
507 max_count: Some(3),
508 regex: true,
509 message: "Sequential await statements may run slower than necessary — use Promise.all() for independent operations".into(),
510 suggest: Some("const [a, b] = await Promise.all([fetchA(), fetchB()])".into()),
511 ..Default::default()
512 },
513 TomlRule {
515 id: "no-derived-state-effect".into(),
516 rule_type: "banned-pattern".into(),
517 severity: "warning".into(),
518 glob: Some("**/*.{tsx,jsx}".into()),
519 pattern: Some(r"useEffect\(\(\)\s*(?:=>)?\s*\{?\s*set[A-Z]\w*\(".into()),
520 regex: true,
521 message: "useEffect that only calls setState is derived state — compute during render instead".into(),
522 suggest: Some("Replace with: const derived = useMemo(() => compute(dep), [dep])".into()),
523 ..Default::default()
524 },
525 TomlRule {
526 id: "no-fetch-in-effect".into(),
527 rule_type: "banned-pattern".into(),
528 severity: "warning".into(),
529 glob: Some("**/*.{tsx,jsx}".into()),
530 pattern: Some(r"useEffect\([^)]*\(\)\s*(?:=>)?\s*\{[^}]*\bfetch\s*\(".into()),
531 regex: true,
532 message: "Avoid fetch() inside useEffect — use a data-fetching library (React Query, SWR) or server components".into(),
533 ..Default::default()
534 },
535 TomlRule {
536 id: "no-lazy-state-init".into(),
537 rule_type: "banned-pattern".into(),
538 severity: "warning".into(),
539 glob: Some("**/*.{tsx,jsx}".into()),
540 pattern: Some(r"useState\(\w+\(.*\)\)".into()),
541 regex: true,
542 message: "Expensive function call in useState runs every render — use lazy initializer: useState(() => fn())".into(),
543 suggest: Some("Wrap in a function: useState(() => computeValue()) for one-time initialization".into()),
544 ..Default::default()
545 },
546 TomlRule {
547 id: "no-object-dep-array".into(),
548 rule_type: "banned-pattern".into(),
549 severity: "warning".into(),
550 glob: Some("**/*.{tsx,jsx}".into()),
551 pattern: Some(r"(?:useEffect|useMemo|useCallback)\([^)]+,\s*\[[^\]]*(?:\{[^}]*\}|\[[^\]]*\])".into()),
552 regex: true,
553 message: "Object/array literal in dependency array creates a new reference every render — extract to useMemo or a ref".into(),
554 ..Default::default()
555 },
556 TomlRule {
557 id: "no-default-object-prop".into(),
558 rule_type: "banned-pattern".into(),
559 severity: "warning".into(),
560 glob: Some("**/*.{tsx,jsx}".into()),
561 pattern: Some(r"(?:function\s+[A-Z]|const\s+[A-Z]\w*\s*=)\s*.*(?:\{\s*\w+\s*=\s*(?:\{\}|\[\])\s*[,}])".into()),
562 regex: true,
563 message: "Default {} or [] in component params creates a new reference every render — extract to a module-level constant".into(),
564 ..Default::default()
565 },
566 TomlRule {
568 id: "no-forwardref".into(),
569 rule_type: "banned-pattern".into(),
570 severity: "warning".into(),
571 glob: Some("**/*.{tsx,jsx}".into()),
572 pattern: Some(r"\bforwardRef\s*[<(]".into()),
573 regex: true,
574 message: "forwardRef is unnecessary in React 19 — ref is a regular prop now".into(),
575 suggest: Some("Accept ref as a prop directly: function Component({ ref, ...props })".into()),
576 ..Default::default()
577 },
578 TomlRule {
579 id: "no-use-context".into(),
580 rule_type: "banned-pattern".into(),
581 severity: "warning".into(),
582 glob: Some("**/*.{tsx,jsx}".into()),
583 pattern: Some(r"\buseContext\s*\(".into()),
584 regex: true,
585 message: "useContext is replaced by use() in React 19".into(),
586 suggest: Some("Replace useContext(MyContext) with use(MyContext)".into()),
587 ..Default::default()
588 },
589 TomlRule {
591 id: "no-unsafe-createcontext-default".into(),
592 rule_type: "banned-pattern".into(),
593 severity: "warning".into(),
594 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
595 pattern: Some(r#"createContext\s*\(\s*(?:\{\}|\[\]|undefined|0|''|"")\s*\)"#.into()),
596 regex: true,
597 message: "Unsafe createContext default value — use null and handle the missing-provider case".into(),
598 suggest: Some("Use createContext<T>(null) and throw in a custom hook if context is null".into()),
599 ..Default::default()
600 },
601 TomlRule {
602 id: "no-effect-callback-sync".into(),
603 rule_type: "banned-pattern".into(),
604 severity: "warning".into(),
605 glob: Some("**/*.{tsx,jsx}".into()),
606 pattern: Some(r"useEffect\(\(\)\s*(?:=>)?\s*\{?\s*on[A-Z]\w*\(".into()),
607 regex: true,
608 message: "Calling event callbacks directly in useEffect may indicate misuse — effects should synchronize, not fire events".into(),
609 suggest: Some("Move the callback invocation to a user action handler or derive state instead".into()),
610 ..Default::default()
611 },
612 TomlRule {
613 id: "no-usestate-localstorage-eager".into(),
614 rule_type: "banned-pattern".into(),
615 severity: "warning".into(),
616 glob: Some("**/*.{tsx,jsx}".into()),
617 pattern: Some(r"useState\(\s*(?:JSON\.parse\s*\()?localStorage\.getItem\(".into()),
618 regex: true,
619 message: "localStorage.getItem in useState runs on every render and breaks SSR — use a lazy initializer".into(),
620 suggest: Some("Use useState(() => localStorage.getItem(...)) for lazy initialization".into()),
621 ..Default::default()
622 },
623 TomlRule {
625 id: "no-regexp-in-render".into(),
626 rule_type: "banned-pattern".into(),
627 severity: "warning".into(),
628 glob: Some("**/*.{tsx,jsx}".into()),
629 pattern: Some(r"new\s+RegExp\s*\(".into()),
630 regex: true,
631 message: "new RegExp() in a component body re-compiles every render — extract to module scope or useMemo".into(),
632 suggest: Some("Move the RegExp to module scope: const MY_RE = new RegExp(...)".into()),
633 ..Default::default()
634 },
635 TomlRule {
636 id: "no-lucide-barrel".into(),
637 rule_type: "banned-pattern".into(),
638 severity: "warning".into(),
639 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]lucide-react['"]"#.into()),
640 regex: true,
641 message: "Barrel import from lucide-react pulls in all icons — use lucide-react/icons/IconName".into(),
642 suggest: Some("Import specific icons: import { Icon } from 'lucide-react/icons/Icon'".into()),
643 ..Default::default()
644 },
645 TomlRule {
646 id: "no-mui-barrel".into(),
647 rule_type: "banned-pattern".into(),
648 severity: "warning".into(),
649 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]@mui/material['"]"#.into()),
650 regex: true,
651 message: "Barrel import from @mui/material increases bundle size — use deep imports".into(),
652 suggest: Some("Import specific components: import Button from '@mui/material/Button'".into()),
653 ..Default::default()
654 },
655 TomlRule {
656 id: "no-mui-icons-barrel".into(),
657 rule_type: "banned-pattern".into(),
658 severity: "warning".into(),
659 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]@mui/icons-material['"]"#.into()),
660 regex: true,
661 message: "Barrel import from @mui/icons-material increases bundle size — use deep imports".into(),
662 suggest: Some("Import specific icons: import HomeIcon from '@mui/icons-material/Home'".into()),
663 ..Default::default()
664 },
665 TomlRule {
666 id: "no-react-icons-barrel".into(),
667 rule_type: "banned-pattern".into(),
668 severity: "warning".into(),
669 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]react-icons['"]"#.into()),
670 regex: true,
671 message: "Barrel import from react-icons pulls in all icon sets — import from a specific set".into(),
672 suggest: Some("Import from a specific set: import { FaHome } from 'react-icons/fa'".into()),
673 ..Default::default()
674 },
675 TomlRule {
676 id: "no-date-fns-barrel".into(),
677 rule_type: "banned-pattern".into(),
678 severity: "warning".into(),
679 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]date-fns['"]"#.into()),
680 regex: true,
681 message: "Barrel import from date-fns increases bundle size — use subpath imports".into(),
682 suggest: Some("Import specific functions: import { format } from 'date-fns/format'".into()),
683 ..Default::default()
684 },
685 ];
686
687 #[cfg(feature = "ast")]
689 {
690 rules.push(TomlRule {
691 id: "max-component-size".into(),
692 rule_type: "max-component-size".into(),
693 severity: "warning".into(),
694 glob: Some("**/*.{tsx,jsx}".into()),
695 max_count: Some(150),
696 message: "Component exceeds 150 lines — split into smaller components".into(),
697 suggest: Some("Extract logic into custom hooks or break into sub-components".into()),
698 ..Default::default()
699 });
700 rules.push(TomlRule {
701 id: "prefer-use-reducer".into(),
702 rule_type: "prefer-use-reducer".into(),
703 severity: "warning".into(),
704 glob: Some("**/*.{tsx,jsx}".into()),
705 max_count: Some(4),
706 message: "Component has 4+ useState calls — consider useReducer for related state".into(),
707 suggest: Some("Group related state into a single useReducer".into()),
708 ..Default::default()
709 });
710 rules.push(TomlRule {
711 id: "no-cascading-set-state".into(),
712 rule_type: "no-cascading-set-state".into(),
713 severity: "warning".into(),
714 glob: Some("**/*.{tsx,jsx}".into()),
715 max_count: Some(3),
716 message: "useEffect has 3+ setState calls — consider useReducer or derived state".into(),
717 suggest: Some("Combine state updates with useReducer or compute derived values".into()),
718 ..Default::default()
719 });
720 }
721
722 rules
723 }
724 Preset::NextjsBestPractices => {
725 #[allow(unused_mut)]
726 let mut rules = vec![
727 TomlRule {
729 id: "use-next-image".into(),
730 rule_type: "banned-pattern".into(),
731 severity: "warning".into(),
732 glob: Some("**/*.{tsx,jsx}".into()),
733 pattern: Some(r"<img\s".into()),
734 regex: true,
735 exclude_glob: vec!["**/opengraph-image.*".into(), "**/og/**".into()],
736 message: "Use next/image instead of <img> for automatic optimization".into(),
737 suggest: Some("Import Image from 'next/image' and use <Image> component".into()),
738 ..Default::default()
739 },
740 TomlRule {
741 id: "next-image-fill-needs-sizes".into(),
742 rule_type: "window-pattern".into(),
743 severity: "warning".into(),
744 glob: Some("**/*.{tsx,jsx}".into()),
745 pattern: Some(r"<Image[^>]*\bfill\b".into()),
746 condition_pattern: Some(r"\bsizes\s*=".into()),
747 max_count: Some(3),
748 regex: true,
749 message: "<Image fill> without sizes attribute downloads unnecessarily large images".into(),
750 suggest: Some("Add sizes prop, e.g. sizes=\"(max-width: 768px) 100vw, 50vw\"".into()),
751 ..Default::default()
752 },
753 TomlRule {
755 id: "use-next-link".into(),
756 rule_type: "banned-pattern".into(),
757 severity: "warning".into(),
758 glob: Some("**/*.{tsx,jsx}".into()),
759 pattern: Some(r#"<a\s+href=["']/"#.into()),
760 regex: true,
761 message: "Use next/link instead of <a> for client-side navigation".into(),
762 suggest: Some("Import Link from 'next/link' and use <Link> component".into()),
763 ..Default::default()
764 },
765 TomlRule {
766 id: "no-next-router-in-app".into(),
767 rule_type: "banned-import".into(),
768 severity: "error".into(),
769 glob: Some("app/**".into()),
770 packages: vec!["next/router".into()],
771 message: "next/router is not available in App Router — use next/navigation instead".into(),
772 ..Default::default()
773 },
774 TomlRule {
775 id: "no-next-head".into(),
776 rule_type: "banned-import".into(),
777 severity: "error".into(),
778 glob: Some("app/**".into()),
779 packages: vec!["next/head".into()],
780 message: "next/head is not supported in App Router — use the Metadata API instead".into(),
781 ..Default::default()
782 },
783 TomlRule {
784 id: "no-client-side-redirect".into(),
785 rule_type: "banned-pattern".into(),
786 severity: "warning".into(),
787 glob: Some("**/*.{tsx,jsx}".into()),
788 pattern: Some(r"useEffect\([^)]*\(\)\s*(?:=>)?\s*\{[^}]*(?:router\.push|window\.location)".into()),
789 regex: true,
790 message: "Avoid client-side redirects in useEffect — use server-side redirect() or middleware".into(),
791 suggest: Some("Move redirect logic to middleware.ts or use redirect() in a server component".into()),
792 ..Default::default()
793 },
794 TomlRule {
796 id: "no-sync-scripts".into(),
797 rule_type: "banned-pattern".into(),
798 severity: "warning".into(),
799 glob: Some("**/*.{tsx,jsx}".into()),
800 pattern: Some(r"<script\s".into()),
801 regex: true,
802 message: "Use next/script instead of <script> for optimized script loading".into(),
803 suggest: Some("Import Script from 'next/script' and use <Script> component".into()),
804 ..Default::default()
805 },
806 TomlRule {
807 id: "no-link-fonts".into(),
808 rule_type: "banned-pattern".into(),
809 severity: "warning".into(),
810 glob: Some("**/*.{tsx,jsx}".into()),
811 pattern: Some(r"<link[^>]*fonts\.googleapis\.com".into()),
812 regex: true,
813 message: "Use next/font instead of Google Fonts <link> for zero layout shift".into(),
814 suggest: Some("Import from 'next/font/google' for automatic font optimization".into()),
815 ..Default::default()
816 },
817 TomlRule {
818 id: "no-css-link".into(),
819 rule_type: "banned-pattern".into(),
820 severity: "warning".into(),
821 glob: Some("**/*.{tsx,jsx}".into()),
822 pattern: Some(r#"<link[^>]*rel=["']stylesheet["']"#.into()),
823 regex: true,
824 message: "Import CSS files directly instead of using <link rel=\"stylesheet\">".into(),
825 suggest: Some("Use import './styles.css' for automatic bundling and optimization".into()),
826 ..Default::default()
827 },
828 TomlRule {
830 id: "no-private-env-client".into(),
831 rule_type: "banned-pattern".into(),
832 severity: "error".into(),
833 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
834 pattern: Some(r"process\.env\.(?:[A-MO-Za-z_]\w*|N[A-DF-Za-z0-9_]\w*|NE[A-WYZa-z0-9_]\w*|NEX[A-SU-Za-z0-9_]\w*|NEXT[A-Za-z0-9]\w*|NEXT_[A-OQ-Za-z0-9_]\w*|NEXT_P[A-TV-Za-z0-9_]\w*|NEXT_PU[A-AC-Za-z0-9_]\w*|NEXT_PUB[A-KM-Za-z0-9_]\w*|NEXT_PUBL[A-HJ-Za-z0-9_]\w*|NEXT_PUBLI[A-BD-Za-z0-9_]\w*|NEXT_PUBLIC[A-Za-z0-9]\w*)".into()),
835 regex: true,
836 file_contains: Some("use client".into()),
837 message: "Private env vars are undefined in client components — prefix with NEXT_PUBLIC_".into(),
838 ..Default::default()
839 },
840 TomlRule {
841 id: "require-use-client-for-hooks".into(),
842 rule_type: "required-pattern".into(),
843 severity: "error".into(),
844 glob: Some("app/**".into()),
845 pattern: Some("use client".into()),
846 regex: true,
847 condition_pattern: Some(r"use(State|Effect|Context|Reducer|Callback|Memo|Ref|Transition|DeferredValue|InsertionEffect|SyncExternalStore|FormStatus|Optimistic)\s*\(".into()),
848 message: "Files using React hooks must include 'use client' directive in App Router".into(),
849 ..Default::default()
850 },
851 TomlRule {
852 id: "no-async-client-component".into(),
853 rule_type: "banned-pattern".into(),
854 severity: "error".into(),
855 glob: Some("**/*.{tsx,jsx}".into()),
856 pattern: Some(r"(?:export\s+default\s+)?async\s+function\s+[A-Z]".into()),
857 regex: true,
858 file_contains: Some("use client".into()),
859 message: "Client components cannot be async — only server components support async/await".into(),
860 suggest: Some("Remove 'use client' to make this a server component, or remove async and use useEffect for data fetching".into()),
861 ..Default::default()
862 },
863 TomlRule {
865 id: "require-metadata-in-pages".into(),
866 rule_type: "required-pattern".into(),
867 severity: "warning".into(),
868 glob: Some("app/**/page.{ts,tsx,js,jsx}".into()),
869 pattern: Some(r"(?:export\s+(?:const\s+metadata|(?:async\s+)?function\s+generateMetadata))".into()),
870 regex: true,
871 message: "Page files should export metadata or generateMetadata for SEO".into(),
872 suggest: Some("Add: export const metadata = { title: '...', description: '...' }".into()),
873 ..Default::default()
874 },
875 TomlRule {
877 id: "no-redirect-in-try-catch".into(),
878 rule_type: "banned-pattern".into(),
879 severity: "error".into(),
880 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
881 pattern: Some(r"try\s*\{[^}]*\bredirect\s*\(".into()),
882 regex: true,
883 message: "redirect() throws a special error — calling it inside try/catch will swallow the redirect".into(),
884 suggest: Some("Move redirect() outside the try/catch block".into()),
885 ..Default::default()
886 },
887 TomlRule {
889 id: "server-action-requires-auth".into(),
890 rule_type: "required-pattern".into(),
891 severity: "warning".into(),
892 glob: Some("app/**/*.{ts,tsx}".into()),
893 pattern: Some(r"(?:verifySession|getSession|auth\(\)|currentUser|getServerSession)".into()),
894 regex: true,
895 condition_pattern: Some("'use server'".into()),
896 message: "Server actions should verify authentication before performing mutations".into(),
897 suggest: Some("Add an auth check: const session = await getSession()".into()),
898 ..Default::default()
899 },
900 TomlRule {
901 id: "server-action-requires-validation".into(),
902 rule_type: "required-pattern".into(),
903 severity: "warning".into(),
904 glob: Some("app/**/*.{ts,tsx}".into()),
905 pattern: Some(r"(?:\.parse\(|\.safeParse\(|z\.object\(|\.validate\()".into()),
906 regex: true,
907 condition_pattern: Some("'use server'".into()),
908 message: "Server actions should validate input — never trust client data".into(),
909 suggest: Some("Use Zod or similar: const data = schema.parse(formData)".into()),
910 ..Default::default()
911 },
912 TomlRule {
914 id: "no-suppress-hydration-warning".into(),
915 rule_type: "banned-pattern".into(),
916 severity: "warning".into(),
917 glob: Some("**/*.{tsx,jsx}".into()),
918 pattern: Some("suppressHydrationWarning".into()),
919 message: "suppressHydrationWarning hides real bugs — fix the mismatch instead".into(),
920 suggest: Some("Use useEffect + state to defer client-only content, or move to a Client Component".into()),
921 ..Default::default()
922 },
923 ];
924
925 #[cfg(feature = "ast")]
927 {
928 rules.push(TomlRule {
929 id: "max-component-size".into(),
930 rule_type: "max-component-size".into(),
931 severity: "warning".into(),
932 glob: Some("**/*.{tsx,jsx}".into()),
933 max_count: Some(150),
934 message: "Component exceeds 150 lines — split into smaller components".into(),
935 suggest: Some("Extract logic into custom hooks or break into sub-components".into()),
936 ..Default::default()
937 });
938 rules.push(TomlRule {
939 id: "no-nested-components".into(),
940 rule_type: "no-nested-components".into(),
941 severity: "error".into(),
942 glob: Some("**/*.{tsx,jsx}".into()),
943 message: "Component defined inside another component — causes remounting on every render".into(),
944 suggest: Some("Move component definition to module scope or extract to a separate file".into()),
945 ..Default::default()
946 });
947 rules.push(TomlRule {
948 id: "prefer-use-reducer".into(),
949 rule_type: "prefer-use-reducer".into(),
950 severity: "warning".into(),
951 glob: Some("**/*.{tsx,jsx}".into()),
952 max_count: Some(4),
953 message: "Component has 4+ useState calls — consider useReducer for related state".into(),
954 suggest: Some("Group related state into a single useReducer".into()),
955 ..Default::default()
956 });
957 rules.push(TomlRule {
958 id: "no-cascading-set-state".into(),
959 rule_type: "no-cascading-set-state".into(),
960 severity: "warning".into(),
961 glob: Some("**/*.{tsx,jsx}".into()),
962 max_count: Some(3),
963 message: "useEffect has 3+ setState calls — consider useReducer or derived state".into(),
964 suggest: Some("Combine state updates with useReducer or compute derived values".into()),
965 ..Default::default()
966 });
967 }
968
969 rules
970 }
971 Preset::AiCodegen => vec![
972 TomlRule {
973 id: "no-placeholder-text".into(),
974 rule_type: "banned-pattern".into(),
975 severity: "warning".into(),
976 pattern: Some(r"(?i)lorem ipsum".into()),
977 regex: true,
978 message: "Placeholder text detected — replace with real content".into(),
979 ..Default::default()
980 },
981 TomlRule {
982 id: "no-unresolved-todos".into(),
983 rule_type: "banned-pattern".into(),
984 severity: "warning".into(),
985 pattern: Some(r"(?://|/?\*)\s*(TODO|FIXME|HACK|XXX)\b".into()),
986 regex: true,
987 message: "Unresolved TODO/FIXME comment — address or remove before merging".into(),
988 ..Default::default()
989 },
990 TomlRule {
991 id: "no-type-any".into(),
992 rule_type: "banned-pattern".into(),
993 severity: "error".into(),
994 glob: Some("**/*.{ts,tsx}".into()),
995 pattern: Some(r"[:<,]\s*any\b".into()),
996 regex: true,
997 exclude_glob: vec!["**/*.d.ts".into()],
998 message: "Avoid using 'any' type — use a specific type or 'unknown'".into(),
999 ..Default::default()
1000 },
1001 TomlRule {
1002 id: "no-empty-catch".into(),
1003 rule_type: "banned-pattern".into(),
1004 severity: "error".into(),
1005 pattern: Some(r"catch\s*\([^)]*\)\s*\{\s*\}".into()),
1006 regex: true,
1007 message: "Empty catch block swallows errors — handle or re-throw the error".into(),
1008 ..Default::default()
1009 },
1010 TomlRule {
1011 id: "no-console-log".into(),
1012 rule_type: "banned-pattern".into(),
1013 severity: "warning".into(),
1014 pattern: Some(r"console\.(log|debug)\(".into()),
1015 regex: true,
1016 exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
1017 message: "Remove console.log/debug before merging — use a proper logger if needed".into(),
1018 ..Default::default()
1019 },
1020 TomlRule {
1021 id: "no-ts-ignore".into(),
1022 rule_type: "banned-pattern".into(),
1023 severity: "error".into(),
1024 glob: Some("**/*.{ts,tsx}".into()),
1025 pattern: Some("@ts-ignore".into()),
1026 message: "Use @ts-expect-error instead of @ts-ignore for type suppressions".into(),
1027 ..Default::default()
1028 },
1029 TomlRule {
1030 id: "no-as-any".into(),
1031 rule_type: "banned-pattern".into(),
1032 severity: "error".into(),
1033 glob: Some("**/*.{ts,tsx}".into()),
1034 pattern: Some(r"\bas\s+any\b".into()),
1035 regex: true,
1036 message: "Avoid 'as any' type assertion — use proper types or 'as unknown'".into(),
1037 ..Default::default()
1038 },
1039 TomlRule {
1040 id: "no-eslint-disable".into(),
1041 rule_type: "banned-pattern".into(),
1042 severity: "warning".into(),
1043 pattern: Some("eslint-disable".into()),
1044 message: "Remove eslint-disable comment — fix the underlying issue instead".into(),
1045 ..Default::default()
1046 },
1047 TomlRule {
1048 id: "no-ts-nocheck".into(),
1049 rule_type: "banned-pattern".into(),
1050 severity: "error".into(),
1051 glob: Some("**/*.{ts,tsx}".into()),
1052 pattern: Some("@ts-nocheck".into()),
1053 message: "Do not disable type checking for entire files — fix type errors instead".into(),
1054 ..Default::default()
1055 },
1056 TomlRule {
1057 id: "no-var".into(),
1058 rule_type: "banned-pattern".into(),
1059 severity: "error".into(),
1060 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
1061 pattern: Some(r"\bvar\s+\w".into()),
1062 regex: true,
1063 exclude_glob: vec!["**/*.d.ts".into()],
1064 message: "Use 'let' or 'const' instead of 'var'".into(),
1065 ..Default::default()
1066 },
1067 TomlRule {
1068 id: "no-require-in-ts".into(),
1069 rule_type: "banned-pattern".into(),
1070 severity: "warning".into(),
1071 glob: Some("**/*.{ts,tsx}".into()),
1072 pattern: Some(r"\brequire\s*\(".into()),
1073 regex: true,
1074 message: "Use ES module 'import' instead of CommonJS 'require()' in TypeScript".into(),
1075 ..Default::default()
1076 },
1077 TomlRule {
1078 id: "no-non-null-assertion".into(),
1079 rule_type: "banned-pattern".into(),
1080 severity: "warning".into(),
1081 glob: Some("**/*.{ts,tsx}".into()),
1082 pattern: Some(r"\w![.\[]".into()),
1083 regex: true,
1084 message: "Avoid non-null assertion (!) — use optional chaining (?.) or proper null checks".into(),
1085 ..Default::default()
1086 },
1087 ],
1088 Preset::Accessibility => {
1089 #[allow(unused_mut)]
1090 let mut rules = vec![
1091 TomlRule {
1092 id: "no-div-click-handler".into(),
1093 rule_type: "banned-pattern".into(),
1094 severity: "error".into(),
1095 glob: Some("**/*.{tsx,jsx}".into()),
1096 pattern: Some(r"<div[^>]+onClick\s*=".into()),
1097 regex: true,
1098 message: "Non-interactive <div> with onClick is not keyboard accessible — use <button> instead".into(),
1099 suggest: Some("Replace <div onClick=...> with <button onClick=...>".into()),
1100 ..Default::default()
1101 },
1102 TomlRule {
1103 id: "no-span-click-handler".into(),
1104 rule_type: "banned-pattern".into(),
1105 severity: "error".into(),
1106 glob: Some("**/*.{tsx,jsx}".into()),
1107 pattern: Some(r"<span[^>]+onClick\s*=".into()),
1108 regex: true,
1109 message: "Non-interactive <span> with onClick is not keyboard accessible — use <button> instead".into(),
1110 suggest: Some("Replace <span onClick=...> with <button onClick=...>".into()),
1111 ..Default::default()
1112 },
1113 TomlRule {
1114 id: "no-outline-none".into(),
1115 rule_type: "banned-pattern".into(),
1116 severity: "warning".into(),
1117 glob: Some("**/*.{tsx,jsx}".into()),
1118 pattern: Some(r"\boutline-none\b".into()),
1119 regex: true,
1120 message: "outline-none removes the focus indicator — keyboard users can't see what's focused".into(),
1121 suggest: Some("Use focus-visible:outline-none with a custom focus ring instead".into()),
1122 ..Default::default()
1123 },
1124 TomlRule {
1125 id: "no-user-scalable-no".into(),
1126 rule_type: "banned-pattern".into(),
1127 severity: "error".into(),
1128 glob: Some("**/*.{tsx,jsx}".into()),
1129 pattern: Some(r"user-scalable\s*=\s*no".into()),
1130 regex: true,
1131 message: "user-scalable=no prevents zooming — violates WCAG 1.4.4 (Resize Text)".into(),
1132 suggest: Some("Remove user-scalable=no to allow pinch-to-zoom".into()),
1133 ..Default::default()
1134 },
1135 TomlRule {
1136 id: "no-autofocus-unrestricted".into(),
1137 rule_type: "banned-pattern".into(),
1138 severity: "warning".into(),
1139 glob: Some("**/*.{tsx,jsx}".into()),
1140 pattern: Some(r"\bautoFocus\b".into()),
1141 regex: true,
1142 message: "autoFocus can disorient screen reader users — use it sparingly (e.g., modals only)".into(),
1143 suggest: Some("Remove autoFocus or limit to modal/dialog initial focus".into()),
1144 ..Default::default()
1145 },
1146 TomlRule {
1147 id: "no-transition-all-tailwind".into(),
1148 rule_type: "banned-pattern".into(),
1149 severity: "warning".into(),
1150 glob: Some("**/*.{tsx,jsx}".into()),
1151 pattern: Some(r"\btransition-all\b".into()),
1152 regex: true,
1153 message: "transition-all can cause motion sickness — transition specific properties and respect prefers-reduced-motion".into(),
1154 suggest: Some("Use transition-colors, transition-opacity, or transition-transform instead".into()),
1155 ..Default::default()
1156 },
1157 TomlRule {
1158 id: "no-hardcoded-date-format".into(),
1159 rule_type: "banned-pattern".into(),
1160 severity: "warning".into(),
1161 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
1162 pattern: Some(r"\.toDateString\(\s*\)|\.toLocaleString\(\s*\)|\.toLocaleDateString\(\s*\)".into()),
1163 regex: true,
1164 message: "Date formatting without explicit locale is inconsistent across browsers — pass a locale".into(),
1165 suggest: Some("Pass an explicit locale: .toLocaleDateString('en-US', { ... })".into()),
1166 ..Default::default()
1167 },
1168 TomlRule {
1169 id: "no-inline-navigation-onclick".into(),
1170 rule_type: "banned-pattern".into(),
1171 severity: "warning".into(),
1172 glob: Some("**/*.{tsx,jsx}".into()),
1173 pattern: Some(r"onClick[^=]*=[^;]*window\.location".into()),
1174 regex: true,
1175 message: "onClick with window.location bypasses browser navigation — use <a> or router for accessible navigation".into(),
1176 suggest: Some("Use <a href=...> or your router's <Link> component instead".into()),
1177 ..Default::default()
1178 },
1179 ];
1180
1181 #[cfg(feature = "ast")]
1182 {
1183 rules.push(TomlRule {
1184 id: "require-img-alt".into(),
1185 rule_type: "require-img-alt".into(),
1186 severity: "error".into(),
1187 glob: Some("**/*.{tsx,jsx}".into()),
1188 message: "img element must have an alt attribute for screen readers".into(),
1189 suggest: Some("Add alt=\"description\" or alt=\"\" for decorative images".into()),
1190 ..Default::default()
1191 });
1192 }
1193
1194 rules
1195 }
1196 Preset::ReactNative => vec![
1197 TomlRule {
1198 id: "rn-no-touchable-opacity".into(),
1199 rule_type: "banned-pattern".into(),
1200 severity: "warning".into(),
1201 glob: Some("**/*.{tsx,jsx}".into()),
1202 pattern: Some(r"<TouchableOpacity|import\s+\{[^}]*TouchableOpacity".into()),
1203 regex: true,
1204 message: "TouchableOpacity is deprecated — use Pressable instead".into(),
1205 suggest: Some("Replace <TouchableOpacity> with <Pressable>".into()),
1206 ..Default::default()
1207 },
1208 TomlRule {
1209 id: "rn-no-touchable-highlight".into(),
1210 rule_type: "banned-pattern".into(),
1211 severity: "warning".into(),
1212 glob: Some("**/*.{tsx,jsx}".into()),
1213 pattern: Some(r"<TouchableHighlight|import\s+\{[^}]*TouchableHighlight".into()),
1214 regex: true,
1215 message: "TouchableHighlight is deprecated — use Pressable instead".into(),
1216 suggest: Some("Replace <TouchableHighlight> with <Pressable>".into()),
1217 ..Default::default()
1218 },
1219 TomlRule {
1220 id: "rn-no-legacy-shadow".into(),
1221 rule_type: "banned-pattern".into(),
1222 severity: "warning".into(),
1223 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
1224 pattern: Some(r"shadowColor\s*:|shadowOffset\s*:|shadowOpacity\s*:|shadowRadius\s*:".into()),
1225 regex: true,
1226 message: "Legacy shadow properties are iOS-only — use boxShadow (RN 0.76+) for cross-platform shadows".into(),
1227 suggest: Some("Use style={{ boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}".into()),
1228 ..Default::default()
1229 },
1230 TomlRule {
1231 id: "rn-no-rn-image-import".into(),
1232 rule_type: "banned-pattern".into(),
1233 severity: "warning".into(),
1234 glob: Some("**/*.{tsx,jsx}".into()),
1235 pattern: Some(r"import\s+\{[^}]*\bImage\b[^}]*\}\s+from\s+['\x22]react-native['\x22]".into()),
1236 regex: true,
1237 message: "react-native Image lacks caching and modern formats — use expo-image instead".into(),
1238 suggest: Some("Replace with: import { Image } from 'expo-image'".into()),
1239 ..Default::default()
1240 },
1241 TomlRule {
1242 id: "rn-no-custom-header".into(),
1243 rule_type: "banned-pattern".into(),
1244 severity: "warning".into(),
1245 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
1246 pattern: Some(r"header:\s*\(\)\s*=>".into()),
1247 regex: true,
1248 message: "Custom header render function loses native header animations — use headerTitle or screen options".into(),
1249 suggest: Some("Use screenOptions={{ headerTitle: ... }} instead of header: () => ...".into()),
1250 ..Default::default()
1251 },
1252 TomlRule {
1253 id: "rn-no-fonts-usefonts".into(),
1254 rule_type: "banned-pattern".into(),
1255 severity: "warning".into(),
1256 glob: Some("**/*.{tsx,jsx}".into()),
1257 pattern: Some(r"useFonts\s*\(\s*\{".into()),
1258 regex: true,
1259 message: "useFonts blocks rendering with a loading screen — use expo-font config plugin for build-time font loading".into(),
1260 suggest: Some("Add fonts to app.json expo-font plugin for instant availability".into()),
1261 ..Default::default()
1262 },
1263 TomlRule {
1264 id: "rn-no-font-loadasync".into(),
1265 rule_type: "banned-pattern".into(),
1266 severity: "warning".into(),
1267 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
1268 pattern: Some(r"Font\.loadAsync\s*\(".into()),
1269 regex: true,
1270 message: "Font.loadAsync blocks rendering — use expo-font config plugin for build-time font loading".into(),
1271 suggest: Some("Add fonts to app.json expo-font plugin for instant availability".into()),
1272 ..Default::default()
1273 },
1274 TomlRule {
1275 id: "rn-no-inline-intl-numberformat".into(),
1276 rule_type: "banned-pattern".into(),
1277 severity: "warning".into(),
1278 glob: Some("**/*.{tsx,jsx}".into()),
1279 pattern: Some(r"new\s+Intl\.NumberFormat\s*\(".into()),
1280 regex: true,
1281 message: "new Intl.NumberFormat() in a component body re-creates the formatter every render — extract to module scope".into(),
1282 suggest: Some("Move to module scope: const fmt = new Intl.NumberFormat(...)".into()),
1283 ..Default::default()
1284 },
1285 TomlRule {
1286 id: "rn-no-inline-intl-datetimeformat".into(),
1287 rule_type: "banned-pattern".into(),
1288 severity: "warning".into(),
1289 glob: Some("**/*.{tsx,jsx}".into()),
1290 pattern: Some(r"new\s+Intl\.DateTimeFormat\s*\(".into()),
1291 regex: true,
1292 message: "new Intl.DateTimeFormat() in a component body re-creates the formatter every render — extract to module scope".into(),
1293 suggest: Some("Move to module scope: const fmt = new Intl.DateTimeFormat(...)".into()),
1294 ..Default::default()
1295 },
1296 TomlRule {
1297 id: "rn-no-js-stack-navigator".into(),
1298 rule_type: "banned-import".into(),
1299 severity: "warning".into(),
1300 packages: vec!["@react-navigation/stack".into()],
1301 message: "JS-based stack navigator is slow — use @react-navigation/native-stack for native performance".into(),
1302 suggest: Some("Replace with: import { createNativeStackNavigator } from '@react-navigation/native-stack'".into()),
1303 ..Default::default()
1304 },
1305 TomlRule {
1306 id: "rn-no-js-bottom-tabs".into(),
1307 rule_type: "banned-import".into(),
1308 severity: "warning".into(),
1309 packages: vec!["@react-navigation/bottom-tabs".into()],
1310 message: "JS-based bottom tabs lack native feel — use react-native-bottom-tabs for native tab bar".into(),
1311 suggest: Some("Replace with: import { createNativeBottomTabNavigator } from 'react-native-bottom-tabs'".into()),
1312 ..Default::default()
1313 },
1314 TomlRule {
1315 id: "rn-no-linear-gradient-lib".into(),
1316 rule_type: "banned-import".into(),
1317 severity: "warning".into(),
1318 packages: vec!["expo-linear-gradient".into()],
1319 message: "expo-linear-gradient adds a JS bridge — use React Native's built-in linearGradient style (0.76+)".into(),
1320 suggest: Some("Use style={{ experimental_backgroundImage: 'linear-gradient(...)' }}".into()),
1321 ..Default::default()
1322 },
1323 TomlRule {
1324 id: "rn-no-js-bottom-sheet".into(),
1325 rule_type: "banned-dependency".into(),
1326 severity: "warning".into(),
1327 packages: vec!["@gorhom/bottom-sheet".into()],
1328 message: "@gorhom/bottom-sheet uses JS animations — use expo-bottom-sheet or react-native-bottom-sheet for native performance".into(),
1329 ..Default::default()
1330 },
1331 ],
1332 }
1333}
1334
1335fn merge_rules(preset_rules: Vec<TomlRule>, user_rules: &[TomlRule]) -> Vec<TomlRule> {
1339 let mut merged = preset_rules;
1340
1341 let mut id_to_index: HashMap<String, usize> = HashMap::new();
1343 for (i, rule) in merged.iter().enumerate() {
1344 id_to_index.insert(rule.id.clone(), i);
1345 }
1346
1347 for user_rule in user_rules {
1348 if let Some(&idx) = id_to_index.get(&user_rule.id) {
1349 merged[idx] = user_rule.clone();
1351 } else {
1352 merged.push(user_rule.clone());
1354 }
1355 }
1356
1357 merged
1358}
1359
1360pub fn resolve_rules(
1363 extends: &[String],
1364 user_rules: &[TomlRule],
1365) -> Result<Vec<TomlRule>, PresetError> {
1366 if extends.is_empty() {
1367 return Ok(user_rules.to_vec());
1368 }
1369
1370 let mut all_preset_rules: Vec<TomlRule> = Vec::new();
1372 let mut seen: HashMap<String, usize> = HashMap::new();
1373
1374 for preset_name in extends {
1375 let preset = resolve_preset(preset_name).ok_or_else(|| PresetError::UnknownPreset {
1376 name: preset_name.clone(),
1377 available: available_presets().to_vec(),
1378 })?;
1379
1380 for rule in preset_rules(preset) {
1381 if let Some(&idx) = seen.get(&rule.id) {
1382 all_preset_rules[idx] = rule;
1384 } else {
1385 seen.insert(rule.id.clone(), all_preset_rules.len());
1386 all_preset_rules.push(rule);
1387 }
1388 }
1389 }
1390
1391 Ok(merge_rules(all_preset_rules, user_rules))
1392}
1393
1394#[cfg(test)]
1395mod tests {
1396 use super::*;
1397
1398 #[test]
1399 fn shadcn_strict_has_five_rules() {
1400 let rules = preset_rules(Preset::ShadcnStrict);
1401 assert_eq!(rules.len(), 5);
1402 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1403 assert!(ids.contains(&"enforce-dark-mode"));
1404 assert!(ids.contains(&"use-theme-tokens"));
1405 assert!(ids.contains(&"no-inline-styles"));
1406 assert!(ids.contains(&"no-css-in-js"));
1407 assert!(ids.contains(&"no-competing-frameworks"));
1408 }
1409
1410 #[test]
1411 fn shadcn_migrate_has_two_rules() {
1412 let rules = preset_rules(Preset::ShadcnMigrate);
1413 assert_eq!(rules.len(), 2);
1414 assert_eq!(rules[0].id, "enforce-dark-mode");
1415 assert_eq!(rules[1].id, "use-theme-tokens");
1416 assert_eq!(rules[1].severity, "warning");
1418 }
1419
1420 #[test]
1421 fn ai_safety_has_three_rules() {
1422 let rules = preset_rules(Preset::AiSafety);
1423 assert_eq!(rules.len(), 3);
1424 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1425 assert!(ids.contains(&"no-moment"));
1426 assert!(ids.contains(&"no-lodash"));
1427 assert!(ids.contains(&"no-deprecated-request"));
1428 }
1429
1430 #[test]
1431 fn resolve_unknown_preset_errors() {
1432 let result = resolve_rules(&["unknown-preset".to_string()], &[]);
1433 assert!(result.is_err());
1434 let err = result.unwrap_err();
1435 let msg = format!("{}", err);
1436 assert!(msg.contains("unknown preset 'unknown-preset'"));
1437 assert!(msg.contains("shadcn-strict"));
1438 }
1439
1440 #[test]
1441 fn resolve_empty_extends_returns_user_rules() {
1442 let user_rules = vec![TomlRule {
1443 id: "custom-rule".into(),
1444 rule_type: "banned-pattern".into(),
1445 pattern: Some("TODO".into()),
1446 message: "No TODOs".into(),
1447 ..Default::default()
1448 }];
1449 let result = resolve_rules(&[], &user_rules).unwrap();
1450 assert_eq!(result.len(), 1);
1451 assert_eq!(result[0].id, "custom-rule");
1452 }
1453
1454 #[test]
1455 fn user_rule_overrides_preset() {
1456 let user_rules = vec![TomlRule {
1457 id: "use-theme-tokens".into(),
1458 rule_type: "tailwind-theme-tokens".into(),
1459 severity: "warning".into(),
1460 glob: Some("**/*.{tsx,jsx}".into()),
1461 message: "Custom message".into(),
1462 ..Default::default()
1463 }];
1464 let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
1465 assert_eq!(result.len(), 5);
1466 let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
1467 assert_eq!(token_rule.severity, "warning");
1468 assert_eq!(token_rule.message, "Custom message");
1469 }
1470
1471 #[test]
1472 fn user_rule_appended_after_preset() {
1473 let user_rules = vec![TomlRule {
1474 id: "my-custom".into(),
1475 rule_type: "banned-pattern".into(),
1476 pattern: Some("foo".into()),
1477 message: "no foo".into(),
1478 ..Default::default()
1479 }];
1480 let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
1481 assert_eq!(result.len(), 6);
1482 assert_eq!(result[5].id, "my-custom");
1483 }
1484
1485 #[test]
1486 fn later_preset_overrides_earlier() {
1487 let result = resolve_rules(
1490 &["shadcn-strict".to_string(), "shadcn-migrate".to_string()],
1491 &[],
1492 )
1493 .unwrap();
1494 let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
1495 assert_eq!(token_rule.severity, "warning");
1496 assert_eq!(result.len(), 5);
1498 }
1499
1500 #[test]
1501 fn multiple_presets_combine() {
1502 let result = resolve_rules(
1503 &["shadcn-migrate".to_string(), "ai-safety".to_string()],
1504 &[],
1505 )
1506 .unwrap();
1507 assert_eq!(result.len(), 5);
1509 }
1510
1511 #[test]
1512 fn security_has_eleven_rules() {
1513 let rules = preset_rules(Preset::Security);
1514 assert_eq!(rules.len(), 11);
1515 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1516 assert!(ids.contains(&"no-env-files"));
1517 assert!(ids.contains(&"no-hardcoded-secrets"));
1518 assert!(ids.contains(&"no-eval"));
1519 assert!(ids.contains(&"no-dangerous-html"));
1520 assert!(ids.contains(&"no-innerhtml"));
1521 assert!(ids.contains(&"no-console-log"));
1522 assert!(ids.contains(&"no-document-write"));
1523 assert!(ids.contains(&"no-postmessage-wildcard"));
1524 assert!(ids.contains(&"no-outerhtml"));
1525 assert!(ids.contains(&"no-http-links"));
1526 assert!(ids.contains(&"no-paste-prevention"));
1527 }
1528
1529 #[test]
1530 fn nextjs_has_eight_rules() {
1531 let rules = preset_rules(Preset::Nextjs);
1532 assert_eq!(rules.len(), 8);
1533 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1534 assert!(ids.contains(&"use-next-image"));
1535 assert!(ids.contains(&"no-next-head"));
1536 assert!(ids.contains(&"no-private-env-client"));
1537 assert!(ids.contains(&"require-use-client-for-hooks"));
1538 assert!(ids.contains(&"use-next-link"));
1539 assert!(ids.contains(&"no-next-router-in-app"));
1540 assert!(ids.contains(&"no-sync-scripts"));
1541 assert!(ids.contains(&"no-link-fonts"));
1542 }
1543
1544 #[test]
1545 fn ai_codegen_has_twelve_rules() {
1546 let rules = preset_rules(Preset::AiCodegen);
1547 assert_eq!(rules.len(), 12);
1548 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1549 assert!(ids.contains(&"no-placeholder-text"));
1550 assert!(ids.contains(&"no-unresolved-todos"));
1551 assert!(ids.contains(&"no-type-any"));
1552 assert!(ids.contains(&"no-empty-catch"));
1553 assert!(ids.contains(&"no-console-log"));
1554 assert!(ids.contains(&"no-ts-ignore"));
1555 assert!(ids.contains(&"no-as-any"));
1556 assert!(ids.contains(&"no-eslint-disable"));
1557 assert!(ids.contains(&"no-ts-nocheck"));
1558 assert!(ids.contains(&"no-var"));
1559 assert!(ids.contains(&"no-require-in-ts"));
1560 assert!(ids.contains(&"no-non-null-assertion"));
1561 }
1562
1563 #[test]
1564 fn react_has_expected_rule_count() {
1565 let rules = preset_rules(Preset::React);
1566 #[cfg(not(feature = "ast"))]
1567 assert_eq!(rules.len(), 27);
1568 #[cfg(feature = "ast")]
1569 assert_eq!(rules.len(), 30); let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1571 assert!(ids.contains(&"no-array-index-key"));
1572 assert!(ids.contains(&"no-conditional-render-zero"));
1573 assert!(ids.contains(&"no-nested-component-def"));
1574 assert!(ids.contains(&"no-dangerous-html"));
1575 assert!(ids.contains(&"no-full-lodash-import"));
1576 assert!(ids.contains(&"no-moment"));
1577 assert!(ids.contains(&"no-moment-dep"));
1578 assert!(ids.contains(&"no-new-function"));
1579 assert!(ids.contains(&"no-transition-all"));
1580 assert!(ids.contains(&"no-layout-animation"));
1581 assert!(ids.contains(&"no-sequential-await"));
1582 assert!(ids.contains(&"no-derived-state-effect"));
1583 assert!(ids.contains(&"no-fetch-in-effect"));
1584 assert!(ids.contains(&"no-lazy-state-init"));
1585 assert!(ids.contains(&"no-object-dep-array"));
1586 assert!(ids.contains(&"no-default-object-prop"));
1587 assert!(ids.contains(&"no-forwardref"));
1589 assert!(ids.contains(&"no-use-context"));
1590 assert!(ids.contains(&"no-unsafe-createcontext-default"));
1592 assert!(ids.contains(&"no-effect-callback-sync"));
1593 assert!(ids.contains(&"no-usestate-localstorage-eager"));
1594 assert!(ids.contains(&"no-regexp-in-render"));
1596 assert!(ids.contains(&"no-lucide-barrel"));
1597 assert!(ids.contains(&"no-mui-barrel"));
1598 assert!(ids.contains(&"no-mui-icons-barrel"));
1599 assert!(ids.contains(&"no-react-icons-barrel"));
1600 assert!(ids.contains(&"no-date-fns-barrel"));
1601 #[cfg(feature = "ast")]
1602 {
1603 assert!(ids.contains(&"max-component-size"));
1604 assert!(ids.contains(&"prefer-use-reducer"));
1605 assert!(ids.contains(&"no-cascading-set-state"));
1606 let nested_rule = rules.iter().find(|r| r.id == "no-nested-component-def").unwrap();
1608 assert_eq!(nested_rule.rule_type, "no-nested-components");
1609 }
1610 #[cfg(not(feature = "ast"))]
1611 {
1612 let nested_rule = rules.iter().find(|r| r.id == "no-nested-component-def").unwrap();
1613 assert_eq!(nested_rule.rule_type, "banned-pattern");
1614 }
1615 }
1616
1617 #[test]
1618 fn nextjs_best_practices_has_expected_rule_count() {
1619 let rules = preset_rules(Preset::NextjsBestPractices);
1620 #[cfg(not(feature = "ast"))]
1621 assert_eq!(rules.len(), 17);
1622 #[cfg(feature = "ast")]
1623 assert_eq!(rules.len(), 21); let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1625 assert!(ids.contains(&"use-next-image"));
1626 assert!(ids.contains(&"next-image-fill-needs-sizes"));
1627 assert!(ids.contains(&"use-next-link"));
1628 assert!(ids.contains(&"no-next-router-in-app"));
1629 assert!(ids.contains(&"no-next-head"));
1630 assert!(ids.contains(&"no-client-side-redirect"));
1631 assert!(ids.contains(&"no-sync-scripts"));
1632 assert!(ids.contains(&"no-link-fonts"));
1633 assert!(ids.contains(&"no-css-link"));
1634 assert!(ids.contains(&"no-private-env-client"));
1635 assert!(ids.contains(&"require-use-client-for-hooks"));
1636 assert!(ids.contains(&"no-async-client-component"));
1637 assert!(ids.contains(&"require-metadata-in-pages"));
1638 assert!(ids.contains(&"no-redirect-in-try-catch"));
1639 assert!(ids.contains(&"server-action-requires-auth"));
1640 assert!(ids.contains(&"server-action-requires-validation"));
1641 assert!(ids.contains(&"no-suppress-hydration-warning"));
1642 #[cfg(feature = "ast")]
1643 {
1644 assert!(ids.contains(&"max-component-size"));
1645 assert!(ids.contains(&"no-nested-components"));
1646 assert!(ids.contains(&"prefer-use-reducer"));
1647 assert!(ids.contains(&"no-cascading-set-state"));
1648 }
1649 }
1650
1651 #[test]
1652 fn accessibility_has_expected_rule_count() {
1653 let rules = preset_rules(Preset::Accessibility);
1654 #[cfg(not(feature = "ast"))]
1655 assert_eq!(rules.len(), 8);
1656 #[cfg(feature = "ast")]
1657 assert_eq!(rules.len(), 9); let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1659 assert!(ids.contains(&"no-div-click-handler"));
1660 assert!(ids.contains(&"no-span-click-handler"));
1661 assert!(ids.contains(&"no-outline-none"));
1662 assert!(ids.contains(&"no-user-scalable-no"));
1663 assert!(ids.contains(&"no-autofocus-unrestricted"));
1664 assert!(ids.contains(&"no-transition-all-tailwind"));
1665 assert!(ids.contains(&"no-hardcoded-date-format"));
1666 assert!(ids.contains(&"no-inline-navigation-onclick"));
1667 #[cfg(feature = "ast")]
1668 assert!(ids.contains(&"require-img-alt"));
1669 }
1670
1671 #[test]
1672 fn react_native_has_thirteen_rules() {
1673 let rules = preset_rules(Preset::ReactNative);
1674 assert_eq!(rules.len(), 13);
1675 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1676 assert!(ids.contains(&"rn-no-touchable-opacity"));
1677 assert!(ids.contains(&"rn-no-touchable-highlight"));
1678 assert!(ids.contains(&"rn-no-legacy-shadow"));
1679 assert!(ids.contains(&"rn-no-rn-image-import"));
1680 assert!(ids.contains(&"rn-no-custom-header"));
1681 assert!(ids.contains(&"rn-no-fonts-usefonts"));
1682 assert!(ids.contains(&"rn-no-font-loadasync"));
1683 assert!(ids.contains(&"rn-no-inline-intl-numberformat"));
1684 assert!(ids.contains(&"rn-no-inline-intl-datetimeformat"));
1685 assert!(ids.contains(&"rn-no-js-stack-navigator"));
1686 assert!(ids.contains(&"rn-no-js-bottom-tabs"));
1687 assert!(ids.contains(&"rn-no-linear-gradient-lib"));
1688 assert!(ids.contains(&"rn-no-js-bottom-sheet"));
1689 }
1690
1691 #[test]
1692 fn all_preset_names_resolve() {
1693 for name in available_presets() {
1694 assert!(
1695 resolve_preset(name).is_some(),
1696 "preset '{}' should resolve",
1697 name
1698 );
1699 }
1700 }
1701
1702 #[test]
1703 fn all_preset_regex_patterns_compile() {
1704 use regex::Regex;
1705 for name in available_presets() {
1706 let preset = resolve_preset(name).unwrap();
1707 for rule in preset_rules(preset) {
1708 if rule.regex {
1709 if let Some(ref pat) = rule.pattern {
1710 Regex::new(pat).unwrap_or_else(|e| {
1711 panic!("preset '{}', rule '{}': invalid pattern: {}", name, rule.id, e)
1712 });
1713 }
1714 if let Some(ref pat) = rule.condition_pattern {
1715 Regex::new(pat).unwrap_or_else(|e| {
1716 panic!(
1717 "preset '{}', rule '{}': invalid condition_pattern: {}",
1718 name, rule.id, e
1719 )
1720 });
1721 }
1722 }
1723 }
1724 }
1725 }
1726
1727 #[test]
1728 fn no_private_env_client_pattern_correctness() {
1729 use regex::Regex;
1730 let rules = preset_rules(Preset::Nextjs);
1731 let rule = rules.iter().find(|r| r.id == "no-private-env-client").unwrap();
1732 let re = Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
1733
1734 assert!(re.is_match("process.env.DATABASE_URL"));
1736 assert!(re.is_match("process.env.API_SECRET"));
1737 assert!(re.is_match("process.env.NODE_ENV"));
1738 assert!(re.is_match("process.env.NEXT_RUNTIME"));
1739
1740 assert!(!re.is_match("process.env.NEXT_PUBLIC_API_URL"));
1742 assert!(!re.is_match("process.env.NEXT_PUBLIC_STRIPE_KEY"));
1743 }
1744
1745 fn regex_for(preset: Preset, rule_id: &str) -> regex::Regex {
1747 let rules = preset_rules(preset);
1748 let rule = rules
1749 .iter()
1750 .find(|r| r.id == rule_id)
1751 .unwrap_or_else(|| panic!("rule '{}' not found", rule_id));
1752 regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap()
1753 }
1754
1755 #[test]
1758 fn no_document_write_pattern() {
1759 let re = regex_for(Preset::Security, "no-document-write");
1760 assert!(re.is_match("document.write('hello')"));
1761 assert!(re.is_match("document.write (html)"));
1762 assert!(re.is_match(" document.write('<div>')"));
1763 assert!(!re.is_match("const w = document.writeln"));
1765 assert!(!re.is_match("documentWriter()"));
1766 }
1767
1768 #[test]
1769 fn no_postmessage_wildcard_pattern() {
1770 let re = regex_for(Preset::Security, "no-postmessage-wildcard");
1771 assert!(re.is_match("window.postMessage(data, '*')"));
1772 assert!(re.is_match(r#"iframe.contentWindow.postMessage({}, "*")"#));
1773 assert!(re.is_match(" w.postMessage(msg, '*')"));
1774 assert!(!re.is_match("window.postMessage(data, 'https://example.com')"));
1776 assert!(!re.is_match("window.postMessage(data, origin)"));
1777 }
1778
1779 #[test]
1780 fn no_outerhtml_pattern() {
1781 let re = regex_for(Preset::Security, "no-outerhtml");
1782 assert!(re.is_match("el.outerHTML = '<div>'"));
1783 assert!(re.is_match("el.outerHTML += '<span>'"));
1784 assert!(re.is_match(" node.outerHTML = html"));
1785 assert!(!re.is_match("const html = el.outerHTML"));
1787 assert!(!re.is_match("console.log(el.outerHTML)"));
1788 }
1789
1790 #[test]
1791 fn no_http_links_pattern() {
1792 let re = regex_for(Preset::Security, "no-http-links");
1793 assert!(re.is_match(r#"fetch("http://api.example.com")"#));
1794 assert!(re.is_match("const url = 'http://cdn.example.com'"));
1795 assert!(!re.is_match(r#"fetch("https://api.example.com")"#));
1797 assert!(!re.is_match("// visit http://example.com"));
1799 }
1800
1801 #[test]
1802 fn no_hardcoded_secrets_expanded() {
1803 let re = regex_for(Preset::Security, "no-hardcoded-secrets");
1804 assert!(re.is_match(r#"api_key = "abc12345678""#));
1806 assert!(re.is_match(r#"API_KEY: "abc12345678""#));
1807 assert!(re.is_match(r#"password = "mysecretpass""#));
1809 assert!(re.is_match(r#"PASSWORD: "supersecret1""#));
1810 assert!(re.is_match(r#"client_secret = "abcdefghij""#));
1811 assert!(!re.is_match(r#"password = "short""#));
1813 assert!(!re.is_match("password = getPassword()"));
1815 }
1816
1817 #[test]
1820 fn no_sync_scripts_pattern() {
1821 let re = regex_for(Preset::Nextjs, "no-sync-scripts");
1822 assert!(re.is_match(r#"<script src="analytics.js">"#));
1823 assert!(re.is_match(r#"<script type="application/ld+json">"#));
1824 assert!(!re.is_match(r#"<Script src="analytics.js">"#));
1826 assert!(!re.is_match("</script>"));
1828 }
1829
1830 #[test]
1831 fn no_link_fonts_pattern() {
1832 let re = regex_for(Preset::Nextjs, "no-link-fonts");
1833 assert!(re.is_match(
1834 r#"<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />"#
1835 ));
1836 assert!(re.is_match(
1837 r#"<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto">"#
1838 ));
1839 assert!(!re.is_match(r#"<link rel="stylesheet" href="/styles.css" />"#));
1841 assert!(!re.is_match(r#"<Link href="/fonts">"#));
1843 }
1844
1845 #[test]
1848 fn no_eslint_disable_pattern() {
1849 let rules = preset_rules(Preset::AiCodegen);
1850 let rule = rules.iter().find(|r| r.id == "no-eslint-disable").unwrap();
1851 let pat = rule.pattern.as_ref().unwrap();
1852 assert!(!rule.regex);
1854 assert!("// eslint-disable-next-line no-console".contains(pat.as_str()));
1855 assert!("/* eslint-disable */".contains(pat.as_str()));
1856 assert!("/* eslint-disable-next-line */".contains(pat.as_str()));
1857 }
1858
1859 #[test]
1860 fn no_var_pattern() {
1861 let re = regex_for(Preset::AiCodegen, "no-var");
1862 assert!(re.is_match("var x = 1"));
1863 assert!(re.is_match("var foo = 'bar'"));
1864 assert!(re.is_match(" var count = 0;"));
1865 assert!(!re.is_match("const variable = 1"));
1867 assert!(!re.is_match("let variance = 2"));
1868 assert!(!re.is_match("const isVariable = true"));
1869 }
1870
1871 #[test]
1872 fn no_require_in_ts_pattern() {
1873 let re = regex_for(Preset::AiCodegen, "no-require-in-ts");
1874 assert!(re.is_match("const fs = require('fs')"));
1875 assert!(re.is_match("const x = require('./module')"));
1876 assert!(re.is_match("require('dotenv').config()"));
1877 assert!(!re.is_match("import fs from 'fs'"));
1879 assert!(!re.is_match("require.resolve('./path')"));
1881 }
1882
1883 #[test]
1884 fn no_non_null_assertion_pattern() {
1885 let re = regex_for(Preset::AiCodegen, "no-non-null-assertion");
1886 assert!(re.is_match("user!.name"));
1888 assert!(re.is_match("items![0]"));
1889 assert!(re.is_match("this.ref!.current"));
1890 assert!(re.is_match("data!.results"));
1891 assert!(!re.is_match("x !== y"));
1893 assert!(!re.is_match("x != y"));
1894 assert!(!re.is_match("if (!foo) {}"));
1895 assert!(!re.is_match("!!value"));
1896 assert!(!re.is_match("foo!==bar"));
1897 }
1898
1899 #[test]
1900 fn no_non_null_assertion_no_false_positives_on_strings() {
1901 let re = regex_for(Preset::AiCodegen, "no-non-null-assertion");
1902 assert!(!re.is_match(r#""Warning!".toUpperCase()"#));
1904 assert!(!re.is_match(r#"'Error!'.length"#));
1905 assert!(!re.is_match(r#"'Click me!'[0]"#));
1906 }
1907
1908 #[test]
1909 fn no_innerhtml_catches_plus_equals() {
1910 let re = regex_for(Preset::Security, "no-innerhtml");
1911 assert!(re.is_match("el.innerHTML = html"));
1912 assert!(re.is_match("el.innerHTML += '<br>'"));
1913 assert!(re.is_match("el.innerHTML = content"));
1914 assert!(!re.is_match("const x = el.innerHTML"));
1915 }
1916
1917 #[test]
1918 fn no_type_any_catches_generics() {
1919 let re = regex_for(Preset::AiCodegen, "no-type-any");
1920 assert!(re.is_match("const x: any = 1"));
1922 assert!(re.is_match("Array<any>"));
1924 assert!(re.is_match("Promise<any>"));
1925 assert!(re.is_match("Record<string, any>"));
1926 assert!(re.is_match("Map<string, any>"));
1927 assert!(!re.is_match("// handle any case"));
1929 assert!(!re.is_match("const anything = 1"));
1930 assert!(!re.is_match("if (any_flag) {}"));
1931 }
1932
1933 #[test]
1936 fn no_forwardref_pattern() {
1937 let re = regex_for(Preset::React, "no-forwardref");
1938 assert!(re.is_match("const Input = forwardRef<HTMLInputElement>((props, ref) => {"));
1939 assert!(re.is_match("const Btn = forwardRef((props, ref) => <button />)"));
1940 assert!(re.is_match("export default forwardRef(MyComponent)"));
1941 assert!(!re.is_match("// removed forwardRef"));
1943 assert!(!re.is_match("const forwardRefValue = 42"));
1944 }
1945
1946 #[test]
1947 fn no_use_context_pattern() {
1948 let re = regex_for(Preset::React, "no-use-context");
1949 assert!(re.is_match("const theme = useContext(ThemeContext)"));
1950 assert!(re.is_match("const val = useContext(Ctx)"));
1951 assert!(!re.is_match("const ctx = useContextSelector(Ctx, s => s.val)"));
1953 assert!(!re.is_match("// useContext is deprecated"));
1954 }
1955
1956 #[test]
1957 fn no_unsafe_createcontext_default_pattern() {
1958 let re = regex_for(Preset::React, "no-unsafe-createcontext-default");
1959 assert!(re.is_match("const Ctx = createContext({})"));
1961 assert!(re.is_match("const Ctx = createContext([])"));
1962 assert!(re.is_match("const Ctx = createContext(undefined)"));
1963 assert!(re.is_match("const Ctx = createContext(0)"));
1964 assert!(re.is_match("const Ctx = createContext('')"));
1965 assert!(re.is_match(r#"const Ctx = createContext("")"#));
1966 assert!(!re.is_match("const Ctx = createContext(null)"));
1968 assert!(!re.is_match("const Ctx = createContext(defaultValue)"));
1969 assert!(!re.is_match("const Ctx = createContext({ theme: 'dark' })"));
1970 }
1971
1972 #[test]
1973 fn no_effect_callback_sync_pattern() {
1974 let re = regex_for(Preset::React, "no-effect-callback-sync");
1975 assert!(re.is_match("useEffect(() => { onChange(value)"));
1976 assert!(re.is_match("useEffect(() => { onUpdate(data)"));
1977 assert!(re.is_match("useEffect(() => onSubmit(form)"));
1978 assert!(!re.is_match("useEffect(() => { setCount(1) }"));
1980 assert!(!re.is_match("useEffect(() => { fetchData() }"));
1981 }
1982
1983 #[test]
1984 fn no_usestate_localstorage_eager_pattern() {
1985 let re = regex_for(Preset::React, "no-usestate-localstorage-eager");
1986 assert!(re.is_match("useState(localStorage.getItem('key'))"));
1987 assert!(re.is_match("useState(JSON.parse(localStorage.getItem('key')))"));
1988 assert!(!re.is_match("useState(() => localStorage.getItem('key'))"));
1990 assert!(!re.is_match("useState(sessionStorage.getItem('key'))"));
1992 }
1993
1994 #[test]
1995 fn no_regexp_in_render_pattern() {
1996 let re = regex_for(Preset::React, "no-regexp-in-render");
1997 assert!(re.is_match("const re = new RegExp(pattern)"));
1998 assert!(re.is_match("new RegExp('\\\\d+', 'g')"));
1999 assert!(!re.is_match("const re = /\\d+/g"));
2001 }
2002
2003 #[test]
2004 fn no_lucide_barrel_pattern() {
2005 let re = regex_for(Preset::React, "no-lucide-barrel");
2006 assert!(re.is_match("import { Home } from 'lucide-react'"));
2008 assert!(re.is_match(r#"import { Home } from "lucide-react""#));
2009 assert!(re.is_match("require('lucide-react')"));
2010 assert!(!re.is_match("import Home from 'lucide-react/icons/Home'"));
2012 assert!(!re.is_match("import { Home } from 'lucide-react/dist/esm/icons/home'"));
2013 }
2014
2015 #[test]
2016 fn no_mui_barrel_pattern() {
2017 let re = regex_for(Preset::React, "no-mui-barrel");
2018 assert!(re.is_match("import { Button } from '@mui/material'"));
2019 assert!(re.is_match("require('@mui/material')"));
2020 assert!(!re.is_match("import Button from '@mui/material/Button'"));
2022 assert!(!re.is_match("import { useTheme } from '@mui/material/styles'"));
2023 }
2024
2025 #[test]
2026 fn no_mui_icons_barrel_pattern() {
2027 let re = regex_for(Preset::React, "no-mui-icons-barrel");
2028 assert!(re.is_match("import { Home } from '@mui/icons-material'"));
2029 assert!(!re.is_match("import HomeIcon from '@mui/icons-material/Home'"));
2031 }
2032
2033 #[test]
2034 fn no_react_icons_barrel_pattern() {
2035 let re = regex_for(Preset::React, "no-react-icons-barrel");
2036 assert!(re.is_match("import { FaHome } from 'react-icons'"));
2037 assert!(!re.is_match("import { FaHome } from 'react-icons/fa'"));
2039 }
2040
2041 #[test]
2042 fn no_date_fns_barrel_pattern() {
2043 let re = regex_for(Preset::React, "no-date-fns-barrel");
2044 assert!(re.is_match("import { format } from 'date-fns'"));
2045 assert!(re.is_match("require('date-fns')"));
2046 assert!(!re.is_match("import { format } from 'date-fns/format'"));
2048 assert!(!re.is_match("import { format } from 'date-fns/esm'"));
2049 }
2050
2051 #[test]
2054 fn server_action_requires_auth_patterns() {
2055 let rules = preset_rules(Preset::NextjsBestPractices);
2056 let rule = rules.iter().find(|r| r.id == "server-action-requires-auth").unwrap();
2057 let re = regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
2058 let cond_re = regex::Regex::new(rule.condition_pattern.as_ref().unwrap()).unwrap();
2059 assert!(cond_re.is_match("'use server'"));
2061 assert!(!cond_re.is_match("'use client'"));
2062 assert!(re.is_match("await verifySession()"));
2064 assert!(re.is_match("const s = await getSession()"));
2065 assert!(re.is_match("const s = await auth()"));
2066 assert!(re.is_match("const u = await currentUser()"));
2067 assert!(re.is_match("const s = await getServerSession()"));
2068 assert!(!re.is_match("await db.insert(data)"));
2070 }
2071
2072 #[test]
2073 fn server_action_requires_validation_patterns() {
2074 let rules = preset_rules(Preset::NextjsBestPractices);
2075 let rule = rules.iter().find(|r| r.id == "server-action-requires-validation").unwrap();
2076 let re = regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
2077 assert!(re.is_match("const data = schema.parse(formData)"));
2079 assert!(re.is_match("const result = schema.safeParse(input)"));
2080 assert!(re.is_match("const s = z.object({})"));
2081 assert!(re.is_match("await body.validate()"));
2082 assert!(!re.is_match("await db.insert(formData)"));
2084 }
2085
2086 #[test]
2087 fn no_suppress_hydration_warning_pattern() {
2088 let rules = preset_rules(Preset::NextjsBestPractices);
2089 let rule = rules.iter().find(|r| r.id == "no-suppress-hydration-warning").unwrap();
2090 let pat = rule.pattern.as_ref().unwrap();
2091 assert!(!rule.regex);
2092 assert!("<div suppressHydrationWarning>".contains(pat.as_str()));
2093 assert!("<body suppressHydrationWarning={true}>".contains(pat.as_str()));
2094 assert!(!"<div className='safe'>".contains(pat.as_str()));
2095 }
2096
2097 #[test]
2100 fn no_paste_prevention_pattern() {
2101 let re = regex_for(Preset::Security, "no-paste-prevention");
2102 assert!(re.is_match("onPaste={(e) => e.preventDefault()}"));
2103 assert!(re.is_match("onPaste={e => { e.preventDefault() }}"));
2104 assert!(re.is_match("onPaste={handlePaste} // where handlePaste calls preventDefault"));
2105 assert!(!re.is_match("onPaste={handlePaste}"));
2107 assert!(!re.is_match("onCopy={(e) => e.preventDefault()}"));
2108 }
2109
2110 #[test]
2113 fn no_div_click_handler_pattern() {
2114 let re = regex_for(Preset::Accessibility, "no-div-click-handler");
2115 assert!(re.is_match("<div className='card' onClick={handleClick}>"));
2116 assert!(re.is_match("<div onClick = {fn}>"));
2117 assert!(!re.is_match("<button onClick={handleClick}>"));
2119 assert!(!re.is_match("</div>"));
2121 assert!(!re.is_match("<div className='card'>"));
2123 }
2124
2125 #[test]
2126 fn no_span_click_handler_pattern() {
2127 let re = regex_for(Preset::Accessibility, "no-span-click-handler");
2128 assert!(re.is_match("<span role='button' onClick={handleClick}>"));
2129 assert!(re.is_match("<span onClick={fn}>"));
2130 assert!(!re.is_match("<button onClick={handleClick}>"));
2132 assert!(!re.is_match("<span className='label'>"));
2134 }
2135
2136 #[test]
2137 fn no_outline_none_pattern() {
2138 let re = regex_for(Preset::Accessibility, "no-outline-none");
2139 assert!(re.is_match("className='outline-none'"));
2140 assert!(re.is_match("className='focus:outline-none ring-2'"));
2141 assert!(!re.is_match("className='outline-offset-2'"));
2143 assert!(!re.is_match("className='outline-0'"));
2144 }
2145
2146 #[test]
2147 fn no_user_scalable_no_pattern() {
2148 let re = regex_for(Preset::Accessibility, "no-user-scalable-no");
2149 assert!(re.is_match("user-scalable=no"));
2150 assert!(re.is_match("user-scalable = no"));
2151 assert!(!re.is_match("user-scalable=yes"));
2153 }
2154
2155 #[test]
2156 fn no_autofocus_unrestricted_pattern() {
2157 let re = regex_for(Preset::Accessibility, "no-autofocus-unrestricted");
2158 assert!(re.is_match("<input autoFocus />"));
2159 assert!(re.is_match("<Input autoFocus={true} />"));
2160 assert!(!re.is_match("const autoFocusEnabled = true"));
2162 }
2163
2164 #[test]
2165 fn no_transition_all_tailwind_pattern() {
2166 let re = regex_for(Preset::Accessibility, "no-transition-all-tailwind");
2167 assert!(re.is_match("className='transition-all duration-300'"));
2168 assert!(!re.is_match("className='transition-colors duration-300'"));
2170 assert!(!re.is_match("className='transition-opacity'"));
2171 }
2172
2173 #[test]
2174 fn no_hardcoded_date_format_pattern() {
2175 let re = regex_for(Preset::Accessibility, "no-hardcoded-date-format");
2176 assert!(re.is_match("date.toDateString()"));
2177 assert!(re.is_match("date.toLocaleString()"));
2178 assert!(re.is_match("date.toLocaleDateString()"));
2179 assert!(!re.is_match("date.toLocaleDateString('en-US')"));
2181 assert!(!re.is_match("date.toLocaleString('de-DE', opts)"));
2182 }
2183
2184 #[test]
2185 fn no_inline_navigation_onclick_pattern() {
2186 let re = regex_for(Preset::Accessibility, "no-inline-navigation-onclick");
2187 assert!(re.is_match("onClick={() => window.location.href = '/home'}"));
2188 assert!(re.is_match("onClick={() => { window.location = '/page' }}"));
2189 assert!(!re.is_match("onClick={() => router.push('/home')}"));
2191 }
2192
2193 #[test]
2196 fn rn_no_touchable_opacity_pattern() {
2197 let re = regex_for(Preset::ReactNative, "rn-no-touchable-opacity");
2198 assert!(re.is_match("<TouchableOpacity onPress={fn}>"));
2199 assert!(re.is_match("import { TouchableOpacity } from 'react-native'"));
2200 assert!(re.is_match("import { View, TouchableOpacity } from 'react-native'"));
2201 assert!(!re.is_match("<Pressable onPress={fn}>"));
2203 }
2204
2205 #[test]
2206 fn rn_no_touchable_highlight_pattern() {
2207 let re = regex_for(Preset::ReactNative, "rn-no-touchable-highlight");
2208 assert!(re.is_match("<TouchableHighlight onPress={fn}>"));
2209 assert!(re.is_match("import { TouchableHighlight } from 'react-native'"));
2210 assert!(!re.is_match("<Pressable onPress={fn}>"));
2212 }
2213
2214 #[test]
2215 fn rn_no_legacy_shadow_pattern() {
2216 let re = regex_for(Preset::ReactNative, "rn-no-legacy-shadow");
2217 assert!(re.is_match("shadowColor: '#000'"));
2218 assert!(re.is_match("shadowOffset: { width: 0 }"));
2219 assert!(re.is_match("shadowOpacity: 0.25"));
2220 assert!(re.is_match("shadowRadius: 3.84"));
2221 assert!(!re.is_match("boxShadow: '0 2px 4px rgba(0,0,0,0.1)'"));
2223 }
2224
2225 #[test]
2226 fn rn_no_rn_image_import_pattern() {
2227 let re = regex_for(Preset::ReactNative, "rn-no-rn-image-import");
2228 assert!(re.is_match("import { Image } from 'react-native'"));
2229 assert!(re.is_match("import { View, Image } from 'react-native'"));
2230 assert!(re.is_match("import { Image, Text } from 'react-native'"));
2231 assert!(!re.is_match("import { Image } from 'expo-image'"));
2233 assert!(!re.is_match("import { ImageBackground } from 'react-native'"));
2235 }
2236
2237 #[test]
2238 fn rn_no_custom_header_pattern() {
2239 let re = regex_for(Preset::ReactNative, "rn-no-custom-header");
2240 assert!(re.is_match("header: () => <CustomHeader />"));
2241 assert!(re.is_match("header: () =>"));
2242 assert!(!re.is_match("headerTitle: 'Settings'"));
2244 }
2245
2246 #[test]
2247 fn rn_no_fonts_usefonts_pattern() {
2248 let re = regex_for(Preset::ReactNative, "rn-no-fonts-usefonts");
2249 assert!(re.is_match("const [loaded] = useFonts({ Inter: require('./fonts/Inter.ttf') })"));
2250 assert!(re.is_match("useFonts({"));
2251 assert!(!re.is_match("useForm({ mode: 'onChange' })"));
2253 }
2254
2255 #[test]
2256 fn rn_no_font_loadasync_pattern() {
2257 let re = regex_for(Preset::ReactNative, "rn-no-font-loadasync");
2258 assert!(re.is_match("await Font.loadAsync({ Inter: require('./Inter.ttf') })"));
2259 assert!(re.is_match("Font.loadAsync(fonts)"));
2260 assert!(!re.is_match("await Image.loadAsync(uri)"));
2262 }
2263
2264 #[test]
2265 fn rn_no_inline_intl_numberformat_pattern() {
2266 let re = regex_for(Preset::ReactNative, "rn-no-inline-intl-numberformat");
2267 assert!(re.is_match("new Intl.NumberFormat('en-US').format(price)"));
2268 assert!(re.is_match("const fmt = new Intl.NumberFormat('de-DE', { style: 'currency' })"));
2269 assert!(!re.is_match("fmt.format(1234)"));
2271 }
2272
2273 #[test]
2274 fn rn_no_inline_intl_datetimeformat_pattern() {
2275 let re = regex_for(Preset::ReactNative, "rn-no-inline-intl-datetimeformat");
2276 assert!(re.is_match("new Intl.DateTimeFormat('en-US').format(date)"));
2277 assert!(re.is_match("const fmt = new Intl.DateTimeFormat('ja-JP', opts)"));
2278 assert!(!re.is_match("fmt.format(date)"));
2279 }
2280}