Skip to main content

testing_conventions/
lint.rs

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