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