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
133pub fn find_unit_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
141 let root = root.as_ref();
142 let mut files = Vec::new();
143 collect_ts_test_files(root, &mut files)?;
144 files.sort();
145
146 let mut violations = Vec::new();
147 for file in &files {
148 let source = std::fs::read_to_string(file)
149 .with_context(|| format!("reading test file `{}`", file.display()))?;
150 violations.extend(unit_violations_in(file, &source)?);
151 }
152
153 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
154 Ok(violations)
155}
156
157fn unit_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
161 let allocator = Allocator::default();
162 let source_type = SourceType::from_path(file).map_err(|err| {
163 anyhow!(
164 "unsupported TypeScript extension `{}`: {err}",
165 file.display()
166 )
167 })?;
168 let ret = Parser::new(&allocator, source, source_type).parse();
169 if ret.panicked || !ret.diagnostics.is_empty() {
170 let detail = ret
171 .diagnostics
172 .iter()
173 .map(|d| d.to_string())
174 .collect::<Vec<_>>()
175 .join("; ");
176 bail!("parsing `{}` failed: {detail}", file.display());
177 }
178
179 let mut collector = UnitCollector {
180 source,
181 imports: Vec::new(),
182 mocked: BTreeSet::new(),
183 untyped: Vec::new(),
184 };
185 collector.visit_program(&ret.program);
186
187 let unit = unit_under_test_specifier(file);
188 let mocked_modules: BTreeSet<&str> = collector
192 .mocked
193 .iter()
194 .map(|m| strip_module_ext(m))
195 .collect();
196 let mut violations = Vec::new();
197 for (spec, line) in &collector.imports {
198 if is_unit_under_test(spec, &unit)
199 || is_test_runner(spec)
200 || mocked_modules.contains(strip_module_ext(spec))
201 {
202 continue;
203 }
204 violations.push(Violation {
205 file: file.to_path_buf(),
206 line: *line,
207 rule: "unmocked-collaborator",
208 message: format!(
209 "unit test imports `{spec}` without mocking it — a unit test isolates the \
210 unit under test, so every collaborator must be `vi.mock()`-ed"
211 ),
212 });
213 }
214 for (spec, line) in &collector.untyped {
215 violations.push(Violation {
216 file: file.to_path_buf(),
217 line: *line,
218 rule: "untyped-mock",
219 message: format!(
220 "`vi.mock('{spec}', …)` has an untyped factory — anchor it to the real module \
221 with `vi.importActual<typeof import('{spec}')>()` so the double can't drift \
222 from the source"
223 ),
224 });
225 }
226 violations.sort_by_key(|v| v.line);
227 Ok(violations)
228}
229
230struct UnitCollector<'s> {
233 source: &'s str,
234 imports: Vec<(String, usize)>,
235 mocked: BTreeSet<String>,
236 untyped: Vec<(String, usize)>,
237}
238
239impl<'a> Visit<'a> for UnitCollector<'_> {
240 fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
241 if matches!(decl.import_kind, ImportOrExportKind::Type) {
243 return;
244 }
245 self.imports.push((
246 decl.source.value.to_string(),
247 line_of(self.source, decl.span.start),
248 ));
249 }
250
251 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
252 if let Some(spec) = vi_mock_target(call) {
253 if let Some(factory) = call.arguments.get(1) {
260 if is_factory(factory) && !factory_is_typed(factory) {
261 self.untyped
262 .push((spec.clone(), line_of(self.source, call.span.start)));
263 }
264 }
265 self.mocked.insert(spec);
266 }
267 walk::walk_call_expression(self, call);
268 }
269}
270
271fn unit_under_test_specifier(file: &Path) -> String {
273 let name = file
274 .file_name()
275 .and_then(|n| n.to_str())
276 .unwrap_or_default();
277 let stem = name.split(".test.").next().unwrap_or(name);
278 format!("./{stem}")
279}
280
281fn is_unit_under_test(spec: &str, unit: &str) -> bool {
284 strip_module_ext(spec) == unit
285}
286
287fn strip_module_ext(spec: &str) -> &str {
289 for ext in [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"] {
290 if let Some(base) = spec.strip_suffix(ext) {
291 return base;
292 }
293 }
294 spec
295}
296
297fn is_test_runner(spec: &str) -> bool {
300 spec == "vitest" || spec.starts_with("vitest/") || spec.starts_with("@vitest/")
301}
302
303fn is_factory(arg: &Argument) -> bool {
310 matches!(
311 arg.as_expression(),
312 Some(Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_))
313 )
314}
315
316fn factory_is_typed(factory: &Argument) -> bool {
320 let mut finder = ImportActualFinder { typed: false };
321 finder.visit_argument(factory);
322 finder.typed
323}
324
325struct ImportActualFinder {
327 typed: bool,
328}
329
330impl<'a> Visit<'a> for ImportActualFinder {
331 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
332 if is_typed_import_actual(call) {
333 self.typed = true;
334 }
335 walk::walk_call_expression(self, call);
336 }
337}
338
339fn is_typed_import_actual(call: &CallExpression) -> bool {
342 let Expression::StaticMemberExpression(member) = &call.callee else {
343 return false;
344 };
345 let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
346 is_vi && member.property.name.as_str() == "importActual" && call.type_arguments.is_some()
347}
348
349fn integration_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
353 let allocator = Allocator::default();
354 let source_type = SourceType::from_path(file).map_err(|err| {
355 anyhow!(
356 "unsupported TypeScript extension `{}`: {err}",
357 file.display()
358 )
359 })?;
360 let ret = Parser::new(&allocator, source, source_type).parse();
361 if ret.panicked || !ret.diagnostics.is_empty() {
362 let detail = ret
363 .diagnostics
364 .iter()
365 .map(|d| d.to_string())
366 .collect::<Vec<_>>()
367 .join("; ");
368 bail!("parsing `{}` failed: {detail}", file.display());
369 }
370
371 let mut visitor = MockVisitor {
372 file,
373 source,
374 violations: Vec::new(),
375 };
376 visitor.visit_program(&ret.program);
377 Ok(visitor.violations)
378}
379
380struct MockVisitor<'s> {
383 file: &'s Path,
384 source: &'s str,
385 violations: Vec<Violation>,
386}
387
388impl MockVisitor<'_> {
389 fn report(&mut self, span: Span, spec: &str) {
390 self.violations.push(Violation {
391 file: self.file.to_path_buf(),
392 line: line_of(self.source, span.start),
393 rule: "no-first-party-mock",
394 message: format!(
395 "integration test mocks first-party module `{spec}` — an integration test \
396 runs first-party code for real; only third-party packages and Node built-ins \
397 may be mocked"
398 ),
399 });
400 }
401}
402
403impl<'a> Visit<'a> for MockVisitor<'_> {
404 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
405 if let Some(spec) = vi_mock_target(call) {
406 if classify(&spec) == Origin::FirstParty {
407 self.report(call.span, &spec);
408 }
409 }
410 walk::walk_call_expression(self, call);
411 }
412}
413
414fn vi_mock_target(call: &CallExpression) -> Option<String> {
420 let Expression::StaticMemberExpression(member) = &call.callee else {
421 return None;
422 };
423 let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
424 if !is_vi {
425 return None;
426 }
427 let method = member.property.name.as_str();
428 if method != "mock" && method != "doMock" {
429 return None;
430 }
431 match call.arguments.first() {
432 Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
433 _ => None,
434 }
435}
436
437fn line_of(source: &str, offset: u32) -> usize {
439 let offset = (offset as usize).min(source.len());
440 source.as_bytes()[..offset]
441 .iter()
442 .filter(|&&byte| byte == b'\n')
443 .count()
444 + 1
445}
446
447fn collect_ts_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
449 let entries =
450 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
451 for entry in entries {
452 let path = entry
453 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
454 .path();
455 if path.is_dir() {
456 collect_ts_test_files(&path, out)?;
457 } else if is_ts_test_file(&path) {
458 out.push(path);
459 }
460 }
461 Ok(())
462}
463
464fn is_ts_test_file(path: &Path) -> bool {
466 let name = path
467 .file_name()
468 .and_then(|n| n.to_str())
469 .unwrap_or_default();
470 name.ends_with(".test.ts")
471 || name.ends_with(".test.tsx")
472 || name.ends_with(".test.mts")
473 || name.ends_with(".test.cts")
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 fn violations(name: &str, source: &str) -> Vec<Violation> {
482 integration_violations_in(Path::new(name), source).expect("source should parse")
483 }
484
485 fn unit_violations(name: &str, source: &str) -> Vec<Violation> {
487 unit_violations_in(Path::new(name), source).expect("source should parse")
488 }
489
490 #[test]
491 fn unit_flags_unmocked_first_party_and_external() {
492 let found = unit_violations(
493 "widget.test.ts",
494 "import { makeWidget } from './widget';\n\
495 import { format } from './formatter';\n\
496 import { chunk } from 'lodash';\n",
497 );
498 assert_eq!(found.len(), 2, "got: {found:?}");
501 assert!(found.iter().all(|v| v.rule == "unmocked-collaborator"));
502 assert!(found.iter().any(|v| v.message.contains("./formatter")));
503 assert!(found.iter().any(|v| v.message.contains("lodash")));
504 }
505
506 #[test]
507 fn unit_mocked_collaborator_is_clean() {
508 let found = unit_violations(
509 "widget.test.ts",
510 "import { format } from './formatter';\nvi.mock('./formatter');\n",
511 );
512 assert!(found.is_empty(), "got: {found:?}");
513 }
514
515 #[test]
516 fn unit_under_test_and_runner_are_not_flagged() {
517 let found = unit_violations(
518 "widget.test.ts",
519 "import { vi } from 'vitest';\n\
520 import { makeWidget } from './widget.js';\n",
521 );
522 assert!(found.is_empty(), "got: {found:?}");
524 }
525
526 #[test]
527 fn unit_type_only_import_is_not_flagged() {
528 let found = unit_violations(
529 "widget.test.ts",
530 "import type { Opts } from './opts';\nimport { x } from './x';\nvi.mock('./x');\n",
531 );
532 assert!(found.is_empty(), "got: {found:?}");
533 }
534
535 #[test]
536 fn unit_under_test_specifier_strips_test_suffix() {
537 assert_eq!(
538 unit_under_test_specifier(Path::new("pkg/widget.test.ts")),
539 "./widget"
540 );
541 assert_eq!(
542 unit_under_test_specifier(Path::new("button.test.tsx")),
543 "./button"
544 );
545 }
546
547 #[test]
548 fn strip_module_ext_drops_known_extensions_only() {
549 assert_eq!(strip_module_ext("./widget.js"), "./widget");
550 assert_eq!(strip_module_ext("./widget.mts"), "./widget");
551 assert_eq!(strip_module_ext("./widget"), "./widget");
552 assert_eq!(strip_module_ext("lodash"), "lodash");
553 }
554
555 #[test]
556 fn recognizes_the_test_runner() {
557 assert!(is_test_runner("vitest"));
558 assert!(is_test_runner("vitest/config"));
559 assert!(is_test_runner("@vitest/spy"));
560 assert!(!is_test_runner("./vitest-helpers"));
561 assert!(!is_test_runner("lodash"));
562 }
563
564 #[test]
565 fn unit_flags_untyped_factory_mock() {
566 let found = unit_violations(
567 "widget.test.ts",
568 "import { x } from './x';\nvi.mock('./x', () => ({ x: vi.fn() }));\n",
569 );
570 assert_eq!(found.len(), 1, "got: {found:?}");
573 assert_eq!(found[0].rule, "untyped-mock");
574 assert!(found[0].message.contains("./x"));
575 }
576
577 #[test]
578 fn unit_typed_factory_mock_is_clean() {
579 let found = unit_violations(
580 "widget.test.ts",
581 "import { x } from './x';\n\
582 vi.mock('./x', async () => {\n\
583 \x20 const actual = await vi.importActual<typeof import('./x')>('./x');\n\
584 \x20 return { ...actual, x: vi.fn() };\n\
585 });\n",
586 );
587 assert!(found.is_empty(), "got: {found:?}");
588 }
589
590 #[test]
591 fn unit_options_object_mock_is_not_a_factory() {
592 let found = unit_violations(
596 "widget.test.ts",
597 "import { x } from './x';\nvi.mock('./x', { spy: true });\n",
598 );
599 assert!(found.is_empty(), "got: {found:?}");
600 }
601
602 #[test]
603 fn unit_untyped_import_actual_is_still_untyped() {
604 let found = unit_violations(
606 "widget.test.ts",
607 "import { x } from './x';\n\
608 vi.mock('./x', async () => {\n\
609 \x20 const actual = await vi.importActual('./x');\n\
610 \x20 return { ...(actual as object), x: vi.fn() };\n\
611 });\n",
612 );
613 assert_eq!(found.len(), 1, "got: {found:?}");
614 assert_eq!(found[0].rule, "untyped-mock");
615 }
616
617 #[test]
618 fn classify_relative_is_first_party() {
619 assert_eq!(classify("./service"), Origin::FirstParty);
620 assert_eq!(classify("../pkg/util"), Origin::FirstParty);
621 assert_eq!(classify("/abs/path"), Origin::FirstParty);
622 }
623
624 #[test]
625 fn classify_node_builtins() {
626 assert_eq!(classify("fs"), Origin::Builtin);
627 assert_eq!(classify("node:fs"), Origin::Builtin);
628 assert_eq!(classify("fs/promises"), Origin::Builtin);
629 assert_eq!(classify("node:test"), Origin::Builtin);
630 assert_eq!(classify("child_process"), Origin::Builtin);
631 assert_eq!(classify("node:some-future-builtin"), Origin::Builtin);
632 }
633
634 #[test]
635 fn classify_third_party() {
636 assert_eq!(classify("lodash"), Origin::ThirdParty);
637 assert_eq!(classify("@scope/pkg"), Origin::ThirdParty);
638 assert_eq!(classify("stripe/lib/client"), Origin::ThirdParty);
639 assert_eq!(classify("test"), Origin::ThirdParty);
642 }
643
644 #[test]
645 fn recognizes_ts_test_files() {
646 assert!(is_ts_test_file(Path::new("widget.test.ts")));
647 assert!(is_ts_test_file(Path::new("pkg/button.test.tsx")));
648 assert!(is_ts_test_file(Path::new("service.test.mts")));
649 assert!(is_ts_test_file(Path::new("legacy.test.cts")));
650 assert!(!is_ts_test_file(Path::new("widget.ts")));
651 assert!(!is_ts_test_file(Path::new("types.d.ts")));
652 assert!(!is_ts_test_file(Path::new("README.md")));
653 }
654
655 #[test]
656 fn line_of_counts_newlines() {
657 let src = "a\nb\nc\n";
658 assert_eq!(line_of(src, 0), 1);
659 assert_eq!(line_of(src, 2), 2);
660 assert_eq!(line_of(src, 4), 3);
661 }
662
663 #[test]
664 fn flags_mock_of_relative_module() {
665 let found = violations("a.test.ts", "vi.mock('./service');\n");
666 assert_eq!(found.len(), 1);
667 assert_eq!(found[0].rule, "no-first-party-mock");
668 assert_eq!(found[0].line, 1);
669 }
670
671 #[test]
672 fn flags_mock_with_factory_and_parent_path() {
673 let found = violations(
674 "a.test.ts",
675 "import { x } from './x';\nvi.mock('../src/ledger', () => ({ record: vi.fn() }));\n",
676 );
677 assert_eq!(found.len(), 1);
678 assert!(found[0].message.contains("../src/ledger"));
679 }
680
681 #[test]
682 fn flags_domock_of_relative_module() {
683 let found = violations("a.test.mts", "vi.doMock('./mailer');\n");
684 assert_eq!(found.len(), 1);
685 }
686
687 #[test]
688 fn allows_mock_of_third_party_and_builtins() {
689 let found = violations(
690 "a.test.ts",
691 "vi.mock('stripe');\nvi.mock('node:fs');\nvi.mock('fs/promises');\nvi.mock('@scope/pkg');\n",
692 );
693 assert!(found.is_empty(), "got: {found:?}");
694 }
695
696 #[test]
697 fn ignores_non_vi_and_non_mock_calls() {
698 let found = violations(
701 "a.test.ts",
702 "describe('s', () => {});\nvi.fn();\nexpect(1).toBe(1);\nother.mock('./x');\n",
703 );
704 assert!(found.is_empty(), "got: {found:?}");
705 }
706
707 #[test]
708 fn ignores_dynamic_mock_target() {
709 let found = violations("a.test.ts", "const m = './x';\nvi.mock(m);\n");
711 assert!(found.is_empty(), "got: {found:?}");
712 }
713
714 #[test]
715 fn finds_mocks_nested_in_blocks() {
716 let found = violations(
719 "a.test.ts",
720 "describe('s', () => {\n vi.mock('./inner');\n});\n",
721 );
722 assert_eq!(found.len(), 1);
723 assert_eq!(found[0].line, 2);
724 }
725
726 #[test]
727 fn parse_error_is_reported() {
728 let err = integration_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
729 assert!(err.to_string().contains("parsing"), "got: {err}");
730 }
731
732 #[test]
733 fn unsupported_extension_is_reported() {
734 let err = integration_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n")
735 .unwrap_err();
736 assert!(err.to_string().contains("unsupported"), "got: {err}");
737 }
738}