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 };
184 collector.visit_program(&ret.program);
185
186 let unit = unit_under_test_specifier(file);
187 let mut violations = Vec::new();
188 for (spec, line) in &collector.imports {
189 if is_unit_under_test(spec, &unit)
190 || is_test_runner(spec)
191 || collector.mocked.contains(spec)
192 {
193 continue;
194 }
195 violations.push(Violation {
196 file: file.to_path_buf(),
197 line: *line,
198 rule: "unmocked-collaborator",
199 message: format!(
200 "unit test imports `{spec}` without mocking it — a unit test isolates the \
201 unit under test, so every collaborator must be `vi.mock()`-ed"
202 ),
203 });
204 }
205 Ok(violations)
206}
207
208struct UnitCollector<'s> {
211 source: &'s str,
212 imports: Vec<(String, usize)>,
213 mocked: BTreeSet<String>,
214}
215
216impl<'a> Visit<'a> for UnitCollector<'_> {
217 fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
218 if matches!(decl.import_kind, ImportOrExportKind::Type) {
220 return;
221 }
222 self.imports.push((
223 decl.source.value.to_string(),
224 line_of(self.source, decl.span.start),
225 ));
226 }
227
228 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
229 if let Some(spec) = vi_mock_target(call) {
230 self.mocked.insert(spec);
231 }
232 walk::walk_call_expression(self, call);
233 }
234}
235
236fn unit_under_test_specifier(file: &Path) -> String {
238 let name = file
239 .file_name()
240 .and_then(|n| n.to_str())
241 .unwrap_or_default();
242 let stem = name.split(".test.").next().unwrap_or(name);
243 format!("./{stem}")
244}
245
246fn is_unit_under_test(spec: &str, unit: &str) -> bool {
249 strip_module_ext(spec) == unit
250}
251
252fn strip_module_ext(spec: &str) -> &str {
254 for ext in [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"] {
255 if let Some(base) = spec.strip_suffix(ext) {
256 return base;
257 }
258 }
259 spec
260}
261
262fn is_test_runner(spec: &str) -> bool {
265 spec == "vitest" || spec.starts_with("vitest/") || spec.starts_with("@vitest/")
266}
267
268fn integration_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
272 let allocator = Allocator::default();
273 let source_type = SourceType::from_path(file).map_err(|err| {
274 anyhow!(
275 "unsupported TypeScript extension `{}`: {err}",
276 file.display()
277 )
278 })?;
279 let ret = Parser::new(&allocator, source, source_type).parse();
280 if ret.panicked || !ret.diagnostics.is_empty() {
281 let detail = ret
282 .diagnostics
283 .iter()
284 .map(|d| d.to_string())
285 .collect::<Vec<_>>()
286 .join("; ");
287 bail!("parsing `{}` failed: {detail}", file.display());
288 }
289
290 let mut visitor = MockVisitor {
291 file,
292 source,
293 violations: Vec::new(),
294 };
295 visitor.visit_program(&ret.program);
296 Ok(visitor.violations)
297}
298
299struct MockVisitor<'s> {
302 file: &'s Path,
303 source: &'s str,
304 violations: Vec<Violation>,
305}
306
307impl MockVisitor<'_> {
308 fn report(&mut self, span: Span, spec: &str) {
309 self.violations.push(Violation {
310 file: self.file.to_path_buf(),
311 line: line_of(self.source, span.start),
312 rule: "no-first-party-mock",
313 message: format!(
314 "integration test mocks first-party module `{spec}` — an integration test \
315 runs first-party code for real; only third-party packages and Node built-ins \
316 may be mocked"
317 ),
318 });
319 }
320}
321
322impl<'a> Visit<'a> for MockVisitor<'_> {
323 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
324 if let Some(spec) = vi_mock_target(call) {
325 if classify(&spec) == Origin::FirstParty {
326 self.report(call.span, &spec);
327 }
328 }
329 walk::walk_call_expression(self, call);
330 }
331}
332
333fn vi_mock_target(call: &CallExpression) -> Option<String> {
339 let Expression::StaticMemberExpression(member) = &call.callee else {
340 return None;
341 };
342 let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
343 if !is_vi {
344 return None;
345 }
346 let method = member.property.name.as_str();
347 if method != "mock" && method != "doMock" {
348 return None;
349 }
350 match call.arguments.first() {
351 Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
352 _ => None,
353 }
354}
355
356fn line_of(source: &str, offset: u32) -> usize {
358 let offset = (offset as usize).min(source.len());
359 source.as_bytes()[..offset]
360 .iter()
361 .filter(|&&byte| byte == b'\n')
362 .count()
363 + 1
364}
365
366fn collect_ts_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
368 let entries =
369 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
370 for entry in entries {
371 let path = entry
372 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
373 .path();
374 if path.is_dir() {
375 collect_ts_test_files(&path, out)?;
376 } else if is_ts_test_file(&path) {
377 out.push(path);
378 }
379 }
380 Ok(())
381}
382
383fn is_ts_test_file(path: &Path) -> bool {
385 let name = path
386 .file_name()
387 .and_then(|n| n.to_str())
388 .unwrap_or_default();
389 name.ends_with(".test.ts")
390 || name.ends_with(".test.tsx")
391 || name.ends_with(".test.mts")
392 || name.ends_with(".test.cts")
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn violations(name: &str, source: &str) -> Vec<Violation> {
401 integration_violations_in(Path::new(name), source).expect("source should parse")
402 }
403
404 fn unit_violations(name: &str, source: &str) -> Vec<Violation> {
406 unit_violations_in(Path::new(name), source).expect("source should parse")
407 }
408
409 #[test]
410 fn unit_flags_unmocked_first_party_and_external() {
411 let found = unit_violations(
412 "widget.test.ts",
413 "import { makeWidget } from './widget';\n\
414 import { format } from './formatter';\n\
415 import { chunk } from 'lodash';\n",
416 );
417 assert_eq!(found.len(), 2, "got: {found:?}");
420 assert!(found.iter().all(|v| v.rule == "unmocked-collaborator"));
421 assert!(found.iter().any(|v| v.message.contains("./formatter")));
422 assert!(found.iter().any(|v| v.message.contains("lodash")));
423 }
424
425 #[test]
426 fn unit_mocked_collaborator_is_clean() {
427 let found = unit_violations(
428 "widget.test.ts",
429 "import { format } from './formatter';\nvi.mock('./formatter');\n",
430 );
431 assert!(found.is_empty(), "got: {found:?}");
432 }
433
434 #[test]
435 fn unit_under_test_and_runner_are_not_flagged() {
436 let found = unit_violations(
437 "widget.test.ts",
438 "import { vi } from 'vitest';\n\
439 import { makeWidget } from './widget.js';\n",
440 );
441 assert!(found.is_empty(), "got: {found:?}");
443 }
444
445 #[test]
446 fn unit_type_only_import_is_not_flagged() {
447 let found = unit_violations(
448 "widget.test.ts",
449 "import type { Opts } from './opts';\nimport { x } from './x';\nvi.mock('./x');\n",
450 );
451 assert!(found.is_empty(), "got: {found:?}");
452 }
453
454 #[test]
455 fn unit_under_test_specifier_strips_test_suffix() {
456 assert_eq!(
457 unit_under_test_specifier(Path::new("pkg/widget.test.ts")),
458 "./widget"
459 );
460 assert_eq!(
461 unit_under_test_specifier(Path::new("button.test.tsx")),
462 "./button"
463 );
464 }
465
466 #[test]
467 fn strip_module_ext_drops_known_extensions_only() {
468 assert_eq!(strip_module_ext("./widget.js"), "./widget");
469 assert_eq!(strip_module_ext("./widget.mts"), "./widget");
470 assert_eq!(strip_module_ext("./widget"), "./widget");
471 assert_eq!(strip_module_ext("lodash"), "lodash");
472 }
473
474 #[test]
475 fn recognizes_the_test_runner() {
476 assert!(is_test_runner("vitest"));
477 assert!(is_test_runner("vitest/config"));
478 assert!(is_test_runner("@vitest/spy"));
479 assert!(!is_test_runner("./vitest-helpers"));
480 assert!(!is_test_runner("lodash"));
481 }
482
483 #[test]
484 fn classify_relative_is_first_party() {
485 assert_eq!(classify("./service"), Origin::FirstParty);
486 assert_eq!(classify("../pkg/util"), Origin::FirstParty);
487 assert_eq!(classify("/abs/path"), Origin::FirstParty);
488 }
489
490 #[test]
491 fn classify_node_builtins() {
492 assert_eq!(classify("fs"), Origin::Builtin);
493 assert_eq!(classify("node:fs"), Origin::Builtin);
494 assert_eq!(classify("fs/promises"), Origin::Builtin);
495 assert_eq!(classify("node:test"), Origin::Builtin);
496 assert_eq!(classify("child_process"), Origin::Builtin);
497 assert_eq!(classify("node:some-future-builtin"), Origin::Builtin);
498 }
499
500 #[test]
501 fn classify_third_party() {
502 assert_eq!(classify("lodash"), Origin::ThirdParty);
503 assert_eq!(classify("@scope/pkg"), Origin::ThirdParty);
504 assert_eq!(classify("stripe/lib/client"), Origin::ThirdParty);
505 assert_eq!(classify("test"), Origin::ThirdParty);
508 }
509
510 #[test]
511 fn recognizes_ts_test_files() {
512 assert!(is_ts_test_file(Path::new("widget.test.ts")));
513 assert!(is_ts_test_file(Path::new("pkg/button.test.tsx")));
514 assert!(is_ts_test_file(Path::new("service.test.mts")));
515 assert!(is_ts_test_file(Path::new("legacy.test.cts")));
516 assert!(!is_ts_test_file(Path::new("widget.ts")));
517 assert!(!is_ts_test_file(Path::new("types.d.ts")));
518 assert!(!is_ts_test_file(Path::new("README.md")));
519 }
520
521 #[test]
522 fn line_of_counts_newlines() {
523 let src = "a\nb\nc\n";
524 assert_eq!(line_of(src, 0), 1);
525 assert_eq!(line_of(src, 2), 2);
526 assert_eq!(line_of(src, 4), 3);
527 }
528
529 #[test]
530 fn flags_mock_of_relative_module() {
531 let found = violations("a.test.ts", "vi.mock('./service');\n");
532 assert_eq!(found.len(), 1);
533 assert_eq!(found[0].rule, "no-first-party-mock");
534 assert_eq!(found[0].line, 1);
535 }
536
537 #[test]
538 fn flags_mock_with_factory_and_parent_path() {
539 let found = violations(
540 "a.test.ts",
541 "import { x } from './x';\nvi.mock('../src/ledger', () => ({ record: vi.fn() }));\n",
542 );
543 assert_eq!(found.len(), 1);
544 assert!(found[0].message.contains("../src/ledger"));
545 }
546
547 #[test]
548 fn flags_domock_of_relative_module() {
549 let found = violations("a.test.mts", "vi.doMock('./mailer');\n");
550 assert_eq!(found.len(), 1);
551 }
552
553 #[test]
554 fn allows_mock_of_third_party_and_builtins() {
555 let found = violations(
556 "a.test.ts",
557 "vi.mock('stripe');\nvi.mock('node:fs');\nvi.mock('fs/promises');\nvi.mock('@scope/pkg');\n",
558 );
559 assert!(found.is_empty(), "got: {found:?}");
560 }
561
562 #[test]
563 fn ignores_non_vi_and_non_mock_calls() {
564 let found = violations(
567 "a.test.ts",
568 "describe('s', () => {});\nvi.fn();\nexpect(1).toBe(1);\nother.mock('./x');\n",
569 );
570 assert!(found.is_empty(), "got: {found:?}");
571 }
572
573 #[test]
574 fn ignores_dynamic_mock_target() {
575 let found = violations("a.test.ts", "const m = './x';\nvi.mock(m);\n");
577 assert!(found.is_empty(), "got: {found:?}");
578 }
579
580 #[test]
581 fn finds_mocks_nested_in_blocks() {
582 let found = violations(
585 "a.test.ts",
586 "describe('s', () => {\n vi.mock('./inner');\n});\n",
587 );
588 assert_eq!(found.len(), 1);
589 assert_eq!(found[0].line, 2);
590 }
591
592 #[test]
593 fn parse_error_is_reported() {
594 let err = integration_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
595 assert!(err.to_string().contains("parsing"), "got: {err}");
596 }
597
598 #[test]
599 fn unsupported_extension_is_reported() {
600 let err = integration_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n")
601 .unwrap_err();
602 assert!(err.to_string().contains("unsupported"), "got: {err}");
603 }
604}