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::hash_map::Entry;
6use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use anyhow::{anyhow, Context, Result};
10use rustpython_ast::Visitor;
11use rustpython_parser::ast::{
12    self, Arg, Arguments, Constant, Expr, ExprCall, StmtAssign, StmtAsyncFunctionDef,
13    StmtAugAssign, StmtDelete, StmtFunctionDef, StmtIf, StmtImport, StmtImportFrom, WithItem,
14};
15use rustpython_parser::text_size::{TextRange, TextSize};
16use rustpython_parser::Parse;
17
18// Re-exported so `testing_conventions::lint::Violation` still resolves.
19pub use crate::violation::Violation;
20
21/// Every lint violation in the Python test files under `root`, sorted by `(file, line)`. A
22/// *Python test file* is `*_test.py` or `conftest.py`, where fixtures live; a legacy
23/// `test_*.py` is ordinary source. A file that cannot be read or parsed is an error.
24pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
25    let root = root.as_ref();
26    // Resolved once for the whole tree; `None` means `no-first-party-patch` flags nothing.
27    let manifest = first_party_manifest(root);
28    let mut files = Vec::new();
29    collect_python_files(root, &mut files, is_python_test_file)?;
30    files.sort();
31
32    let mut violations = Vec::new();
33    for file in &files {
34        let source = std::fs::read_to_string(file)
35            .with_context(|| format!("reading test file `{}`", file.display()))?;
36        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
37            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
38        let mut visitor = LintVisitor {
39            file,
40            source: &source,
41            fixture_depth: 0,
42            first_party: manifest.as_ref().map(|(name, _)| name.as_str()),
43            source_root: manifest.as_ref().map(|(_, dir)| dir.as_path()),
44            imports: HashMap::new(),
45            declared_modules: HashSet::new(),
46            violations: Vec::new(),
47        };
48        for stmt in suite {
49            visitor.visit_stmt(stmt);
50        }
51        violations.append(&mut visitor.violations);
52    }
53
54    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
55    Ok(violations)
56}
57
58const UNKNOWN_TIER_MSG: &str = "test file sits under `tests/` outside the standard suite tiers; \
59     a suite lives in `tests/integration/` or `tests/e2e/`";
60
61/// Every lint violation in `package_root`'s suite tiers, sorted by `(file, line)`.
62/// `tests/integration/` and `tests/e2e/` both run first-party code for real; a `*_test.py`
63/// under `tests/` outside them is `unknown-tier` rather than silently unscanned.
64pub fn find_suite_violations(package_root: &Path) -> Result<Vec<Violation>> {
65    let tests = package_root.join("tests");
66    let mut violations = Vec::new();
67    let tiers = ["integration", "e2e"].map(|tier| tests.join(tier));
68    for tier in &tiers {
69        if tier.is_dir() {
70            violations.extend(find_violations(tier)?);
71        }
72    }
73    if tests.is_dir() {
74        let mut strays = Vec::new();
75        collect_python_files(&tests, &mut strays, is_python_unit_test_file)?;
76        strays.retain(|file| !tiers.iter().any(|tier| file.starts_with(tier)));
77        for file in strays {
78            violations.push(Violation {
79                file,
80                line: 1,
81                rule: "unknown-tier",
82                message: UNKNOWN_TIER_MSG.to_string(),
83            });
84        }
85    }
86    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
87    Ok(violations)
88}
89
90/// Every `unmocked-collaborator` violation under `root` — a collaborator a `*_test.py`
91/// imports without mocking it — sorted by `(file, line)`. First-party is the dist's own
92/// package ([`first_party_package`]); a tree that declares none reports nothing.
93pub fn find_unit_isolation_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
94    let root = root.as_ref();
95    // Resolved from the same `pyproject.toml` as `first_party_package`, so a tree with no
96    // manifest exits here and the package name is the only remaining unknown.
97    let Some(tests) = crate::tiers::suite_tests_dir(root, "pyproject.toml") else {
98        return Ok(Vec::new());
99    };
100    let Some(first_party) = first_party_package(root) else {
101        return Ok(Vec::new());
102    };
103    let mut files = Vec::new();
104    collect_python_files(root, &mut files, is_python_unit_test_file)?;
105    // The suite tiers run first-party code for real, so their files are never unit subjects.
106    files.retain(|file| !file.starts_with(&tests));
107    files.sort();
108
109    let mut violations = Vec::new();
110    for file in &files {
111        let source = std::fs::read_to_string(file)
112            .with_context(|| format!("reading test file `{}`", file.display()))?;
113        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
114            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
115        let base = unit_under_test_base(file);
116        let mut visitor = UnitIsolationVisitor {
117            source: &source,
118            first_party: &first_party,
119            base: &base,
120            type_checking_depth: 0,
121            imports: Vec::new(),
122            patch_targets: Vec::new(),
123        };
124        for stmt in suite {
125            visitor.visit_stmt(stmt);
126        }
127        for import in &visitor.imports {
128            if import.is_uut || import.is_mocked(&visitor.patch_targets) {
129                continue;
130            }
131            violations.push(Violation {
132                file: file.to_path_buf(),
133                line: import.line,
134                rule: "unmocked-collaborator",
135                message: format!(
136                    "unit test imports `{}` without mocking it — a unit test isolates the \
137                     unit under test, so mock every collaborator (patch it by string in a \
138                     fixture)",
139                    import.display
140                ),
141            });
142        }
143    }
144
145    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
146    Ok(violations)
147}
148
149/// One import seen in a unit test, with what it takes to decide whether it is mocked.
150struct ImportRecord {
151    /// The module path to name in the message (`myproject.ledger`, `.ledger`).
152    display: String,
153    line: usize,
154    is_uut: bool,
155    /// For `from X import a, b` — the bound symbols, each of which must be mocked.
156    symbols: Vec<String>,
157    /// For an **absolute** `from X import a, b` — the source module `X`, which a mocking
158    /// patch must name. `None` for a relative `from`-import, which has no module to compare.
159    source: Option<String>,
160    /// For `import X.Y` — the module path (a patch reaching into it counts as a mock).
161    module: Option<String>,
162}
163
164impl ImportRecord {
165    /// `true` when some `patch("…")` target mocks this import: a plain `import X.Y` by any
166    /// patch reaching into `X.Y`, a `from X import a, b` only when **every** bound symbol
167    /// is patched at `X` itself.
168    fn is_mocked(&self, patch_targets: &[String]) -> bool {
169        if let Some(module) = &self.module {
170            let prefix = format!("{module}.");
171            return patch_targets
172                .iter()
173                .any(|target| target == module || target.starts_with(&prefix));
174        }
175        if self.symbols.is_empty() {
176            return false;
177        }
178        self.symbols.iter().all(|symbol| {
179            patch_targets
180                .iter()
181                .any(|target| self.symbol_is_mocked(target, symbol))
182        })
183    }
184
185    /// `true` when `target`'s last dotted segment is `symbol` and — for an absolute import
186    /// — its module path is the import's own [`source`](Self::source).
187    fn symbol_is_mocked(&self, target: &str, symbol: &str) -> bool {
188        let Some(module) = target.strip_suffix(&format!(".{symbol}")) else {
189            return false;
190        };
191        match &self.source {
192            Some(source) => module == source,
193            None => true,
194        }
195    }
196}
197
198/// Walks one unit test, collecting its imports and every `patch("…")` string target so
199/// [`find_unit_isolation_violations`] can pair them. An `if TYPE_CHECKING:` import is erased
200/// at runtime and skipped.
201struct UnitIsolationVisitor<'a> {
202    source: &'a str,
203    first_party: &'a str,
204    base: &'a str,
205    type_checking_depth: usize,
206    imports: Vec<ImportRecord>,
207    patch_targets: Vec<String>,
208}
209
210impl Visitor for UnitIsolationVisitor<'_> {
211    fn visit_stmt_import(&mut self, node: StmtImport) {
212        if self.type_checking_depth == 0 {
213            let line = line_of(self.source, node.range.start());
214            for alias in &node.names {
215                let module = alias.name.as_str();
216                if is_checked_import(import_head(module), self.first_party) {
217                    self.imports.push(ImportRecord {
218                        display: module.to_string(),
219                        line,
220                        is_uut: last_segment(module) == self.base,
221                        symbols: Vec::new(),
222                        source: None,
223                        module: Some(module.to_string()),
224                    });
225                }
226            }
227        }
228        self.generic_visit_stmt_import(node);
229    }
230
231    fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
232        if self.type_checking_depth == 0 {
233            let level = relative_level(&node);
234            let module = node.module.as_ref().map(|m| m.as_str());
235            // A relative import is first-party; an absolute one is judged by its head.
236            let should_check = level > 0
237                || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
238            if should_check {
239                let line = line_of(self.source, node.range.start());
240                let dots = ".".repeat(level);
241                match module {
242                    // `from <module> import a, b` — the bound symbols are the collaborators.
243                    Some(module) => self.imports.push(ImportRecord {
244                        display: format!("{dots}{module}"),
245                        line,
246                        is_uut: last_segment(module) == self.base,
247                        symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
248                        source: (level == 0).then(|| module.to_string()),
249                        module: None,
250                    }),
251                    // `from . import sub` — each name is a submodule.
252                    None => {
253                        // In `__init___test.py` a bare `from . import …` names the
254                        // package's own re-export surface — the unit under test itself.
255                        let barrel_sut = self.base == "__init__" && level == 1;
256                        for alias in &node.names {
257                            let name = alias.name.as_str();
258                            self.imports.push(ImportRecord {
259                                display: format!("{dots}{name}"),
260                                line,
261                                is_uut: barrel_sut || name == self.base,
262                                symbols: vec![name.to_string()],
263                                source: None,
264                                module: None,
265                            });
266                        }
267                    }
268                }
269            }
270        }
271        self.generic_visit_stmt_import_from(node);
272    }
273
274    fn visit_expr_call(&mut self, node: ExprCall) {
275        if is_patch_call(&node) {
276            if let Some(target) = patch_string_target(&node) {
277                self.patch_targets.push(target.to_string());
278            }
279        }
280        self.generic_visit_expr_call(node);
281    }
282
283    fn visit_stmt_if(&mut self, node: StmtIf) {
284        // An `if TYPE_CHECKING:` body is type-only; its runtime `else` is still walked.
285        if is_type_checking(node.test.as_ref()) {
286            self.type_checking_depth += 1;
287            for stmt in node.body {
288                self.visit_stmt(stmt);
289            }
290            self.type_checking_depth -= 1;
291            for stmt in node.orelse {
292                self.visit_stmt(stmt);
293            }
294        } else {
295            self.generic_visit_stmt_if(node);
296        }
297    }
298}
299
300/// The leading dotted segment of a module path (`myproject.db` → `myproject`).
301fn import_head(module: &str) -> &str {
302    module.split('.').next().unwrap_or(module)
303}
304
305/// `true` when an import head names a checked collaborator — the dist package, a third-party
306/// package, or effectful stdlib. The test framework and pure stdlib are not collaborators.
307fn is_checked_import(head: &str, first_party: &str) -> bool {
308    if head == first_party {
309        return true;
310    }
311    if TEST_FRAMEWORK.contains(&head) {
312        return false;
313    }
314    if EFFECTFUL_STDLIB.contains(&head) {
315        return true;
316    }
317    if STDLIB_MODULES.contains(&head) {
318        return false;
319    }
320    true // an unrecognized head is a third-party package
321}
322
323/// The test harness, never a collaborator. `unittest` is stdlib; these are the rest.
324const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
325
326/// Standard-library modules that are **effectful at the head**. Dual-nature heads (`os`,
327/// `pathlib`, `datetime`, `time`, `io`, `logging`, `threading`) are excluded: a pure use
328/// can't be told from an effectful one at the import, so the patch convention catches those.
329const EFFECTFUL_STDLIB: &[&str] = &[
330    "asynchat",
331    "asyncore",
332    "ctypes",
333    "curses",
334    "dbm",
335    "fcntl",
336    "ftplib",
337    "imaplib",
338    "mmap",
339    "msvcrt",
340    "multiprocessing",
341    "nis",
342    "nntplib",
343    "ossaudiodev",
344    "poplib",
345    "pty",
346    "random",
347    "secrets",
348    "select",
349    "selectors",
350    "signal",
351    "smtpd",
352    "smtplib",
353    "socket",
354    "socketserver",
355    "spwd",
356    "sqlite3",
357    "ssl",
358    "subprocess",
359    "syslog",
360    "telnetlib",
361    "termios",
362    "tty",
363    "webbrowser",
364    "winreg",
365    "winsound",
366];
367
368/// Python's `sys.stdlib_module_names`, which tells pure stdlib from a third-party package.
369/// The [`EFFECTFUL_STDLIB`] subset is what is actually flagged.
370const STDLIB_MODULES: &[&str] = &[
371    "__future__",
372    "_abc",
373    "_aix_support",
374    "_ast",
375    "_asyncio",
376    "_bisect",
377    "_blake2",
378    "_bz2",
379    "_codecs",
380    "_codecs_cn",
381    "_codecs_hk",
382    "_codecs_iso2022",
383    "_codecs_jp",
384    "_codecs_kr",
385    "_codecs_tw",
386    "_collections",
387    "_collections_abc",
388    "_compat_pickle",
389    "_compression",
390    "_contextvars",
391    "_crypt",
392    "_csv",
393    "_ctypes",
394    "_curses",
395    "_curses_panel",
396    "_datetime",
397    "_dbm",
398    "_decimal",
399    "_elementtree",
400    "_frozen_importlib",
401    "_frozen_importlib_external",
402    "_functools",
403    "_gdbm",
404    "_hashlib",
405    "_heapq",
406    "_imp",
407    "_io",
408    "_json",
409    "_locale",
410    "_lsprof",
411    "_lzma",
412    "_markupbase",
413    "_md5",
414    "_msi",
415    "_multibytecodec",
416    "_multiprocessing",
417    "_opcode",
418    "_operator",
419    "_osx_support",
420    "_overlapped",
421    "_pickle",
422    "_posixshmem",
423    "_posixsubprocess",
424    "_py_abc",
425    "_pydatetime",
426    "_pydecimal",
427    "_pyio",
428    "_pylong",
429    "_queue",
430    "_random",
431    "_scproxy",
432    "_sha1",
433    "_sha2",
434    "_sha3",
435    "_signal",
436    "_sitebuiltins",
437    "_socket",
438    "_sqlite3",
439    "_sre",
440    "_ssl",
441    "_stat",
442    "_statistics",
443    "_string",
444    "_strptime",
445    "_struct",
446    "_symtable",
447    "_thread",
448    "_threading_local",
449    "_tkinter",
450    "_tokenize",
451    "_tracemalloc",
452    "_typing",
453    "_uuid",
454    "_warnings",
455    "_weakref",
456    "_weakrefset",
457    "_winapi",
458    "_zoneinfo",
459    "abc",
460    "aifc",
461    "antigravity",
462    "argparse",
463    "array",
464    "ast",
465    "asynchat",
466    "asyncio",
467    "asyncore",
468    "atexit",
469    "audioop",
470    "base64",
471    "bdb",
472    "binascii",
473    "bisect",
474    "builtins",
475    "bz2",
476    "cProfile",
477    "calendar",
478    "cgi",
479    "cgitb",
480    "chunk",
481    "cmath",
482    "cmd",
483    "code",
484    "codecs",
485    "codeop",
486    "collections",
487    "colorsys",
488    "compileall",
489    "concurrent",
490    "configparser",
491    "contextlib",
492    "contextvars",
493    "copy",
494    "copyreg",
495    "crypt",
496    "csv",
497    "ctypes",
498    "curses",
499    "dataclasses",
500    "datetime",
501    "dbm",
502    "decimal",
503    "difflib",
504    "dis",
505    "distutils",
506    "doctest",
507    "email",
508    "encodings",
509    "ensurepip",
510    "enum",
511    "errno",
512    "faulthandler",
513    "fcntl",
514    "filecmp",
515    "fileinput",
516    "fnmatch",
517    "fractions",
518    "ftplib",
519    "functools",
520    "gc",
521    "genericpath",
522    "getopt",
523    "getpass",
524    "gettext",
525    "glob",
526    "graphlib",
527    "grp",
528    "gzip",
529    "hashlib",
530    "heapq",
531    "hmac",
532    "html",
533    "http",
534    "idlelib",
535    "imaplib",
536    "imghdr",
537    "imp",
538    "importlib",
539    "inspect",
540    "io",
541    "ipaddress",
542    "itertools",
543    "json",
544    "keyword",
545    "lib2to3",
546    "linecache",
547    "locale",
548    "logging",
549    "lzma",
550    "mailbox",
551    "mailcap",
552    "marshal",
553    "math",
554    "mimetypes",
555    "mmap",
556    "modulefinder",
557    "msilib",
558    "msvcrt",
559    "multiprocessing",
560    "netrc",
561    "nis",
562    "nntplib",
563    "nt",
564    "ntpath",
565    "nturl2path",
566    "numbers",
567    "opcode",
568    "operator",
569    "optparse",
570    "os",
571    "ossaudiodev",
572    "pathlib",
573    "pdb",
574    "pickle",
575    "pickletools",
576    "pipes",
577    "pkgutil",
578    "platform",
579    "plistlib",
580    "poplib",
581    "posix",
582    "posixpath",
583    "pprint",
584    "profile",
585    "pstats",
586    "pty",
587    "pwd",
588    "py_compile",
589    "pyclbr",
590    "pydoc",
591    "pydoc_data",
592    "pyexpat",
593    "queue",
594    "quopri",
595    "random",
596    "re",
597    "readline",
598    "reprlib",
599    "resource",
600    "rlcompleter",
601    "runpy",
602    "sched",
603    "secrets",
604    "select",
605    "selectors",
606    "shelve",
607    "shlex",
608    "shutil",
609    "signal",
610    "site",
611    "smtpd",
612    "smtplib",
613    "sndhdr",
614    "socket",
615    "socketserver",
616    "spwd",
617    "sqlite3",
618    "sre_compile",
619    "sre_constants",
620    "sre_parse",
621    "ssl",
622    "stat",
623    "statistics",
624    "string",
625    "stringprep",
626    "struct",
627    "subprocess",
628    "sunau",
629    "symtable",
630    "sys",
631    "sysconfig",
632    "syslog",
633    "tabnanny",
634    "tarfile",
635    "telnetlib",
636    "tempfile",
637    "termios",
638    "textwrap",
639    "this",
640    "threading",
641    "time",
642    "timeit",
643    "tkinter",
644    "token",
645    "tokenize",
646    "tomllib",
647    "trace",
648    "traceback",
649    "tracemalloc",
650    "tty",
651    "turtle",
652    "turtledemo",
653    "types",
654    "typing",
655    "unicodedata",
656    "unittest",
657    "urllib",
658    "uu",
659    "uuid",
660    "venv",
661    "warnings",
662    "wave",
663    "weakref",
664    "webbrowser",
665    "winreg",
666    "winsound",
667    "wsgiref",
668    "xdrlib",
669    "xml",
670    "xmlrpc",
671    "zipapp",
672    "zipfile",
673    "zipimport",
674    "zlib",
675    "zoneinfo",
676];
677
678/// The trailing dotted segment of a module path (`myproject.db` → `db`).
679fn last_segment(module: &str) -> &str {
680    module.rsplit('.').next().unwrap_or(module)
681}
682
683/// The number of leading dots on a `from`-import: `from ..pkg import x` → 2, absolute → 0.
684fn relative_level(node: &StmtImportFrom) -> usize {
685    node.level.map_or(0, |level| level.to_usize())
686}
687
688/// `true` for `TYPE_CHECKING` / `typing.TYPE_CHECKING`, the guard over type-only imports.
689fn is_type_checking(test: &Expr) -> bool {
690    match test {
691        Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
692        Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
693        _ => false,
694    }
695}
696
697/// The unit-under-test base name for a test file: `widget_test.py` → `widget`. Only
698/// `*_test.py` reaches here, so stripping the `_test` suffix is all it takes.
699fn unit_under_test_base(file: &Path) -> String {
700    let name = file
701        .file_name()
702        .and_then(|n| n.to_str())
703        .unwrap_or_default();
704    let stem = name.strip_suffix(".py").unwrap_or(name);
705    stem.strip_suffix("_test").unwrap_or(stem).to_string()
706}
707
708/// Walks one test file, collecting lint violations. `fixture_depth` tracks `@pytest.fixture`
709/// nesting, so `no-inline-patch` allows a patch there and flags one in a test body.
710struct LintVisitor<'a> {
711    file: &'a Path,
712    source: &'a str,
713    fixture_depth: usize,
714    /// The dist's own top-level package, or `None` when undiscoverable.
715    first_party: Option<&'a str>,
716    /// The directory holding the dist's `pyproject.toml`, where module sources live.
717    source_root: Option<&'a Path>,
718    /// Local name → the dotted module path its import binds, for object patch targets.
719    imports: HashMap<String, String>,
720    /// Every dotted prefix of an imported module path, each a module by construction.
721    declared_modules: HashSet<String>,
722    violations: Vec<Violation>,
723}
724
725impl LintVisitor<'_> {
726    fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
727        self.violations.push(Violation {
728            file: self.file.to_path_buf(),
729            line: line_of(self.source, range.start()),
730            rule,
731            message: message.to_string(),
732        });
733    }
734
735    /// Run the parameter lint, and return whether this function is a fixture.
736    fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
737        let takes_monkeypatch = args
738            .posonlyargs
739            .iter()
740            .chain(&args.args)
741            .chain(&args.kwonlyargs)
742            .any(|arg| arg.def.arg.as_str() == "monkeypatch")
743            || arg_named(&args.vararg, "monkeypatch")
744            || arg_named(&args.kwarg, "monkeypatch");
745        if takes_monkeypatch {
746            self.report(
747                range,
748                "no-monkeypatch",
749                "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
750            );
751        }
752
753        decorators.iter().any(is_fixture_decorator)
754    }
755
756    fn declare_module(&mut self, module: &str) {
757        let mut path = String::new();
758        for segment in module.split('.') {
759            if !path.is_empty() {
760                path.push('.');
761            }
762            path.push_str(segment);
763            self.declared_modules.insert(path.clone());
764        }
765    }
766
767    fn resolve_ctx(&self) -> ResolveCtx<'_> {
768        ResolveCtx {
769            imports: &self.imports,
770            declared_modules: &self.declared_modules,
771            first_party: self.first_party,
772            source_root: self.source_root,
773        }
774    }
775}
776
777impl Visitor for LintVisitor<'_> {
778    fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
779        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
780        if is_fixture {
781            self.fixture_depth += 1;
782        }
783        self.generic_visit_stmt_function_def(node);
784        if is_fixture {
785            self.fixture_depth -= 1;
786        }
787    }
788
789    fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
790        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
791        if is_fixture {
792            self.fixture_depth += 1;
793        }
794        self.generic_visit_stmt_async_function_def(node);
795        if is_fixture {
796            self.fixture_depth -= 1;
797        }
798    }
799
800    fn visit_expr_call(&mut self, node: ExprCall) {
801        // A fixture is the right place for a patch; a test body is not.
802        if is_patch_call(&node) && self.fixture_depth == 0 {
803            self.report(
804                node.range,
805                "no-inline-patch",
806                "patch is called inline in a test body; move it into a `pytest.fixture`",
807            );
808        }
809        // Both target rules fire regardless of fixture depth — a config constant is usually
810        // patched in one — and only on a statically resolved target.
811        if let Some(target) = patch_target(&node, &self.resolve_ctx()) {
812            if patches_constant(&target) {
813                self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
814            }
815            if let Some(pkg) = self.first_party {
816                if patches_first_party(&target, pkg) {
817                    self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
818                }
819            }
820        }
821        if is_environ_mutation_call(&node) {
822            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
823        }
824        self.generic_visit_expr_call(node);
825    }
826
827    fn visit_stmt_import(&mut self, node: StmtImport) {
828        for alias in &node.names {
829            self.declare_module(alias.name.as_str());
830            match &alias.asname {
831                // `import X.Y as A` binds `A`; a plain `import X.Y` binds only the head `X`.
832                Some(asname) => {
833                    self.imports
834                        .insert(asname.to_string(), alias.name.to_string());
835                }
836                None => {
837                    let head = import_head(alias.name.as_str());
838                    self.imports.insert(head.to_string(), head.to_string());
839                }
840            }
841        }
842        self.generic_visit_stmt_import(node);
843    }
844
845    fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
846        // A relative import names no absolute module, so its bindings resolve nothing.
847        if let (0, Some(module)) = (relative_level(&node), &node.module) {
848            self.declare_module(module.as_str());
849            for alias in &node.names {
850                let bound = alias.asname.as_ref().unwrap_or(&alias.name);
851                self.imports
852                    .insert(bound.to_string(), format!("{module}.{}", alias.name));
853            }
854        }
855        self.generic_visit_stmt_import_from(node);
856    }
857
858    // The generated `generic_visit_withitem` is a no-op, so a `with patch(...)`
859    // context expression is never walked unless we descend into it here.
860    fn visit_withitem(&mut self, node: WithItem) {
861        self.visit_expr(node.context_expr);
862        if let Some(optional_vars) = node.optional_vars {
863            self.visit_expr(*optional_vars);
864        }
865    }
866
867    fn visit_stmt_assign(&mut self, node: StmtAssign) {
868        if node.targets.iter().any(is_os_environ_subscript) {
869            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
870        }
871        self.generic_visit_stmt_assign(node);
872    }
873
874    fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
875        if is_os_environ_subscript(node.target.as_ref()) {
876            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
877        }
878        self.generic_visit_stmt_aug_assign(node);
879    }
880
881    fn visit_stmt_delete(&mut self, node: StmtDelete) {
882        if node.targets.iter().any(is_os_environ_subscript) {
883            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
884        }
885        self.generic_visit_stmt_delete(node);
886    }
887}
888
889/// `true` when a `*args` / `**kwargs` arg is named `name`.
890fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
891    arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
892}
893
894/// `true` for an `@pytest.fixture` / `@fixture` decorator, called or bare.
895fn is_fixture_decorator(decorator: &Expr) -> bool {
896    let target = match decorator {
897        Expr::Call(call) => call.func.as_ref(),
898        other => other,
899    };
900    match target {
901        Expr::Name(name) => name.id.as_str() == "fixture",
902        Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
903        _ => false,
904    }
905}
906
907/// The three call shapes of `unittest.mock.patch`, which name their target differently.
908enum PatchForm {
909    /// `patch("pkg.mod.attr")` — the target is the string-literal first argument.
910    Target,
911    /// `patch.object(base, "attr")` — the target is `base`'s module plus the attribute.
912    Object,
913    /// `patch.dict(base_or_string, ...)` — the target is the dict itself.
914    Dict,
915}
916
917/// The form of a `patch(...)` / `patch.object(...)` / `patch.dict(...)` call, plain or
918/// reached through a module (`mock.patch(...)`, `unittest.mock.patch`). `None` otherwise.
919fn patch_form(call: &ExprCall) -> Option<PatchForm> {
920    match call.func.as_ref() {
921        Expr::Name(name) if name.id.as_str() == "patch" => Some(PatchForm::Target),
922        Expr::Attribute(attr) => match attr.attr.as_str() {
923            "patch" => Some(PatchForm::Target),
924            "object" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Object),
925            "dict" if attr_base_is_patch(attr.value.as_ref()) => Some(PatchForm::Dict),
926            _ => None,
927        },
928        _ => None,
929    }
930}
931
932/// `true` for any [`PatchForm`] call.
933fn is_patch_call(call: &ExprCall) -> bool {
934    patch_form(call).is_some()
935}
936
937/// `true` when an attribute's base resolves to `patch` — a `patch.object` receiver.
938fn attr_base_is_patch(expr: &Expr) -> bool {
939    match expr {
940        Expr::Name(name) => name.id.as_str() == "patch",
941        Expr::Attribute(attr) => attr.attr.as_str() == "patch",
942        _ => false,
943    }
944}
945
946const 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)";
947
948const 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";
949
950/// The string-literal first argument of a `patch(...)` call, the dotted target. `None` for
951/// a non-literal argument, which can't be classified deterministically.
952fn patch_string_target(call: &ExprCall) -> Option<&str> {
953    string_arg(call, 0)
954}
955
956/// The string literal at argument position `index` of a call, if that is what sits there.
957fn string_arg(call: &ExprCall, index: usize) -> Option<&str> {
958    if let Some(Expr::Constant(constant)) = call.args.get(index) {
959        if let Constant::Str(value) = &constant.value {
960            return Some(value.as_str());
961        }
962    }
963    None
964}
965
966/// The dotted segments of a plain attribute chain (`myproject.ledger` → `["myproject",
967/// "ledger"]`). `None` for a chain rooted in anything but a name.
968fn attr_chain_segments(expr: &Expr) -> Option<Vec<&str>> {
969    match expr {
970        Expr::Name(name) => Some(vec![name.id.as_str()]),
971        Expr::Attribute(attr) => {
972            let mut segments = attr_chain_segments(attr.value.as_ref())?;
973            segments.push(attr.attr.as_str());
974            Some(segments)
975        }
976        _ => None,
977    }
978}
979
980/// What object-target resolution reads: the test file's bindings plus the tree's manifest.
981struct ResolveCtx<'a> {
982    /// Local name → the dotted module path its import binds.
983    imports: &'a HashMap<String, String>,
984    /// Every dotted prefix of an imported module path, each a module by construction.
985    declared_modules: &'a HashSet<String>,
986    first_party: Option<&'a str>,
987    source_root: Option<&'a Path>,
988}
989
990/// The dotted path an object target names, classified by what the chain ends at: the head is
991/// the module its import binds, and each trailing segment on a first-party path is read from
992/// that module's own top-level source. `None` when any step resists static resolution.
993fn resolve_object_target(expr: &Expr, ctx: &ResolveCtx) -> Option<String> {
994    let segments = attr_chain_segments(expr)?;
995    let (head, rest) = segments.split_first()?;
996    let mut target = ctx.imports.get(*head)?.clone();
997    for (index, segment) in rest.iter().enumerate() {
998        let candidate = format!("{target}.{segment}");
999        if ctx.declared_modules.contains(&candidate) {
1000            target = candidate;
1001            continue;
1002        }
1003        let first_party_root = ctx
1004            .first_party
1005            .zip(ctx.source_root)
1006            .filter(|(pkg, _)| target.split('.').next() == Some(*pkg));
1007        let Some((_, root)) = first_party_root else {
1008            return Some(append_segments(target, &rest[index..]));
1009        };
1010        target = match module_attribute(root, &target, segment)? {
1011            Attr::Module(absolute) => absolute,
1012            Attr::Defined if index == rest.len() - 1 => candidate,
1013            Attr::Defined => return None,
1014        };
1015    }
1016    Some(target)
1017}
1018
1019fn append_segments(mut target: String, segments: &[&str]) -> String {
1020    for segment in segments {
1021        target.push('.');
1022        target.push_str(segment);
1023    }
1024    target
1025}
1026
1027/// What a module attribute resolves to, read from the module's own top-level source.
1028enum Attr {
1029    /// The attribute names another module, by import binding or by submodule file.
1030    Module(String),
1031    /// The module defines the attribute itself: a `def`, `class`, or literal assignment.
1032    Defined,
1033}
1034
1035/// Classify `module`'s attribute `name`. `None` when the source leaves it unnamed: a missing
1036/// or unparsable file, a dynamic or conflicting binding, or a star import over an unbound name.
1037fn module_attribute(root: &Path, module: &str, name: &str) -> Option<Attr> {
1038    let file = locate_module(root, module)?;
1039    let source = std::fs::read_to_string(&file.path).ok()?;
1040    let scope = module_scope(&source, &file.package)?;
1041    match scope.bindings.get(name) {
1042        Some(Binding::Module(absolute)) => Some(Attr::Module(absolute.clone())),
1043        Some(Binding::Defined) => Some(Attr::Defined),
1044        Some(Binding::Opaque) => None,
1045        None if scope.has_star_import => None,
1046        None => {
1047            let submodule = format!("{module}.{name}");
1048            locate_module(root, &submodule).map(|_| Attr::Module(submodule))
1049        }
1050    }
1051}
1052
1053/// A module's source file plus the package its relative imports resolve against.
1054struct ModuleFile {
1055    path: PathBuf,
1056    package: Vec<String>,
1057}
1058
1059/// The file for dotted `module` under `root`, tried in flat and `src/` layouts.
1060fn locate_module(root: &Path, module: &str) -> Option<ModuleFile> {
1061    let segments: Vec<String> = module.split('.').map(str::to_owned).collect();
1062    let rel: PathBuf = segments.iter().collect();
1063    for base in [root.to_path_buf(), root.join("src")] {
1064        let file = base.join(&rel).with_extension("py");
1065        if file.is_file() {
1066            let package = segments[..segments.len() - 1].to_vec();
1067            return Some(ModuleFile {
1068                path: file,
1069                package,
1070            });
1071        }
1072        let init = base.join(&rel).join("__init__.py");
1073        if init.is_file() {
1074            return Some(ModuleFile {
1075                path: init,
1076                package: segments,
1077            });
1078        }
1079    }
1080    None
1081}
1082
1083/// What a module's own top-level source binds a name to.
1084#[derive(Debug, PartialEq)]
1085enum Binding {
1086    /// An import binds the name to this absolute module path.
1087    Module(String),
1088    /// A `def`, `class`, or literal assignment defines the name in this module.
1089    Defined,
1090    /// A dynamic or conflicting binding, which static resolution declines to classify.
1091    Opaque,
1092}
1093
1094struct ModuleScope {
1095    bindings: HashMap<String, Binding>,
1096    has_star_import: bool,
1097}
1098
1099impl ModuleScope {
1100    fn bind(&mut self, name: String, binding: Binding) {
1101        match self.bindings.entry(name) {
1102            Entry::Occupied(mut entry) => {
1103                if *entry.get() != binding {
1104                    entry.insert(Binding::Opaque);
1105                }
1106            }
1107            Entry::Vacant(entry) => {
1108                entry.insert(binding);
1109            }
1110        }
1111    }
1112
1113    fn bind_import_from(&mut self, node: &StmtImportFrom, package: &[String]) {
1114        let base = match relative_level(node) {
1115            0 => node.module.as_ref().map(|module| module.to_string()),
1116            // A level beyond the package walks above the top-level package, which Python rejects.
1117            level if level <= package.len() => {
1118                let parent = package[..package.len() + 1 - level].join(".");
1119                Some(match &node.module {
1120                    Some(module) => format!("{parent}.{module}"),
1121                    None => parent,
1122                })
1123            }
1124            _ => None,
1125        };
1126        for alias in &node.names {
1127            if alias.name.as_str() == "*" {
1128                self.has_star_import = true;
1129                continue;
1130            }
1131            let bound = alias.asname.as_ref().unwrap_or(&alias.name).to_string();
1132            let binding = match &base {
1133                Some(base) => Binding::Module(format!("{base}.{}", alias.name)),
1134                None => Binding::Opaque,
1135            };
1136            self.bind(bound, binding);
1137        }
1138    }
1139
1140    fn bind_assign_target(&mut self, target: &Expr, literal: bool) {
1141        match target {
1142            Expr::Name(name) => {
1143                let binding = if literal {
1144                    Binding::Defined
1145                } else {
1146                    Binding::Opaque
1147                };
1148                self.bind(name.id.to_string(), binding);
1149            }
1150            Expr::Tuple(tuple) => {
1151                for elt in &tuple.elts {
1152                    self.bind_assign_target(elt, false);
1153                }
1154            }
1155            Expr::List(list) => {
1156                for elt in &list.elts {
1157                    self.bind_assign_target(elt, false);
1158                }
1159            }
1160            _ => {}
1161        }
1162    }
1163
1164    /// Bind what one top-level statement declares. A statement that binds no name —
1165    /// an expression, a conditional, a loop — leaves the scope unchanged.
1166    fn bind_stmt(&mut self, stmt: &ast::Stmt, package: &[String]) {
1167        match stmt {
1168            ast::Stmt::Import(node) => {
1169                for alias in &node.names {
1170                    match &alias.asname {
1171                        Some(asname) => {
1172                            self.bind(asname.to_string(), Binding::Module(alias.name.to_string()));
1173                        }
1174                        None => {
1175                            let head = import_head(alias.name.as_str());
1176                            self.bind(head.to_string(), Binding::Module(head.to_string()));
1177                        }
1178                    }
1179                }
1180            }
1181            ast::Stmt::ImportFrom(node) => self.bind_import_from(node, package),
1182            ast::Stmt::FunctionDef(node) => self.bind(node.name.to_string(), Binding::Defined),
1183            ast::Stmt::AsyncFunctionDef(node) => {
1184                self.bind(node.name.to_string(), Binding::Defined);
1185            }
1186            ast::Stmt::ClassDef(node) => self.bind(node.name.to_string(), Binding::Defined),
1187            ast::Stmt::Assign(node) => {
1188                let literal = is_literal(&node.value);
1189                for target in &node.targets {
1190                    self.bind_assign_target(target, literal);
1191                }
1192            }
1193            ast::Stmt::AnnAssign(node) => {
1194                if let Some(value) = &node.value {
1195                    self.bind_assign_target(&node.target, is_literal(value));
1196                }
1197            }
1198            _ => {}
1199        }
1200    }
1201}
1202
1203/// The top-level name bindings of a module's source. `package` is the dotted package its
1204/// relative imports resolve against. `None` when the source does not parse.
1205fn module_scope(source: &str, package: &[String]) -> Option<ModuleScope> {
1206    let suite = ast::Suite::parse(source, "module.py").ok()?;
1207    let mut scope = ModuleScope {
1208        bindings: HashMap::new(),
1209        has_star_import: false,
1210    };
1211    for stmt in &suite {
1212        scope.bind_stmt(stmt, package);
1213    }
1214    Some(scope)
1215}
1216
1217/// `true` for an expression built of literals alone — data the module itself defines.
1218fn is_literal(expr: &Expr) -> bool {
1219    match expr {
1220        Expr::Constant(_) => true,
1221        Expr::UnaryOp(op) => is_literal(&op.operand),
1222        Expr::Dict(dict) => {
1223            dict.keys.iter().flatten().all(is_literal) && dict.values.iter().all(is_literal)
1224        }
1225        Expr::List(list) => list.elts.iter().all(is_literal),
1226        Expr::Tuple(tuple) => tuple.elts.iter().all(is_literal),
1227        Expr::Set(set) => set.elts.iter().all(is_literal),
1228        _ => false,
1229    }
1230}
1231
1232/// The dotted target a patch call names, resolved statically: the string literal for
1233/// `patch(...)` (and a string-target `patch.dict`), the resolved first argument for the
1234/// object forms. `None` when the target resists static resolution — nothing fires.
1235fn patch_target(call: &ExprCall, ctx: &ResolveCtx) -> Option<String> {
1236    match patch_form(call)? {
1237        PatchForm::Target => patch_string_target(call).map(str::to_owned),
1238        PatchForm::Dict => patch_string_target(call)
1239            .map(str::to_owned)
1240            .or_else(|| resolve_object_target(call.args.first()?, ctx)),
1241        PatchForm::Object => {
1242            let base = resolve_object_target(call.args.first()?, ctx)?;
1243            Some(match string_arg(call, 1) {
1244                Some(attr) => format!("{base}.{attr}"),
1245                None => base,
1246            })
1247        }
1248    }
1249}
1250
1251/// `true` when a patch target names an UPPER_CASE constant (`"pkg.cfg.CACHE_DIR"`).
1252fn patches_constant(target: &str) -> bool {
1253    target.rsplit('.').next().is_some_and(is_upper_constant)
1254}
1255
1256/// `true` when patch `target`'s head segment names the first-party package `pkg`.
1257fn patches_first_party(target: &str, pkg: &str) -> bool {
1258    target
1259        .split('.')
1260        .next()
1261        .is_some_and(|head| !head.is_empty() && head == pkg)
1262}
1263
1264/// `true` for an ALL-CAPS name: uppercase letters, digits, underscores, one letter minimum.
1265fn is_upper_constant(name: &str) -> bool {
1266    !name.is_empty()
1267        && name
1268            .chars()
1269            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
1270        && name.chars().any(|c| c.is_ascii_uppercase())
1271}
1272
1273const ENVIRON_MUTATION_MSG: &str =
1274    "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
1275
1276/// `true` for the expression `os.environ`.
1277fn is_os_environ(expr: &Expr) -> bool {
1278    matches!(
1279        expr,
1280        Expr::Attribute(attr)
1281            if attr.attr.as_str() == "environ"
1282                && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
1283    )
1284}
1285
1286/// `true` for `os.environ[...]`, the form used as an assignment or `del` target.
1287fn is_os_environ_subscript(expr: &Expr) -> bool {
1288    matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
1289}
1290
1291/// `true` for a mutating method call on `os.environ`, like `os.environ.update(...)`.
1292fn is_environ_mutation_call(call: &ExprCall) -> bool {
1293    matches!(
1294        call.func.as_ref(),
1295        Expr::Attribute(attr)
1296            if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
1297    )
1298}
1299
1300/// `true` for a `dict` method that mutates in place.
1301fn is_environ_mutator(method: &str) -> bool {
1302    matches!(
1303        method,
1304        "update" | "pop" | "setdefault" | "clear" | "popitem"
1305    )
1306}
1307
1308/// The 1-based line containing byte `offset` in `source`.
1309fn line_of(source: &str, offset: TextSize) -> usize {
1310    let offset = (u32::from(offset) as usize).min(source.len());
1311    source.as_bytes()[..offset]
1312        .iter()
1313        .filter(|&&byte| byte == b'\n')
1314        .count()
1315        + 1
1316}
1317
1318/// The dist's own top-level import package: the nearest `pyproject.toml`'s `[project].name`,
1319/// [normalized](normalize_dist_name). The walk up stops at a `.git` boundary so it can't
1320/// escape into an unrelated project, and `None` means nothing is flagged rather than guessed.
1321fn first_party_package(root: &Path) -> Option<String> {
1322    first_party_manifest(root).map(|(name, _)| name)
1323}
1324
1325/// [`first_party_package`] plus the directory holding the manifest, where module sources live.
1326fn first_party_manifest(root: &Path) -> Option<(String, PathBuf)> {
1327    for dir in root.ancestors() {
1328        let candidate = dir.join("pyproject.toml");
1329        if candidate.is_file() {
1330            return read_project_name(&candidate)
1331                .map(|name| (normalize_dist_name(&name), dir.to_path_buf()));
1332        }
1333        if dir.join(".git").exists() {
1334            break;
1335        }
1336    }
1337    None
1338}
1339
1340/// `[project].name` from a `pyproject.toml`, if present and a string.
1341fn read_project_name(path: &Path) -> Option<String> {
1342    let contents = std::fs::read_to_string(path).ok()?;
1343    let value: toml::Value = toml::from_str(&contents).ok()?;
1344    value
1345        .get("project")?
1346        .get("name")?
1347        .as_str()
1348        .map(str::to_owned)
1349}
1350
1351/// A distribution name as its import package name, PEP 503-flavoured: `My-Project` →
1352/// `my_project`.
1353fn normalize_dist_name(name: &str) -> String {
1354    name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
1355}
1356
1357fn collect_python_files(
1358    dir: &Path,
1359    out: &mut Vec<PathBuf>,
1360    is_match: fn(&Path) -> bool,
1361) -> Result<()> {
1362    let entries =
1363        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
1364    for entry in entries {
1365        let path = crate::walk::dir_entry(entry, dir)?.path();
1366        if path.is_dir() {
1367            collect_python_files(&path, out, is_match)?;
1368        } else if is_match(&path) {
1369            out.push(path);
1370        }
1371    }
1372    Ok(())
1373}
1374
1375/// `true` for a file the integration lints scan: `*_test.py` or `conftest.py`. A legacy
1376/// `test_*.py` is ordinary source.
1377fn is_python_test_file(path: &Path) -> bool {
1378    let name = path
1379        .file_name()
1380        .and_then(|n| n.to_str())
1381        .unwrap_or_default();
1382    name == "conftest.py" || name.ends_with("_test.py")
1383}
1384
1385/// `true` for a colocated unit test: `*_test.py`. A legacy `test_*.py` is ordinary source,
1386/// and `conftest.py` holds fixtures rather than a unit.
1387fn is_python_unit_test_file(path: &Path) -> bool {
1388    let name = path
1389        .file_name()
1390        .and_then(|n| n.to_str())
1391        .unwrap_or_default();
1392    name.ends_with("_test.py")
1393}
1394
1395#[cfg(test)]
1396mod tests {
1397    use super::*;
1398    use std::sync::atomic::{AtomicU64, Ordering};
1399
1400    /// A throwaway directory, removed on drop — for the `pyproject.toml` discovery.
1401    struct TempDir(PathBuf);
1402
1403    impl TempDir {
1404        fn new() -> Self {
1405            static COUNTER: AtomicU64 = AtomicU64::new(0);
1406            let dir = std::env::temp_dir().join(format!(
1407                "tc-lint-{}-{}",
1408                std::process::id(),
1409                COUNTER.fetch_add(1, Ordering::Relaxed),
1410            ));
1411            std::fs::create_dir_all(&dir).unwrap();
1412            TempDir(dir)
1413        }
1414
1415        fn write(&self, name: &str, contents: &str) {
1416            let path = self.0.join(name);
1417            if let Some(parent) = path.parent() {
1418                std::fs::create_dir_all(parent).unwrap();
1419            }
1420            std::fs::write(path, contents).unwrap();
1421        }
1422    }
1423
1424    impl Drop for TempDir {
1425        fn drop(&mut self) {
1426            let _ = std::fs::remove_dir_all(&self.0);
1427        }
1428    }
1429
1430    #[test]
1431    fn normalize_dist_name_maps_to_import_name() {
1432        assert_eq!(normalize_dist_name("My-Project"), "my_project");
1433        assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1434        assert_eq!(normalize_dist_name("  myproject  "), "myproject");
1435        assert_eq!(normalize_dist_name("myproject"), "myproject");
1436    }
1437
1438    /// Parse `src` (a single expression statement) and return its call.
1439    fn parse_call(src: &str) -> ExprCall {
1440        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1441        let stmt = suite.into_iter().next().expect("one statement");
1442        (*stmt.expect_expr_stmt().value).expect_call_expr()
1443    }
1444
1445    /// A ctx with no manifest, where trailing segments append without classification.
1446    fn naive_ctx<'a>(
1447        imports: &'a HashMap<String, String>,
1448        declared: &'a HashSet<String>,
1449    ) -> ResolveCtx<'a> {
1450        ResolveCtx {
1451            imports,
1452            declared_modules: declared,
1453            first_party: None,
1454            source_root: None,
1455        }
1456    }
1457
1458    #[test]
1459    fn patch_target_only_reads_string_literals_for_the_string_form() {
1460        let imports = HashMap::new();
1461        let declared = HashSet::new();
1462        let ctx = naive_ctx(&imports, &declared);
1463        let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1464        assert_eq!(
1465            patch_target(&str_call, &ctx).as_deref(),
1466            Some("pkg.mod.attr")
1467        );
1468        // A name in `patch(...)` holds a string, which static resolution cannot read.
1469        let name_call = parse_call("patch(target)\n");
1470        assert_eq!(patch_target(&name_call, &ctx), None);
1471        let int_call = parse_call("patch(42)\n");
1472        assert_eq!(patch_target(&int_call, &ctx), None);
1473        let empty_call = parse_call("patch()\n");
1474        assert_eq!(patch_target(&empty_call, &ctx), None);
1475    }
1476
1477    /// An import map binding the names the object-form snippets use.
1478    fn object_form_imports() -> HashMap<String, String> {
1479        HashMap::from([
1480            ("ledger".to_string(), "myproject.ledger".to_string()),
1481            ("myproject".to_string(), "myproject".to_string()),
1482            ("cfg".to_string(), "myproject.cfg".to_string()),
1483        ])
1484    }
1485
1486    #[test]
1487    fn patch_target_resolves_object_forms_through_imports() {
1488        let imports = object_form_imports();
1489        let declared = HashSet::new();
1490        let ctx = naive_ctx(&imports, &declared);
1491        let imported_name = parse_call("patch.object(ledger, \"record\")\n");
1492        assert_eq!(
1493            patch_target(&imported_name, &ctx).as_deref(),
1494            Some("myproject.ledger.record")
1495        );
1496        let dotted_module = parse_call("patch.object(myproject.ledger, \"record\")\n");
1497        assert_eq!(
1498            patch_target(&dotted_module, &ctx).as_deref(),
1499            Some("myproject.ledger.record")
1500        );
1501        // A non-literal attribute still names the base module, enough for the first-party rule.
1502        let name_attr = parse_call("patch.object(ledger, attr)\n");
1503        assert_eq!(
1504            patch_target(&name_attr, &ctx).as_deref(),
1505            Some("myproject.ledger")
1506        );
1507        let dict_object = parse_call("patch.dict(cfg.SETTINGS, {})\n");
1508        assert_eq!(
1509            patch_target(&dict_object, &ctx).as_deref(),
1510            Some("myproject.cfg.SETTINGS")
1511        );
1512        let dict_string = parse_call("patch.dict(\"pkg.cfg.FLAGS\", {})\n");
1513        assert_eq!(
1514            patch_target(&dict_string, &ctx).as_deref(),
1515            Some("pkg.cfg.FLAGS")
1516        );
1517    }
1518
1519    #[test]
1520    fn patch_target_declines_a_base_bound_by_no_import() {
1521        let imports = object_form_imports();
1522        let declared = HashSet::new();
1523        let ctx = naive_ctx(&imports, &declared);
1524        let call_base = parse_call("patch.object(get_mod(), \"x\")\n");
1525        assert_eq!(patch_target(&call_base, &ctx), None);
1526        let unbound_name = parse_call("patch.object(client, \"send\")\n");
1527        assert_eq!(patch_target(&unbound_name, &ctx), None);
1528        let empty = parse_call("patch.object()\n");
1529        assert_eq!(patch_target(&empty, &ctx), None);
1530    }
1531
1532    /// A tree holding `pyproject.toml` (`name = "myproject"`) plus one integration test
1533    /// whose fixture patches `patch.object(<base>, "<attr>")`.
1534    fn object_patch_tree(base: &str, attr: &str) -> TempDir {
1535        let tree = TempDir::new();
1536        tree.write(
1537            "pyproject.toml",
1538            "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1539        );
1540        tree.write(
1541            "tests/integration/spy_test.py",
1542            &format!(
1543                "from unittest.mock import patch\n\
1544                 import pytest\n\
1545                 from myproject import async_mod\n\
1546                 @pytest.fixture\n\
1547                 def spy():\n\
1548                 \x20   with patch.object({base}, \"{attr}\") as s:\n\
1549                 \x20       yield s\n"
1550            ),
1551        );
1552        tree
1553    }
1554
1555    fn first_party_patch_count(tree: &TempDir) -> usize {
1556        find_violations(&tree.0)
1557            .expect("walking a readable tree should succeed")
1558            .iter()
1559            .filter(|v| v.rule == "no-first-party-patch")
1560            .count()
1561    }
1562
1563    #[test]
1564    fn object_form_stdlib_reached_through_first_party_is_not_flagged() {
1565        let tree = object_patch_tree("async_mod.asyncio", "to_thread");
1566        tree.write("myproject/async_mod.py", "import asyncio\n");
1567        assert_eq!(first_party_patch_count(&tree), 0);
1568    }
1569
1570    #[test]
1571    fn object_form_unnamed_module_attribute_declines_to_fire() {
1572        let missing_source = object_patch_tree("async_mod.transport", "send");
1573        assert_eq!(first_party_patch_count(&missing_source), 0);
1574        let dynamic = object_patch_tree("async_mod.transport", "send");
1575        dynamic.write("myproject/async_mod.py", "transport = build()\n");
1576        assert_eq!(first_party_patch_count(&dynamic), 0);
1577    }
1578
1579    #[test]
1580    fn object_form_first_party_module_attribute_still_fires() {
1581        let tree = object_patch_tree("async_mod.helper", "run");
1582        tree.write("myproject/async_mod.py", "from . import helper\n");
1583        assert_eq!(first_party_patch_count(&tree), 1);
1584    }
1585
1586    #[test]
1587    fn object_form_resolves_in_a_src_layout() {
1588        let tree = object_patch_tree("async_mod.helper", "run");
1589        tree.write("src/myproject/async_mod.py", "from . import helper\n");
1590        assert_eq!(first_party_patch_count(&tree), 1);
1591    }
1592
1593    #[test]
1594    fn object_form_star_import_over_an_unbound_name_declines() {
1595        let tree = object_patch_tree("async_mod.walk", "call");
1596        tree.write("myproject/async_mod.py", "from os import *\n");
1597        assert_eq!(first_party_patch_count(&tree), 0);
1598    }
1599
1600    #[test]
1601    fn object_form_conflicting_binding_declines() {
1602        let tree = object_patch_tree("async_mod.helper", "run");
1603        tree.write(
1604            "myproject/async_mod.py",
1605            "from . import helper\nhelper = None\n",
1606        );
1607        assert_eq!(first_party_patch_count(&tree), 0);
1608    }
1609
1610    #[test]
1611    fn object_form_defined_name_mid_chain_declines() {
1612        let tree = object_patch_tree("async_mod.Client.send", "retry");
1613        tree.write("myproject/async_mod.py", "class Client:\n    pass\n");
1614        assert_eq!(first_party_patch_count(&tree), 0);
1615    }
1616
1617    #[test]
1618    fn object_form_package_attribute_resolves_through_init() {
1619        let tree = TempDir::new();
1620        tree.write(
1621            "pyproject.toml",
1622            "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1623        );
1624        tree.write("myproject/sub/__init__.py", "from . import leaf\n");
1625        tree.write("myproject/sub/leaf.py", "def run():\n    pass\n");
1626        tree.write(
1627            "tests/integration/spy_test.py",
1628            "from unittest.mock import patch\n\
1629             import pytest\n\
1630             from myproject import sub\n\
1631             @pytest.fixture\n\
1632             def spy():\n\
1633             \x20   with patch.object(sub.leaf, \"run\") as s:\n\
1634             \x20       yield s\n",
1635        );
1636        assert_eq!(first_party_patch_count(&tree), 1);
1637    }
1638
1639    #[test]
1640    fn object_form_unbound_name_falls_back_to_the_submodule_file() {
1641        let tree = TempDir::new();
1642        tree.write(
1643            "pyproject.toml",
1644            "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
1645        );
1646        tree.write("myproject/sub/__init__.py", "");
1647        tree.write("myproject/sub/leaf.py", "def run():\n    pass\n");
1648        tree.write(
1649            "tests/integration/spy_test.py",
1650            "from unittest.mock import patch\n\
1651             import pytest\n\
1652             from myproject import sub\n\
1653             @pytest.fixture\n\
1654             def spy():\n\
1655             \x20   with patch.object(sub.leaf, \"run\") as s:\n\
1656             \x20       yield s\n",
1657        );
1658        assert_eq!(first_party_patch_count(&tree), 1);
1659    }
1660
1661    #[test]
1662    fn resolve_appends_naively_past_a_third_party_head() {
1663        let tree = TempDir::new();
1664        let imports = HashMap::from([("requests".to_string(), "requests".to_string())]);
1665        let declared = HashSet::new();
1666        let ctx = ResolveCtx {
1667            imports: &imports,
1668            declared_modules: &declared,
1669            first_party: Some("myproject"),
1670            source_root: Some(&tree.0),
1671        };
1672        let call = parse_call("patch.object(requests.utils, \"default_headers\")\n");
1673        assert_eq!(
1674            patch_target(&call, &ctx).as_deref(),
1675            Some("requests.utils.default_headers")
1676        );
1677    }
1678
1679    #[test]
1680    fn locate_module_tries_flat_and_src_layouts() {
1681        let tree = TempDir::new();
1682        tree.write("myproject/flat.py", "");
1683        tree.write("myproject/pkg/__init__.py", "");
1684        tree.write("src/myproject/nested.py", "");
1685        let flat = locate_module(&tree.0, "myproject.flat").expect("flat module");
1686        assert_eq!(flat.path, tree.0.join("myproject/flat.py"));
1687        assert_eq!(flat.package, vec!["myproject".to_string()]);
1688        let pkg = locate_module(&tree.0, "myproject.pkg").expect("package module");
1689        assert_eq!(pkg.path, tree.0.join("myproject/pkg/__init__.py"));
1690        assert_eq!(
1691            pkg.package,
1692            vec!["myproject".to_string(), "pkg".to_string()]
1693        );
1694        let nested = locate_module(&tree.0, "myproject.nested").expect("src module");
1695        assert_eq!(nested.path, tree.0.join("src/myproject/nested.py"));
1696        assert!(locate_module(&tree.0, "myproject.absent").is_none());
1697    }
1698
1699    fn scope_of(source: &str) -> ModuleScope {
1700        module_scope(source, &["myproject".to_string()]).expect("source should parse")
1701    }
1702
1703    #[test]
1704    fn module_scope_classifies_import_bindings() {
1705        let scope = scope_of(
1706            "import asyncio\n\
1707             import myproject.util as util\n\
1708             from . import helper\n\
1709             from .sub import leaf\n\
1710             from myproject.vendor import client as vc\n",
1711        );
1712        assert_eq!(
1713            scope.bindings.get("asyncio"),
1714            Some(&Binding::Module("asyncio".to_string()))
1715        );
1716        assert_eq!(
1717            scope.bindings.get("util"),
1718            Some(&Binding::Module("myproject.util".to_string()))
1719        );
1720        assert_eq!(
1721            scope.bindings.get("helper"),
1722            Some(&Binding::Module("myproject.helper".to_string()))
1723        );
1724        assert_eq!(
1725            scope.bindings.get("leaf"),
1726            Some(&Binding::Module("myproject.sub.leaf".to_string()))
1727        );
1728        assert_eq!(
1729            scope.bindings.get("vc"),
1730            Some(&Binding::Module("myproject.vendor.client".to_string()))
1731        );
1732    }
1733
1734    #[test]
1735    fn module_scope_classifies_definitions_and_assignments() {
1736        let scope = scope_of(
1737            "def run():\n    pass\n\
1738             async def poll():\n    pass\n\
1739             class Client:\n    pass\n\
1740             LIMITS = [1, 2]\n\
1741             OFFSET = -1\n\
1742             registry = {\"on\": True}\n\
1743             PAIR: tuple = (1, 2)\n\
1744             transport = build()\n\
1745             alias = registry\n\
1746             a, b = make()\n",
1747        );
1748        for name in [
1749            "run", "poll", "Client", "LIMITS", "OFFSET", "registry", "PAIR",
1750        ] {
1751            assert_eq!(scope.bindings.get(name), Some(&Binding::Defined), "{name}");
1752        }
1753        for name in ["transport", "alias", "a", "b"] {
1754            assert_eq!(scope.bindings.get(name), Some(&Binding::Opaque), "{name}");
1755        }
1756    }
1757
1758    #[test]
1759    fn module_scope_marks_conflicts_opaque_and_dedupes_repeats() {
1760        let scope = scope_of("import asyncio\nimport asyncio\nfrom . import helper\nhelper = 1\n");
1761        assert_eq!(
1762            scope.bindings.get("asyncio"),
1763            Some(&Binding::Module("asyncio".to_string()))
1764        );
1765        assert_eq!(scope.bindings.get("helper"), Some(&Binding::Opaque));
1766    }
1767
1768    #[test]
1769    fn module_scope_flags_star_imports_and_rejects_deep_relatives() {
1770        let scope = scope_of("from os import *\nfrom .. import escape\n");
1771        assert!(scope.has_star_import);
1772        assert_eq!(scope.bindings.get("escape"), Some(&Binding::Opaque));
1773    }
1774
1775    #[test]
1776    fn module_scope_rejects_unparsable_source() {
1777        assert!(module_scope("def (\n", &[]).is_none());
1778    }
1779
1780    #[test]
1781    fn module_scope_binds_list_unpack_targets_as_opaque() {
1782        let scope = scope_of("[c, d] = make()\n");
1783        assert_eq!(scope.bindings.get("c"), Some(&Binding::Opaque));
1784        assert_eq!(scope.bindings.get("d"), Some(&Binding::Opaque));
1785    }
1786
1787    #[test]
1788    fn module_scope_skips_an_attribute_target() {
1789        let scope = scope_of("obj.attr = 1\n");
1790        assert!(scope.bindings.is_empty(), "{:?}", scope.bindings);
1791    }
1792
1793    #[test]
1794    fn module_scope_ignores_non_binding_statements() {
1795        let scope = scope_of("print(1)\n");
1796        assert!(scope.bindings.is_empty(), "{:?}", scope.bindings);
1797    }
1798
1799    #[test]
1800    fn module_scope_treats_a_set_literal_as_defined() {
1801        let scope = scope_of("NAMES = {1, 2}\n");
1802        assert_eq!(scope.bindings.get("NAMES"), Some(&Binding::Defined));
1803    }
1804
1805    /// The imports and declared modules a [`LintVisitor`] records for `src`.
1806    fn collect_bindings(src: &str) -> (HashMap<String, String>, HashSet<String>) {
1807        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1808        let mut visitor = LintVisitor {
1809            file: Path::new("t.py"),
1810            source: src,
1811            fixture_depth: 0,
1812            first_party: None,
1813            source_root: None,
1814            imports: HashMap::new(),
1815            declared_modules: HashSet::new(),
1816            violations: Vec::new(),
1817        };
1818        for stmt in suite {
1819            visitor.visit_stmt(stmt);
1820        }
1821        (visitor.imports, visitor.declared_modules)
1822    }
1823
1824    fn collect_imports(src: &str) -> HashMap<String, String> {
1825        collect_bindings(src).0
1826    }
1827
1828    #[test]
1829    fn lint_visitor_binds_imports_to_their_modules() {
1830        let imports = collect_imports(
1831            "import myproject.ledger\n\
1832             import myproject.config as cfg\n\
1833             from myproject import ledger\n\
1834             from myproject import charge as ch\n\
1835             from . import rel\n",
1836        );
1837        assert_eq!(
1838            imports.get("myproject").map(String::as_str),
1839            Some("myproject")
1840        );
1841        assert_eq!(
1842            imports.get("cfg").map(String::as_str),
1843            Some("myproject.config")
1844        );
1845        assert_eq!(
1846            imports.get("ledger").map(String::as_str),
1847            Some("myproject.ledger")
1848        );
1849        assert_eq!(
1850            imports.get("ch").map(String::as_str),
1851            Some("myproject.charge")
1852        );
1853        assert_eq!(imports.get("rel"), None);
1854    }
1855
1856    #[test]
1857    fn lint_visitor_declares_every_import_prefix() {
1858        let (_, declared) = collect_bindings(
1859            "import myproject.sub.ledger\n\
1860             from myproject.api import charge\n\
1861             from . import rel\n",
1862        );
1863        for module in [
1864            "myproject",
1865            "myproject.sub",
1866            "myproject.sub.ledger",
1867            "myproject.api",
1868        ] {
1869            assert!(declared.contains(module), "{module}");
1870        }
1871        assert!(!declared.contains("myproject.api.charge"));
1872        assert!(!declared.contains("rel"));
1873    }
1874
1875    /// Build a `from <source> import <symbols>` record (`source: None` → relative).
1876    fn from_import(source: Option<&str>, symbols: &[&str]) -> ImportRecord {
1877        ImportRecord {
1878            display: source.unwrap_or(".rel").to_string(),
1879            line: 1,
1880            is_uut: false,
1881            symbols: symbols.iter().map(|s| (*s).to_string()).collect(),
1882            source: source.map(str::to_string),
1883            module: None,
1884        }
1885    }
1886
1887    fn targets(list: &[&str]) -> Vec<String> {
1888        list.iter().map(|s| (*s).to_string()).collect()
1889    }
1890
1891    #[test]
1892    fn is_mocked_requires_every_symbol_at_the_import_module() {
1893        let rec = from_import(Some("pkg.ledger"), &["record", "erase"]);
1894        // Only `record` patched → the un-mocked `erase` leaves the import un-mocked.
1895        assert!(!rec.is_mocked(&targets(&["pkg.ledger.record"])));
1896        assert!(rec.is_mocked(&targets(&["pkg.ledger.record", "pkg.ledger.erase"])));
1897    }
1898
1899    #[test]
1900    fn is_mocked_rejects_a_last_segment_match_in_another_module() {
1901        let rec = from_import(Some("pkg.ledger"), &["record"]);
1902        // Same last segment, different module → not mocked.
1903        assert!(!rec.is_mocked(&targets(&["otherpkg.unrelated.record"])));
1904        let dumps = from_import(Some("pkg.formatter"), &["dumps"]);
1905        assert!(!dumps.is_mocked(&targets(&["json.dumps"])));
1906        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1907    }
1908
1909    #[test]
1910    fn is_mocked_relative_import_accepts_a_last_segment_match() {
1911        // A relative import has no module to compare, so a last-segment match is accepted.
1912        let rec = from_import(None, &["record"]);
1913        assert!(rec.is_mocked(&targets(&["pkg.ledger.record"])));
1914        assert!(!rec.is_mocked(&targets(&["pkg.ledger.other"])));
1915    }
1916
1917    #[test]
1918    fn is_mocked_module_import_matches_a_patch_reaching_in() {
1919        let rec = ImportRecord {
1920            display: "pkg.db".to_string(),
1921            line: 1,
1922            is_uut: false,
1923            symbols: Vec::new(),
1924            source: None,
1925            module: Some("pkg.db".to_string()),
1926        };
1927        assert!(rec.is_mocked(&targets(&["pkg.db.connect"])));
1928        assert!(rec.is_mocked(&targets(&["pkg.db"])));
1929        assert!(!rec.is_mocked(&targets(&["pkg.other.connect"])));
1930        let empty = from_import(Some("pkg.mod"), &[]);
1931        assert!(!empty.is_mocked(&targets(&["pkg.mod.thing"])));
1932    }
1933
1934    #[test]
1935    fn patches_first_party_matches_head_segment() {
1936        assert!(patches_first_party("myproject.ledger.record", "myproject"));
1937        assert!(patches_first_party("myproject", "myproject"));
1938        assert!(!patches_first_party("requests.get", "myproject"));
1939        assert!(!patches_first_party("myproject_extra.x", "myproject"));
1940        assert!(!patches_first_party("", "myproject"));
1941        assert!(!patches_first_party(".leading", "myproject"));
1942    }
1943
1944    #[test]
1945    fn first_party_package_reads_pyproject_name() {
1946        let tree = TempDir::new();
1947        tree.write(
1948            "pyproject.toml",
1949            "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1950        );
1951        assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1952    }
1953
1954    #[test]
1955    fn first_party_package_is_none_without_a_project_name() {
1956        let tree = TempDir::new();
1957        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1958        tree.write(".git", "");
1959        assert_eq!(first_party_package(&tree.0), None);
1960    }
1961
1962    #[test]
1963    fn first_party_package_is_none_when_absent() {
1964        let tree = TempDir::new();
1965        assert_eq!(first_party_package(&tree.0), None);
1966    }
1967
1968    /// The displays of the imports `source` leaves un-mocked.
1969    fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1970        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1971        let mut visitor = UnitIsolationVisitor {
1972            source,
1973            first_party,
1974            base,
1975            type_checking_depth: 0,
1976            imports: Vec::new(),
1977            patch_targets: Vec::new(),
1978        };
1979        for stmt in suite {
1980            visitor.visit_stmt(stmt);
1981        }
1982        visitor
1983            .imports
1984            .iter()
1985            .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1986            .map(|i| i.display.clone())
1987            .collect()
1988    }
1989
1990    #[test]
1991    fn import_head_and_last_segment() {
1992        assert_eq!(import_head("myproject.db.conn"), "myproject");
1993        assert_eq!(import_head("requests"), "requests");
1994        assert_eq!(last_segment("myproject.db.conn"), "conn");
1995        assert_eq!(last_segment("widget"), "widget");
1996    }
1997
1998    #[test]
1999    fn unit_under_test_base_strips_test_suffix() {
2000        assert_eq!(
2001            unit_under_test_base(Path::new("pkg/widget_test.py")),
2002            "widget"
2003        );
2004        // Only `*_test.py` reaches here, so a legacy `test_*.py` keeps its prefix.
2005        assert_eq!(
2006            unit_under_test_base(Path::new("test_widget.py")),
2007            "test_widget"
2008        );
2009        assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
2010    }
2011
2012    #[test]
2013    fn recognizes_python_unit_test_files() {
2014        assert!(is_python_unit_test_file(Path::new("widget_test.py")));
2015        assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
2016        assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
2017        assert!(!is_python_unit_test_file(Path::new("conftest.py")));
2018        assert!(!is_python_unit_test_file(Path::new("widget.py")));
2019    }
2020
2021    #[test]
2022    fn visitor_flags_first_party_and_external_collaborators() {
2023        // The UUT is left alone; the first-party and third-party imports are flagged.
2024        let found = unmocked(
2025            "widget",
2026            "myproject",
2027            "from myproject.widget import build\n\
2028             from myproject.ledger import record\n\
2029             import requests\n",
2030        );
2031        assert_eq!(
2032            found,
2033            vec!["myproject.ledger".to_string(), "requests".to_string()]
2034        );
2035    }
2036
2037    #[test]
2038    fn visitor_clears_a_mocked_collaborator() {
2039        let found = unmocked(
2040            "widget",
2041            "myproject",
2042            "from myproject.ledger import record\npatch(\"myproject.ledger.record\")\n",
2043        );
2044        assert!(found.is_empty(), "got: {found:?}");
2045    }
2046
2047    #[test]
2048    fn visitor_flags_a_wrong_module_patch() {
2049        // A patch sharing only the last segment names a different module, so `record`
2050        // stays an un-mocked collaborator.
2051        let found = unmocked(
2052            "widget",
2053            "myproject",
2054            "from myproject.ledger import record\npatch(\"otherpkg.unrelated.record\")\n",
2055        );
2056        assert_eq!(found, vec!["myproject.ledger".to_string()]);
2057    }
2058
2059    #[test]
2060    fn visitor_flags_a_partly_mocked_multi_symbol_import() {
2061        // Patching only `record` leaves the sibling `erase` a real collaborator.
2062        let found = unmocked(
2063            "widget",
2064            "myproject",
2065            "from myproject.ledger import record, erase\npatch(\"myproject.ledger.record\")\n",
2066        );
2067        assert_eq!(found, vec!["myproject.ledger".to_string()]);
2068        let both = unmocked(
2069            "widget",
2070            "myproject",
2071            "from myproject.ledger import record, erase\n\
2072             patch(\"myproject.ledger.record\")\npatch(\"myproject.ledger.erase\")\n",
2073        );
2074        assert!(both.is_empty(), "got: {both:?}");
2075    }
2076
2077    #[test]
2078    fn visitor_handles_module_and_relative_imports() {
2079        assert_eq!(
2080            unmocked("widget", "myproject", "import myproject.db\n"),
2081            vec!["myproject.db".to_string()]
2082        );
2083        assert!(unmocked(
2084            "widget",
2085            "myproject",
2086            "import myproject.db\npatch(\"myproject.db.connect\")\n"
2087        )
2088        .is_empty());
2089        assert_eq!(
2090            unmocked("widget", "myproject", "from .ledger import record\n"),
2091            vec![".ledger".to_string()]
2092        );
2093        assert_eq!(
2094            unmocked(
2095                "widget",
2096                "myproject",
2097                "from . import ledger\nfrom . import widget\n"
2098            ),
2099            vec![".ledger".to_string()]
2100        );
2101    }
2102
2103    #[test]
2104    fn visitor_treats_barrel_reexport_import_as_the_unit_under_test() {
2105        // A bare `from . import …` names the package's own re-export surface, the SUT.
2106        assert!(unmocked(
2107            "__init__",
2108            "myproject",
2109            "from . import Thing, __all__, __version__\n"
2110        )
2111        .is_empty());
2112        // Reaching around the barrel into a sibling module is still a collaborator.
2113        assert_eq!(
2114            unmocked("__init__", "myproject", "from .core import Thing\n"),
2115            vec![".core".to_string()]
2116        );
2117        // `from .. import x` resolves to the parent package, not the SUT file.
2118        assert_eq!(
2119            unmocked("__init__", "myproject", "from .. import sibling\n"),
2120            vec!["..sibling".to_string()]
2121        );
2122        // The barrel shortcut is scoped to the `__init__` base.
2123        assert_eq!(
2124            unmocked("widget", "myproject", "from . import ledger\n"),
2125            vec![".ledger".to_string()]
2126        );
2127    }
2128
2129    #[test]
2130    fn visitor_skips_type_checking_imports() {
2131        // A TYPE_CHECKING import is type-only; the runtime `else` import is still seen.
2132        let found = unmocked(
2133            "widget",
2134            "myproject",
2135            "if TYPE_CHECKING:\n    from myproject.models import Widget\nelse:\n    from myproject.ledger import record\n",
2136        );
2137        assert_eq!(found, vec!["myproject.ledger".to_string()]);
2138    }
2139
2140    #[test]
2141    fn is_checked_import_classifies_origins() {
2142        assert!(is_checked_import("myproject", "myproject")); // first-party
2143        assert!(!is_checked_import("pytest", "myproject")); // test framework
2144        assert!(!is_checked_import("_pytest", "myproject"));
2145        assert!(is_checked_import("subprocess", "myproject")); // effectful stdlib
2146        assert!(is_checked_import("socket", "myproject"));
2147        assert!(!is_checked_import("json", "myproject")); // pure stdlib
2148        assert!(!is_checked_import("dataclasses", "myproject"));
2149        assert!(is_checked_import("requests", "myproject")); // third-party
2150        assert!(is_checked_import("stripe", "myproject"));
2151        // A dual-nature head stays pure — the patch convention catches it, not the import.
2152        assert!(!is_checked_import("os", "myproject"));
2153        assert!(!is_checked_import("pathlib", "myproject"));
2154        assert!(!is_checked_import("datetime", "myproject"));
2155    }
2156
2157    #[test]
2158    fn is_checked_import_classifies_private_stdlib_as_stdlib() {
2159        assert!(!is_checked_import("__future__", "myproject"));
2160        assert!(!is_checked_import("_thread", "myproject"));
2161        assert!(!is_checked_import("_socket", "myproject"));
2162        assert!(!is_checked_import("_ast", "myproject"));
2163        assert!(!is_checked_import("_collections_abc", "myproject"));
2164        assert!(is_checked_import("_stripe", "myproject")); // third-party
2165    }
2166
2167    #[test]
2168    fn visitor_flags_external_collaborators() {
2169        let found = unmocked(
2170            "widget",
2171            "myproject",
2172            "import requests\nimport subprocess\nimport json\nimport pytest\n",
2173        );
2174        assert_eq!(found.len(), 2, "got: {found:?}");
2175        assert!(found.contains(&"requests".to_string()));
2176        assert!(found.contains(&"subprocess".to_string()));
2177    }
2178
2179    #[test]
2180    fn visitor_type_checking_variants_and_plain_if() {
2181        // The attribute form guards type-only imports too.
2182        assert!(unmocked(
2183            "widget",
2184            "myproject",
2185            "if typing.TYPE_CHECKING:\n    from myproject.models import W\n    import myproject.db\n"
2186        )
2187        .is_empty());
2188        // A plain `if` is walked normally; its import is still a collaborator.
2189        assert_eq!(
2190            unmocked(
2191                "widget",
2192                "myproject",
2193                "if ready == 1:\n    from myproject.ledger import record\n"
2194            ),
2195            vec!["myproject.ledger".to_string()]
2196        );
2197    }
2198
2199    #[test]
2200    fn find_unit_isolation_without_pyproject_reports_nothing() {
2201        let tree = TempDir::new();
2202        tree.write("widget_test.py", "from myproject.ledger import record\n");
2203        tree.write(".git", "");
2204        assert!(find_unit_isolation_violations(&tree.0)
2205            .expect("a readable tree should succeed")
2206            .is_empty());
2207    }
2208
2209    #[test]
2210    fn find_unit_isolation_walks_subdirs_and_flags() {
2211        let tree = TempDir::new();
2212        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
2213        tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
2214        let found =
2215            find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
2216        assert_eq!(found.len(), 1, "got: {found:?}");
2217        assert_eq!(found[0].rule, "unmocked-collaborator");
2218        assert!(found[0].message.contains("myproject.ledger"));
2219    }
2220
2221    #[test]
2222    fn recognizes_python_test_files() {
2223        assert!(is_python_test_file(Path::new("widget_test.py")));
2224        assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
2225        assert!(is_python_test_file(Path::new("conftest.py")));
2226        assert!(!is_python_test_file(Path::new("test_widget.py")));
2227    }
2228
2229    #[test]
2230    fn ignores_non_test_files() {
2231        assert!(!is_python_test_file(Path::new("widget.py")));
2232        assert!(!is_python_test_file(Path::new("conftest.pyi")));
2233        assert!(!is_python_test_file(Path::new("README.md")));
2234        assert!(!is_python_test_file(Path::new("testing.py")));
2235    }
2236
2237    #[test]
2238    fn line_of_counts_newlines() {
2239        let src = "a\nb\nc\n";
2240        assert_eq!(line_of(src, TextSize::from(0)), 1);
2241        assert_eq!(line_of(src, TextSize::from(2)), 2);
2242        assert_eq!(line_of(src, TextSize::from(4)), 3);
2243    }
2244
2245    #[test]
2246    fn recognizes_environ_mutators() {
2247        assert!(is_environ_mutator("update"));
2248        assert!(is_environ_mutator("pop"));
2249        assert!(is_environ_mutator("clear"));
2250        assert!(!is_environ_mutator("get"));
2251        assert!(!is_environ_mutator("keys"));
2252    }
2253
2254    /// The rules the suite lint reports for `source`, in report order.
2255    fn lint_rules(source: &str) -> Vec<&'static str> {
2256        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
2257        let mut visitor = LintVisitor {
2258            file: Path::new("t.py"),
2259            source,
2260            fixture_depth: 0,
2261            first_party: Some("myproject"),
2262            source_root: None,
2263            imports: HashMap::new(),
2264            declared_modules: HashSet::new(),
2265            violations: Vec::new(),
2266        };
2267        for stmt in suite {
2268            visitor.visit_stmt(stmt);
2269        }
2270        visitor.violations.iter().map(|v| v.rule).collect()
2271    }
2272
2273    #[test]
2274    fn an_async_fixture_shelters_a_patch_that_an_async_test_does_not() {
2275        assert!(
2276            lint_rules("@pytest.fixture\nasync def client():\n    patch(\"pkg.mod.attr\")\n")
2277                .is_empty()
2278        );
2279        assert_eq!(
2280            lint_rules("async def widget_test():\n    patch(\"pkg.mod.attr\")\n"),
2281            vec!["no-inline-patch"]
2282        );
2283        assert_eq!(
2284            lint_rules("async def widget_test(monkeypatch):\n    pass\n"),
2285            vec!["no-monkeypatch"]
2286        );
2287    }
2288
2289    #[test]
2290    fn an_augmented_assignment_to_environ_is_a_mutation() {
2291        assert_eq!(
2292            lint_rules("def widget_test():\n    os.environ[\"PATH\"] += \":/x\"\n"),
2293            vec!["no-environ-mutation"]
2294        );
2295        assert!(lint_rules("def widget_test():\n    total += 1\n").is_empty());
2296    }
2297
2298    #[test]
2299    fn a_fixture_decorator_is_a_bare_name_or_an_attribute() {
2300        assert!(
2301            lint_rules("@fixture\ndef client():\n    patch(\"pkg.mod.attr\")\n").is_empty(),
2302            "a bare `@fixture` shelters the patch"
2303        );
2304        assert_eq!(
2305            lint_rules("@registry[\"fixture\"]\ndef client():\n    patch(\"pkg.mod.attr\")\n"),
2306            vec!["no-inline-patch"],
2307            "a subscripted decorator is not a fixture"
2308        );
2309    }
2310
2311    #[test]
2312    fn patch_object_is_recognized_only_through_a_patch_receiver() {
2313        assert_eq!(
2314            lint_rules("def widget_test():\n    mock.patch.object(svc, \"send\")\n"),
2315            vec!["no-inline-patch"]
2316        );
2317        assert!(
2318            lint_rules("def widget_test():\n    helpers[0].object(svc, \"send\")\n").is_empty(),
2319            "a subscripted receiver is not `patch`"
2320        );
2321        assert!(
2322            lint_rules("def widget_test():\n    helpers[0](\"pkg.mod.attr\")\n").is_empty(),
2323            "a subscripted callee is not a patch call"
2324        );
2325    }
2326
2327    #[test]
2328    fn find_suite_without_a_tests_directory_reports_nothing() {
2329        let tree = TempDir::new();
2330        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
2331        assert!(find_suite_violations(&tree.0)
2332            .expect("a readable tree should succeed")
2333            .is_empty());
2334    }
2335
2336    #[test]
2337    fn find_unit_isolation_without_a_project_name_reports_nothing() {
2338        let tree = TempDir::new();
2339        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
2340        tree.write("widget_test.py", "from myproject.ledger import record\n");
2341        assert!(find_unit_isolation_violations(&tree.0)
2342            .expect("a readable tree should succeed")
2343            .is_empty());
2344    }
2345
2346    #[test]
2347    fn recognizes_upper_constants() {
2348        assert!(is_upper_constant("CACHE_DIR"));
2349        assert!(is_upper_constant("DEBUG"));
2350        assert!(is_upper_constant("MAX_2"));
2351        assert!(!is_upper_constant("cache_dir"));
2352        assert!(!is_upper_constant("CacheDir"));
2353        assert!(!is_upper_constant("fetch"));
2354        assert!(!is_upper_constant(""));
2355        assert!(!is_upper_constant("_"));
2356        assert!(!is_upper_constant("123"));
2357    }
2358
2359    #[test]
2360    fn an_unreadable_test_file_names_the_file() {
2361        let tree = TempDir::new();
2362        std::fs::write(tree.0.join("widget_test.py"), [0xFF, 0xFE]).unwrap();
2363        let err = find_violations(&tree.0).unwrap_err();
2364        assert!(
2365            format!("{err:#}").contains("reading test file"),
2366            "got: {err:#}"
2367        );
2368    }
2369
2370    #[test]
2371    fn an_unparsable_test_file_names_the_file() {
2372        let tree = TempDir::new();
2373        tree.write("widget_test.py", "def broken(:\n");
2374        let err = find_violations(&tree.0).unwrap_err();
2375        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
2376    }
2377
2378    #[test]
2379    fn a_missing_root_is_an_error() {
2380        let err = find_violations(Path::new("/nonexistent-tc-lint")).unwrap_err();
2381        assert!(
2382            format!("{err:#}").contains("reading directory"),
2383            "got: {err:#}"
2384        );
2385    }
2386
2387    fn unit_isolation_tree() -> TempDir {
2388        let tree = TempDir::new();
2389        tree.write(
2390            "pyproject.toml",
2391            "[project]\nname = \"myproject\"\nversion = \"0.0.0\"\n",
2392        );
2393        tree
2394    }
2395
2396    #[test]
2397    fn an_unreadable_unit_test_file_names_the_file() {
2398        let tree = unit_isolation_tree();
2399        std::fs::write(tree.0.join("widget_test.py"), [0xFF, 0xFE]).unwrap();
2400        let err = find_unit_isolation_violations(&tree.0).unwrap_err();
2401        assert!(
2402            format!("{err:#}").contains("reading test file"),
2403            "got: {err:#}"
2404        );
2405    }
2406
2407    #[test]
2408    fn an_unparsable_unit_test_file_names_the_file() {
2409        let tree = unit_isolation_tree();
2410        tree.write("widget_test.py", "def broken(:\n");
2411        let err = find_unit_isolation_violations(&tree.0).unwrap_err();
2412        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
2413    }
2414
2415    #[test]
2416    fn the_unit_under_test_import_is_never_a_collaborator() {
2417        let tree = unit_isolation_tree();
2418        tree.write("widget_test.py", "from myproject.widget import build\n");
2419        let violations = find_unit_isolation_violations(&tree.0).unwrap();
2420        assert!(violations.is_empty(), "got {violations:?}");
2421    }
2422
2423    #[test]
2424    fn a_starred_monkeypatch_parameter_is_flagged() {
2425        let tree = TempDir::new();
2426        tree.write(
2427            "widget_test.py",
2428            "def test_widget(*monkeypatch):\n    pass\n",
2429        );
2430        let violations = find_violations(&tree.0).unwrap();
2431        assert_eq!(violations.len(), 1, "got {violations:?}");
2432        assert_eq!(violations[0].rule, "no-monkeypatch");
2433    }
2434
2435    #[test]
2436    fn a_double_starred_monkeypatch_parameter_is_flagged() {
2437        let tree = TempDir::new();
2438        tree.write(
2439            "widget_test.py",
2440            "def test_widget(**monkeypatch):\n    pass\n",
2441        );
2442        let violations = find_violations(&tree.0).unwrap();
2443        assert_eq!(violations.len(), 1, "got {violations:?}");
2444        assert_eq!(violations[0].rule, "no-monkeypatch");
2445    }
2446
2447    /// Parse `src` (a single expression statement) and return the expression itself.
2448    fn parse_expr(src: &str) -> Expr {
2449        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
2450        let stmt = suite.into_iter().next().expect("one statement");
2451        *stmt.expect_expr_stmt().value
2452    }
2453
2454    #[test]
2455    fn a_called_fixture_decorator_is_recognized() {
2456        assert!(is_fixture_decorator(&parse_expr("pytest.fixture()\n")));
2457        assert!(is_fixture_decorator(&parse_expr("fixture()\n")));
2458        assert!(!is_fixture_decorator(&parse_expr("staticmethod()\n")));
2459    }
2460
2461    #[test]
2462    fn object_target_walks_declared_submodules() {
2463        let imports = HashMap::from([("myproject".to_string(), "myproject".to_string())]);
2464        let declared = HashSet::from(["myproject.sub".to_string()]);
2465        let ctx = naive_ctx(&imports, &declared);
2466        let call = parse_call("patch.object(myproject.sub.helper, \"run\")\n");
2467        assert_eq!(
2468            patch_target(&call, &ctx).as_deref(),
2469            Some("myproject.sub.helper.run")
2470        );
2471    }
2472
2473    #[test]
2474    fn a_stdlib_from_import_is_not_a_collaborator() {
2475        let found = unmocked("widget", "myproject", "from os import path\n");
2476        assert!(found.is_empty(), "got {found:?}");
2477    }
2478}