Skip to main content

code_baseline/
presets.rs

1use crate::cli::toml_config::TomlRule;
2use std::collections::HashMap;
3use std::fmt;
4
5#[derive(Debug)]
6pub enum PresetError {
7    UnknownPreset {
8        name: String,
9        available: Vec<&'static str>,
10    },
11}
12
13impl fmt::Display for PresetError {
14    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15        match self {
16            PresetError::UnknownPreset { name, available } => {
17                write!(
18                    f,
19                    "unknown preset '{}'. available presets: {}",
20                    name,
21                    available.join(", ")
22                )
23            }
24        }
25    }
26}
27
28impl std::error::Error for PresetError {}
29
30#[derive(Debug, Clone, Copy)]
31enum Preset {
32    ShadcnStrict,
33    ShadcnMigrate,
34    AiSafety,
35    Security,
36    Nextjs,
37    AiCodegen,
38    React,
39    NextjsBestPractices,
40}
41
42/// Returns the list of all available preset names.
43pub fn available_presets() -> &'static [&'static str] {
44    &[
45        "shadcn-strict",
46        "shadcn-migrate",
47        "ai-safety",
48        "security",
49        "nextjs",
50        "ai-codegen",
51        "react",
52        "nextjs-best-practices",
53    ]
54}
55
56fn resolve_preset(name: &str) -> Option<Preset> {
57    match name {
58        "shadcn-strict" => Some(Preset::ShadcnStrict),
59        "shadcn-migrate" => Some(Preset::ShadcnMigrate),
60        "ai-safety" => Some(Preset::AiSafety),
61        "security" => Some(Preset::Security),
62        "nextjs" => Some(Preset::Nextjs),
63        "ai-codegen" => Some(Preset::AiCodegen),
64        "react" => Some(Preset::React),
65        "nextjs-best-practices" => Some(Preset::NextjsBestPractices),
66        _ => None,
67    }
68}
69
70fn preset_rules(preset: Preset) -> Vec<TomlRule> {
71    match preset {
72        Preset::ShadcnStrict => vec![
73            TomlRule {
74                id: "enforce-dark-mode".into(),
75                rule_type: "tailwind-dark-mode".into(),
76                severity: "error".into(),
77                glob: Some("**/*.{tsx,jsx}".into()),
78                message: "Missing dark: variant for color class".into(),
79                suggest: Some(
80                    "Use a shadcn semantic token class or add an explicit dark: counterpart"
81                        .into(),
82                ),
83                ..Default::default()
84            },
85            TomlRule {
86                id: "use-theme-tokens".into(),
87                rule_type: "tailwind-theme-tokens".into(),
88                severity: "error".into(),
89                glob: Some("**/*.{tsx,jsx}".into()),
90                message: "Use shadcn semantic token instead of raw color".into(),
91                ..Default::default()
92            },
93            TomlRule {
94                id: "no-inline-styles".into(),
95                rule_type: "banned-pattern".into(),
96                severity: "warning".into(),
97                glob: Some("**/*.{tsx,jsx}".into()),
98                pattern: Some("style={{".into()),
99                message: "Avoid inline styles — use Tailwind utility classes instead".into(),
100                suggest: Some("Replace style={{ ... }} with Tailwind classes".into()),
101                ..Default::default()
102            },
103            TomlRule {
104                id: "no-css-in-js".into(),
105                rule_type: "banned-import".into(),
106                severity: "error".into(),
107                packages: vec![
108                    "styled-components".into(),
109                    "@emotion/styled".into(),
110                    "@emotion/css".into(),
111                    "@emotion/react".into(),
112                ],
113                message: "CSS-in-JS libraries conflict with Tailwind — use utility classes instead"
114                    .into(),
115                ..Default::default()
116            },
117            TomlRule {
118                id: "no-competing-frameworks".into(),
119                rule_type: "banned-dependency".into(),
120                severity: "error".into(),
121                packages: vec![
122                    "bootstrap".into(),
123                    "bulma".into(),
124                    "@mui/material".into(),
125                    "antd".into(),
126                ],
127                message:
128                    "Competing CSS framework detected — this project uses Tailwind + shadcn/ui"
129                        .into(),
130                ..Default::default()
131            },
132        ],
133        Preset::ShadcnMigrate => vec![
134            TomlRule {
135                id: "enforce-dark-mode".into(),
136                rule_type: "tailwind-dark-mode".into(),
137                severity: "error".into(),
138                glob: Some("**/*.{tsx,jsx}".into()),
139                message: "Missing dark: variant for color class".into(),
140                suggest: Some(
141                    "Use a shadcn semantic token class or add an explicit dark: counterpart"
142                        .into(),
143                ),
144                ..Default::default()
145            },
146            TomlRule {
147                id: "use-theme-tokens".into(),
148                rule_type: "tailwind-theme-tokens".into(),
149                severity: "warning".into(),
150                glob: Some("**/*.{tsx,jsx}".into()),
151                message: "Use shadcn semantic token instead of raw color".into(),
152                ..Default::default()
153            },
154        ],
155        Preset::AiSafety => vec![
156            TomlRule {
157                id: "no-moment".into(),
158                rule_type: "banned-dependency".into(),
159                severity: "error".into(),
160                packages: vec!["moment".into(), "moment-timezone".into()],
161                message: "moment.js is deprecated — use date-fns or Temporal API".into(),
162                ..Default::default()
163            },
164            TomlRule {
165                id: "no-lodash".into(),
166                rule_type: "banned-dependency".into(),
167                severity: "error".into(),
168                packages: vec!["lodash".into()],
169                message: "lodash is unnecessary — use native JS methods".into(),
170                ..Default::default()
171            },
172            TomlRule {
173                id: "no-deprecated-request".into(),
174                rule_type: "banned-dependency".into(),
175                severity: "error".into(),
176                packages: vec!["request".into(), "request-promise".into()],
177                message: "The 'request' package is deprecated — use 'node-fetch' or 'undici'".into(),
178                ..Default::default()
179            },
180        ],
181        Preset::Security => vec![
182            TomlRule {
183                id: "no-env-files".into(),
184                rule_type: "file-presence".into(),
185                severity: "error".into(),
186                forbidden_files: vec![
187                    ".env".into(),
188                    ".env.local".into(),
189                    ".env.development".into(),
190                    ".env.production".into(),
191                    ".env.staging".into(),
192                ],
193                message: "Environment files must not be committed — add to .gitignore".into(),
194                ..Default::default()
195            },
196            TomlRule {
197                id: "no-hardcoded-secrets".into(),
198                rule_type: "banned-pattern".into(),
199                severity: "error".into(),
200                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()),
201                regex: true,
202                exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
203                message: "Hardcoded secret detected — use environment variables instead".into(),
204                ..Default::default()
205            },
206            TomlRule {
207                id: "no-eval".into(),
208                rule_type: "banned-pattern".into(),
209                severity: "error".into(),
210                pattern: Some(r"\beval\s*\(".into()),
211                regex: true,
212                message: "eval() is a security risk — avoid arbitrary code execution".into(),
213                ..Default::default()
214            },
215            TomlRule {
216                id: "no-dangerous-html".into(),
217                rule_type: "banned-pattern".into(),
218                severity: "error".into(),
219                pattern: Some("dangerouslySetInnerHTML".into()),
220                message: "dangerouslySetInnerHTML can lead to XSS — sanitize content or use a safe alternative".into(),
221                ..Default::default()
222            },
223            TomlRule {
224                id: "no-innerhtml".into(),
225                rule_type: "banned-pattern".into(),
226                severity: "error".into(),
227                pattern: Some(r"\.innerHTML\s*\+?=".into()),
228                regex: true,
229                message: "Direct innerHTML assignment can lead to XSS — use textContent or a sanitizer".into(),
230                ..Default::default()
231            },
232            TomlRule {
233                id: "no-console-log".into(),
234                rule_type: "banned-pattern".into(),
235                severity: "warning".into(),
236                pattern: Some(r"console\.(log|debug)\(".into()),
237                regex: true,
238                exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
239                message: "Remove console.log/debug before deploying to production".into(),
240                ..Default::default()
241            },
242            TomlRule {
243                id: "no-document-write".into(),
244                rule_type: "banned-pattern".into(),
245                severity: "error".into(),
246                pattern: Some(r"document\.write\s*\(".into()),
247                regex: true,
248                message: "document.write() is an XSS risk and blocks rendering — use DOM APIs instead".into(),
249                ..Default::default()
250            },
251            TomlRule {
252                id: "no-postmessage-wildcard".into(),
253                rule_type: "banned-pattern".into(),
254                severity: "error".into(),
255                pattern: Some(r#"\.postMessage\(.*,\s*['"]\*['"]"#.into()),
256                regex: true,
257                message: "postMessage with '*' origin exposes data to any window — specify the target origin".into(),
258                ..Default::default()
259            },
260            TomlRule {
261                id: "no-outerhtml".into(),
262                rule_type: "banned-pattern".into(),
263                severity: "error".into(),
264                pattern: Some(r"\.outerHTML\s*\+?=".into()),
265                regex: true,
266                message: "Direct outerHTML assignment can lead to XSS — use DOM APIs or a sanitizer".into(),
267                ..Default::default()
268            },
269            TomlRule {
270                id: "no-http-links".into(),
271                rule_type: "banned-pattern".into(),
272                severity: "warning".into(),
273                glob: Some("**/*.{ts,tsx,js,jsx}".into()),
274                pattern: Some(r#"['"]http://"#.into()),
275                regex: true,
276                exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
277                message: "Insecure http:// URL — use https:// instead".into(),
278                ..Default::default()
279            },
280        ],
281        Preset::Nextjs => vec![
282            TomlRule {
283                id: "use-next-image".into(),
284                rule_type: "banned-pattern".into(),
285                severity: "warning".into(),
286                glob: Some("**/*.{tsx,jsx}".into()),
287                pattern: Some(r"<img\s".into()),
288                regex: true,
289                message: "Use next/image instead of <img> for automatic optimization".into(),
290                suggest: Some("Import Image from 'next/image' and use <Image> component".into()),
291                ..Default::default()
292            },
293            TomlRule {
294                id: "no-next-head".into(),
295                rule_type: "banned-import".into(),
296                severity: "error".into(),
297                glob: Some("app/**".into()),
298                packages: vec!["next/head".into()],
299                message: "next/head is not supported in App Router — use the Metadata API instead".into(),
300                ..Default::default()
301            },
302            TomlRule {
303                id: "no-private-env-client".into(),
304                rule_type: "banned-pattern".into(),
305                severity: "error".into(),
306                glob: Some("**/*.{ts,tsx,js,jsx}".into()),
307                // Alternation-based exclusion of NEXT_PUBLIC_ (regex crate lacks lookahead)
308                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()),
309                regex: true,
310                file_contains: Some("use client".into()),
311                message: "Private env vars are undefined in client components — prefix with NEXT_PUBLIC_".into(),
312                ..Default::default()
313            },
314            TomlRule {
315                id: "require-use-client-for-hooks".into(),
316                rule_type: "required-pattern".into(),
317                severity: "error".into(),
318                glob: Some("app/**".into()),
319                pattern: Some("use client".into()),
320                regex: true,
321                condition_pattern: Some(r"use(State|Effect|Context|Reducer|Callback|Memo|Ref|Transition|DeferredValue|InsertionEffect|SyncExternalStore|FormStatus|Optimistic)\s*\(".into()),
322                message: "Files using React hooks must include 'use client' directive in App Router".into(),
323                ..Default::default()
324            },
325            TomlRule {
326                id: "use-next-link".into(),
327                rule_type: "banned-pattern".into(),
328                severity: "warning".into(),
329                glob: Some("**/*.{tsx,jsx}".into()),
330                pattern: Some(r#"<a\s+href=["']/"#.into()),
331                regex: true,
332                message: "Use next/link instead of <a> for client-side navigation".into(),
333                suggest: Some("Import Link from 'next/link' and use <Link> component".into()),
334                ..Default::default()
335            },
336            TomlRule {
337                id: "no-next-router-in-app".into(),
338                rule_type: "banned-import".into(),
339                severity: "error".into(),
340                glob: Some("app/**".into()),
341                packages: vec!["next/router".into()],
342                message: "next/router is not available in App Router — use next/navigation instead".into(),
343                ..Default::default()
344            },
345            TomlRule {
346                id: "no-sync-scripts".into(),
347                rule_type: "banned-pattern".into(),
348                severity: "warning".into(),
349                glob: Some("**/*.{tsx,jsx}".into()),
350                pattern: Some(r"<script\s".into()),
351                regex: true,
352                message: "Use next/script instead of <script> for optimized script loading".into(),
353                suggest: Some("Import Script from 'next/script' and use <Script> component".into()),
354                ..Default::default()
355            },
356            TomlRule {
357                id: "no-link-fonts".into(),
358                rule_type: "banned-pattern".into(),
359                severity: "warning".into(),
360                glob: Some("**/*.{tsx,jsx}".into()),
361                pattern: Some(r"<link[^>]*fonts\.googleapis\.com".into()),
362                regex: true,
363                message: "Use next/font instead of Google Fonts <link> for zero layout shift".into(),
364                suggest: Some("Import from 'next/font/google' for automatic font optimization".into()),
365                ..Default::default()
366            },
367        ],
368        Preset::React => {
369            #[allow(unused_mut)]
370            let mut rules = vec![
371                // ── Correctness ──────────────────────────────────────────
372                TomlRule {
373                    id: "no-array-index-key".into(),
374                    rule_type: "banned-pattern".into(),
375                    severity: "error".into(),
376                    glob: Some("**/*.{tsx,jsx}".into()),
377                    pattern: Some(r"key=\{[a-zA-Z_]*[iI](?:ndex|dx)".into()),
378                    regex: true,
379                    message: "Don't use array index as key — causes bugs on reorder/filter".into(),
380                    suggest: Some("Use a stable unique identifier from the data instead".into()),
381                    ..Default::default()
382                },
383                TomlRule {
384                    id: "no-conditional-render-zero".into(),
385                    rule_type: "banned-pattern".into(),
386                    severity: "warning".into(),
387                    glob: Some("**/*.{tsx,jsx}".into()),
388                    pattern: Some(r"\{\s*\w+\.length\s*&&".into()),
389                    regex: true,
390                    message: "array.length && <JSX> renders '0' when empty — use array.length > 0".into(),
391                    suggest: Some("Replace {arr.length && ...} with {arr.length > 0 && ...}".into()),
392                    ..Default::default()
393                },
394                // no-nested-component-def: regex heuristic without `ast`, AST version with `ast`
395                #[cfg(not(feature = "ast"))]
396                TomlRule {
397                    id: "no-nested-component-def".into(),
398                    rule_type: "banned-pattern".into(),
399                    severity: "error".into(),
400                    glob: Some("**/*.{tsx,jsx}".into()),
401                    pattern: Some(r"^\s+(?:const|let|function)\s+[A-Z][a-zA-Z0-9]*\s*(?::\s*React\.FC|=\s*(?:\([^)]*\)|[a-zA-Z_]\w*)\s*(?::\s*[A-Za-z<>\[\]|&, ]+)?\s*=>|=\s*function|\()".into()),
402                    regex: true,
403                    message: "Component defined inside another component — causes remounting on every render".into(),
404                    suggest: Some("Move component definition to module scope or extract to a separate file".into()),
405                    ..Default::default()
406                },
407                #[cfg(feature = "ast")]
408                TomlRule {
409                    id: "no-nested-component-def".into(),
410                    rule_type: "no-nested-components".into(),
411                    severity: "error".into(),
412                    glob: Some("**/*.{tsx,jsx}".into()),
413                    message: "Component defined inside another component — causes remounting on every render".into(),
414                    suggest: Some("Move component definition to module scope or extract to a separate file".into()),
415                    ..Default::default()
416                },
417                // ── Security ─────────────────────────────────────────────
418                TomlRule {
419                    id: "no-dangerous-html".into(),
420                    rule_type: "banned-pattern".into(),
421                    severity: "warning".into(),
422                    glob: Some("**/*.{tsx,jsx}".into()),
423                    pattern: Some("dangerouslySetInnerHTML".into()),
424                    message: "dangerouslySetInnerHTML can lead to XSS — sanitize content or use a safe alternative".into(),
425                    ..Default::default()
426                },
427                // ── Performance: bundle size ─────────────────────────────
428                TomlRule {
429                    id: "no-full-lodash-import".into(),
430                    rule_type: "banned-import".into(),
431                    severity: "warning".into(),
432                    packages: vec!["lodash".into()],
433                    message: "Importing all of lodash (~70kb) — use lodash-es or per-function imports like lodash/debounce".into(),
434                    ..Default::default()
435                },
436                TomlRule {
437                    id: "no-moment".into(),
438                    rule_type: "banned-import".into(),
439                    severity: "warning".into(),
440                    packages: vec!["moment".into(), "moment-timezone".into()],
441                    message: "moment.js is 300kb+ and deprecated — use date-fns, dayjs, or Temporal API".into(),
442                    ..Default::default()
443                },
444                TomlRule {
445                    id: "no-moment-dep".into(),
446                    rule_type: "banned-dependency".into(),
447                    severity: "warning".into(),
448                    packages: vec!["moment".into(), "moment-timezone".into()],
449                    message: "moment.js is 300kb+ and deprecated — use date-fns, dayjs, or Temporal API".into(),
450                    ..Default::default()
451                },
452                TomlRule {
453                    id: "no-new-function".into(),
454                    rule_type: "banned-pattern".into(),
455                    severity: "error".into(),
456                    pattern: Some(r"\bnew\s+Function\s*\(".into()),
457                    regex: true,
458                    message: "new Function() is equivalent to eval() — avoid dynamic code execution".into(),
459                    ..Default::default()
460                },
461                // ── Performance: rendering ───────────────────────────────
462                TomlRule {
463                    id: "no-transition-all".into(),
464                    rule_type: "banned-pattern".into(),
465                    severity: "warning".into(),
466                    glob: Some("**/*.{tsx,jsx}".into()),
467                    pattern: Some(r#"transition:\s*["']all"#.into()),
468                    regex: true,
469                    message: "transition: 'all' is expensive — list specific properties to transition".into(),
470                    ..Default::default()
471                },
472                TomlRule {
473                    id: "no-layout-animation".into(),
474                    rule_type: "banned-pattern".into(),
475                    severity: "warning".into(),
476                    glob: Some("**/*.{tsx,jsx,css}".into()),
477                    pattern: Some(r"(?:animation|transition)(?:-property)?:\s*(?:.*\b(?:width|height|top|left|right|bottom|margin|padding)\b)".into()),
478                    regex: true,
479                    message: "Animating layout properties (width/height/margin) triggers expensive reflows — use transform instead".into(),
480                    suggest: Some("Use transform: scale() or translate() for smooth GPU-accelerated animations".into()),
481                    ..Default::default()
482                },
483                // ── Async ─────────────────────────────────────────────────
484                TomlRule {
485                    id: "no-sequential-await".into(),
486                    rule_type: "window-pattern".into(),
487                    severity: "warning".into(),
488                    glob: Some("**/*.{ts,tsx,js,jsx}".into()),
489                    pattern: Some(r"^\s*(?:const\s+\w+\s*=\s*)?await\s".into()),
490                    condition_pattern: Some(r"^\s*(?:const\s+\w+\s*=\s*)?await\s".into()),
491                    max_count: Some(3),
492                    regex: true,
493                    message: "Sequential await statements may run slower than necessary — use Promise.all() for independent operations".into(),
494                    suggest: Some("const [a, b] = await Promise.all([fetchA(), fetchB()])".into()),
495                    ..Default::default()
496                },
497                // ── State & Effects ──────────────────────────────────────
498                TomlRule {
499                    id: "no-derived-state-effect".into(),
500                    rule_type: "banned-pattern".into(),
501                    severity: "warning".into(),
502                    glob: Some("**/*.{tsx,jsx}".into()),
503                    pattern: Some(r"useEffect\(\(\)\s*(?:=>)?\s*\{?\s*set[A-Z]\w*\(".into()),
504                    regex: true,
505                    message: "useEffect that only calls setState is derived state — compute during render instead".into(),
506                    suggest: Some("Replace with: const derived = useMemo(() => compute(dep), [dep])".into()),
507                    ..Default::default()
508                },
509                TomlRule {
510                    id: "no-fetch-in-effect".into(),
511                    rule_type: "banned-pattern".into(),
512                    severity: "warning".into(),
513                    glob: Some("**/*.{tsx,jsx}".into()),
514                    pattern: Some(r"useEffect\([^)]*\(\)\s*(?:=>)?\s*\{[^}]*\bfetch\s*\(".into()),
515                    regex: true,
516                    message: "Avoid fetch() inside useEffect — use a data-fetching library (React Query, SWR) or server components".into(),
517                    ..Default::default()
518                },
519                TomlRule {
520                    id: "no-lazy-state-init".into(),
521                    rule_type: "banned-pattern".into(),
522                    severity: "warning".into(),
523                    glob: Some("**/*.{tsx,jsx}".into()),
524                    pattern: Some(r"useState\(\w+\(.*\)\)".into()),
525                    regex: true,
526                    message: "Expensive function call in useState runs every render — use lazy initializer: useState(() => fn())".into(),
527                    suggest: Some("Wrap in a function: useState(() => computeValue()) for one-time initialization".into()),
528                    ..Default::default()
529                },
530                TomlRule {
531                    id: "no-object-dep-array".into(),
532                    rule_type: "banned-pattern".into(),
533                    severity: "warning".into(),
534                    glob: Some("**/*.{tsx,jsx}".into()),
535                    pattern: Some(r"(?:useEffect|useMemo|useCallback)\([^)]+,\s*\[[^\]]*(?:\{[^}]*\}|\[[^\]]*\])".into()),
536                    regex: true,
537                    message: "Object/array literal in dependency array creates a new reference every render — extract to useMemo or a ref".into(),
538                    ..Default::default()
539                },
540                TomlRule {
541                    id: "no-default-object-prop".into(),
542                    rule_type: "banned-pattern".into(),
543                    severity: "warning".into(),
544                    glob: Some("**/*.{tsx,jsx}".into()),
545                    pattern: Some(r"(?:function\s+[A-Z]|const\s+[A-Z]\w*\s*=)\s*.*(?:\{\s*\w+\s*=\s*(?:\{\}|\[\])\s*[,}])".into()),
546                    regex: true,
547                    message: "Default {} or [] in component params creates a new reference every render — extract to a module-level constant".into(),
548                    ..Default::default()
549                },
550            ];
551
552            // ── AST-powered rules (require `ast` feature) ────────────
553            #[cfg(feature = "ast")]
554            {
555                rules.push(TomlRule {
556                    id: "max-component-size".into(),
557                    rule_type: "max-component-size".into(),
558                    severity: "warning".into(),
559                    glob: Some("**/*.{tsx,jsx}".into()),
560                    max_count: Some(150),
561                    message: "Component exceeds 150 lines — split into smaller components".into(),
562                    suggest: Some("Extract logic into custom hooks or break into sub-components".into()),
563                    ..Default::default()
564                });
565                rules.push(TomlRule {
566                    id: "prefer-use-reducer".into(),
567                    rule_type: "prefer-use-reducer".into(),
568                    severity: "warning".into(),
569                    glob: Some("**/*.{tsx,jsx}".into()),
570                    max_count: Some(4),
571                    message: "Component has 4+ useState calls — consider useReducer for related state".into(),
572                    suggest: Some("Group related state into a single useReducer".into()),
573                    ..Default::default()
574                });
575                rules.push(TomlRule {
576                    id: "no-cascading-set-state".into(),
577                    rule_type: "no-cascading-set-state".into(),
578                    severity: "warning".into(),
579                    glob: Some("**/*.{tsx,jsx}".into()),
580                    max_count: Some(3),
581                    message: "useEffect has 3+ setState calls — consider useReducer or derived state".into(),
582                    suggest: Some("Combine state updates with useReducer or compute derived values".into()),
583                    ..Default::default()
584                });
585            }
586
587            rules
588        }
589        Preset::NextjsBestPractices => {
590            #[allow(unused_mut)]
591            let mut rules = vec![
592                // ── Images & Media ───────────────────────────────────────
593                TomlRule {
594                    id: "use-next-image".into(),
595                    rule_type: "banned-pattern".into(),
596                    severity: "warning".into(),
597                    glob: Some("**/*.{tsx,jsx}".into()),
598                    pattern: Some(r"<img\s".into()),
599                    regex: true,
600                    exclude_glob: vec!["**/opengraph-image.*".into(), "**/og/**".into()],
601                    message: "Use next/image instead of <img> for automatic optimization".into(),
602                    suggest: Some("Import Image from 'next/image' and use <Image> component".into()),
603                    ..Default::default()
604                },
605                TomlRule {
606                    id: "next-image-fill-needs-sizes".into(),
607                    rule_type: "window-pattern".into(),
608                    severity: "warning".into(),
609                    glob: Some("**/*.{tsx,jsx}".into()),
610                    pattern: Some(r"<Image[^>]*\bfill\b".into()),
611                    condition_pattern: Some(r"\bsizes\s*=".into()),
612                    max_count: Some(3),
613                    regex: true,
614                    message: "<Image fill> without sizes attribute downloads unnecessarily large images".into(),
615                    suggest: Some("Add sizes prop, e.g. sizes=\"(max-width: 768px) 100vw, 50vw\"".into()),
616                    ..Default::default()
617                },
618                // ── Routing & Navigation ─────────────────────────────────
619                TomlRule {
620                    id: "use-next-link".into(),
621                    rule_type: "banned-pattern".into(),
622                    severity: "warning".into(),
623                    glob: Some("**/*.{tsx,jsx}".into()),
624                    pattern: Some(r#"<a\s+href=["']/"#.into()),
625                    regex: true,
626                    message: "Use next/link instead of <a> for client-side navigation".into(),
627                    suggest: Some("Import Link from 'next/link' and use <Link> component".into()),
628                    ..Default::default()
629                },
630                TomlRule {
631                    id: "no-next-router-in-app".into(),
632                    rule_type: "banned-import".into(),
633                    severity: "error".into(),
634                    glob: Some("app/**".into()),
635                    packages: vec!["next/router".into()],
636                    message: "next/router is not available in App Router — use next/navigation instead".into(),
637                    ..Default::default()
638                },
639                TomlRule {
640                    id: "no-next-head".into(),
641                    rule_type: "banned-import".into(),
642                    severity: "error".into(),
643                    glob: Some("app/**".into()),
644                    packages: vec!["next/head".into()],
645                    message: "next/head is not supported in App Router — use the Metadata API instead".into(),
646                    ..Default::default()
647                },
648                TomlRule {
649                    id: "no-client-side-redirect".into(),
650                    rule_type: "banned-pattern".into(),
651                    severity: "warning".into(),
652                    glob: Some("**/*.{tsx,jsx}".into()),
653                    pattern: Some(r"useEffect\([^)]*\(\)\s*(?:=>)?\s*\{[^}]*(?:router\.push|window\.location)".into()),
654                    regex: true,
655                    message: "Avoid client-side redirects in useEffect — use server-side redirect() or middleware".into(),
656                    suggest: Some("Move redirect logic to middleware.ts or use redirect() in a server component".into()),
657                    ..Default::default()
658                },
659                // ── Scripts & Fonts ──────────────────────────────────────
660                TomlRule {
661                    id: "no-sync-scripts".into(),
662                    rule_type: "banned-pattern".into(),
663                    severity: "warning".into(),
664                    glob: Some("**/*.{tsx,jsx}".into()),
665                    pattern: Some(r"<script\s".into()),
666                    regex: true,
667                    message: "Use next/script instead of <script> for optimized script loading".into(),
668                    suggest: Some("Import Script from 'next/script' and use <Script> component".into()),
669                    ..Default::default()
670                },
671                TomlRule {
672                    id: "no-link-fonts".into(),
673                    rule_type: "banned-pattern".into(),
674                    severity: "warning".into(),
675                    glob: Some("**/*.{tsx,jsx}".into()),
676                    pattern: Some(r"<link[^>]*fonts\.googleapis\.com".into()),
677                    regex: true,
678                    message: "Use next/font instead of Google Fonts <link> for zero layout shift".into(),
679                    suggest: Some("Import from 'next/font/google' for automatic font optimization".into()),
680                    ..Default::default()
681                },
682                TomlRule {
683                    id: "no-css-link".into(),
684                    rule_type: "banned-pattern".into(),
685                    severity: "warning".into(),
686                    glob: Some("**/*.{tsx,jsx}".into()),
687                    pattern: Some(r#"<link[^>]*rel=["']stylesheet["']"#.into()),
688                    regex: true,
689                    message: "Import CSS files directly instead of using <link rel=\"stylesheet\">".into(),
690                    suggest: Some("Use import './styles.css' for automatic bundling and optimization".into()),
691                    ..Default::default()
692                },
693                // ── Server/Client Boundary ───────────────────────────────
694                TomlRule {
695                    id: "no-private-env-client".into(),
696                    rule_type: "banned-pattern".into(),
697                    severity: "error".into(),
698                    glob: Some("**/*.{ts,tsx,js,jsx}".into()),
699                    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()),
700                    regex: true,
701                    file_contains: Some("use client".into()),
702                    message: "Private env vars are undefined in client components — prefix with NEXT_PUBLIC_".into(),
703                    ..Default::default()
704                },
705                TomlRule {
706                    id: "require-use-client-for-hooks".into(),
707                    rule_type: "required-pattern".into(),
708                    severity: "error".into(),
709                    glob: Some("app/**".into()),
710                    pattern: Some("use client".into()),
711                    regex: true,
712                    condition_pattern: Some(r"use(State|Effect|Context|Reducer|Callback|Memo|Ref|Transition|DeferredValue|InsertionEffect|SyncExternalStore|FormStatus|Optimistic)\s*\(".into()),
713                    message: "Files using React hooks must include 'use client' directive in App Router".into(),
714                    ..Default::default()
715                },
716                TomlRule {
717                    id: "no-async-client-component".into(),
718                    rule_type: "banned-pattern".into(),
719                    severity: "error".into(),
720                    glob: Some("**/*.{tsx,jsx}".into()),
721                    pattern: Some(r"(?:export\s+default\s+)?async\s+function\s+[A-Z]".into()),
722                    regex: true,
723                    file_contains: Some("use client".into()),
724                    message: "Client components cannot be async — only server components support async/await".into(),
725                    suggest: Some("Remove 'use client' to make this a server component, or remove async and use useEffect for data fetching".into()),
726                    ..Default::default()
727                },
728                // ── SEO ──────────────────────────────────────────────────
729                TomlRule {
730                    id: "require-metadata-in-pages".into(),
731                    rule_type: "required-pattern".into(),
732                    severity: "warning".into(),
733                    glob: Some("app/**/page.{ts,tsx,js,jsx}".into()),
734                    pattern: Some(r"(?:export\s+(?:const\s+metadata|(?:async\s+)?function\s+generateMetadata))".into()),
735                    regex: true,
736                    message: "Page files should export metadata or generateMetadata for SEO".into(),
737                    suggest: Some("Add: export const metadata = { title: '...', description: '...' }".into()),
738                    ..Default::default()
739                },
740                // ── Server Actions ───────────────────────────────────────
741                TomlRule {
742                    id: "no-redirect-in-try-catch".into(),
743                    rule_type: "banned-pattern".into(),
744                    severity: "error".into(),
745                    glob: Some("**/*.{ts,tsx,js,jsx}".into()),
746                    pattern: Some(r"try\s*\{[^}]*\bredirect\s*\(".into()),
747                    regex: true,
748                    message: "redirect() throws a special error — calling it inside try/catch will swallow the redirect".into(),
749                    suggest: Some("Move redirect() outside the try/catch block".into()),
750                    ..Default::default()
751                },
752            ];
753
754            // ── AST-powered rules (require `ast` feature) ────────────
755            #[cfg(feature = "ast")]
756            {
757                rules.push(TomlRule {
758                    id: "max-component-size".into(),
759                    rule_type: "max-component-size".into(),
760                    severity: "warning".into(),
761                    glob: Some("**/*.{tsx,jsx}".into()),
762                    max_count: Some(150),
763                    message: "Component exceeds 150 lines — split into smaller components".into(),
764                    suggest: Some("Extract logic into custom hooks or break into sub-components".into()),
765                    ..Default::default()
766                });
767                rules.push(TomlRule {
768                    id: "no-nested-components".into(),
769                    rule_type: "no-nested-components".into(),
770                    severity: "error".into(),
771                    glob: Some("**/*.{tsx,jsx}".into()),
772                    message: "Component defined inside another component — causes remounting on every render".into(),
773                    suggest: Some("Move component definition to module scope or extract to a separate file".into()),
774                    ..Default::default()
775                });
776                rules.push(TomlRule {
777                    id: "prefer-use-reducer".into(),
778                    rule_type: "prefer-use-reducer".into(),
779                    severity: "warning".into(),
780                    glob: Some("**/*.{tsx,jsx}".into()),
781                    max_count: Some(4),
782                    message: "Component has 4+ useState calls — consider useReducer for related state".into(),
783                    suggest: Some("Group related state into a single useReducer".into()),
784                    ..Default::default()
785                });
786                rules.push(TomlRule {
787                    id: "no-cascading-set-state".into(),
788                    rule_type: "no-cascading-set-state".into(),
789                    severity: "warning".into(),
790                    glob: Some("**/*.{tsx,jsx}".into()),
791                    max_count: Some(3),
792                    message: "useEffect has 3+ setState calls — consider useReducer or derived state".into(),
793                    suggest: Some("Combine state updates with useReducer or compute derived values".into()),
794                    ..Default::default()
795                });
796            }
797
798            rules
799        }
800        Preset::AiCodegen => vec![
801            TomlRule {
802                id: "no-placeholder-text".into(),
803                rule_type: "banned-pattern".into(),
804                severity: "warning".into(),
805                pattern: Some(r"(?i)lorem ipsum".into()),
806                regex: true,
807                message: "Placeholder text detected — replace with real content".into(),
808                ..Default::default()
809            },
810            TomlRule {
811                id: "no-unresolved-todos".into(),
812                rule_type: "banned-pattern".into(),
813                severity: "warning".into(),
814                pattern: Some(r"(?://|/?\*)\s*(TODO|FIXME|HACK|XXX)\b".into()),
815                regex: true,
816                message: "Unresolved TODO/FIXME comment — address or remove before merging".into(),
817                ..Default::default()
818            },
819            TomlRule {
820                id: "no-type-any".into(),
821                rule_type: "banned-pattern".into(),
822                severity: "error".into(),
823                glob: Some("**/*.{ts,tsx}".into()),
824                pattern: Some(r"[:<,]\s*any\b".into()),
825                regex: true,
826                exclude_glob: vec!["**/*.d.ts".into()],
827                message: "Avoid using 'any' type — use a specific type or 'unknown'".into(),
828                ..Default::default()
829            },
830            TomlRule {
831                id: "no-empty-catch".into(),
832                rule_type: "banned-pattern".into(),
833                severity: "error".into(),
834                pattern: Some(r"catch\s*\([^)]*\)\s*\{\s*\}".into()),
835                regex: true,
836                message: "Empty catch block swallows errors — handle or re-throw the error".into(),
837                ..Default::default()
838            },
839            TomlRule {
840                id: "no-console-log".into(),
841                rule_type: "banned-pattern".into(),
842                severity: "warning".into(),
843                pattern: Some(r"console\.(log|debug)\(".into()),
844                regex: true,
845                exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
846                message: "Remove console.log/debug before merging — use a proper logger if needed".into(),
847                ..Default::default()
848            },
849            TomlRule {
850                id: "no-ts-ignore".into(),
851                rule_type: "banned-pattern".into(),
852                severity: "error".into(),
853                glob: Some("**/*.{ts,tsx}".into()),
854                pattern: Some("@ts-ignore".into()),
855                message: "Use @ts-expect-error instead of @ts-ignore for type suppressions".into(),
856                ..Default::default()
857            },
858            TomlRule {
859                id: "no-as-any".into(),
860                rule_type: "banned-pattern".into(),
861                severity: "error".into(),
862                glob: Some("**/*.{ts,tsx}".into()),
863                pattern: Some(r"\bas\s+any\b".into()),
864                regex: true,
865                message: "Avoid 'as any' type assertion — use proper types or 'as unknown'".into(),
866                ..Default::default()
867            },
868            TomlRule {
869                id: "no-eslint-disable".into(),
870                rule_type: "banned-pattern".into(),
871                severity: "warning".into(),
872                pattern: Some("eslint-disable".into()),
873                message: "Remove eslint-disable comment — fix the underlying issue instead".into(),
874                ..Default::default()
875            },
876            TomlRule {
877                id: "no-ts-nocheck".into(),
878                rule_type: "banned-pattern".into(),
879                severity: "error".into(),
880                glob: Some("**/*.{ts,tsx}".into()),
881                pattern: Some("@ts-nocheck".into()),
882                message: "Do not disable type checking for entire files — fix type errors instead".into(),
883                ..Default::default()
884            },
885            TomlRule {
886                id: "no-var".into(),
887                rule_type: "banned-pattern".into(),
888                severity: "error".into(),
889                glob: Some("**/*.{ts,tsx,js,jsx}".into()),
890                pattern: Some(r"\bvar\s+\w".into()),
891                regex: true,
892                exclude_glob: vec!["**/*.d.ts".into()],
893                message: "Use 'let' or 'const' instead of 'var'".into(),
894                ..Default::default()
895            },
896            TomlRule {
897                id: "no-require-in-ts".into(),
898                rule_type: "banned-pattern".into(),
899                severity: "warning".into(),
900                glob: Some("**/*.{ts,tsx}".into()),
901                pattern: Some(r"\brequire\s*\(".into()),
902                regex: true,
903                message: "Use ES module 'import' instead of CommonJS 'require()' in TypeScript".into(),
904                ..Default::default()
905            },
906            TomlRule {
907                id: "no-non-null-assertion".into(),
908                rule_type: "banned-pattern".into(),
909                severity: "warning".into(),
910                glob: Some("**/*.{ts,tsx}".into()),
911                pattern: Some(r"\w![.\[]".into()),
912                regex: true,
913                message: "Avoid non-null assertion (!) — use optional chaining (?.) or proper null checks".into(),
914                ..Default::default()
915            },
916        ],
917    }
918}
919
920/// Merge preset rules with user-defined rules. User rules with the same `id`
921/// as a preset rule replace the preset version entirely. New user rules are
922/// appended after all preset rules.
923fn merge_rules(preset_rules: Vec<TomlRule>, user_rules: &[TomlRule]) -> Vec<TomlRule> {
924    let mut merged = preset_rules;
925
926    // Index preset rules by id for O(1) lookup
927    let mut id_to_index: HashMap<String, usize> = HashMap::new();
928    for (i, rule) in merged.iter().enumerate() {
929        id_to_index.insert(rule.id.clone(), i);
930    }
931
932    for user_rule in user_rules {
933        if let Some(&idx) = id_to_index.get(&user_rule.id) {
934            // User rule overrides preset rule with same id
935            merged[idx] = user_rule.clone();
936        } else {
937            // New user rule appended
938            merged.push(user_rule.clone());
939        }
940    }
941
942    merged
943}
944
945/// Resolve all `extends` presets and merge with user-defined rules.
946/// Returns the final list of `TomlRule` entries ready for the build pipeline.
947pub fn resolve_rules(
948    extends: &[String],
949    user_rules: &[TomlRule],
950) -> Result<Vec<TomlRule>, PresetError> {
951    if extends.is_empty() {
952        return Ok(user_rules.to_vec());
953    }
954
955    // Collect all preset rules in order, later presets override earlier ones
956    let mut all_preset_rules: Vec<TomlRule> = Vec::new();
957    let mut seen: HashMap<String, usize> = HashMap::new();
958
959    for preset_name in extends {
960        let preset = resolve_preset(preset_name).ok_or_else(|| PresetError::UnknownPreset {
961            name: preset_name.clone(),
962            available: available_presets().to_vec(),
963        })?;
964
965        for rule in preset_rules(preset) {
966            if let Some(&idx) = seen.get(&rule.id) {
967                // Later preset overrides earlier for same id
968                all_preset_rules[idx] = rule;
969            } else {
970                seen.insert(rule.id.clone(), all_preset_rules.len());
971                all_preset_rules.push(rule);
972            }
973        }
974    }
975
976    Ok(merge_rules(all_preset_rules, user_rules))
977}
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982
983    #[test]
984    fn shadcn_strict_has_five_rules() {
985        let rules = preset_rules(Preset::ShadcnStrict);
986        assert_eq!(rules.len(), 5);
987        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
988        assert!(ids.contains(&"enforce-dark-mode"));
989        assert!(ids.contains(&"use-theme-tokens"));
990        assert!(ids.contains(&"no-inline-styles"));
991        assert!(ids.contains(&"no-css-in-js"));
992        assert!(ids.contains(&"no-competing-frameworks"));
993    }
994
995    #[test]
996    fn shadcn_migrate_has_two_rules() {
997        let rules = preset_rules(Preset::ShadcnMigrate);
998        assert_eq!(rules.len(), 2);
999        assert_eq!(rules[0].id, "enforce-dark-mode");
1000        assert_eq!(rules[1].id, "use-theme-tokens");
1001        // migrate uses warning for theme tokens
1002        assert_eq!(rules[1].severity, "warning");
1003    }
1004
1005    #[test]
1006    fn ai_safety_has_three_rules() {
1007        let rules = preset_rules(Preset::AiSafety);
1008        assert_eq!(rules.len(), 3);
1009        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1010        assert!(ids.contains(&"no-moment"));
1011        assert!(ids.contains(&"no-lodash"));
1012        assert!(ids.contains(&"no-deprecated-request"));
1013    }
1014
1015    #[test]
1016    fn resolve_unknown_preset_errors() {
1017        let result = resolve_rules(&["unknown-preset".to_string()], &[]);
1018        assert!(result.is_err());
1019        let err = result.unwrap_err();
1020        let msg = format!("{}", err);
1021        assert!(msg.contains("unknown preset 'unknown-preset'"));
1022        assert!(msg.contains("shadcn-strict"));
1023    }
1024
1025    #[test]
1026    fn resolve_empty_extends_returns_user_rules() {
1027        let user_rules = vec![TomlRule {
1028            id: "custom-rule".into(),
1029            rule_type: "banned-pattern".into(),
1030            pattern: Some("TODO".into()),
1031            message: "No TODOs".into(),
1032            ..Default::default()
1033        }];
1034        let result = resolve_rules(&[], &user_rules).unwrap();
1035        assert_eq!(result.len(), 1);
1036        assert_eq!(result[0].id, "custom-rule");
1037    }
1038
1039    #[test]
1040    fn user_rule_overrides_preset() {
1041        let user_rules = vec![TomlRule {
1042            id: "use-theme-tokens".into(),
1043            rule_type: "tailwind-theme-tokens".into(),
1044            severity: "warning".into(),
1045            glob: Some("**/*.{tsx,jsx}".into()),
1046            message: "Custom message".into(),
1047            ..Default::default()
1048        }];
1049        let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
1050        assert_eq!(result.len(), 5);
1051        let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
1052        assert_eq!(token_rule.severity, "warning");
1053        assert_eq!(token_rule.message, "Custom message");
1054    }
1055
1056    #[test]
1057    fn user_rule_appended_after_preset() {
1058        let user_rules = vec![TomlRule {
1059            id: "my-custom".into(),
1060            rule_type: "banned-pattern".into(),
1061            pattern: Some("foo".into()),
1062            message: "no foo".into(),
1063            ..Default::default()
1064        }];
1065        let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
1066        assert_eq!(result.len(), 6);
1067        assert_eq!(result[5].id, "my-custom");
1068    }
1069
1070    #[test]
1071    fn later_preset_overrides_earlier() {
1072        // shadcn-strict sets use-theme-tokens severity to "error"
1073        // shadcn-migrate sets it to "warning"
1074        let result = resolve_rules(
1075            &["shadcn-strict".to_string(), "shadcn-migrate".to_string()],
1076            &[],
1077        )
1078        .unwrap();
1079        let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
1080        assert_eq!(token_rule.severity, "warning");
1081        // Should have 5 unique rules (strict has 5, migrate shares 2 ids)
1082        assert_eq!(result.len(), 5);
1083    }
1084
1085    #[test]
1086    fn multiple_presets_combine() {
1087        let result = resolve_rules(
1088            &["shadcn-migrate".to_string(), "ai-safety".to_string()],
1089            &[],
1090        )
1091        .unwrap();
1092        // 2 from migrate + 3 from ai-safety = 5
1093        assert_eq!(result.len(), 5);
1094    }
1095
1096    #[test]
1097    fn security_has_ten_rules() {
1098        let rules = preset_rules(Preset::Security);
1099        assert_eq!(rules.len(), 10);
1100        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1101        assert!(ids.contains(&"no-env-files"));
1102        assert!(ids.contains(&"no-hardcoded-secrets"));
1103        assert!(ids.contains(&"no-eval"));
1104        assert!(ids.contains(&"no-dangerous-html"));
1105        assert!(ids.contains(&"no-innerhtml"));
1106        assert!(ids.contains(&"no-console-log"));
1107        assert!(ids.contains(&"no-document-write"));
1108        assert!(ids.contains(&"no-postmessage-wildcard"));
1109        assert!(ids.contains(&"no-outerhtml"));
1110        assert!(ids.contains(&"no-http-links"));
1111    }
1112
1113    #[test]
1114    fn nextjs_has_eight_rules() {
1115        let rules = preset_rules(Preset::Nextjs);
1116        assert_eq!(rules.len(), 8);
1117        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1118        assert!(ids.contains(&"use-next-image"));
1119        assert!(ids.contains(&"no-next-head"));
1120        assert!(ids.contains(&"no-private-env-client"));
1121        assert!(ids.contains(&"require-use-client-for-hooks"));
1122        assert!(ids.contains(&"use-next-link"));
1123        assert!(ids.contains(&"no-next-router-in-app"));
1124        assert!(ids.contains(&"no-sync-scripts"));
1125        assert!(ids.contains(&"no-link-fonts"));
1126    }
1127
1128    #[test]
1129    fn ai_codegen_has_twelve_rules() {
1130        let rules = preset_rules(Preset::AiCodegen);
1131        assert_eq!(rules.len(), 12);
1132        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1133        assert!(ids.contains(&"no-placeholder-text"));
1134        assert!(ids.contains(&"no-unresolved-todos"));
1135        assert!(ids.contains(&"no-type-any"));
1136        assert!(ids.contains(&"no-empty-catch"));
1137        assert!(ids.contains(&"no-console-log"));
1138        assert!(ids.contains(&"no-ts-ignore"));
1139        assert!(ids.contains(&"no-as-any"));
1140        assert!(ids.contains(&"no-eslint-disable"));
1141        assert!(ids.contains(&"no-ts-nocheck"));
1142        assert!(ids.contains(&"no-var"));
1143        assert!(ids.contains(&"no-require-in-ts"));
1144        assert!(ids.contains(&"no-non-null-assertion"));
1145    }
1146
1147    #[test]
1148    fn react_has_expected_rule_count() {
1149        let rules = preset_rules(Preset::React);
1150        #[cfg(not(feature = "ast"))]
1151        assert_eq!(rules.len(), 16);
1152        #[cfg(feature = "ast")]
1153        assert_eq!(rules.len(), 19); // 16 base + 3 AST rules (nested-component-def swapped in-place)
1154        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1155        assert!(ids.contains(&"no-array-index-key"));
1156        assert!(ids.contains(&"no-conditional-render-zero"));
1157        assert!(ids.contains(&"no-nested-component-def"));
1158        assert!(ids.contains(&"no-dangerous-html"));
1159        assert!(ids.contains(&"no-full-lodash-import"));
1160        assert!(ids.contains(&"no-moment"));
1161        assert!(ids.contains(&"no-moment-dep"));
1162        assert!(ids.contains(&"no-new-function"));
1163        assert!(ids.contains(&"no-transition-all"));
1164        assert!(ids.contains(&"no-layout-animation"));
1165        assert!(ids.contains(&"no-sequential-await"));
1166        assert!(ids.contains(&"no-derived-state-effect"));
1167        assert!(ids.contains(&"no-fetch-in-effect"));
1168        assert!(ids.contains(&"no-lazy-state-init"));
1169        assert!(ids.contains(&"no-object-dep-array"));
1170        assert!(ids.contains(&"no-default-object-prop"));
1171        #[cfg(feature = "ast")]
1172        {
1173            assert!(ids.contains(&"max-component-size"));
1174            assert!(ids.contains(&"prefer-use-reducer"));
1175            assert!(ids.contains(&"no-cascading-set-state"));
1176            // no-nested-component-def uses AST type when feature is enabled
1177            let nested_rule = rules.iter().find(|r| r.id == "no-nested-component-def").unwrap();
1178            assert_eq!(nested_rule.rule_type, "no-nested-components");
1179        }
1180        #[cfg(not(feature = "ast"))]
1181        {
1182            let nested_rule = rules.iter().find(|r| r.id == "no-nested-component-def").unwrap();
1183            assert_eq!(nested_rule.rule_type, "banned-pattern");
1184        }
1185    }
1186
1187    #[test]
1188    fn nextjs_best_practices_has_expected_rule_count() {
1189        let rules = preset_rules(Preset::NextjsBestPractices);
1190        #[cfg(not(feature = "ast"))]
1191        assert_eq!(rules.len(), 14);
1192        #[cfg(feature = "ast")]
1193        assert_eq!(rules.len(), 18); // 14 base + 4 AST rules
1194        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
1195        assert!(ids.contains(&"use-next-image"));
1196        assert!(ids.contains(&"next-image-fill-needs-sizes"));
1197        assert!(ids.contains(&"use-next-link"));
1198        assert!(ids.contains(&"no-next-router-in-app"));
1199        assert!(ids.contains(&"no-next-head"));
1200        assert!(ids.contains(&"no-client-side-redirect"));
1201        assert!(ids.contains(&"no-sync-scripts"));
1202        assert!(ids.contains(&"no-link-fonts"));
1203        assert!(ids.contains(&"no-css-link"));
1204        assert!(ids.contains(&"no-private-env-client"));
1205        assert!(ids.contains(&"require-use-client-for-hooks"));
1206        assert!(ids.contains(&"no-async-client-component"));
1207        assert!(ids.contains(&"require-metadata-in-pages"));
1208        assert!(ids.contains(&"no-redirect-in-try-catch"));
1209        #[cfg(feature = "ast")]
1210        {
1211            assert!(ids.contains(&"max-component-size"));
1212            assert!(ids.contains(&"no-nested-components"));
1213            assert!(ids.contains(&"prefer-use-reducer"));
1214            assert!(ids.contains(&"no-cascading-set-state"));
1215        }
1216    }
1217
1218    #[test]
1219    fn all_preset_names_resolve() {
1220        for name in available_presets() {
1221            assert!(
1222                resolve_preset(name).is_some(),
1223                "preset '{}' should resolve",
1224                name
1225            );
1226        }
1227    }
1228
1229    #[test]
1230    fn all_preset_regex_patterns_compile() {
1231        use regex::Regex;
1232        for name in available_presets() {
1233            let preset = resolve_preset(name).unwrap();
1234            for rule in preset_rules(preset) {
1235                if rule.regex {
1236                    if let Some(ref pat) = rule.pattern {
1237                        Regex::new(pat).unwrap_or_else(|e| {
1238                            panic!("preset '{}', rule '{}': invalid pattern: {}", name, rule.id, e)
1239                        });
1240                    }
1241                    if let Some(ref pat) = rule.condition_pattern {
1242                        Regex::new(pat).unwrap_or_else(|e| {
1243                            panic!(
1244                                "preset '{}', rule '{}': invalid condition_pattern: {}",
1245                                name, rule.id, e
1246                            )
1247                        });
1248                    }
1249                }
1250            }
1251        }
1252    }
1253
1254    #[test]
1255    fn no_private_env_client_pattern_correctness() {
1256        use regex::Regex;
1257        let rules = preset_rules(Preset::Nextjs);
1258        let rule = rules.iter().find(|r| r.id == "no-private-env-client").unwrap();
1259        let re = Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
1260
1261        // Should match private env vars
1262        assert!(re.is_match("process.env.DATABASE_URL"));
1263        assert!(re.is_match("process.env.API_SECRET"));
1264        assert!(re.is_match("process.env.NODE_ENV"));
1265        assert!(re.is_match("process.env.NEXT_RUNTIME"));
1266
1267        // Should NOT match NEXT_PUBLIC_ prefixed vars
1268        assert!(!re.is_match("process.env.NEXT_PUBLIC_API_URL"));
1269        assert!(!re.is_match("process.env.NEXT_PUBLIC_STRIPE_KEY"));
1270    }
1271
1272    /// Helper: get a compiled Regex for a preset rule by preset and rule id.
1273    fn regex_for(preset: Preset, rule_id: &str) -> regex::Regex {
1274        let rules = preset_rules(preset);
1275        let rule = rules
1276            .iter()
1277            .find(|r| r.id == rule_id)
1278            .unwrap_or_else(|| panic!("rule '{}' not found", rule_id));
1279        regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap()
1280    }
1281
1282    // ── Security pattern tests ─────────────────────────────────────────
1283
1284    #[test]
1285    fn no_document_write_pattern() {
1286        let re = regex_for(Preset::Security, "no-document-write");
1287        assert!(re.is_match("document.write('hello')"));
1288        assert!(re.is_match("document.write (html)"));
1289        assert!(re.is_match("  document.write('<div>')"));
1290        // read access is fine
1291        assert!(!re.is_match("const w = document.writeln"));
1292        assert!(!re.is_match("documentWriter()"));
1293    }
1294
1295    #[test]
1296    fn no_postmessage_wildcard_pattern() {
1297        let re = regex_for(Preset::Security, "no-postmessage-wildcard");
1298        assert!(re.is_match("window.postMessage(data, '*')"));
1299        assert!(re.is_match(r#"iframe.contentWindow.postMessage({}, "*")"#));
1300        assert!(re.is_match("  w.postMessage(msg, '*')"));
1301        // specific origins are fine
1302        assert!(!re.is_match("window.postMessage(data, 'https://example.com')"));
1303        assert!(!re.is_match("window.postMessage(data, origin)"));
1304    }
1305
1306    #[test]
1307    fn no_outerhtml_pattern() {
1308        let re = regex_for(Preset::Security, "no-outerhtml");
1309        assert!(re.is_match("el.outerHTML = '<div>'"));
1310        assert!(re.is_match("el.outerHTML += '<span>'"));
1311        assert!(re.is_match("  node.outerHTML = html"));
1312        // reading outerHTML is fine
1313        assert!(!re.is_match("const html = el.outerHTML"));
1314        assert!(!re.is_match("console.log(el.outerHTML)"));
1315    }
1316
1317    #[test]
1318    fn no_http_links_pattern() {
1319        let re = regex_for(Preset::Security, "no-http-links");
1320        assert!(re.is_match(r#"fetch("http://api.example.com")"#));
1321        assert!(re.is_match("const url = 'http://cdn.example.com'"));
1322        // https is fine
1323        assert!(!re.is_match(r#"fetch("https://api.example.com")"#));
1324        // not in a string literal
1325        assert!(!re.is_match("// visit http://example.com"));
1326    }
1327
1328    #[test]
1329    fn no_hardcoded_secrets_expanded() {
1330        let re = regex_for(Preset::Security, "no-hardcoded-secrets");
1331        // original keywords still work
1332        assert!(re.is_match(r#"api_key = "abc12345678""#));
1333        assert!(re.is_match(r#"API_KEY: "abc12345678""#));
1334        // new keywords
1335        assert!(re.is_match(r#"password = "mysecretpass""#));
1336        assert!(re.is_match(r#"PASSWORD: "supersecret1""#));
1337        assert!(re.is_match(r#"client_secret = "abcdefghij""#));
1338        // short values (< 8 chars) should NOT match
1339        assert!(!re.is_match(r#"password = "short""#));
1340        // no string value should NOT match
1341        assert!(!re.is_match("password = getPassword()"));
1342    }
1343
1344    // ── Next.js pattern tests ──────────────────────────────────────────
1345
1346    #[test]
1347    fn no_sync_scripts_pattern() {
1348        let re = regex_for(Preset::Nextjs, "no-sync-scripts");
1349        assert!(re.is_match(r#"<script src="analytics.js">"#));
1350        assert!(re.is_match(r#"<script type="application/ld+json">"#));
1351        // next/script component (uppercase) should NOT match
1352        assert!(!re.is_match(r#"<Script src="analytics.js">"#));
1353        // closing tag should NOT match
1354        assert!(!re.is_match("</script>"));
1355    }
1356
1357    #[test]
1358    fn no_link_fonts_pattern() {
1359        let re = regex_for(Preset::Nextjs, "no-link-fonts");
1360        assert!(re.is_match(
1361            r#"<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />"#
1362        ));
1363        assert!(re.is_match(
1364            r#"<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto">"#
1365        ));
1366        // other link tags should NOT match
1367        assert!(!re.is_match(r#"<link rel="stylesheet" href="/styles.css" />"#));
1368        // next/link is fine
1369        assert!(!re.is_match(r#"<Link href="/fonts">"#));
1370    }
1371
1372    // ── AI Codegen pattern tests ───────────────────────────────────────
1373
1374    #[test]
1375    fn no_eslint_disable_pattern() {
1376        let rules = preset_rules(Preset::AiCodegen);
1377        let rule = rules.iter().find(|r| r.id == "no-eslint-disable").unwrap();
1378        let pat = rule.pattern.as_ref().unwrap();
1379        // literal match (no regex)
1380        assert!(!rule.regex);
1381        assert!("// eslint-disable-next-line no-console".contains(pat.as_str()));
1382        assert!("/* eslint-disable */".contains(pat.as_str()));
1383        assert!("/* eslint-disable-next-line */".contains(pat.as_str()));
1384    }
1385
1386    #[test]
1387    fn no_var_pattern() {
1388        let re = regex_for(Preset::AiCodegen, "no-var");
1389        assert!(re.is_match("var x = 1"));
1390        assert!(re.is_match("var foo = 'bar'"));
1391        assert!(re.is_match("  var count = 0;"));
1392        // should NOT match these
1393        assert!(!re.is_match("const variable = 1"));
1394        assert!(!re.is_match("let variance = 2"));
1395        assert!(!re.is_match("const isVariable = true"));
1396    }
1397
1398    #[test]
1399    fn no_require_in_ts_pattern() {
1400        let re = regex_for(Preset::AiCodegen, "no-require-in-ts");
1401        assert!(re.is_match("const fs = require('fs')"));
1402        assert!(re.is_match("const x = require('./module')"));
1403        assert!(re.is_match("require('dotenv').config()"));
1404        // import is fine
1405        assert!(!re.is_match("import fs from 'fs'"));
1406        // require.resolve is different (no parens right after require)
1407        assert!(!re.is_match("require.resolve('./path')"));
1408    }
1409
1410    #[test]
1411    fn no_non_null_assertion_pattern() {
1412        let re = regex_for(Preset::AiCodegen, "no-non-null-assertion");
1413        // should match non-null assertions
1414        assert!(re.is_match("user!.name"));
1415        assert!(re.is_match("items![0]"));
1416        assert!(re.is_match("this.ref!.current"));
1417        assert!(re.is_match("data!.results"));
1418        // should NOT match these
1419        assert!(!re.is_match("x !== y"));
1420        assert!(!re.is_match("x != y"));
1421        assert!(!re.is_match("if (!foo) {}"));
1422        assert!(!re.is_match("!!value"));
1423        assert!(!re.is_match("foo!==bar"));
1424    }
1425
1426    #[test]
1427    fn no_non_null_assertion_no_false_positives_on_strings() {
1428        let re = regex_for(Preset::AiCodegen, "no-non-null-assertion");
1429        // String ending in '!' with method call — quote sits between ! and .
1430        assert!(!re.is_match(r#""Warning!".toUpperCase()"#));
1431        assert!(!re.is_match(r#"'Error!'.length"#));
1432        assert!(!re.is_match(r#"'Click me!'[0]"#));
1433    }
1434
1435    #[test]
1436    fn no_innerhtml_catches_plus_equals() {
1437        let re = regex_for(Preset::Security, "no-innerhtml");
1438        assert!(re.is_match("el.innerHTML = html"));
1439        assert!(re.is_match("el.innerHTML += '<br>'"));
1440        assert!(re.is_match("el.innerHTML  =  content"));
1441        assert!(!re.is_match("const x = el.innerHTML"));
1442    }
1443
1444    #[test]
1445    fn no_type_any_catches_generics() {
1446        let re = regex_for(Preset::AiCodegen, "no-type-any");
1447        // type annotation
1448        assert!(re.is_match("const x: any = 1"));
1449        // generic position
1450        assert!(re.is_match("Array<any>"));
1451        assert!(re.is_match("Promise<any>"));
1452        assert!(re.is_match("Record<string, any>"));
1453        assert!(re.is_match("Map<string, any>"));
1454        // should NOT match word 'any' in other contexts
1455        assert!(!re.is_match("// handle any case"));
1456        assert!(!re.is_match("const anything = 1"));
1457        assert!(!re.is_match("if (any_flag) {}"));
1458    }
1459}