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
42pub 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 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 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 #[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 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 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 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 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 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 #[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 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 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 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 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 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 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 #[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
920fn merge_rules(preset_rules: Vec<TomlRule>, user_rules: &[TomlRule]) -> Vec<TomlRule> {
924 let mut merged = preset_rules;
925
926 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 merged[idx] = user_rule.clone();
936 } else {
937 merged.push(user_rule.clone());
939 }
940 }
941
942 merged
943}
944
945pub 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 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 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 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 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 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 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); 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 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); 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 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 assert!(!re.is_match("process.env.NEXT_PUBLIC_API_URL"));
1269 assert!(!re.is_match("process.env.NEXT_PUBLIC_STRIPE_KEY"));
1270 }
1271
1272 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 #[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 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 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 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 assert!(!re.is_match(r#"fetch("https://api.example.com")"#));
1324 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 assert!(re.is_match(r#"api_key = "abc12345678""#));
1333 assert!(re.is_match(r#"API_KEY: "abc12345678""#));
1334 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 assert!(!re.is_match(r#"password = "short""#));
1340 assert!(!re.is_match("password = getPassword()"));
1342 }
1343
1344 #[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 assert!(!re.is_match(r#"<Script src="analytics.js">"#));
1353 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 assert!(!re.is_match(r#"<link rel="stylesheet" href="/styles.css" />"#));
1368 assert!(!re.is_match(r#"<Link href="/fonts">"#));
1370 }
1371
1372 #[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 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 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 assert!(!re.is_match("import fs from 'fs'"));
1406 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 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 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 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 assert!(re.is_match("const x: any = 1"));
1449 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 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}