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}
39
40/// Returns the list of all available preset names.
41pub fn available_presets() -> &'static [&'static str] {
42    &[
43        "shadcn-strict",
44        "shadcn-migrate",
45        "ai-safety",
46        "security",
47        "nextjs",
48        "ai-codegen",
49    ]
50}
51
52fn resolve_preset(name: &str) -> Option<Preset> {
53    match name {
54        "shadcn-strict" => Some(Preset::ShadcnStrict),
55        "shadcn-migrate" => Some(Preset::ShadcnMigrate),
56        "ai-safety" => Some(Preset::AiSafety),
57        "security" => Some(Preset::Security),
58        "nextjs" => Some(Preset::Nextjs),
59        "ai-codegen" => Some(Preset::AiCodegen),
60        _ => None,
61    }
62}
63
64fn preset_rules(preset: Preset) -> Vec<TomlRule> {
65    match preset {
66        Preset::ShadcnStrict => vec![
67            TomlRule {
68                id: "enforce-dark-mode".into(),
69                rule_type: "tailwind-dark-mode".into(),
70                severity: "error".into(),
71                glob: Some("**/*.{tsx,jsx}".into()),
72                message: "Missing dark: variant for color class".into(),
73                suggest: Some(
74                    "Use a shadcn semantic token class or add an explicit dark: counterpart"
75                        .into(),
76                ),
77                ..Default::default()
78            },
79            TomlRule {
80                id: "use-theme-tokens".into(),
81                rule_type: "tailwind-theme-tokens".into(),
82                severity: "error".into(),
83                glob: Some("**/*.{tsx,jsx}".into()),
84                message: "Use shadcn semantic token instead of raw color".into(),
85                ..Default::default()
86            },
87            TomlRule {
88                id: "no-inline-styles".into(),
89                rule_type: "banned-pattern".into(),
90                severity: "warning".into(),
91                glob: Some("**/*.{tsx,jsx}".into()),
92                pattern: Some("style={{".into()),
93                message: "Avoid inline styles — use Tailwind utility classes instead".into(),
94                suggest: Some("Replace style={{ ... }} with Tailwind classes".into()),
95                ..Default::default()
96            },
97            TomlRule {
98                id: "no-css-in-js".into(),
99                rule_type: "banned-import".into(),
100                severity: "error".into(),
101                packages: vec![
102                    "styled-components".into(),
103                    "@emotion/styled".into(),
104                    "@emotion/css".into(),
105                    "@emotion/react".into(),
106                ],
107                message: "CSS-in-JS libraries conflict with Tailwind — use utility classes instead"
108                    .into(),
109                ..Default::default()
110            },
111            TomlRule {
112                id: "no-competing-frameworks".into(),
113                rule_type: "banned-dependency".into(),
114                severity: "error".into(),
115                packages: vec![
116                    "bootstrap".into(),
117                    "bulma".into(),
118                    "@mui/material".into(),
119                    "antd".into(),
120                ],
121                message:
122                    "Competing CSS framework detected — this project uses Tailwind + shadcn/ui"
123                        .into(),
124                ..Default::default()
125            },
126        ],
127        Preset::ShadcnMigrate => vec![
128            TomlRule {
129                id: "enforce-dark-mode".into(),
130                rule_type: "tailwind-dark-mode".into(),
131                severity: "error".into(),
132                glob: Some("**/*.{tsx,jsx}".into()),
133                message: "Missing dark: variant for color class".into(),
134                suggest: Some(
135                    "Use a shadcn semantic token class or add an explicit dark: counterpart"
136                        .into(),
137                ),
138                ..Default::default()
139            },
140            TomlRule {
141                id: "use-theme-tokens".into(),
142                rule_type: "tailwind-theme-tokens".into(),
143                severity: "warning".into(),
144                glob: Some("**/*.{tsx,jsx}".into()),
145                message: "Use shadcn semantic token instead of raw color".into(),
146                ..Default::default()
147            },
148        ],
149        Preset::AiSafety => vec![
150            TomlRule {
151                id: "no-moment".into(),
152                rule_type: "banned-dependency".into(),
153                severity: "error".into(),
154                packages: vec!["moment".into(), "moment-timezone".into()],
155                message: "moment.js is deprecated — use date-fns or Temporal API".into(),
156                ..Default::default()
157            },
158            TomlRule {
159                id: "no-lodash".into(),
160                rule_type: "banned-dependency".into(),
161                severity: "error".into(),
162                packages: vec!["lodash".into()],
163                message: "lodash is unnecessary — use native JS methods".into(),
164                ..Default::default()
165            },
166            TomlRule {
167                id: "no-deprecated-request".into(),
168                rule_type: "banned-dependency".into(),
169                severity: "error".into(),
170                packages: vec!["request".into(), "request-promise".into()],
171                message: "The 'request' package is deprecated — use 'node-fetch' or 'undici'".into(),
172                ..Default::default()
173            },
174        ],
175        Preset::Security => vec![
176            TomlRule {
177                id: "no-env-files".into(),
178                rule_type: "file-presence".into(),
179                severity: "error".into(),
180                forbidden_files: vec![
181                    ".env".into(),
182                    ".env.local".into(),
183                    ".env.development".into(),
184                    ".env.production".into(),
185                    ".env.staging".into(),
186                ],
187                message: "Environment files must not be committed — add to .gitignore".into(),
188                ..Default::default()
189            },
190            TomlRule {
191                id: "no-hardcoded-secrets".into(),
192                rule_type: "banned-pattern".into(),
193                severity: "error".into(),
194                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()),
195                regex: true,
196                exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
197                message: "Hardcoded secret detected — use environment variables instead".into(),
198                ..Default::default()
199            },
200            TomlRule {
201                id: "no-eval".into(),
202                rule_type: "banned-pattern".into(),
203                severity: "error".into(),
204                pattern: Some(r"\beval\s*\(".into()),
205                regex: true,
206                message: "eval() is a security risk — avoid arbitrary code execution".into(),
207                ..Default::default()
208            },
209            TomlRule {
210                id: "no-dangerous-html".into(),
211                rule_type: "banned-pattern".into(),
212                severity: "error".into(),
213                pattern: Some("dangerouslySetInnerHTML".into()),
214                message: "dangerouslySetInnerHTML can lead to XSS — sanitize content or use a safe alternative".into(),
215                ..Default::default()
216            },
217            TomlRule {
218                id: "no-innerhtml".into(),
219                rule_type: "banned-pattern".into(),
220                severity: "error".into(),
221                pattern: Some(r"\.innerHTML\s*\+?=".into()),
222                regex: true,
223                message: "Direct innerHTML assignment can lead to XSS — use textContent or a sanitizer".into(),
224                ..Default::default()
225            },
226            TomlRule {
227                id: "no-console-log".into(),
228                rule_type: "banned-pattern".into(),
229                severity: "warning".into(),
230                pattern: Some(r"console\.(log|debug)\(".into()),
231                regex: true,
232                exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
233                message: "Remove console.log/debug before deploying to production".into(),
234                ..Default::default()
235            },
236            TomlRule {
237                id: "no-document-write".into(),
238                rule_type: "banned-pattern".into(),
239                severity: "error".into(),
240                pattern: Some(r"document\.write\s*\(".into()),
241                regex: true,
242                message: "document.write() is an XSS risk and blocks rendering — use DOM APIs instead".into(),
243                ..Default::default()
244            },
245            TomlRule {
246                id: "no-postmessage-wildcard".into(),
247                rule_type: "banned-pattern".into(),
248                severity: "error".into(),
249                pattern: Some(r#"\.postMessage\(.*,\s*['"]\*['"]"#.into()),
250                regex: true,
251                message: "postMessage with '*' origin exposes data to any window — specify the target origin".into(),
252                ..Default::default()
253            },
254            TomlRule {
255                id: "no-outerhtml".into(),
256                rule_type: "banned-pattern".into(),
257                severity: "error".into(),
258                pattern: Some(r"\.outerHTML\s*\+?=".into()),
259                regex: true,
260                message: "Direct outerHTML assignment can lead to XSS — use DOM APIs or a sanitizer".into(),
261                ..Default::default()
262            },
263            TomlRule {
264                id: "no-http-links".into(),
265                rule_type: "banned-pattern".into(),
266                severity: "warning".into(),
267                glob: Some("**/*.{ts,tsx,js,jsx}".into()),
268                pattern: Some(r#"['"]http://"#.into()),
269                regex: true,
270                exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
271                message: "Insecure http:// URL — use https:// instead".into(),
272                ..Default::default()
273            },
274        ],
275        Preset::Nextjs => vec![
276            TomlRule {
277                id: "use-next-image".into(),
278                rule_type: "banned-pattern".into(),
279                severity: "warning".into(),
280                glob: Some("**/*.{tsx,jsx}".into()),
281                pattern: Some(r"<img\s".into()),
282                regex: true,
283                message: "Use next/image instead of <img> for automatic optimization".into(),
284                suggest: Some("Import Image from 'next/image' and use <Image> component".into()),
285                ..Default::default()
286            },
287            TomlRule {
288                id: "no-next-head".into(),
289                rule_type: "banned-import".into(),
290                severity: "error".into(),
291                glob: Some("app/**".into()),
292                packages: vec!["next/head".into()],
293                message: "next/head is not supported in App Router — use the Metadata API instead".into(),
294                ..Default::default()
295            },
296            TomlRule {
297                id: "no-private-env-client".into(),
298                rule_type: "banned-pattern".into(),
299                severity: "error".into(),
300                glob: Some("**/*.{ts,tsx,js,jsx}".into()),
301                // Alternation-based exclusion of NEXT_PUBLIC_ (regex crate lacks lookahead)
302                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()),
303                regex: true,
304                file_contains: Some("use client".into()),
305                message: "Private env vars are undefined in client components — prefix with NEXT_PUBLIC_".into(),
306                ..Default::default()
307            },
308            TomlRule {
309                id: "require-use-client-for-hooks".into(),
310                rule_type: "required-pattern".into(),
311                severity: "error".into(),
312                glob: Some("app/**".into()),
313                pattern: Some("use client".into()),
314                regex: true,
315                condition_pattern: Some(r"use(State|Effect|Context|Reducer|Callback|Memo|Ref|Transition|DeferredValue|InsertionEffect|SyncExternalStore|FormStatus|Optimistic)\s*\(".into()),
316                message: "Files using React hooks must include 'use client' directive in App Router".into(),
317                ..Default::default()
318            },
319            TomlRule {
320                id: "use-next-link".into(),
321                rule_type: "banned-pattern".into(),
322                severity: "warning".into(),
323                glob: Some("**/*.{tsx,jsx}".into()),
324                pattern: Some(r#"<a\s+href=["']/"#.into()),
325                regex: true,
326                message: "Use next/link instead of <a> for client-side navigation".into(),
327                suggest: Some("Import Link from 'next/link' and use <Link> component".into()),
328                ..Default::default()
329            },
330            TomlRule {
331                id: "no-next-router-in-app".into(),
332                rule_type: "banned-import".into(),
333                severity: "error".into(),
334                glob: Some("app/**".into()),
335                packages: vec!["next/router".into()],
336                message: "next/router is not available in App Router — use next/navigation instead".into(),
337                ..Default::default()
338            },
339            TomlRule {
340                id: "no-sync-scripts".into(),
341                rule_type: "banned-pattern".into(),
342                severity: "warning".into(),
343                glob: Some("**/*.{tsx,jsx}".into()),
344                pattern: Some(r"<script\s".into()),
345                regex: true,
346                message: "Use next/script instead of <script> for optimized script loading".into(),
347                suggest: Some("Import Script from 'next/script' and use <Script> component".into()),
348                ..Default::default()
349            },
350            TomlRule {
351                id: "no-link-fonts".into(),
352                rule_type: "banned-pattern".into(),
353                severity: "warning".into(),
354                glob: Some("**/*.{tsx,jsx}".into()),
355                pattern: Some(r"<link[^>]*fonts\.googleapis\.com".into()),
356                regex: true,
357                message: "Use next/font instead of Google Fonts <link> for zero layout shift".into(),
358                suggest: Some("Import from 'next/font/google' for automatic font optimization".into()),
359                ..Default::default()
360            },
361        ],
362        Preset::AiCodegen => vec![
363            TomlRule {
364                id: "no-placeholder-text".into(),
365                rule_type: "banned-pattern".into(),
366                severity: "warning".into(),
367                pattern: Some(r"(?i)lorem ipsum".into()),
368                regex: true,
369                message: "Placeholder text detected — replace with real content".into(),
370                ..Default::default()
371            },
372            TomlRule {
373                id: "no-unresolved-todos".into(),
374                rule_type: "banned-pattern".into(),
375                severity: "warning".into(),
376                pattern: Some(r"(?://|/?\*)\s*(TODO|FIXME|HACK|XXX)\b".into()),
377                regex: true,
378                message: "Unresolved TODO/FIXME comment — address or remove before merging".into(),
379                ..Default::default()
380            },
381            TomlRule {
382                id: "no-type-any".into(),
383                rule_type: "banned-pattern".into(),
384                severity: "error".into(),
385                glob: Some("**/*.{ts,tsx}".into()),
386                pattern: Some(r"[:<,]\s*any\b".into()),
387                regex: true,
388                exclude_glob: vec!["**/*.d.ts".into()],
389                message: "Avoid using 'any' type — use a specific type or 'unknown'".into(),
390                ..Default::default()
391            },
392            TomlRule {
393                id: "no-empty-catch".into(),
394                rule_type: "banned-pattern".into(),
395                severity: "error".into(),
396                pattern: Some(r"catch\s*\([^)]*\)\s*\{\s*\}".into()),
397                regex: true,
398                message: "Empty catch block swallows errors — handle or re-throw the error".into(),
399                ..Default::default()
400            },
401            TomlRule {
402                id: "no-console-log".into(),
403                rule_type: "banned-pattern".into(),
404                severity: "warning".into(),
405                pattern: Some(r"console\.(log|debug)\(".into()),
406                regex: true,
407                exclude_glob: vec!["**/*.test.*".into(), "**/*.spec.*".into()],
408                message: "Remove console.log/debug before merging — use a proper logger if needed".into(),
409                ..Default::default()
410            },
411            TomlRule {
412                id: "no-ts-ignore".into(),
413                rule_type: "banned-pattern".into(),
414                severity: "error".into(),
415                glob: Some("**/*.{ts,tsx}".into()),
416                pattern: Some("@ts-ignore".into()),
417                message: "Use @ts-expect-error instead of @ts-ignore for type suppressions".into(),
418                ..Default::default()
419            },
420            TomlRule {
421                id: "no-as-any".into(),
422                rule_type: "banned-pattern".into(),
423                severity: "error".into(),
424                glob: Some("**/*.{ts,tsx}".into()),
425                pattern: Some(r"\bas\s+any\b".into()),
426                regex: true,
427                message: "Avoid 'as any' type assertion — use proper types or 'as unknown'".into(),
428                ..Default::default()
429            },
430            TomlRule {
431                id: "no-eslint-disable".into(),
432                rule_type: "banned-pattern".into(),
433                severity: "warning".into(),
434                pattern: Some("eslint-disable".into()),
435                message: "Remove eslint-disable comment — fix the underlying issue instead".into(),
436                ..Default::default()
437            },
438            TomlRule {
439                id: "no-ts-nocheck".into(),
440                rule_type: "banned-pattern".into(),
441                severity: "error".into(),
442                glob: Some("**/*.{ts,tsx}".into()),
443                pattern: Some("@ts-nocheck".into()),
444                message: "Do not disable type checking for entire files — fix type errors instead".into(),
445                ..Default::default()
446            },
447            TomlRule {
448                id: "no-var".into(),
449                rule_type: "banned-pattern".into(),
450                severity: "error".into(),
451                glob: Some("**/*.{ts,tsx,js,jsx}".into()),
452                pattern: Some(r"\bvar\s+\w".into()),
453                regex: true,
454                exclude_glob: vec!["**/*.d.ts".into()],
455                message: "Use 'let' or 'const' instead of 'var'".into(),
456                ..Default::default()
457            },
458            TomlRule {
459                id: "no-require-in-ts".into(),
460                rule_type: "banned-pattern".into(),
461                severity: "warning".into(),
462                glob: Some("**/*.{ts,tsx}".into()),
463                pattern: Some(r"\brequire\s*\(".into()),
464                regex: true,
465                message: "Use ES module 'import' instead of CommonJS 'require()' in TypeScript".into(),
466                ..Default::default()
467            },
468            TomlRule {
469                id: "no-non-null-assertion".into(),
470                rule_type: "banned-pattern".into(),
471                severity: "warning".into(),
472                glob: Some("**/*.{ts,tsx}".into()),
473                pattern: Some(r"\w![.\[]".into()),
474                regex: true,
475                message: "Avoid non-null assertion (!) — use optional chaining (?.) or proper null checks".into(),
476                ..Default::default()
477            },
478        ],
479    }
480}
481
482/// Merge preset rules with user-defined rules. User rules with the same `id`
483/// as a preset rule replace the preset version entirely. New user rules are
484/// appended after all preset rules.
485fn merge_rules(preset_rules: Vec<TomlRule>, user_rules: &[TomlRule]) -> Vec<TomlRule> {
486    let mut merged = preset_rules;
487
488    // Index preset rules by id for O(1) lookup
489    let mut id_to_index: HashMap<String, usize> = HashMap::new();
490    for (i, rule) in merged.iter().enumerate() {
491        id_to_index.insert(rule.id.clone(), i);
492    }
493
494    for user_rule in user_rules {
495        if let Some(&idx) = id_to_index.get(&user_rule.id) {
496            // User rule overrides preset rule with same id
497            merged[idx] = user_rule.clone();
498        } else {
499            // New user rule appended
500            merged.push(user_rule.clone());
501        }
502    }
503
504    merged
505}
506
507/// Resolve all `extends` presets and merge with user-defined rules.
508/// Returns the final list of `TomlRule` entries ready for the build pipeline.
509pub fn resolve_rules(
510    extends: &[String],
511    user_rules: &[TomlRule],
512) -> Result<Vec<TomlRule>, PresetError> {
513    if extends.is_empty() {
514        return Ok(user_rules.to_vec());
515    }
516
517    // Collect all preset rules in order, later presets override earlier ones
518    let mut all_preset_rules: Vec<TomlRule> = Vec::new();
519    let mut seen: HashMap<String, usize> = HashMap::new();
520
521    for preset_name in extends {
522        let preset = resolve_preset(preset_name).ok_or_else(|| PresetError::UnknownPreset {
523            name: preset_name.clone(),
524            available: available_presets().to_vec(),
525        })?;
526
527        for rule in preset_rules(preset) {
528            if let Some(&idx) = seen.get(&rule.id) {
529                // Later preset overrides earlier for same id
530                all_preset_rules[idx] = rule;
531            } else {
532                seen.insert(rule.id.clone(), all_preset_rules.len());
533                all_preset_rules.push(rule);
534            }
535        }
536    }
537
538    Ok(merge_rules(all_preset_rules, user_rules))
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn shadcn_strict_has_five_rules() {
547        let rules = preset_rules(Preset::ShadcnStrict);
548        assert_eq!(rules.len(), 5);
549        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
550        assert!(ids.contains(&"enforce-dark-mode"));
551        assert!(ids.contains(&"use-theme-tokens"));
552        assert!(ids.contains(&"no-inline-styles"));
553        assert!(ids.contains(&"no-css-in-js"));
554        assert!(ids.contains(&"no-competing-frameworks"));
555    }
556
557    #[test]
558    fn shadcn_migrate_has_two_rules() {
559        let rules = preset_rules(Preset::ShadcnMigrate);
560        assert_eq!(rules.len(), 2);
561        assert_eq!(rules[0].id, "enforce-dark-mode");
562        assert_eq!(rules[1].id, "use-theme-tokens");
563        // migrate uses warning for theme tokens
564        assert_eq!(rules[1].severity, "warning");
565    }
566
567    #[test]
568    fn ai_safety_has_three_rules() {
569        let rules = preset_rules(Preset::AiSafety);
570        assert_eq!(rules.len(), 3);
571        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
572        assert!(ids.contains(&"no-moment"));
573        assert!(ids.contains(&"no-lodash"));
574        assert!(ids.contains(&"no-deprecated-request"));
575    }
576
577    #[test]
578    fn resolve_unknown_preset_errors() {
579        let result = resolve_rules(&["unknown-preset".to_string()], &[]);
580        assert!(result.is_err());
581        let err = result.unwrap_err();
582        let msg = format!("{}", err);
583        assert!(msg.contains("unknown preset 'unknown-preset'"));
584        assert!(msg.contains("shadcn-strict"));
585    }
586
587    #[test]
588    fn resolve_empty_extends_returns_user_rules() {
589        let user_rules = vec![TomlRule {
590            id: "custom-rule".into(),
591            rule_type: "banned-pattern".into(),
592            pattern: Some("TODO".into()),
593            message: "No TODOs".into(),
594            ..Default::default()
595        }];
596        let result = resolve_rules(&[], &user_rules).unwrap();
597        assert_eq!(result.len(), 1);
598        assert_eq!(result[0].id, "custom-rule");
599    }
600
601    #[test]
602    fn user_rule_overrides_preset() {
603        let user_rules = vec![TomlRule {
604            id: "use-theme-tokens".into(),
605            rule_type: "tailwind-theme-tokens".into(),
606            severity: "warning".into(),
607            glob: Some("**/*.{tsx,jsx}".into()),
608            message: "Custom message".into(),
609            ..Default::default()
610        }];
611        let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
612        assert_eq!(result.len(), 5);
613        let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
614        assert_eq!(token_rule.severity, "warning");
615        assert_eq!(token_rule.message, "Custom message");
616    }
617
618    #[test]
619    fn user_rule_appended_after_preset() {
620        let user_rules = vec![TomlRule {
621            id: "my-custom".into(),
622            rule_type: "banned-pattern".into(),
623            pattern: Some("foo".into()),
624            message: "no foo".into(),
625            ..Default::default()
626        }];
627        let result = resolve_rules(&["shadcn-strict".to_string()], &user_rules).unwrap();
628        assert_eq!(result.len(), 6);
629        assert_eq!(result[5].id, "my-custom");
630    }
631
632    #[test]
633    fn later_preset_overrides_earlier() {
634        // shadcn-strict sets use-theme-tokens severity to "error"
635        // shadcn-migrate sets it to "warning"
636        let result = resolve_rules(
637            &["shadcn-strict".to_string(), "shadcn-migrate".to_string()],
638            &[],
639        )
640        .unwrap();
641        let token_rule = result.iter().find(|r| r.id == "use-theme-tokens").unwrap();
642        assert_eq!(token_rule.severity, "warning");
643        // Should have 5 unique rules (strict has 5, migrate shares 2 ids)
644        assert_eq!(result.len(), 5);
645    }
646
647    #[test]
648    fn multiple_presets_combine() {
649        let result = resolve_rules(
650            &["shadcn-migrate".to_string(), "ai-safety".to_string()],
651            &[],
652        )
653        .unwrap();
654        // 2 from migrate + 3 from ai-safety = 5
655        assert_eq!(result.len(), 5);
656    }
657
658    #[test]
659    fn security_has_ten_rules() {
660        let rules = preset_rules(Preset::Security);
661        assert_eq!(rules.len(), 10);
662        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
663        assert!(ids.contains(&"no-env-files"));
664        assert!(ids.contains(&"no-hardcoded-secrets"));
665        assert!(ids.contains(&"no-eval"));
666        assert!(ids.contains(&"no-dangerous-html"));
667        assert!(ids.contains(&"no-innerhtml"));
668        assert!(ids.contains(&"no-console-log"));
669        assert!(ids.contains(&"no-document-write"));
670        assert!(ids.contains(&"no-postmessage-wildcard"));
671        assert!(ids.contains(&"no-outerhtml"));
672        assert!(ids.contains(&"no-http-links"));
673    }
674
675    #[test]
676    fn nextjs_has_eight_rules() {
677        let rules = preset_rules(Preset::Nextjs);
678        assert_eq!(rules.len(), 8);
679        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
680        assert!(ids.contains(&"use-next-image"));
681        assert!(ids.contains(&"no-next-head"));
682        assert!(ids.contains(&"no-private-env-client"));
683        assert!(ids.contains(&"require-use-client-for-hooks"));
684        assert!(ids.contains(&"use-next-link"));
685        assert!(ids.contains(&"no-next-router-in-app"));
686        assert!(ids.contains(&"no-sync-scripts"));
687        assert!(ids.contains(&"no-link-fonts"));
688    }
689
690    #[test]
691    fn ai_codegen_has_twelve_rules() {
692        let rules = preset_rules(Preset::AiCodegen);
693        assert_eq!(rules.len(), 12);
694        let ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
695        assert!(ids.contains(&"no-placeholder-text"));
696        assert!(ids.contains(&"no-unresolved-todos"));
697        assert!(ids.contains(&"no-type-any"));
698        assert!(ids.contains(&"no-empty-catch"));
699        assert!(ids.contains(&"no-console-log"));
700        assert!(ids.contains(&"no-ts-ignore"));
701        assert!(ids.contains(&"no-as-any"));
702        assert!(ids.contains(&"no-eslint-disable"));
703        assert!(ids.contains(&"no-ts-nocheck"));
704        assert!(ids.contains(&"no-var"));
705        assert!(ids.contains(&"no-require-in-ts"));
706        assert!(ids.contains(&"no-non-null-assertion"));
707    }
708
709    #[test]
710    fn all_preset_names_resolve() {
711        for name in available_presets() {
712            assert!(
713                resolve_preset(name).is_some(),
714                "preset '{}' should resolve",
715                name
716            );
717        }
718    }
719
720    #[test]
721    fn all_preset_regex_patterns_compile() {
722        use regex::Regex;
723        for name in available_presets() {
724            let preset = resolve_preset(name).unwrap();
725            for rule in preset_rules(preset) {
726                if rule.regex {
727                    if let Some(ref pat) = rule.pattern {
728                        Regex::new(pat).unwrap_or_else(|e| {
729                            panic!("preset '{}', rule '{}': invalid pattern: {}", name, rule.id, e)
730                        });
731                    }
732                    if let Some(ref pat) = rule.condition_pattern {
733                        Regex::new(pat).unwrap_or_else(|e| {
734                            panic!(
735                                "preset '{}', rule '{}': invalid condition_pattern: {}",
736                                name, rule.id, e
737                            )
738                        });
739                    }
740                }
741            }
742        }
743    }
744
745    #[test]
746    fn no_private_env_client_pattern_correctness() {
747        use regex::Regex;
748        let rules = preset_rules(Preset::Nextjs);
749        let rule = rules.iter().find(|r| r.id == "no-private-env-client").unwrap();
750        let re = Regex::new(rule.pattern.as_ref().unwrap()).unwrap();
751
752        // Should match private env vars
753        assert!(re.is_match("process.env.DATABASE_URL"));
754        assert!(re.is_match("process.env.API_SECRET"));
755        assert!(re.is_match("process.env.NODE_ENV"));
756        assert!(re.is_match("process.env.NEXT_RUNTIME"));
757
758        // Should NOT match NEXT_PUBLIC_ prefixed vars
759        assert!(!re.is_match("process.env.NEXT_PUBLIC_API_URL"));
760        assert!(!re.is_match("process.env.NEXT_PUBLIC_STRIPE_KEY"));
761    }
762
763    /// Helper: get a compiled Regex for a preset rule by preset and rule id.
764    fn regex_for(preset: Preset, rule_id: &str) -> regex::Regex {
765        let rules = preset_rules(preset);
766        let rule = rules
767            .iter()
768            .find(|r| r.id == rule_id)
769            .unwrap_or_else(|| panic!("rule '{}' not found", rule_id));
770        regex::Regex::new(rule.pattern.as_ref().unwrap()).unwrap()
771    }
772
773    // ── Security pattern tests ─────────────────────────────────────────
774
775    #[test]
776    fn no_document_write_pattern() {
777        let re = regex_for(Preset::Security, "no-document-write");
778        assert!(re.is_match("document.write('hello')"));
779        assert!(re.is_match("document.write (html)"));
780        assert!(re.is_match("  document.write('<div>')"));
781        // read access is fine
782        assert!(!re.is_match("const w = document.writeln"));
783        assert!(!re.is_match("documentWriter()"));
784    }
785
786    #[test]
787    fn no_postmessage_wildcard_pattern() {
788        let re = regex_for(Preset::Security, "no-postmessage-wildcard");
789        assert!(re.is_match("window.postMessage(data, '*')"));
790        assert!(re.is_match(r#"iframe.contentWindow.postMessage({}, "*")"#));
791        assert!(re.is_match("  w.postMessage(msg, '*')"));
792        // specific origins are fine
793        assert!(!re.is_match("window.postMessage(data, 'https://example.com')"));
794        assert!(!re.is_match("window.postMessage(data, origin)"));
795    }
796
797    #[test]
798    fn no_outerhtml_pattern() {
799        let re = regex_for(Preset::Security, "no-outerhtml");
800        assert!(re.is_match("el.outerHTML = '<div>'"));
801        assert!(re.is_match("el.outerHTML += '<span>'"));
802        assert!(re.is_match("  node.outerHTML = html"));
803        // reading outerHTML is fine
804        assert!(!re.is_match("const html = el.outerHTML"));
805        assert!(!re.is_match("console.log(el.outerHTML)"));
806    }
807
808    #[test]
809    fn no_http_links_pattern() {
810        let re = regex_for(Preset::Security, "no-http-links");
811        assert!(re.is_match(r#"fetch("http://api.example.com")"#));
812        assert!(re.is_match("const url = 'http://cdn.example.com'"));
813        // https is fine
814        assert!(!re.is_match(r#"fetch("https://api.example.com")"#));
815        // not in a string literal
816        assert!(!re.is_match("// visit http://example.com"));
817    }
818
819    #[test]
820    fn no_hardcoded_secrets_expanded() {
821        let re = regex_for(Preset::Security, "no-hardcoded-secrets");
822        // original keywords still work
823        assert!(re.is_match(r#"api_key = "abc12345678""#));
824        assert!(re.is_match(r#"API_KEY: "abc12345678""#));
825        // new keywords
826        assert!(re.is_match(r#"password = "mysecretpass""#));
827        assert!(re.is_match(r#"PASSWORD: "supersecret1""#));
828        assert!(re.is_match(r#"client_secret = "abcdefghij""#));
829        // short values (< 8 chars) should NOT match
830        assert!(!re.is_match(r#"password = "short""#));
831        // no string value should NOT match
832        assert!(!re.is_match("password = getPassword()"));
833    }
834
835    // ── Next.js pattern tests ──────────────────────────────────────────
836
837    #[test]
838    fn no_sync_scripts_pattern() {
839        let re = regex_for(Preset::Nextjs, "no-sync-scripts");
840        assert!(re.is_match(r#"<script src="analytics.js">"#));
841        assert!(re.is_match(r#"<script type="application/ld+json">"#));
842        // next/script component (uppercase) should NOT match
843        assert!(!re.is_match(r#"<Script src="analytics.js">"#));
844        // closing tag should NOT match
845        assert!(!re.is_match("</script>"));
846    }
847
848    #[test]
849    fn no_link_fonts_pattern() {
850        let re = regex_for(Preset::Nextjs, "no-link-fonts");
851        assert!(re.is_match(
852            r#"<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />"#
853        ));
854        assert!(re.is_match(
855            r#"<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto">"#
856        ));
857        // other link tags should NOT match
858        assert!(!re.is_match(r#"<link rel="stylesheet" href="/styles.css" />"#));
859        // next/link is fine
860        assert!(!re.is_match(r#"<Link href="/fonts">"#));
861    }
862
863    // ── AI Codegen pattern tests ───────────────────────────────────────
864
865    #[test]
866    fn no_eslint_disable_pattern() {
867        let rules = preset_rules(Preset::AiCodegen);
868        let rule = rules.iter().find(|r| r.id == "no-eslint-disable").unwrap();
869        let pat = rule.pattern.as_ref().unwrap();
870        // literal match (no regex)
871        assert!(!rule.regex);
872        assert!("// eslint-disable-next-line no-console".contains(pat.as_str()));
873        assert!("/* eslint-disable */".contains(pat.as_str()));
874        assert!("/* eslint-disable-next-line */".contains(pat.as_str()));
875    }
876
877    #[test]
878    fn no_var_pattern() {
879        let re = regex_for(Preset::AiCodegen, "no-var");
880        assert!(re.is_match("var x = 1"));
881        assert!(re.is_match("var foo = 'bar'"));
882        assert!(re.is_match("  var count = 0;"));
883        // should NOT match these
884        assert!(!re.is_match("const variable = 1"));
885        assert!(!re.is_match("let variance = 2"));
886        assert!(!re.is_match("const isVariable = true"));
887    }
888
889    #[test]
890    fn no_require_in_ts_pattern() {
891        let re = regex_for(Preset::AiCodegen, "no-require-in-ts");
892        assert!(re.is_match("const fs = require('fs')"));
893        assert!(re.is_match("const x = require('./module')"));
894        assert!(re.is_match("require('dotenv').config()"));
895        // import is fine
896        assert!(!re.is_match("import fs from 'fs'"));
897        // require.resolve is different (no parens right after require)
898        assert!(!re.is_match("require.resolve('./path')"));
899    }
900
901    #[test]
902    fn no_non_null_assertion_pattern() {
903        let re = regex_for(Preset::AiCodegen, "no-non-null-assertion");
904        // should match non-null assertions
905        assert!(re.is_match("user!.name"));
906        assert!(re.is_match("items![0]"));
907        assert!(re.is_match("this.ref!.current"));
908        assert!(re.is_match("data!.results"));
909        // should NOT match these
910        assert!(!re.is_match("x !== y"));
911        assert!(!re.is_match("x != y"));
912        assert!(!re.is_match("if (!foo) {}"));
913        assert!(!re.is_match("!!value"));
914        assert!(!re.is_match("foo!==bar"));
915    }
916
917    #[test]
918    fn no_non_null_assertion_no_false_positives_on_strings() {
919        let re = regex_for(Preset::AiCodegen, "no-non-null-assertion");
920        // String ending in '!' with method call — quote sits between ! and .
921        assert!(!re.is_match(r#""Warning!".toUpperCase()"#));
922        assert!(!re.is_match(r#"'Error!'.length"#));
923        assert!(!re.is_match(r#"'Click me!'[0]"#));
924    }
925
926    #[test]
927    fn no_innerhtml_catches_plus_equals() {
928        let re = regex_for(Preset::Security, "no-innerhtml");
929        assert!(re.is_match("el.innerHTML = html"));
930        assert!(re.is_match("el.innerHTML += '<br>'"));
931        assert!(re.is_match("el.innerHTML  =  content"));
932        assert!(!re.is_match("const x = el.innerHTML"));
933    }
934
935    #[test]
936    fn no_type_any_catches_generics() {
937        let re = regex_for(Preset::AiCodegen, "no-type-any");
938        // type annotation
939        assert!(re.is_match("const x: any = 1"));
940        // generic position
941        assert!(re.is_match("Array<any>"));
942        assert!(re.is_match("Promise<any>"));
943        assert!(re.is_match("Record<string, any>"));
944        assert!(re.is_match("Map<string, any>"));
945        // should NOT match word 'any' in other contexts
946        assert!(!re.is_match("// handle any case"));
947        assert!(!re.is_match("const anything = 1"));
948        assert!(!re.is_match("if (any_flag) {}"));
949    }
950}