Skip to main content

testing_conventions/
ts.rs

1//! TypeScript isolation analysis, parsed with `oxc`.
2//!
3//! This is the TypeScript counterpart to the Python [`crate::lint`] module. In
4//! the *integration direction*, an integration test runs first-party code for
5//! real, so it may mock third-party packages and Node built-ins but **never** a
6//! 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 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::{
20    Argument, CallExpression, Declaration, Expression, ImportDeclaration, ImportOrExportKind,
21    Statement,
22};
23use oxc::ast_visit::{walk, Visit};
24use oxc::parser::Parser;
25use oxc::span::{SourceType, Span};
26
27use crate::lint::Violation;
28
29/// Where a module specifier resolves, for isolation purposes.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Origin {
32    /// A relative or absolute path (`./x`, `../x`, `/abs`) — first-party code.
33    FirstParty,
34    /// A Node.js built-in (`node:fs`, `fs`, `fs/promises`, `path`, …).
35    Builtin,
36    /// Any other bare specifier — a third-party package (`lodash`, `@scope/x`).
37    ThirdParty,
38}
39
40/// Classify a module specifier as first-party, Node-builtin, or third-party.
41///
42/// Deterministic and resolution-free — the bright-line rule the README's
43/// isolation checks rest on:
44/// - a **relative or absolute** path (`./`, `../`, `/`) is first-party;
45/// - a `node:`-prefixed specifier, or one whose first path segment is a known
46///   Node built-in (so `fs` and `fs/promises` both match), is a built-in;
47/// - every other (bare) specifier is a third-party package.
48pub fn classify(specifier: &str) -> Origin {
49    if specifier.starts_with('.') || specifier.starts_with('/') {
50        return Origin::FirstParty;
51    }
52    if specifier.starts_with("node:") || is_node_builtin(specifier) {
53        return Origin::Builtin;
54    }
55    Origin::ThirdParty
56}
57
58/// `true` when `specifier`'s first path segment is a Node.js built-in module —
59/// so a subpath export like `fs/promises` matches on its `fs` head.
60fn is_node_builtin(specifier: &str) -> bool {
61    let head = specifier.split('/').next().unwrap_or(specifier);
62    NODE_BUILTINS.contains(&head)
63}
64
65/// The Node.js built-in module names (the stable set). The explicit `node:`
66/// prefix is handled separately in [`classify`], so future built-ins stay
67/// recognized when written `node:<name>`.
68const NODE_BUILTINS: &[&str] = &[
69    "assert",
70    "async_hooks",
71    "buffer",
72    "child_process",
73    "cluster",
74    "console",
75    "constants",
76    "crypto",
77    "dgram",
78    "diagnostics_channel",
79    "dns",
80    "domain",
81    "events",
82    "fs",
83    "http",
84    "http2",
85    "https",
86    "inspector",
87    "module",
88    "net",
89    "os",
90    "path",
91    "perf_hooks",
92    "process",
93    "punycode",
94    "querystring",
95    "readline",
96    "repl",
97    "stream",
98    "string_decoder",
99    "sys",
100    "timers",
101    "tls",
102    "trace_events",
103    "tty",
104    "url",
105    "util",
106    "v8",
107    "vm",
108    "wasi",
109    "worker_threads",
110    "zlib",
111];
112
113/// Scan the TypeScript test files under `root` and return every
114/// integration-isolation violation, sorted by `(file, line)` for deterministic
115/// output.
116///
117/// A *TypeScript test file* is `*.test.{ts,tsx,mts,cts}`. Each is parsed and
118/// walked; a file that cannot be read or parsed is an error.
119pub fn find_integration_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
120    let root = root.as_ref();
121    let mut files = Vec::new();
122    collect_ts_test_files(root, &mut files)?;
123    files.sort();
124
125    let mut violations = Vec::new();
126    for file in &files {
127        let source = std::fs::read_to_string(file)
128            .with_context(|| format!("reading test file `{}`", file.display()))?;
129        violations.extend(integration_violations_in(file, &source)?);
130    }
131
132    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
133    Ok(violations)
134}
135
136/// Message for a test file under `<package root>/tests/` outside a standard
137/// suite tier.
138const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
139     a suite lives in `tests/integration/` or `tests/e2e/`";
140
141/// Scan `package_root`'s suite tiers and return every integration-isolation
142/// violation, sorted by `(file, line)` for deterministic output.
143///
144/// The subjects are the standard suite directories — `tests/integration/` and
145/// `tests/e2e/`, both of which run first-party code for real and so are held to
146/// the integration rules ([`find_integration_violations`]). A test file under
147/// `<package root>/tests/` outside a standard tier is flagged as `unknown-tier`:
148/// the layout is part of the standard, so a suite the scan would silently miss
149/// is an error instead.
150pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
151    let tests = package_root.join("tests");
152    let mut violations = Vec::new();
153    let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
154    for tier in &tiers {
155        if tier.is_dir() {
156            violations.extend(find_integration_violations(tier)?);
157        }
158    }
159    if tests.is_dir() {
160        let mut strays = Vec::new();
161        collect_ts_test_files(&tests, &mut strays)?;
162        strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
163        for file in strays {
164            violations.push(Violation {
165                file,
166                line: 1,
167                rule: "unknown-tier",
168                message: UNKNOWN_TIER_MSG.to_string(),
169            });
170        }
171    }
172    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
173    Ok(violations)
174}
175
176/// Scan the unit test files under `root` and return every isolation violation —
177/// a runtime import that isn't `vi.mock()`-ed — sorted by `(file, line)`.
178/// The TypeScript arm of `unit lint`
179/// ([`crate::isolation::Language::TypeScript`]).
180///
181/// A *TypeScript unit test* is `*.test.{ts,tsx,mts,cts}`. Each is parsed and
182/// walked; a file that cannot be read or parsed is an error.
183pub fn find_unit_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
184    let root = root.as_ref();
185    let mut files = Vec::new();
186    collect_ts_test_files(root, &mut files)?;
187    // `<package root>/tests/` belongs to the suite tiers (integration / e2e run
188    // first-party code for real), so its files are never unit subjects.
189    if let Some(tests) = crate::tiers::suite_tests_dir(root, "package.json") {
190        files.retain(|file| !file.starts_with(&tests));
191    }
192    files.sort();
193
194    let mut violations = Vec::new();
195    for file in &files {
196        let source = std::fs::read_to_string(file)
197            .with_context(|| format!("reading test file `{}`", file.display()))?;
198        violations.extend(unit_violations_in(file, &source)?);
199    }
200
201    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
202    Ok(violations)
203}
204
205/// Parse one unit test file and collect its `unmocked-collaborator` violations:
206/// every runtime import that isn't the unit under test, the test runner, or
207/// `vi.mock()`-ed.
208fn unit_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
209    let allocator = Allocator::default();
210    let source_type = SourceType::from_path(file).map_err(|err| {
211        anyhow!(
212            "unsupported TypeScript extension `{}`: {err}",
213            file.display()
214        )
215    })?;
216    let ret = Parser::new(&allocator, source, source_type).parse();
217    if ret.panicked || !ret.diagnostics.is_empty() {
218        let detail = ret
219            .diagnostics
220            .iter()
221            .map(|d| d.to_string())
222            .collect::<Vec<_>>()
223            .join("; ");
224        bail!("parsing `{}` failed: {detail}", file.display());
225    }
226
227    let mut collector = UnitCollector {
228        source,
229        imports: Vec::new(),
230        mocked: BTreeSet::new(),
231        untyped: Vec::new(),
232    };
233    collector.visit_program(&ret.program);
234
235    let unit = unit_under_test_specifier(file);
236    // Vitest resolves `./formatter` and `./formatter.js` to the same module, so a mock
237    // matches its import by resolved module — the extension may differ between the two.
238    // Normalize the module extension on both sides of the comparison.
239    let mocked_modules: BTreeSet<&str> = collector
240        .mocked
241        .iter()
242        .map(|m| strip_module_ext(m))
243        .collect();
244    let mut violations = Vec::new();
245    for (spec, line) in &collector.imports {
246        if is_unit_under_test(spec, &unit)
247            || is_test_runner(spec)
248            || mocked_modules.contains(strip_module_ext(spec))
249        {
250            continue;
251        }
252        violations.push(Violation {
253            file: file.to_path_buf(),
254            line: *line,
255            rule: "unmocked-collaborator",
256            message: format!(
257                "unit test imports `{spec}` without mocking it — a unit test isolates the \
258                 unit under test, so every collaborator must be `vi.mock()`-ed"
259            ),
260        });
261    }
262    for (spec, line) in &collector.untyped {
263        violations.push(Violation {
264            file: file.to_path_buf(),
265            line: *line,
266            rule: "untyped-mock",
267            message: format!(
268                "`vi.mock('{spec}', …)` has an untyped factory — anchor it to the real module \
269                 with `vi.importActual<typeof import('{spec}')>()` so the double can't drift \
270                 from the source"
271            ),
272        });
273    }
274    violations.sort_by_key(|v| v.line);
275    Ok(violations)
276}
277
278/// Collects a unit test's runtime imports (specifier + line), its `vi.mock()`
279/// targets, and any `vi.mock()` with an untyped factory in one AST pass.
280struct UnitCollector<'s> {
281    source: &'s str,
282    imports: Vec<(String, usize)>,
283    mocked: BTreeSet<String>,
284    untyped: Vec<(String, usize)>,
285}
286
287impl<'a> Visit<'a> for UnitCollector<'_> {
288    fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
289        // `import type …` is erased at compile time — not a runtime dependency.
290        if matches!(decl.import_kind, ImportOrExportKind::Type) {
291            return;
292        }
293        self.imports.push((
294            decl.source.value.to_string(),
295            line_of(self.source, decl.span.start),
296        ));
297    }
298
299    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
300        if let Some(spec) = vi_mock_target(call) {
301            // A factory *function* (2nd arg) that doesn't anchor to the real
302            // module's type via `vi.importActual<…>()` lets the double drift from
303            // the source. The 2nd arg is only a factory when it's a function:
304            // a bare `vi.mock(spec)` is an auto-mock (typed from the real module),
305            // and so is the options form `vi.mock(spec, { spy: true })`, which spies
306            // on the real module and can't drift — neither is flagged.
307            if let Some(factory) = call.arguments.get(1) {
308                if is_factory(factory) && !factory_is_typed(factory) {
309                    self.untyped
310                        .push((spec.clone(), line_of(self.source, call.span.start)));
311                }
312            }
313            self.mocked.insert(spec);
314        }
315        walk::walk_call_expression(self, call);
316    }
317}
318
319/// The unit-under-test specifier for a test file: `pkg/widget.test.ts` → `./widget`.
320fn unit_under_test_specifier(file: &Path) -> String {
321    let name = file
322        .file_name()
323        .and_then(|n| n.to_str())
324        .unwrap_or_default();
325    let stem = name.split(".test.").next().unwrap_or(name);
326    format!("./{stem}")
327}
328
329/// `true` when `spec` resolves to the unit under test, ignoring an explicit
330/// module extension (`./widget` and `./widget.js` both match `./widget`).
331fn is_unit_under_test(spec: &str, unit: &str) -> bool {
332    strip_module_ext(spec) == unit
333}
334
335/// `spec` without a trailing JS/TS module extension.
336fn strip_module_ext(spec: &str) -> &str {
337    for ext in [".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx"] {
338        if let Some(base) = spec.strip_suffix(ext) {
339            return base;
340        }
341    }
342    spec
343}
344
345/// `true` for the Vitest test runner itself (`vitest`, `vitest/*`, `@vitest/*`) —
346/// the harness, never a collaborator to mock.
347fn is_test_runner(spec: &str) -> bool {
348    spec == "vitest" || spec.starts_with("vitest/") || spec.starts_with("@vitest/")
349}
350
351/// `true` when a `vi.mock` second argument is a factory *function* — an arrow or
352/// `function` expression. Vitest's other 2nd-arg form is an options object
353/// (`vi.mock(spec, { spy: true })`), which is **not** a factory: it spies on the
354/// real module, so the double can't drift, exactly like a bare `vi.mock(spec)`
355/// auto-mock. Only a function factory can return a hand-built double that
356/// needs a `vi.importActual<…>` type anchor.
357fn is_factory(arg: &Argument) -> bool {
358    matches!(
359        arg.as_expression(),
360        Some(Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_))
361    )
362}
363
364/// `true` when a `vi.mock` factory anchors to the real module's type — i.e. its
365/// body contains a `vi.importActual<…>()` call carrying a type argument.
366/// The conventional form is `vi.importActual<typeof import('<spec>')>()`.
367fn factory_is_typed(factory: &Argument) -> bool {
368    let mut finder = ImportActualFinder { typed: false };
369    finder.visit_argument(factory);
370    finder.typed
371}
372
373/// Walks a `vi.mock` factory looking for a typed `vi.importActual<…>()` call.
374struct ImportActualFinder {
375    typed: bool,
376}
377
378impl<'a> Visit<'a> for ImportActualFinder {
379    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
380        if is_typed_import_actual(call) {
381            self.typed = true;
382        }
383        walk::walk_call_expression(self, call);
384    }
385}
386
387/// `true` for `vi.importActual<…>(…)` — a call to `vi.importActual` that carries a
388/// type argument (an untyped `vi.importActual(…)` returns `unknown`).
389fn is_typed_import_actual(call: &CallExpression) -> bool {
390    let Expression::StaticMemberExpression(member) = &call.callee else {
391        return false;
392    };
393    let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
394    is_vi && member.property.name.as_str() == "importActual" && call.type_arguments.is_some()
395}
396
397/// Parse one TypeScript test file and collect its `no-first-party-mock`
398/// violations. A parse failure is an error — a malformed test file is never a
399/// silent pass.
400fn integration_violations_in(file: &Path, source: &str) -> Result<Vec<Violation>> {
401    let allocator = Allocator::default();
402    let source_type = SourceType::from_path(file).map_err(|err| {
403        anyhow!(
404            "unsupported TypeScript extension `{}`: {err}",
405            file.display()
406        )
407    })?;
408    let ret = Parser::new(&allocator, source, source_type).parse();
409    if ret.panicked || !ret.diagnostics.is_empty() {
410        let detail = ret
411            .diagnostics
412            .iter()
413            .map(|d| d.to_string())
414            .collect::<Vec<_>>()
415            .join("; ");
416        bail!("parsing `{}` failed: {detail}", file.display());
417    }
418
419    let mut visitor = MockVisitor {
420        file,
421        source,
422        violations: Vec::new(),
423    };
424    visitor.visit_program(&ret.program);
425    Ok(visitor.violations)
426}
427
428/// Walks one parsed test file, flagging every `vi.mock()` / `vi.doMock()` of a
429/// first-party module.
430struct MockVisitor<'s> {
431    file: &'s Path,
432    source: &'s str,
433    violations: Vec<Violation>,
434}
435
436impl MockVisitor<'_> {
437    fn report(&mut self, span: Span, spec: &str) {
438        self.violations.push(Violation {
439            file: self.file.to_path_buf(),
440            line: line_of(self.source, span.start),
441            rule: "no-first-party-mock",
442            message: format!(
443                "integration test mocks first-party module `{spec}` — an integration test \
444                 runs first-party code for real; only third-party packages and Node built-ins \
445                 may be mocked"
446            ),
447        });
448    }
449}
450
451impl<'a> Visit<'a> for MockVisitor<'_> {
452    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
453        if let Some(spec) = vi_mock_target(call) {
454            if classify(&spec) == Origin::FirstParty {
455                self.report(call.span, &spec);
456            }
457        }
458        walk::walk_call_expression(self, call);
459    }
460}
461
462/// If `call` is `vi.mock("spec", …)` or `vi.doMock("spec", …)` with a string
463/// literal first argument, return that specifier; otherwise `None`.
464///
465/// A non-literal target (`vi.mock(name)`) can't be classified deterministically,
466/// so it is skipped rather than guessed at.
467fn vi_mock_target(call: &CallExpression) -> Option<String> {
468    let Expression::StaticMemberExpression(member) = &call.callee else {
469        return None;
470    };
471    let is_vi = matches!(&member.object, Expression::Identifier(id) if id.name == "vi");
472    if !is_vi {
473        return None;
474    }
475    let method = member.property.name.as_str();
476    if method != "mock" && method != "doMock" {
477        return None;
478    }
479    match call.arguments.first() {
480        Some(Argument::StringLiteral(lit)) => Some(lit.value.to_string()),
481        _ => None,
482    }
483}
484
485/// The 1-based line containing byte `offset` in `source`.
486fn line_of(source: &str, offset: u32) -> usize {
487    let offset = (offset as usize).min(source.len());
488    source.as_bytes()[..offset]
489        .iter()
490        .filter(|&&byte| byte == b'\n')
491        .count()
492        + 1
493}
494
495/// `true` when `source` (a module at `path`) is **type-only** — its top level is
496/// exclusively type declarations, so it compiles to zero runtime JavaScript and has no
497/// behavior to unit-test, exactly like a `.d.ts` file.
498///
499/// The colocated-test presence rule reads this to skip such a module as a non-subject
500/// ([`crate::colocated_test`]). A statement counts as type-only when it is a `type` alias, an
501/// `interface`, an `import type` / `export type`, or an `export` whose declaration is itself a
502/// type — an `enum` or `namespace` emits runtime code and does **not** qualify, and any runtime
503/// statement (a `const`, a function, a class, a value `import`/`export`, a bare expression)
504/// makes the module a subject. An empty module is not type-only — an empty/comment-only file is
505/// already a non-subject on its own ([`crate::colocated_test::Language::has_code`]), so this
506/// answers only for a module that actually declares something.
507///
508/// A file that fails to parse is conservatively **not** type-only: the presence rule then keeps
509/// it as a subject rather than skip a module it couldn't read.
510pub fn is_type_only_module(source: &str, path: &Path) -> bool {
511    let allocator = Allocator::default();
512    let Ok(source_type) = SourceType::from_path(path) else {
513        return false;
514    };
515    let ret = Parser::new(&allocator, source, source_type).parse();
516    if ret.panicked || !ret.diagnostics.is_empty() {
517        return false;
518    }
519    let body = &ret.program.body;
520    !body.is_empty() && body.iter().all(is_type_only_statement)
521}
522
523/// `true` when a top-level statement contributes no runtime code — see [`is_type_only_module`].
524fn is_type_only_statement(statement: &Statement) -> bool {
525    match statement {
526        Statement::TSTypeAliasDeclaration(_) | Statement::TSInterfaceDeclaration(_) => true,
527        Statement::ImportDeclaration(decl) => decl.import_kind.is_type(),
528        Statement::ExportAllDeclaration(decl) => decl.export_kind.is_type(),
529        Statement::ExportNamedDeclaration(decl) => {
530            // `export type { X }` (kind Type, no declaration), or an inline `export type Foo` /
531            // `export interface Foo` (a type declaration). `export { value }` / `export const …`
532            // is runtime.
533            if decl.export_kind.is_type() {
534                return true;
535            }
536            match &decl.declaration {
537                Some(Declaration::TSTypeAliasDeclaration(_))
538                | Some(Declaration::TSInterfaceDeclaration(_)) => true,
539                Some(_) => false,
540                // A specifier-only `export { … }` with no `type` kind re-exports runtime bindings.
541                None => false,
542            }
543        }
544        _ => false,
545    }
546}
547
548/// Recursively collect every TypeScript test file under `dir` into `out`.
549fn collect_ts_test_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
550    let entries =
551        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
552    for entry in entries {
553        let path = entry
554            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
555            .path();
556        if path.is_dir() {
557            collect_ts_test_files(&path, out)?;
558        } else if is_ts_test_file(&path) {
559            out.push(path);
560        }
561    }
562    Ok(())
563}
564
565/// `true` for a TypeScript test file: `*.test.{ts,tsx,mts,cts}`.
566fn is_ts_test_file(path: &Path) -> bool {
567    let name = path
568        .file_name()
569        .and_then(|n| n.to_str())
570        .unwrap_or_default();
571    name.ends_with(".test.ts")
572        || name.ends_with(".test.tsx")
573        || name.ends_with(".test.mts")
574        || name.ends_with(".test.cts")
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    /// Parse `source` as `name` and return its integration violations.
582    fn violations(name: &str, source: &str) -> Vec<Violation> {
583        integration_violations_in(Path::new(name), source).expect("source should parse")
584    }
585
586    /// Parse `source` as `name` and return its unit-isolation violations.
587    fn unit_violations(name: &str, source: &str) -> Vec<Violation> {
588        unit_violations_in(Path::new(name), source).expect("source should parse")
589    }
590
591    #[test]
592    fn unit_flags_unmocked_first_party_and_external() {
593        let found = unit_violations(
594            "widget.test.ts",
595            "import { makeWidget } from './widget';\n\
596             import { format } from './formatter';\n\
597             import { chunk } from 'lodash';\n",
598        );
599        // The unit under test (`./widget`) is not a collaborator; the other two are
600        // imported but not mocked.
601        assert_eq!(found.len(), 2, "got: {found:?}");
602        assert!(found.iter().all(|v| v.rule == "unmocked-collaborator"));
603        assert!(found.iter().any(|v| v.message.contains("./formatter")));
604        assert!(found.iter().any(|v| v.message.contains("lodash")));
605    }
606
607    #[test]
608    fn unit_mocked_collaborator_is_clean() {
609        let found = unit_violations(
610            "widget.test.ts",
611            "import { format } from './formatter';\nvi.mock('./formatter');\n",
612        );
613        assert!(found.is_empty(), "got: {found:?}");
614    }
615
616    #[test]
617    fn unit_under_test_and_runner_are_not_flagged() {
618        let found = unit_violations(
619            "widget.test.ts",
620            "import { vi } from 'vitest';\n\
621             import { makeWidget } from './widget.js';\n",
622        );
623        // `vitest` is the runner; `./widget.js` is the unit under test (extension ignored).
624        assert!(found.is_empty(), "got: {found:?}");
625    }
626
627    /// Whether `source` (named `foo.ts`) is a type-only module.
628    fn type_only(source: &str) -> bool {
629        is_type_only_module(source, Path::new("foo.ts"))
630    }
631
632    #[test]
633    fn type_only_recognizes_a_pure_type_module() {
634        assert!(type_only(
635            "export interface Shape { kind: string }\nexport type Id = string;\n"
636        ));
637        assert!(type_only(
638            "import type { Shape } from './shape';\nexport type Wrapped = Shape;\n"
639        ));
640        assert!(type_only("export type { Id } from './shape';\n"));
641        assert!(type_only(
642            "type Local = number;\ninterface Bare { x: Local }\n"
643        ));
644        assert!(type_only("export type * from './shapes';\n"));
645    }
646
647    #[test]
648    fn type_only_rejects_any_runtime_construct() {
649        // A runtime `const` / function / class makes the module a subject.
650        assert!(!type_only(
651            "export type T = number;\nexport const version: T = 1;\n"
652        ));
653        assert!(!type_only(
654            "export interface I {}\nexport function make(): I { return {}; }\n"
655        ));
656        // A value `import` / `export` is runtime, even next to a type.
657        assert!(!type_only(
658            "import { x } from './x';\nexport type T = typeof x;\n"
659        ));
660        assert!(!type_only("export * from './widget';\n"));
661        assert!(!type_only("export { thing } from './thing';\n"));
662        // `enum` and `namespace` emit runtime code, so they are not type-only.
663        assert!(!type_only("export enum Color { Red, Green }\n"));
664        assert!(!type_only("export namespace N { export const x = 1; }\n"));
665    }
666
667    #[test]
668    fn type_only_is_false_for_empty_or_unparsable() {
669        // An empty/comment-only file is a non-subject on its own account, not via this path.
670        assert!(!type_only(""));
671        assert!(!type_only("// just a comment\n"));
672        // A parse failure is conservatively not type-only — the module stays a subject.
673        assert!(!type_only("export type T = ;;;\nconst {{{ = \n"));
674    }
675
676    #[test]
677    fn unit_type_only_import_is_not_flagged() {
678        let found = unit_violations(
679            "widget.test.ts",
680            "import type { Opts } from './opts';\nimport { x } from './x';\nvi.mock('./x');\n",
681        );
682        assert!(found.is_empty(), "got: {found:?}");
683    }
684
685    #[test]
686    fn unit_under_test_specifier_strips_test_suffix() {
687        assert_eq!(
688            unit_under_test_specifier(Path::new("pkg/widget.test.ts")),
689            "./widget"
690        );
691        assert_eq!(
692            unit_under_test_specifier(Path::new("button.test.tsx")),
693            "./button"
694        );
695    }
696
697    #[test]
698    fn strip_module_ext_drops_known_extensions_only() {
699        assert_eq!(strip_module_ext("./widget.js"), "./widget");
700        assert_eq!(strip_module_ext("./widget.mts"), "./widget");
701        assert_eq!(strip_module_ext("./widget"), "./widget");
702        assert_eq!(strip_module_ext("lodash"), "lodash");
703    }
704
705    #[test]
706    fn recognizes_the_test_runner() {
707        assert!(is_test_runner("vitest"));
708        assert!(is_test_runner("vitest/config"));
709        assert!(is_test_runner("@vitest/spy"));
710        assert!(!is_test_runner("./vitest-helpers"));
711        assert!(!is_test_runner("lodash"));
712    }
713
714    #[test]
715    fn unit_flags_untyped_factory_mock() {
716        let found = unit_violations(
717            "widget.test.ts",
718            "import { x } from './x';\nvi.mock('./x', () => ({ x: vi.fn() }));\n",
719        );
720        // Mocked, so not an `unmocked-collaborator`; but the factory has no
721        // `vi.importActual<…>` anchor.
722        assert_eq!(found.len(), 1, "got: {found:?}");
723        assert_eq!(found[0].rule, "untyped-mock");
724        assert!(found[0].message.contains("./x"));
725    }
726
727    #[test]
728    fn unit_typed_factory_mock_is_clean() {
729        let found = unit_violations(
730            "widget.test.ts",
731            "import { x } from './x';\n\
732             vi.mock('./x', async () => {\n\
733             \x20 const actual = await vi.importActual<typeof import('./x')>('./x');\n\
734             \x20 return { ...actual, x: vi.fn() };\n\
735             });\n",
736        );
737        assert!(found.is_empty(), "got: {found:?}");
738    }
739
740    #[test]
741    fn unit_options_object_mock_is_not_a_factory() {
742        // Vitest's options form `vi.mock(spec, { spy: true })` is not a factory —
743        // it spies on the real module (can't drift), like a bare auto-mock — so it
744        // must not be flagged `untyped-mock`.
745        let found = unit_violations(
746            "widget.test.ts",
747            "import { x } from './x';\nvi.mock('./x', { spy: true });\n",
748        );
749        assert!(found.is_empty(), "got: {found:?}");
750    }
751
752    #[test]
753    fn unit_untyped_import_actual_is_still_untyped() {
754        // `vi.importActual` without a type argument returns `unknown` — not a type anchor.
755        let found = unit_violations(
756            "widget.test.ts",
757            "import { x } from './x';\n\
758             vi.mock('./x', async () => {\n\
759             \x20 const actual = await vi.importActual('./x');\n\
760             \x20 return { ...(actual as object), x: vi.fn() };\n\
761             });\n",
762        );
763        assert_eq!(found.len(), 1, "got: {found:?}");
764        assert_eq!(found[0].rule, "untyped-mock");
765    }
766
767    #[test]
768    fn classify_relative_is_first_party() {
769        assert_eq!(classify("./service"), Origin::FirstParty);
770        assert_eq!(classify("../pkg/util"), Origin::FirstParty);
771        assert_eq!(classify("/abs/path"), Origin::FirstParty);
772    }
773
774    #[test]
775    fn classify_node_builtins() {
776        assert_eq!(classify("fs"), Origin::Builtin);
777        assert_eq!(classify("node:fs"), Origin::Builtin);
778        assert_eq!(classify("fs/promises"), Origin::Builtin);
779        assert_eq!(classify("node:test"), Origin::Builtin);
780        assert_eq!(classify("child_process"), Origin::Builtin);
781        assert_eq!(classify("node:some-future-builtin"), Origin::Builtin);
782    }
783
784    #[test]
785    fn classify_third_party() {
786        assert_eq!(classify("lodash"), Origin::ThirdParty);
787        assert_eq!(classify("@scope/pkg"), Origin::ThirdParty);
788        assert_eq!(classify("stripe/lib/client"), Origin::ThirdParty);
789        // A bare `test` is too ambiguous to assume the built-in; only `node:test`
790        // is treated as a built-in.
791        assert_eq!(classify("test"), Origin::ThirdParty);
792    }
793
794    #[test]
795    fn recognizes_ts_test_files() {
796        assert!(is_ts_test_file(Path::new("widget.test.ts")));
797        assert!(is_ts_test_file(Path::new("pkg/button.test.tsx")));
798        assert!(is_ts_test_file(Path::new("service.test.mts")));
799        assert!(is_ts_test_file(Path::new("legacy.test.cts")));
800        assert!(!is_ts_test_file(Path::new("widget.ts")));
801        assert!(!is_ts_test_file(Path::new("types.d.ts")));
802        assert!(!is_ts_test_file(Path::new("README.md")));
803    }
804
805    #[test]
806    fn line_of_counts_newlines() {
807        let src = "a\nb\nc\n";
808        assert_eq!(line_of(src, 0), 1);
809        assert_eq!(line_of(src, 2), 2);
810        assert_eq!(line_of(src, 4), 3);
811    }
812
813    #[test]
814    fn flags_mock_of_relative_module() {
815        let found = violations("a.test.ts", "vi.mock('./service');\n");
816        assert_eq!(found.len(), 1);
817        assert_eq!(found[0].rule, "no-first-party-mock");
818        assert_eq!(found[0].line, 1);
819    }
820
821    #[test]
822    fn flags_mock_with_factory_and_parent_path() {
823        let found = violations(
824            "a.test.ts",
825            "import { x } from './x';\nvi.mock('../src/ledger', () => ({ record: vi.fn() }));\n",
826        );
827        assert_eq!(found.len(), 1);
828        assert!(found[0].message.contains("../src/ledger"));
829    }
830
831    #[test]
832    fn flags_domock_of_relative_module() {
833        let found = violations("a.test.mts", "vi.doMock('./mailer');\n");
834        assert_eq!(found.len(), 1);
835    }
836
837    #[test]
838    fn allows_mock_of_third_party_and_builtins() {
839        let found = violations(
840            "a.test.ts",
841            "vi.mock('stripe');\nvi.mock('node:fs');\nvi.mock('fs/promises');\nvi.mock('@scope/pkg');\n",
842        );
843        assert!(found.is_empty(), "got: {found:?}");
844    }
845
846    #[test]
847    fn ignores_non_vi_and_non_mock_calls() {
848        // `describe(...)` (plain call), `vi.fn()` (vi, not mock), and a method
849        // call whose receiver isn't `vi` must all be left alone.
850        let found = violations(
851            "a.test.ts",
852            "describe('s', () => {});\nvi.fn();\nexpect(1).toBe(1);\nother.mock('./x');\n",
853        );
854        assert!(found.is_empty(), "got: {found:?}");
855    }
856
857    #[test]
858    fn ignores_dynamic_mock_target() {
859        // A non-literal specifier can't be classified deterministically.
860        let found = violations("a.test.ts", "const m = './x';\nvi.mock(m);\n");
861        assert!(found.is_empty(), "got: {found:?}");
862    }
863
864    #[test]
865    fn finds_mocks_nested_in_blocks() {
866        // `vi.mock` is normally hoisted to the top level, but a nested call is
867        // still reached by the walk.
868        let found = violations(
869            "a.test.ts",
870            "describe('s', () => {\n  vi.mock('./inner');\n});\n",
871        );
872        assert_eq!(found.len(), 1);
873        assert_eq!(found[0].line, 2);
874    }
875
876    #[test]
877    fn parse_error_is_reported() {
878        let err = integration_violations_in(Path::new("bad.test.ts"), "const x = ;\n").unwrap_err();
879        assert!(err.to_string().contains("parsing"), "got: {err}");
880    }
881
882    #[test]
883    fn unsupported_extension_is_reported() {
884        let err = integration_violations_in(Path::new("weird.test.bogus"), "vi.mock('./x');\n")
885            .unwrap_err();
886        assert!(err.to_string().contains("unsupported"), "got: {err}");
887    }
888}