Skip to main content

code_baseline/
presets.rs

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