1use crate::cli::toml_config::{ScopedPreset, 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 skip_strings: true,
329 ..Default::default()
330 },
331 TomlRule {
332 id: "require-use-client-for-hooks".into(),
333 rule_type: "required-pattern".into(),
334 severity: "error".into(),
335 glob: Some("app/**".into()),
336 pattern: Some("use client".into()),
337 regex: true,
338 condition_pattern: Some(r"use(State|Effect|Context|Reducer|Callback|Memo|Ref|Transition|DeferredValue|InsertionEffect|SyncExternalStore|FormStatus|Optimistic)\s*\(".into()),
339 message: "Files using React hooks must include 'use client' directive in App Router".into(),
340 ..Default::default()
341 },
342 TomlRule {
343 id: "use-next-link".into(),
344 rule_type: "banned-pattern".into(),
345 severity: "warning".into(),
346 glob: Some("**/*.{tsx,jsx}".into()),
347 pattern: Some(r#"<a\s+href=["']/"#.into()),
348 regex: true,
349 message: "Use next/link instead of <a> for client-side navigation".into(),
350 suggest: Some("Import Link from 'next/link' and use <Link> component".into()),
351 ..Default::default()
352 },
353 TomlRule {
354 id: "no-next-router-in-app".into(),
355 rule_type: "banned-import".into(),
356 severity: "error".into(),
357 glob: Some("app/**".into()),
358 packages: vec!["next/router".into()],
359 message: "next/router is not available in App Router — use next/navigation instead".into(),
360 ..Default::default()
361 },
362 TomlRule {
363 id: "no-sync-scripts".into(),
364 rule_type: "banned-pattern".into(),
365 severity: "warning".into(),
366 glob: Some("**/*.{tsx,jsx}".into()),
367 pattern: Some(r"<script\s".into()),
368 regex: true,
369 message: "Use next/script instead of <script> for optimized script loading".into(),
370 suggest: Some("Import Script from 'next/script' and use <Script> component".into()),
371 ..Default::default()
372 },
373 TomlRule {
374 id: "no-link-fonts".into(),
375 rule_type: "banned-pattern".into(),
376 severity: "warning".into(),
377 glob: Some("**/*.{tsx,jsx}".into()),
378 pattern: Some(r"<link[^>]*fonts\.googleapis\.com".into()),
379 regex: true,
380 message: "Use next/font instead of Google Fonts <link> for zero layout shift".into(),
381 suggest: Some("Import from 'next/font/google' for automatic font optimization".into()),
382 ..Default::default()
383 },
384 ],
385 Preset::React => {
386 #[allow(unused_mut)]
387 let mut rules = vec![
388 TomlRule {
390 id: "no-array-index-key".into(),
391 rule_type: "banned-pattern".into(),
392 severity: "error".into(),
393 glob: Some("**/*.{tsx,jsx}".into()),
394 pattern: Some(r"key=\{[a-zA-Z_]*[iI](?:ndex|dx)".into()),
395 regex: true,
396 message: "Don't use array index as key — causes bugs on reorder/filter".into(),
397 suggest: Some("Use a stable unique identifier from the data instead".into()),
398 ..Default::default()
399 },
400 TomlRule {
401 id: "no-conditional-render-zero".into(),
402 rule_type: "banned-pattern".into(),
403 severity: "warning".into(),
404 glob: Some("**/*.{tsx,jsx}".into()),
405 pattern: Some(r"\{\s*\w+\.length\s*&&".into()),
406 regex: true,
407 message: "array.length && <JSX> renders '0' when empty — use array.length > 0".into(),
408 suggest: Some("Replace {arr.length && ...} with {arr.length > 0 && ...}".into()),
409 ..Default::default()
410 },
411 TomlRule {
412 id: "no-nested-component-def".into(),
413 rule_type: "no-nested-components".into(),
414 severity: "error".into(),
415 glob: Some("**/*.{tsx,jsx}".into()),
416 message: "Component defined inside another component — causes remounting on every render".into(),
417 suggest: Some("Move component definition to module scope or extract to a separate file".into()),
418 ..Default::default()
419 },
420 TomlRule {
422 id: "no-dangerous-html".into(),
423 rule_type: "banned-pattern".into(),
424 severity: "warning".into(),
425 glob: Some("**/*.{tsx,jsx}".into()),
426 pattern: Some("dangerouslySetInnerHTML".into()),
427 message: "dangerouslySetInnerHTML can lead to XSS — sanitize content or use a safe alternative".into(),
428 ..Default::default()
429 },
430 TomlRule {
432 id: "no-full-lodash-import".into(),
433 rule_type: "banned-import".into(),
434 severity: "warning".into(),
435 packages: vec!["lodash".into()],
436 message: "Importing all of lodash (~70kb) — use lodash-es or per-function imports like lodash/debounce".into(),
437 ..Default::default()
438 },
439 TomlRule {
440 id: "no-moment".into(),
441 rule_type: "banned-import".into(),
442 severity: "warning".into(),
443 packages: vec!["moment".into(), "moment-timezone".into()],
444 message: "moment.js is 300kb+ and deprecated — use date-fns, dayjs, or Temporal API".into(),
445 ..Default::default()
446 },
447 TomlRule {
448 id: "no-moment-dep".into(),
449 rule_type: "banned-dependency".into(),
450 severity: "warning".into(),
451 packages: vec!["moment".into(), "moment-timezone".into()],
452 message: "moment.js is 300kb+ and deprecated — use date-fns, dayjs, or Temporal API".into(),
453 ..Default::default()
454 },
455 TomlRule {
456 id: "no-new-function".into(),
457 rule_type: "banned-pattern".into(),
458 severity: "error".into(),
459 pattern: Some(r"\bnew\s+Function\s*\(".into()),
460 regex: true,
461 message: "new Function() is equivalent to eval() — avoid dynamic code execution".into(),
462 ..Default::default()
463 },
464 TomlRule {
466 id: "no-transition-all".into(),
467 rule_type: "banned-pattern".into(),
468 severity: "warning".into(),
469 glob: Some("**/*.{tsx,jsx}".into()),
470 pattern: Some(r#"transition:\s*["']all"#.into()),
471 regex: true,
472 message: "transition: 'all' is expensive — list specific properties to transition".into(),
473 ..Default::default()
474 },
475 TomlRule {
476 id: "no-layout-animation".into(),
477 rule_type: "banned-pattern".into(),
478 severity: "warning".into(),
479 glob: Some("**/*.{tsx,jsx,css}".into()),
480 pattern: Some(r"(?:animation|transition)(?:-property)?:\s*(?:.*\b(?:width|height|top|left|right|bottom|margin|padding)\b)".into()),
481 regex: true,
482 message: "Animating layout properties (width/height/margin) triggers expensive reflows — use transform instead".into(),
483 suggest: Some("Use transform: scale() or translate() for smooth GPU-accelerated animations".into()),
484 ..Default::default()
485 },
486 TomlRule {
488 id: "no-sequential-await".into(),
489 rule_type: "window-pattern".into(),
490 severity: "warning".into(),
491 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
492 pattern: Some(r"^\s*(?:const\s+\w+\s*=\s*)?await\s".into()),
493 condition_pattern: Some(r"^\s*(?:const\s+\w+\s*=\s*)?await\s".into()),
494 max_count: Some(3),
495 regex: true,
496 message: "Sequential await statements may run slower than necessary — use Promise.all() for independent operations".into(),
497 suggest: Some("const [a, b] = await Promise.all([fetchA(), fetchB()])".into()),
498 ..Default::default()
499 },
500 TomlRule {
502 id: "no-derived-state-effect".into(),
503 rule_type: "banned-pattern".into(),
504 severity: "warning".into(),
505 glob: Some("**/*.{tsx,jsx}".into()),
506 pattern: Some(r"useEffect\(\(\)\s*(?:=>)?\s*\{?\s*set[A-Z]\w*\(".into()),
507 regex: true,
508 message: "useEffect that only calls setState is derived state — compute during render instead".into(),
509 suggest: Some("Replace with: const derived = useMemo(() => compute(dep), [dep])".into()),
510 ..Default::default()
511 },
512 TomlRule {
513 id: "no-fetch-in-effect".into(),
514 rule_type: "banned-pattern".into(),
515 severity: "warning".into(),
516 glob: Some("**/*.{tsx,jsx}".into()),
517 pattern: Some(r"useEffect\([^)]*\(\)\s*(?:=>)?\s*\{[^}]*\bfetch\s*\(".into()),
518 regex: true,
519 message: "Avoid fetch() inside useEffect — use a data-fetching library (React Query, SWR) or server components".into(),
520 ..Default::default()
521 },
522 TomlRule {
523 id: "no-lazy-state-init".into(),
524 rule_type: "banned-pattern".into(),
525 severity: "warning".into(),
526 glob: Some("**/*.{tsx,jsx}".into()),
527 pattern: Some(r"useState\(\w+\(.*\)\)".into()),
528 regex: true,
529 message: "Expensive function call in useState runs every render — use lazy initializer: useState(() => fn())".into(),
530 suggest: Some("Wrap in a function: useState(() => computeValue()) for one-time initialization".into()),
531 ..Default::default()
532 },
533 TomlRule {
534 id: "no-object-dep-array".into(),
535 rule_type: "banned-pattern".into(),
536 severity: "warning".into(),
537 glob: Some("**/*.{tsx,jsx}".into()),
538 pattern: Some(r"(?:useEffect|useMemo|useCallback)\([^)]+,\s*\[[^\]]*(?:\{[^}]*\}|\[[^\]]*\])".into()),
539 regex: true,
540 message: "Object/array literal in dependency array creates a new reference every render — extract to useMemo or a ref".into(),
541 ..Default::default()
542 },
543 TomlRule {
544 id: "no-default-object-prop".into(),
545 rule_type: "banned-pattern".into(),
546 severity: "warning".into(),
547 glob: Some("**/*.{tsx,jsx}".into()),
548 pattern: Some(r"(?:function\s+[A-Z]|const\s+[A-Z]\w*\s*=)\s*.*(?:\{\s*\w+\s*=\s*(?:\{\}|\[\])\s*[,}])".into()),
549 regex: true,
550 message: "Default {} or [] in component params creates a new reference every render — extract to a module-level constant".into(),
551 ..Default::default()
552 },
553 TomlRule {
555 id: "no-forwardref".into(),
556 rule_type: "banned-pattern".into(),
557 severity: "warning".into(),
558 glob: Some("**/*.{tsx,jsx}".into()),
559 pattern: Some(r"\bforwardRef\s*[<(]".into()),
560 regex: true,
561 message: "forwardRef is unnecessary in React 19 — ref is a regular prop now".into(),
562 suggest: Some("Accept ref as a prop directly: function Component({ ref, ...props })".into()),
563 ..Default::default()
564 },
565 TomlRule {
566 id: "no-use-context".into(),
567 rule_type: "banned-pattern".into(),
568 severity: "warning".into(),
569 glob: Some("**/*.{tsx,jsx}".into()),
570 pattern: Some(r"\buseContext\s*\(".into()),
571 regex: true,
572 message: "useContext is replaced by use() in React 19".into(),
573 suggest: Some("Replace useContext(MyContext) with use(MyContext)".into()),
574 ..Default::default()
575 },
576 TomlRule {
578 id: "no-unsafe-createcontext-default".into(),
579 rule_type: "banned-pattern".into(),
580 severity: "warning".into(),
581 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
582 pattern: Some(r#"createContext\s*\(\s*(?:\{\}|\[\]|undefined|0|''|"")\s*\)"#.into()),
583 regex: true,
584 message: "Unsafe createContext default value — use null and handle the missing-provider case".into(),
585 suggest: Some("Use createContext<T>(null) and throw in a custom hook if context is null".into()),
586 ..Default::default()
587 },
588 TomlRule {
589 id: "no-effect-callback-sync".into(),
590 rule_type: "banned-pattern".into(),
591 severity: "warning".into(),
592 glob: Some("**/*.{tsx,jsx}".into()),
593 pattern: Some(r"useEffect\(\(\)\s*(?:=>)?\s*\{?\s*on[A-Z]\w*\(".into()),
594 regex: true,
595 message: "Calling event callbacks directly in useEffect may indicate misuse — effects should synchronize, not fire events".into(),
596 suggest: Some("Move the callback invocation to a user action handler or derive state instead".into()),
597 ..Default::default()
598 },
599 TomlRule {
600 id: "no-usestate-browser-api".into(),
601 rule_type: "banned-pattern".into(),
602 severity: "error".into(),
603 glob: Some("**/*.{tsx,jsx}".into()),
604 pattern: Some(r"useState\(\s*(?:JSON\.parse\s*\()?\s*(?:localStorage|sessionStorage)\.".into()),
605 regex: true,
606 message: "Browser storage API in useState crashes during SSR — initialize with a default and read in useEffect".into(),
607 suggest: Some("useState('default') + useEffect(() => setState(localStorage.getItem(...)), [])".into()),
608 ..Default::default()
609 },
610 TomlRule {
612 id: "no-hardcoded-jsx-id".into(),
613 rule_type: "banned-pattern".into(),
614 severity: "warning".into(),
615 glob: Some("**/*.{tsx,jsx}".into()),
616 pattern: Some(r#"id=["'][^"']+["']"#.into()),
617 regex: true,
618 message: "Hardcoded id attribute — multiple instances will share the same ID and clash".into(),
619 suggest: Some("Use useId() to generate unique, SSR-safe IDs".into()),
620 ..Default::default()
621 },
622 TomlRule {
623 id: "no-clone-element".into(),
624 rule_type: "banned-pattern".into(),
625 severity: "warning".into(),
626 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
627 pattern: Some("cloneElement".into()),
628 message: "cloneElement breaks with Server Components, React.lazy, and async children".into(),
629 suggest: Some("Use React Context to pass data to children instead".into()),
630 ..Default::default()
631 },
632 TomlRule {
633 id: "no-react-children-api".into(),
634 rule_type: "banned-pattern".into(),
635 severity: "warning".into(),
636 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
637 pattern: Some(r"Children\.(map|forEach|count|only|toArray)".into()),
638 regex: true,
639 message: "React.Children API is fragile — breaks with Server Components and async children".into(),
640 suggest: Some("Use composition patterns or React Context instead".into()),
641 ..Default::default()
642 },
643 TomlRule {
644 id: "no-direct-window-listener".into(),
645 rule_type: "banned-pattern".into(),
646 severity: "warning".into(),
647 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
648 pattern: Some(r"window\.(addEventListener|removeEventListener)".into()),
649 regex: true,
650 message: "Direct window reference breaks in iframes, portals, and pop-out windows".into(),
651 suggest: Some("Use ref.current?.ownerDocument.defaultView to resolve the correct window".into()),
652 ..Default::default()
653 },
654 TomlRule {
655 id: "no-direct-document-query".into(),
656 rule_type: "banned-pattern".into(),
657 severity: "warning".into(),
658 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
659 pattern: Some(r"document\.(getElementById|querySelector|querySelectorAll|getElementsBy)".into()),
660 regex: true,
661 message: "Direct DOM queries bypass React and break in SSR — use refs instead".into(),
662 suggest: Some("Use useRef() and ref.current for DOM access".into()),
663 ..Default::default()
664 },
665 TomlRule {
667 id: "no-regexp-in-render".into(),
668 rule_type: "banned-pattern".into(),
669 severity: "warning".into(),
670 glob: Some("**/*.{tsx,jsx}".into()),
671 pattern: Some(r"new\s+RegExp\s*\(".into()),
672 regex: true,
673 message: "new RegExp() in a component body re-compiles every render — extract to module scope or useMemo".into(),
674 suggest: Some("Move the RegExp to module scope: const MY_RE = new RegExp(...)".into()),
675 ..Default::default()
676 },
677 TomlRule {
678 id: "no-lucide-barrel".into(),
679 rule_type: "banned-pattern".into(),
680 severity: "warning".into(),
681 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]lucide-react['"]"#.into()),
682 regex: true,
683 message: "Barrel import from lucide-react pulls in all icons — use lucide-react/icons/IconName".into(),
684 suggest: Some("Import specific icons: import { Icon } from 'lucide-react/icons/Icon'".into()),
685 ..Default::default()
686 },
687 TomlRule {
688 id: "no-mui-barrel".into(),
689 rule_type: "banned-pattern".into(),
690 severity: "warning".into(),
691 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]@mui/material['"]"#.into()),
692 regex: true,
693 message: "Barrel import from @mui/material increases bundle size — use deep imports".into(),
694 suggest: Some("Import specific components: import Button from '@mui/material/Button'".into()),
695 ..Default::default()
696 },
697 TomlRule {
698 id: "no-mui-icons-barrel".into(),
699 rule_type: "banned-pattern".into(),
700 severity: "warning".into(),
701 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]@mui/icons-material['"]"#.into()),
702 regex: true,
703 message: "Barrel import from @mui/icons-material increases bundle size — use deep imports".into(),
704 suggest: Some("Import specific icons: import HomeIcon from '@mui/icons-material/Home'".into()),
705 ..Default::default()
706 },
707 TomlRule {
708 id: "no-react-icons-barrel".into(),
709 rule_type: "banned-pattern".into(),
710 severity: "warning".into(),
711 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]react-icons['"]"#.into()),
712 regex: true,
713 message: "Barrel import from react-icons pulls in all icon sets — import from a specific set".into(),
714 suggest: Some("Import from a specific set: import { FaHome } from 'react-icons/fa'".into()),
715 ..Default::default()
716 },
717 TomlRule {
718 id: "no-date-fns-barrel".into(),
719 rule_type: "banned-pattern".into(),
720 severity: "warning".into(),
721 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]date-fns['"]"#.into()),
722 regex: true,
723 message: "Barrel import from date-fns increases bundle size — use subpath imports".into(),
724 suggest: Some("Import specific functions: import { format } from 'date-fns/format'".into()),
725 ..Default::default()
726 },
727 ];
728
729 {
730 rules.push(TomlRule {
731 id: "max-component-size".into(),
732 rule_type: "max-component-size".into(),
733 severity: "warning".into(),
734 glob: Some("**/*.{tsx,jsx}".into()),
735 max_count: Some(150),
736 message: "Component exceeds 150 lines — split into smaller components".into(),
737 suggest: Some("Extract logic into custom hooks or break into sub-components".into()),
738 ..Default::default()
739 });
740 rules.push(TomlRule {
741 id: "prefer-use-reducer".into(),
742 rule_type: "prefer-use-reducer".into(),
743 severity: "warning".into(),
744 glob: Some("**/*.{tsx,jsx}".into()),
745 max_count: Some(4),
746 message: "Component has 4+ useState calls — consider useReducer for related state".into(),
747 suggest: Some("Group related state into a single useReducer".into()),
748 ..Default::default()
749 });
750 rules.push(TomlRule {
751 id: "no-cascading-set-state".into(),
752 rule_type: "no-cascading-set-state".into(),
753 severity: "warning".into(),
754 glob: Some("**/*.{tsx,jsx}".into()),
755 max_count: Some(3),
756 message: "useEffect has 3+ setState calls — consider useReducer or derived state".into(),
757 suggest: Some("Combine state updates with useReducer or compute derived values".into()),
758 ..Default::default()
759 });
760 }
761
762 rules
763 }
764 Preset::NextjsBestPractices => {
765 #[allow(unused_mut)]
766 let mut rules = vec![
767 TomlRule {
769 id: "use-next-image".into(),
770 rule_type: "banned-pattern".into(),
771 severity: "warning".into(),
772 glob: Some("**/*.{tsx,jsx}".into()),
773 pattern: Some(r"<img\s".into()),
774 regex: true,
775 exclude_glob: vec!["**/opengraph-image.*".into(), "**/og/**".into()],
776 message: "Use next/image instead of <img> for automatic optimization".into(),
777 suggest: Some("Import Image from 'next/image' and use <Image> component".into()),
778 ..Default::default()
779 },
780 TomlRule {
781 id: "next-image-fill-needs-sizes".into(),
782 rule_type: "window-pattern".into(),
783 severity: "warning".into(),
784 glob: Some("**/*.{tsx,jsx}".into()),
785 pattern: Some(r"<Image[^>]*\bfill\b".into()),
786 condition_pattern: Some(r"\bsizes\s*=".into()),
787 max_count: Some(3),
788 regex: true,
789 message: "<Image fill> without sizes attribute downloads unnecessarily large images".into(),
790 suggest: Some("Add sizes prop, e.g. sizes=\"(max-width: 768px) 100vw, 50vw\"".into()),
791 ..Default::default()
792 },
793 TomlRule {
795 id: "use-next-link".into(),
796 rule_type: "banned-pattern".into(),
797 severity: "warning".into(),
798 glob: Some("**/*.{tsx,jsx}".into()),
799 pattern: Some(r#"<a\s+href=["']/"#.into()),
800 regex: true,
801 message: "Use next/link instead of <a> for client-side navigation".into(),
802 suggest: Some("Import Link from 'next/link' and use <Link> component".into()),
803 ..Default::default()
804 },
805 TomlRule {
806 id: "no-next-router-in-app".into(),
807 rule_type: "banned-import".into(),
808 severity: "error".into(),
809 glob: Some("app/**".into()),
810 packages: vec!["next/router".into()],
811 message: "next/router is not available in App Router — use next/navigation instead".into(),
812 ..Default::default()
813 },
814 TomlRule {
815 id: "no-next-head".into(),
816 rule_type: "banned-import".into(),
817 severity: "error".into(),
818 glob: Some("app/**".into()),
819 packages: vec!["next/head".into()],
820 message: "next/head is not supported in App Router — use the Metadata API instead".into(),
821 ..Default::default()
822 },
823 TomlRule {
824 id: "no-client-side-redirect".into(),
825 rule_type: "banned-pattern".into(),
826 severity: "warning".into(),
827 glob: Some("**/*.{tsx,jsx}".into()),
828 pattern: Some(r"useEffect\([^)]*\(\)\s*(?:=>)?\s*\{[^}]*(?:router\.push|window\.location)".into()),
829 regex: true,
830 message: "Avoid client-side redirects in useEffect — use server-side redirect() or middleware".into(),
831 suggest: Some("Move redirect logic to middleware.ts or use redirect() in a server component".into()),
832 ..Default::default()
833 },
834 TomlRule {
836 id: "no-sync-scripts".into(),
837 rule_type: "banned-pattern".into(),
838 severity: "warning".into(),
839 glob: Some("**/*.{tsx,jsx}".into()),
840 pattern: Some(r"<script\s".into()),
841 regex: true,
842 message: "Use next/script instead of <script> for optimized script loading".into(),
843 suggest: Some("Import Script from 'next/script' and use <Script> component".into()),
844 ..Default::default()
845 },
846 TomlRule {
847 id: "no-link-fonts".into(),
848 rule_type: "banned-pattern".into(),
849 severity: "warning".into(),
850 glob: Some("**/*.{tsx,jsx}".into()),
851 pattern: Some(r"<link[^>]*fonts\.googleapis\.com".into()),
852 regex: true,
853 message: "Use next/font instead of Google Fonts <link> for zero layout shift".into(),
854 suggest: Some("Import from 'next/font/google' for automatic font optimization".into()),
855 ..Default::default()
856 },
857 TomlRule {
858 id: "no-css-link".into(),
859 rule_type: "banned-pattern".into(),
860 severity: "warning".into(),
861 glob: Some("**/*.{tsx,jsx}".into()),
862 pattern: Some(r#"<link[^>]*rel=["']stylesheet["']"#.into()),
863 regex: true,
864 message: "Import CSS files directly instead of using <link rel=\"stylesheet\">".into(),
865 suggest: Some("Use import './styles.css' for automatic bundling and optimization".into()),
866 ..Default::default()
867 },
868 TomlRule {
870 id: "no-private-env-client".into(),
871 rule_type: "banned-pattern".into(),
872 severity: "error".into(),
873 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
874 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()),
875 regex: true,
876 file_contains: Some("use client".into()),
877 message: "Private env vars are undefined in client components — prefix with NEXT_PUBLIC_".into(),
878 skip_strings: true,
879 ..Default::default()
880 },
881 TomlRule {
882 id: "require-use-client-for-hooks".into(),
883 rule_type: "required-pattern".into(),
884 severity: "error".into(),
885 glob: Some("app/**".into()),
886 pattern: Some("use client".into()),
887 regex: true,
888 condition_pattern: Some(r"use(State|Effect|Context|Reducer|Callback|Memo|Ref|Transition|DeferredValue|InsertionEffect|SyncExternalStore|FormStatus|Optimistic)\s*\(".into()),
889 message: "Files using React hooks must include 'use client' directive in App Router".into(),
890 ..Default::default()
891 },
892 TomlRule {
893 id: "no-async-client-component".into(),
894 rule_type: "banned-pattern".into(),
895 severity: "error".into(),
896 glob: Some("**/*.{tsx,jsx}".into()),
897 pattern: Some(r"(?:export\s+default\s+)?async\s+function\s+[A-Z]".into()),
898 regex: true,
899 file_contains: Some("use client".into()),
900 message: "Client components cannot be async — only server components support async/await".into(),
901 suggest: Some("Remove 'use client' to make this a server component, or remove async and use useEffect for data fetching".into()),
902 ..Default::default()
903 },
904 TomlRule {
906 id: "require-metadata-in-pages".into(),
907 rule_type: "required-pattern".into(),
908 severity: "warning".into(),
909 glob: Some("app/**/page.{ts,tsx,js,jsx}".into()),
910 pattern: Some(r"(?:export\s+(?:const\s+metadata|(?:async\s+)?function\s+generateMetadata))".into()),
911 regex: true,
912 message: "Page files should export metadata or generateMetadata for SEO".into(),
913 suggest: Some("Add: export const metadata = { title: '...', description: '...' }".into()),
914 ..Default::default()
915 },
916 TomlRule {
918 id: "no-redirect-in-try-catch".into(),
919 rule_type: "banned-pattern".into(),
920 severity: "error".into(),
921 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
922 pattern: Some(r"try\s*\{[^}]*\bredirect\s*\(".into()),
923 regex: true,
924 message: "redirect() throws a special error — calling it inside try/catch will swallow the redirect".into(),
925 suggest: Some("Move redirect() outside the try/catch block".into()),
926 ..Default::default()
927 },
928 TomlRule {
930 id: "server-action-requires-auth".into(),
931 rule_type: "required-pattern".into(),
932 severity: "warning".into(),
933 glob: Some("app/**/*.{ts,tsx}".into()),
934 pattern: Some(r"(?:verifySession|getSession|auth\(\)|currentUser|getServerSession)".into()),
935 regex: true,
936 condition_pattern: Some("'use server'".into()),
937 message: "Server actions should verify authentication before performing mutations".into(),
938 suggest: Some("Add an auth check: const session = await getSession()".into()),
939 ..Default::default()
940 },
941 TomlRule {
942 id: "server-action-requires-validation".into(),
943 rule_type: "required-pattern".into(),
944 severity: "warning".into(),
945 glob: Some("app/**/*.{ts,tsx}".into()),
946 pattern: Some(r"(?:\.parse\(|\.safeParse\(|z\.object\(|\.validate\()".into()),
947 regex: true,
948 condition_pattern: Some("'use server'".into()),
949 message: "Server actions should validate input — never trust client data".into(),
950 suggest: Some("Use Zod or similar: const data = schema.parse(formData)".into()),
951 ..Default::default()
952 },
953 TomlRule {
955 id: "no-suppress-hydration-warning".into(),
956 rule_type: "banned-pattern".into(),
957 severity: "warning".into(),
958 glob: Some("**/*.{tsx,jsx}".into()),
959 pattern: Some("suppressHydrationWarning".into()),
960 message: "suppressHydrationWarning hides real bugs — fix the mismatch instead".into(),
961 suggest: Some("Use useEffect + state to defer client-only content, or move to a Client Component".into()),
962 ..Default::default()
963 },
964 ];
965
966 {
967 rules.push(TomlRule {
968 id: "max-component-size".into(),
969 rule_type: "max-component-size".into(),
970 severity: "warning".into(),
971 glob: Some("**/*.{tsx,jsx}".into()),
972 max_count: Some(150),
973 message: "Component exceeds 150 lines — split into smaller components".into(),
974 suggest: Some("Extract logic into custom hooks or break into sub-components".into()),
975 ..Default::default()
976 });
977 rules.push(TomlRule {
978 id: "no-nested-components".into(),
979 rule_type: "no-nested-components".into(),
980 severity: "error".into(),
981 glob: Some("**/*.{tsx,jsx}".into()),
982 message: "Component defined inside another component — causes remounting on every render".into(),
983 suggest: Some("Move component definition to module scope or extract to a separate file".into()),
984 ..Default::default()
985 });
986 rules.push(TomlRule {
987 id: "prefer-use-reducer".into(),
988 rule_type: "prefer-use-reducer".into(),
989 severity: "warning".into(),
990 glob: Some("**/*.{tsx,jsx}".into()),
991 max_count: Some(4),
992 message: "Component has 4+ useState calls — consider useReducer for related state".into(),
993 suggest: Some("Group related state into a single useReducer".into()),
994 ..Default::default()
995 });
996 rules.push(TomlRule {
997 id: "no-cascading-set-state".into(),
998 rule_type: "no-cascading-set-state".into(),
999 severity: "warning".into(),
1000 glob: Some("**/*.{tsx,jsx}".into()),
1001 max_count: Some(3),
1002 message: "useEffect has 3+ setState calls — consider useReducer or derived state".into(),
1003 suggest: Some("Combine state updates with useReducer or compute derived values".into()),
1004 ..Default::default()
1005 });
1006 }
1007
1008 rules
1009 }
1010 Preset::AiCodegen => vec![
1011 TomlRule {
1012 id: "no-placeholder-text".into(),
1013 rule_type: "banned-pattern".into(),
1014 severity: "warning".into(),
1015 pattern: Some(r"(?i)lorem ipsum".into()),
1016 regex: true,
1017 message: "Placeholder text detected — replace with real content".into(),
1018 ..Default::default()
1019 },
1020 TomlRule {
1021 id: "no-unresolved-todos".into(),
1022 rule_type: "banned-pattern".into(),
1023 severity: "warning".into(),
1024 pattern: Some(r"(?://|/?\*)\s*(TODO|FIXME|HACK|XXX)\b".into()),
1025 regex: true,
1026 message: "Unresolved TODO/FIXME comment — address or remove before merging".into(),
1027 ..Default::default()
1028 },
1029 TomlRule {
1030 id: "no-type-any".into(),
1031 rule_type: "banned-pattern".into(),
1032 severity: "error".into(),
1033 glob: Some("**/*.{ts,tsx}".into()),
1034 pattern: Some(r"[:<,]\s*any\b".into()),
1035 regex: true,
1036 exclude_glob: vec!["**/*.d.ts".into()],
1037 message: "Avoid using 'any' type — use a specific type or 'unknown'".into(),
1038 ..Default::default()
1039 },
1040 TomlRule {
1041 id: "no-empty-catch".into(),
1042 rule_type: "banned-pattern".into(),
1043 severity: "error".into(),
1044 pattern: Some(r"catch\s*\([^)]*\)\s*\{\s*\}".into()),
1045 regex: true,
1046 message: "Empty catch block swallows errors — handle or re-throw the error".into(),
1047 ..Default::default()
1048 },
1049 TomlRule {
1050 id: "no-console-log".into(),
1051 rule_type: "banned-pattern".into(),
1052 severity: "warning".into(),
1053 pattern: Some(r"console\.(log|debug)\(".into()),
1054 regex: true,
1055 exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
1056 message: "Remove console.log/debug before merging — use a proper logger if needed".into(),
1057 ..Default::default()
1058 },
1059 TomlRule {
1060 id: "no-ts-ignore".into(),
1061 rule_type: "banned-pattern".into(),
1062 severity: "error".into(),
1063 glob: Some("**/*.{ts,tsx}".into()),
1064 pattern: Some("@ts-ignore".into()),
1065 message: "Use @ts-expect-error instead of @ts-ignore for type suppressions".into(),
1066 ..Default::default()
1067 },
1068 TomlRule {
1069 id: "no-as-any".into(),
1070 rule_type: "banned-pattern".into(),
1071 severity: "error".into(),
1072 glob: Some("**/*.{ts,tsx}".into()),
1073 pattern: Some(r"\bas\s+any\b".into()),
1074 regex: true,
1075 message: "Avoid 'as any' type assertion — use proper types or 'as unknown'".into(),
1076 ..Default::default()
1077 },
1078 TomlRule {
1079 id: "no-eslint-disable".into(),
1080 rule_type: "banned-pattern".into(),
1081 severity: "warning".into(),
1082 pattern: Some("eslint-disable".into()),
1083 message: "Remove eslint-disable comment — fix the underlying issue instead".into(),
1084 ..Default::default()
1085 },
1086 TomlRule {
1087 id: "no-ts-nocheck".into(),
1088 rule_type: "banned-pattern".into(),
1089 severity: "error".into(),
1090 glob: Some("**/*.{ts,tsx}".into()),
1091 pattern: Some("@ts-nocheck".into()),
1092 message: "Do not disable type checking for entire files — fix type errors instead".into(),
1093 ..Default::default()
1094 },
1095 TomlRule {
1096 id: "no-var".into(),
1097 rule_type: "banned-pattern".into(),
1098 severity: "error".into(),
1099 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
1100 pattern: Some(r"\bvar\s+\w".into()),
1101 regex: true,
1102 exclude_glob: vec!["**/*.d.ts".into()],
1103 message: "Use 'let' or 'const' instead of 'var'".into(),
1104 ..Default::default()
1105 },
1106 TomlRule {
1107 id: "no-require-in-ts".into(),
1108 rule_type: "banned-pattern".into(),
1109 severity: "warning".into(),
1110 glob: Some("**/*.{ts,tsx}".into()),
1111 pattern: Some(r"\brequire\s*\(".into()),
1112 regex: true,
1113 message: "Use ES module 'import' instead of CommonJS 'require()' in TypeScript".into(),
1114 ..Default::default()
1115 },
1116 TomlRule {
1117 id: "no-non-null-assertion".into(),
1118 rule_type: "banned-pattern".into(),
1119 severity: "warning".into(),
1120 glob: Some("**/*.{ts,tsx}".into()),
1121 pattern: Some(r"\w![.\[]".into()),
1122 regex: true,
1123 message: "Avoid non-null assertion (!) — use optional chaining (?.) or proper null checks".into(),
1124 ..Default::default()
1125 },
1126 ],
1127 Preset::Accessibility => {
1128 #[allow(unused_mut)]
1129 let mut rules = vec![
1130 TomlRule {
1131 id: "no-div-click-handler".into(),
1132 rule_type: "banned-pattern".into(),
1133 severity: "error".into(),
1134 glob: Some("**/*.{tsx,jsx}".into()),
1135 pattern: Some(r"<div[^>]+onClick\s*=".into()),
1136 regex: true,
1137 message: "Non-interactive <div> with onClick is not keyboard accessible — use <button> instead".into(),
1138 suggest: Some("Replace <div onClick=...> with <button onClick=...>".into()),
1139 ..Default::default()
1140 },
1141 TomlRule {
1142 id: "no-span-click-handler".into(),
1143 rule_type: "banned-pattern".into(),
1144 severity: "error".into(),
1145 glob: Some("**/*.{tsx,jsx}".into()),
1146 pattern: Some(r"<span[^>]+onClick\s*=".into()),
1147 regex: true,
1148 message: "Non-interactive <span> with onClick is not keyboard accessible — use <button> instead".into(),
1149 suggest: Some("Replace <span onClick=...> with <button onClick=...>".into()),
1150 ..Default::default()
1151 },
1152 TomlRule {
1153 id: "no-outline-none".into(),
1154 rule_type: "banned-pattern".into(),
1155 severity: "warning".into(),
1156 glob: Some("**/*.{tsx,jsx}".into()),
1157 pattern: Some(r"\boutline-none\b".into()),
1158 regex: true,
1159 message: "outline-none removes the focus indicator — keyboard users can't see what's focused".into(),
1160 suggest: Some("Use focus-visible:outline-none with a custom focus ring instead".into()),
1161 ..Default::default()
1162 },
1163 TomlRule {
1164 id: "no-user-scalable-no".into(),
1165 rule_type: "banned-pattern".into(),
1166 severity: "error".into(),
1167 glob: Some("**/*.{tsx,jsx}".into()),
1168 pattern: Some(r"user-scalable\s*=\s*no".into()),
1169 regex: true,
1170 message: "user-scalable=no prevents zooming — violates WCAG 1.4.4 (Resize Text)".into(),
1171 suggest: Some("Remove user-scalable=no to allow pinch-to-zoom".into()),
1172 ..Default::default()
1173 },
1174 TomlRule {
1175 id: "no-autofocus-unrestricted".into(),
1176 rule_type: "banned-pattern".into(),
1177 severity: "warning".into(),
1178 glob: Some("**/*.{tsx,jsx}".into()),
1179 pattern: Some(r"\bautoFocus\b".into()),
1180 regex: true,
1181 message: "autoFocus can disorient screen reader users — use it sparingly (e.g., modals only)".into(),
1182 suggest: Some("Remove autoFocus or limit to modal/dialog initial focus".into()),
1183 ..Default::default()
1184 },
1185 TomlRule {
1186 id: "no-transition-all-tailwind".into(),
1187 rule_type: "banned-pattern".into(),
1188 severity: "warning".into(),
1189 glob: Some("**/*.{tsx,jsx}".into()),
1190 pattern: Some(r"\btransition-all\b".into()),
1191 regex: true,
1192 message: "transition-all can cause motion sickness — transition specific properties and respect prefers-reduced-motion".into(),
1193 suggest: Some("Use transition-colors, transition-opacity, or transition-transform instead".into()),
1194 ..Default::default()
1195 },
1196 TomlRule {
1197 id: "no-hardcoded-date-format".into(),
1198 rule_type: "banned-pattern".into(),
1199 severity: "warning".into(),
1200 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
1201 pattern: Some(r"\.toDateString\(\s*\)|\.toLocaleString\(\s*\)|\.toLocaleDateString\(\s*\)".into()),
1202 regex: true,
1203 message: "Date formatting without explicit locale is inconsistent across browsers — pass a locale".into(),
1204 suggest: Some("Pass an explicit locale: .toLocaleDateString('en-US', { ... })".into()),
1205 ..Default::default()
1206 },
1207 TomlRule {
1208 id: "no-inline-navigation-onclick".into(),
1209 rule_type: "banned-pattern".into(),
1210 severity: "warning".into(),
1211 glob: Some("**/*.{tsx,jsx}".into()),
1212 pattern: Some(r"onClick[^=]*=[^;]*window\.location".into()),
1213 regex: true,
1214 message: "onClick with window.location bypasses browser navigation — use <a> or router for accessible navigation".into(),
1215 suggest: Some("Use <a href=...> or your router's <Link> component instead".into()),
1216 ..Default::default()
1217 },
1218 ];
1219
1220 {
1221 rules.push(TomlRule {
1222 id: "require-img-alt".into(),
1223 rule_type: "require-img-alt".into(),
1224 severity: "error".into(),
1225 glob: Some("**/*.{tsx,jsx}".into()),
1226 message: "img element must have an alt attribute for screen readers".into(),
1227 suggest: Some("Add alt=\"description\" or alt=\"\" for decorative images".into()),
1228 ..Default::default()
1229 });
1230 }
1231
1232 rules
1233 }
1234 Preset::ReactNative => vec![
1235 TomlRule {
1236 id: "rn-no-touchable-opacity".into(),
1237 rule_type: "banned-pattern".into(),
1238 severity: "warning".into(),
1239 glob: Some("**/*.{tsx,jsx}".into()),
1240 pattern: Some(r"<TouchableOpacity|import\s+\{[^}]*TouchableOpacity".into()),
1241 regex: true,
1242 message: "TouchableOpacity is deprecated — use Pressable instead".into(),
1243 suggest: Some("Replace <TouchableOpacity> with <Pressable>".into()),
1244 ..Default::default()
1245 },
1246 TomlRule {
1247 id: "rn-no-touchable-highlight".into(),
1248 rule_type: "banned-pattern".into(),
1249 severity: "warning".into(),
1250 glob: Some("**/*.{tsx,jsx}".into()),
1251 pattern: Some(r"<TouchableHighlight|import\s+\{[^}]*TouchableHighlight".into()),
1252 regex: true,
1253 message: "TouchableHighlight is deprecated — use Pressable instead".into(),
1254 suggest: Some("Replace <TouchableHighlight> with <Pressable>".into()),
1255 ..Default::default()
1256 },
1257 TomlRule {
1258 id: "rn-no-legacy-shadow".into(),
1259 rule_type: "banned-pattern".into(),
1260 severity: "warning".into(),
1261 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
1262 pattern: Some(r"shadowColor\s*:|shadowOffset\s*:|shadowOpacity\s*:|shadowRadius\s*:".into()),
1263 regex: true,
1264 message: "Legacy shadow properties are iOS-only — use boxShadow (RN 0.76+) for cross-platform shadows".into(),
1265 suggest: Some("Use style={{ boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}".into()),
1266 ..Default::default()
1267 },
1268 TomlRule {
1269 id: "rn-no-rn-image-import".into(),
1270 rule_type: "banned-pattern".into(),
1271 severity: "warning".into(),
1272 glob: Some("**/*.{tsx,jsx}".into()),
1273 pattern: Some(r"import\s+\{[^}]*\bImage\b[^}]*\}\s+from\s+['\x22]react-native['\x22]".into()),
1274 regex: true,
1275 message: "react-native Image lacks caching and modern formats — use expo-image instead".into(),
1276 suggest: Some("Replace with: import { Image } from 'expo-image'".into()),
1277 ..Default::default()
1278 },
1279 TomlRule {
1280 id: "rn-no-custom-header".into(),
1281 rule_type: "banned-pattern".into(),
1282 severity: "warning".into(),
1283 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
1284 pattern: Some(r"header:\s*\(\)\s*=>".into()),
1285 regex: true,
1286 message: "Custom header render function loses native header animations — use headerTitle or screen options".into(),
1287 suggest: Some("Use screenOptions={{ headerTitle: ... }} instead of header: () => ...".into()),
1288 ..Default::default()
1289 },
1290 TomlRule {
1291 id: "rn-no-fonts-usefonts".into(),
1292 rule_type: "banned-pattern".into(),
1293 severity: "warning".into(),
1294 glob: Some("**/*.{tsx,jsx}".into()),
1295 pattern: Some(r"useFonts\s*\(\s*\{".into()),
1296 regex: true,
1297 message: "useFonts blocks rendering with a loading screen — use expo-font config plugin for build-time font loading".into(),
1298 suggest: Some("Add fonts to app.json expo-font plugin for instant availability".into()),
1299 ..Default::default()
1300 },
1301 TomlRule {
1302 id: "rn-no-font-loadasync".into(),
1303 rule_type: "banned-pattern".into(),
1304 severity: "warning".into(),
1305 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
1306 pattern: Some(r"Font\.loadAsync\s*\(".into()),
1307 regex: true,
1308 message: "Font.loadAsync blocks rendering — use expo-font config plugin for build-time font loading".into(),
1309 suggest: Some("Add fonts to app.json expo-font plugin for instant availability".into()),
1310 ..Default::default()
1311 },
1312 TomlRule {
1313 id: "rn-no-inline-intl-numberformat".into(),
1314 rule_type: "banned-pattern".into(),
1315 severity: "warning".into(),
1316 glob: Some("**/*.{tsx,jsx}".into()),
1317 pattern: Some(r"new\s+Intl\.NumberFormat\s*\(".into()),
1318 regex: true,
1319 message: "new Intl.NumberFormat() in a component body re-creates the formatter every render — extract to module scope".into(),
1320 suggest: Some("Move to module scope: const fmt = new Intl.NumberFormat(...)".into()),
1321 ..Default::default()
1322 },
1323 TomlRule {
1324 id: "rn-no-inline-intl-datetimeformat".into(),
1325 rule_type: "banned-pattern".into(),
1326 severity: "warning".into(),
1327 glob: Some("**/*.{tsx,jsx}".into()),
1328 pattern: Some(r"new\s+Intl\.DateTimeFormat\s*\(".into()),
1329 regex: true,
1330 message: "new Intl.DateTimeFormat() in a component body re-creates the formatter every render — extract to module scope".into(),
1331 suggest: Some("Move to module scope: const fmt = new Intl.DateTimeFormat(...)".into()),
1332 ..Default::default()
1333 },
1334 TomlRule {
1335 id: "rn-no-js-stack-navigator".into(),
1336 rule_type: "banned-import".into(),
1337 severity: "warning".into(),
1338 packages: vec!["@react-navigation/stack".into()],
1339 message: "JS-based stack navigator is slow — use @react-navigation/native-stack for native performance".into(),
1340 suggest: Some("Replace with: import { createNativeStackNavigator } from '@react-navigation/native-stack'".into()),
1341 ..Default::default()
1342 },
1343 TomlRule {
1344 id: "rn-no-js-bottom-tabs".into(),
1345 rule_type: "banned-import".into(),
1346 severity: "warning".into(),
1347 packages: vec!["@react-navigation/bottom-tabs".into()],
1348 message: "JS-based bottom tabs lack native feel — use react-native-bottom-tabs for native tab bar".into(),
1349 suggest: Some("Replace with: import { createNativeBottomTabNavigator } from 'react-native-bottom-tabs'".into()),
1350 ..Default::default()
1351 },
1352 TomlRule {
1353 id: "rn-no-linear-gradient-lib".into(),
1354 rule_type: "banned-import".into(),
1355 severity: "warning".into(),
1356 packages: vec!["expo-linear-gradient".into()],
1357 message: "expo-linear-gradient adds a JS bridge — use React Native's built-in linearGradient style (0.76+)".into(),
1358 suggest: Some("Use style={{ experimental_backgroundImage: 'linear-gradient(...)' }}".into()),
1359 ..Default::default()
1360 },
1361 TomlRule {
1362 id: "rn-no-js-bottom-sheet".into(),
1363 rule_type: "banned-dependency".into(),
1364 severity: "warning".into(),
1365 packages: vec!["@gorhom/bottom-sheet".into()],
1366 message: "@gorhom/bottom-sheet uses JS animations — use expo-bottom-sheet or react-native-bottom-sheet for native performance".into(),
1367 ..Default::default()
1368 },
1369 ],
1370 }
1371}
1372
1373fn merge_rules(preset_rules: Vec<TomlRule>, user_rules: &[TomlRule]) -> Vec<TomlRule> {
1377 let mut merged = preset_rules;
1378
1379 let mut id_to_index: HashMap<String, usize> = HashMap::new();
1381 for (i, rule) in merged.iter().enumerate() {
1382 id_to_index.insert(rule.id.clone(), i);
1383 }
1384
1385 for user_rule in user_rules {
1386 if let Some(&idx) = id_to_index.get(&user_rule.id) {
1387 merged[idx] = user_rule.clone();
1389 } else {
1390 merged.push(user_rule.clone());
1392 }
1393 }
1394
1395 merged
1396}
1397
1398pub fn resolve_rules(
1401 extends: &[String],
1402 user_rules: &[TomlRule],
1403) -> Result<Vec<TomlRule>, PresetError> {
1404 if extends.is_empty() {
1405 return Ok(user_rules.to_vec());
1406 }
1407
1408 let mut all_preset_rules: Vec<TomlRule> = Vec::new();
1410 let mut seen: HashMap<String, usize> = HashMap::new();
1411
1412 for preset_name in extends {
1413 let preset = resolve_preset(preset_name).ok_or_else(|| PresetError::UnknownPreset {
1414 name: preset_name.clone(),
1415 available: available_presets().to_vec(),
1416 })?;
1417
1418 for rule in preset_rules(preset) {
1419 if let Some(&idx) = seen.get(&rule.id) {
1420 all_preset_rules[idx] = rule;
1422 } else {
1423 seen.insert(rule.id.clone(), all_preset_rules.len());
1424 all_preset_rules.push(rule);
1425 }
1426 }
1427 }
1428
1429 Ok(merge_rules(all_preset_rules, user_rules))
1430}
1431
1432fn scope_glob(path: &str, glob: &str) -> String {
1435 let stripped = glob.strip_prefix("**/").unwrap_or(glob);
1436 format!("{path}/{stripped}")
1437}
1438
1439pub fn resolve_scoped_rules(
1441 scoped: &[ScopedPreset],
1442 user_rules: &[TomlRule],
1443) -> Result<Vec<TomlRule>, PresetError> {
1444 let mut result: Vec<TomlRule> = Vec::new();
1445
1446 for entry in scoped {
1447 let preset = resolve_preset(&entry.preset).ok_or_else(|| PresetError::UnknownPreset {
1448 name: entry.preset.clone(),
1449 available: available_presets().to_vec(),
1450 })?;
1451
1452 for mut rule in preset_rules(preset) {
1453 rule.glob = Some(match rule.glob {
1455 Some(g) => scope_glob(&entry.path, &g),
1456 None => format!("{}/**", entry.path),
1457 });
1458
1459 rule.exclude_glob = rule
1461 .exclude_glob
1462 .iter()
1463 .map(|g| scope_glob(&entry.path, g))
1464 .collect();
1465
1466 rule.required_files = rule
1468 .required_files
1469 .iter()
1470 .map(|f| format!("{}/{f}", entry.path))
1471 .collect();
1472 rule.forbidden_files = rule
1473 .forbidden_files
1474 .iter()
1475 .map(|f| format!("{}/{f}", entry.path))
1476 .collect();
1477
1478 if user_rules.iter().any(|u| u.id == rule.id) {
1480 continue;
1481 }
1482
1483 if entry.exclude_rules.contains(&rule.id) {
1485 continue;
1486 }
1487
1488 result.push(rule);
1489 }
1490 }
1491
1492 Ok(result)
1493}
1494
1495#[cfg(test)]
1496mod tests {
1497 use super::*;
1498
1499 #[test]
1500 fn shadcn_strict_has_five_rules() {
1501 let rules = preset_rules(Preset::ShadcnStrict);
1502 assert_eq!(rules.len(), 5);
1503 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1504 assert!(ids.contains(&"enforce-dark-mode"));
1505 assert!(ids.contains(&"use-theme-tokens"));
1506 assert!(ids.contains(&"no-inline-styles"));
1507 assert!(ids.contains(&"no-css-in-js"));
1508 assert!(ids.contains(&"no-competing-frameworks"));
1509 }
1510
1511 #[test]
1512 fn shadcn_migrate_has_two_rules() {
1513 let rules = preset_rules(Preset::ShadcnMigrate);
1514 assert_eq!(rules.len(), 2);
1515 assert_eq!(rules[0].id, "enforce-dark-mode");
1516 assert_eq!(rules[1].id, "use-theme-tokens");
1517 assert_eq!(rules[1].severity, "warning");
1519 }
1520
1521 #[test]
1522 fn ai_safety_has_three_rules() {
1523 let rules = preset_rules(Preset::AiSafety);
1524 assert_eq!(rules.len(), 3);
1525 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1526 assert!(ids.contains(&"no-moment"));
1527 assert!(ids.contains(&"no-lodash"));
1528 assert!(ids.contains(&"no-deprecated-request"));
1529 }
1530
1531 #[test]
1532 fn resolve_unknown_preset_errors() {
1533 let result = resolve_rules(&["unknown-preset".to_string()], &[]);
1534 assert!(result.is_err());
1535 let err = result.unwrap_err();
1536 let msg = format!("{}", err);
1537 assert!(msg.contains("unknown preset 'unknown-preset'"));
1538 assert!(msg.contains("shadcn-strict"));
1539 }
1540
1541 #[test]
1542 fn resolve_empty_extends_returns_user_rules() {
1543 let user_rules = vec![TomlRule {
1544 id: "custom-rule".into(),
1545 rule_type: "banned-pattern".into(),
1546 pattern: Some("TODO".into()),
1547 message: "No TODOs".into(),
1548 ..Default::default()
1549 }];
1550 let result = resolve_rules(&[], &user_rules).unwrap();
1551 assert_eq!(result.len(), 1);
1552 assert_eq!(result[0].id, "custom-rule");
1553 }
1554
1555 #[test]
1556 fn user_rule_overrides_preset() {
1557 let user_rules = vec![TomlRule {
1558 id: "use-theme-tokens".into(),
1559 rule_type: "tailwind-theme-tokens".into(),
1560 severity: "warning".into(),
1561 glob: Some("**/*.{tsx,jsx}".into()),
1562 message: "Custom message".into(),
1563 ..Default::default()
1564 }];
1565 let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
1566 assert_eq!(result.len(), 5);
1567 let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
1568 assert_eq!(token_rule.severity, "warning");
1569 assert_eq!(token_rule.message, "Custom message");
1570 }
1571
1572 #[test]
1573 fn user_rule_appended_after_preset() {
1574 let user_rules = vec![TomlRule {
1575 id: "my-custom".into(),
1576 rule_type: "banned-pattern".into(),
1577 pattern: Some("foo".into()),
1578 message: "no foo".into(),
1579 ..Default::default()
1580 }];
1581 let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
1582 assert_eq!(result.len(), 6);
1583 assert_eq!(result[5].id, "my-custom");
1584 }
1585
1586 #[test]
1587 fn later_preset_overrides_earlier() {
1588 let result = resolve_rules(
1591 &["shadcn-strict".to_string(), "shadcn-migrate".to_string()],
1592 &[],
1593 )
1594 .unwrap();
1595 let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
1596 assert_eq!(token_rule.severity, "warning");
1597 assert_eq!(result.len(), 5);
1599 }
1600
1601 #[test]
1602 fn multiple_presets_combine() {
1603 let result = resolve_rules(
1604 &["shadcn-migrate".to_string(), "ai-safety".to_string()],
1605 &[],
1606 )
1607 .unwrap();
1608 assert_eq!(result.len(), 5);
1610 }
1611
1612 #[test]
1613 fn security_has_eleven_rules() {
1614 let rules = preset_rules(Preset::Security);
1615 assert_eq!(rules.len(), 11);
1616 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1617 assert!(ids.contains(&"no-env-files"));
1618 assert!(ids.contains(&"no-hardcoded-secrets"));
1619 assert!(ids.contains(&"no-eval"));
1620 assert!(ids.contains(&"no-dangerous-html"));
1621 assert!(ids.contains(&"no-innerhtml"));
1622 assert!(ids.contains(&"no-console-log"));
1623 assert!(ids.contains(&"no-document-write"));
1624 assert!(ids.contains(&"no-postmessage-wildcard"));
1625 assert!(ids.contains(&"no-outerhtml"));
1626 assert!(ids.contains(&"no-http-links"));
1627 assert!(ids.contains(&"no-paste-prevention"));
1628 }
1629
1630 #[test]
1631 fn nextjs_has_eight_rules() {
1632 let rules = preset_rules(Preset::Nextjs);
1633 assert_eq!(rules.len(), 8);
1634 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1635 assert!(ids.contains(&"use-next-image"));
1636 assert!(ids.contains(&"no-next-head"));
1637 assert!(ids.contains(&"no-private-env-client"));
1638 assert!(ids.contains(&"require-use-client-for-hooks"));
1639 assert!(ids.contains(&"use-next-link"));
1640 assert!(ids.contains(&"no-next-router-in-app"));
1641 assert!(ids.contains(&"no-sync-scripts"));
1642 assert!(ids.contains(&"no-link-fonts"));
1643 }
1644
1645 #[test]
1646 fn ai_codegen_has_twelve_rules() {
1647 let rules = preset_rules(Preset::AiCodegen);
1648 assert_eq!(rules.len(), 12);
1649 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1650 assert!(ids.contains(&"no-placeholder-text"));
1651 assert!(ids.contains(&"no-unresolved-todos"));
1652 assert!(ids.contains(&"no-type-any"));
1653 assert!(ids.contains(&"no-empty-catch"));
1654 assert!(ids.contains(&"no-console-log"));
1655 assert!(ids.contains(&"no-ts-ignore"));
1656 assert!(ids.contains(&"no-as-any"));
1657 assert!(ids.contains(&"no-eslint-disable"));
1658 assert!(ids.contains(&"no-ts-nocheck"));
1659 assert!(ids.contains(&"no-var"));
1660 assert!(ids.contains(&"no-require-in-ts"));
1661 assert!(ids.contains(&"no-non-null-assertion"));
1662 }
1663
1664 #[test]
1665 fn react_has_expected_rule_count() {
1666 let rules = preset_rules(Preset::React);
1667 assert_eq!(rules.len(), 35);
1668 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1669 assert!(ids.contains(&"no-array-index-key"));
1670 assert!(ids.contains(&"no-conditional-render-zero"));
1671 assert!(ids.contains(&"no-nested-component-def"));
1672 assert!(ids.contains(&"no-dangerous-html"));
1673 assert!(ids.contains(&"no-full-lodash-import"));
1674 assert!(ids.contains(&"no-moment"));
1675 assert!(ids.contains(&"no-moment-dep"));
1676 assert!(ids.contains(&"no-new-function"));
1677 assert!(ids.contains(&"no-transition-all"));
1678 assert!(ids.contains(&"no-layout-animation"));
1679 assert!(ids.contains(&"no-sequential-await"));
1680 assert!(ids.contains(&"no-derived-state-effect"));
1681 assert!(ids.contains(&"no-fetch-in-effect"));
1682 assert!(ids.contains(&"no-lazy-state-init"));
1683 assert!(ids.contains(&"no-object-dep-array"));
1684 assert!(ids.contains(&"no-default-object-prop"));
1685 assert!(ids.contains(&"no-forwardref"));
1687 assert!(ids.contains(&"no-use-context"));
1688 assert!(ids.contains(&"no-unsafe-createcontext-default"));
1690 assert!(ids.contains(&"no-effect-callback-sync"));
1691 assert!(ids.contains(&"no-usestate-browser-api"));
1692 assert!(ids.contains(&"no-hardcoded-jsx-id"));
1694 assert!(ids.contains(&"no-clone-element"));
1695 assert!(ids.contains(&"no-react-children-api"));
1696 assert!(ids.contains(&"no-direct-window-listener"));
1697 assert!(ids.contains(&"no-direct-document-query"));
1698 assert!(ids.contains(&"no-regexp-in-render"));
1700 assert!(ids.contains(&"no-lucide-barrel"));
1701 assert!(ids.contains(&"no-mui-barrel"));
1702 assert!(ids.contains(&"no-mui-icons-barrel"));
1703 assert!(ids.contains(&"no-react-icons-barrel"));
1704 assert!(ids.contains(&"no-date-fns-barrel"));
1705 assert!(ids.contains(&"max-component-size"));
1706 assert!(ids.contains(&"prefer-use-reducer"));
1707 assert!(ids.contains(&"no-cascading-set-state"));
1708 let nested_rule = rules.iter().find(|r| r.id == "no-nested-component-def").unwrap();
1709 assert_eq!(nested_rule.rule_type, "no-nested-components");
1710 }
1711
1712 #[test]
1713 fn nextjs_best_practices_has_expected_rule_count() {
1714 let rules = preset_rules(Preset::NextjsBestPractices);
1715 assert_eq!(rules.len(), 21);
1716 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1717 assert!(ids.contains(&"use-next-image"));
1718 assert!(ids.contains(&"next-image-fill-needs-sizes"));
1719 assert!(ids.contains(&"use-next-link"));
1720 assert!(ids.contains(&"no-next-router-in-app"));
1721 assert!(ids.contains(&"no-next-head"));
1722 assert!(ids.contains(&"no-client-side-redirect"));
1723 assert!(ids.contains(&"no-sync-scripts"));
1724 assert!(ids.contains(&"no-link-fonts"));
1725 assert!(ids.contains(&"no-css-link"));
1726 assert!(ids.contains(&"no-private-env-client"));
1727 assert!(ids.contains(&"require-use-client-for-hooks"));
1728 assert!(ids.contains(&"no-async-client-component"));
1729 assert!(ids.contains(&"require-metadata-in-pages"));
1730 assert!(ids.contains(&"no-redirect-in-try-catch"));
1731 assert!(ids.contains(&"server-action-requires-auth"));
1732 assert!(ids.contains(&"server-action-requires-validation"));
1733 assert!(ids.contains(&"no-suppress-hydration-warning"));
1734 assert!(ids.contains(&"max-component-size"));
1735 assert!(ids.contains(&"no-nested-components"));
1736 assert!(ids.contains(&"prefer-use-reducer"));
1737 assert!(ids.contains(&"no-cascading-set-state"));
1738 }
1739
1740 #[test]
1741 fn accessibility_has_expected_rule_count() {
1742 let rules = preset_rules(Preset::Accessibility);
1743 assert_eq!(rules.len(), 9);
1744 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1745 assert!(ids.contains(&"no-div-click-handler"));
1746 assert!(ids.contains(&"no-span-click-handler"));
1747 assert!(ids.contains(&"no-outline-none"));
1748 assert!(ids.contains(&"no-user-scalable-no"));
1749 assert!(ids.contains(&"no-autofocus-unrestricted"));
1750 assert!(ids.contains(&"no-transition-all-tailwind"));
1751 assert!(ids.contains(&"no-hardcoded-date-format"));
1752 assert!(ids.contains(&"no-inline-navigation-onclick"));
1753 assert!(ids.contains(&"require-img-alt"));
1754 }
1755
1756 #[test]
1757 fn react_native_has_thirteen_rules() {
1758 let rules = preset_rules(Preset::ReactNative);
1759 assert_eq!(rules.len(), 13);
1760 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1761 assert!(ids.contains(&"rn-no-touchable-opacity"));
1762 assert!(ids.contains(&"rn-no-touchable-highlight"));
1763 assert!(ids.contains(&"rn-no-legacy-shadow"));
1764 assert!(ids.contains(&"rn-no-rn-image-import"));
1765 assert!(ids.contains(&"rn-no-custom-header"));
1766 assert!(ids.contains(&"rn-no-fonts-usefonts"));
1767 assert!(ids.contains(&"rn-no-font-loadasync"));
1768 assert!(ids.contains(&"rn-no-inline-intl-numberformat"));
1769 assert!(ids.contains(&"rn-no-inline-intl-datetimeformat"));
1770 assert!(ids.contains(&"rn-no-js-stack-navigator"));
1771 assert!(ids.contains(&"rn-no-js-bottom-tabs"));
1772 assert!(ids.contains(&"rn-no-linear-gradient-lib"));
1773 assert!(ids.contains(&"rn-no-js-bottom-sheet"));
1774 }
1775
1776 #[test]
1777 fn all_preset_names_resolve() {
1778 for name in available_presets() {
1779 assert!(
1780 resolve_preset(name).is_some(),
1781 "preset '{}' should resolve",
1782 name
1783 );
1784 }
1785 }
1786
1787 #[test]
1788 fn all_preset_regex_patterns_compile() {
1789 use regex::Regex;
1790 for name in available_presets() {
1791 let preset = resolve_preset(name).unwrap();
1792 for rule in preset_rules(preset) {
1793 if rule.regex {
1794 if let Some(ref pat) = rule.pattern {
1795 Regex::new(pat).unwrap_or_else(|e| {
1796 panic!("preset '{}', rule '{}': invalid pattern: {}", name, rule.id, e)
1797 });
1798 }
1799 if let Some(ref pat) = rule.condition_pattern {
1800 Regex::new(pat).unwrap_or_else(|e| {
1801 panic!(
1802 "preset '{}', rule '{}': invalid condition_pattern: {}",
1803 name, rule.id, e
1804 )
1805 });
1806 }
1807 }
1808 }
1809 }
1810 }
1811
1812 #[test]
1813 fn no_private_env_client_pattern_correctness() {
1814 use regex::Regex;
1815 let rules = preset_rules(Preset::Nextjs);
1816 let rule = rules.iter().find(|r| r.id == "no-private-env-client").unwrap();
1817 let re = Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
1818
1819 assert!(re.is_match("process.env.DATABASE_URL"));
1821 assert!(re.is_match("process.env.API_SECRET"));
1822 assert!(re.is_match("process.env.NODE_ENV"));
1823 assert!(re.is_match("process.env.NEXT_RUNTIME"));
1824
1825 assert!(!re.is_match("process.env.NEXT_PUBLIC_API_URL"));
1827 assert!(!re.is_match("process.env.NEXT_PUBLIC_STRIPE_KEY"));
1828 }
1829
1830 fn regex_for(preset: Preset, rule_id: &str) -> regex::Regex {
1832 let rules = preset_rules(preset);
1833 let rule = rules
1834 .iter()
1835 .find(|r| r.id == rule_id)
1836 .unwrap_or_else(|| panic!("rule '{}' not found", rule_id));
1837 regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap()
1838 }
1839
1840 #[test]
1843 fn no_document_write_pattern() {
1844 let re = regex_for(Preset::Security, "no-document-write");
1845 assert!(re.is_match("document.write('hello')"));
1846 assert!(re.is_match("document.write (html)"));
1847 assert!(re.is_match(" document.write('<div>')"));
1848 assert!(!re.is_match("const w = document.writeln"));
1850 assert!(!re.is_match("documentWriter()"));
1851 }
1852
1853 #[test]
1854 fn no_postmessage_wildcard_pattern() {
1855 let re = regex_for(Preset::Security, "no-postmessage-wildcard");
1856 assert!(re.is_match("window.postMessage(data, '*')"));
1857 assert!(re.is_match(r#"iframe.contentWindow.postMessage({}, "*")"#));
1858 assert!(re.is_match(" w.postMessage(msg, '*')"));
1859 assert!(!re.is_match("window.postMessage(data, 'https://example.com')"));
1861 assert!(!re.is_match("window.postMessage(data, origin)"));
1862 }
1863
1864 #[test]
1865 fn no_outerhtml_pattern() {
1866 let re = regex_for(Preset::Security, "no-outerhtml");
1867 assert!(re.is_match("el.outerHTML = '<div>'"));
1868 assert!(re.is_match("el.outerHTML += '<span>'"));
1869 assert!(re.is_match(" node.outerHTML = html"));
1870 assert!(!re.is_match("const html = el.outerHTML"));
1872 assert!(!re.is_match("console.log(el.outerHTML)"));
1873 }
1874
1875 #[test]
1876 fn no_http_links_pattern() {
1877 let re = regex_for(Preset::Security, "no-http-links");
1878 assert!(re.is_match(r#"fetch("http://api.example.com")"#));
1879 assert!(re.is_match("const url = 'http://cdn.example.com'"));
1880 assert!(!re.is_match(r#"fetch("https://api.example.com")"#));
1882 assert!(!re.is_match("// visit http://example.com"));
1884 }
1885
1886 #[test]
1887 fn no_hardcoded_secrets_expanded() {
1888 let re = regex_for(Preset::Security, "no-hardcoded-secrets");
1889 assert!(re.is_match(r#"api_key = "abc12345678""#));
1891 assert!(re.is_match(r#"API_KEY: "abc12345678""#));
1892 assert!(re.is_match(r#"password = "mysecretpass""#));
1894 assert!(re.is_match(r#"PASSWORD: "supersecret1""#));
1895 assert!(re.is_match(r#"client_secret = "abcdefghij""#));
1896 assert!(!re.is_match(r#"password = "short""#));
1898 assert!(!re.is_match("password = getPassword()"));
1900 }
1901
1902 #[test]
1905 fn no_sync_scripts_pattern() {
1906 let re = regex_for(Preset::Nextjs, "no-sync-scripts");
1907 assert!(re.is_match(r#"<script src="analytics.js">"#));
1908 assert!(re.is_match(r#"<script type="application/ld+json">"#));
1909 assert!(!re.is_match(r#"<Script src="analytics.js">"#));
1911 assert!(!re.is_match("</script>"));
1913 }
1914
1915 #[test]
1916 fn no_link_fonts_pattern() {
1917 let re = regex_for(Preset::Nextjs, "no-link-fonts");
1918 assert!(re.is_match(
1919 r#"<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />"#
1920 ));
1921 assert!(re.is_match(
1922 r#"<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto">"#
1923 ));
1924 assert!(!re.is_match(r#"<link rel="stylesheet" href="/styles.css" />"#));
1926 assert!(!re.is_match(r#"<Link href="/fonts">"#));
1928 }
1929
1930 #[test]
1933 fn no_eslint_disable_pattern() {
1934 let rules = preset_rules(Preset::AiCodegen);
1935 let rule = rules.iter().find(|r| r.id == "no-eslint-disable").unwrap();
1936 let pat = rule.pattern.as_ref().unwrap();
1937 assert!(!rule.regex);
1939 assert!("// eslint-disable-next-line no-console".contains(pat.as_str()));
1940 assert!("/* eslint-disable */".contains(pat.as_str()));
1941 assert!("/* eslint-disable-next-line */".contains(pat.as_str()));
1942 }
1943
1944 #[test]
1945 fn no_var_pattern() {
1946 let re = regex_for(Preset::AiCodegen, "no-var");
1947 assert!(re.is_match("var x = 1"));
1948 assert!(re.is_match("var foo = 'bar'"));
1949 assert!(re.is_match(" var count = 0;"));
1950 assert!(!re.is_match("const variable = 1"));
1952 assert!(!re.is_match("let variance = 2"));
1953 assert!(!re.is_match("const isVariable = true"));
1954 }
1955
1956 #[test]
1957 fn no_require_in_ts_pattern() {
1958 let re = regex_for(Preset::AiCodegen, "no-require-in-ts");
1959 assert!(re.is_match("const fs = require('fs')"));
1960 assert!(re.is_match("const x = require('./module')"));
1961 assert!(re.is_match("require('dotenv').config()"));
1962 assert!(!re.is_match("import fs from 'fs'"));
1964 assert!(!re.is_match("require.resolve('./path')"));
1966 }
1967
1968 #[test]
1969 fn no_non_null_assertion_pattern() {
1970 let re = regex_for(Preset::AiCodegen, "no-non-null-assertion");
1971 assert!(re.is_match("user!.name"));
1973 assert!(re.is_match("items![0]"));
1974 assert!(re.is_match("this.ref!.current"));
1975 assert!(re.is_match("data!.results"));
1976 assert!(!re.is_match("x !== y"));
1978 assert!(!re.is_match("x != y"));
1979 assert!(!re.is_match("if (!foo) {}"));
1980 assert!(!re.is_match("!!value"));
1981 assert!(!re.is_match("foo!==bar"));
1982 }
1983
1984 #[test]
1985 fn no_non_null_assertion_no_false_positives_on_strings() {
1986 let re = regex_for(Preset::AiCodegen, "no-non-null-assertion");
1987 assert!(!re.is_match(r#""Warning!".toUpperCase()"#));
1989 assert!(!re.is_match(r#"'Error!'.length"#));
1990 assert!(!re.is_match(r#"'Click me!'[0]"#));
1991 }
1992
1993 #[test]
1994 fn no_innerhtml_catches_plus_equals() {
1995 let re = regex_for(Preset::Security, "no-innerhtml");
1996 assert!(re.is_match("el.innerHTML = html"));
1997 assert!(re.is_match("el.innerHTML += '<br>'"));
1998 assert!(re.is_match("el.innerHTML = content"));
1999 assert!(!re.is_match("const x = el.innerHTML"));
2000 }
2001
2002 #[test]
2003 fn no_type_any_catches_generics() {
2004 let re = regex_for(Preset::AiCodegen, "no-type-any");
2005 assert!(re.is_match("const x: any = 1"));
2007 assert!(re.is_match("Array<any>"));
2009 assert!(re.is_match("Promise<any>"));
2010 assert!(re.is_match("Record<string, any>"));
2011 assert!(re.is_match("Map<string, any>"));
2012 assert!(!re.is_match("// handle any case"));
2014 assert!(!re.is_match("const anything = 1"));
2015 assert!(!re.is_match("if (any_flag) {}"));
2016 }
2017
2018 #[test]
2021 fn no_forwardref_pattern() {
2022 let re = regex_for(Preset::React, "no-forwardref");
2023 assert!(re.is_match("const Input = forwardRef<HTMLInputElement>((props, ref) => {"));
2024 assert!(re.is_match("const Btn = forwardRef((props, ref) => <button />)"));
2025 assert!(re.is_match("export default forwardRef(MyComponent)"));
2026 assert!(!re.is_match("// removed forwardRef"));
2028 assert!(!re.is_match("const forwardRefValue = 42"));
2029 }
2030
2031 #[test]
2032 fn no_use_context_pattern() {
2033 let re = regex_for(Preset::React, "no-use-context");
2034 assert!(re.is_match("const theme = useContext(ThemeContext)"));
2035 assert!(re.is_match("const val = useContext(Ctx)"));
2036 assert!(!re.is_match("const ctx = useContextSelector(Ctx, s => s.val)"));
2038 assert!(!re.is_match("// useContext is deprecated"));
2039 }
2040
2041 #[test]
2042 fn no_unsafe_createcontext_default_pattern() {
2043 let re = regex_for(Preset::React, "no-unsafe-createcontext-default");
2044 assert!(re.is_match("const Ctx = createContext({})"));
2046 assert!(re.is_match("const Ctx = createContext([])"));
2047 assert!(re.is_match("const Ctx = createContext(undefined)"));
2048 assert!(re.is_match("const Ctx = createContext(0)"));
2049 assert!(re.is_match("const Ctx = createContext('')"));
2050 assert!(re.is_match(r#"const Ctx = createContext("")"#));
2051 assert!(!re.is_match("const Ctx = createContext(null)"));
2053 assert!(!re.is_match("const Ctx = createContext(defaultValue)"));
2054 assert!(!re.is_match("const Ctx = createContext({ theme: 'dark' })"));
2055 }
2056
2057 #[test]
2058 fn no_effect_callback_sync_pattern() {
2059 let re = regex_for(Preset::React, "no-effect-callback-sync");
2060 assert!(re.is_match("useEffect(() => { onChange(value)"));
2061 assert!(re.is_match("useEffect(() => { onUpdate(data)"));
2062 assert!(re.is_match("useEffect(() => onSubmit(form)"));
2063 assert!(!re.is_match("useEffect(() => { setCount(1) }"));
2065 assert!(!re.is_match("useEffect(() => { fetchData() }"));
2066 }
2067
2068 #[test]
2069 fn no_usestate_browser_api_pattern() {
2070 let re = regex_for(Preset::React, "no-usestate-browser-api");
2071 assert!(re.is_match("useState(localStorage.getItem('key'))"));
2072 assert!(re.is_match("useState(JSON.parse(localStorage.getItem('key')))"));
2073 assert!(re.is_match("useState(sessionStorage.getItem('key'))"));
2074 assert!(re.is_match("useState(JSON.parse(sessionStorage.getItem('key')))"));
2075 assert!(!re.is_match("useState(() => localStorage.getItem('key'))"));
2077 }
2078
2079 #[test]
2080 fn no_hardcoded_jsx_id_pattern() {
2081 let re = regex_for(Preset::React, "no-hardcoded-jsx-id");
2082 assert!(re.is_match(r#"<div id="theme">"#));
2083 assert!(re.is_match(r#"<input id='modal'/>"#));
2084 assert!(!re.is_match(r#"<div id={id}>"#));
2086 }
2087
2088 #[test]
2089 fn no_clone_element_pattern() {
2090 let rules = preset_rules(Preset::React);
2091 let rule = rules.iter().find(|r| r.id == "no-clone-element").unwrap();
2092 assert!(rule.pattern.as_ref().unwrap() == "cloneElement");
2093 }
2094
2095 #[test]
2096 fn no_react_children_api_pattern() {
2097 let re = regex_for(Preset::React, "no-react-children-api");
2098 assert!(re.is_match("Children.map(children, child =>"));
2099 assert!(re.is_match("React.Children.forEach(children,"));
2100 assert!(re.is_match("Children.toArray(children)"));
2101 assert!(!re.is_match("children.map("));
2102 }
2103
2104 #[test]
2105 fn no_direct_window_listener_pattern() {
2106 let re = regex_for(Preset::React, "no-direct-window-listener");
2107 assert!(re.is_match("window.addEventListener('keydown',"));
2108 assert!(re.is_match("window.removeEventListener('resize',"));
2109 assert!(!re.is_match("win.addEventListener('keydown',"));
2110 }
2111
2112 #[test]
2113 fn no_direct_document_query_pattern() {
2114 let re = regex_for(Preset::React, "no-direct-document-query");
2115 assert!(re.is_match("document.getElementById('root')"));
2116 assert!(re.is_match("document.querySelector('.modal')"));
2117 assert!(re.is_match("document.querySelectorAll('button')"));
2118 assert!(!re.is_match("ref.current"));
2119 }
2120
2121 #[test]
2122 fn no_regexp_in_render_pattern() {
2123 let re = regex_for(Preset::React, "no-regexp-in-render");
2124 assert!(re.is_match("const re = new RegExp(pattern)"));
2125 assert!(re.is_match("new RegExp('\\\\d+', 'g')"));
2126 assert!(!re.is_match("const re = /\\d+/g"));
2128 }
2129
2130 #[test]
2131 fn no_lucide_barrel_pattern() {
2132 let re = regex_for(Preset::React, "no-lucide-barrel");
2133 assert!(re.is_match("import { Home } from 'lucide-react'"));
2135 assert!(re.is_match(r#"import { Home } from "lucide-react""#));
2136 assert!(re.is_match("require('lucide-react')"));
2137 assert!(!re.is_match("import Home from 'lucide-react/icons/Home'"));
2139 assert!(!re.is_match("import { Home } from 'lucide-react/dist/esm/icons/home'"));
2140 }
2141
2142 #[test]
2143 fn no_mui_barrel_pattern() {
2144 let re = regex_for(Preset::React, "no-mui-barrel");
2145 assert!(re.is_match("import { Button } from '@mui/material'"));
2146 assert!(re.is_match("require('@mui/material')"));
2147 assert!(!re.is_match("import Button from '@mui/material/Button'"));
2149 assert!(!re.is_match("import { useTheme } from '@mui/material/styles'"));
2150 }
2151
2152 #[test]
2153 fn no_mui_icons_barrel_pattern() {
2154 let re = regex_for(Preset::React, "no-mui-icons-barrel");
2155 assert!(re.is_match("import { Home } from '@mui/icons-material'"));
2156 assert!(!re.is_match("import HomeIcon from '@mui/icons-material/Home'"));
2158 }
2159
2160 #[test]
2161 fn no_react_icons_barrel_pattern() {
2162 let re = regex_for(Preset::React, "no-react-icons-barrel");
2163 assert!(re.is_match("import { FaHome } from 'react-icons'"));
2164 assert!(!re.is_match("import { FaHome } from 'react-icons/fa'"));
2166 }
2167
2168 #[test]
2169 fn no_date_fns_barrel_pattern() {
2170 let re = regex_for(Preset::React, "no-date-fns-barrel");
2171 assert!(re.is_match("import { format } from 'date-fns'"));
2172 assert!(re.is_match("require('date-fns')"));
2173 assert!(!re.is_match("import { format } from 'date-fns/format'"));
2175 assert!(!re.is_match("import { format } from 'date-fns/esm'"));
2176 }
2177
2178 #[test]
2181 fn server_action_requires_auth_patterns() {
2182 let rules = preset_rules(Preset::NextjsBestPractices);
2183 let rule = rules.iter().find(|r| r.id == "server-action-requires-auth").unwrap();
2184 let re = regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
2185 let cond_re = regex::Regex::new(rule.condition_pattern.as_ref().unwrap()).unwrap();
2186 assert!(cond_re.is_match("'use server'"));
2188 assert!(!cond_re.is_match("'use client'"));
2189 assert!(re.is_match("await verifySession()"));
2191 assert!(re.is_match("const s = await getSession()"));
2192 assert!(re.is_match("const s = await auth()"));
2193 assert!(re.is_match("const u = await currentUser()"));
2194 assert!(re.is_match("const s = await getServerSession()"));
2195 assert!(!re.is_match("await db.insert(data)"));
2197 }
2198
2199 #[test]
2200 fn server_action_requires_validation_patterns() {
2201 let rules = preset_rules(Preset::NextjsBestPractices);
2202 let rule = rules.iter().find(|r| r.id == "server-action-requires-validation").unwrap();
2203 let re = regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
2204 assert!(re.is_match("const data = schema.parse(formData)"));
2206 assert!(re.is_match("const result = schema.safeParse(input)"));
2207 assert!(re.is_match("const s = z.object({})"));
2208 assert!(re.is_match("await body.validate()"));
2209 assert!(!re.is_match("await db.insert(formData)"));
2211 }
2212
2213 #[test]
2214 fn no_suppress_hydration_warning_pattern() {
2215 let rules = preset_rules(Preset::NextjsBestPractices);
2216 let rule = rules.iter().find(|r| r.id == "no-suppress-hydration-warning").unwrap();
2217 let pat = rule.pattern.as_ref().unwrap();
2218 assert!(!rule.regex);
2219 assert!("<div suppressHydrationWarning>".contains(pat.as_str()));
2220 assert!("<body suppressHydrationWarning={true}>".contains(pat.as_str()));
2221 assert!(!"<div className='safe'>".contains(pat.as_str()));
2222 }
2223
2224 #[test]
2227 fn no_paste_prevention_pattern() {
2228 let re = regex_for(Preset::Security, "no-paste-prevention");
2229 assert!(re.is_match("onPaste={(e) => e.preventDefault()}"));
2230 assert!(re.is_match("onPaste={e => { e.preventDefault() }}"));
2231 assert!(re.is_match("onPaste={handlePaste} // where handlePaste calls preventDefault"));
2232 assert!(!re.is_match("onPaste={handlePaste}"));
2234 assert!(!re.is_match("onCopy={(e) => e.preventDefault()}"));
2235 }
2236
2237 #[test]
2240 fn no_div_click_handler_pattern() {
2241 let re = regex_for(Preset::Accessibility, "no-div-click-handler");
2242 assert!(re.is_match("<div className='card' onClick={handleClick}>"));
2243 assert!(re.is_match("<div onClick = {fn}>"));
2244 assert!(!re.is_match("<button onClick={handleClick}>"));
2246 assert!(!re.is_match("</div>"));
2248 assert!(!re.is_match("<div className='card'>"));
2250 }
2251
2252 #[test]
2253 fn no_span_click_handler_pattern() {
2254 let re = regex_for(Preset::Accessibility, "no-span-click-handler");
2255 assert!(re.is_match("<span role='button' onClick={handleClick}>"));
2256 assert!(re.is_match("<span onClick={fn}>"));
2257 assert!(!re.is_match("<button onClick={handleClick}>"));
2259 assert!(!re.is_match("<span className='label'>"));
2261 }
2262
2263 #[test]
2264 fn no_outline_none_pattern() {
2265 let re = regex_for(Preset::Accessibility, "no-outline-none");
2266 assert!(re.is_match("className='outline-none'"));
2267 assert!(re.is_match("className='focus:outline-none ring-2'"));
2268 assert!(!re.is_match("className='outline-offset-2'"));
2270 assert!(!re.is_match("className='outline-0'"));
2271 }
2272
2273 #[test]
2274 fn no_user_scalable_no_pattern() {
2275 let re = regex_for(Preset::Accessibility, "no-user-scalable-no");
2276 assert!(re.is_match("user-scalable=no"));
2277 assert!(re.is_match("user-scalable = no"));
2278 assert!(!re.is_match("user-scalable=yes"));
2280 }
2281
2282 #[test]
2283 fn no_autofocus_unrestricted_pattern() {
2284 let re = regex_for(Preset::Accessibility, "no-autofocus-unrestricted");
2285 assert!(re.is_match("<input autoFocus />"));
2286 assert!(re.is_match("<Input autoFocus={true} />"));
2287 assert!(!re.is_match("const autoFocusEnabled = true"));
2289 }
2290
2291 #[test]
2292 fn no_transition_all_tailwind_pattern() {
2293 let re = regex_for(Preset::Accessibility, "no-transition-all-tailwind");
2294 assert!(re.is_match("className='transition-all duration-300'"));
2295 assert!(!re.is_match("className='transition-colors duration-300'"));
2297 assert!(!re.is_match("className='transition-opacity'"));
2298 }
2299
2300 #[test]
2301 fn no_hardcoded_date_format_pattern() {
2302 let re = regex_for(Preset::Accessibility, "no-hardcoded-date-format");
2303 assert!(re.is_match("date.toDateString()"));
2304 assert!(re.is_match("date.toLocaleString()"));
2305 assert!(re.is_match("date.toLocaleDateString()"));
2306 assert!(!re.is_match("date.toLocaleDateString('en-US')"));
2308 assert!(!re.is_match("date.toLocaleString('de-DE', opts)"));
2309 }
2310
2311 #[test]
2312 fn no_inline_navigation_onclick_pattern() {
2313 let re = regex_for(Preset::Accessibility, "no-inline-navigation-onclick");
2314 assert!(re.is_match("onClick={() => window.location.href = '/home'}"));
2315 assert!(re.is_match("onClick={() => { window.location = '/page' }}"));
2316 assert!(!re.is_match("onClick={() => router.push('/home')}"));
2318 }
2319
2320 #[test]
2323 fn rn_no_touchable_opacity_pattern() {
2324 let re = regex_for(Preset::ReactNative, "rn-no-touchable-opacity");
2325 assert!(re.is_match("<TouchableOpacity onPress={fn}>"));
2326 assert!(re.is_match("import { TouchableOpacity } from 'react-native'"));
2327 assert!(re.is_match("import { View, TouchableOpacity } from 'react-native'"));
2328 assert!(!re.is_match("<Pressable onPress={fn}>"));
2330 }
2331
2332 #[test]
2333 fn rn_no_touchable_highlight_pattern() {
2334 let re = regex_for(Preset::ReactNative, "rn-no-touchable-highlight");
2335 assert!(re.is_match("<TouchableHighlight onPress={fn}>"));
2336 assert!(re.is_match("import { TouchableHighlight } from 'react-native'"));
2337 assert!(!re.is_match("<Pressable onPress={fn}>"));
2339 }
2340
2341 #[test]
2342 fn rn_no_legacy_shadow_pattern() {
2343 let re = regex_for(Preset::ReactNative, "rn-no-legacy-shadow");
2344 assert!(re.is_match("shadowColor: '#000'"));
2345 assert!(re.is_match("shadowOffset: { width: 0 }"));
2346 assert!(re.is_match("shadowOpacity: 0.25"));
2347 assert!(re.is_match("shadowRadius: 3.84"));
2348 assert!(!re.is_match("boxShadow: '0 2px 4px rgba(0,0,0,0.1)'"));
2350 }
2351
2352 #[test]
2353 fn rn_no_rn_image_import_pattern() {
2354 let re = regex_for(Preset::ReactNative, "rn-no-rn-image-import");
2355 assert!(re.is_match("import { Image } from 'react-native'"));
2356 assert!(re.is_match("import { View, Image } from 'react-native'"));
2357 assert!(re.is_match("import { Image, Text } from 'react-native'"));
2358 assert!(!re.is_match("import { Image } from 'expo-image'"));
2360 assert!(!re.is_match("import { ImageBackground } from 'react-native'"));
2362 }
2363
2364 #[test]
2365 fn rn_no_custom_header_pattern() {
2366 let re = regex_for(Preset::ReactNative, "rn-no-custom-header");
2367 assert!(re.is_match("header: () => <CustomHeader />"));
2368 assert!(re.is_match("header: () =>"));
2369 assert!(!re.is_match("headerTitle: 'Settings'"));
2371 }
2372
2373 #[test]
2374 fn rn_no_fonts_usefonts_pattern() {
2375 let re = regex_for(Preset::ReactNative, "rn-no-fonts-usefonts");
2376 assert!(re.is_match("const [loaded] = useFonts({ Inter: require('./fonts/Inter.ttf') })"));
2377 assert!(re.is_match("useFonts({"));
2378 assert!(!re.is_match("useForm({ mode: 'onChange' })"));
2380 }
2381
2382 #[test]
2383 fn rn_no_font_loadasync_pattern() {
2384 let re = regex_for(Preset::ReactNative, "rn-no-font-loadasync");
2385 assert!(re.is_match("await Font.loadAsync({ Inter: require('./Inter.ttf') })"));
2386 assert!(re.is_match("Font.loadAsync(fonts)"));
2387 assert!(!re.is_match("await Image.loadAsync(uri)"));
2389 }
2390
2391 #[test]
2392 fn rn_no_inline_intl_numberformat_pattern() {
2393 let re = regex_for(Preset::ReactNative, "rn-no-inline-intl-numberformat");
2394 assert!(re.is_match("new Intl.NumberFormat('en-US').format(price)"));
2395 assert!(re.is_match("const fmt = new Intl.NumberFormat('de-DE', { style: 'currency' })"));
2396 assert!(!re.is_match("fmt.format(1234)"));
2398 }
2399
2400 #[test]
2401 fn rn_no_inline_intl_datetimeformat_pattern() {
2402 let re = regex_for(Preset::ReactNative, "rn-no-inline-intl-datetimeformat");
2403 assert!(re.is_match("new Intl.DateTimeFormat('en-US').format(date)"));
2404 assert!(re.is_match("const fmt = new Intl.DateTimeFormat('ja-JP', opts)"));
2405 assert!(!re.is_match("fmt.format(date)"));
2406 }
2407
2408 #[test]
2411 fn scope_glob_strips_leading_double_star() {
2412 assert_eq!(scope_glob("apps/web", "**/*.tsx"), "apps/web/*.tsx");
2413 assert_eq!(
2414 scope_glob("apps/web", "**/*.{tsx,jsx}"),
2415 "apps/web/*.{tsx,jsx}"
2416 );
2417 }
2418
2419 #[test]
2420 fn scope_glob_prepends_path_to_plain_glob() {
2421 assert_eq!(
2422 scope_glob("apps/web", "src/**/*.ts"),
2423 "apps/web/src/**/*.ts"
2424 );
2425 }
2426
2427 #[test]
2428 fn scope_glob_handles_simple_filename() {
2429 assert_eq!(scope_glob("apps/web", "*.json"), "apps/web/*.json");
2430 }
2431
2432 #[test]
2433 fn resolve_scoped_rules_prefixes_globs() {
2434 let scoped = vec![ScopedPreset {
2435 preset: "nextjs".into(),
2436 path: "apps/web".into(),
2437 exclude_rules: vec![],
2438 }];
2439 let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2440 assert!(!rules.is_empty());
2441 for rule in &rules {
2442 let glob = rule.glob.as_ref().unwrap();
2443 assert!(
2444 glob.starts_with("apps/web/"),
2445 "expected glob to start with 'apps/web/', got: {glob}"
2446 );
2447 }
2448 }
2449
2450 #[test]
2451 fn resolve_scoped_rules_none_glob_gets_catch_all() {
2452 let scoped = vec![ScopedPreset {
2454 preset: "ai-safety".into(),
2455 path: "packages/core".into(),
2456 exclude_rules: vec![],
2457 }];
2458 let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2459 for rule in &rules {
2461 let glob = rule.glob.as_ref().unwrap();
2462 assert!(
2463 glob.starts_with("packages/core/"),
2464 "expected glob to start with 'packages/core/', got: {glob}"
2465 );
2466 }
2467 }
2468
2469 #[test]
2470 fn resolve_scoped_rules_user_override_skips_rule() {
2471 let scoped = vec![ScopedPreset {
2472 preset: "nextjs".into(),
2473 path: "apps/web".into(),
2474 exclude_rules: vec![],
2475 }];
2476 let user_rules = vec![TomlRule {
2477 id: "use-next-image".into(),
2478 rule_type: "banned-pattern".into(),
2479 message: "custom override".into(),
2480 ..Default::default()
2481 }];
2482 let rules = resolve_scoped_rules(&scoped, &user_rules).unwrap();
2483 assert!(
2485 !rules.iter().any(|r| r.id == "use-next-image"),
2486 "scoped rule should be skipped when user defines same id"
2487 );
2488 }
2489
2490 #[test]
2491 fn scoped_and_global_presets_merge() {
2492 let global =
2493 resolve_rules(&["security".to_string()], &[]).unwrap();
2494 let scoped = resolve_scoped_rules(
2495 &[ScopedPreset {
2496 preset: "nextjs".into(),
2497 path: "apps/web".into(),
2498 exclude_rules: vec![],
2499 }],
2500 &[],
2501 )
2502 .unwrap();
2503 let mut all = global;
2504 all.extend(scoped);
2505 assert!(all.iter().any(|r| r.id == "no-eval"));
2507 assert!(all.iter().any(|r| r.id == "use-next-image"));
2508 let next_img = all.iter().find(|r| r.id == "use-next-image").unwrap();
2510 assert!(next_img.glob.as_ref().unwrap().starts_with("apps/web/"));
2511 }
2512
2513 #[test]
2514 fn resolve_scoped_unknown_preset_errors() {
2515 let scoped = vec![ScopedPreset {
2516 preset: "nonexistent".into(),
2517 path: "apps/web".into(),
2518 exclude_rules: vec![],
2519 }];
2520 let result = resolve_scoped_rules(&scoped, &[]);
2521 assert!(result.is_err());
2522 let msg = format!("{}", result.unwrap_err());
2523 assert!(msg.contains("unknown preset 'nonexistent'"));
2524 }
2525
2526 #[test]
2527 fn resolve_scoped_prefixes_file_presence_paths() {
2528 let scoped = vec![ScopedPreset {
2530 preset: "security".into(),
2531 path: "apps/api".into(),
2532 exclude_rules: vec![],
2533 }];
2534 let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2535 let fp_rule = rules.iter().find(|r| r.id == "no-env-files").unwrap();
2536 for f in &fp_rule.forbidden_files {
2537 assert!(
2538 f.starts_with("apps/api/"),
2539 "expected forbidden_file to start with 'apps/api/', got: {f}"
2540 );
2541 }
2542 }
2543
2544 #[test]
2545 fn resolve_scoped_prefixes_exclude_glob() {
2546 let scoped = vec![ScopedPreset {
2548 preset: "security".into(),
2549 path: "apps/api".into(),
2550 exclude_rules: vec![],
2551 }];
2552 let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2553 let console_rule = rules.iter().find(|r| r.id == "no-console-log").unwrap();
2554 for eg in &console_rule.exclude_glob {
2555 assert!(
2556 eg.starts_with("apps/api/"),
2557 "expected exclude_glob to start with 'apps/api/', got: {eg}"
2558 );
2559 }
2560 }
2561
2562 #[test]
2563 fn resolve_scoped_exclude_rules_skips_listed() {
2564 let scoped = vec![ScopedPreset {
2565 preset: "nextjs".into(),
2566 path: "apps/web".into(),
2567 exclude_rules: vec!["use-next-image".into()],
2568 }];
2569 let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2570 assert!(
2571 !rules.iter().any(|r| r.id == "use-next-image"),
2572 "excluded rule should not appear in resolved rules"
2573 );
2574 assert!(
2576 rules.iter().any(|r| r.id == "no-sync-scripts"),
2577 "non-excluded rules should still be present"
2578 );
2579 }
2580
2581 #[test]
2582 fn resolve_scoped_exclude_rules_empty_is_noop() {
2583 let scoped_empty = vec![ScopedPreset {
2584 preset: "nextjs".into(),
2585 path: "apps/web".into(),
2586 exclude_rules: vec![],
2587 }];
2588 let scoped_none = vec![ScopedPreset {
2589 preset: "nextjs".into(),
2590 path: "apps/web".into(),
2591 exclude_rules: vec![],
2592 }];
2593 let rules_empty = resolve_scoped_rules(&scoped_empty, &[]).unwrap();
2594 let rules_none = resolve_scoped_rules(&scoped_none, &[]).unwrap();
2595 let ids_empty: Vec<&str> = rules_empty.iter().map(|r| r.id.as_str()).collect();
2596 let ids_none: Vec<&str> = rules_none.iter().map(|r| r.id.as_str()).collect();
2597 assert_eq!(ids_empty, ids_none, "empty exclude_rules should be a no-op");
2598 }
2599}