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