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