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