Skip to main content

testing_conventions/
ts.rs

1//! TypeScript isolation analysis (issue #43), parsed with `oxc`.
2//!
3//! This is the TypeScript counterpart to the Python [`crate::lint`] module. The
4//! *integration direction* (#75) lands first: an integration test runs
5//! first-party code for real, so it may mock third-party packages and Node
6//! built-ins but **never** a first-party module.
7//!
8//! Detection is AST-based — each `*.test.{ts,tsx,mts,cts}` file is parsed with
9//! `oxc_parser` and walked for `vi.mock()` / `vi.doMock()` calls whose target
10//! specifier is first-party. The specifier [`classify`]-ication (first-party /
11//! Node-builtin / third-party) is the shared foundation the unit-direction
12//! slices (#76, #77) build on.
13
14use 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/// Where a module specifier resolves, for isolation purposes.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Origin {
29    /// A relative or absolute path (`./x`, `../x`, `/abs`) — first-party code.
30    FirstParty,
31    /// A Node.js built-in (`node:fs`, `fs`, `fs/promises`, `path`, …).
32    Builtin,
33    /// Any other bare specifier — a third-party package (`lodash`, `@scope/x`).
34    ThirdParty,
35}
36
37/// Classify a module specifier as first-party, Node-builtin, or third-party.
38///
39/// Deterministic and resolution-free — the bright-line rule the README's
40/// isolation checks rest on:
41/// - a **relative or absolute** path (`./`, `../`, `/`) is first-party;
42/// - a `node:`-prefixed specifier, or one whose first path segment is a known
43///   Node built-in (so `fs` and `fs/promises` both match), is a built-in;
44/// - every other (bare) specifier is a third-party package.
45pub 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
55/// `true` when `specifier`'s first path segment is a Node.js built-in module —
56/// so a subpath export like `fs/promises` matches on its `fs` head.
57fn is_node_builtin(specifier: &str) -> bool {
58    let head = specifier.split('/').next().unwrap_or(specifier);
59    NODE_BUILTINS.contains(&head)
60}
61
62/// The Node.js built-in module names (the stable set). The explicit `node:`
63/// prefix is handled separately in [`classify`], so future built-ins stay
64/// recognized when written `node:<name>`.
65const 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
110/// Scan the TypeScript test files under `root` and return every
111/// integration-isolation violation, sorted by `(file, line)` for deterministic
112/// output.
113///
114/// A *TypeScript test file* is `*.test.{ts,tsx,mts,cts}`. Each is parsed and
115/// walked; a file that cannot be read or parsed is an error.
116pub 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
133/// Scan the unit test files under `root` and return every isolation violation —
134/// a runtime import that isn't `vi.mock()`-ed (#76) — sorted by `(file, line)`.
135/// The TypeScript arm of `unit isolation`
136/// ([`crate::isolation::Language::TypeScript`]).
137///
138/// A *TypeScript unit test* is `*.test.{ts,tsx,mts,cts}`. Each is parsed and
139/// walked; a file that cannot be read or parsed is an error.
140pub 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
157/// Parse one unit test file and collect its `unmocked-collaborator` violations:
158/// every runtime import that isn't the unit under test, the test runner, or
159/// `vi.mock()`-ed.
160fn 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
208/// Collects a unit test's runtime imports (specifier + line) and its `vi.mock()`
209/// targets in one AST pass.
210struct 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        // `import type …` is erased at compile time — not a runtime dependency.
219        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
236/// The unit-under-test specifier for a test file: `pkg/widget.test.ts` → `./widget`.
237fn 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
246/// `true` when `spec` resolves to the unit under test, ignoring an explicit
247/// module extension (`./widget` and `./widget.js` both match `./widget`).
248fn is_unit_under_test(spec: &str, unit: &str) -> bool {
249    strip_module_ext(spec) == unit
250}
251
252/// `spec` without a trailing JS/TS module extension.
253fn 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
262/// `true` for the Vitest test runner itself (`vitest`, `vitest/*`, `@vitest/*`) —
263/// the harness, never a collaborator to mock.
264fn is_test_runner(spec: &str) -> bool {
265    spec == "vitest" || spec.starts_with("vitest/") || spec.starts_with("@vitest/")
266}
267
268/// Parse one TypeScript test file and collect its `no-first-party-mock`
269/// violations. A parse failure is an error — a malformed test file is never a
270/// silent pass.
271fn 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
299/// Walks one parsed test file, flagging every `vi.mock()` / `vi.doMock()` of a
300/// first-party module.
301struct 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
333/// If `call` is `vi.mock("spec", …)` or `vi.doMock("spec", …)` with a string
334/// literal first argument, return that specifier; otherwise `None`.
335///
336/// A non-literal target (`vi.mock(name)`) can't be classified deterministically,
337/// so it is skipped rather than guessed at.
338fn 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
356/// The 1-based line containing byte `offset` in `source`.
357fn 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
366/// Recursively collect every TypeScript test file under `dir` into `out`.
367fn 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
383/// `true` for a TypeScript test file: `*.test.{ts,tsx,mts,cts}`.
384fn 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    /// Parse `source` as `name` and return its integration violations.
400    fn violations(name: &str, source: &str) -> Vec<Violation> {
401        integration_violations_in(Path::new(name), source).expect("source should parse")
402    }
403
404    /// Parse `source` as `name` and return its unit-isolation violations.
405    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        // The unit under test (`./widget`) is not a collaborator; the other two are
418        // imported but not mocked.
419        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        // `vitest` is the runner; `./widget.js` is the unit under test (extension ignored).
442        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        // A bare `test` is too ambiguous to assume the built-in; only `node:test`
506        // is treated as a built-in.
507        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        // `describe(...)` (plain call), `vi.fn()` (vi, not mock), and a method
565        // call whose receiver isn't `vi` must all be left alone.
566        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        // A non-literal specifier can't be classified deterministically.
576        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        // `vi.mock` is normally hoisted to the top level, but a nested call is
583        // still reached by the walk.
584        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}