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