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        let preset = resolve_preset(&entry.preset).ok_or_else(|| PresetError::UnknownPreset {
1438            name: entry.preset.clone(),
1439            available: available_presets().to_vec(),
1440        })?;
1441
1442        for mut rule in preset_rules(preset) {
1443            // Prefix glob
1444            rule.glob = Some(match rule.glob {
1445                Some(g) => scope_glob(&entry.path, &g),
1446                None => format!("{}/**", entry.path),
1447            });
1448
1449            // Prefix exclude_glob entries
1450            rule.exclude_glob = rule
1451                .exclude_glob
1452                .iter()
1453                .map(|g| scope_glob(&entry.path, g))
1454                .collect();
1455
1456            // Prefix file-presence paths
1457            rule.required_files = rule
1458                .required_files
1459                .iter()
1460                .map(|f| format!("{}/{f}", entry.path))
1461                .collect();
1462            rule.forbidden_files = rule
1463                .forbidden_files
1464                .iter()
1465                .map(|f| format!("{}/{f}", entry.path))
1466                .collect();
1467
1468            // User rules with the same id override scoped preset rules
1469            if user_rules.iter().any(|u| u.id == rule.id) {
1470                continue;
1471            }
1472
1473            // Skip rules listed in this scope's exclude_rules
1474            if entry.exclude_rules.contains(&rule.id) {
1475                continue;
1476            }
1477
1478            result.push(rule);
1479        }
1480    }
1481
1482    Ok(result)
1483}
1484
1485#[cfg(test)]
1486mod tests {
1487    use super::*;
1488
1489    #[test]
1490    fn shadcn_strict_has_five_rules() {
1491        let rules = preset_rules(Preset::ShadcnStrict);
1492        assert_eq!(rules.len(), 5);
1493        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1494        assert!(ids.contains(&"enforce-dark-mode"));
1495        assert!(ids.contains(&"use-theme-tokens"));
1496        assert!(ids.contains(&"no-inline-styles"));
1497        assert!(ids.contains(&"no-css-in-js"));
1498        assert!(ids.contains(&"no-competing-frameworks"));
1499    }
1500
1501    #[test]
1502    fn shadcn_migrate_has_two_rules() {
1503        let rules = preset_rules(Preset::ShadcnMigrate);
1504        assert_eq!(rules.len(), 2);
1505        assert_eq!(rules[0].id, "enforce-dark-mode");
1506        assert_eq!(rules[1].id, "use-theme-tokens");
1507        // migrate uses warning for theme tokens
1508        assert_eq!(rules[1].severity, "warning");
1509    }
1510
1511    #[test]
1512    fn dependency_hygiene_has_three_rules() {
1513        let rules = preset_rules(Preset::DependencyHygiene);
1514        assert_eq!(rules.len(), 3);
1515        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1516        assert!(ids.contains(&"no-moment"));
1517        assert!(ids.contains(&"no-lodash"));
1518        assert!(ids.contains(&"no-deprecated-request"));
1519    }
1520
1521    #[test]
1522    fn ai_safety_resolves_as_alias() {
1523        assert!(resolve_preset("ai-safety").is_some());
1524        assert!(resolve_preset("dependency-hygiene").is_some());
1525    }
1526
1527    #[test]
1528    fn resolve_unknown_preset_errors() {
1529        let result = resolve_rules(&["unknown-preset".to_string()], &[]);
1530        assert!(result.is_err());
1531        let err = result.unwrap_err();
1532        let msg = format!("{}", err);
1533        assert!(msg.contains("unknown preset 'unknown-preset'"));
1534        assert!(msg.contains("shadcn-strict"));
1535    }
1536
1537    #[test]
1538    fn resolve_empty_extends_returns_user_rules() {
1539        let user_rules = vec![TomlRule {
1540            id: "custom-rule".into(),
1541            rule_type: "banned-pattern".into(),
1542            pattern: Some("TODO".into()),
1543            message: "No TODOs".into(),
1544            ..Default::default()
1545        }];
1546        let result = resolve_rules(&[], &user_rules).unwrap();
1547        assert_eq!(result.len(), 1);
1548        assert_eq!(result[0].id, "custom-rule");
1549    }
1550
1551    #[test]
1552    fn user_rule_overrides_preset() {
1553        let user_rules = vec![TomlRule {
1554            id: "use-theme-tokens".into(),
1555            rule_type: "tailwind-theme-tokens".into(),
1556            severity: "warning".into(),
1557            glob: Some("**/*.{tsx,jsx}".into()),
1558            message: "Custom message".into(),
1559            ..Default::default()
1560        }];
1561        let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
1562        assert_eq!(result.len(), 5);
1563        let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
1564        assert_eq!(token_rule.severity, "warning");
1565        assert_eq!(token_rule.message, "Custom message");
1566    }
1567
1568    #[test]
1569    fn user_rule_appended_after_preset() {
1570        let user_rules = vec![TomlRule {
1571            id: "my-custom".into(),
1572            rule_type: "banned-pattern".into(),
1573            pattern: Some("foo".into()),
1574            message: "no foo".into(),
1575            ..Default::default()
1576        }];
1577        let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
1578        assert_eq!(result.len(), 6);
1579        assert_eq!(result[5].id, "my-custom");
1580    }
1581
1582    #[test]
1583    fn later_preset_overrides_earlier() {
1584        // shadcn-strict sets use-theme-tokens severity to "error"
1585        // shadcn-migrate sets it to "warning"
1586        let result = resolve_rules(
1587            &["shadcn-strict".to_string(), "shadcn-migrate".to_string()],
1588            &[],
1589        )
1590        .unwrap();
1591        let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
1592        assert_eq!(token_rule.severity, "warning");
1593        // Should have 5 unique rules (strict has 5, migrate shares 2 ids)
1594        assert_eq!(result.len(), 5);
1595    }
1596
1597    #[test]
1598    fn multiple_presets_combine() {
1599        let result = resolve_rules(
1600            &["shadcn-migrate".to_string(), "ai-safety".to_string()],
1601            &[],
1602        )
1603        .unwrap();
1604        // 2 from migrate + 3 from ai-safety = 5
1605        assert_eq!(result.len(), 5);
1606    }
1607
1608    #[test]
1609    fn security_has_eleven_rules() {
1610        let rules = preset_rules(Preset::Security);
1611        assert_eq!(rules.len(), 11);
1612        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1613        assert!(ids.contains(&"no-env-files"));
1614        assert!(ids.contains(&"no-hardcoded-secrets"));
1615        assert!(ids.contains(&"no-eval"));
1616        assert!(ids.contains(&"no-dangerous-html"));
1617        assert!(ids.contains(&"no-innerhtml"));
1618        assert!(ids.contains(&"no-console-log"));
1619        assert!(ids.contains(&"no-document-write"));
1620        assert!(ids.contains(&"no-postmessage-wildcard"));
1621        assert!(ids.contains(&"no-outerhtml"));
1622        assert!(ids.contains(&"no-http-links"));
1623        assert!(ids.contains(&"no-paste-prevention"));
1624    }
1625
1626    #[test]
1627    fn nextjs_has_eight_rules() {
1628        let rules = preset_rules(Preset::Nextjs);
1629        assert_eq!(rules.len(), 8);
1630        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1631        assert!(ids.contains(&"use-next-image"));
1632        assert!(ids.contains(&"no-next-head"));
1633        assert!(ids.contains(&"no-private-env-client"));
1634        assert!(ids.contains(&"require-use-client-for-hooks"));
1635        assert!(ids.contains(&"use-next-link"));
1636        assert!(ids.contains(&"no-next-router-in-app"));
1637        assert!(ids.contains(&"no-sync-scripts"));
1638        assert!(ids.contains(&"no-link-fonts"));
1639    }
1640
1641    #[test]
1642    fn ai_codegen_has_twelve_rules() {
1643        let rules = preset_rules(Preset::AiCodegen);
1644        assert_eq!(rules.len(), 12);
1645        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1646        assert!(ids.contains(&"no-placeholder-text"));
1647        assert!(ids.contains(&"no-unresolved-todos"));
1648        assert!(ids.contains(&"no-type-any"));
1649        assert!(ids.contains(&"no-empty-catch"));
1650        assert!(ids.contains(&"no-console-log"));
1651        assert!(ids.contains(&"no-ts-ignore"));
1652        assert!(ids.contains(&"no-as-any"));
1653        assert!(ids.contains(&"no-eslint-disable"));
1654        assert!(ids.contains(&"no-ts-nocheck"));
1655        assert!(ids.contains(&"no-var"));
1656        assert!(ids.contains(&"no-require-in-ts"));
1657        assert!(ids.contains(&"no-non-null-assertion"));
1658    }
1659
1660    #[test]
1661    fn react_has_expected_rule_count() {
1662        let rules = preset_rules(Preset::React);
1663        assert_eq!(rules.len(), 18);
1664        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1665        assert!(ids.contains(&"no-array-index-key"));
1666        assert!(ids.contains(&"no-conditional-render-zero"));
1667        assert!(ids.contains(&"no-nested-component-def"));
1668        assert!(ids.contains(&"no-dangerous-html"));
1669        assert!(ids.contains(&"no-new-function"));
1670        assert!(ids.contains(&"no-derived-state-effect"));
1671        assert!(ids.contains(&"no-fetch-in-effect"));
1672        assert!(ids.contains(&"no-lazy-state-init"));
1673        assert!(ids.contains(&"no-object-dep-array"));
1674        assert!(ids.contains(&"no-default-object-prop"));
1675        assert!(ids.contains(&"no-unsafe-createcontext-default"));
1676        assert!(ids.contains(&"no-usestate-browser-api"));
1677        assert!(ids.contains(&"no-clone-element"));
1678        assert!(ids.contains(&"no-react-children-api"));
1679        assert!(ids.contains(&"no-direct-document-query"));
1680        assert!(ids.contains(&"max-component-size"));
1681        assert!(ids.contains(&"prefer-use-reducer"));
1682        assert!(ids.contains(&"no-cascading-set-state"));
1683        let nested_rule = rules.iter().find(|r| r.id == "no-nested-component-def").unwrap();
1684        assert_eq!(nested_rule.rule_type, "no-nested-components");
1685    }
1686
1687    #[test]
1688    fn react_opinions_has_expected_rule_count() {
1689        let rules = preset_rules(Preset::ReactOpinions);
1690        assert_eq!(rules.len(), 12);
1691        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1692        assert!(ids.contains(&"no-full-lodash-import"));
1693        assert!(ids.contains(&"no-moment"));
1694        assert!(ids.contains(&"no-moment-dep"));
1695        assert!(ids.contains(&"no-transition-all"));
1696        assert!(ids.contains(&"no-layout-animation"));
1697        assert!(ids.contains(&"no-sequential-await"));
1698        assert!(ids.contains(&"no-regexp-in-render"));
1699        assert!(ids.contains(&"no-lucide-barrel"));
1700        assert!(ids.contains(&"no-mui-barrel"));
1701        assert!(ids.contains(&"no-mui-icons-barrel"));
1702        assert!(ids.contains(&"no-react-icons-barrel"));
1703        assert!(ids.contains(&"no-date-fns-barrel"));
1704    }
1705
1706    #[test]
1707    fn react_19_has_two_rules() {
1708        let rules = preset_rules(Preset::React19);
1709        assert_eq!(rules.len(), 2);
1710        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1711        assert!(ids.contains(&"no-forwardref"));
1712        assert!(ids.contains(&"no-use-context"));
1713    }
1714
1715    #[test]
1716    fn nextjs_best_practices_has_expected_rule_count() {
1717        let rules = preset_rules(Preset::NextjsBestPractices);
1718        assert_eq!(rules.len(), 21);
1719        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1720        assert!(ids.contains(&"use-next-image"));
1721        assert!(ids.contains(&"next-image-fill-needs-sizes"));
1722        assert!(ids.contains(&"use-next-link"));
1723        assert!(ids.contains(&"no-next-router-in-app"));
1724        assert!(ids.contains(&"no-next-head"));
1725        assert!(ids.contains(&"no-client-side-redirect"));
1726        assert!(ids.contains(&"no-sync-scripts"));
1727        assert!(ids.contains(&"no-link-fonts"));
1728        assert!(ids.contains(&"no-css-link"));
1729        assert!(ids.contains(&"no-private-env-client"));
1730        assert!(ids.contains(&"require-use-client-for-hooks"));
1731        assert!(ids.contains(&"no-async-client-component"));
1732        assert!(ids.contains(&"require-metadata-in-pages"));
1733        assert!(ids.contains(&"no-redirect-in-try-catch"));
1734        assert!(ids.contains(&"server-action-requires-auth"));
1735        assert!(ids.contains(&"server-action-requires-validation"));
1736        assert!(ids.contains(&"no-suppress-hydration-warning"));
1737        assert!(ids.contains(&"max-component-size"));
1738        assert!(ids.contains(&"no-nested-components"));
1739        assert!(ids.contains(&"prefer-use-reducer"));
1740        assert!(ids.contains(&"no-cascading-set-state"));
1741    }
1742
1743    #[test]
1744    fn accessibility_has_expected_rule_count() {
1745        let rules = preset_rules(Preset::Accessibility);
1746        assert_eq!(rules.len(), 9);
1747        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1748        assert!(ids.contains(&"no-div-click-handler"));
1749        assert!(ids.contains(&"no-span-click-handler"));
1750        assert!(ids.contains(&"no-outline-none"));
1751        assert!(ids.contains(&"no-user-scalable-no"));
1752        assert!(ids.contains(&"no-autofocus-unrestricted"));
1753        assert!(ids.contains(&"no-transition-all-tailwind"));
1754        assert!(ids.contains(&"no-hardcoded-date-format"));
1755        assert!(ids.contains(&"no-inline-navigation-onclick"));
1756        assert!(ids.contains(&"require-img-alt"));
1757    }
1758
1759    #[test]
1760    fn react_native_has_thirteen_rules() {
1761        let rules = preset_rules(Preset::ReactNative);
1762        assert_eq!(rules.len(), 13);
1763        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1764        assert!(ids.contains(&"rn-no-touchable-opacity"));
1765        assert!(ids.contains(&"rn-no-touchable-highlight"));
1766        assert!(ids.contains(&"rn-no-legacy-shadow"));
1767        assert!(ids.contains(&"rn-no-rn-image-import"));
1768        assert!(ids.contains(&"rn-no-custom-header"));
1769        assert!(ids.contains(&"rn-no-fonts-usefonts"));
1770        assert!(ids.contains(&"rn-no-font-loadasync"));
1771        assert!(ids.contains(&"rn-no-inline-intl-numberformat"));
1772        assert!(ids.contains(&"rn-no-inline-intl-datetimeformat"));
1773        assert!(ids.contains(&"rn-no-js-stack-navigator"));
1774        assert!(ids.contains(&"rn-no-js-bottom-tabs"));
1775        assert!(ids.contains(&"rn-no-linear-gradient-lib"));
1776        assert!(ids.contains(&"rn-no-js-bottom-sheet"));
1777    }
1778
1779    #[test]
1780    fn all_preset_names_resolve() {
1781        for name in available_presets() {
1782            assert!(
1783                resolve_preset(name).is_some(),
1784                "preset '{}' should resolve",
1785                name
1786            );
1787        }
1788    }
1789
1790    #[test]
1791    fn all_preset_regex_patterns_compile() {
1792        use regex::Regex;
1793        for name in available_presets() {
1794            let preset = resolve_preset(name).unwrap();
1795            for rule in preset_rules(preset) {
1796                if rule.regex {
1797                    if let Some(ref pat) = rule.pattern {
1798                        Regex::new(pat).unwrap_or_else(|e| {
1799                            panic!("preset '{}', rule '{}': invalid pattern: {}", name, rule.id, e)
1800                        });
1801                    }
1802                    if let Some(ref pat) = rule.condition_pattern {
1803                        Regex::new(pat).unwrap_or_else(|e| {
1804                            panic!(
1805                                "preset '{}', rule '{}': invalid condition_pattern: {}",
1806                                name, rule.id, e
1807                            )
1808                        });
1809                    }
1810                }
1811            }
1812        }
1813    }
1814
1815    #[test]
1816    fn no_private_env_client_pattern_correctness() {
1817        use regex::Regex;
1818        let rules = preset_rules(Preset::Nextjs);
1819        let rule = rules.iter().find(|r| r.id == "no-private-env-client").unwrap();
1820        let re = Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
1821
1822        // Should match private env vars
1823        assert!(re.is_match("process.env.DATABASE_URL"));
1824        assert!(re.is_match("process.env.API_SECRET"));
1825        assert!(re.is_match("process.env.NODE_ENV"));
1826        assert!(re.is_match("process.env.NEXT_RUNTIME"));
1827
1828        // Should NOT match NEXT_PUBLIC_ prefixed vars
1829        assert!(!re.is_match("process.env.NEXT_PUBLIC_API_URL"));
1830        assert!(!re.is_match("process.env.NEXT_PUBLIC_STRIPE_KEY"));
1831    }
1832
1833    /// Helper: get a compiled Regex for a preset rule by preset and rule id.
1834    fn regex_for(preset: Preset, rule_id: &str) -> regex::Regex {
1835        let rules = preset_rules(preset);
1836        let rule = rules
1837            .iter()
1838            .find(|r| r.id == rule_id)
1839            .unwrap_or_else(|| panic!("rule '{}' not found", rule_id));
1840        regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap()
1841    }
1842
1843    // ── Security pattern tests ─────────────────────────────────────────
1844
1845    #[test]
1846    fn no_document_write_pattern() {
1847        let re = regex_for(Preset::Security, "no-document-write");
1848        assert!(re.is_match("document.write('hello')"));
1849        assert!(re.is_match("document.write (html)"));
1850        assert!(re.is_match("  document.write('<div>')"));
1851        // read access is fine
1852        assert!(!re.is_match("const w = document.writeln"));
1853        assert!(!re.is_match("documentWriter()"));
1854    }
1855
1856    #[test]
1857    fn no_postmessage_wildcard_pattern() {
1858        let re = regex_for(Preset::Security, "no-postmessage-wildcard");
1859        assert!(re.is_match("window.postMessage(data, '*')"));
1860        assert!(re.is_match(r#"iframe.contentWindow.postMessage({}, "*")"#));
1861        assert!(re.is_match("  w.postMessage(msg, '*')"));
1862        // specific origins are fine
1863        assert!(!re.is_match("window.postMessage(data, 'https://example.com')"));
1864        assert!(!re.is_match("window.postMessage(data, origin)"));
1865    }
1866
1867    #[test]
1868    fn no_outerhtml_pattern() {
1869        let re = regex_for(Preset::Security, "no-outerhtml");
1870        assert!(re.is_match("el.outerHTML = '<div>'"));
1871        assert!(re.is_match("el.outerHTML += '<span>'"));
1872        assert!(re.is_match("  node.outerHTML = html"));
1873        // reading outerHTML is fine
1874        assert!(!re.is_match("const html = el.outerHTML"));
1875        assert!(!re.is_match("console.log(el.outerHTML)"));
1876    }
1877
1878    #[test]
1879    fn no_http_links_pattern() {
1880        let re = regex_for(Preset::Security, "no-http-links");
1881        assert!(re.is_match(r#"fetch("http://api.example.com")"#));
1882        assert!(re.is_match("const url = 'http://cdn.example.com'"));
1883        // https is fine
1884        assert!(!re.is_match(r#"fetch("https://api.example.com")"#));
1885        // not in a string literal
1886        assert!(!re.is_match("// visit http://example.com"));
1887    }
1888
1889    #[test]
1890    fn no_hardcoded_secrets_expanded() {
1891        let re = regex_for(Preset::Security, "no-hardcoded-secrets");
1892        // original keywords still work
1893        assert!(re.is_match(r#"api_key = "abc12345678""#));
1894        assert!(re.is_match(r#"API_KEY: "abc12345678""#));
1895        // new keywords
1896        assert!(re.is_match(r#"password = "mysecretpass""#));
1897        assert!(re.is_match(r#"PASSWORD: "supersecret1""#));
1898        assert!(re.is_match(r#"client_secret = "abcdefghij""#));
1899        // short values (< 8 chars) should NOT match
1900        assert!(!re.is_match(r#"password = "short""#));
1901        // no string value should NOT match
1902        assert!(!re.is_match("password = getPassword()"));
1903    }
1904
1905    // ── Next.js pattern tests ──────────────────────────────────────────
1906
1907    #[test]
1908    fn no_sync_scripts_pattern() {
1909        let re = regex_for(Preset::Nextjs, "no-sync-scripts");
1910        assert!(re.is_match(r#"<script src="analytics.js">"#));
1911        assert!(re.is_match(r#"<script type="application/ld+json">"#));
1912        // next/script component (uppercase) should NOT match
1913        assert!(!re.is_match(r#"<Script src="analytics.js">"#));
1914        // closing tag should NOT match
1915        assert!(!re.is_match("</script>"));
1916    }
1917
1918    #[test]
1919    fn no_link_fonts_pattern() {
1920        let re = regex_for(Preset::Nextjs, "no-link-fonts");
1921        assert!(re.is_match(
1922            r#"<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />"#
1923        ));
1924        assert!(re.is_match(
1925            r#"<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto">"#
1926        ));
1927        // other link tags should NOT match
1928        assert!(!re.is_match(r#"<link rel="stylesheet" href="/styles.css" />"#));
1929        // next/link is fine
1930        assert!(!re.is_match(r#"<Link href="/fonts">"#));
1931    }
1932
1933    // ── AI Codegen pattern tests ───────────────────────────────────────
1934
1935    #[test]
1936    fn no_eslint_disable_pattern() {
1937        let rules = preset_rules(Preset::AiCodegen);
1938        let rule = rules.iter().find(|r| r.id == "no-eslint-disable").unwrap();
1939        let pat = rule.pattern.as_ref().unwrap();
1940        // literal match (no regex)
1941        assert!(!rule.regex);
1942        assert!("// eslint-disable-next-line no-console".contains(pat.as_str()));
1943        assert!("/* eslint-disable */".contains(pat.as_str()));
1944        assert!("/* eslint-disable-next-line */".contains(pat.as_str()));
1945    }
1946
1947    #[test]
1948    fn no_var_pattern() {
1949        let re = regex_for(Preset::AiCodegen, "no-var");
1950        assert!(re.is_match("var x = 1"));
1951        assert!(re.is_match("var foo = 'bar'"));
1952        assert!(re.is_match("  var count = 0;"));
1953        // should NOT match these
1954        assert!(!re.is_match("const variable = 1"));
1955        assert!(!re.is_match("let variance = 2"));
1956        assert!(!re.is_match("const isVariable = true"));
1957    }
1958
1959    #[test]
1960    fn no_require_in_ts_pattern() {
1961        let re = regex_for(Preset::AiCodegen, "no-require-in-ts");
1962        assert!(re.is_match("const fs = require('fs')"));
1963        assert!(re.is_match("const x = require('./module')"));
1964        assert!(re.is_match("require('dotenv').config()"));
1965        // import is fine
1966        assert!(!re.is_match("import fs from 'fs'"));
1967        // require.resolve is different (no parens right after require)
1968        assert!(!re.is_match("require.resolve('./path')"));
1969    }
1970
1971    #[test]
1972    fn no_non_null_assertion_pattern() {
1973        let re = regex_for(Preset::AiCodegen, "no-non-null-assertion");
1974        // should match non-null assertions
1975        assert!(re.is_match("user!.name"));
1976        assert!(re.is_match("items![0]"));
1977        assert!(re.is_match("this.ref!.current"));
1978        assert!(re.is_match("data!.results"));
1979        // should NOT match these
1980        assert!(!re.is_match("x !== y"));
1981        assert!(!re.is_match("x != y"));
1982        assert!(!re.is_match("if (!foo) {}"));
1983        assert!(!re.is_match("!!value"));
1984        assert!(!re.is_match("foo!==bar"));
1985    }
1986
1987    #[test]
1988    fn no_non_null_assertion_no_false_positives_on_strings() {
1989        let re = regex_for(Preset::AiCodegen, "no-non-null-assertion");
1990        // String ending in '!' with method call — quote sits between ! and .
1991        assert!(!re.is_match(r#""Warning!".toUpperCase()"#));
1992        assert!(!re.is_match(r#"'Error!'.length"#));
1993        assert!(!re.is_match(r#"'Click me!'[0]"#));
1994    }
1995
1996    #[test]
1997    fn no_innerhtml_catches_plus_equals() {
1998        let re = regex_for(Preset::Security, "no-innerhtml");
1999        assert!(re.is_match("el.innerHTML = html"));
2000        assert!(re.is_match("el.innerHTML += '<br>'"));
2001        assert!(re.is_match("el.innerHTML  =  content"));
2002        assert!(!re.is_match("const x = el.innerHTML"));
2003    }
2004
2005    #[test]
2006    fn no_type_any_catches_generics() {
2007        let re = regex_for(Preset::AiCodegen, "no-type-any");
2008        // type annotation
2009        assert!(re.is_match("const x: any = 1"));
2010        // generic position
2011        assert!(re.is_match("Array<any>"));
2012        assert!(re.is_match("Promise<any>"));
2013        assert!(re.is_match("Record<string, any>"));
2014        assert!(re.is_match("Map<string, any>"));
2015        // should NOT match word 'any' in other contexts
2016        assert!(!re.is_match("// handle any case"));
2017        assert!(!re.is_match("const anything = 1"));
2018        assert!(!re.is_match("if (any_flag) {}"));
2019    }
2020
2021    // ── React preset pattern tests ──────────────────────────────────
2022
2023    #[test]
2024    fn no_forwardref_pattern() {
2025        let re = regex_for(Preset::React19, "no-forwardref");
2026        assert!(re.is_match("const Input = forwardRef<HTMLInputElement>((props, ref) => {"));
2027        assert!(re.is_match("const Btn = forwardRef((props, ref) => <button />)"));
2028        assert!(re.is_match("export default forwardRef(MyComponent)"));
2029        // should NOT match
2030        assert!(!re.is_match("// removed forwardRef"));
2031        assert!(!re.is_match("const forwardRefValue = 42"));
2032    }
2033
2034    #[test]
2035    fn no_use_context_pattern() {
2036        let re = regex_for(Preset::React19, "no-use-context");
2037        assert!(re.is_match("const theme = useContext(ThemeContext)"));
2038        assert!(re.is_match("const val = useContext(Ctx)"));
2039        // should NOT match
2040        assert!(!re.is_match("const ctx = useContextSelector(Ctx, s => s.val)"));
2041        assert!(!re.is_match("// useContext is deprecated"));
2042    }
2043
2044    #[test]
2045    fn no_unsafe_createcontext_default_pattern() {
2046        let re = regex_for(Preset::React, "no-unsafe-createcontext-default");
2047        // unsafe defaults
2048        assert!(re.is_match("const Ctx = createContext({})"));
2049        assert!(re.is_match("const Ctx = createContext([])"));
2050        assert!(re.is_match("const Ctx = createContext(undefined)"));
2051        assert!(re.is_match("const Ctx = createContext(0)"));
2052        assert!(re.is_match("const Ctx = createContext('')"));
2053        assert!(re.is_match(r#"const Ctx = createContext("")"#));
2054        // safe: null or meaningful value
2055        assert!(!re.is_match("const Ctx = createContext(null)"));
2056        assert!(!re.is_match("const Ctx = createContext(defaultValue)"));
2057        assert!(!re.is_match("const Ctx = createContext({ theme: 'dark' })"));
2058    }
2059
2060    #[test]
2061    fn no_usestate_browser_api_pattern() {
2062        let re = regex_for(Preset::React, "no-usestate-browser-api");
2063        assert!(re.is_match("useState(localStorage.getItem('key'))"));
2064        assert!(re.is_match("useState(JSON.parse(localStorage.getItem('key')))"));
2065        assert!(re.is_match("useState(sessionStorage.getItem('key'))"));
2066        assert!(re.is_match("useState(JSON.parse(sessionStorage.getItem('key')))"));
2067        // lazy initializer is fine
2068        assert!(!re.is_match("useState(() => localStorage.getItem('key'))"));
2069    }
2070
2071    #[test]
2072    fn no_clone_element_pattern() {
2073        let rules = preset_rules(Preset::React);
2074        let rule = rules.iter().find(|r| r.id == "no-clone-element").unwrap();
2075        assert!(rule.pattern.as_ref().unwrap() == "cloneElement");
2076    }
2077
2078    #[test]
2079    fn no_react_children_api_pattern() {
2080        let re = regex_for(Preset::React, "no-react-children-api");
2081        assert!(re.is_match("Children.map(children, child =>"));
2082        assert!(re.is_match("React.Children.forEach(children,"));
2083        assert!(re.is_match("Children.toArray(children)"));
2084        assert!(!re.is_match("children.map("));
2085    }
2086
2087    #[test]
2088    fn no_direct_document_query_pattern() {
2089        let re = regex_for(Preset::React, "no-direct-document-query");
2090        assert!(re.is_match("document.getElementById('root')"));
2091        assert!(re.is_match("document.querySelector('.modal')"));
2092        assert!(re.is_match("document.querySelectorAll('button')"));
2093        assert!(!re.is_match("ref.current"));
2094    }
2095
2096    #[test]
2097    fn no_regexp_in_render_is_ast_rule() {
2098        let rules = preset_rules(Preset::ReactOpinions);
2099        let rule = rules.iter().find(|r| r.id == "no-regexp-in-render").unwrap();
2100        assert_eq!(rule.rule_type, "no-regexp-in-render");
2101        assert!(rule.pattern.is_none());
2102    }
2103
2104    #[test]
2105    fn no_lucide_barrel_pattern() {
2106        let re = regex_for(Preset::ReactOpinions, "no-lucide-barrel");
2107        // barrel imports should match
2108        assert!(re.is_match("import { Home } from 'lucide-react'"));
2109        assert!(re.is_match(r#"import { Home } from "lucide-react""#));
2110        assert!(re.is_match("require('lucide-react')"));
2111        // deep imports should NOT match
2112        assert!(!re.is_match("import Home from 'lucide-react/icons/Home'"));
2113        assert!(!re.is_match("import { Home } from 'lucide-react/dist/esm/icons/home'"));
2114    }
2115
2116    #[test]
2117    fn no_mui_barrel_pattern() {
2118        let re = regex_for(Preset::ReactOpinions, "no-mui-barrel");
2119        assert!(re.is_match("import { Button } from '@mui/material'"));
2120        assert!(re.is_match("require('@mui/material')"));
2121        // deep imports should NOT match
2122        assert!(!re.is_match("import Button from '@mui/material/Button'"));
2123        assert!(!re.is_match("import { useTheme } from '@mui/material/styles'"));
2124    }
2125
2126    #[test]
2127    fn no_mui_icons_barrel_pattern() {
2128        let re = regex_for(Preset::ReactOpinions, "no-mui-icons-barrel");
2129        assert!(re.is_match("import { Home } from '@mui/icons-material'"));
2130        // deep import is fine
2131        assert!(!re.is_match("import HomeIcon from '@mui/icons-material/Home'"));
2132    }
2133
2134    #[test]
2135    fn no_react_icons_barrel_pattern() {
2136        let re = regex_for(Preset::ReactOpinions, "no-react-icons-barrel");
2137        assert!(re.is_match("import { FaHome } from 'react-icons'"));
2138        // subpath import is fine
2139        assert!(!re.is_match("import { FaHome } from 'react-icons/fa'"));
2140    }
2141
2142    #[test]
2143    fn no_date_fns_barrel_pattern() {
2144        let re = regex_for(Preset::ReactOpinions, "no-date-fns-barrel");
2145        assert!(re.is_match("import { format } from 'date-fns'"));
2146        assert!(re.is_match("require('date-fns')"));
2147        // subpath import is fine
2148        assert!(!re.is_match("import { format } from 'date-fns/format'"));
2149        assert!(!re.is_match("import { format } from 'date-fns/esm'"));
2150    }
2151
2152    // ── Next.js best-practices pattern tests ────────────────────────
2153
2154    #[test]
2155    fn server_action_requires_auth_patterns() {
2156        let rules = preset_rules(Preset::NextjsBestPractices);
2157        let rule = rules.iter().find(|r| r.id == "server-action-requires-auth").unwrap();
2158        let re = regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
2159        let cond_re = regex::Regex::new(rule.condition_pattern.as_ref().unwrap()).unwrap();
2160        // condition pattern matches server action files
2161        assert!(cond_re.is_match("'use server'"));
2162        assert!(!cond_re.is_match("'use client'"));
2163        // required pattern matches auth calls
2164        assert!(re.is_match("await verifySession()"));
2165        assert!(re.is_match("const s = await getSession()"));
2166        assert!(re.is_match("const s = await auth()"));
2167        assert!(re.is_match("const u = await currentUser()"));
2168        assert!(re.is_match("const s = await getServerSession()"));
2169        // no auth call
2170        assert!(!re.is_match("await db.insert(data)"));
2171    }
2172
2173    #[test]
2174    fn server_action_requires_validation_patterns() {
2175        let rules = preset_rules(Preset::NextjsBestPractices);
2176        let rule = rules.iter().find(|r| r.id == "server-action-requires-validation").unwrap();
2177        let re = regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
2178        // validation calls
2179        assert!(re.is_match("const data = schema.parse(formData)"));
2180        assert!(re.is_match("const result = schema.safeParse(input)"));
2181        assert!(re.is_match("const s = z.object({})"));
2182        assert!(re.is_match("await body.validate()"));
2183        // no validation
2184        assert!(!re.is_match("await db.insert(formData)"));
2185    }
2186
2187    #[test]
2188    fn no_suppress_hydration_warning_pattern() {
2189        let rules = preset_rules(Preset::NextjsBestPractices);
2190        let rule = rules.iter().find(|r| r.id == "no-suppress-hydration-warning").unwrap();
2191        let pat = rule.pattern.as_ref().unwrap();
2192        assert!(!rule.regex);
2193        assert!("<div suppressHydrationWarning>".contains(pat.as_str()));
2194        assert!("<body suppressHydrationWarning={true}>".contains(pat.as_str()));
2195        assert!(!"<div className='safe'>".contains(pat.as_str()));
2196    }
2197
2198    // ── Security pattern tests (new) ────────────────────────────────
2199
2200    #[test]
2201    fn no_paste_prevention_pattern() {
2202        let re = regex_for(Preset::Security, "no-paste-prevention");
2203        assert!(re.is_match("onPaste={(e) => e.preventDefault()}"));
2204        assert!(re.is_match("onPaste={e => { e.preventDefault() }}"));
2205        assert!(re.is_match("onPaste={handlePaste} // where handlePaste calls preventDefault"));
2206        // should NOT match unrelated
2207        assert!(!re.is_match("onPaste={handlePaste}"));
2208        assert!(!re.is_match("onCopy={(e) => e.preventDefault()}"));
2209    }
2210
2211    // ── Accessibility pattern tests ─────────────────────────────────
2212
2213    #[test]
2214    fn no_div_click_handler_is_ast_rule() {
2215        let rules = preset_rules(Preset::Accessibility);
2216        let rule = rules.iter().find(|r| r.id == "no-div-click-handler").unwrap();
2217        assert_eq!(rule.rule_type, "no-div-click-handler");
2218        assert!(rule.pattern.is_none());
2219    }
2220
2221    #[test]
2222    fn no_span_click_handler_is_ast_rule() {
2223        let rules = preset_rules(Preset::Accessibility);
2224        let rule = rules.iter().find(|r| r.id == "no-span-click-handler").unwrap();
2225        assert_eq!(rule.rule_type, "no-span-click-handler");
2226        assert!(rule.pattern.is_none());
2227    }
2228
2229    #[test]
2230    fn no_outline_none_is_ast_rule() {
2231        let rules = preset_rules(Preset::Accessibility);
2232        let rule = rules.iter().find(|r| r.id == "no-outline-none").unwrap();
2233        assert_eq!(rule.rule_type, "no-outline-none");
2234        // AST rule — no pattern field
2235        assert!(rule.pattern.is_none());
2236    }
2237
2238    #[test]
2239    fn no_user_scalable_no_pattern() {
2240        let re = regex_for(Preset::Accessibility, "no-user-scalable-no");
2241        assert!(re.is_match("user-scalable=no"));
2242        assert!(re.is_match("user-scalable = no"));
2243        // user-scalable=yes is fine
2244        assert!(!re.is_match("user-scalable=yes"));
2245    }
2246
2247    #[test]
2248    fn no_autofocus_unrestricted_pattern() {
2249        let re = regex_for(Preset::Accessibility, "no-autofocus-unrestricted");
2250        assert!(re.is_match("<input autoFocus />"));
2251        assert!(re.is_match("<Input autoFocus={true} />"));
2252        // should NOT match substring
2253        assert!(!re.is_match("const autoFocusEnabled = true"));
2254    }
2255
2256    #[test]
2257    fn no_transition_all_tailwind_pattern() {
2258        let re = regex_for(Preset::Accessibility, "no-transition-all-tailwind");
2259        assert!(re.is_match("className='transition-all duration-300'"));
2260        // specific transition is fine
2261        assert!(!re.is_match("className='transition-colors duration-300'"));
2262        assert!(!re.is_match("className='transition-opacity'"));
2263    }
2264
2265    #[test]
2266    fn no_hardcoded_date_format_pattern() {
2267        let re = regex_for(Preset::Accessibility, "no-hardcoded-date-format");
2268        assert!(re.is_match("date.toDateString()"));
2269        assert!(re.is_match("date.toLocaleString()"));
2270        assert!(re.is_match("date.toLocaleDateString()"));
2271        // with locale argument is fine (not empty parens)
2272        assert!(!re.is_match("date.toLocaleDateString('en-US')"));
2273        assert!(!re.is_match("date.toLocaleString('de-DE', opts)"));
2274    }
2275
2276    #[test]
2277    fn no_inline_navigation_onclick_pattern() {
2278        let re = regex_for(Preset::Accessibility, "no-inline-navigation-onclick");
2279        assert!(re.is_match("onClick={() => window.location.href = '/home'}"));
2280        assert!(re.is_match("onClick={() => { window.location = '/page' }}"));
2281        // router.push is fine (not window.location)
2282        assert!(!re.is_match("onClick={() => router.push('/home')}"));
2283    }
2284
2285    // ── React Native pattern tests ──────────────────────────────────
2286
2287    #[test]
2288    fn rn_no_touchable_opacity_pattern() {
2289        let re = regex_for(Preset::ReactNative, "rn-no-touchable-opacity");
2290        assert!(re.is_match("<TouchableOpacity onPress={fn}>"));
2291        assert!(re.is_match("import { TouchableOpacity } from 'react-native'"));
2292        assert!(re.is_match("import { View, TouchableOpacity } from 'react-native'"));
2293        // Pressable is fine
2294        assert!(!re.is_match("<Pressable onPress={fn}>"));
2295    }
2296
2297    #[test]
2298    fn rn_no_touchable_highlight_pattern() {
2299        let re = regex_for(Preset::ReactNative, "rn-no-touchable-highlight");
2300        assert!(re.is_match("<TouchableHighlight onPress={fn}>"));
2301        assert!(re.is_match("import { TouchableHighlight } from 'react-native'"));
2302        // Pressable is fine
2303        assert!(!re.is_match("<Pressable onPress={fn}>"));
2304    }
2305
2306    #[test]
2307    fn rn_no_legacy_shadow_pattern() {
2308        let re = regex_for(Preset::ReactNative, "rn-no-legacy-shadow");
2309        assert!(re.is_match("shadowColor: '#000'"));
2310        assert!(re.is_match("shadowOffset: { width: 0 }"));
2311        assert!(re.is_match("shadowOpacity: 0.25"));
2312        assert!(re.is_match("shadowRadius: 3.84"));
2313        // boxShadow is fine
2314        assert!(!re.is_match("boxShadow: '0 2px 4px rgba(0,0,0,0.1)'"));
2315    }
2316
2317    #[test]
2318    fn rn_no_rn_image_import_pattern() {
2319        let re = regex_for(Preset::ReactNative, "rn-no-rn-image-import");
2320        assert!(re.is_match("import { Image } from 'react-native'"));
2321        assert!(re.is_match("import { View, Image } from 'react-native'"));
2322        assert!(re.is_match("import { Image, Text } from 'react-native'"));
2323        // expo-image is fine
2324        assert!(!re.is_match("import { Image } from 'expo-image'"));
2325        // ImageBackground is different
2326        assert!(!re.is_match("import { ImageBackground } from 'react-native'"));
2327    }
2328
2329    #[test]
2330    fn rn_no_custom_header_pattern() {
2331        let re = regex_for(Preset::ReactNative, "rn-no-custom-header");
2332        assert!(re.is_match("header: () => <CustomHeader />"));
2333        assert!(re.is_match("header: () =>"));
2334        // headerTitle is fine
2335        assert!(!re.is_match("headerTitle: 'Settings'"));
2336    }
2337
2338    #[test]
2339    fn rn_no_fonts_usefonts_pattern() {
2340        let re = regex_for(Preset::ReactNative, "rn-no-fonts-usefonts");
2341        assert!(re.is_match("const [loaded] = useFonts({ Inter: require('./fonts/Inter.ttf') })"));
2342        assert!(re.is_match("useFonts({"));
2343        // unrelated hooks
2344        assert!(!re.is_match("useForm({ mode: 'onChange' })"));
2345    }
2346
2347    #[test]
2348    fn rn_no_font_loadasync_pattern() {
2349        let re = regex_for(Preset::ReactNative, "rn-no-font-loadasync");
2350        assert!(re.is_match("await Font.loadAsync({ Inter: require('./Inter.ttf') })"));
2351        assert!(re.is_match("Font.loadAsync(fonts)"));
2352        // unrelated
2353        assert!(!re.is_match("await Image.loadAsync(uri)"));
2354    }
2355
2356    #[test]
2357    fn rn_no_inline_intl_numberformat_pattern() {
2358        let re = regex_for(Preset::ReactNative, "rn-no-inline-intl-numberformat");
2359        assert!(re.is_match("new Intl.NumberFormat('en-US').format(price)"));
2360        assert!(re.is_match("const fmt = new Intl.NumberFormat('de-DE', { style: 'currency' })"));
2361        // already extracted (no new keyword in this context, but the pattern is about new)
2362        assert!(!re.is_match("fmt.format(1234)"));
2363    }
2364
2365    #[test]
2366    fn rn_no_inline_intl_datetimeformat_pattern() {
2367        let re = regex_for(Preset::ReactNative, "rn-no-inline-intl-datetimeformat");
2368        assert!(re.is_match("new Intl.DateTimeFormat('en-US').format(date)"));
2369        assert!(re.is_match("const fmt = new Intl.DateTimeFormat('ja-JP', opts)"));
2370        assert!(!re.is_match("fmt.format(date)"));
2371    }
2372
2373    // ── Scoped presets tests ──────────────────────────────────────────
2374
2375    #[test]
2376    fn scope_glob_strips_leading_double_star() {
2377        assert_eq!(scope_glob("apps/web", "**/*.tsx"), "apps/web/*.tsx");
2378        assert_eq!(
2379            scope_glob("apps/web", "**/*.{tsx,jsx}"),
2380            "apps/web/*.{tsx,jsx}"
2381        );
2382    }
2383
2384    #[test]
2385    fn scope_glob_prepends_path_to_plain_glob() {
2386        assert_eq!(
2387            scope_glob("apps/web", "src/**/*.ts"),
2388            "apps/web/src/**/*.ts"
2389        );
2390    }
2391
2392    #[test]
2393    fn scope_glob_handles_simple_filename() {
2394        assert_eq!(scope_glob("apps/web", "*.json"), "apps/web/*.json");
2395    }
2396
2397    #[test]
2398    fn resolve_scoped_rules_prefixes_globs() {
2399        let scoped = vec![ScopedPreset {
2400            preset: "nextjs".into(),
2401            path: "apps/web".into(),
2402            exclude_rules: vec![],
2403        }];
2404        let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2405        assert!(!rules.is_empty());
2406        for rule in &rules {
2407            let glob = rule.glob.as_ref().unwrap();
2408            assert!(
2409                glob.starts_with("apps/web/"),
2410                "expected glob to start with 'apps/web/', got: {glob}"
2411            );
2412        }
2413    }
2414
2415    #[test]
2416    fn resolve_scoped_rules_none_glob_gets_catch_all() {
2417        // ai-safety has banned-dependency rules with no glob
2418        let scoped = vec![ScopedPreset {
2419            preset: "ai-safety".into(),
2420            path: "packages/core".into(),
2421            exclude_rules: vec![],
2422        }];
2423        let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2424        // banned-dependency rules have no glob by default — should get scoped catch-all
2425        for rule in &rules {
2426            let glob = rule.glob.as_ref().unwrap();
2427            assert!(
2428                glob.starts_with("packages/core/"),
2429                "expected glob to start with 'packages/core/', got: {glob}"
2430            );
2431        }
2432    }
2433
2434    #[test]
2435    fn resolve_scoped_rules_user_override_skips_rule() {
2436        let scoped = vec![ScopedPreset {
2437            preset: "nextjs".into(),
2438            path: "apps/web".into(),
2439            exclude_rules: vec![],
2440        }];
2441        let user_rules = vec![TomlRule {
2442            id: "use-next-image".into(),
2443            rule_type: "banned-pattern".into(),
2444            message: "custom override".into(),
2445            ..Default::default()
2446        }];
2447        let rules = resolve_scoped_rules(&scoped, &user_rules).unwrap();
2448        // use-next-image should be skipped because the user overrides it
2449        assert!(
2450            !rules.iter().any(|r| r.id == "use-next-image"),
2451            "scoped rule should be skipped when user defines same id"
2452        );
2453    }
2454
2455    #[test]
2456    fn scoped_and_global_presets_merge() {
2457        let global =
2458            resolve_rules(&["security".to_string()], &[]).unwrap();
2459        let scoped = resolve_scoped_rules(
2460            &[ScopedPreset {
2461                preset: "nextjs".into(),
2462                path: "apps/web".into(),
2463                exclude_rules: vec![],
2464            }],
2465            &[],
2466        )
2467        .unwrap();
2468        let mut all = global;
2469        all.extend(scoped);
2470        // Should have security rules + nextjs scoped rules
2471        assert!(all.iter().any(|r| r.id == "no-eval"));
2472        assert!(all.iter().any(|r| r.id == "use-next-image"));
2473        // The nextjs rules should have scoped globs
2474        let next_img = all.iter().find(|r| r.id == "use-next-image").unwrap();
2475        assert!(next_img.glob.as_ref().unwrap().starts_with("apps/web/"));
2476    }
2477
2478    #[test]
2479    fn resolve_scoped_unknown_preset_errors() {
2480        let scoped = vec![ScopedPreset {
2481            preset: "nonexistent".into(),
2482            path: "apps/web".into(),
2483            exclude_rules: vec![],
2484        }];
2485        let result = resolve_scoped_rules(&scoped, &[]);
2486        assert!(result.is_err());
2487        let msg = format!("{}", result.unwrap_err());
2488        assert!(msg.contains("unknown preset 'nonexistent'"));
2489    }
2490
2491    #[test]
2492    fn resolve_scoped_prefixes_file_presence_paths() {
2493        // security preset has file-presence rules with forbidden_files
2494        let scoped = vec![ScopedPreset {
2495            preset: "security".into(),
2496            path: "apps/api".into(),
2497            exclude_rules: vec![],
2498        }];
2499        let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2500        let fp_rule = rules.iter().find(|r| r.id == "no-env-files").unwrap();
2501        for f in &fp_rule.forbidden_files {
2502            assert!(
2503                f.starts_with("apps/api/"),
2504                "expected forbidden_file to start with 'apps/api/', got: {f}"
2505            );
2506        }
2507    }
2508
2509    #[test]
2510    fn resolve_scoped_prefixes_exclude_glob() {
2511        // security preset's no-console-log has exclude_glob
2512        let scoped = vec![ScopedPreset {
2513            preset: "security".into(),
2514            path: "apps/api".into(),
2515            exclude_rules: vec![],
2516        }];
2517        let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2518        let console_rule = rules.iter().find(|r| r.id == "no-console-log").unwrap();
2519        for eg in &console_rule.exclude_glob {
2520            assert!(
2521                eg.starts_with("apps/api/"),
2522                "expected exclude_glob to start with 'apps/api/', got: {eg}"
2523            );
2524        }
2525    }
2526
2527    #[test]
2528    fn resolve_scoped_exclude_rules_skips_listed() {
2529        let scoped = vec![ScopedPreset {
2530            preset: "nextjs".into(),
2531            path: "apps/web".into(),
2532            exclude_rules: vec!["use-next-image".into()],
2533        }];
2534        let rules = resolve_scoped_rules(&scoped, &[]).unwrap();
2535        assert!(
2536            !rules.iter().any(|r| r.id == "use-next-image"),
2537            "excluded rule should not appear in resolved rules"
2538        );
2539        // Other rules from the preset should still be present
2540        assert!(
2541            rules.iter().any(|r| r.id == "no-sync-scripts"),
2542            "non-excluded rules should still be present"
2543        );
2544    }
2545
2546    #[test]
2547    fn resolve_scoped_exclude_rules_empty_is_noop() {
2548        let scoped_empty = vec![ScopedPreset {
2549            preset: "nextjs".into(),
2550            path: "apps/web".into(),
2551            exclude_rules: vec![],
2552        }];
2553        let scoped_none = vec![ScopedPreset {
2554            preset: "nextjs".into(),
2555            path: "apps/web".into(),
2556            exclude_rules: vec![],
2557        }];
2558        let rules_empty = resolve_scoped_rules(&scoped_empty, &[]).unwrap();
2559        let rules_none = resolve_scoped_rules(&scoped_none, &[]).unwrap();
2560        let ids_empty: Vec<&str> = rules_empty.iter().map(|r| r.id.as_str()).collect();
2561        let ids_none: Vec<&str> = rules_none.iter().map(|r| r.id.as_str()).collect();
2562        assert_eq!(ids_empty, ids_none, "empty exclude_rules should be a no-op");
2563    }
2564}