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-localstorage-eager".into(),
601 rule_type: "banned-pattern".into(),
602 severity: "warning".into(),
603 glob: Some("**/*.{tsx,jsx}".into()),
604 pattern: Some(r"useState\(\s*(?:JSON\.parse\s*\()?localStorage\.getItem\(".into()),
605 regex: true,
606 message: "localStorage.getItem in useState runs on every render and breaks SSR — use a lazy initializer".into(),
607 suggest: Some("Use useState(() => localStorage.getItem(...)) for lazy initialization".into()),
608 ..Default::default()
609 },
610 TomlRule {
612 id: "no-regexp-in-render".into(),
613 rule_type: "banned-pattern".into(),
614 severity: "warning".into(),
615 glob: Some("**/*.{tsx,jsx}".into()),
616 pattern: Some(r"new\s+RegExp\s*\(".into()),
617 regex: true,
618 message: "new RegExp() in a component body re-compiles every render — extract to module scope or useMemo".into(),
619 suggest: Some("Move the RegExp to module scope: const MY_RE = new RegExp(...)".into()),
620 ..Default::default()
621 },
622 TomlRule {
623 id: "no-lucide-barrel".into(),
624 rule_type: "banned-pattern".into(),
625 severity: "warning".into(),
626 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]lucide-react['"]"#.into()),
627 regex: true,
628 message: "Barrel import from lucide-react pulls in all icons — use lucide-react/icons/IconName".into(),
629 suggest: Some("Import specific icons: import { Icon } from 'lucide-react/icons/Icon'".into()),
630 ..Default::default()
631 },
632 TomlRule {
633 id: "no-mui-barrel".into(),
634 rule_type: "banned-pattern".into(),
635 severity: "warning".into(),
636 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]@mui/material['"]"#.into()),
637 regex: true,
638 message: "Barrel import from @mui/material increases bundle size — use deep imports".into(),
639 suggest: Some("Import specific components: import Button from '@mui/material/Button'".into()),
640 ..Default::default()
641 },
642 TomlRule {
643 id: "no-mui-icons-barrel".into(),
644 rule_type: "banned-pattern".into(),
645 severity: "warning".into(),
646 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]@mui/icons-material['"]"#.into()),
647 regex: true,
648 message: "Barrel import from @mui/icons-material increases bundle size — use deep imports".into(),
649 suggest: Some("Import specific icons: import HomeIcon from '@mui/icons-material/Home'".into()),
650 ..Default::default()
651 },
652 TomlRule {
653 id: "no-react-icons-barrel".into(),
654 rule_type: "banned-pattern".into(),
655 severity: "warning".into(),
656 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]react-icons['"]"#.into()),
657 regex: true,
658 message: "Barrel import from react-icons pulls in all icon sets — import from a specific set".into(),
659 suggest: Some("Import from a specific set: import { FaHome } from 'react-icons/fa'".into()),
660 ..Default::default()
661 },
662 TomlRule {
663 id: "no-date-fns-barrel".into(),
664 rule_type: "banned-pattern".into(),
665 severity: "warning".into(),
666 pattern: Some(r#"(?:import\s+.*?\s+from\s+|import\s+|require\s*\(\s*)['"]date-fns['"]"#.into()),
667 regex: true,
668 message: "Barrel import from date-fns increases bundle size — use subpath imports".into(),
669 suggest: Some("Import specific functions: import { format } from 'date-fns/format'".into()),
670 ..Default::default()
671 },
672 ];
673
674 {
675 rules.push(TomlRule {
676 id: "max-component-size".into(),
677 rule_type: "max-component-size".into(),
678 severity: "warning".into(),
679 glob: Some("**/*.{tsx,jsx}".into()),
680 max_count: Some(150),
681 message: "Component exceeds 150 lines — split into smaller components".into(),
682 suggest: Some("Extract logic into custom hooks or break into sub-components".into()),
683 ..Default::default()
684 });
685 rules.push(TomlRule {
686 id: "prefer-use-reducer".into(),
687 rule_type: "prefer-use-reducer".into(),
688 severity: "warning".into(),
689 glob: Some("**/*.{tsx,jsx}".into()),
690 max_count: Some(4),
691 message: "Component has 4+ useState calls — consider useReducer for related state".into(),
692 suggest: Some("Group related state into a single useReducer".into()),
693 ..Default::default()
694 });
695 rules.push(TomlRule {
696 id: "no-cascading-set-state".into(),
697 rule_type: "no-cascading-set-state".into(),
698 severity: "warning".into(),
699 glob: Some("**/*.{tsx,jsx}".into()),
700 max_count: Some(3),
701 message: "useEffect has 3+ setState calls — consider useReducer or derived state".into(),
702 suggest: Some("Combine state updates with useReducer or compute derived values".into()),
703 ..Default::default()
704 });
705 }
706
707 rules
708 }
709 Preset::NextjsBestPractices => {
710 #[allow(unused_mut)]
711 let mut rules = vec![
712 TomlRule {
714 id: "use-next-image".into(),
715 rule_type: "banned-pattern".into(),
716 severity: "warning".into(),
717 glob: Some("**/*.{tsx,jsx}".into()),
718 pattern: Some(r"<img\s".into()),
719 regex: true,
720 exclude_glob: vec!["**/opengraph-image.*".into(), "**/og/**".into()],
721 message: "Use next/image instead of <img> for automatic optimization".into(),
722 suggest: Some("Import Image from 'next/image' and use <Image> component".into()),
723 ..Default::default()
724 },
725 TomlRule {
726 id: "next-image-fill-needs-sizes".into(),
727 rule_type: "window-pattern".into(),
728 severity: "warning".into(),
729 glob: Some("**/*.{tsx,jsx}".into()),
730 pattern: Some(r"<Image[^>]*\bfill\b".into()),
731 condition_pattern: Some(r"\bsizes\s*=".into()),
732 max_count: Some(3),
733 regex: true,
734 message: "<Image fill> without sizes attribute downloads unnecessarily large images".into(),
735 suggest: Some("Add sizes prop, e.g. sizes=\"(max-width: 768px) 100vw, 50vw\"".into()),
736 ..Default::default()
737 },
738 TomlRule {
740 id: "use-next-link".into(),
741 rule_type: "banned-pattern".into(),
742 severity: "warning".into(),
743 glob: Some("**/*.{tsx,jsx}".into()),
744 pattern: Some(r#"<a\s+href=["']/"#.into()),
745 regex: true,
746 message: "Use next/link instead of <a> for client-side navigation".into(),
747 suggest: Some("Import Link from 'next/link' and use <Link> component".into()),
748 ..Default::default()
749 },
750 TomlRule {
751 id: "no-next-router-in-app".into(),
752 rule_type: "banned-import".into(),
753 severity: "error".into(),
754 glob: Some("app/**".into()),
755 packages: vec!["next/router".into()],
756 message: "next/router is not available in App Router — use next/navigation instead".into(),
757 ..Default::default()
758 },
759 TomlRule {
760 id: "no-next-head".into(),
761 rule_type: "banned-import".into(),
762 severity: "error".into(),
763 glob: Some("app/**".into()),
764 packages: vec!["next/head".into()],
765 message: "next/head is not supported in App Router — use the Metadata API instead".into(),
766 ..Default::default()
767 },
768 TomlRule {
769 id: "no-client-side-redirect".into(),
770 rule_type: "banned-pattern".into(),
771 severity: "warning".into(),
772 glob: Some("**/*.{tsx,jsx}".into()),
773 pattern: Some(r"useEffect\([^)]*\(\)\s*(?:=>)?\s*\{[^}]*(?:router\.push|window\.location)".into()),
774 regex: true,
775 message: "Avoid client-side redirects in useEffect — use server-side redirect() or middleware".into(),
776 suggest: Some("Move redirect logic to middleware.ts or use redirect() in a server component".into()),
777 ..Default::default()
778 },
779 TomlRule {
781 id: "no-sync-scripts".into(),
782 rule_type: "banned-pattern".into(),
783 severity: "warning".into(),
784 glob: Some("**/*.{tsx,jsx}".into()),
785 pattern: Some(r"<script\s".into()),
786 regex: true,
787 message: "Use next/script instead of <script> for optimized script loading".into(),
788 suggest: Some("Import Script from 'next/script' and use <Script> component".into()),
789 ..Default::default()
790 },
791 TomlRule {
792 id: "no-link-fonts".into(),
793 rule_type: "banned-pattern".into(),
794 severity: "warning".into(),
795 glob: Some("**/*.{tsx,jsx}".into()),
796 pattern: Some(r"<link[^>]*fonts\.googleapis\.com".into()),
797 regex: true,
798 message: "Use next/font instead of Google Fonts <link> for zero layout shift".into(),
799 suggest: Some("Import from 'next/font/google' for automatic font optimization".into()),
800 ..Default::default()
801 },
802 TomlRule {
803 id: "no-css-link".into(),
804 rule_type: "banned-pattern".into(),
805 severity: "warning".into(),
806 glob: Some("**/*.{tsx,jsx}".into()),
807 pattern: Some(r#"<link[^>]*rel=["']stylesheet["']"#.into()),
808 regex: true,
809 message: "Import CSS files directly instead of using <link rel=\"stylesheet\">".into(),
810 suggest: Some("Use import './styles.css' for automatic bundling and optimization".into()),
811 ..Default::default()
812 },
813 TomlRule {
815 id: "no-private-env-client".into(),
816 rule_type: "banned-pattern".into(),
817 severity: "error".into(),
818 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
819 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()),
820 regex: true,
821 file_contains: Some("use client".into()),
822 message: "Private env vars are undefined in client components — prefix with NEXT_PUBLIC_".into(),
823 skip_strings: true,
824 ..Default::default()
825 },
826 TomlRule {
827 id: "require-use-client-for-hooks".into(),
828 rule_type: "required-pattern".into(),
829 severity: "error".into(),
830 glob: Some("app/**".into()),
831 pattern: Some("use client".into()),
832 regex: true,
833 condition_pattern: Some(r"use(State|Effect|Context|Reducer|Callback|Memo|Ref|Transition|DeferredValue|InsertionEffect|SyncExternalStore|FormStatus|Optimistic)\s*\(".into()),
834 message: "Files using React hooks must include 'use client' directive in App Router".into(),
835 ..Default::default()
836 },
837 TomlRule {
838 id: "no-async-client-component".into(),
839 rule_type: "banned-pattern".into(),
840 severity: "error".into(),
841 glob: Some("**/*.{tsx,jsx}".into()),
842 pattern: Some(r"(?:export\s+default\s+)?async\s+function\s+[A-Z]".into()),
843 regex: true,
844 file_contains: Some("use client".into()),
845 message: "Client components cannot be async — only server components support async/await".into(),
846 suggest: Some("Remove 'use client' to make this a server component, or remove async and use useEffect for data fetching".into()),
847 ..Default::default()
848 },
849 TomlRule {
851 id: "require-metadata-in-pages".into(),
852 rule_type: "required-pattern".into(),
853 severity: "warning".into(),
854 glob: Some("app/**/page.{ts,tsx,js,jsx}".into()),
855 pattern: Some(r"(?:export\s+(?:const\s+metadata|(?:async\s+)?function\s+generateMetadata))".into()),
856 regex: true,
857 message: "Page files should export metadata or generateMetadata for SEO".into(),
858 suggest: Some("Add: export const metadata = { title: '...', description: '...' }".into()),
859 ..Default::default()
860 },
861 TomlRule {
863 id: "no-redirect-in-try-catch".into(),
864 rule_type: "banned-pattern".into(),
865 severity: "error".into(),
866 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
867 pattern: Some(r"try\s*\{[^}]*\bredirect\s*\(".into()),
868 regex: true,
869 message: "redirect() throws a special error — calling it inside try/catch will swallow the redirect".into(),
870 suggest: Some("Move redirect() outside the try/catch block".into()),
871 ..Default::default()
872 },
873 TomlRule {
875 id: "server-action-requires-auth".into(),
876 rule_type: "required-pattern".into(),
877 severity: "warning".into(),
878 glob: Some("app/**/*.{ts,tsx}".into()),
879 pattern: Some(r"(?:verifySession|getSession|auth\(\)|currentUser|getServerSession)".into()),
880 regex: true,
881 condition_pattern: Some("'use server'".into()),
882 message: "Server actions should verify authentication before performing mutations".into(),
883 suggest: Some("Add an auth check: const session = await getSession()".into()),
884 ..Default::default()
885 },
886 TomlRule {
887 id: "server-action-requires-validation".into(),
888 rule_type: "required-pattern".into(),
889 severity: "warning".into(),
890 glob: Some("app/**/*.{ts,tsx}".into()),
891 pattern: Some(r"(?:\.parse\(|\.safeParse\(|z\.object\(|\.validate\()".into()),
892 regex: true,
893 condition_pattern: Some("'use server'".into()),
894 message: "Server actions should validate input — never trust client data".into(),
895 suggest: Some("Use Zod or similar: const data = schema.parse(formData)".into()),
896 ..Default::default()
897 },
898 TomlRule {
900 id: "no-suppress-hydration-warning".into(),
901 rule_type: "banned-pattern".into(),
902 severity: "warning".into(),
903 glob: Some("**/*.{tsx,jsx}".into()),
904 pattern: Some("suppressHydrationWarning".into()),
905 message: "suppressHydrationWarning hides real bugs — fix the mismatch instead".into(),
906 suggest: Some("Use useEffect + state to defer client-only content, or move to a Client Component".into()),
907 ..Default::default()
908 },
909 ];
910
911 {
912 rules.push(TomlRule {
913 id: "max-component-size".into(),
914 rule_type: "max-component-size".into(),
915 severity: "warning".into(),
916 glob: Some("**/*.{tsx,jsx}".into()),
917 max_count: Some(150),
918 message: "Component exceeds 150 lines — split into smaller components".into(),
919 suggest: Some("Extract logic into custom hooks or break into sub-components".into()),
920 ..Default::default()
921 });
922 rules.push(TomlRule {
923 id: "no-nested-components".into(),
924 rule_type: "no-nested-components".into(),
925 severity: "error".into(),
926 glob: Some("**/*.{tsx,jsx}".into()),
927 message: "Component defined inside another component — causes remounting on every render".into(),
928 suggest: Some("Move component definition to module scope or extract to a separate file".into()),
929 ..Default::default()
930 });
931 rules.push(TomlRule {
932 id: "prefer-use-reducer".into(),
933 rule_type: "prefer-use-reducer".into(),
934 severity: "warning".into(),
935 glob: Some("**/*.{tsx,jsx}".into()),
936 max_count: Some(4),
937 message: "Component has 4+ useState calls — consider useReducer for related state".into(),
938 suggest: Some("Group related state into a single useReducer".into()),
939 ..Default::default()
940 });
941 rules.push(TomlRule {
942 id: "no-cascading-set-state".into(),
943 rule_type: "no-cascading-set-state".into(),
944 severity: "warning".into(),
945 glob: Some("**/*.{tsx,jsx}".into()),
946 max_count: Some(3),
947 message: "useEffect has 3+ setState calls — consider useReducer or derived state".into(),
948 suggest: Some("Combine state updates with useReducer or compute derived values".into()),
949 ..Default::default()
950 });
951 }
952
953 rules
954 }
955 Preset::AiCodegen => vec![
956 TomlRule {
957 id: "no-placeholder-text".into(),
958 rule_type: "banned-pattern".into(),
959 severity: "warning".into(),
960 pattern: Some(r"(?i)lorem ipsum".into()),
961 regex: true,
962 message: "Placeholder text detected — replace with real content".into(),
963 ..Default::default()
964 },
965 TomlRule {
966 id: "no-unresolved-todos".into(),
967 rule_type: "banned-pattern".into(),
968 severity: "warning".into(),
969 pattern: Some(r"(?://|/?\*)\s*(TODO|FIXME|HACK|XXX)\b".into()),
970 regex: true,
971 message: "Unresolved TODO/FIXME comment — address or remove before merging".into(),
972 ..Default::default()
973 },
974 TomlRule {
975 id: "no-type-any".into(),
976 rule_type: "banned-pattern".into(),
977 severity: "error".into(),
978 glob: Some("**/*.{ts,tsx}".into()),
979 pattern: Some(r"[:<,]\s*any\b".into()),
980 regex: true,
981 exclude_glob: vec!["**/*.d.ts".into()],
982 message: "Avoid using 'any' type — use a specific type or 'unknown'".into(),
983 ..Default::default()
984 },
985 TomlRule {
986 id: "no-empty-catch".into(),
987 rule_type: "banned-pattern".into(),
988 severity: "error".into(),
989 pattern: Some(r"catch\s*\([^)]*\)\s*\{\s*\}".into()),
990 regex: true,
991 message: "Empty catch block swallows errors — handle or re-throw the error".into(),
992 ..Default::default()
993 },
994 TomlRule {
995 id: "no-console-log".into(),
996 rule_type: "banned-pattern".into(),
997 severity: "warning".into(),
998 pattern: Some(r"console\.(log|debug)\(".into()),
999 regex: true,
1000 exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
1001 message: "Remove console.log/debug before merging — use a proper logger if needed".into(),
1002 ..Default::default()
1003 },
1004 TomlRule {
1005 id: "no-ts-ignore".into(),
1006 rule_type: "banned-pattern".into(),
1007 severity: "error".into(),
1008 glob: Some("**/*.{ts,tsx}".into()),
1009 pattern: Some("@ts-ignore".into()),
1010 message: "Use @ts-expect-error instead of @ts-ignore for type suppressions".into(),
1011 ..Default::default()
1012 },
1013 TomlRule {
1014 id: "no-as-any".into(),
1015 rule_type: "banned-pattern".into(),
1016 severity: "error".into(),
1017 glob: Some("**/*.{ts,tsx}".into()),
1018 pattern: Some(r"\bas\s+any\b".into()),
1019 regex: true,
1020 message: "Avoid 'as any' type assertion — use proper types or 'as unknown'".into(),
1021 ..Default::default()
1022 },
1023 TomlRule {
1024 id: "no-eslint-disable".into(),
1025 rule_type: "banned-pattern".into(),
1026 severity: "warning".into(),
1027 pattern: Some("eslint-disable".into()),
1028 message: "Remove eslint-disable comment — fix the underlying issue instead".into(),
1029 ..Default::default()
1030 },
1031 TomlRule {
1032 id: "no-ts-nocheck".into(),
1033 rule_type: "banned-pattern".into(),
1034 severity: "error".into(),
1035 glob: Some("**/*.{ts,tsx}".into()),
1036 pattern: Some("@ts-nocheck".into()),
1037 message: "Do not disable type checking for entire files — fix type errors instead".into(),
1038 ..Default::default()
1039 },
1040 TomlRule {
1041 id: "no-var".into(),
1042 rule_type: "banned-pattern".into(),
1043 severity: "error".into(),
1044 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
1045 pattern: Some(r"\bvar\s+\w".into()),
1046 regex: true,
1047 exclude_glob: vec!["**/*.d.ts".into()],
1048 message: "Use 'let' or 'const' instead of 'var'".into(),
1049 ..Default::default()
1050 },
1051 TomlRule {
1052 id: "no-require-in-ts".into(),
1053 rule_type: "banned-pattern".into(),
1054 severity: "warning".into(),
1055 glob: Some("**/*.{ts,tsx}".into()),
1056 pattern: Some(r"\brequire\s*\(".into()),
1057 regex: true,
1058 message: "Use ES module 'import' instead of CommonJS 'require()' in TypeScript".into(),
1059 ..Default::default()
1060 },
1061 TomlRule {
1062 id: "no-non-null-assertion".into(),
1063 rule_type: "banned-pattern".into(),
1064 severity: "warning".into(),
1065 glob: Some("**/*.{ts,tsx}".into()),
1066 pattern: Some(r"\w![.\[]".into()),
1067 regex: true,
1068 message: "Avoid non-null assertion (!) — use optional chaining (?.) or proper null checks".into(),
1069 ..Default::default()
1070 },
1071 ],
1072 Preset::Accessibility => {
1073 #[allow(unused_mut)]
1074 let mut rules = vec![
1075 TomlRule {
1076 id: "no-div-click-handler".into(),
1077 rule_type: "banned-pattern".into(),
1078 severity: "error".into(),
1079 glob: Some("**/*.{tsx,jsx}".into()),
1080 pattern: Some(r"<div[^>]+onClick\s*=".into()),
1081 regex: true,
1082 message: "Non-interactive <div> with onClick is not keyboard accessible — use <button> instead".into(),
1083 suggest: Some("Replace <div onClick=...> with <button onClick=...>".into()),
1084 ..Default::default()
1085 },
1086 TomlRule {
1087 id: "no-span-click-handler".into(),
1088 rule_type: "banned-pattern".into(),
1089 severity: "error".into(),
1090 glob: Some("**/*.{tsx,jsx}".into()),
1091 pattern: Some(r"<span[^>]+onClick\s*=".into()),
1092 regex: true,
1093 message: "Non-interactive <span> with onClick is not keyboard accessible — use <button> instead".into(),
1094 suggest: Some("Replace <span onClick=...> with <button onClick=...>".into()),
1095 ..Default::default()
1096 },
1097 TomlRule {
1098 id: "no-outline-none".into(),
1099 rule_type: "banned-pattern".into(),
1100 severity: "warning".into(),
1101 glob: Some("**/*.{tsx,jsx}".into()),
1102 pattern: Some(r"\boutline-none\b".into()),
1103 regex: true,
1104 message: "outline-none removes the focus indicator — keyboard users can't see what's focused".into(),
1105 suggest: Some("Use focus-visible:outline-none with a custom focus ring instead".into()),
1106 ..Default::default()
1107 },
1108 TomlRule {
1109 id: "no-user-scalable-no".into(),
1110 rule_type: "banned-pattern".into(),
1111 severity: "error".into(),
1112 glob: Some("**/*.{tsx,jsx}".into()),
1113 pattern: Some(r"user-scalable\s*=\s*no".into()),
1114 regex: true,
1115 message: "user-scalable=no prevents zooming — violates WCAG 1.4.4 (Resize Text)".into(),
1116 suggest: Some("Remove user-scalable=no to allow pinch-to-zoom".into()),
1117 ..Default::default()
1118 },
1119 TomlRule {
1120 id: "no-autofocus-unrestricted".into(),
1121 rule_type: "banned-pattern".into(),
1122 severity: "warning".into(),
1123 glob: Some("**/*.{tsx,jsx}".into()),
1124 pattern: Some(r"\bautoFocus\b".into()),
1125 regex: true,
1126 message: "autoFocus can disorient screen reader users — use it sparingly (e.g., modals only)".into(),
1127 suggest: Some("Remove autoFocus or limit to modal/dialog initial focus".into()),
1128 ..Default::default()
1129 },
1130 TomlRule {
1131 id: "no-transition-all-tailwind".into(),
1132 rule_type: "banned-pattern".into(),
1133 severity: "warning".into(),
1134 glob: Some("**/*.{tsx,jsx}".into()),
1135 pattern: Some(r"\btransition-all\b".into()),
1136 regex: true,
1137 message: "transition-all can cause motion sickness — transition specific properties and respect prefers-reduced-motion".into(),
1138 suggest: Some("Use transition-colors, transition-opacity, or transition-transform instead".into()),
1139 ..Default::default()
1140 },
1141 TomlRule {
1142 id: "no-hardcoded-date-format".into(),
1143 rule_type: "banned-pattern".into(),
1144 severity: "warning".into(),
1145 glob: Some("**/*.{ts,tsx,js,jsx}".into()),
1146 pattern: Some(r"\.toDateString\(\s*\)|\.toLocaleString\(\s*\)|\.toLocaleDateString\(\s*\)".into()),
1147 regex: true,
1148 message: "Date formatting without explicit locale is inconsistent across browsers — pass a locale".into(),
1149 suggest: Some("Pass an explicit locale: .toLocaleDateString('en-US', { ... })".into()),
1150 ..Default::default()
1151 },
1152 TomlRule {
1153 id: "no-inline-navigation-onclick".into(),
1154 rule_type: "banned-pattern".into(),
1155 severity: "warning".into(),
1156 glob: Some("**/*.{tsx,jsx}".into()),
1157 pattern: Some(r"onClick[^=]*=[^;]*window\.location".into()),
1158 regex: true,
1159 message: "onClick with window.location bypasses browser navigation — use <a> or router for accessible navigation".into(),
1160 suggest: Some("Use <a href=...> or your router's <Link> component instead".into()),
1161 ..Default::default()
1162 },
1163 ];
1164
1165 {
1166 rules.push(TomlRule {
1167 id: "require-img-alt".into(),
1168 rule_type: "require-img-alt".into(),
1169 severity: "error".into(),
1170 glob: Some("**/*.{tsx,jsx}".into()),
1171 message: "img element must have an alt attribute for screen readers".into(),
1172 suggest: Some("Add alt=\"description\" or alt=\"\" for decorative images".into()),
1173 ..Default::default()
1174 });
1175 }
1176
1177 rules
1178 }
1179 Preset::ReactNative => vec![
1180 TomlRule {
1181 id: "rn-no-touchable-opacity".into(),
1182 rule_type: "banned-pattern".into(),
1183 severity: "warning".into(),
1184 glob: Some("**/*.{tsx,jsx}".into()),
1185 pattern: Some(r"<TouchableOpacity|import\s+\{[^}]*TouchableOpacity".into()),
1186 regex: true,
1187 message: "TouchableOpacity is deprecated — use Pressable instead".into(),
1188 suggest: Some("Replace <TouchableOpacity> with <Pressable>".into()),
1189 ..Default::default()
1190 },
1191 TomlRule {
1192 id: "rn-no-touchable-highlight".into(),
1193 rule_type: "banned-pattern".into(),
1194 severity: "warning".into(),
1195 glob: Some("**/*.{tsx,jsx}".into()),
1196 pattern: Some(r"<TouchableHighlight|import\s+\{[^}]*TouchableHighlight".into()),
1197 regex: true,
1198 message: "TouchableHighlight is deprecated — use Pressable instead".into(),
1199 suggest: Some("Replace <TouchableHighlight> with <Pressable>".into()),
1200 ..Default::default()
1201 },
1202 TomlRule {
1203 id: "rn-no-legacy-shadow".into(),
1204 rule_type: "banned-pattern".into(),
1205 severity: "warning".into(),
1206 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
1207 pattern: Some(r"shadowColor\s*:|shadowOffset\s*:|shadowOpacity\s*:|shadowRadius\s*:".into()),
1208 regex: true,
1209 message: "Legacy shadow properties are iOS-only — use boxShadow (RN 0.76+) for cross-platform shadows".into(),
1210 suggest: Some("Use style={{ boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}".into()),
1211 ..Default::default()
1212 },
1213 TomlRule {
1214 id: "rn-no-rn-image-import".into(),
1215 rule_type: "banned-pattern".into(),
1216 severity: "warning".into(),
1217 glob: Some("**/*.{tsx,jsx}".into()),
1218 pattern: Some(r"import\s+\{[^}]*\bImage\b[^}]*\}\s+from\s+['\x22]react-native['\x22]".into()),
1219 regex: true,
1220 message: "react-native Image lacks caching and modern formats — use expo-image instead".into(),
1221 suggest: Some("Replace with: import { Image } from 'expo-image'".into()),
1222 ..Default::default()
1223 },
1224 TomlRule {
1225 id: "rn-no-custom-header".into(),
1226 rule_type: "banned-pattern".into(),
1227 severity: "warning".into(),
1228 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
1229 pattern: Some(r"header:\s*\(\)\s*=>".into()),
1230 regex: true,
1231 message: "Custom header render function loses native header animations — use headerTitle or screen options".into(),
1232 suggest: Some("Use screenOptions={{ headerTitle: ... }} instead of header: () => ...".into()),
1233 ..Default::default()
1234 },
1235 TomlRule {
1236 id: "rn-no-fonts-usefonts".into(),
1237 rule_type: "banned-pattern".into(),
1238 severity: "warning".into(),
1239 glob: Some("**/*.{tsx,jsx}".into()),
1240 pattern: Some(r"useFonts\s*\(\s*\{".into()),
1241 regex: true,
1242 message: "useFonts blocks rendering with a loading screen — use expo-font config plugin for build-time font loading".into(),
1243 suggest: Some("Add fonts to app.json expo-font plugin for instant availability".into()),
1244 ..Default::default()
1245 },
1246 TomlRule {
1247 id: "rn-no-font-loadasync".into(),
1248 rule_type: "banned-pattern".into(),
1249 severity: "warning".into(),
1250 glob: Some("**/*.{tsx,jsx,ts,js}".into()),
1251 pattern: Some(r"Font\.loadAsync\s*\(".into()),
1252 regex: true,
1253 message: "Font.loadAsync blocks rendering — use expo-font config plugin for build-time font loading".into(),
1254 suggest: Some("Add fonts to app.json expo-font plugin for instant availability".into()),
1255 ..Default::default()
1256 },
1257 TomlRule {
1258 id: "rn-no-inline-intl-numberformat".into(),
1259 rule_type: "banned-pattern".into(),
1260 severity: "warning".into(),
1261 glob: Some("**/*.{tsx,jsx}".into()),
1262 pattern: Some(r"new\s+Intl\.NumberFormat\s*\(".into()),
1263 regex: true,
1264 message: "new Intl.NumberFormat() in a component body re-creates the formatter every render — extract to module scope".into(),
1265 suggest: Some("Move to module scope: const fmt = new Intl.NumberFormat(...)".into()),
1266 ..Default::default()
1267 },
1268 TomlRule {
1269 id: "rn-no-inline-intl-datetimeformat".into(),
1270 rule_type: "banned-pattern".into(),
1271 severity: "warning".into(),
1272 glob: Some("**/*.{tsx,jsx}".into()),
1273 pattern: Some(r"new\s+Intl\.DateTimeFormat\s*\(".into()),
1274 regex: true,
1275 message: "new Intl.DateTimeFormat() in a component body re-creates the formatter every render — extract to module scope".into(),
1276 suggest: Some("Move to module scope: const fmt = new Intl.DateTimeFormat(...)".into()),
1277 ..Default::default()
1278 },
1279 TomlRule {
1280 id: "rn-no-js-stack-navigator".into(),
1281 rule_type: "banned-import".into(),
1282 severity: "warning".into(),
1283 packages: vec!["@react-navigation/stack".into()],
1284 message: "JS-based stack navigator is slow — use @react-navigation/native-stack for native performance".into(),
1285 suggest: Some("Replace with: import { createNativeStackNavigator } from '@react-navigation/native-stack'".into()),
1286 ..Default::default()
1287 },
1288 TomlRule {
1289 id: "rn-no-js-bottom-tabs".into(),
1290 rule_type: "banned-import".into(),
1291 severity: "warning".into(),
1292 packages: vec!["@react-navigation/bottom-tabs".into()],
1293 message: "JS-based bottom tabs lack native feel — use react-native-bottom-tabs for native tab bar".into(),
1294 suggest: Some("Replace with: import { createNativeBottomTabNavigator } from 'react-native-bottom-tabs'".into()),
1295 ..Default::default()
1296 },
1297 TomlRule {
1298 id: "rn-no-linear-gradient-lib".into(),
1299 rule_type: "banned-import".into(),
1300 severity: "warning".into(),
1301 packages: vec!["expo-linear-gradient".into()],
1302 message: "expo-linear-gradient adds a JS bridge — use React Native's built-in linearGradient style (0.76+)".into(),
1303 suggest: Some("Use style={{ experimental_backgroundImage: 'linear-gradient(...)' }}".into()),
1304 ..Default::default()
1305 },
1306 TomlRule {
1307 id: "rn-no-js-bottom-sheet".into(),
1308 rule_type: "banned-dependency".into(),
1309 severity: "warning".into(),
1310 packages: vec!["@gorhom/bottom-sheet".into()],
1311 message: "@gorhom/bottom-sheet uses JS animations — use expo-bottom-sheet or react-native-bottom-sheet for native performance".into(),
1312 ..Default::default()
1313 },
1314 ],
1315 }
1316}
1317
1318fn merge_rules(preset_rules: Vec<TomlRule>, user_rules: &[TomlRule]) -> Vec<TomlRule> {
1322 let mut merged = preset_rules;
1323
1324 let mut id_to_index: HashMap<String, usize> = HashMap::new();
1326 for (i, rule) in merged.iter().enumerate() {
1327 id_to_index.insert(rule.id.clone(), i);
1328 }
1329
1330 for user_rule in user_rules {
1331 if let Some(&idx) = id_to_index.get(&user_rule.id) {
1332 merged[idx] = user_rule.clone();
1334 } else {
1335 merged.push(user_rule.clone());
1337 }
1338 }
1339
1340 merged
1341}
1342
1343pub fn resolve_rules(
1346 extends: &[String],
1347 user_rules: &[TomlRule],
1348) -> Result<Vec<TomlRule>, PresetError> {
1349 if extends.is_empty() {
1350 return Ok(user_rules.to_vec());
1351 }
1352
1353 let mut all_preset_rules: Vec<TomlRule> = Vec::new();
1355 let mut seen: HashMap<String, usize> = HashMap::new();
1356
1357 for preset_name in extends {
1358 let preset = resolve_preset(preset_name).ok_or_else(|| PresetError::UnknownPreset {
1359 name: preset_name.clone(),
1360 available: available_presets().to_vec(),
1361 })?;
1362
1363 for rule in preset_rules(preset) {
1364 if let Some(&idx) = seen.get(&rule.id) {
1365 all_preset_rules[idx] = rule;
1367 } else {
1368 seen.insert(rule.id.clone(), all_preset_rules.len());
1369 all_preset_rules.push(rule);
1370 }
1371 }
1372 }
1373
1374 Ok(merge_rules(all_preset_rules, user_rules))
1375}
1376
1377fn scope_glob(path: &str, glob: &str) -> String {
1380 let stripped = glob.strip_prefix("**/").unwrap_or(glob);
1381 format!("{path}/{stripped}")
1382}
1383
1384pub fn resolve_scoped_rules(
1386 scoped: &[ScopedPreset],
1387 user_rules: &[TomlRule],
1388) -> Result<Vec<TomlRule>, PresetError> {
1389 let mut result: Vec<TomlRule> = Vec::new();
1390
1391 for entry in scoped {
1392 let preset = resolve_preset(&entry.preset).ok_or_else(|| PresetError::UnknownPreset {
1393 name: entry.preset.clone(),
1394 available: available_presets().to_vec(),
1395 })?;
1396
1397 for mut rule in preset_rules(preset) {
1398 rule.glob = Some(match rule.glob {
1400 Some(g) => scope_glob(&entry.path, &g),
1401 None => format!("{}/**", entry.path),
1402 });
1403
1404 rule.exclude_glob = rule
1406 .exclude_glob
1407 .iter()
1408 .map(|g| scope_glob(&entry.path, g))
1409 .collect();
1410
1411 rule.required_files = rule
1413 .required_files
1414 .iter()
1415 .map(|f| format!("{}/{f}", entry.path))
1416 .collect();
1417 rule.forbidden_files = rule
1418 .forbidden_files
1419 .iter()
1420 .map(|f| format!("{}/{f}", entry.path))
1421 .collect();
1422
1423 if user_rules.iter().any(|u| u.id == rule.id) {
1425 continue;
1426 }
1427
1428 if entry.exclude_rules.contains(&rule.id) {
1430 continue;
1431 }
1432
1433 result.push(rule);
1434 }
1435 }
1436
1437 Ok(result)
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442 use super::*;
1443
1444 #[test]
1445 fn shadcn_strict_has_five_rules() {
1446 let rules = preset_rules(Preset::ShadcnStrict);
1447 assert_eq!(rules.len(), 5);
1448 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1449 assert!(ids.contains(&"enforce-dark-mode"));
1450 assert!(ids.contains(&"use-theme-tokens"));
1451 assert!(ids.contains(&"no-inline-styles"));
1452 assert!(ids.contains(&"no-css-in-js"));
1453 assert!(ids.contains(&"no-competing-frameworks"));
1454 }
1455
1456 #[test]
1457 fn shadcn_migrate_has_two_rules() {
1458 let rules = preset_rules(Preset::ShadcnMigrate);
1459 assert_eq!(rules.len(), 2);
1460 assert_eq!(rules[0].id, "enforce-dark-mode");
1461 assert_eq!(rules[1].id, "use-theme-tokens");
1462 assert_eq!(rules[1].severity, "warning");
1464 }
1465
1466 #[test]
1467 fn ai_safety_has_three_rules() {
1468 let rules = preset_rules(Preset::AiSafety);
1469 assert_eq!(rules.len(), 3);
1470 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1471 assert!(ids.contains(&"no-moment"));
1472 assert!(ids.contains(&"no-lodash"));
1473 assert!(ids.contains(&"no-deprecated-request"));
1474 }
1475
1476 #[test]
1477 fn resolve_unknown_preset_errors() {
1478 let result = resolve_rules(&["unknown-preset".to_string()], &[]);
1479 assert!(result.is_err());
1480 let err = result.unwrap_err();
1481 let msg = format!("{}", err);
1482 assert!(msg.contains("unknown preset 'unknown-preset'"));
1483 assert!(msg.contains("shadcn-strict"));
1484 }
1485
1486 #[test]
1487 fn resolve_empty_extends_returns_user_rules() {
1488 let user_rules = vec![TomlRule {
1489 id: "custom-rule".into(),
1490 rule_type: "banned-pattern".into(),
1491 pattern: Some("TODO".into()),
1492 message: "No TODOs".into(),
1493 ..Default::default()
1494 }];
1495 let result = resolve_rules(&[], &user_rules).unwrap();
1496 assert_eq!(result.len(), 1);
1497 assert_eq!(result[0].id, "custom-rule");
1498 }
1499
1500 #[test]
1501 fn user_rule_overrides_preset() {
1502 let user_rules = vec![TomlRule {
1503 id: "use-theme-tokens".into(),
1504 rule_type: "tailwind-theme-tokens".into(),
1505 severity: "warning".into(),
1506 glob: Some("**/*.{tsx,jsx}".into()),
1507 message: "Custom message".into(),
1508 ..Default::default()
1509 }];
1510 let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
1511 assert_eq!(result.len(), 5);
1512 let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
1513 assert_eq!(token_rule.severity, "warning");
1514 assert_eq!(token_rule.message, "Custom message");
1515 }
1516
1517 #[test]
1518 fn user_rule_appended_after_preset() {
1519 let user_rules = vec![TomlRule {
1520 id: "my-custom".into(),
1521 rule_type: "banned-pattern".into(),
1522 pattern: Some("foo".into()),
1523 message: "no foo".into(),
1524 ..Default::default()
1525 }];
1526 let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
1527 assert_eq!(result.len(), 6);
1528 assert_eq!(result[5].id, "my-custom");
1529 }
1530
1531 #[test]
1532 fn later_preset_overrides_earlier() {
1533 let result = resolve_rules(
1536 &["shadcn-strict".to_string(), "shadcn-migrate".to_string()],
1537 &[],
1538 )
1539 .unwrap();
1540 let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
1541 assert_eq!(token_rule.severity, "warning");
1542 assert_eq!(result.len(), 5);
1544 }
1545
1546 #[test]
1547 fn multiple_presets_combine() {
1548 let result = resolve_rules(
1549 &["shadcn-migrate".to_string(), "ai-safety".to_string()],
1550 &[],
1551 )
1552 .unwrap();
1553 assert_eq!(result.len(), 5);
1555 }
1556
1557 #[test]
1558 fn security_has_eleven_rules() {
1559 let rules = preset_rules(Preset::Security);
1560 assert_eq!(rules.len(), 11);
1561 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1562 assert!(ids.contains(&"no-env-files"));
1563 assert!(ids.contains(&"no-hardcoded-secrets"));
1564 assert!(ids.contains(&"no-eval"));
1565 assert!(ids.contains(&"no-dangerous-html"));
1566 assert!(ids.contains(&"no-innerhtml"));
1567 assert!(ids.contains(&"no-console-log"));
1568 assert!(ids.contains(&"no-document-write"));
1569 assert!(ids.contains(&"no-postmessage-wildcard"));
1570 assert!(ids.contains(&"no-outerhtml"));
1571 assert!(ids.contains(&"no-http-links"));
1572 assert!(ids.contains(&"no-paste-prevention"));
1573 }
1574
1575 #[test]
1576 fn nextjs_has_eight_rules() {
1577 let rules = preset_rules(Preset::Nextjs);
1578 assert_eq!(rules.len(), 8);
1579 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1580 assert!(ids.contains(&"use-next-image"));
1581 assert!(ids.contains(&"no-next-head"));
1582 assert!(ids.contains(&"no-private-env-client"));
1583 assert!(ids.contains(&"require-use-client-for-hooks"));
1584 assert!(ids.contains(&"use-next-link"));
1585 assert!(ids.contains(&"no-next-router-in-app"));
1586 assert!(ids.contains(&"no-sync-scripts"));
1587 assert!(ids.contains(&"no-link-fonts"));
1588 }
1589
1590 #[test]
1591 fn ai_codegen_has_twelve_rules() {
1592 let rules = preset_rules(Preset::AiCodegen);
1593 assert_eq!(rules.len(), 12);
1594 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1595 assert!(ids.contains(&"no-placeholder-text"));
1596 assert!(ids.contains(&"no-unresolved-todos"));
1597 assert!(ids.contains(&"no-type-any"));
1598 assert!(ids.contains(&"no-empty-catch"));
1599 assert!(ids.contains(&"no-console-log"));
1600 assert!(ids.contains(&"no-ts-ignore"));
1601 assert!(ids.contains(&"no-as-any"));
1602 assert!(ids.contains(&"no-eslint-disable"));
1603 assert!(ids.contains(&"no-ts-nocheck"));
1604 assert!(ids.contains(&"no-var"));
1605 assert!(ids.contains(&"no-require-in-ts"));
1606 assert!(ids.contains(&"no-non-null-assertion"));
1607 }
1608
1609 #[test]
1610 fn react_has_expected_rule_count() {
1611 let rules = preset_rules(Preset::React);
1612 assert_eq!(rules.len(), 30);
1613 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1614 assert!(ids.contains(&"no-array-index-key"));
1615 assert!(ids.contains(&"no-conditional-render-zero"));
1616 assert!(ids.contains(&"no-nested-component-def"));
1617 assert!(ids.contains(&"no-dangerous-html"));
1618 assert!(ids.contains(&"no-full-lodash-import"));
1619 assert!(ids.contains(&"no-moment"));
1620 assert!(ids.contains(&"no-moment-dep"));
1621 assert!(ids.contains(&"no-new-function"));
1622 assert!(ids.contains(&"no-transition-all"));
1623 assert!(ids.contains(&"no-layout-animation"));
1624 assert!(ids.contains(&"no-sequential-await"));
1625 assert!(ids.contains(&"no-derived-state-effect"));
1626 assert!(ids.contains(&"no-fetch-in-effect"));
1627 assert!(ids.contains(&"no-lazy-state-init"));
1628 assert!(ids.contains(&"no-object-dep-array"));
1629 assert!(ids.contains(&"no-default-object-prop"));
1630 assert!(ids.contains(&"no-forwardref"));
1632 assert!(ids.contains(&"no-use-context"));
1633 assert!(ids.contains(&"no-unsafe-createcontext-default"));
1635 assert!(ids.contains(&"no-effect-callback-sync"));
1636 assert!(ids.contains(&"no-usestate-localstorage-eager"));
1637 assert!(ids.contains(&"no-regexp-in-render"));
1639 assert!(ids.contains(&"no-lucide-barrel"));
1640 assert!(ids.contains(&"no-mui-barrel"));
1641 assert!(ids.contains(&"no-mui-icons-barrel"));
1642 assert!(ids.contains(&"no-react-icons-barrel"));
1643 assert!(ids.contains(&"no-date-fns-barrel"));
1644 assert!(ids.contains(&"max-component-size"));
1645 assert!(ids.contains(&"prefer-use-reducer"));
1646 assert!(ids.contains(&"no-cascading-set-state"));
1647 let nested_rule = rules.iter().find(|r| r.id == "no-nested-component-def").unwrap();
1648 assert_eq!(nested_rule.rule_type, "no-nested-components");
1649 }
1650
1651 #[test]
1652 fn nextjs_best_practices_has_expected_rule_count() {
1653 let rules = preset_rules(Preset::NextjsBestPractices);
1654 assert_eq!(rules.len(), 21);
1655 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1656 assert!(ids.contains(&"use-next-image"));
1657 assert!(ids.contains(&"next-image-fill-needs-sizes"));
1658 assert!(ids.contains(&"use-next-link"));
1659 assert!(ids.contains(&"no-next-router-in-app"));
1660 assert!(ids.contains(&"no-next-head"));
1661 assert!(ids.contains(&"no-client-side-redirect"));
1662 assert!(ids.contains(&"no-sync-scripts"));
1663 assert!(ids.contains(&"no-link-fonts"));
1664 assert!(ids.contains(&"no-css-link"));
1665 assert!(ids.contains(&"no-private-env-client"));
1666 assert!(ids.contains(&"require-use-client-for-hooks"));
1667 assert!(ids.contains(&"no-async-client-component"));
1668 assert!(ids.contains(&"require-metadata-in-pages"));
1669 assert!(ids.contains(&"no-redirect-in-try-catch"));
1670 assert!(ids.contains(&"server-action-requires-auth"));
1671 assert!(ids.contains(&"server-action-requires-validation"));
1672 assert!(ids.contains(&"no-suppress-hydration-warning"));
1673 assert!(ids.contains(&"max-component-size"));
1674 assert!(ids.contains(&"no-nested-components"));
1675 assert!(ids.contains(&"prefer-use-reducer"));
1676 assert!(ids.contains(&"no-cascading-set-state"));
1677 }
1678
1679 #[test]
1680 fn accessibility_has_expected_rule_count() {
1681 let rules = preset_rules(Preset::Accessibility);
1682 assert_eq!(rules.len(), 9);
1683 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1684 assert!(ids.contains(&"no-div-click-handler"));
1685 assert!(ids.contains(&"no-span-click-handler"));
1686 assert!(ids.contains(&"no-outline-none"));
1687 assert!(ids.contains(&"no-user-scalable-no"));
1688 assert!(ids.contains(&"no-autofocus-unrestricted"));
1689 assert!(ids.contains(&"no-transition-all-tailwind"));
1690 assert!(ids.contains(&"no-hardcoded-date-format"));
1691 assert!(ids.contains(&"no-inline-navigation-onclick"));
1692 assert!(ids.contains(&"require-img-alt"));
1693 }
1694
1695 #[test]
1696 fn react_native_has_thirteen_rules() {
1697 let rules = preset_rules(Preset::ReactNative);
1698 assert_eq!(rules.len(), 13);
1699 let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1700 assert!(ids.contains(&"rn-no-touchable-opacity"));
1701 assert!(ids.contains(&"rn-no-touchable-highlight"));
1702 assert!(ids.contains(&"rn-no-legacy-shadow"));
1703 assert!(ids.contains(&"rn-no-rn-image-import"));
1704 assert!(ids.contains(&"rn-no-custom-header"));
1705 assert!(ids.contains(&"rn-no-fonts-usefonts"));
1706 assert!(ids.contains(&"rn-no-font-loadasync"));
1707 assert!(ids.contains(&"rn-no-inline-intl-numberformat"));
1708 assert!(ids.contains(&"rn-no-inline-intl-datetimeformat"));
1709 assert!(ids.contains(&"rn-no-js-stack-navigator"));
1710 assert!(ids.contains(&"rn-no-js-bottom-tabs"));
1711 assert!(ids.contains(&"rn-no-linear-gradient-lib"));
1712 assert!(ids.contains(&"rn-no-js-bottom-sheet"));
1713 }
1714
1715 #[test]
1716 fn all_preset_names_resolve() {
1717 for name in available_presets() {
1718 assert!(
1719 resolve_preset(name).is_some(),
1720 "preset '{}' should resolve",
1721 name
1722 );
1723 }
1724 }
1725
1726 #[test]
1727 fn all_preset_regex_patterns_compile() {
1728 use regex::Regex;
1729 for name in available_presets() {
1730 let preset = resolve_preset(name).unwrap();
1731 for rule in preset_rules(preset) {
1732 if rule.regex {
1733 if let Some(ref pat) = rule.pattern {
1734 Regex::new(pat).unwrap_or_else(|e| {
1735 panic!("preset '{}', rule '{}': invalid pattern: {}", name, rule.id, e)
1736 });
1737 }
1738 if let Some(ref pat) = rule.condition_pattern {
1739 Regex::new(pat).unwrap_or_else(|e| {
1740 panic!(
1741 "preset '{}', rule '{}': invalid condition_pattern: {}",
1742 name, rule.id, e
1743 )
1744 });
1745 }
1746 }
1747 }
1748 }
1749 }
1750
1751 #[test]
1752 fn no_private_env_client_pattern_correctness() {
1753 use regex::Regex;
1754 let rules = preset_rules(Preset::Nextjs);
1755 let rule = rules.iter().find(|r| r.id == "no-private-env-client").unwrap();
1756 let re = Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
1757
1758 assert!(re.is_match("process.env.DATABASE_URL"));
1760 assert!(re.is_match("process.env.API_SECRET"));
1761 assert!(re.is_match("process.env.NODE_ENV"));
1762 assert!(re.is_match("process.env.NEXT_RUNTIME"));
1763
1764 assert!(!re.is_match("process.env.NEXT_PUBLIC_API_URL"));
1766 assert!(!re.is_match("process.env.NEXT_PUBLIC_STRIPE_KEY"));
1767 }
1768
1769 fn regex_for(preset: Preset, rule_id: &str) -> regex::Regex {
1771 let rules = preset_rules(preset);
1772 let rule = rules
1773 .iter()
1774 .find(|r| r.id == rule_id)
1775 .unwrap_or_else(|| panic!("rule '{}' not found", rule_id));
1776 regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap()
1777 }
1778
1779 #[test]
1782 fn no_document_write_pattern() {
1783 let re = regex_for(Preset::Security, "no-document-write");
1784 assert!(re.is_match("document.write('hello')"));
1785 assert!(re.is_match("document.write (html)"));
1786 assert!(re.is_match(" document.write('<div>')"));
1787 assert!(!re.is_match("const w = document.writeln"));
1789 assert!(!re.is_match("documentWriter()"));
1790 }
1791
1792 #[test]
1793 fn no_postmessage_wildcard_pattern() {
1794 let re = regex_for(Preset::Security, "no-postmessage-wildcard");
1795 assert!(re.is_match("window.postMessage(data, '*')"));
1796 assert!(re.is_match(r#"iframe.contentWindow.postMessage({}, "*")"#));
1797 assert!(re.is_match(" w.postMessage(msg, '*')"));
1798 assert!(!re.is_match("window.postMessage(data, 'https://example.com')"));
1800 assert!(!re.is_match("window.postMessage(data, origin)"));
1801 }
1802
1803 #[test]
1804 fn no_outerhtml_pattern() {
1805 let re = regex_for(Preset::Security, "no-outerhtml");
1806 assert!(re.is_match("el.outerHTML = '<div>'"));
1807 assert!(re.is_match("el.outerHTML += '<span>'"));
1808 assert!(re.is_match(" node.outerHTML = html"));
1809 assert!(!re.is_match("const html = el.outerHTML"));
1811 assert!(!re.is_match("console.log(el.outerHTML)"));
1812 }
1813
1814 #[test]
1815 fn no_http_links_pattern() {
1816 let re = regex_for(Preset::Security, "no-http-links");
1817 assert!(re.is_match(r#"fetch("http://api.example.com")"#));
1818 assert!(re.is_match("const url = 'http://cdn.example.com'"));
1819 assert!(!re.is_match(r#"fetch("https://api.example.com")"#));
1821 assert!(!re.is_match("// visit http://example.com"));
1823 }
1824
1825 #[test]
1826 fn no_hardcoded_secrets_expanded() {
1827 let re = regex_for(Preset::Security, "no-hardcoded-secrets");
1828 assert!(re.is_match(r#"api_key = "abc12345678""#));
1830 assert!(re.is_match(r#"API_KEY: "abc12345678""#));
1831 assert!(re.is_match(r#"password = "mysecretpass""#));
1833 assert!(re.is_match(r#"PASSWORD: "supersecret1""#));
1834 assert!(re.is_match(r#"client_secret = "abcdefghij""#));
1835 assert!(!re.is_match(r#"password = "short""#));
1837 assert!(!re.is_match("password = getPassword()"));
1839 }
1840
1841 #[test]
1844 fn no_sync_scripts_pattern() {
1845 let re = regex_for(Preset::Nextjs, "no-sync-scripts");
1846 assert!(re.is_match(r#"<script src="analytics.js">"#));
1847 assert!(re.is_match(r#"<script type="application/ld+json">"#));
1848 assert!(!re.is_match(r#"<Script src="analytics.js">"#));
1850 assert!(!re.is_match("</script>"));
1852 }
1853
1854 #[test]
1855 fn no_link_fonts_pattern() {
1856 let re = regex_for(Preset::Nextjs, "no-link-fonts");
1857 assert!(re.is_match(
1858 r#"<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />"#
1859 ));
1860 assert!(re.is_match(
1861 r#"<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto">"#
1862 ));
1863 assert!(!re.is_match(r#"<link rel="stylesheet" href="/styles.css" />"#));
1865 assert!(!re.is_match(r#"<Link href="/fonts">"#));
1867 }
1868
1869 #[test]
1872 fn no_eslint_disable_pattern() {
1873 let rules = preset_rules(Preset::AiCodegen);
1874 let rule = rules.iter().find(|r| r.id == "no-eslint-disable").unwrap();
1875 let pat = rule.pattern.as_ref().unwrap();
1876 assert!(!rule.regex);
1878 assert!("// eslint-disable-next-line no-console".contains(pat.as_str()));
1879 assert!("/* eslint-disable */".contains(pat.as_str()));
1880 assert!("/* eslint-disable-next-line */".contains(pat.as_str()));
1881 }
1882
1883 #[test]
1884 fn no_var_pattern() {
1885 let re = regex_for(Preset::AiCodegen, "no-var");
1886 assert!(re.is_match("var x = 1"));
1887 assert!(re.is_match("var foo = 'bar'"));
1888 assert!(re.is_match(" var count = 0;"));
1889 assert!(!re.is_match("const variable = 1"));
1891 assert!(!re.is_match("let variance = 2"));
1892 assert!(!re.is_match("const isVariable = true"));
1893 }
1894
1895 #[test]
1896 fn no_require_in_ts_pattern() {
1897 let re = regex_for(Preset::AiCodegen, "no-require-in-ts");
1898 assert!(re.is_match("const fs = require('fs')"));
1899 assert!(re.is_match("const x = require('./module')"));
1900 assert!(re.is_match("require('dotenv').config()"));
1901 assert!(!re.is_match("import fs from 'fs'"));
1903 assert!(!re.is_match("require.resolve('./path')"));
1905 }
1906
1907 #[test]
1908 fn no_non_null_assertion_pattern() {
1909 let re = regex_for(Preset::AiCodegen, "no-non-null-assertion");
1910 assert!(re.is_match("user!.name"));
1912 assert!(re.is_match("items![0]"));
1913 assert!(re.is_match("this.ref!.current"));
1914 assert!(re.is_match("data!.results"));
1915 assert!(!re.is_match("x !== y"));
1917 assert!(!re.is_match("x != y"));
1918 assert!(!re.is_match("if (!foo) {}"));
1919 assert!(!re.is_match("!!value"));
1920 assert!(!re.is_match("foo!==bar"));
1921 }
1922
1923 #[test]
1924 fn no_non_null_assertion_no_false_positives_on_strings() {
1925 let re = regex_for(Preset::AiCodegen, "no-non-null-assertion");
1926 assert!(!re.is_match(r#""Warning!".toUpperCase()"#));
1928 assert!(!re.is_match(r#"'Error!'.length"#));
1929 assert!(!re.is_match(r#"'Click me!'[0]"#));
1930 }
1931
1932 #[test]
1933 fn no_innerhtml_catches_plus_equals() {
1934 let re = regex_for(Preset::Security, "no-innerhtml");
1935 assert!(re.is_match("el.innerHTML = html"));
1936 assert!(re.is_match("el.innerHTML += '<br>'"));
1937 assert!(re.is_match("el.innerHTML = content"));
1938 assert!(!re.is_match("const x = el.innerHTML"));
1939 }
1940
1941 #[test]
1942 fn no_type_any_catches_generics() {
1943 let re = regex_for(Preset::AiCodegen, "no-type-any");
1944 assert!(re.is_match("const x: any = 1"));
1946 assert!(re.is_match("Array<any>"));
1948 assert!(re.is_match("Promise<any>"));
1949 assert!(re.is_match("Record<string, any>"));
1950 assert!(re.is_match("Map<string, any>"));
1951 assert!(!re.is_match("// handle any case"));
1953 assert!(!re.is_match("const anything = 1"));
1954 assert!(!re.is_match("if (any_flag) {}"));
1955 }
1956
1957 #[test]
1960 fn no_forwardref_pattern() {
1961 let re = regex_for(Preset::React, "no-forwardref");
1962 assert!(re.is_match("const Input = forwardRef<HTMLInputElement>((props, ref) => {"));
1963 assert!(re.is_match("const Btn = forwardRef((props, ref) => <button />)"));
1964 assert!(re.is_match("export default forwardRef(MyComponent)"));
1965 assert!(!re.is_match("// removed forwardRef"));
1967 assert!(!re.is_match("const forwardRefValue = 42"));
1968 }
1969
1970 #[test]
1971 fn no_use_context_pattern() {
1972 let re = regex_for(Preset::React, "no-use-context");
1973 assert!(re.is_match("const theme = useContext(ThemeContext)"));
1974 assert!(re.is_match("const val = useContext(Ctx)"));
1975 assert!(!re.is_match("const ctx = useContextSelector(Ctx, s => s.val)"));
1977 assert!(!re.is_match("// useContext is deprecated"));
1978 }
1979
1980 #[test]
1981 fn no_unsafe_createcontext_default_pattern() {
1982 let re = regex_for(Preset::React, "no-unsafe-createcontext-default");
1983 assert!(re.is_match("const Ctx = createContext({})"));
1985 assert!(re.is_match("const Ctx = createContext([])"));
1986 assert!(re.is_match("const Ctx = createContext(undefined)"));
1987 assert!(re.is_match("const Ctx = createContext(0)"));
1988 assert!(re.is_match("const Ctx = createContext('')"));
1989 assert!(re.is_match(r#"const Ctx = createContext("")"#));
1990 assert!(!re.is_match("const Ctx = createContext(null)"));
1992 assert!(!re.is_match("const Ctx = createContext(defaultValue)"));
1993 assert!(!re.is_match("const Ctx = createContext({ theme: 'dark' })"));
1994 }
1995
1996 #[test]
1997 fn no_effect_callback_sync_pattern() {
1998 let re = regex_for(Preset::React, "no-effect-callback-sync");
1999 assert!(re.is_match("useEffect(() => { onChange(value)"));
2000 assert!(re.is_match("useEffect(() => { onUpdate(data)"));
2001 assert!(re.is_match("useEffect(() => onSubmit(form)"));
2002 assert!(!re.is_match("useEffect(() => { setCount(1) }"));
2004 assert!(!re.is_match("useEffect(() => { fetchData() }"));
2005 }
2006
2007 #[test]
2008 fn no_usestate_localstorage_eager_pattern() {
2009 let re = regex_for(Preset::React, "no-usestate-localstorage-eager");
2010 assert!(re.is_match("useState(localStorage.getItem('key'))"));
2011 assert!(re.is_match("useState(JSON.parse(localStorage.getItem('key')))"));
2012 assert!(!re.is_match("useState(() => localStorage.getItem('key'))"));
2014 assert!(!re.is_match("useState(sessionStorage.getItem('key'))"));
2016 }
2017
2018 #[test]
2019 fn no_regexp_in_render_pattern() {
2020 let re = regex_for(Preset::React, "no-regexp-in-render");
2021 assert!(re.is_match("const re = new RegExp(pattern)"));
2022 assert!(re.is_match("new RegExp('\\\\d+', 'g')"));
2023 assert!(!re.is_match("const re = /\\d+/g"));
2025 }
2026
2027 #[test]
2028 fn no_lucide_barrel_pattern() {
2029 let re = regex_for(Preset::React, "no-lucide-barrel");
2030 assert!(re.is_match("import { Home } from 'lucide-react'"));
2032 assert!(re.is_match(r#"import { Home } from "lucide-react""#));
2033 assert!(re.is_match("require('lucide-react')"));
2034 assert!(!re.is_match("import Home from 'lucide-react/icons/Home'"));
2036 assert!(!re.is_match("import { Home } from 'lucide-react/dist/esm/icons/home'"));
2037 }
2038
2039 #[test]
2040 fn no_mui_barrel_pattern() {
2041 let re = regex_for(Preset::React, "no-mui-barrel");
2042 assert!(re.is_match("import { Button } from '@mui/material'"));
2043 assert!(re.is_match("require('@mui/material')"));
2044 assert!(!re.is_match("import Button from '@mui/material/Button'"));
2046 assert!(!re.is_match("import { useTheme } from '@mui/material/styles'"));
2047 }
2048
2049 #[test]
2050 fn no_mui_icons_barrel_pattern() {
2051 let re = regex_for(Preset::React, "no-mui-icons-barrel");
2052 assert!(re.is_match("import { Home } from '@mui/icons-material'"));
2053 assert!(!re.is_match("import HomeIcon from '@mui/icons-material/Home'"));
2055 }
2056
2057 #[test]
2058 fn no_react_icons_barrel_pattern() {
2059 let re = regex_for(Preset::React, "no-react-icons-barrel");
2060 assert!(re.is_match("import { FaHome } from 'react-icons'"));
2061 assert!(!re.is_match("import { FaHome } from 'react-icons/fa'"));
2063 }
2064
2065 #[test]
2066 fn no_date_fns_barrel_pattern() {
2067 let re = regex_for(Preset::React, "no-date-fns-barrel");
2068 assert!(re.is_match("import { format } from 'date-fns'"));
2069 assert!(re.is_match("require('date-fns')"));
2070 assert!(!re.is_match("import { format } from 'date-fns/format'"));
2072 assert!(!re.is_match("import { format } from 'date-fns/esm'"));
2073 }
2074
2075 #[test]
2078 fn server_action_requires_auth_patterns() {
2079 let rules = preset_rules(Preset::NextjsBestPractices);
2080 let rule = rules.iter().find(|r| r.id == "server-action-requires-auth").unwrap();
2081 let re = regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
2082 let cond_re = regex::Regex::new(rule.condition_pattern.as_ref().unwrap()).unwrap();
2083 assert!(cond_re.is_match("'use server'"));
2085 assert!(!cond_re.is_match("'use client'"));
2086 assert!(re.is_match("await verifySession()"));
2088 assert!(re.is_match("const s = await getSession()"));
2089 assert!(re.is_match("const s = await auth()"));
2090 assert!(re.is_match("const u = await currentUser()"));
2091 assert!(re.is_match("const s = await getServerSession()"));
2092 assert!(!re.is_match("await db.insert(data)"));
2094 }
2095
2096 #[test]
2097 fn server_action_requires_validation_patterns() {
2098 let rules = preset_rules(Preset::NextjsBestPractices);
2099 let rule = rules.iter().find(|r| r.id == "server-action-requires-validation").unwrap();
2100 let re = regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
2101 assert!(re.is_match("const data = schema.parse(formData)"));
2103 assert!(re.is_match("const result = schema.safeParse(input)"));
2104 assert!(re.is_match("const s = z.object({})"));
2105 assert!(re.is_match("await body.validate()"));
2106 assert!(!re.is_match("await db.insert(formData)"));
2108 }
2109
2110 #[test]
2111 fn no_suppress_hydration_warning_pattern() {
2112 let rules = preset_rules(Preset::NextjsBestPractices);
2113 let rule = rules.iter().find(|r| r.id == "no-suppress-hydration-warning").unwrap();
2114 let pat = rule.pattern.as_ref().unwrap();
2115 assert!(!rule.regex);
2116 assert!("<div suppressHydrationWarning>".contains(pat.as_str()));
2117 assert!("<body suppressHydrationWarning={true}>".contains(pat.as_str()));
2118 assert!(!"<div className='safe'>".contains(pat.as_str()));
2119 }
2120
2121 #[test]
2124 fn no_paste_prevention_pattern() {
2125 let re = regex_for(Preset::Security, "no-paste-prevention");
2126 assert!(re.is_match("onPaste={(e) => e.preventDefault()}"));
2127 assert!(re.is_match("onPaste={e => { e.preventDefault() }}"));
2128 assert!(re.is_match("onPaste={handlePaste} // where handlePaste calls preventDefault"));
2129 assert!(!re.is_match("onPaste={handlePaste}"));
2131 assert!(!re.is_match("onCopy={(e) => e.preventDefault()}"));
2132 }
2133
2134 #[test]
2137 fn no_div_click_handler_pattern() {
2138 let re = regex_for(Preset::Accessibility, "no-div-click-handler");
2139 assert!(re.is_match("<div className='card' onClick={handleClick}>"));
2140 assert!(re.is_match("<div onClick = {fn}>"));
2141 assert!(!re.is_match("<button onClick={handleClick}>"));
2143 assert!(!re.is_match("</div>"));
2145 assert!(!re.is_match("<div className='card'>"));
2147 }
2148
2149 #[test]
2150 fn no_span_click_handler_pattern() {
2151 let re = regex_for(Preset::Accessibility, "no-span-click-handler");
2152 assert!(re.is_match("<span role='button' onClick={handleClick}>"));
2153 assert!(re.is_match("<span onClick={fn}>"));
2154 assert!(!re.is_match("<button onClick={handleClick}>"));
2156 assert!(!re.is_match("<span className='label'>"));
2158 }
2159
2160 #[test]
2161 fn no_outline_none_pattern() {
2162 let re = regex_for(Preset::Accessibility, "no-outline-none");
2163 assert!(re.is_match("className='outline-none'"));
2164 assert!(re.is_match("className='focus:outline-none ring-2'"));
2165 assert!(!re.is_match("className='outline-offset-2'"));
2167 assert!(!re.is_match("className='outline-0'"));
2168 }
2169
2170 #[test]
2171 fn no_user_scalable_no_pattern() {
2172 let re = regex_for(Preset::Accessibility, "no-user-scalable-no");
2173 assert!(re.is_match("user-scalable=no"));
2174 assert!(re.is_match("user-scalable = no"));
2175 assert!(!re.is_match("user-scalable=yes"));
2177 }
2178
2179 #[test]
2180 fn no_autofocus_unrestricted_pattern() {
2181 let re = regex_for(Preset::Accessibility, "no-autofocus-unrestricted");
2182 assert!(re.is_match("<input autoFocus />"));
2183 assert!(re.is_match("<Input autoFocus={true} />"));
2184 assert!(!re.is_match("const autoFocusEnabled = true"));
2186 }
2187
2188 #[test]
2189 fn no_transition_all_tailwind_pattern() {
2190 let re = regex_for(Preset::Accessibility, "no-transition-all-tailwind");
2191 assert!(re.is_match("className='transition-all duration-300'"));
2192 assert!(!re.is_match("className='transition-colors duration-300'"));
2194 assert!(!re.is_match("className='transition-opacity'"));
2195 }
2196
2197 #[test]
2198 fn no_hardcoded_date_format_pattern() {
2199 let re = regex_for(Preset::Accessibility, "no-hardcoded-date-format");
2200 assert!(re.is_match("date.toDateString()"));
2201 assert!(re.is_match("date.toLocaleString()"));
2202 assert!(re.is_match("date.toLocaleDateString()"));
2203 assert!(!re.is_match("date.toLocaleDateString('en-US')"));
2205 assert!(!re.is_match("date.toLocaleString('de-DE', opts)"));
2206 }
2207
2208 #[test]
2209 fn no_inline_navigation_onclick_pattern() {
2210 let re = regex_for(Preset::Accessibility, "no-inline-navigation-onclick");
2211 assert!(re.is_match("onClick={() => window.location.href = '/home'}"));
2212 assert!(re.is_match("onClick={() => { window.location = '/page' }}"));
2213 assert!(!re.is_match("onClick={() => router.push('/home')}"));
2215 }
2216
2217 #[test]
2220 fn rn_no_touchable_opacity_pattern() {
2221 let re = regex_for(Preset::ReactNative, "rn-no-touchable-opacity");
2222 assert!(re.is_match("<TouchableOpacity onPress={fn}>"));
2223 assert!(re.is_match("import { TouchableOpacity } from 'react-native'"));
2224 assert!(re.is_match("import { View, TouchableOpacity } from 'react-native'"));
2225 assert!(!re.is_match("<Pressable onPress={fn}>"));
2227 }
2228
2229 #[test]
2230 fn rn_no_touchable_highlight_pattern() {
2231 let re = regex_for(Preset::ReactNative, "rn-no-touchable-highlight");
2232 assert!(re.is_match("<TouchableHighlight onPress={fn}>"));
2233 assert!(re.is_match("import { TouchableHighlight } from 'react-native'"));
2234 assert!(!re.is_match("<Pressable onPress={fn}>"));
2236 }
2237
2238 #[test]
2239 fn rn_no_legacy_shadow_pattern() {
2240 let re = regex_for(Preset::ReactNative, "rn-no-legacy-shadow");
2241 assert!(re.is_match("shadowColor: '#000'"));
2242 assert!(re.is_match("shadowOffset: { width: 0 }"));
2243 assert!(re.is_match("shadowOpacity: 0.25"));
2244 assert!(re.is_match("shadowRadius: 3.84"));
2245 assert!(!re.is_match("boxShadow: '0 2px 4px rgba(0,0,0,0.1)'"));
2247 }
2248
2249 #[test]
2250 fn rn_no_rn_image_import_pattern() {
2251 let re = regex_for(Preset::ReactNative, "rn-no-rn-image-import");
2252 assert!(re.is_match("import { Image } from 'react-native'"));
2253 assert!(re.is_match("import { View, Image } from 'react-native'"));
2254 assert!(re.is_match("import { Image, Text } from 'react-native'"));
2255 assert!(!re.is_match("import { Image } from 'expo-image'"));
2257 assert!(!re.is_match("import { ImageBackground } from 'react-native'"));
2259 }
2260
2261 #[test]
2262 fn rn_no_custom_header_pattern() {
2263 let re = regex_for(Preset::ReactNative, "rn-no-custom-header");
2264 assert!(re.is_match("header: () => <CustomHeader />"));
2265 assert!(re.is_match("header: () =>"));
2266 assert!(!re.is_match("headerTitle: 'Settings'"));
2268 }
2269
2270 #[test]
2271 fn rn_no_fonts_usefonts_pattern() {
2272 let re = regex_for(Preset::ReactNative, "rn-no-fonts-usefonts");
2273 assert!(re.is_match("const [loaded] = useFonts({ Inter: require('./fonts/Inter.ttf') })"));
2274 assert!(re.is_match("useFonts({"));
2275 assert!(!re.is_match("useForm({ mode: 'onChange' })"));
2277 }
2278
2279 #[test]
2280 fn rn_no_font_loadasync_pattern() {
2281 let re = regex_for(Preset::ReactNative, "rn-no-font-loadasync");
2282 assert!(re.is_match("await Font.loadAsync({ Inter: require('./Inter.ttf') })"));
2283 assert!(re.is_match("Font.loadAsync(fonts)"));
2284 assert!(!re.is_match("await Image.loadAsync(uri)"));
2286 }
2287
2288 #[test]
2289 fn rn_no_inline_intl_numberformat_pattern() {
2290 let re = regex_for(Preset::ReactNative, "rn-no-inline-intl-numberformat");
2291 assert!(re.is_match("new Intl.NumberFormat('en-US').format(price)"));
2292 assert!(re.is_match("const fmt = new Intl.NumberFormat('de-DE', { style: 'currency' })"));
2293 assert!(!re.is_match("fmt.format(1234)"));
2295 }
2296
2297 #[test]
2298 fn rn_no_inline_intl_datetimeformat_pattern() {
2299 let re = regex_for(Preset::ReactNative, "rn-no-inline-intl-datetimeformat");
2300 assert!(re.is_match("new Intl.DateTimeFormat('en-US').format(date)"));
2301 assert!(re.is_match("const fmt = new Intl.DateTimeFormat('ja-JP', opts)"));
2302 assert!(!re.is_match("fmt.format(date)"));
2303 }
2304
2305 #[test]
2308 fn scope_glob_strips_leading_double_star() {
2309 assert_eq!(scope_glob("apps/web", "**/*.tsx"), "apps/web/*.tsx");
2310 assert_eq!(
2311 scope_glob("apps/web", "**/*.{tsx,jsx}"),
2312 "apps/web/*.{tsx,jsx}"
2313 );
2314 }
2315
2316 #[test]
2317 fn scope_glob_prepends_path_to_plain_glob() {
2318 assert_eq!(
2319 scope_glob("apps/web", "src/**/*.ts"),
2320 "apps/web/src/**/*.ts"
2321 );
2322 }
2323
2324 #[test]
2325 fn scope_glob_handles_simple_filename() {
2326 assert_eq!(scope_glob("apps/web", "*.json"), "apps/web/*.json");
2327 }
2328
2329 #[test]
2330 fn resolve_scoped_rules_prefixes_globs() {
2331 let scoped = vec![ScopedPreset {
2332 preset: "nextjs".into(),
2333 path: "apps/web".into(),
2334 exclude_rules: vec![],
2335 }];
2336 let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2337 assert!(!rules.is_empty());
2338 for rule in &rules {
2339 let glob = rule.glob.as_ref().unwrap();
2340 assert!(
2341 glob.starts_with("apps/web/"),
2342 "expected glob to start with 'apps/web/', got: {glob}"
2343 );
2344 }
2345 }
2346
2347 #[test]
2348 fn resolve_scoped_rules_none_glob_gets_catch_all() {
2349 let scoped = vec![ScopedPreset {
2351 preset: "ai-safety".into(),
2352 path: "packages/core".into(),
2353 exclude_rules: vec![],
2354 }];
2355 let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2356 for rule in &rules {
2358 let glob = rule.glob.as_ref().unwrap();
2359 assert!(
2360 glob.starts_with("packages/core/"),
2361 "expected glob to start with 'packages/core/', got: {glob}"
2362 );
2363 }
2364 }
2365
2366 #[test]
2367 fn resolve_scoped_rules_user_override_skips_rule() {
2368 let scoped = vec![ScopedPreset {
2369 preset: "nextjs".into(),
2370 path: "apps/web".into(),
2371 exclude_rules: vec![],
2372 }];
2373 let user_rules = vec![TomlRule {
2374 id: "use-next-image".into(),
2375 rule_type: "banned-pattern".into(),
2376 message: "custom override".into(),
2377 ..Default::default()
2378 }];
2379 let rules = resolve_scoped_rules(&scoped, &user_rules).unwrap();
2380 assert!(
2382 !rules.iter().any(|r| r.id == "use-next-image"),
2383 "scoped rule should be skipped when user defines same id"
2384 );
2385 }
2386
2387 #[test]
2388 fn scoped_and_global_presets_merge() {
2389 let global =
2390 resolve_rules(&["security".to_string()], &[]).unwrap();
2391 let scoped = resolve_scoped_rules(
2392 &[ScopedPreset {
2393 preset: "nextjs".into(),
2394 path: "apps/web".into(),
2395 exclude_rules: vec![],
2396 }],
2397 &[],
2398 )
2399 .unwrap();
2400 let mut all = global;
2401 all.extend(scoped);
2402 assert!(all.iter().any(|r| r.id == "no-eval"));
2404 assert!(all.iter().any(|r| r.id == "use-next-image"));
2405 let next_img = all.iter().find(|r| r.id == "use-next-image").unwrap();
2407 assert!(next_img.glob.as_ref().unwrap().starts_with("apps/web/"));
2408 }
2409
2410 #[test]
2411 fn resolve_scoped_unknown_preset_errors() {
2412 let scoped = vec![ScopedPreset {
2413 preset: "nonexistent".into(),
2414 path: "apps/web".into(),
2415 exclude_rules: vec![],
2416 }];
2417 let result = resolve_scoped_rules(&scoped, &[]);
2418 assert!(result.is_err());
2419 let msg = format!("{}", result.unwrap_err());
2420 assert!(msg.contains("unknown preset 'nonexistent'"));
2421 }
2422
2423 #[test]
2424 fn resolve_scoped_prefixes_file_presence_paths() {
2425 let scoped = vec![ScopedPreset {
2427 preset: "security".into(),
2428 path: "apps/api".into(),
2429 exclude_rules: vec![],
2430 }];
2431 let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2432 let fp_rule = rules.iter().find(|r| r.id == "no-env-files").unwrap();
2433 for f in &fp_rule.forbidden_files {
2434 assert!(
2435 f.starts_with("apps/api/"),
2436 "expected forbidden_file to start with 'apps/api/', got: {f}"
2437 );
2438 }
2439 }
2440
2441 #[test]
2442 fn resolve_scoped_prefixes_exclude_glob() {
2443 let scoped = vec![ScopedPreset {
2445 preset: "security".into(),
2446 path: "apps/api".into(),
2447 exclude_rules: vec![],
2448 }];
2449 let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2450 let console_rule = rules.iter().find(|r| r.id == "no-console-log").unwrap();
2451 for eg in &console_rule.exclude_glob {
2452 assert!(
2453 eg.starts_with("apps/api/"),
2454 "expected exclude_glob to start with 'apps/api/', got: {eg}"
2455 );
2456 }
2457 }
2458
2459 #[test]
2460 fn resolve_scoped_exclude_rules_skips_listed() {
2461 let scoped = vec![ScopedPreset {
2462 preset: "nextjs".into(),
2463 path: "apps/web".into(),
2464 exclude_rules: vec!["use-next-image".into()],
2465 }];
2466 let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2467 assert!(
2468 !rules.iter().any(|r| r.id == "use-next-image"),
2469 "excluded rule should not appear in resolved rules"
2470 );
2471 assert!(
2473 rules.iter().any(|r| r.id == "no-sync-scripts"),
2474 "non-excluded rules should still be present"
2475 );
2476 }
2477
2478 #[test]
2479 fn resolve_scoped_exclude_rules_empty_is_noop() {
2480 let scoped_empty = vec![ScopedPreset {
2481 preset: "nextjs".into(),
2482 path: "apps/web".into(),
2483 exclude_rules: vec![],
2484 }];
2485 let scoped_none = vec![ScopedPreset {
2486 preset: "nextjs".into(),
2487 path: "apps/web".into(),
2488 exclude_rules: vec![],
2489 }];
2490 let rules_empty = resolve_scoped_rules(&scoped_empty, &[]).unwrap();
2491 let rules_none = resolve_scoped_rules(&scoped_none, &[]).unwrap();
2492 let ids_empty: Vec<&str> = rules_empty.iter().map(|r| r.id.as_str()).collect();
2493 let ids_none: Vec<&str> = rules_none.iter().map(|r| r.id.as_str()).collect();
2494 assert_eq!(ids_empty, ids_none, "empty exclude_rules should be a no-op");
2495 }
2496}