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
40pub 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 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
482fn merge_rules(preset_rules: Vec<TomlRule>, user_rules: &[TomlRule]) -> Vec<TomlRule> {
486 let mut merged = preset_rules;
487
488 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 merged[idx] = user_rule.clone();
498 } else {
499 merged.push(user_rule.clone());
501 }
502 }
503
504 merged
505}
506
507pub 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 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 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 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 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 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 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 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 assert!(!re.is_match("process.env.NEXT_PUBLIC_API_URL"));
760 assert!(!re.is_match("process.env.NEXT_PUBLIC_STRIPE_KEY"));
761 }
762
763 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 #[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 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 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 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 assert!(!re.is_match(r#"fetch("https://api.example.com")"#));
815 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 assert!(re.is_match(r#"api_key = "abc12345678""#));
824 assert!(re.is_match(r#"API_KEY: "abc12345678""#));
825 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 assert!(!re.is_match(r#"password = "short""#));
831 assert!(!re.is_match("password = getPassword()"));
833 }
834
835 #[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 assert!(!re.is_match(r#"<Script src="analytics.js">"#));
844 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 assert!(!re.is_match(r#"<link rel="stylesheet" href="/styles.css" />"#));
859 assert!(!re.is_match(r#"<Link href="/fonts">"#));
861 }
862
863 #[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 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 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 assert!(!re.is_match("import fs from 'fs'"));
897 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 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 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 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 assert!(re.is_match("const x: any = 1"));
940 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 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}