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