1use std::collections::BTreeSet;
15use std::path::{Path, PathBuf};
16
17use anyhow::{anyhow, bail, Context, Result};
18use oxc::allocator::Allocator;
19use oxc::ast::ast::{Argument, CallExpression, Expression, ImportDeclaration, ImportOrExportKind};
20use oxc::ast_visit::{walk, Visit};
21use oxc::parser::Parser;
22use oxc::span::{SourceType, Span};
23
24use crate::lint::Violation;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Origin {
29 FirstParty,
31 Builtin,
33 ThirdParty,
35}
36
37pub fn classify(specifier: &str) -> Origin {
46 if specifier.starts_with('.') || specifier.starts_with('/') {
47 return Origin::FirstParty;
48 }
49 if specifier.starts_with("node:") || is_node_builtin(specifier) {
50 return Origin::Builtin;
51 }
52 Origin::ThirdParty
53}
54
55fn is_node_builtin(specifier: &str) -> bool {
58 let head = specifier.split('/').next().unwrap_or(specifier);
59 NODE_BUILTINS.contains(&head)
60}
61
62const NODE_BUILTINS: &[&str] = &[
66 "assert",
67 "async_hooks",
68 "buffer",
69 "child_process",
70 "cluster",
71 "console",
72 "constants",
73 "crypto",
74 "dgram",
75 "diagnostics_channel",
76 "dns",
77 "domain",
78 "events",
79 "fs",
80 "http",
81 "http2",
82 "https",
83 "inspector",
84 "module",
85 "net",
86 "os",
87 "path",
88 "perf_hooks",
89 "process",
90 "punycode",
91 "querystring",
92 "readline",
93 "repl",
94 "stream",
95 "string_decoder",
96 "sys",
97 "timers",
98 "tls",
99 "trace_events",
100 "tty",
101 "url",
102 "util",
103 "v8",
104 "vm",
105 "wasi",
106 "worker_threads",
107 "zlib",
108];
109
110pub fn find_integration_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
117 let root = root.as_ref();
118 let mut files = Vec::new();
119 collect_ts_test_files(root, &mut files)?;
120 files.sort();
121
122 let mut violations = Vec::new();
123 for file in &files {
124 let source = std::fs::read_to_string(file)
125 .with_context(|| format!("reading test file `{}`", file.display()))?;
126 violations.extend(integration_violations_in(file, &source)?);
127 }
128
129 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
130 Ok(violations)
131}
132
133const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
136 a suite lives in `tests/integration/` or `tests/e2e/`";
137
138pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
148 let tests = package_root.join("tests");
149 let mut violations = Vec::new();
150 let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
151 for tier in &tiers {
152 if tier.is_dir() {
153 violations.extend(find_integration_violations(tier)?);
154 }
155 }
156 if tests.is_dir() {
157 let mut strays = Vec::new();
158 collect_ts_test_files(&tests, &mut strays)?;
159 strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
160 for file in strays {
161 violations.push(Violation {
162 file,
163 line: 1,
164 rule: "unknown-tier",
165 message: UNKNOWN_TIER_MSG.to_string(),
166 });
167 }
168 }
169 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
170 Ok(violations)
171}
172
173pub fn find_unit_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
181 let root = root.as_ref();
182 let mut files = Vec::new();
183 collect_ts_test_files(root, &mut files)?;
184 if let Some(tests) = crate::tiers::suite_tests_dir(root, "package.json") {
187 files.retain(|file| !file.starts_with(&tests));
188 }
189 files.sort();
190
191 let mut violations = Vec::new();
192 for file in &files {
193 let source = std::fs::read_to_string(file)
194 .with_context(|| format!("reading test file `{}`", file.display()))?;
195 violations.extend(unit_violations_in(file, &source)?);
196 }
197
198 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
199 Ok(violations)
200}
201
202fn unit_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
206 let allocator = Allocator::default();
207 let source_type = SourceType::from_path(file).map_err(|err| {
208 anyhow!(
209 "unsupported TypeScript extension `{}`: {err}",
210 file.display()
211 )
212 })?;
213 let ret = Parser::new(&allocator, source, source_type).parse();
214 if ret.panicked || !ret.diagnostics.is_empty() {
215 let detail = ret
216 .diagnostics
217 .iter()
218 .map(|d| d.to_string())
219 .collect::<Vec<_>>()
220 .join("; ");
221 bail!("parsing `{}` failed: {detail}", file.display());
222 }
223
224 let mut collector = UnitCollector {
225 source,
226 imports: Vec::new(),
227 mocked: BTreeSet::new(),
228 untyped: Vec::new(),
229 };
230 collector.visit_program(&ret.program);
231
232 let unit = unit_under_test_specifier(file);
233 let mocked_modules: BTreeSet<&str> = collector
237 .mocked
238 .iter()
239 .map(|m| strip_module_ext(m))
240 .collect();
241 let mut violations = Vec::new();
242 for (spec, line) in &collector.imports {
243 if is_unit_under_test(spec, &unit)
244 || is_test_runner(spec)
245 || mocked_modules.contains(strip_module_ext(spec))
246 {
247 continue;
248 }
249 violations.push(Violation {
250 file: file.to_path_buf(),
251 line: *line,
252 rule: "unmocked-collaborator",
253 message: format!(
254 "unit test imports `{spec}` without mocking it — a unit test isolates the \
255 unit under test, so every collaborator must be `vi.mock()`-ed"
256 ),
257 });
258 }
259 for (spec, line) in &collector.untyped {
260 violations.push(Violation {
261 file: file.to_path_buf(),
262 line: *line,
263 rule: "untyped-mock",
264 message: format!(
265 "`vi.mock('{spec}', …)` has an untyped factory — anchor it to the real module \
266 with `vi.importActual<typeof import('{spec}')>()` so the double can't drift \
267 from the source"
268 ),
269 });
270 }
271 violations.sort_by_key(|v| v.line);
272 Ok(violations)
273}
274
275struct UnitCollector<'s> {
278 source: &'s str,
279 imports: Vec<(String, usize)>,
280 mocked: BTreeSet<String>,
281 untyped: Vec<(String, usize)>,
282}
283
284impl<'a> Visit<'a> for UnitCollector<'_> {
285 fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
286 if matches!(decl.import_kind, ImportOrExportKind::Type) {
288 return;
289 }
290 self.imports.push((
291 decl.source.value.to_string(),
292 line_of(self.source, decl.span.start),
293 ));
294 }
295
296 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
297 if let Some(spec) = vi_mock_target(call) {
298 if let Some(factory) = call.arguments.get(1) {
305 if is_factory(factory) && !factory_is_typed(factory) {
306 self.untyped
307 .push((spec.clone(), line_of(self.source, call.span.start)));
308 }
309 }
310 self.mocked.insert(spec);
311 }
312 walk::walk_call_expression(self, call);
313 }
314}
315
316fn unit_under_test_specifier(file: &Path) -> String {
318 let name = file
319 .file_name()
320 .and_then(|n| n.to_str())
321 .unwrap_or_default();
322 let stem = name.split(".test.").next().unwrap_or(name);
323 format!("./{stem}")
324}
325
326fn is_unit_under_test(spec: &str, unit: &str) -> bool {
329 strip_module_ext(spec) == unit
330}
331
332fn strip_module_ext(spec: &str) -> &str {
334 for ext in [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"] {
335 if let Some(base) = spec.strip_suffix(ext) {
336 return base;
337 }
338 }
339 spec
340}
341
342fn is_test_runner(spec: &str) -> bool {
345 spec == "vitest" || spec.starts_with("vitest/") || spec.starts_with("@vitest/")
346}
347
348fn is_factory(arg: &Argument) -> bool {
355 matches!(
356 arg.as_expression(),
357 Some(Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_))
358 )
359}
360
361fn factory_is_typed(factory: &Argument) -> bool {
365 let mut finder = ImportActualFinder { typed: false };
366 finder.visit_argument(factory);
367 finder.typed
368}
369
370struct ImportActualFinder {
372 typed: bool,
373}
374
375impl<'a> Visit<'a> for ImportActualFinder {
376 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
377 if is_typed_import_actual(call) {
378 self.typed = true;
379 }
380 walk::walk_call_expression(self, call);
381 }
382}
383
384fn is_typed_import_actual(call: &CallExpression) -> bool {
387 let Expression::StaticMemberExpression(member) = &call.callee else {
388 return false;
389 };
390 let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
391 is_vi && member.property.name.as_str() == "importActual" && call.type_arguments.is_some()
392}
393
394fn integration_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
398 let allocator = Allocator::default();
399 let source_type = SourceType::from_path(file).map_err(|err| {
400 anyhow!(
401 "unsupported TypeScript extension `{}`: {err}",
402 file.display()
403 )
404 })?;
405 let ret = Parser::new(&allocator, source, source_type).parse();
406 if ret.panicked || !ret.diagnostics.is_empty() {
407 let detail = ret
408 .diagnostics
409 .iter()
410 .map(|d| d.to_string())
411 .collect::<Vec<_>>()
412 .join("; ");
413 bail!("parsing `{}` failed: {detail}", file.display());
414 }
415
416 let mut visitor = MockVisitor {
417 file,
418 source,
419 violations: Vec::new(),
420 };
421 visitor.visit_program(&ret.program);
422 Ok(visitor.violations)
423}
424
425struct MockVisitor<'s> {
428 file: &'s Path,
429 source: &'s str,
430 violations: Vec<Violation>,
431}
432
433impl MockVisitor<'_> {
434 fn report(&mut self, span: Span, spec: &str) {
435 self.violations.push(Violation {
436 file: self.file.to_path_buf(),
437 line: line_of(self.source, span.start),
438 rule: "no-first-party-mock",
439 message: format!(
440 "integration test mocks first-party module `{spec}` — an integration test \
441 runs first-party code for real; only third-party packages and Node built-ins \
442 may be mocked"
443 ),
444 });
445 }
446}
447
448impl<'a> Visit<'a> for MockVisitor<'_> {
449 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
450 if let Some(spec) = vi_mock_target(call) {
451 if classify(&spec) == Origin::FirstParty {
452 self.report(call.span, &spec);
453 }
454 }
455 walk::walk_call_expression(self, call);
456 }
457}
458
459fn vi_mock_target(call: &CallExpression) -> Option<String> {
465 let Expression::StaticMemberExpression(member) = &call.callee else {
466 return None;
467 };
468 let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
469 if !is_vi {
470 return None;
471 }
472 let method = member.property.name.as_str();
473 if method != "mock" && method != "doMock" {
474 return None;
475 }
476 match call.arguments.first() {
477 Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
478 _ => None,
479 }
480}
481
482fn line_of(source: &str, offset: u32) -> usize {
484 let offset = (offset as usize).min(source.len());
485 source.as_bytes()[..offset]
486 .iter()
487 .filter(|&&byte| byte == b'\n')
488 .count()
489 + 1
490}
491
492fn collect_ts_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
494 let entries =
495 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
496 for entry in entries {
497 let path = entry
498 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
499 .path();
500 if path.is_dir() {
501 collect_ts_test_files(&path, out)?;
502 } else if is_ts_test_file(&path) {
503 out.push(path);
504 }
505 }
506 Ok(())
507}
508
509fn is_ts_test_file(path: &Path) -> bool {
511 let name = path
512 .file_name()
513 .and_then(|n| n.to_str())
514 .unwrap_or_default();
515 name.ends_with(".test.ts")
516 || name.ends_with(".test.tsx")
517 || name.ends_with(".test.mts")
518 || name.ends_with(".test.cts")
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 fn violations(name: &str, source: &str) -> Vec<Violation> {
527 integration_violations_in(Path::new(name), source).expect("source should parse")
528 }
529
530 fn unit_violations(name: &str, source: &str) -> Vec<Violation> {
532 unit_violations_in(Path::new(name), source).expect("source should parse")
533 }
534
535 #[test]
536 fn unit_flags_unmocked_first_party_and_external() {
537 let found = unit_violations(
538 "widget.test.ts",
539 "import { makeWidget } from './widget';\n\
540 import { format } from './formatter';\n\
541 import { chunk } from 'lodash';\n",
542 );
543 assert_eq!(found.len(), 2, "got: {found:?}");
546 assert!(found.iter().all(|v| v.rule == "unmocked-collaborator"));
547 assert!(found.iter().any(|v| v.message.contains("./formatter")));
548 assert!(found.iter().any(|v| v.message.contains("lodash")));
549 }
550
551 #[test]
552 fn unit_mocked_collaborator_is_clean() {
553 let found = unit_violations(
554 "widget.test.ts",
555 "import { format } from './formatter';\nvi.mock('./formatter');\n",
556 );
557 assert!(found.is_empty(), "got: {found:?}");
558 }
559
560 #[test]
561 fn unit_under_test_and_runner_are_not_flagged() {
562 let found = unit_violations(
563 "widget.test.ts",
564 "import { vi } from 'vitest';\n\
565 import { makeWidget } from './widget.js';\n",
566 );
567 assert!(found.is_empty(), "got: {found:?}");
569 }
570
571 #[test]
572 fn unit_type_only_import_is_not_flagged() {
573 let found = unit_violations(
574 "widget.test.ts",
575 "import type { Opts } from './opts';\nimport { x } from './x';\nvi.mock('./x');\n",
576 );
577 assert!(found.is_empty(), "got: {found:?}");
578 }
579
580 #[test]
581 fn unit_under_test_specifier_strips_test_suffix() {
582 assert_eq!(
583 unit_under_test_specifier(Path::new("pkg/widget.test.ts")),
584 "./widget"
585 );
586 assert_eq!(
587 unit_under_test_specifier(Path::new("button.test.tsx")),
588 "./button"
589 );
590 }
591
592 #[test]
593 fn strip_module_ext_drops_known_extensions_only() {
594 assert_eq!(strip_module_ext("./widget.js"), "./widget");
595 assert_eq!(strip_module_ext("./widget.mts"), "./widget");
596 assert_eq!(strip_module_ext("./widget"), "./widget");
597 assert_eq!(strip_module_ext("lodash"), "lodash");
598 }
599
600 #[test]
601 fn recognizes_the_test_runner() {
602 assert!(is_test_runner("vitest"));
603 assert!(is_test_runner("vitest/config"));
604 assert!(is_test_runner("@vitest/spy"));
605 assert!(!is_test_runner("./vitest-helpers"));
606 assert!(!is_test_runner("lodash"));
607 }
608
609 #[test]
610 fn unit_flags_untyped_factory_mock() {
611 let found = unit_violations(
612 "widget.test.ts",
613 "import { x } from './x';\nvi.mock('./x', () => ({ x: vi.fn() }));\n",
614 );
615 assert_eq!(found.len(), 1, "got: {found:?}");
618 assert_eq!(found[0].rule, "untyped-mock");
619 assert!(found[0].message.contains("./x"));
620 }
621
622 #[test]
623 fn unit_typed_factory_mock_is_clean() {
624 let found = unit_violations(
625 "widget.test.ts",
626 "import { x } from './x';\n\
627 vi.mock('./x', async () => {\n\
628 \x20 const actual = await vi.importActual<typeof import('./x')>('./x');\n\
629 \x20 return { ...actual, x: vi.fn() };\n\
630 });\n",
631 );
632 assert!(found.is_empty(), "got: {found:?}");
633 }
634
635 #[test]
636 fn unit_options_object_mock_is_not_a_factory() {
637 let found = unit_violations(
641 "widget.test.ts",
642 "import { x } from './x';\nvi.mock('./x', { spy: true });\n",
643 );
644 assert!(found.is_empty(), "got: {found:?}");
645 }
646
647 #[test]
648 fn unit_untyped_import_actual_is_still_untyped() {
649 let found = unit_violations(
651 "widget.test.ts",
652 "import { x } from './x';\n\
653 vi.mock('./x', async () => {\n\
654 \x20 const actual = await vi.importActual('./x');\n\
655 \x20 return { ...(actual as object), x: vi.fn() };\n\
656 });\n",
657 );
658 assert_eq!(found.len(), 1, "got: {found:?}");
659 assert_eq!(found[0].rule, "untyped-mock");
660 }
661
662 #[test]
663 fn classify_relative_is_first_party() {
664 assert_eq!(classify("./service"), Origin::FirstParty);
665 assert_eq!(classify("../pkg/util"), Origin::FirstParty);
666 assert_eq!(classify("/abs/path"), Origin::FirstParty);
667 }
668
669 #[test]
670 fn classify_node_builtins() {
671 assert_eq!(classify("fs"), Origin::Builtin);
672 assert_eq!(classify("node:fs"), Origin::Builtin);
673 assert_eq!(classify("fs/promises"), Origin::Builtin);
674 assert_eq!(classify("node:test"), Origin::Builtin);
675 assert_eq!(classify("child_process"), Origin::Builtin);
676 assert_eq!(classify("node:some-future-builtin"), Origin::Builtin);
677 }
678
679 #[test]
680 fn classify_third_party() {
681 assert_eq!(classify("lodash"), Origin::ThirdParty);
682 assert_eq!(classify("@scope/pkg"), Origin::ThirdParty);
683 assert_eq!(classify("stripe/lib/client"), Origin::ThirdParty);
684 assert_eq!(classify("test"), Origin::ThirdParty);
687 }
688
689 #[test]
690 fn recognizes_ts_test_files() {
691 assert!(is_ts_test_file(Path::new("widget.test.ts")));
692 assert!(is_ts_test_file(Path::new("pkg/button.test.tsx")));
693 assert!(is_ts_test_file(Path::new("service.test.mts")));
694 assert!(is_ts_test_file(Path::new("legacy.test.cts")));
695 assert!(!is_ts_test_file(Path::new("widget.ts")));
696 assert!(!is_ts_test_file(Path::new("types.d.ts")));
697 assert!(!is_ts_test_file(Path::new("README.md")));
698 }
699
700 #[test]
701 fn line_of_counts_newlines() {
702 let src = "a\nb\nc\n";
703 assert_eq!(line_of(src, 0), 1);
704 assert_eq!(line_of(src, 2), 2);
705 assert_eq!(line_of(src, 4), 3);
706 }
707
708 #[test]
709 fn flags_mock_of_relative_module() {
710 let found = violations("a.test.ts", "vi.mock('./service');\n");
711 assert_eq!(found.len(), 1);
712 assert_eq!(found[0].rule, "no-first-party-mock");
713 assert_eq!(found[0].line, 1);
714 }
715
716 #[test]
717 fn flags_mock_with_factory_and_parent_path() {
718 let found = violations(
719 "a.test.ts",
720 "import { x } from './x';\nvi.mock('../src/ledger', () => ({ record: vi.fn() }));\n",
721 );
722 assert_eq!(found.len(), 1);
723 assert!(found[0].message.contains("../src/ledger"));
724 }
725
726 #[test]
727 fn flags_domock_of_relative_module() {
728 let found = violations("a.test.mts", "vi.doMock('./mailer');\n");
729 assert_eq!(found.len(), 1);
730 }
731
732 #[test]
733 fn allows_mock_of_third_party_and_builtins() {
734 let found = violations(
735 "a.test.ts",
736 "vi.mock('stripe');\nvi.mock('node:fs');\nvi.mock('fs/promises');\nvi.mock('@scope/pkg');\n",
737 );
738 assert!(found.is_empty(), "got: {found:?}");
739 }
740
741 #[test]
742 fn ignores_non_vi_and_non_mock_calls() {
743 let found = violations(
746 "a.test.ts",
747 "describe('s', () => {});\nvi.fn();\nexpect(1).toBe(1);\nother.mock('./x');\n",
748 );
749 assert!(found.is_empty(), "got: {found:?}");
750 }
751
752 #[test]
753 fn ignores_dynamic_mock_target() {
754 let found = violations("a.test.ts", "const m = './x';\nvi.mock(m);\n");
756 assert!(found.is_empty(), "got: {found:?}");
757 }
758
759 #[test]
760 fn finds_mocks_nested_in_blocks() {
761 let found = violations(
764 "a.test.ts",
765 "describe('s', () => {\n vi.mock('./inner');\n});\n",
766 );
767 assert_eq!(found.len(), 1);
768 assert_eq!(found[0].line, 2);
769 }
770
771 #[test]
772 fn parse_error_is_reported() {
773 let err = integration_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
774 assert!(err.to_string().contains("parsing"), "got: {err}");
775 }
776
777 #[test]
778 fn unsupported_extension_is_reported() {
779 let err = integration_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n")
780 .unwrap_err();
781 assert!(err.to_string().contains("unsupported"), "got: {err}");
782 }
783}