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