Skip to main content

code_baseline/
presets.rs

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