Skip to main content

testing_conventions/
lint.rs

1//! Integration-test lints — the `integration lint`
2//! command.
3//!
4//! A *lint* here is a deterministic style/mechanism check on test code, as
5//! opposed to the structural `colocated-test` / `coverage` rules. This module hosts
6//! the mocking mechanism & style lints; more lints will join them under the
7//! same command.
8//!
9//! Detection is AST-based: each Python test file is parsed with
10//! `rustpython_parser` and the tree is walked with a [`Visitor`].
11//!
12//! Implemented lints:
13//! - **`no-monkeypatch`**: a test/fixture function that declares the
14//!   `monkeypatch` parameter (pytest's fixture). Patch with `unittest.mock`
15//!   wrapped in a `pytest.fixture` instead.
16//! - **`no-inline-patch`**: a `patch(...)` / `patch.object(...)` /
17//!   `patch.dict(...)` call inside a test body — the `with patch(...)` form or a
18//!   bare call. Patches belong in a `pytest.fixture`; a patch *inside* a fixture
19//!   is allowed.
20//! - **`no-environ-mutation`**: direct mutation of `os.environ` —
21//!   `os.environ[...] = …`, `del os.environ[...]`, or a mutating method
22//!   (`update` / `pop` / `setdefault` / `clear` / `popitem`). Set env via
23//!   `patch.dict(os.environ, {...})` instead.
24//! - **`no-constant-patch`**: patching a module-global UPPER_CASE constant,
25//!   e.g. `patch("pkg.config.CACHE_DIR", …)`. Inject config explicitly. Waivable
26//!   per file via the config `exempt` list.
27
28use std::path::{Path, PathBuf};
29
30use anyhow::{anyhow, Context, Result};
31use rustpython_ast::Visitor;
32use rustpython_parser::ast::{
33    self, Arg, Arguments, Constant, Expr, ExprCall, StmtAssign, StmtAsyncFunctionDef,
34    StmtAugAssign, StmtDelete, StmtFunctionDef, StmtIf, StmtImport, StmtImportFrom, WithItem,
35};
36use rustpython_parser::text_size::{TextRange, TextSize};
37use rustpython_parser::Parse;
38
39// `Violation` is shared with the Rust `isolation` lint; it lives in `violation`
40// and is re-exported here so `testing_conventions::lint::Violation` still resolves.
41pub use crate::violation::Violation;
42
43/// Scan the Python test files under `root` and return every lint violation,
44/// sorted by `(file, line)` for deterministic output.
45///
46/// A *Python test file* is `*_test.py` or `conftest.py` (where fixtures live); a
47/// legacy `test_*.py` is ordinary source. Each is parsed and walked. A file
48/// that cannot be read or parsed is an error.
49pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
50    let root = root.as_ref();
51    // The dist's own top-level package, for `no-first-party-patch`. Resolved
52    // once for the whole tree; `None` (no declared package) means that rule flags
53    // nothing.
54    let first_party = first_party_package(root);
55    let mut files = Vec::new();
56    collect_python_files(root, &mut files, is_python_test_file)?;
57    files.sort();
58
59    let mut violations = Vec::new();
60    for file in &files {
61        let source = std::fs::read_to_string(file)
62            .with_context(|| format!("reading test file `{}`", file.display()))?;
63        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
64            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
65        let mut visitor = LintVisitor {
66            file,
67            source: &source,
68            fixture_depth: 0,
69            first_party: first_party.as_deref(),
70            violations: Vec::new(),
71        };
72        for stmt in suite {
73            visitor.visit_stmt(stmt);
74        }
75        violations.append(&mut visitor.violations);
76    }
77
78    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
79    Ok(violations)
80}
81
82/// Message for a test file under `<package root>/tests/` outside a standard
83/// suite tier.
84const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
85     a suite lives in `tests/integration/` or `tests/e2e/`";
86
87/// Scan `package_root`'s suite tiers and return every lint violation, sorted by
88/// `(file, line)` for deterministic output.
89///
90/// The subjects are the standard suite directories — `tests/integration/` and
91/// `tests/e2e/`, both of which run first-party code for real and so are held to
92/// the integration rules ([`find_violations`]). A `*_test.py` under
93/// `<package root>/tests/` outside a standard tier is flagged as `unknown-tier`:
94/// the layout is part of the standard, so a suite the scan would silently miss
95/// is an error instead.
96pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
97    let tests = package_root.join("tests");
98    let mut violations = Vec::new();
99    let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
100    for tier in &tiers {
101        if tier.is_dir() {
102            violations.extend(find_violations(tier)?);
103        }
104    }
105    if tests.is_dir() {
106        let mut strays = Vec::new();
107        collect_python_files(&tests, &mut strays, is_python_unit_test_file)?;
108        strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
109        for file in strays {
110            violations.push(Violation {
111                file,
112                line: 1,
113                rule: "unknown-tier",
114                message: UNKNOWN_TIER_MSG.to_string(),
115            });
116        }
117    }
118    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
119    Ok(violations)
120}
121
122/// Scan the colocated Python unit tests under `root` and return every
123/// `unmocked-collaborator` violation: a first-party collaborator a
124/// unit test imports without mocking it. The Python arm of `unit lint`
125/// ([`crate::isolation::Language::Python`]).
126///
127/// A *unit test* here is `*_test.py` (not `conftest.py`); a legacy `test_*.py` is
128/// ordinary source. First-party is the dist's own package
129/// ([`first_party_package`]); a tree with no declared package has no first-party
130/// collaborators and so reports nothing.
131pub fn find_unit_isolation_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
132    let root = root.as_ref();
133    // First-party is the dist's own package; with none declared there are no
134    // first-party collaborators to flag.
135    let Some(first_party) = first_party_package(root) else {
136        return Ok(Vec::new());
137    };
138    let mut files = Vec::new();
139    collect_python_files(root, &mut files, is_python_unit_test_file)?;
140    // `<package root>/tests/` belongs to the suite tiers (integration / e2e run
141    // first-party code for real), so its files are never unit subjects.
142    if let Some(tests) = crate::tiers::suite_tests_dir(root, "pyproject.toml") {
143        files.retain(|file| !file.starts_with(&tests));
144    }
145    files.sort();
146
147    let mut violations = Vec::new();
148    for file in &files {
149        let source = std::fs::read_to_string(file)
150            .with_context(|| format!("reading test file `{}`", file.display()))?;
151        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
152            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
153        let base = unit_under_test_base(file);
154        let mut visitor = UnitIsolationVisitor {
155            source: &source,
156            first_party: &first_party,
157            base: &base,
158            type_checking_depth: 0,
159            imports: Vec::new(),
160            patch_targets: Vec::new(),
161        };
162        for stmt in suite {
163            visitor.visit_stmt(stmt);
164        }
165        // A first-party import that is neither the unit under test nor mocked by
166        // some `patch(...)` in the file is an un-mocked collaborator.
167        for import in &visitor.imports {
168            if import.is_uut || import.is_mocked(&visitor.patch_targets) {
169                continue;
170            }
171            violations.push(Violation {
172                file: file.to_path_buf(),
173                line: import.line,
174                rule: "unmocked-collaborator",
175                message: format!(
176                    "unit test imports `{}` without mocking it — a unit test isolates the \
177                     unit under test, so mock every collaborator (patch it by string in a \
178                     fixture)",
179                    import.display
180                ),
181            });
182        }
183    }
184
185    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
186    Ok(violations)
187}
188
189/// One first-party import seen in a unit test, with what it takes to decide
190/// whether it's the unit under test or mocked.
191struct ImportRecord {
192    /// The module path to name in the message (`myproject.ledger`, `.ledger`).
193    display: String,
194    line: usize,
195    /// `true` when this import *is* the unit under test (never a collaborator).
196    is_uut: bool,
197    /// For `from X import a, b` — the bound symbols. Each must be individually
198    /// mocked. Empty for a plain module import.
199    symbols: Vec<String>,
200    /// For an **absolute** `from X import a, b` — the source module `X`, which a
201    /// mocking patch's target must name (`patch("X.a")`). `None` for a relative
202    /// `from`-import (no absolute module to compare a patch against) and for a plain
203    /// `import X.Y` (which mocks via [`module`](Self::module)).
204    source: Option<String>,
205    /// For `import X.Y` — the module path (a patch reaching into it counts as a mock).
206    module: Option<String>,
207}
208
209impl ImportRecord {
210    /// `true` when some `patch("…")` target mocks this import.
211    ///
212    /// - A plain `import X.Y` is mocked by a patch that reaches into the module —
213    ///   `patch("X.Y")` or `patch("X.Y.attr")`.
214    /// - A `from <module> import a, b` is mocked only when **every** bound symbol is
215    ///   individually mocked. A symbol `s` is mocked by a target whose last dotted
216    ///   segment is `s` **and**, for an absolute import, whose module path is the
217    ///   import's own [`source`](Self::source): `from pkg.ledger import record`
218    ///   needs `patch("pkg.ledger.record")`, not merely any `"….record"`. A relative
219    ///   import has no absolute module to compare, so a matching last segment alone
220    ///   is accepted (name resolution stays a documented non-goal).
221    fn is_mocked(&self, patch_targets: &[String]) -> bool {
222        if let Some(module) = &self.module {
223            let prefix = format!("{module}.");
224            return patch_targets
225                .iter()
226                .any(|target| target == module || target.starts_with(&prefix));
227        }
228        if self.symbols.is_empty() {
229            return false;
230        }
231        self.symbols.iter().all(|symbol| {
232            patch_targets
233                .iter()
234                .any(|target| self.symbol_is_mocked(target, symbol))
235        })
236    }
237
238    /// `true` when `target` patches `symbol` for this `from`-import: the target's
239    /// last dotted segment is `symbol` and — for an absolute import — its module
240    /// path equals the import's own [`source`](Self::source).
241    fn symbol_is_mocked(&self, target: &str, symbol: &str) -> bool {
242        let Some(module) = target.strip_suffix(&format!(".{symbol}")) else {
243            return false;
244        };
245        match &self.source {
246            Some(source) => module == source,
247            None => true,
248        }
249    }
250}
251
252/// Walks one parsed unit test, collecting its first-party imports and every
253/// `patch("…")` string target so [`find_unit_isolation_violations`] can pair them.
254/// Imports guarded by `if TYPE_CHECKING:` are type-only (erased at runtime) and
255/// skipped.
256struct UnitIsolationVisitor<'a> {
257    source: &'a str,
258    first_party: &'a str,
259    base: &'a str,
260    type_checking_depth: usize,
261    imports: Vec<ImportRecord>,
262    patch_targets: Vec<String>,
263}
264
265impl Visitor for UnitIsolationVisitor<'_> {
266    fn visit_stmt_import(&mut self, node: StmtImport) {
267        if self.type_checking_depth == 0 {
268            let line = line_of(self.source, node.range.start());
269            for alias in &node.names {
270                let module = alias.name.as_str();
271                if is_checked_import(import_head(module), self.first_party) {
272                    self.imports.push(ImportRecord {
273                        display: module.to_string(),
274                        line,
275                        is_uut: last_segment(module) == self.base,
276                        symbols: Vec::new(),
277                        source: None,
278                        module: Some(module.to_string()),
279                    });
280                }
281            }
282        }
283        self.generic_visit_stmt_import(node);
284    }
285
286    fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
287        if self.type_checking_depth == 0 {
288            let level = relative_level(&node);
289            let module = node.module.as_ref().map(|m| m.as_str());
290            // Relative imports are first-party; an absolute import is checked when
291            // its head is first-party or external (third-party / effectful stdlib).
292            let should_check = level > 0
293                || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
294            if should_check {
295                let line = line_of(self.source, node.range.start());
296                let dots = ".".repeat(level);
297                match module {
298                    // `from <module> import a, b` — the bound symbols are collaborators.
299                    // An absolute import (`level == 0`) records its source module so a
300                    // mocking patch must name it; a relative import has no absolute
301                    // module to compare (`source: None`).
302                    Some(module) => self.imports.push(ImportRecord {
303                        display: format!("{dots}{module}"),
304                        line,
305                        is_uut: last_segment(module) == self.base,
306                        symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
307                        source: (level == 0).then(|| module.to_string()),
308                        module: None,
309                    }),
310                    // `from . import sub` — each name is a submodule.
311                    None => {
312                        // A re-export barrel is tested by importing its public surface: in
313                        // `__init___test.py` (base `__init__`), a bare `from . import …`
314                        // (`level == 1`) names the package's own `__init__.py` — the unit
315                        // under test — so every bound name is the SUT, never a collaborator
316                        // (`__all__` / `__version__` included, since they live in the SUT).
317                        // Parity with TS's `index.test.ts` / `import … from './index.js'`.
318                        // Scoped exactly: a `from .. import …` (`level == 2`) reaches
319                        // the *parent* package, and a `from .core import …` takes the `Some`
320                        // branch above — both stay collaborators.
321                        let barrel_sut = self.base == "__init__" && level == 1;
322                        for alias in &node.names {
323                            let name = alias.name.as_str();
324                            self.imports.push(ImportRecord {
325                                display: format!("{dots}{name}"),
326                                line,
327                                is_uut: barrel_sut || name == self.base,
328                                symbols: vec![name.to_string()],
329                                source: None,
330                                module: None,
331                            });
332                        }
333                    }
334                }
335            }
336        }
337        self.generic_visit_stmt_import_from(node);
338    }
339
340    fn visit_expr_call(&mut self, node: ExprCall) {
341        if is_patch_call(&node) {
342            if let Some(target) = patch_string_target(&node) {
343                self.patch_targets.push(target.to_string());
344            }
345        }
346        self.generic_visit_expr_call(node);
347    }
348
349    fn visit_stmt_if(&mut self, node: StmtIf) {
350        // Imports under `if TYPE_CHECKING:` are type-only — skip the body (the
351        // runtime `else` is still walked). Other `if`s recurse normally.
352        if is_type_checking(node.test.as_ref()) {
353            self.type_checking_depth += 1;
354            for stmt in node.body {
355                self.visit_stmt(stmt);
356            }
357            self.type_checking_depth -= 1;
358            for stmt in node.orelse {
359                self.visit_stmt(stmt);
360            }
361        } else {
362            self.generic_visit_stmt_if(node);
363        }
364    }
365}
366
367/// The leading dotted segment of a module path (`myproject.db` → `myproject`).
368fn import_head(module: &str) -> &str {
369    module.split('.').next().unwrap_or(module)
370}
371
372/// `true` when an import head names a collaborator the unit-isolation rule checks:
373/// **first-party** (the dist package) or **external** — a third-party package, or an
374/// effectful-stdlib module. The test framework and **pure** stdlib are not
375/// collaborators.
376fn is_checked_import(head: &str, first_party: &str) -> bool {
377    if head == first_party {
378        return true; // first-party
379    }
380    if TEST_FRAMEWORK.contains(&head) {
381        return false; // pytest et al. — the harness, never a collaborator
382    }
383    if EFFECTFUL_STDLIB.contains(&head) {
384        return true; // external — effectful stdlib
385    }
386    if STDLIB_MODULES.contains(&head) {
387        return false; // pure stdlib
388    }
389    true // external — a third-party package
390}
391
392/// The test harness — never a collaborator to mock. `unittest` / `unittest.mock`
393/// are stdlib (handled by [`STDLIB_MODULES`]); these are the rest.
394const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
395
396/// Standard-library modules that are **effectful at the head** — the README's
397/// External Dependencies (network / subprocess / process & IPC / randomness /
398/// database / low-level OS). **Dual-nature** heads (`os`, `pathlib`, `datetime`,
399/// `time`, `io`, `logging`, `threading`) are deliberately excluded: a pure use
400/// (`os.path.join`, `datetime(2020, 1, 1)`) can't be told from an effectful one at
401/// the import, so the clock / filesystem stay caught by the patch convention, not
402/// here (a documented non-goal). A tunable heuristic, not an exhaustive map.
403const EFFECTFUL_STDLIB: &[&str] = &[
404    "asynchat",
405    "asyncore",
406    "ctypes",
407    "curses",
408    "dbm",
409    "fcntl",
410    "ftplib",
411    "imaplib",
412    "mmap",
413    "msvcrt",
414    "multiprocessing",
415    "nis",
416    "nntplib",
417    "ossaudiodev",
418    "poplib",
419    "pty",
420    "random",
421    "secrets",
422    "select",
423    "selectors",
424    "signal",
425    "smtpd",
426    "smtplib",
427    "socket",
428    "socketserver",
429    "spwd",
430    "sqlite3",
431    "ssl",
432    "subprocess",
433    "syslog",
434    "telnetlib",
435    "termios",
436    "tty",
437    "webbrowser",
438    "winreg",
439    "winsound",
440];
441
442/// Top-level standard-library module names (Python's `sys.stdlib_module_names`).
443/// Used to tell **pure** stdlib (allowed) from a **third-party** package (checked);
444/// the [`EFFECTFUL_STDLIB`] subset is what's actually flagged.
445const STDLIB_MODULES: &[&str] = &[
446    "abc",
447    "aifc",
448    "antigravity",
449    "argparse",
450    "array",
451    "ast",
452    "asynchat",
453    "asyncio",
454    "asyncore",
455    "atexit",
456    "audioop",
457    "base64",
458    "bdb",
459    "binascii",
460    "bisect",
461    "builtins",
462    "bz2",
463    "cProfile",
464    "calendar",
465    "cgi",
466    "cgitb",
467    "chunk",
468    "cmath",
469    "cmd",
470    "code",
471    "codecs",
472    "codeop",
473    "collections",
474    "colorsys",
475    "compileall",
476    "concurrent",
477    "configparser",
478    "contextlib",
479    "contextvars",
480    "copy",
481    "copyreg",
482    "crypt",
483    "csv",
484    "ctypes",
485    "curses",
486    "dataclasses",
487    "datetime",
488    "dbm",
489    "decimal",
490    "difflib",
491    "dis",
492    "distutils",
493    "doctest",
494    "email",
495    "encodings",
496    "ensurepip",
497    "enum",
498    "errno",
499    "faulthandler",
500    "fcntl",
501    "filecmp",
502    "fileinput",
503    "fnmatch",
504    "fractions",
505    "ftplib",
506    "functools",
507    "gc",
508    "genericpath",
509    "getopt",
510    "getpass",
511    "gettext",
512    "glob",
513    "graphlib",
514    "grp",
515    "gzip",
516    "hashlib",
517    "heapq",
518    "hmac",
519    "html",
520    "http",
521    "idlelib",
522    "imaplib",
523    "imghdr",
524    "imp",
525    "importlib",
526    "inspect",
527    "io",
528    "ipaddress",
529    "itertools",
530    "json",
531    "keyword",
532    "lib2to3",
533    "linecache",
534    "locale",
535    "logging",
536    "lzma",
537    "mailbox",
538    "mailcap",
539    "marshal",
540    "math",
541    "mimetypes",
542    "mmap",
543    "modulefinder",
544    "msilib",
545    "msvcrt",
546    "multiprocessing",
547    "netrc",
548    "nis",
549    "nntplib",
550    "nt",
551    "ntpath",
552    "nturl2path",
553    "numbers",
554    "opcode",
555    "operator",
556    "optparse",
557    "os",
558    "ossaudiodev",
559    "pathlib",
560    "pdb",
561    "pickle",
562    "pickletools",
563    "pipes",
564    "pkgutil",
565    "platform",
566    "plistlib",
567    "poplib",
568    "posix",
569    "posixpath",
570    "pprint",
571    "profile",
572    "pstats",
573    "pty",
574    "pwd",
575    "py_compile",
576    "pyclbr",
577    "pydoc",
578    "pydoc_data",
579    "pyexpat",
580    "queue",
581    "quopri",
582    "random",
583    "re",
584    "readline",
585    "reprlib",
586    "resource",
587    "rlcompleter",
588    "runpy",
589    "sched",
590    "secrets",
591    "select",
592    "selectors",
593    "shelve",
594    "shlex",
595    "shutil",
596    "signal",
597    "site",
598    "smtpd",
599    "smtplib",
600    "sndhdr",
601    "socket",
602    "socketserver",
603    "spwd",
604    "sqlite3",
605    "sre_compile",
606    "sre_constants",
607    "sre_parse",
608    "ssl",
609    "stat",
610    "statistics",
611    "string",
612    "stringprep",
613    "struct",
614    "subprocess",
615    "sunau",
616    "symtable",
617    "sys",
618    "sysconfig",
619    "syslog",
620    "tabnanny",
621    "tarfile",
622    "telnetlib",
623    "tempfile",
624    "termios",
625    "textwrap",
626    "this",
627    "threading",
628    "time",
629    "timeit",
630    "tkinter",
631    "token",
632    "tokenize",
633    "tomllib",
634    "trace",
635    "traceback",
636    "tracemalloc",
637    "tty",
638    "turtle",
639    "turtledemo",
640    "types",
641    "typing",
642    "unicodedata",
643    "unittest",
644    "urllib",
645    "uu",
646    "uuid",
647    "venv",
648    "warnings",
649    "wave",
650    "weakref",
651    "webbrowser",
652    "winreg",
653    "winsound",
654    "wsgiref",
655    "xdrlib",
656    "xml",
657    "xmlrpc",
658    "zipapp",
659    "zipfile",
660    "zipimport",
661    "zlib",
662    "zoneinfo",
663];
664
665/// The trailing dotted segment of a module path (`myproject.db` → `db`).
666fn last_segment(module: &str) -> &str {
667    module.rsplit('.').next().unwrap_or(module)
668}
669
670/// The number of leading dots on a `from`-import (`from ..pkg import x` → 2; an
671/// absolute import → 0).
672fn relative_level(node: &StmtImportFrom) -> usize {
673    node.level.map_or(0, |level| level.to_usize())
674}
675
676/// `true` for `TYPE_CHECKING` / `typing.TYPE_CHECKING` — the guard whose body holds
677/// type-only imports.
678fn is_type_checking(test: &Expr) -> bool {
679    match test {
680        Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
681        Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
682        _ => false,
683    }
684}
685
686/// The unit-under-test base name for a test file: `widget_test.py` → `widget`.
687/// Only `*_test.py` reaches here (the unit-isolation scan no longer recognizes a
688/// legacy `test_*.py`), so stripping the `_test` suffix is all it takes.
689fn unit_under_test_base(file: &Path) -> String {
690    let name = file
691        .file_name()
692        .and_then(|n| n.to_str())
693        .unwrap_or_default();
694    let stem = name.strip_suffix(".py").unwrap_or(name);
695    stem.strip_suffix("_test").unwrap_or(stem).to_string()
696}
697
698/// Walks one parsed test file, collecting lint violations. Tracks how deep we
699/// are inside `@pytest.fixture` functions so `no-inline-patch` can allow patches
700/// there while flagging them in test bodies.
701struct LintVisitor<'a> {
702    file: &'a Path,
703    source: &'a str,
704    fixture_depth: usize,
705    /// The dist's own top-level package, or `None` when undiscoverable.
706    first_party: Option<&'a str>,
707    violations: Vec<Violation>,
708}
709
710impl LintVisitor<'_> {
711    fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
712        self.violations.push(Violation {
713            file: self.file.to_path_buf(),
714            line: line_of(self.source, range.start()),
715            rule,
716            message: message.to_string(),
717        });
718    }
719
720    /// Shared entry for both function kinds: run the parameter lint, then return
721    /// whether this function is a fixture (so the caller bumps `fixture_depth`).
722    fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
723        // `no-monkeypatch`: the `monkeypatch` parameter is the signal.
724        let takes_monkeypatch = args
725            .posonlyargs
726            .iter()
727            .chain(&args.args)
728            .chain(&args.kwonlyargs)
729            .any(|arg| arg.def.arg.as_str() == "monkeypatch")
730            || arg_named(&args.vararg, "monkeypatch")
731            || arg_named(&args.kwarg, "monkeypatch");
732        if takes_monkeypatch {
733            self.report(
734                range,
735                "no-monkeypatch",
736                "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
737            );
738        }
739
740        decorators.iter().any(is_fixture_decorator)
741    }
742}
743
744impl Visitor for LintVisitor<'_> {
745    fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
746        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
747        if is_fixture {
748            self.fixture_depth += 1;
749        }
750        self.generic_visit_stmt_function_def(node);
751        if is_fixture {
752            self.fixture_depth -= 1;
753        }
754    }
755
756    fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
757        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
758        if is_fixture {
759            self.fixture_depth += 1;
760        }
761        self.generic_visit_stmt_async_function_def(node);
762        if is_fixture {
763            self.fixture_depth -= 1;
764        }
765    }
766
767    fn visit_expr_call(&mut self, node: ExprCall) {
768        let is_patch = is_patch_call(&node);
769        // `no-inline-patch`: a patch(...) call outside any fixture is a
770        // patch in a test body. Inside a fixture it is the right place.
771        if is_patch && self.fixture_depth == 0 {
772            self.report(
773                node.range,
774                "no-inline-patch",
775                "patch is called inline in a test body; move it into a `pytest.fixture`",
776            );
777        }
778        // `no-constant-patch`: patching a module-global UPPER_CASE constant.
779        // Fires regardless of fixture — config constants are usually patched in one.
780        if is_patch && patches_constant(&node) {
781            self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
782        }
783        // `no-first-party-patch`: in an integration test, patching a
784        // first-party target — `patch("ourpkg.mod.fn")` — is forbidden; an
785        // integration test runs first-party code for real. Fires regardless of
786        // fixture (the patch belongs in one); only when the dist's own package is
787        // known (`first_party`) and the target's head segment names it.
788        if is_patch {
789            if let Some(pkg) = self.first_party {
790                if patch_string_target(&node).is_some_and(|target| patches_first_party(target, pkg))
791                {
792                    self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
793                }
794            }
795        }
796        // `no-environ-mutation`: `os.environ.update(...)` and friends.
797        if is_environ_mutation_call(&node) {
798            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
799        }
800        self.generic_visit_expr_call(node);
801    }
802
803    // The generated `generic_visit_withitem` is a no-op, so a `with patch(...)`
804    // context expression is never walked unless we descend into it here.
805    fn visit_withitem(&mut self, node: WithItem) {
806        self.visit_expr(node.context_expr);
807        if let Some(optional_vars) = node.optional_vars {
808            self.visit_expr(*optional_vars);
809        }
810    }
811
812    // `no-environ-mutation`: `os.environ[...] = …`, augmented assignment,
813    // and `del os.environ[...]`.
814    fn visit_stmt_assign(&mut self, node: StmtAssign) {
815        if node.targets.iter().any(is_os_environ_subscript) {
816            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
817        }
818        self.generic_visit_stmt_assign(node);
819    }
820
821    fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
822        if is_os_environ_subscript(node.target.as_ref()) {
823            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
824        }
825        self.generic_visit_stmt_aug_assign(node);
826    }
827
828    fn visit_stmt_delete(&mut self, node: StmtDelete) {
829        if node.targets.iter().any(is_os_environ_subscript) {
830            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
831        }
832        self.generic_visit_stmt_delete(node);
833    }
834}
835
836/// `true` when a `*args` / `**kwargs` arg is named `name`.
837fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
838    arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
839}
840
841/// `true` for an `@pytest.fixture` / `@fixture` decorator, with or without a
842/// call (`@pytest.fixture(autouse=True)`).
843fn is_fixture_decorator(decorator: &Expr) -> bool {
844    let target = match decorator {
845        Expr::Call(call) => call.func.as_ref(),
846        other => other,
847    };
848    match target {
849        Expr::Name(name) => name.id.as_str() == "fixture",
850        Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
851        _ => false,
852    }
853}
854
855/// `true` when a call is `patch(...)`, `patch.object(...)`, `patch.dict(...)`, or
856/// the same reached through a module (`mock.patch(...)`, `unittest.mock.patch`).
857fn is_patch_call(call: &ExprCall) -> bool {
858    match call.func.as_ref() {
859        Expr::Name(name) => name.id.as_str() == "patch",
860        Expr::Attribute(attr) => {
861            let name = attr.attr.as_str();
862            name == "patch"
863                || ((name == "object" || name == "dict") && attr_base_is_patch(attr.value.as_ref()))
864        }
865        _ => false,
866    }
867}
868
869/// `true` when an attribute's base resolves to `patch` — the receiver of
870/// `patch.object` / `patch.dict`.
871fn attr_base_is_patch(expr: &Expr) -> bool {
872    match expr {
873        Expr::Name(name) => name.id.as_str() == "patch",
874        Expr::Attribute(attr) => attr.attr.as_str() == "patch",
875        _ => false,
876    }
877}
878
879/// Message for the `no-constant-patch` lint.
880const CONSTANT_PATCH_MSG: &str = "patches a module-global config constant; inject config explicitly (a consumer that did `from pkg import CONSTANT` snapshots the value at import time and ignores the patch)";
881
882/// Message for the `no-first-party-patch` lint.
883const FIRST_PARTY_PATCH_MSG: &str = "patches a first-party target; an integration test must run first-party code for real — only third-party packages and effectful stdlib may be patched";
884
885/// The string-literal first argument of a `patch(...)` call — the dotted target
886/// like `"pkg.mod.attr"`. `None` when the first argument isn't a string literal
887/// (a non-literal target can't be classified deterministically).
888fn patch_string_target(call: &ExprCall) -> Option<&str> {
889    if let Some(Expr::Constant(constant)) = call.args.first() {
890        if let Constant::Str(target) = &constant.value {
891            return Some(target.as_str());
892        }
893    }
894    None
895}
896
897/// `true` when a `patch(...)` call's first string argument names a module-global
898/// UPPER_CASE constant, e.g. `patch("pkg.config.CACHE_DIR", …)`.
899fn patches_constant(call: &ExprCall) -> bool {
900    patch_string_target(call)
901        .and_then(|target| target.rsplit('.').next())
902        .is_some_and(is_upper_constant)
903}
904
905/// `true` when a patch `target`'s head dotted segment names the first-party
906/// package `pkg`, e.g. `target = "ourpkg.mod.fn"`, `pkg = "ourpkg"`.
907fn patches_first_party(target: &str, pkg: &str) -> bool {
908    target
909        .split('.')
910        .next()
911        .is_some_and(|head| !head.is_empty() && head == pkg)
912}
913
914/// `true` for an ALL-CAPS constant name — letters uppercase, digits and
915/// underscores allowed, at least one letter (`CACHE_DIR`, `DEBUG`, `MAX_SIZE`).
916fn is_upper_constant(name: &str) -> bool {
917    !name.is_empty()
918        && name
919            .chars()
920            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
921        && name.chars().any(|c| c.is_ascii_uppercase())
922}
923
924/// Message for the `no-environ-mutation` lint.
925const ENVIRON_MUTATION_MSG: &str =
926    "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
927
928/// `true` for the expression `os.environ`.
929fn is_os_environ(expr: &Expr) -> bool {
930    matches!(
931        expr,
932        Expr::Attribute(attr)
933            if attr.attr.as_str() == "environ"
934                && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
935    )
936}
937
938/// `true` for `os.environ[...]` — a subscript of `os.environ`, the form used as
939/// an assignment or `del` target.
940fn is_os_environ_subscript(expr: &Expr) -> bool {
941    matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
942}
943
944/// `true` for a mutating method call on `os.environ` (`os.environ.update(...)`
945/// and friends).
946fn is_environ_mutation_call(call: &ExprCall) -> bool {
947    matches!(
948        call.func.as_ref(),
949        Expr::Attribute(attr)
950            if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
951    )
952}
953
954/// `true` for a `dict` method that mutates in place.
955fn is_environ_mutator(method: &str) -> bool {
956    matches!(
957        method,
958        "update" | "pop" | "setdefault" | "clear" | "popitem"
959    )
960}
961
962/// The 1-based line containing byte `offset` in `source`.
963fn line_of(source: &str, offset: TextSize) -> usize {
964    let offset = (u32::from(offset) as usize).min(source.len());
965    source.as_bytes()[..offset]
966        .iter()
967        .filter(|&&byte| byte == b'\n')
968        .count()
969        + 1
970}
971
972/// The dist's own top-level import package — the first-party root for
973/// `no-first-party-patch`.
974///
975/// Walk up from `root` to the nearest `pyproject.toml`, read its `[project].name`,
976/// and [normalize](normalize_dist_name) it to an import name. Returns `None` when
977/// no `pyproject.toml` (with a `[project].name`) is found, so a tree with no
978/// declared package flags nothing rather than guess. The walk stops at a `.git`
979/// boundary so it can't escape the project into an unrelated `pyproject.toml`.
980fn first_party_package(root: &Path) -> Option<String> {
981    for dir in root.ancestors() {
982        let candidate = dir.join("pyproject.toml");
983        if candidate.is_file() {
984            return read_project_name(&candidate).map(|name| normalize_dist_name(&name));
985        }
986        if dir.join(".git").exists() {
987            break;
988        }
989    }
990    None
991}
992
993/// `[project].name` from a `pyproject.toml`, if present and a string.
994fn read_project_name(path: &Path) -> Option<String> {
995    let contents = std::fs::read_to_string(path).ok()?;
996    let value: toml::Value = toml::from_str(&contents).ok()?;
997    value
998        .get("project")?
999        .get("name")?
1000        .as_str()
1001        .map(str::to_owned)
1002}
1003
1004/// Normalize a distribution name to its import package name: lower-cased, with
1005/// `-` and `.` mapped to `_` (PEP 503-flavoured — `My-Project` → `my_project`).
1006fn normalize_dist_name(name: &str) -> String {
1007    name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
1008}
1009
1010/// Recursively collect every Python file under `dir` matching `is_match` into `out`.
1011fn collect_python_files(
1012    dir: &Path,
1013    out: &mut Vec<PathBuf>,
1014    is_match: fn(&Path) -> bool,
1015) -> Result<()> {
1016    let entries =
1017        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
1018    for entry in entries {
1019        let path = entry
1020            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
1021            .path();
1022        if path.is_dir() {
1023            collect_python_files(&path, out, is_match)?;
1024        } else if is_match(&path) {
1025            out.push(path);
1026        }
1027    }
1028    Ok(())
1029}
1030
1031/// `true` for a file the integration lints scan: `*_test.py` or `conftest.py`
1032/// (where fixtures live). A legacy `test_*.py` is ordinary source, so
1033/// it is not scanned.
1034fn is_python_test_file(path: &Path) -> bool {
1035    let name = path
1036        .file_name()
1037        .and_then(|n| n.to_str())
1038        .unwrap_or_default();
1039    name == "conftest.py" || name.ends_with("_test.py")
1040}
1041
1042/// `true` for a colocated *unit* test the isolation rule scans: `*_test.py`. A
1043/// legacy `test_*.py` is ordinary source, and `conftest.py` holds
1044/// fixtures (not a unit) — neither is scanned.
1045fn is_python_unit_test_file(path: &Path) -> bool {
1046    let name = path
1047        .file_name()
1048        .and_then(|n| n.to_str())
1049        .unwrap_or_default();
1050    name.ends_with("_test.py")
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055    use super::*;
1056    use std::sync::atomic::{AtomicU64, Ordering};
1057
1058    /// A throwaway directory, removed on drop — for the `pyproject.toml` discovery.
1059    struct TempDir(PathBuf);
1060
1061    impl TempDir {
1062        fn new() -> Self {
1063            static COUNTER: AtomicU64 = AtomicU64::new(0);
1064            let dir = std::env::temp_dir().join(format!(
1065                "tc-lint-{}-{}",
1066                std::process::id(),
1067                COUNTER.fetch_add(1, Ordering::Relaxed),
1068            ));
1069            std::fs::create_dir_all(&dir).unwrap();
1070            TempDir(dir)
1071        }
1072
1073        fn write(&self, name: &str, contents: &str) {
1074            let path = self.0.join(name);
1075            if let Some(parent) = path.parent() {
1076                std::fs::create_dir_all(parent).unwrap();
1077            }
1078            std::fs::write(path, contents).unwrap();
1079        }
1080    }
1081
1082    impl Drop for TempDir {
1083        fn drop(&mut self) {
1084            let _ = std::fs::remove_dir_all(&self.0);
1085        }
1086    }
1087
1088    #[test]
1089    fn normalize_dist_name_maps_to_import_name() {
1090        assert_eq!(normalize_dist_name("My-Project"), "my_project");
1091        assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1092        assert_eq!(normalize_dist_name("  myproject  "), "myproject");
1093        assert_eq!(normalize_dist_name("myproject"), "myproject");
1094    }
1095
1096    /// Parse `src` (a single expression statement) and return its call.
1097    fn parse_call(src: &str) -> ExprCall {
1098        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1099        match suite.into_iter().next().expect("one statement") {
1100            ast::Stmt::Expr(stmt) => match *stmt.value {
1101                Expr::Call(call) => call,
1102                other => panic!("expected a call, got {other:?}"),
1103            },
1104            other => panic!("expected an expression statement, got {other:?}"),
1105        }
1106    }
1107
1108    #[test]
1109    fn patch_string_target_only_reads_string_literals() {
1110        let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1111        assert_eq!(patch_string_target(&str_call), Some("pkg.mod.attr"));
1112        // A non-string literal (`patch(42)`), a name (`patch(target)`), and no args
1113        // all yield `None` — a non-literal target can't be classified.
1114        let int_call = parse_call("patch(42)\n");
1115        assert_eq!(patch_string_target(&int_call), None);
1116        let name_call = parse_call("patch(target)\n");
1117        assert_eq!(patch_string_target(&name_call), None);
1118        let empty_call = parse_call("patch()\n");
1119        assert_eq!(patch_string_target(&empty_call), None);
1120    }
1121
1122    /// Build a `from <source> import <symbols>` record (`source: None` → relative).
1123    fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1124        ImportRecord {
1125            display: source.unwrap_or(".rel").to_string(),
1126            line: 1,
1127            is_uut: false,
1128            symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1129            source: source.map(str::to_string),
1130            module: None,
1131        }
1132    }
1133
1134    fn targets(list: &[&str]) -> Vec<String> {
1135        list.iter().map(|s| (*s).to_string()).collect()
1136    }
1137
1138    #[test]
1139    fn is_mocked_requires_every_symbol_at_the_import_module() {
1140        let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1141        // Only `record` patched → the un-mocked `erase` leaves the import un-mocked (1a).
1142        assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1143        // Both symbols patched at the import's own module → mocked.
1144        assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1145    }
1146
1147    #[test]
1148    fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1149        let rec = from_import(Some("pkg.ledger"), &["record"]);
1150        // Same last segment, different module → not mocked (1b).
1151        assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1152        // A stdlib target sharing only the last segment likewise (`json.dumps`).
1153        let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1154        assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1155        // The exact module path clears it.
1156        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1157    }
1158
1159    #[test]
1160    fn is_mocked_relative_import_accepts_a_last_segment_match() {
1161        // A relative import has no absolute module to compare, so a matching last
1162        // segment alone is accepted (documented non-goal).
1163        let rec = from_import(None, &["record"]);
1164        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1165        assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1166    }
1167
1168    #[test]
1169    fn is_mocked_module_import_matches_a_patch_reaching_in() {
1170        let rec = ImportRecord {
1171            display: "pkg.db".to_string(),
1172            line: 1,
1173            is_uut: false,
1174            symbols: Vec::new(),
1175            source: None,
1176            module: Some("pkg.db".to_string()),
1177        };
1178        assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1179        assert!(rec.is_mocked(&targets(&["pkg.db"])));
1180        assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1181        // A `from`-import with no symbols and no module is never mocked.
1182        let empty = from_import(Some("pkg.mod"), &[]);
1183        assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1184    }
1185
1186    #[test]
1187    fn patches_first_party_matches_head_segment() {
1188        assert!(patches_first_party("myproject.ledger.record", "myproject"));
1189        assert!(patches_first_party("myproject", "myproject"));
1190        assert!(!patches_first_party("requests.get", "myproject"));
1191        assert!(!patches_first_party("myproject_extra.x", "myproject"));
1192        assert!(!patches_first_party("", "myproject"));
1193        assert!(!patches_first_party(".leading", "myproject"));
1194    }
1195
1196    #[test]
1197    fn first_party_package_reads_pyproject_name() {
1198        let tree = TempDir::new();
1199        tree.write(
1200            "pyproject.toml",
1201            "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1202        );
1203        // Normalized to the import name.
1204        assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1205    }
1206
1207    #[test]
1208    fn first_party_package_is_none_without_a_project_name() {
1209        let tree = TempDir::new();
1210        // A pyproject with no `[project].name` — found, but no usable package.
1211        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1212        tree.write(".git", "");
1213        assert_eq!(first_party_package(&tree.0), None);
1214    }
1215
1216    #[test]
1217    fn first_party_package_is_none_when_absent() {
1218        // No pyproject.toml anywhere up the (temp) tree → nothing first-party.
1219        let tree = TempDir::new();
1220        assert_eq!(first_party_package(&tree.0), None);
1221    }
1222
1223    /// Run the unit-isolation visitor over `source` and return the flagged
1224    /// (un-mocked, non-UUT first-party) import displays.
1225    fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1226        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1227        let mut visitor = UnitIsolationVisitor {
1228            source,
1229            first_party,
1230            base,
1231            type_checking_depth: 0,
1232            imports: Vec::new(),
1233            patch_targets: Vec::new(),
1234        };
1235        for stmt in suite {
1236            visitor.visit_stmt(stmt);
1237        }
1238        visitor
1239            .imports
1240            .iter()
1241            .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1242            .map(|i| i.display.clone())
1243            .collect()
1244    }
1245
1246    #[test]
1247    fn import_head_and_last_segment() {
1248        assert_eq!(import_head("myproject.db.conn"), "myproject");
1249        assert_eq!(import_head("requests"), "requests");
1250        assert_eq!(last_segment("myproject.db.conn"), "conn");
1251        assert_eq!(last_segment("widget"), "widget");
1252    }
1253
1254    #[test]
1255    fn unit_under_test_base_strips_test_suffix() {
1256        assert_eq!(
1257            unit_under_test_base(Path::new("pkg/widget_test.py")),
1258            "widget"
1259        );
1260        // Only `*_test.py` reaches here, so a legacy `test_*.py` keeps its
1261        // `test_` prefix, and a name without the `_test` suffix is its own stem.
1262        assert_eq!(
1263            unit_under_test_base(Path::new("test_widget.py")),
1264            "test_widget"
1265        );
1266        assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1267    }
1268
1269    #[test]
1270    fn recognizes_python_unit_test_files() {
1271        assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1272        assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1273        // A legacy `test_*.py` is ordinary source, not a unit test.
1274        assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1275        // conftest holds fixtures, not a unit — excluded from unit lint.
1276        assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1277        assert!(!is_python_unit_test_file(Path::new("widget.py")));
1278    }
1279
1280    #[test]
1281    fn visitor_flags_first_party_and_external_collaborators() {
1282        // The UUT is left alone; the first-party collaborator and the third-party
1283        // import are both flagged (slice 3 broadened the rule to external deps).
1284        let found = unmocked(
1285            "widget",
1286            "myproject",
1287            "from myproject.widget import build\n\
1288             from myproject.ledger import record\n\
1289             import requests\n",
1290        );
1291        assert_eq!(
1292            found,
1293            vec!["myproject.ledger".to_string(), "requests".to_string()]
1294        );
1295    }
1296
1297    #[test]
1298    fn visitor_clears_a_mocked_collaborator() {
1299        // The imported `record` is patched at its own source module → not flagged.
1300        let found = unmocked(
1301            "widget",
1302            "myproject",
1303            "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
1304        );
1305        assert!(found.is_empty(), "got: {found:?}");
1306    }
1307
1308    #[test]
1309    fn visitor_flags_a_wrong_module_patch() {
1310        // A patch that shares only the last segment but names a different module does
1311        // not mock the import (#393, 1b): `record` stays an un-mocked collaborator.
1312        let found = unmocked(
1313            "widget",
1314            "myproject",
1315            "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
1316        );
1317        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1318    }
1319
1320    #[test]
1321    fn visitor_flags_a_partly_mocked_multi_symbol_import() {
1322        // Every imported symbol must be mocked (#393, 1a): patching only `record`
1323        // leaves the sibling `erase` a real collaborator, so the import is flagged.
1324        let found = unmocked(
1325            "widget",
1326            "myproject",
1327            "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
1328        );
1329        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1330        // Patching both symbols at their module clears it.
1331        let both = unmocked(
1332            "widget",
1333            "myproject",
1334            "from myproject.ledger import record, erase\n\
1335             patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
1336        );
1337        assert!(both.is_empty(), "got: {both:?}");
1338    }
1339
1340    #[test]
1341    fn visitor_handles_module_and_relative_imports() {
1342        // A first-party module import, not the UUT, un-mocked → flagged.
1343        assert_eq!(
1344            unmocked("widget", "myproject", "import myproject.db\n"),
1345            vec!["myproject.db".to_string()]
1346        );
1347        // `import myproject.db` reached by a patch → mocked.
1348        assert!(unmocked(
1349            "widget",
1350            "myproject",
1351            "import myproject.db\npatch(\"myproject.db.connect\")\n"
1352        )
1353        .is_empty());
1354        // Relative imports are first-party; `from . import widget` is the UUT.
1355        assert_eq!(
1356            unmocked("widget", "myproject", "from .ledger import record\n"),
1357            vec![".ledger".to_string()]
1358        );
1359        assert_eq!(
1360            unmocked(
1361                "widget",
1362                "myproject",
1363                "from . import ledger\nfrom . import widget\n"
1364            ),
1365            vec![".ledger".to_string()]
1366        );
1367    }
1368
1369    #[test]
1370    fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
1371        // In `__init___test.py` (base `__init__`), a bare `from . import …`
1372        // imports the package's own re-export surface — the SUT — so no name is a
1373        // collaborator, `__all__` / `__version__` included (they live in the SUT).
1374        assert!(unmocked(
1375            "__init__",
1376            "myproject",
1377            "from . import Thing, __all__, __version__\n"
1378        )
1379        .is_empty());
1380        // Reaching AROUND the barrel into a sibling module directly is still a
1381        // collaborator (the `module: Some("core")` branch, `core != __init__`).
1382        assert_eq!(
1383            unmocked("__init__", "myproject", "from .core import Thing\n"),
1384            vec![".core".to_string()]
1385        );
1386        // `from .. import x` (level 2) resolves to the PARENT package, not the SUT
1387        // file — still a collaborator even inside a barrel test.
1388        assert_eq!(
1389            unmocked("__init__", "myproject", "from .. import sibling\n"),
1390            vec!["..sibling".to_string()]
1391        );
1392        // The barrel shortcut is scoped to the `__init__` base: an ordinary
1393        // `widget_test.py` doing `from . import ledger` is still a collaborator.
1394        assert_eq!(
1395            unmocked("widget", "myproject", "from . import ledger\n"),
1396            vec![".ledger".to_string()]
1397        );
1398    }
1399
1400    #[test]
1401    fn visitor_skips_type_checking_imports() {
1402        // A first-party import guarded by TYPE_CHECKING is type-only — not a runtime
1403        // collaborator; the runtime `else` import is still seen.
1404        let found = unmocked(
1405            "widget",
1406            "myproject",
1407            "if TYPE_CHECKING:\n    from myproject.models import Widget\nelse:\n    from myproject.ledger import record\n",
1408        );
1409        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1410    }
1411
1412    #[test]
1413    fn is_checked_import_classifies_origins() {
1414        assert!(is_checked_import("myproject", "myproject")); // first-party
1415        assert!(!is_checked_import("pytest", "myproject")); // test framework
1416        assert!(!is_checked_import("_pytest", "myproject"));
1417        assert!(is_checked_import("subprocess", "myproject")); // effectful stdlib
1418        assert!(is_checked_import("socket", "myproject"));
1419        assert!(!is_checked_import("json", "myproject")); // pure stdlib
1420        assert!(!is_checked_import("dataclasses", "myproject"));
1421        assert!(is_checked_import("requests", "myproject")); // third-party
1422        assert!(is_checked_import("stripe", "myproject"));
1423        // dual-nature stdlib heads stay pure (not flagged) — caught by patching, not import
1424        assert!(!is_checked_import("os", "myproject"));
1425        assert!(!is_checked_import("pathlib", "myproject"));
1426        assert!(!is_checked_import("datetime", "myproject"));
1427    }
1428
1429    #[test]
1430    fn visitor_flags_external_collaborators() {
1431        // Third-party + effectful stdlib are flagged; pure stdlib and the framework aren't.
1432        let found = unmocked(
1433            "widget",
1434            "myproject",
1435            "import requests\nimport subprocess\nimport json\nimport pytest\n",
1436        );
1437        assert_eq!(found.len(), 2, "got: {found:?}");
1438        assert!(found.contains(&"requests".to_string()));
1439        assert!(found.contains(&"subprocess".to_string()));
1440    }
1441
1442    #[test]
1443    fn visitor_type_checking_variants_and_plain_if() {
1444        // `typing.TYPE_CHECKING` (attribute form) guards type-only imports — both
1445        // the `from`-import and the plain module import are skipped.
1446        assert!(unmocked(
1447            "widget",
1448            "myproject",
1449            "if typing.TYPE_CHECKING:\n    from myproject.models import W\n    import myproject.db\n"
1450        )
1451        .is_empty());
1452        // A plain `if` (not TYPE_CHECKING) is walked normally — its first-party
1453        // import is still a collaborator.
1454        assert_eq!(
1455            unmocked(
1456                "widget",
1457                "myproject",
1458                "if ready == 1:\n    from myproject.ledger import record\n"
1459            ),
1460            vec!["myproject.ledger".to_string()]
1461        );
1462    }
1463
1464    #[test]
1465    fn find_unit_isolation_without_pyproject_reports_nothing() {
1466        // No declared package → no first-party collaborators (the early return).
1467        let tree = TempDir::new();
1468        tree.write("widget_test.py", "from myproject.ledger import record\n");
1469        tree.write(".git", "");
1470        assert!(find_unit_isolation_violations(&tree.0)
1471            .expect("a readable tree should succeed")
1472            .is_empty());
1473    }
1474
1475    #[test]
1476    fn find_unit_isolation_walks_subdirs_and_flags() {
1477        let tree = TempDir::new();
1478        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1479        // A nested unit test — exercises the directory recursion.
1480        tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
1481        let found =
1482            find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
1483        assert_eq!(found.len(), 1, "got: {found:?}");
1484        assert_eq!(found[0].rule, "unmocked-collaborator");
1485        assert!(found[0].message.contains("myproject.ledger"));
1486    }
1487
1488    #[test]
1489    fn recognizes_python_test_files() {
1490        assert!(is_python_test_file(Path::new("widget_test.py")));
1491        assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
1492        assert!(is_python_test_file(Path::new("conftest.py")));
1493        // A legacy `test_*.py` is ordinary source, not a test file.
1494        assert!(!is_python_test_file(Path::new("test_widget.py")));
1495    }
1496
1497    #[test]
1498    fn ignores_non_test_files() {
1499        assert!(!is_python_test_file(Path::new("widget.py")));
1500        assert!(!is_python_test_file(Path::new("conftest.pyi")));
1501        assert!(!is_python_test_file(Path::new("README.md")));
1502        assert!(!is_python_test_file(Path::new("testing.py")));
1503    }
1504
1505    #[test]
1506    fn line_of_counts_newlines() {
1507        let src = "a\nb\nc\n";
1508        assert_eq!(line_of(src, TextSize::from(0)), 1);
1509        assert_eq!(line_of(src, TextSize::from(2)), 2);
1510        assert_eq!(line_of(src, TextSize::from(4)), 3);
1511    }
1512
1513    #[test]
1514    fn recognizes_environ_mutators() {
1515        assert!(is_environ_mutator("update"));
1516        assert!(is_environ_mutator("pop"));
1517        assert!(is_environ_mutator("clear"));
1518        assert!(!is_environ_mutator("get"));
1519        assert!(!is_environ_mutator("keys"));
1520    }
1521
1522    #[test]
1523    fn recognizes_upper_constants() {
1524        assert!(is_upper_constant("CACHE_DIR"));
1525        assert!(is_upper_constant("DEBUG"));
1526        assert!(is_upper_constant("MAX_2"));
1527        assert!(!is_upper_constant("cache_dir"));
1528        assert!(!is_upper_constant("CacheDir"));
1529        assert!(!is_upper_constant("fetch"));
1530        assert!(!is_upper_constant(""));
1531        assert!(!is_upper_constant("_"));
1532        assert!(!is_upper_constant("123"));
1533    }
1534}