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    "abc",
369    "aifc",
370    "antigravity",
371    "argparse",
372    "array",
373    "ast",
374    "asynchat",
375    "asyncio",
376    "asyncore",
377    "atexit",
378    "audioop",
379    "base64",
380    "bdb",
381    "binascii",
382    "bisect",
383    "builtins",
384    "bz2",
385    "cProfile",
386    "calendar",
387    "cgi",
388    "cgitb",
389    "chunk",
390    "cmath",
391    "cmd",
392    "code",
393    "codecs",
394    "codeop",
395    "collections",
396    "colorsys",
397    "compileall",
398    "concurrent",
399    "configparser",
400    "contextlib",
401    "contextvars",
402    "copy",
403    "copyreg",
404    "crypt",
405    "csv",
406    "ctypes",
407    "curses",
408    "dataclasses",
409    "datetime",
410    "dbm",
411    "decimal",
412    "difflib",
413    "dis",
414    "distutils",
415    "doctest",
416    "email",
417    "encodings",
418    "ensurepip",
419    "enum",
420    "errno",
421    "faulthandler",
422    "fcntl",
423    "filecmp",
424    "fileinput",
425    "fnmatch",
426    "fractions",
427    "ftplib",
428    "functools",
429    "gc",
430    "genericpath",
431    "getopt",
432    "getpass",
433    "gettext",
434    "glob",
435    "graphlib",
436    "grp",
437    "gzip",
438    "hashlib",
439    "heapq",
440    "hmac",
441    "html",
442    "http",
443    "idlelib",
444    "imaplib",
445    "imghdr",
446    "imp",
447    "importlib",
448    "inspect",
449    "io",
450    "ipaddress",
451    "itertools",
452    "json",
453    "keyword",
454    "lib2to3",
455    "linecache",
456    "locale",
457    "logging",
458    "lzma",
459    "mailbox",
460    "mailcap",
461    "marshal",
462    "math",
463    "mimetypes",
464    "mmap",
465    "modulefinder",
466    "msilib",
467    "msvcrt",
468    "multiprocessing",
469    "netrc",
470    "nis",
471    "nntplib",
472    "nt",
473    "ntpath",
474    "nturl2path",
475    "numbers",
476    "opcode",
477    "operator",
478    "optparse",
479    "os",
480    "ossaudiodev",
481    "pathlib",
482    "pdb",
483    "pickle",
484    "pickletools",
485    "pipes",
486    "pkgutil",
487    "platform",
488    "plistlib",
489    "poplib",
490    "posix",
491    "posixpath",
492    "pprint",
493    "profile",
494    "pstats",
495    "pty",
496    "pwd",
497    "py_compile",
498    "pyclbr",
499    "pydoc",
500    "pydoc_data",
501    "pyexpat",
502    "queue",
503    "quopri",
504    "random",
505    "re",
506    "readline",
507    "reprlib",
508    "resource",
509    "rlcompleter",
510    "runpy",
511    "sched",
512    "secrets",
513    "select",
514    "selectors",
515    "shelve",
516    "shlex",
517    "shutil",
518    "signal",
519    "site",
520    "smtpd",
521    "smtplib",
522    "sndhdr",
523    "socket",
524    "socketserver",
525    "spwd",
526    "sqlite3",
527    "sre_compile",
528    "sre_constants",
529    "sre_parse",
530    "ssl",
531    "stat",
532    "statistics",
533    "string",
534    "stringprep",
535    "struct",
536    "subprocess",
537    "sunau",
538    "symtable",
539    "sys",
540    "sysconfig",
541    "syslog",
542    "tabnanny",
543    "tarfile",
544    "telnetlib",
545    "tempfile",
546    "termios",
547    "textwrap",
548    "this",
549    "threading",
550    "time",
551    "timeit",
552    "tkinter",
553    "token",
554    "tokenize",
555    "tomllib",
556    "trace",
557    "traceback",
558    "tracemalloc",
559    "tty",
560    "turtle",
561    "turtledemo",
562    "types",
563    "typing",
564    "unicodedata",
565    "unittest",
566    "urllib",
567    "uu",
568    "uuid",
569    "venv",
570    "warnings",
571    "wave",
572    "weakref",
573    "webbrowser",
574    "winreg",
575    "winsound",
576    "wsgiref",
577    "xdrlib",
578    "xml",
579    "xmlrpc",
580    "zipapp",
581    "zipfile",
582    "zipimport",
583    "zlib",
584    "zoneinfo",
585];
586
587/// The trailing dotted segment of a module path (`myproject.db` → `db`).
588fn last_segment(module: &str) -> &str {
589    module.rsplit('.').next().unwrap_or(module)
590}
591
592/// The number of leading dots on a `from`-import: `from ..pkg import x` → 2, absolute → 0.
593fn relative_level(node: &StmtImportFrom) -> usize {
594    node.level.map_or(0, |level| level.to_usize())
595}
596
597/// `true` for `TYPE_CHECKING` / `typing.TYPE_CHECKING`, the guard over type-only imports.
598fn is_type_checking(test: &Expr) -> bool {
599    match test {
600        Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
601        Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
602        _ => false,
603    }
604}
605
606/// The unit-under-test base name for a test file: `widget_test.py` → `widget`. Only
607/// `*_test.py` reaches here, so stripping the `_test` suffix is all it takes.
608fn unit_under_test_base(file: &Path) -> String {
609    let name = file
610        .file_name()
611        .and_then(|n| n.to_str())
612        .unwrap_or_default();
613    let stem = name.strip_suffix(".py").unwrap_or(name);
614    stem.strip_suffix("_test").unwrap_or(stem).to_string()
615}
616
617/// Walks one test file, collecting lint violations. `fixture_depth` tracks `@pytest.fixture`
618/// nesting, so `no-inline-patch` allows a patch there and flags one in a test body.
619struct LintVisitor<'a> {
620    file: &'a Path,
621    source: &'a str,
622    fixture_depth: usize,
623    /// The dist's own top-level package, or `None` when undiscoverable.
624    first_party: Option<&'a str>,
625    /// Local name → the dotted module path its import binds, for object patch targets.
626    imports: HashMap<String, String>,
627    violations: Vec<Violation>,
628}
629
630impl LintVisitor<'_> {
631    fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
632        self.violations.push(Violation {
633            file: self.file.to_path_buf(),
634            line: line_of(self.source, range.start()),
635            rule,
636            message: message.to_string(),
637        });
638    }
639
640    /// Run the parameter lint, and return whether this function is a fixture.
641    fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
642        let takes_monkeypatch = args
643            .posonlyargs
644            .iter()
645            .chain(&args.args)
646            .chain(&args.kwonlyargs)
647            .any(|arg| arg.def.arg.as_str() == "monkeypatch")
648            || arg_named(&args.vararg, "monkeypatch")
649            || arg_named(&args.kwarg, "monkeypatch");
650        if takes_monkeypatch {
651            self.report(
652                range,
653                "no-monkeypatch",
654                "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
655            );
656        }
657
658        decorators.iter().any(is_fixture_decorator)
659    }
660}
661
662impl Visitor for LintVisitor<'_> {
663    fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
664        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
665        if is_fixture {
666            self.fixture_depth += 1;
667        }
668        self.generic_visit_stmt_function_def(node);
669        if is_fixture {
670            self.fixture_depth -= 1;
671        }
672    }
673
674    fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
675        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
676        if is_fixture {
677            self.fixture_depth += 1;
678        }
679        self.generic_visit_stmt_async_function_def(node);
680        if is_fixture {
681            self.fixture_depth -= 1;
682        }
683    }
684
685    fn visit_expr_call(&mut self, node: ExprCall) {
686        // A fixture is the right place for a patch; a test body is not.
687        if is_patch_call(&node) && self.fixture_depth == 0 {
688            self.report(
689                node.range,
690                "no-inline-patch",
691                "patch is called inline in a test body; move it into a `pytest.fixture`",
692            );
693        }
694        // Both target rules fire regardless of fixture depth — a config constant is usually
695        // patched in one — and only on a statically resolved target.
696        if let Some(target) = patch_target(&node, &self.imports) {
697            if patches_constant(&target) {
698                self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
699            }
700            if let Some(pkg) = self.first_party {
701                if patches_first_party(&target, pkg) {
702                    self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
703                }
704            }
705        }
706        if is_environ_mutation_call(&node) {
707            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
708        }
709        self.generic_visit_expr_call(node);
710    }
711
712    fn visit_stmt_import(&mut self, node: StmtImport) {
713        for alias in &node.names {
714            match &alias.asname {
715                // `import X.Y as A` binds `A`; a plain `import X.Y` binds only the head `X`.
716                Some(asname) => {
717                    self.imports
718                        .insert(asname.to_string(), alias.name.to_string());
719                }
720                None => {
721                    let head = import_head(alias.name.as_str());
722                    self.imports.insert(head.to_string(), head.to_string());
723                }
724            }
725        }
726        self.generic_visit_stmt_import(node);
727    }
728
729    fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
730        // A relative import names no absolute module, so its bindings resolve nothing.
731        if relative_level(&node) == 0 {
732            if let Some(module) = &node.module {
733                for alias in &node.names {
734                    let bound = alias.asname.as_ref().unwrap_or(&alias.name);
735                    self.imports
736                        .insert(bound.to_string(), format!("{module}.{}", alias.name));
737                }
738            }
739        }
740        self.generic_visit_stmt_import_from(node);
741    }
742
743    // The generated `generic_visit_withitem` is a no-op, so a `with patch(...)`
744    // context expression is never walked unless we descend into it here.
745    fn visit_withitem(&mut self, node: WithItem) {
746        self.visit_expr(node.context_expr);
747        if let Some(optional_vars) = node.optional_vars {
748            self.visit_expr(*optional_vars);
749        }
750    }
751
752    fn visit_stmt_assign(&mut self, node: StmtAssign) {
753        if node.targets.iter().any(is_os_environ_subscript) {
754            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
755        }
756        self.generic_visit_stmt_assign(node);
757    }
758
759    fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
760        if is_os_environ_subscript(node.target.as_ref()) {
761            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
762        }
763        self.generic_visit_stmt_aug_assign(node);
764    }
765
766    fn visit_stmt_delete(&mut self, node: StmtDelete) {
767        if node.targets.iter().any(is_os_environ_subscript) {
768            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
769        }
770        self.generic_visit_stmt_delete(node);
771    }
772}
773
774/// `true` when a `*args` / `**kwargs` arg is named `name`.
775fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
776    arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
777}
778
779/// `true` for an `@pytest.fixture` / `@fixture` decorator, called or bare.
780fn is_fixture_decorator(decorator: &Expr) -> bool {
781    let target = match decorator {
782        Expr::Call(call) => call.func.as_ref(),
783        other => other,
784    };
785    match target {
786        Expr::Name(name) => name.id.as_str() == "fixture",
787        Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
788        _ => false,
789    }
790}
791
792/// The three call shapes of `unittest.mock.patch`, which name their target differently.
793enum PatchForm {
794    /// `patch("pkg.mod.attr")` — the target is the string-literal first argument.
795    Target,
796    /// `patch.object(base, "attr")` — the target is `base`'s module plus the attribute.
797    Object,
798    /// `patch.dict(base_or_string, ...)` — the target is the dict itself.
799    Dict,
800}
801
802/// The form of a `patch(...)` / `patch.object(...)` / `patch.dict(...)` call, plain or
803/// reached through a module (`mock.patch(...)`, `unittest.mock.patch`). `None` otherwise.
804fn patch_form(call: &ExprCall) -> Option<PatchForm> {
805    match call.func.as_ref() {
806        Expr::Name(name) if name.id.as_str() == "patch" => Some(PatchForm::Target),
807        Expr::Attribute(attr) => match attr.attr.as_str() {
808            "patch" => Some(PatchForm::Target),
809            "object" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Object),
810            "dict" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Dict),
811            _ => None,
812        },
813        _ => None,
814    }
815}
816
817/// `true` for any [`PatchForm`] call.
818fn is_patch_call(call: &ExprCall) -> bool {
819    patch_form(call).is_some()
820}
821
822/// `true` when an attribute's base resolves to `patch` — a `patch.object` receiver.
823fn attr_base_is_patch(expr: &Expr) -> bool {
824    match expr {
825        Expr::Name(name) => name.id.as_str() == "patch",
826        Expr::Attribute(attr) => attr.attr.as_str() == "patch",
827        _ => false,
828    }
829}
830
831const 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)";
832
833const 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";
834
835/// The string-literal first argument of a `patch(...)` call, the dotted target. `None` for
836/// a non-literal argument, which can't be classified deterministically.
837fn patch_string_target(call: &ExprCall) -> Option<&str> {
838    string_arg(call, 0)
839}
840
841/// The string literal at argument position `index` of a call, if that is what sits there.
842fn string_arg(call: &ExprCall, index: usize) -> Option<&str> {
843    if let Some(Expr::Constant(constant)) = call.args.get(index) {
844        if let Constant::Str(value) = &constant.value {
845            return Some(value.as_str());
846        }
847    }
848    None
849}
850
851/// The dotted segments of a plain attribute chain (`myproject.ledger` → `["myproject",
852/// "ledger"]`). `None` for a chain rooted in anything but a name.
853fn attr_chain_segments(expr: &Expr) -> Option<Vec<&str>> {
854    match expr {
855        Expr::Name(name) => Some(vec![name.id.as_str()]),
856        Expr::Attribute(attr) => {
857            let mut segments = attr_chain_segments(attr.value.as_ref())?;
858            segments.push(attr.attr.as_str());
859            Some(segments)
860        }
861        _ => None,
862    }
863}
864
865/// The dotted module path an object target names, its head replaced by the module its import
866/// binds (`ledger` → `myproject.ledger` after `from myproject import ledger`). `None` when no
867/// import binds the head — a local name has no statically known module.
868fn resolve_object_target(expr: &Expr, imports: &HashMap<String, String>) -> Option<String> {
869    let segments = attr_chain_segments(expr)?;
870    let (head, rest) = segments.split_first()?;
871    let mut target = imports.get(*head)?.clone();
872    for segment in rest {
873        target.push('.');
874        target.push_str(segment);
875    }
876    Some(target)
877}
878
879/// The dotted target a patch call names, resolved statically: the string literal for
880/// `patch(...)` (and a string-target `patch.dict`), the import-resolved first argument for
881/// the object forms. `None` when the target resists static resolution — nothing fires.
882fn patch_target(call: &ExprCall, imports: &HashMap<String, String>) -> Option<String> {
883    match patch_form(call)? {
884        PatchForm::Target => patch_string_target(call).map(str::to_owned),
885        PatchForm::Dict => patch_string_target(call)
886            .map(str::to_owned)
887            .or_else(|| resolve_object_target(call.args.first()?, imports)),
888        PatchForm::Object => {
889            let base = resolve_object_target(call.args.first()?, imports)?;
890            Some(match string_arg(call, 1) {
891                Some(attr) => format!("{base}.{attr}"),
892                None => base,
893            })
894        }
895    }
896}
897
898/// `true` when a patch target names an UPPER_CASE constant (`"pkg.cfg.CACHE_DIR"`).
899fn patches_constant(target: &str) -> bool {
900    target.rsplit('.').next().is_some_and(is_upper_constant)
901}
902
903/// `true` when patch `target`'s head segment names the first-party package `pkg`.
904fn patches_first_party(target: &str, pkg: &str) -> bool {
905    target
906        .split('.')
907        .next()
908        .is_some_and(|head| !head.is_empty() && head == pkg)
909}
910
911/// `true` for an ALL-CAPS name: uppercase letters, digits, underscores, one letter minimum.
912fn is_upper_constant(name: &str) -> bool {
913    !name.is_empty()
914        && name
915            .chars()
916            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
917        && name.chars().any(|c| c.is_ascii_uppercase())
918}
919
920const ENVIRON_MUTATION_MSG: &str =
921    "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
922
923/// `true` for the expression `os.environ`.
924fn is_os_environ(expr: &Expr) -> bool {
925    matches!(
926        expr,
927        Expr::Attribute(attr)
928            if attr.attr.as_str() == "environ"
929                && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
930    )
931}
932
933/// `true` for `os.environ[...]`, the form used as an assignment or `del` target.
934fn is_os_environ_subscript(expr: &Expr) -> bool {
935    matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
936}
937
938/// `true` for a mutating method call on `os.environ`, like `os.environ.update(...)`.
939fn is_environ_mutation_call(call: &ExprCall) -> bool {
940    matches!(
941        call.func.as_ref(),
942        Expr::Attribute(attr)
943            if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
944    )
945}
946
947/// `true` for a `dict` method that mutates in place.
948fn is_environ_mutator(method: &str) -> bool {
949    matches!(
950        method,
951        "update" | "pop" | "setdefault" | "clear" | "popitem"
952    )
953}
954
955/// The 1-based line containing byte `offset` in `source`.
956fn line_of(source: &str, offset: TextSize) -> usize {
957    let offset = (u32::from(offset) as usize).min(source.len());
958    source.as_bytes()[..offset]
959        .iter()
960        .filter(|&&byte| byte == b'\n')
961        .count()
962        + 1
963}
964
965/// The dist's own top-level import package: the nearest `pyproject.toml`'s `[project].name`,
966/// [normalized](normalize_dist_name). The walk up stops at a `.git` boundary so it can't
967/// escape into an unrelated project, and `None` means nothing is flagged rather than guessed.
968fn first_party_package(root: &Path) -> Option<String> {
969    for dir in root.ancestors() {
970        let candidate = dir.join("pyproject.toml");
971        if candidate.is_file() {
972            return read_project_name(&candidate).map(|name| normalize_dist_name(&name));
973        }
974        if dir.join(".git").exists() {
975            break;
976        }
977    }
978    None
979}
980
981/// `[project].name` from a `pyproject.toml`, if present and a string.
982fn read_project_name(path: &Path) -> Option<String> {
983    let contents = std::fs::read_to_string(path).ok()?;
984    let value: toml::Value = toml::from_str(&contents).ok()?;
985    value
986        .get("project")?
987        .get("name")?
988        .as_str()
989        .map(str::to_owned)
990}
991
992/// A distribution name as its import package name, PEP 503-flavoured: `My-Project` →
993/// `my_project`.
994fn normalize_dist_name(name: &str) -> String {
995    name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
996}
997
998fn collect_python_files(
999    dir: &Path,
1000    out: &mut Vec<PathBuf>,
1001    is_match: fn(&Path) -> bool,
1002) -> Result<()> {
1003    let entries =
1004        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
1005    for entry in entries {
1006        let path = entry
1007            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
1008            .path();
1009        if path.is_dir() {
1010            collect_python_files(&path, out, is_match)?;
1011        } else if is_match(&path) {
1012            out.push(path);
1013        }
1014    }
1015    Ok(())
1016}
1017
1018/// `true` for a file the integration lints scan: `*_test.py` or `conftest.py`. A legacy
1019/// `test_*.py` is ordinary source.
1020fn is_python_test_file(path: &Path) -> bool {
1021    let name = path
1022        .file_name()
1023        .and_then(|n| n.to_str())
1024        .unwrap_or_default();
1025    name == "conftest.py" || name.ends_with("_test.py")
1026}
1027
1028/// `true` for a colocated unit test: `*_test.py`. A legacy `test_*.py` is ordinary source,
1029/// and `conftest.py` holds fixtures rather than a unit.
1030fn is_python_unit_test_file(path: &Path) -> bool {
1031    let name = path
1032        .file_name()
1033        .and_then(|n| n.to_str())
1034        .unwrap_or_default();
1035    name.ends_with("_test.py")
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041    use std::sync::atomic::{AtomicU64, Ordering};
1042
1043    /// A throwaway directory, removed on drop — for the `pyproject.toml` discovery.
1044    struct TempDir(PathBuf);
1045
1046    impl TempDir {
1047        fn new() -> Self {
1048            static COUNTER: AtomicU64 = AtomicU64::new(0);
1049            let dir = std::env::temp_dir().join(format!(
1050                "tc-lint-{}-{}",
1051                std::process::id(),
1052                COUNTER.fetch_add(1, Ordering::Relaxed),
1053            ));
1054            std::fs::create_dir_all(&dir).unwrap();
1055            TempDir(dir)
1056        }
1057
1058        fn write(&self, name: &str, contents: &str) {
1059            let path = self.0.join(name);
1060            if let Some(parent) = path.parent() {
1061                std::fs::create_dir_all(parent).unwrap();
1062            }
1063            std::fs::write(path, contents).unwrap();
1064        }
1065    }
1066
1067    impl Drop for TempDir {
1068        fn drop(&mut self) {
1069            let _ = std::fs::remove_dir_all(&self.0);
1070        }
1071    }
1072
1073    #[test]
1074    fn normalize_dist_name_maps_to_import_name() {
1075        assert_eq!(normalize_dist_name("My-Project"), "my_project");
1076        assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1077        assert_eq!(normalize_dist_name("  myproject  "), "myproject");
1078        assert_eq!(normalize_dist_name("myproject"), "myproject");
1079    }
1080
1081    /// Parse `src` (a single expression statement) and return its call.
1082    fn parse_call(src: &str) -> ExprCall {
1083        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1084        let stmt = suite.into_iter().next().expect("one statement");
1085        (*stmt.expect_expr_stmt().value).expect_call_expr()
1086    }
1087
1088    #[test]
1089    fn patch_target_only_reads_string_literals_for_the_string_form() {
1090        let imports = HashMap::new();
1091        let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1092        assert_eq!(
1093            patch_target(&str_call, &imports).as_deref(),
1094            Some("pkg.mod.attr")
1095        );
1096        // A name in `patch(...)` holds a string, which static resolution cannot read.
1097        let name_call = parse_call("patch(target)\n");
1098        assert_eq!(patch_target(&name_call, &imports), None);
1099        let int_call = parse_call("patch(42)\n");
1100        assert_eq!(patch_target(&int_call, &imports), None);
1101        let empty_call = parse_call("patch()\n");
1102        assert_eq!(patch_target(&empty_call, &imports), None);
1103    }
1104
1105    /// An import map binding the names the object-form snippets use.
1106    fn object_form_imports() -> HashMap<String, String> {
1107        HashMap::from([
1108            ("ledger".to_string(), "myproject.ledger".to_string()),
1109            ("myproject".to_string(), "myproject".to_string()),
1110            ("cfg".to_string(), "myproject.cfg".to_string()),
1111        ])
1112    }
1113
1114    #[test]
1115    fn patch_target_resolves_object_forms_through_imports() {
1116        let imports = object_form_imports();
1117        let imported_name = parse_call("patch.object(ledger, \"record\")\n");
1118        assert_eq!(
1119            patch_target(&imported_name, &imports).as_deref(),
1120            Some("myproject.ledger.record")
1121        );
1122        let dotted_module = parse_call("patch.object(myproject.ledger, \"record\")\n");
1123        assert_eq!(
1124            patch_target(&dotted_module, &imports).as_deref(),
1125            Some("myproject.ledger.record")
1126        );
1127        // A non-literal attribute still names the base module, enough for the first-party rule.
1128        let name_attr = parse_call("patch.object(ledger, attr)\n");
1129        assert_eq!(
1130            patch_target(&name_attr, &imports).as_deref(),
1131            Some("myproject.ledger")
1132        );
1133        let dict_object = parse_call("patch.dict(cfg.SETTINGS, {})\n");
1134        assert_eq!(
1135            patch_target(&dict_object, &imports).as_deref(),
1136            Some("myproject.cfg.SETTINGS")
1137        );
1138        let dict_string = parse_call("patch.dict(\"pkg.cfg.FLAGS\", {})\n");
1139        assert_eq!(
1140            patch_target(&dict_string, &imports).as_deref(),
1141            Some("pkg.cfg.FLAGS")
1142        );
1143    }
1144
1145    #[test]
1146    fn patch_target_declines_a_base_bound_by_no_import() {
1147        let imports = object_form_imports();
1148        let call_base = parse_call("patch.object(get_mod(), \"x\")\n");
1149        assert_eq!(patch_target(&call_base, &imports), None);
1150        let unbound_name = parse_call("patch.object(client, \"send\")\n");
1151        assert_eq!(patch_target(&unbound_name, &imports), None);
1152        let empty = parse_call("patch.object()\n");
1153        assert_eq!(patch_target(&empty, &imports), None);
1154    }
1155
1156    /// The imports a [`LintVisitor`] records for `src`.
1157    fn collect_imports(src: &str) -> HashMap<String, String> {
1158        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1159        let mut visitor = LintVisitor {
1160            file: Path::new("t.py"),
1161            source: src,
1162            fixture_depth: 0,
1163            first_party: None,
1164            imports: HashMap::new(),
1165            violations: Vec::new(),
1166        };
1167        for stmt in suite {
1168            visitor.visit_stmt(stmt);
1169        }
1170        visitor.imports
1171    }
1172
1173    #[test]
1174    fn lint_visitor_binds_imports_to_their_modules() {
1175        let imports = collect_imports(
1176            "import myproject.ledger\n\
1177             import myproject.config as cfg\n\
1178             from myproject import ledger\n\
1179             from myproject import charge as ch\n\
1180             from . import rel\n",
1181        );
1182        assert_eq!(
1183            imports.get("myproject").map(String::as_str),
1184            Some("myproject")
1185        );
1186        assert_eq!(
1187            imports.get("cfg").map(String::as_str),
1188            Some("myproject.config")
1189        );
1190        assert_eq!(
1191            imports.get("ledger").map(String::as_str),
1192            Some("myproject.ledger")
1193        );
1194        assert_eq!(
1195            imports.get("ch").map(String::as_str),
1196            Some("myproject.charge")
1197        );
1198        assert_eq!(imports.get("rel"), None);
1199    }
1200
1201    /// Build a `from <source> import <symbols>` record (`source: None` → relative).
1202    fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1203        ImportRecord {
1204            display: source.unwrap_or(".rel").to_string(),
1205            line: 1,
1206            is_uut: false,
1207            symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1208            source: source.map(str::to_string),
1209            module: None,
1210        }
1211    }
1212
1213    fn targets(list: &[&str]) -> Vec<String> {
1214        list.iter().map(|s| (*s).to_string()).collect()
1215    }
1216
1217    #[test]
1218    fn is_mocked_requires_every_symbol_at_the_import_module() {
1219        let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1220        // Only `record` patched → the un-mocked `erase` leaves the import un-mocked.
1221        assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1222        assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1223    }
1224
1225    #[test]
1226    fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1227        let rec = from_import(Some("pkg.ledger"), &["record"]);
1228        // Same last segment, different module → not mocked.
1229        assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1230        let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1231        assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1232        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1233    }
1234
1235    #[test]
1236    fn is_mocked_relative_import_accepts_a_last_segment_match() {
1237        // A relative import has no module to compare, so a last-segment match is accepted.
1238        let rec = from_import(None, &["record"]);
1239        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1240        assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1241    }
1242
1243    #[test]
1244    fn is_mocked_module_import_matches_a_patch_reaching_in() {
1245        let rec = ImportRecord {
1246            display: "pkg.db".to_string(),
1247            line: 1,
1248            is_uut: false,
1249            symbols: Vec::new(),
1250            source: None,
1251            module: Some("pkg.db".to_string()),
1252        };
1253        assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1254        assert!(rec.is_mocked(&targets(&["pkg.db"])));
1255        assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1256        let empty = from_import(Some("pkg.mod"), &[]);
1257        assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1258    }
1259
1260    #[test]
1261    fn patches_first_party_matches_head_segment() {
1262        assert!(patches_first_party("myproject.ledger.record", "myproject"));
1263        assert!(patches_first_party("myproject", "myproject"));
1264        assert!(!patches_first_party("requests.get", "myproject"));
1265        assert!(!patches_first_party("myproject_extra.x", "myproject"));
1266        assert!(!patches_first_party("", "myproject"));
1267        assert!(!patches_first_party(".leading", "myproject"));
1268    }
1269
1270    #[test]
1271    fn first_party_package_reads_pyproject_name() {
1272        let tree = TempDir::new();
1273        tree.write(
1274            "pyproject.toml",
1275            "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1276        );
1277        assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1278    }
1279
1280    #[test]
1281    fn first_party_package_is_none_without_a_project_name() {
1282        let tree = TempDir::new();
1283        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1284        tree.write(".git", "");
1285        assert_eq!(first_party_package(&tree.0), None);
1286    }
1287
1288    #[test]
1289    fn first_party_package_is_none_when_absent() {
1290        let tree = TempDir::new();
1291        assert_eq!(first_party_package(&tree.0), None);
1292    }
1293
1294    /// The displays of the imports `source` leaves un-mocked.
1295    fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1296        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1297        let mut visitor = UnitIsolationVisitor {
1298            source,
1299            first_party,
1300            base,
1301            type_checking_depth: 0,
1302            imports: Vec::new(),
1303            patch_targets: Vec::new(),
1304        };
1305        for stmt in suite {
1306            visitor.visit_stmt(stmt);
1307        }
1308        visitor
1309            .imports
1310            .iter()
1311            .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1312            .map(|i| i.display.clone())
1313            .collect()
1314    }
1315
1316    #[test]
1317    fn import_head_and_last_segment() {
1318        assert_eq!(import_head("myproject.db.conn"), "myproject");
1319        assert_eq!(import_head("requests"), "requests");
1320        assert_eq!(last_segment("myproject.db.conn"), "conn");
1321        assert_eq!(last_segment("widget"), "widget");
1322    }
1323
1324    #[test]
1325    fn unit_under_test_base_strips_test_suffix() {
1326        assert_eq!(
1327            unit_under_test_base(Path::new("pkg/widget_test.py")),
1328            "widget"
1329        );
1330        // Only `*_test.py` reaches here, so a legacy `test_*.py` keeps its prefix.
1331        assert_eq!(
1332            unit_under_test_base(Path::new("test_widget.py")),
1333            "test_widget"
1334        );
1335        assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1336    }
1337
1338    #[test]
1339    fn recognizes_python_unit_test_files() {
1340        assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1341        assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1342        assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1343        assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1344        assert!(!is_python_unit_test_file(Path::new("widget.py")));
1345    }
1346
1347    #[test]
1348    fn visitor_flags_first_party_and_external_collaborators() {
1349        // The UUT is left alone; the first-party and third-party imports are flagged.
1350        let found = unmocked(
1351            "widget",
1352            "myproject",
1353            "from myproject.widget import build\n\
1354             from myproject.ledger import record\n\
1355             import requests\n",
1356        );
1357        assert_eq!(
1358            found,
1359            vec!["myproject.ledger".to_string(), "requests".to_string()]
1360        );
1361    }
1362
1363    #[test]
1364    fn visitor_clears_a_mocked_collaborator() {
1365        let found = unmocked(
1366            "widget",
1367            "myproject",
1368            "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
1369        );
1370        assert!(found.is_empty(), "got: {found:?}");
1371    }
1372
1373    #[test]
1374    fn visitor_flags_a_wrong_module_patch() {
1375        // A patch sharing only the last segment names a different module, so `record`
1376        // stays an un-mocked collaborator.
1377        let found = unmocked(
1378            "widget",
1379            "myproject",
1380            "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
1381        );
1382        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1383    }
1384
1385    #[test]
1386    fn visitor_flags_a_partly_mocked_multi_symbol_import() {
1387        // Patching only `record` leaves the sibling `erase` a real collaborator.
1388        let found = unmocked(
1389            "widget",
1390            "myproject",
1391            "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
1392        );
1393        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1394        let both = unmocked(
1395            "widget",
1396            "myproject",
1397            "from myproject.ledger import record, erase\n\
1398             patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
1399        );
1400        assert!(both.is_empty(), "got: {both:?}");
1401    }
1402
1403    #[test]
1404    fn visitor_handles_module_and_relative_imports() {
1405        assert_eq!(
1406            unmocked("widget", "myproject", "import myproject.db\n"),
1407            vec!["myproject.db".to_string()]
1408        );
1409        assert!(unmocked(
1410            "widget",
1411            "myproject",
1412            "import myproject.db\npatch(\"myproject.db.connect\")\n"
1413        )
1414        .is_empty());
1415        assert_eq!(
1416            unmocked("widget", "myproject", "from .ledger import record\n"),
1417            vec![".ledger".to_string()]
1418        );
1419        assert_eq!(
1420            unmocked(
1421                "widget",
1422                "myproject",
1423                "from . import ledger\nfrom . import widget\n"
1424            ),
1425            vec![".ledger".to_string()]
1426        );
1427    }
1428
1429    #[test]
1430    fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
1431        // A bare `from . import …` names the package's own re-export surface, the SUT.
1432        assert!(unmocked(
1433            "__init__",
1434            "myproject",
1435            "from . import Thing, __all__, __version__\n"
1436        )
1437        .is_empty());
1438        // Reaching around the barrel into a sibling module is still a collaborator.
1439        assert_eq!(
1440            unmocked("__init__", "myproject", "from .core import Thing\n"),
1441            vec![".core".to_string()]
1442        );
1443        // `from .. import x` resolves to the parent package, not the SUT file.
1444        assert_eq!(
1445            unmocked("__init__", "myproject", "from .. import sibling\n"),
1446            vec!["..sibling".to_string()]
1447        );
1448        // The barrel shortcut is scoped to the `__init__` base.
1449        assert_eq!(
1450            unmocked("widget", "myproject", "from . import ledger\n"),
1451            vec![".ledger".to_string()]
1452        );
1453    }
1454
1455    #[test]
1456    fn visitor_skips_type_checking_imports() {
1457        // A TYPE_CHECKING import is type-only; the runtime `else` import is still seen.
1458        let found = unmocked(
1459            "widget",
1460            "myproject",
1461            "if TYPE_CHECKING:\n    from myproject.models import Widget\nelse:\n    from myproject.ledger import record\n",
1462        );
1463        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1464    }
1465
1466    #[test]
1467    fn is_checked_import_classifies_origins() {
1468        assert!(is_checked_import("myproject", "myproject")); // first-party
1469        assert!(!is_checked_import("pytest", "myproject")); // test framework
1470        assert!(!is_checked_import("_pytest", "myproject"));
1471        assert!(is_checked_import("subprocess", "myproject")); // effectful stdlib
1472        assert!(is_checked_import("socket", "myproject"));
1473        assert!(!is_checked_import("json", "myproject")); // pure stdlib
1474        assert!(!is_checked_import("dataclasses", "myproject"));
1475        assert!(is_checked_import("requests", "myproject")); // third-party
1476        assert!(is_checked_import("stripe", "myproject"));
1477        // A dual-nature head stays pure — the patch convention catches it, not the import.
1478        assert!(!is_checked_import("os", "myproject"));
1479        assert!(!is_checked_import("pathlib", "myproject"));
1480        assert!(!is_checked_import("datetime", "myproject"));
1481    }
1482
1483    #[test]
1484    fn visitor_flags_external_collaborators() {
1485        let found = unmocked(
1486            "widget",
1487            "myproject",
1488            "import requests\nimport subprocess\nimport json\nimport pytest\n",
1489        );
1490        assert_eq!(found.len(), 2, "got: {found:?}");
1491        assert!(found.contains(&"requests".to_string()));
1492        assert!(found.contains(&"subprocess".to_string()));
1493    }
1494
1495    #[test]
1496    fn visitor_type_checking_variants_and_plain_if() {
1497        // The attribute form guards type-only imports too.
1498        assert!(unmocked(
1499            "widget",
1500            "myproject",
1501            "if typing.TYPE_CHECKING:\n    from myproject.models import W\n    import myproject.db\n"
1502        )
1503        .is_empty());
1504        // A plain `if` is walked normally; its import is still a collaborator.
1505        assert_eq!(
1506            unmocked(
1507                "widget",
1508                "myproject",
1509                "if ready == 1:\n    from myproject.ledger import record\n"
1510            ),
1511            vec!["myproject.ledger".to_string()]
1512        );
1513    }
1514
1515    #[test]
1516    fn find_unit_isolation_without_pyproject_reports_nothing() {
1517        let tree = TempDir::new();
1518        tree.write("widget_test.py", "from myproject.ledger import record\n");
1519        tree.write(".git", "");
1520        assert!(find_unit_isolation_violations(&tree.0)
1521            .expect("a readable tree should succeed")
1522            .is_empty());
1523    }
1524
1525    #[test]
1526    fn find_unit_isolation_walks_subdirs_and_flags() {
1527        let tree = TempDir::new();
1528        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1529        tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
1530        let found =
1531            find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
1532        assert_eq!(found.len(), 1, "got: {found:?}");
1533        assert_eq!(found[0].rule, "unmocked-collaborator");
1534        assert!(found[0].message.contains("myproject.ledger"));
1535    }
1536
1537    #[test]
1538    fn recognizes_python_test_files() {
1539        assert!(is_python_test_file(Path::new("widget_test.py")));
1540        assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
1541        assert!(is_python_test_file(Path::new("conftest.py")));
1542        assert!(!is_python_test_file(Path::new("test_widget.py")));
1543    }
1544
1545    #[test]
1546    fn ignores_non_test_files() {
1547        assert!(!is_python_test_file(Path::new("widget.py")));
1548        assert!(!is_python_test_file(Path::new("conftest.pyi")));
1549        assert!(!is_python_test_file(Path::new("README.md")));
1550        assert!(!is_python_test_file(Path::new("testing.py")));
1551    }
1552
1553    #[test]
1554    fn line_of_counts_newlines() {
1555        let src = "a\nb\nc\n";
1556        assert_eq!(line_of(src, TextSize::from(0)), 1);
1557        assert_eq!(line_of(src, TextSize::from(2)), 2);
1558        assert_eq!(line_of(src, TextSize::from(4)), 3);
1559    }
1560
1561    #[test]
1562    fn recognizes_environ_mutators() {
1563        assert!(is_environ_mutator("update"));
1564        assert!(is_environ_mutator("pop"));
1565        assert!(is_environ_mutator("clear"));
1566        assert!(!is_environ_mutator("get"));
1567        assert!(!is_environ_mutator("keys"));
1568    }
1569
1570    /// The rules the suite lint reports for `source`, in report order.
1571    fn lint_rules(source: &str) -> Vec<&'static str> {
1572        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1573        let mut visitor = LintVisitor {
1574            file: Path::new("t.py"),
1575            source,
1576            fixture_depth: 0,
1577            first_party: Some("myproject"),
1578            imports: HashMap::new(),
1579            violations: Vec::new(),
1580        };
1581        for stmt in suite {
1582            visitor.visit_stmt(stmt);
1583        }
1584        visitor.violations.iter().map(|v| v.rule).collect()
1585    }
1586
1587    #[test]
1588    fn an_async_fixture_shelters_a_patch_that_an_async_test_does_not() {
1589        assert!(
1590            lint_rules("@pytest.fixture\nasync def client():\n    patch(\"pkg.mod.attr\")\n")
1591                .is_empty()
1592        );
1593        assert_eq!(
1594            lint_rules("async def widget_test():\n    patch(\"pkg.mod.attr\")\n"),
1595            vec!["no-inline-patch"]
1596        );
1597        assert_eq!(
1598            lint_rules("async def widget_test(monkeypatch):\n    pass\n"),
1599            vec!["no-monkeypatch"]
1600        );
1601    }
1602
1603    #[test]
1604    fn an_augmented_assignment_to_environ_is_a_mutation() {
1605        assert_eq!(
1606            lint_rules("def widget_test():\n    os.environ[\"PATH\"] += \":/x\"\n"),
1607            vec!["no-environ-mutation"]
1608        );
1609        assert!(lint_rules("def widget_test():\n    total += 1\n").is_empty());
1610    }
1611
1612    #[test]
1613    fn a_fixture_decorator_is_a_bare_name_or_an_attribute() {
1614        assert!(
1615            lint_rules("@fixture\ndef client():\n    patch(\"pkg.mod.attr\")\n").is_empty(),
1616            "a bare `@fixture` shelters the patch"
1617        );
1618        assert_eq!(
1619            lint_rules("@registry[\"fixture\"]\ndef client():\n    patch(\"pkg.mod.attr\")\n"),
1620            vec!["no-inline-patch"],
1621            "a subscripted decorator is not a fixture"
1622        );
1623    }
1624
1625    #[test]
1626    fn patch_object_is_recognized_only_through_a_patch_receiver() {
1627        assert_eq!(
1628            lint_rules("def widget_test():\n    mock.patch.object(svc, \"send\")\n"),
1629            vec!["no-inline-patch"]
1630        );
1631        assert!(
1632            lint_rules("def widget_test():\n    helpers[0].object(svc, \"send\")\n").is_empty(),
1633            "a subscripted receiver is not `patch`"
1634        );
1635        assert!(
1636            lint_rules("def widget_test():\n    helpers[0](\"pkg.mod.attr\")\n").is_empty(),
1637            "a subscripted callee is not a patch call"
1638        );
1639    }
1640
1641    #[test]
1642    fn find_suite_without_a_tests_directory_reports_nothing() {
1643        let tree = TempDir::new();
1644        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1645        assert!(find_suite_violations(&tree.0)
1646            .expect("a readable tree should succeed")
1647            .is_empty());
1648    }
1649
1650    #[test]
1651    fn find_unit_isolation_without_a_project_name_reports_nothing() {
1652        let tree = TempDir::new();
1653        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1654        tree.write("widget_test.py", "from myproject.ledger import record\n");
1655        assert!(find_unit_isolation_violations(&tree.0)
1656            .expect("a readable tree should succeed")
1657            .is_empty());
1658    }
1659
1660    #[test]
1661    fn recognizes_upper_constants() {
1662        assert!(is_upper_constant("CACHE_DIR"));
1663        assert!(is_upper_constant("DEBUG"));
1664        assert!(is_upper_constant("MAX_2"));
1665        assert!(!is_upper_constant("cache_dir"));
1666        assert!(!is_upper_constant("CacheDir"));
1667        assert!(!is_upper_constant("fetch"));
1668        assert!(!is_upper_constant(""));
1669        assert!(!is_upper_constant("_"));
1670        assert!(!is_upper_constant("123"));
1671    }
1672}