Skip to main content

code_baseline/
presets.rs

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