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