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